Adds the expiry job (docs/prd-point-coin.md F12, PC-503).
Every 15 minutes it lists the lots whose expiry has passed and that still
hold something, the longest overdue first, 500 at a time, and expires each
in its own transaction through WalletProcessor.ExpireLot: lock the wallet,
read the lot again, and take what is left with an EXPIRE row pointing at
the lot, keyed expire:{lot_id}. The description is frozen as
"Kedaluwarsa: 130 EnakPoint dari Belanja #ORD-0098", using the amount read
under the lock. Lots expire at the end of their day, so none stays past it
for more than about a quarter of an hour.
It is safe on several instances and across restarts, keeping no state in
memory as OmsetMilestoneScheduler does. Selecting the lots FOR UPDATE SKIP
LOCKED, as PC-503 suggested, would lock a lot before its wallet and
deadlock against payments, which lock the wallet first; instead the
listing takes no lock, and the wallet lock plus the idempotency key make a
second instance find the lot empty or the key used and take nothing.
A lot that fails is logged and retried on the next run without stopping
the others. Each customer gets one FCM push per currency with the total
that expired ("180 EnakPoint kamu sudah kedaluwarsa.", type
WALLET_EXPIRED).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
153 lines
5.0 KiB
Go
153 lines
5.0 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
|
|
}
|
|
|
|
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.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)
|
|
}
|