feat(loyalty): remind customers before balances expire

Adds what the customer sees of expiry (docs/prd-point-coin.md F6, F12,
PC-504).

GET /customer/wallet/expiring lists everything that will expire, per
currency and day, soonest first. GET /customer/wallet already had the
nearest expiry per currency.

The expiry job now also sends reminders, with the settings of note N4 as
decided: once, reminder_days before (7 by default, 0 for none), per
currency. A customer gets one FCM push per currency and expiry day,
however many lots make it up: "150 EnakPoint akan kedaluwarsa pada 31 Okt
2026. Pakai sebelum hangus.", with type WALLET_EXPIRING, the currency,
amount and expiry_date in its data. Reminders cover whatever falls within
the window, so a run that was missed catches up rather than skipping a day.

Migration 000097 adds wallet_expiry_reminders, one row per customer,
currency and expiry day. The row is written before the push is sent, so
several instances of the job or a restart never remind twice; a push that
then fails is logged and not retried. Lots that expire later on the same
day as an earlier reminder are not reminded of again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-09-30 14:35:12 +07:00
co-authored by Claude Opus 5.5
parent d293786cde
commit 550122f29c
17 changed files with 374 additions and 11 deletions
@@ -20,6 +20,14 @@ type DueLot struct {
SourceDescription string
}
// UpcomingExpiry is how much of a customer's balance expires on one day.
type UpcomingExpiry struct {
CustomerID uuid.UUID
// A calendar date in walletDisplayTimeZone, formatted YYYY-MM-DD.
Date string
Amount int64
}
// WalletExpiryRepository finds what the expiry job has to do (docs/prd-point-coin.md
// F12). Balances only change through WalletProcessor.
type WalletExpiryRepository interface {
@@ -27,6 +35,17 @@ type WalletExpiryRepository interface {
// takes no lock: locking a lot before its wallet would deadlock against payments,
// which lock the wallet first. WalletProcessor.ExpireLot locks and reads again.
ListDueLots(ctx context.Context, asOf time.Time, limit int) ([]DueLot, error)
// OrganizationsWithUpcomingExpiry lists the organizations that have balance
// expiring after asOf.
OrganizationsWithUpcomingExpiry(ctx context.Context, asOf time.Time) ([]uuid.UUID, error)
// UpcomingUnreminded sums, per customer and expiry day, the balance of one currency
// of an organization expiring after asOf and up to until, leaving out the days the
// customer has already been reminded of.
UpcomingUnreminded(ctx context.Context, organizationID uuid.UUID, currency string, asOf, until time.Time) ([]UpcomingExpiry, error)
// MarkReminded records a reminder, and reports false when it was already recorded,
// by this run or another.
MarkReminded(ctx context.Context, reminder UpcomingExpiry, currency string) (bool, error)
}
type walletExpiryRepository struct {
@@ -52,3 +71,48 @@ func (r *walletExpiryRepository) ListDueLots(ctx context.Context, asOf time.Time
}
return lots, nil
}
func (r *walletExpiryRepository) OrganizationsWithUpcomingExpiry(ctx context.Context, asOf time.Time) ([]uuid.UUID, error) {
var ids []uuid.UUID
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT DISTINCT organization_id FROM wallet_lots
WHERE remaining_amount > 0 AND expires_at > ?`, asOf).Scan(&ids).Error
if err != nil {
return nil, fmt.Errorf("failed to list organizations with expiring balances: %w", err)
}
return ids, nil
}
func (r *walletExpiryRepository) UpcomingUnreminded(ctx context.Context, organizationID uuid.UUID, currency string, asOf, until time.Time) ([]UpcomingExpiry, error) {
var rows []UpcomingExpiry
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
WITH by_day AS (
SELECT customer_id, (expires_at AT TIME ZONE ?)::date AS day, SUM(remaining_amount) AS amount
FROM wallet_lots
WHERE organization_id = ? AND currency = ? AND remaining_amount > 0
AND expires_at > ? AND expires_at <= ?
GROUP BY customer_id, day
)
SELECT d.customer_id, to_char(d.day, 'YYYY-MM-DD') AS date, d.amount
FROM by_day d
LEFT JOIN wallet_expiry_reminders w
ON w.customer_id = d.customer_id AND w.currency = ? AND w.expiry_date = d.day
WHERE w.customer_id IS NULL
ORDER BY d.day, d.customer_id`,
walletDisplayTimeZone, organizationID, currency, asOf, until, currency).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to list upcoming expiry: %w", err)
}
return rows, nil
}
func (r *walletExpiryRepository) MarkReminded(ctx context.Context, reminder UpcomingExpiry, currency string) (bool, error) {
res := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
INSERT INTO wallet_expiry_reminders (customer_id, currency, expiry_date, amount)
VALUES (?, ?, ?::date, ?)
ON CONFLICT DO NOTHING`, reminder.CustomerID, currency, reminder.Date, reminder.Amount)
if res.Error != nil {
return false, fmt.Errorf("failed to record expiry reminder: %w", res.Error)
}
return res.RowsAffected == 1, nil
}
@@ -47,6 +47,9 @@ type WalletQueryRepository interface {
// NearestExpiring returns, per currency, the earliest day after asOf on which some
// balance expires, and how much expires that day.
NearestExpiring(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error)
// ExpiringByDay returns, per currency and day, everything that expires after asOf,
// soonest first.
ExpiringByDay(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error)
// ListTransactions returns a page of the ledger, newest first, and the total count.
ListTransactions(ctx context.Context, filter WalletTransactionFilter) ([]entities.WalletTransaction, int64, error)
// OrganizationOutstanding sums every wallet balance of an organization.
@@ -181,3 +184,19 @@ func (r *walletQueryRepository) OrganizationOutstanding(ctx context.Context, org
}
return totals.Points, totals.Coins, nil
}
func (r *walletQueryRepository) ExpiringByDay(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error) {
var rows []WalletExpiringAmount
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT currency, to_char((expires_at AT TIME ZONE ?)::date, 'YYYY-MM-DD') AS date,
SUM(remaining_amount) AS amount
FROM wallet_lots
WHERE customer_id = ? AND remaining_amount > 0 AND expires_at > ?
GROUP BY currency, date
ORDER BY date, currency`, walletDisplayTimeZone, customerID, asOf).
Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to list expiring wallet balance: %w", err)
}
return rows, nil
}