package processor import ( "context" "sort" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/repository" ) // walletExpiryRepoFake lists the fake wallet's due lots the way the query does, plus // any extra lots a test wants listed. 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) { descriptions := map[uuid.UUID]string{} for _, tx := range f.wallet.transactions { descriptions[tx.ID] = tx.Description } due := append([]repository.DueLot(nil), f.extra...) for _, lot := range f.wallet.lots { if lot.RemainingAmount > 0 && lot.ExpiresAt != nil && !lot.ExpiresAt.After(asOf) { due = append(due, repository.DueLot{ ID: lot.ID, CustomerID: lot.CustomerID, Currency: lot.Currency, Remaining: lot.RemainingAmount, ExpiresAt: *lot.ExpiresAt, SourceDescription: descriptions[lot.SourceTransactionID], }) } } sort.SliceStable(due, func(i, j int) bool { return due[i].ExpiresAt.Before(due[j].ExpiresAt) }) if len(due) > limit { due = due[:limit] } return due, nil } func (e *walletMoveEnv) expiry(notifier customerNotifier) (*WalletExpiryProcessor, *walletExpiryRepoFake) { repo := &walletExpiryRepoFake{wallet: e.repo} p := NewWalletExpiryProcessor(repo, e, e.p, txRunnerFake{}, notifier) p.now = func() time.Time { return e.now } return p, repo } func TestWalletExpiry_ExpiresWhatIsDueAndTellsTheCustomer(t *testing.T) { e := newWalletMoveEnv(t) a := e.member("Anita", "6281200005678") ord := earn(a, 150, e.at(-time.Hour)) ord.Description = "Belanja #ORD-0098" due := e.credit(t, ord) e.credit(t, earn(a, 50, e.at(-2*time.Hour))) e.credit(t, earn(a, 70, e.at(time.Hour))) // not yet e.credit(t, earn(a, 30, nil)) // never e.earnCoins(t, a, 4, e.at(-time.Minute)) // A payment already used part of the first lot; only the rest expires. _, err := e.p.Debit(e.ctx, WalletDebitInput{WalletEntry: redeem(a, 20).WalletEntry, PreferredLotIDs: []uuid.UUID{due.Lots[0].ID}}) require.NoError(t, err) notifier := ¬ifierFake{} p, _ := e.expiry(notifier) count, err := p.ExpireDue(e.ctx) require.NoError(t, err) assert.Equal(t, 3, count) assert.Equal(t, int64(100), e.balance(t, a), "70 not due yet + 30 that never expires") assert.Equal(t, int64(0), e.coinBalance(t, a)) var expire *entities.WalletTransaction for _, tx := range e.repo.transactions { if tx.Type == constants.WalletTxTypeExpire && tx.ReferenceID == due.Lots[0].ID { expire = tx } } require.NotNil(t, expire) assert.Equal(t, int64(-130), expire.Amount) assert.Equal(t, "Kedaluwarsa: 130 EnakPoint dari Belanja #ORD-0098", expire.Description) assert.Equal(t, constants.WalletRefTypeLot, expire.ReferenceType) assert.Equal(t, "expire:"+due.Lots[0].ID.String(), *expire.IdempotencyKey) // One push per currency, with the total. pushes := notifier.pushes[a] require.Len(t, pushes, 2) byCurrency := map[string]pushFake{} for _, p := range pushes { byCurrency[p.data["currency"]] = p } assert.Equal(t, "EnakPoint kedaluwarsa", byCurrency["POINT"].title) assert.Equal(t, "180 EnakPoint kamu sudah kedaluwarsa.", byCurrency["POINT"].body) assert.Equal(t, NotificationTypeWalletExpired, byCurrency["POINT"].data["type"]) assert.Equal(t, "4", byCurrency["COIN"].data["amount"]) } func TestWalletExpiry_RunningAgainExpiresNothingMore(t *testing.T) { e := newWalletMoveEnv(t) a := e.member("Anita", "6281200005678") e.credit(t, earn(a, 150, e.at(-time.Hour))) notifier := ¬ifierFake{} // Two instances, one after the other. first, _ := e.expiry(notifier) second, _ := e.expiry(notifier) n1, err := first.ExpireDue(e.ctx) require.NoError(t, err) n2, err := second.ExpireDue(e.ctx) require.NoError(t, err) assert.Equal(t, 1, n1) assert.Equal(t, 0, n2) assert.Len(t, notifier.pushes[a], 1) var expires int for _, tx := range e.repo.transactions { if tx.Type == constants.WalletTxTypeExpire { expires++ } } assert.Equal(t, 1, expires) } func TestWalletExpiry_OneFailingLotDoesNotStopTheOthers(t *testing.T) { e := newWalletMoveEnv(t) a := e.member("Anita", "6281200005678") e.credit(t, earn(a, 150, e.at(-time.Hour))) p, repo := e.expiry(nil) // A lot listed that ExpireLot cannot find. repo.extra = []repository.DueLot{{ID: uuid.New(), CustomerID: a, Currency: "POINT", Remaining: 5, ExpiresAt: e.now.Add(-3 * time.Hour)}} count, err := p.ExpireDue(e.ctx) require.NoError(t, err) assert.Equal(t, 1, count) assert.Equal(t, int64(0), e.balance(t, a)) } func TestWalletExpiry_NothingDue(t *testing.T) { e := newWalletMoveEnv(t) a := e.member("Anita", "6281200005678") e.credit(t, earn(a, 150, e.at(time.Hour))) notifier := ¬ifierFake{} p, _ := e.expiry(notifier) count, err := p.ExpireDue(e.ctx) require.NoError(t, err) 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", "6281200005678") 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")) }