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:
co-authored by
Claude Opus 5.5
parent
d293786cde
commit
550122f29c
@@ -20,6 +20,8 @@ import (
|
||||
type walletExpiryRepoFake struct {
|
||||
wallet *walletRepoFake
|
||||
extra []repository.DueLot
|
||||
// customer/currency/date of the reminders recorded.
|
||||
reminded map[string]bool
|
||||
}
|
||||
|
||||
func (f *walletExpiryRepoFake) ListDueLots(_ context.Context, asOf time.Time, limit int) ([]repository.DueLot, error) {
|
||||
@@ -45,7 +47,7 @@ func (f *walletExpiryRepoFake) ListDueLots(_ context.Context, asOf time.Time, li
|
||||
|
||||
func (e *walletMoveEnv) expiry(notifier customerNotifier) (*WalletExpiryProcessor, *walletExpiryRepoFake) {
|
||||
repo := &walletExpiryRepoFake{wallet: e.repo}
|
||||
p := NewWalletExpiryProcessor(repo, e.p, txRunnerFake{}, notifier)
|
||||
p := NewWalletExpiryProcessor(repo, e, e.p, txRunnerFake{}, notifier)
|
||||
p.now = func() time.Time { return e.now }
|
||||
return p, repo
|
||||
}
|
||||
@@ -150,3 +152,93 @@ func TestWalletExpiry_NothingDue(t *testing.T) {
|
||||
assert.Zero(t, count)
|
||||
assert.Empty(t, notifier.pushes)
|
||||
}
|
||||
|
||||
func (f *walletExpiryRepoFake) OrganizationsWithUpcomingExpiry(_ context.Context, asOf time.Time) ([]uuid.UUID, error) {
|
||||
seen := map[uuid.UUID]bool{}
|
||||
var out []uuid.UUID
|
||||
for _, lot := range f.wallet.lots {
|
||||
if lot.RemainingAmount > 0 && lot.ExpiresAt != nil && lot.ExpiresAt.After(asOf) && !seen[lot.OrganizationID] {
|
||||
seen[lot.OrganizationID] = true
|
||||
out = append(out, lot.OrganizationID)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *walletExpiryRepoFake) UpcomingUnreminded(_ context.Context, organizationID uuid.UUID, currency string, asOf, until time.Time) ([]repository.UpcomingExpiry, error) {
|
||||
sums := map[[2]string]int64{}
|
||||
var order [][2]string
|
||||
for _, lot := range f.wallet.lots {
|
||||
if lot.OrganizationID != organizationID || lot.Currency != currency || lot.RemainingAmount == 0 ||
|
||||
lot.ExpiresAt == nil || !lot.ExpiresAt.After(asOf) || lot.ExpiresAt.After(until) {
|
||||
continue
|
||||
}
|
||||
key := [2]string{lot.CustomerID.String(), lot.ExpiresAt.In(walletDisplayLocation).Format("2006-01-02")}
|
||||
if f.reminded[key[0]+"/"+currency+"/"+key[1]] {
|
||||
continue
|
||||
}
|
||||
if _, ok := sums[key]; !ok {
|
||||
order = append(order, key)
|
||||
}
|
||||
sums[key] += lot.RemainingAmount
|
||||
}
|
||||
var out []repository.UpcomingExpiry
|
||||
for _, key := range order {
|
||||
out = append(out, repository.UpcomingExpiry{CustomerID: uuid.MustParse(key[0]), Date: key[1], Amount: sums[key]})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *walletExpiryRepoFake) MarkReminded(_ context.Context, u repository.UpcomingExpiry, currency string) (bool, error) {
|
||||
if f.reminded == nil {
|
||||
f.reminded = map[string]bool{}
|
||||
}
|
||||
key := u.CustomerID.String() + "/" + currency + "/" + u.Date
|
||||
if f.reminded[key] {
|
||||
return false, nil
|
||||
}
|
||||
f.reminded[key] = true
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func TestWalletExpiry_RemindsOncePerDayBeforeExpiry(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
e.now = wib(2026, 10, 25, 9, 0)
|
||||
e.settings.PointExpiry.ReminderDays = 7
|
||||
e.settings.CoinExpiry.ReminderDays = 0 // no reminders for EnakCoin
|
||||
a := e.member("Anita", "081200005678")
|
||||
oct31 := wib(2026, 10, 31, 23, 59)
|
||||
nov30 := wib(2026, 11, 30, 23, 59)
|
||||
e.credit(t, earn(a, 100, &oct31))
|
||||
e.credit(t, earn(a, 50, &oct31))
|
||||
e.credit(t, earn(a, 70, &nov30)) // too far off yet
|
||||
e.earnCoins(t, a, 5, &oct31)
|
||||
notifier := ¬ifierFake{}
|
||||
p, _ := e.expiry(notifier)
|
||||
|
||||
sent, err := p.SendReminders(e.ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, sent)
|
||||
require.Len(t, notifier.pushes[a], 1)
|
||||
push := notifier.pushes[a][0]
|
||||
assert.Equal(t, "EnakPoint akan kedaluwarsa", push.title)
|
||||
assert.Equal(t, "150 EnakPoint akan kedaluwarsa pada 31 Okt 2026. Pakai sebelum hangus.", push.body)
|
||||
assert.Equal(t, map[string]string{"type": NotificationTypeWalletExpiring, "currency": "POINT", "amount": "150", "expiry_date": "2026-10-31"}, push.data)
|
||||
|
||||
// The next run, on this instance or another, sends nothing again.
|
||||
again, err := p.SendReminders(e.ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, again)
|
||||
|
||||
// Once 30 Nov comes within seven days, it gets its own reminder.
|
||||
e.now = wib(2026, 11, 23, 9, 0)
|
||||
sent, err = p.SendReminders(e.ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, sent)
|
||||
assert.Equal(t, "70", notifier.pushes[a][1].data["amount"])
|
||||
}
|
||||
|
||||
func TestFormatWalletDate(t *testing.T) {
|
||||
assert.Equal(t, "31 Okt 2026", formatWalletDate("2026-10-31"))
|
||||
assert.Equal(t, "1 Mei 2027", formatWalletDate("2027-05-01"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user