245 lines
8.3 KiB
Go
245 lines
8.3 KiB
Go
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", "081200005678")
|
|
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: pay(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", "081200005678")
|
|
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", "081200005678")
|
|
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", "081200005678")
|
|
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", "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"))
|
|
}
|