WalletProcessor writes the balance, the ledger row and the lots or allocations together, which keeps SUM(ledger) = balance = SUM(lot remaining) (docs/prd-point-coin.md §7.5, PC-104). - Credit writes the ledger row and creates lots, each with its own expiry and origin lot. - Debit draws from the preferred lots first (a reversal's own lots, or the lot being expired), then from unexpired lots in K9 order, and returns the allocations with their expiry so CarryOver can give the receiving side of a transfer or exchange the same expiry. - DebitUpTo takes what the wallet has and reports the shortfall (F10, Q3). - An idempotency key returns the first result; reusing it for a different operation is an error. - §8.1 is checked in code from one rule table, ahead of the database constraints, so callers get a readable error. Each method locks the wallet itself, after validating the input and before checking the idempotency key, so correctness does not depend on the caller. Operations on two wallets still call LockWallets first to keep lock order. Unit tests run on an in-memory repository and check the §7.5 invariants after every scenario; one more test runs the engine against Postgres when TEST_DATABASE_URL is set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
789 lines
26 KiB
Go
789 lines
26 KiB
Go
package processor
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"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"
|
|
)
|
|
|
|
// walletRepoFake is an in-memory WalletRepository with the same conditional-update
|
|
// semantics as the real one. Locks are only counted: these tests are single-threaded,
|
|
// and the locking itself is covered by the repository tests against Postgres.
|
|
type walletRepoFake struct {
|
|
customers map[uuid.UUID]uuid.UUID // customer -> organization
|
|
wallets map[uuid.UUID]*entities.CustomerWallet
|
|
transactions []*entities.WalletTransaction
|
|
lots []*entities.WalletLot
|
|
allocations []entities.WalletLotAllocation
|
|
locks map[uuid.UUID]int
|
|
clock time.Time
|
|
}
|
|
|
|
func newWalletRepoFake() *walletRepoFake {
|
|
return &walletRepoFake{
|
|
customers: map[uuid.UUID]uuid.UUID{},
|
|
wallets: map[uuid.UUID]*entities.CustomerWallet{},
|
|
locks: map[uuid.UUID]int{},
|
|
clock: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
|
|
}
|
|
}
|
|
|
|
func (f *walletRepoFake) tick() time.Time {
|
|
f.clock = f.clock.Add(time.Second)
|
|
return f.clock
|
|
}
|
|
|
|
func (f *walletRepoFake) LockWallet(_ context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) {
|
|
org, ok := f.customers[customerID]
|
|
if !ok {
|
|
return nil, repository.ErrWalletNotFound
|
|
}
|
|
if f.wallets[customerID] == nil {
|
|
f.wallets[customerID] = &entities.CustomerWallet{CustomerID: customerID, OrganizationID: org}
|
|
}
|
|
f.locks[customerID]++
|
|
w := *f.wallets[customerID]
|
|
return &w, nil
|
|
}
|
|
|
|
func (f *walletRepoFake) LockWallets(ctx context.Context, a, b uuid.UUID) (*entities.CustomerWallet, *entities.CustomerWallet, error) {
|
|
wa, err := f.LockWallet(ctx, a)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
wb, err := f.LockWallet(ctx, b)
|
|
return wa, wb, err
|
|
}
|
|
|
|
func (f *walletRepoFake) AddBalance(_ context.Context, customerID uuid.UUID, currency string, delta int64) (int64, error) {
|
|
w := f.wallets[customerID]
|
|
if w == nil {
|
|
return 0, repository.ErrWalletNotFound
|
|
}
|
|
balance := &w.PointBalance
|
|
if currency == constants.WalletCurrencyCoin {
|
|
balance = &w.CoinBalance
|
|
}
|
|
if *balance+delta < 0 {
|
|
return 0, repository.ErrWalletInsufficientBalance
|
|
}
|
|
*balance += delta
|
|
return *balance, nil
|
|
}
|
|
|
|
func (f *walletRepoFake) GetWallet(_ context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) {
|
|
w := f.wallets[customerID]
|
|
if w == nil {
|
|
return nil, errors.New("not found")
|
|
}
|
|
c := *w
|
|
return &c, nil
|
|
}
|
|
|
|
func (f *walletRepoFake) CreateTransaction(_ context.Context, tx *entities.WalletTransaction) error {
|
|
if tx.IdempotencyKey != nil {
|
|
for _, t := range f.transactions {
|
|
if t.IdempotencyKey != nil && *t.IdempotencyKey == *tx.IdempotencyKey {
|
|
return errors.New("duplicate idempotency key")
|
|
}
|
|
}
|
|
}
|
|
if tx.ID == uuid.Nil {
|
|
tx.ID = uuid.New()
|
|
}
|
|
tx.CreatedAt = f.tick()
|
|
c := *tx
|
|
f.transactions = append(f.transactions, &c)
|
|
return nil
|
|
}
|
|
|
|
func (f *walletRepoFake) GetTransactionByIdempotencyKey(_ context.Context, key string) (*entities.WalletTransaction, error) {
|
|
for _, t := range f.transactions {
|
|
if t.IdempotencyKey != nil && *t.IdempotencyKey == key {
|
|
c := *t
|
|
return &c, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *walletRepoFake) CreateLot(_ context.Context, lot *entities.WalletLot) error {
|
|
if lot.ID == uuid.Nil {
|
|
lot.ID = uuid.New()
|
|
}
|
|
lot.CreatedAt = f.tick()
|
|
c := *lot
|
|
f.lots = append(f.lots, &c)
|
|
return nil
|
|
}
|
|
|
|
func (f *walletRepoFake) GetLotsByIDs(_ context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) {
|
|
var out []entities.WalletLot
|
|
for _, lot := range f.lots {
|
|
for _, id := range ids {
|
|
if lot.ID == id {
|
|
out = append(out, *lot)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *walletRepoFake) ListLotsBySourceTransaction(_ context.Context, txID uuid.UUID) ([]entities.WalletLot, error) {
|
|
var out []entities.WalletLot
|
|
for _, lot := range f.lots {
|
|
if lot.SourceTransactionID == txID {
|
|
out = append(out, *lot)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *walletRepoFake) ListActiveLots(_ context.Context, customerID uuid.UUID, currency string, asOf time.Time) ([]entities.WalletLot, error) {
|
|
var out []entities.WalletLot
|
|
for _, lot := range f.lots {
|
|
if lot.CustomerID == customerID && lot.Currency == currency && lot.RemainingAmount > 0 &&
|
|
(lot.ExpiresAt == nil || lot.ExpiresAt.After(asOf)) {
|
|
out = append(out, *lot)
|
|
}
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
a, b := out[i], out[j]
|
|
switch {
|
|
case a.ExpiresAt == nil && b.ExpiresAt != nil:
|
|
return false
|
|
case a.ExpiresAt != nil && b.ExpiresAt == nil:
|
|
return true
|
|
case a.ExpiresAt != nil && !a.ExpiresAt.Equal(*b.ExpiresAt):
|
|
return a.ExpiresAt.Before(*b.ExpiresAt)
|
|
}
|
|
return a.CreatedAt.Before(b.CreatedAt)
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
func (f *walletRepoFake) ConsumeLot(_ context.Context, lotID uuid.UUID, amount int64) error {
|
|
for _, lot := range f.lots {
|
|
if lot.ID == lotID {
|
|
if lot.RemainingAmount < amount {
|
|
return repository.ErrWalletLotInsufficient
|
|
}
|
|
lot.RemainingAmount -= amount
|
|
return nil
|
|
}
|
|
}
|
|
return repository.ErrWalletLotInsufficient
|
|
}
|
|
|
|
func (f *walletRepoFake) CreateAllocations(_ context.Context, allocations []entities.WalletLotAllocation) error {
|
|
f.allocations = append(f.allocations, allocations...)
|
|
return nil
|
|
}
|
|
|
|
func (f *walletRepoFake) ListAllocationsByTransaction(_ context.Context, txID uuid.UUID) ([]entities.WalletLotAllocation, error) {
|
|
var out []entities.WalletLotAllocation
|
|
for _, a := range f.allocations {
|
|
if a.TransactionID == txID {
|
|
out = append(out, a)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// assertInvariants checks the reconciliation rules of §7.5 over everything the fake
|
|
// holds.
|
|
func (f *walletRepoFake) assertInvariants(t *testing.T) {
|
|
t.Helper()
|
|
allocatedFromLot := map[uuid.UUID]int64{}
|
|
allocatedByTx := map[uuid.UUID]int64{}
|
|
for _, a := range f.allocations {
|
|
allocatedFromLot[a.LotID] += a.Amount
|
|
allocatedByTx[a.TransactionID] += a.Amount
|
|
}
|
|
createdByTx := map[uuid.UUID]int64{}
|
|
for _, lot := range f.lots {
|
|
createdByTx[lot.SourceTransactionID] += lot.OriginalAmount
|
|
assert.Equal(t, lot.OriginalAmount-allocatedFromLot[lot.ID], lot.RemainingAmount, "lot %s: original - allocations = remaining", lot.ID)
|
|
}
|
|
for _, tx := range f.transactions {
|
|
if tx.Amount > 0 {
|
|
assert.Equal(t, tx.Amount, createdByTx[tx.ID], "credit %s: lots add up to the amount", tx.Type)
|
|
assert.Zero(t, allocatedByTx[tx.ID], "credit %s has no allocations", tx.Type)
|
|
} else {
|
|
assert.Equal(t, -tx.Amount, allocatedByTx[tx.ID], "debit %s: allocations add up to the amount", tx.Type)
|
|
assert.Zero(t, createdByTx[tx.ID], "debit %s creates no lots", tx.Type)
|
|
}
|
|
}
|
|
for customerID, w := range f.wallets {
|
|
for currency, balance := range map[string]int64{
|
|
constants.WalletCurrencyPoint: w.PointBalance,
|
|
constants.WalletCurrencyCoin: w.CoinBalance,
|
|
} {
|
|
var ledger, lots, last int64
|
|
for _, tx := range f.transactions {
|
|
if tx.CustomerID == customerID && tx.Currency == currency {
|
|
ledger += tx.Amount
|
|
last = tx.BalanceAfter
|
|
}
|
|
}
|
|
for _, lot := range f.lots {
|
|
if lot.CustomerID == customerID && lot.Currency == currency {
|
|
lots += lot.RemainingAmount
|
|
}
|
|
}
|
|
assert.Equal(t, balance, ledger, "%s balance = SUM(ledger)", currency)
|
|
assert.Equal(t, balance, lots, "%s balance = SUM(lot remaining)", currency)
|
|
assert.Equal(t, balance, last, "%s balance = last balance_after", currency)
|
|
}
|
|
}
|
|
}
|
|
|
|
type walletTestEnv struct {
|
|
repo *walletRepoFake
|
|
p *WalletProcessor
|
|
now time.Time
|
|
org uuid.UUID
|
|
ctx context.Context
|
|
}
|
|
|
|
func newWalletTestEnv(t *testing.T) *walletTestEnv {
|
|
repo := newWalletRepoFake()
|
|
env := &walletTestEnv{
|
|
repo: repo,
|
|
p: NewWalletProcessor(repo),
|
|
now: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC),
|
|
org: uuid.New(),
|
|
ctx: context.Background(),
|
|
}
|
|
env.p.now = func() time.Time { return env.now }
|
|
t.Cleanup(func() { repo.assertInvariants(t) })
|
|
return env
|
|
}
|
|
|
|
func (e *walletTestEnv) customer() uuid.UUID {
|
|
id := uuid.New()
|
|
e.repo.customers[id] = e.org
|
|
return id
|
|
}
|
|
|
|
func (e *walletTestEnv) at(d time.Duration) *time.Time {
|
|
v := e.now.Add(d)
|
|
return &v
|
|
}
|
|
|
|
func ptr[T any](v T) *T { return &v }
|
|
|
|
func earn(customerID uuid.UUID, amount int64, expiresAt *time.Time) WalletCreditInput {
|
|
return WalletCreditInput{
|
|
WalletEntry: WalletEntry{
|
|
CustomerID: customerID,
|
|
Currency: constants.WalletCurrencyPoint,
|
|
Type: constants.WalletTxTypeEarn,
|
|
Amount: amount,
|
|
ReferenceType: constants.WalletRefTypeOrder,
|
|
ReferenceID: uuid.New(),
|
|
OutletID: ptr(uuid.New()),
|
|
Description: "Belanja",
|
|
},
|
|
Lots: []WalletLotInput{{Amount: amount, ExpiresAt: expiresAt}},
|
|
}
|
|
}
|
|
|
|
func pay(customerID uuid.UUID, amount int64) WalletDebitInput {
|
|
return WalletDebitInput{WalletEntry: WalletEntry{
|
|
CustomerID: customerID,
|
|
Currency: constants.WalletCurrencyPoint,
|
|
Type: constants.WalletTxTypePayment,
|
|
Amount: amount,
|
|
ReferenceType: constants.WalletRefTypePayment,
|
|
ReferenceID: uuid.New(),
|
|
OutletID: ptr(uuid.New()),
|
|
Description: "Bayar",
|
|
}}
|
|
}
|
|
|
|
func (e *walletTestEnv) credit(t *testing.T, in WalletCreditInput) *WalletResult {
|
|
t.Helper()
|
|
res, err := e.p.Credit(e.ctx, in)
|
|
require.NoError(t, err)
|
|
return res
|
|
}
|
|
|
|
func (e *walletTestEnv) balance(t *testing.T, customerID uuid.UUID) int64 {
|
|
t.Helper()
|
|
w, err := e.repo.GetWallet(e.ctx, customerID)
|
|
require.NoError(t, err)
|
|
return w.PointBalance
|
|
}
|
|
|
|
func allocationsOf(res *WalletResult) map[uuid.UUID]int64 {
|
|
out := map[uuid.UUID]int64{}
|
|
for _, a := range res.Allocations {
|
|
out[a.LotID] = a.Amount
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestWalletProcessor_CreditCreatesLedgerRowAndLot(t *testing.T) {
|
|
e := newWalletTestEnv(t)
|
|
c := e.customer()
|
|
|
|
res := e.credit(t, earn(c, 100, e.at(24*time.Hour)))
|
|
|
|
assert.Equal(t, int64(100), res.Transaction.Amount)
|
|
assert.Equal(t, int64(100), res.Transaction.BalanceAfter)
|
|
assert.Equal(t, e.org, res.Transaction.OrganizationID, "organization comes from the wallet")
|
|
require.Len(t, res.Lots, 1)
|
|
assert.Equal(t, res.Transaction.ID, res.Lots[0].SourceTransactionID)
|
|
assert.Equal(t, e.at(24*time.Hour), res.Lots[0].ExpiresAt)
|
|
assert.Equal(t, int64(100), e.balance(t, c))
|
|
assert.Equal(t, 1, e.repo.locks[c], "credit locks the wallet itself")
|
|
}
|
|
|
|
func TestWalletProcessor_CreditWithoutLotsMakesOneNonExpiringLot(t *testing.T) {
|
|
e := newWalletTestEnv(t)
|
|
c := e.customer()
|
|
in := earn(c, 40, nil)
|
|
in.Lots = nil
|
|
|
|
res := e.credit(t, in)
|
|
require.Len(t, res.Lots, 1)
|
|
assert.Equal(t, int64(40), res.Lots[0].OriginalAmount)
|
|
assert.Nil(t, res.Lots[0].ExpiresAt)
|
|
}
|
|
|
|
func TestWalletProcessor_CreditRejectsLotsThatDoNotAddUp(t *testing.T) {
|
|
e := newWalletTestEnv(t)
|
|
c := e.customer()
|
|
in := earn(c, 100, nil)
|
|
in.Lots = []WalletLotInput{{Amount: 60}, {Amount: 30}}
|
|
|
|
_, err := e.p.Credit(e.ctx, in)
|
|
assert.ErrorIs(t, err, ErrWalletInvalidEntry)
|
|
|
|
in.Lots = []WalletLotInput{{Amount: 100}, {Amount: 0}}
|
|
_, err = e.p.Credit(e.ctx, in)
|
|
assert.ErrorIs(t, err, ErrWalletInvalidEntry)
|
|
assert.Empty(t, e.repo.transactions)
|
|
}
|
|
|
|
func TestWalletProcessor_DebitAcrossSeveralLots(t *testing.T) {
|
|
e := newWalletTestEnv(t)
|
|
c := e.customer()
|
|
first := e.credit(t, earn(c, 30, e.at(1*time.Hour))).Lots[0]
|
|
second := e.credit(t, earn(c, 50, e.at(2*time.Hour))).Lots[0]
|
|
third := e.credit(t, earn(c, 40, e.at(3*time.Hour))).Lots[0]
|
|
|
|
res, err := e.p.Debit(e.ctx, pay(c, 70))
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, int64(-70), res.Transaction.Amount)
|
|
assert.Equal(t, int64(50), res.Transaction.BalanceAfter)
|
|
assert.Equal(t, map[uuid.UUID]int64{first.ID: 30, second.ID: 40}, allocationsOf(res))
|
|
assert.Equal(t, first.ID, res.Allocations[0].LotID, "allocations are reported in the order used")
|
|
assert.Equal(t, e.at(1*time.Hour), res.Allocations[0].ExpiresAt)
|
|
|
|
lots, _ := e.repo.GetLotsByIDs(e.ctx, []uuid.UUID{first.ID, second.ID, third.ID})
|
|
remaining := map[uuid.UUID]int64{}
|
|
for _, l := range lots {
|
|
remaining[l.ID] = l.RemainingAmount
|
|
}
|
|
assert.Equal(t, map[uuid.UUID]int64{first.ID: 0, second.ID: 10, third.ID: 40}, remaining)
|
|
}
|
|
|
|
// K9: soonest expiry first, lots without an expiry last and oldest first among them,
|
|
// expired lots never.
|
|
func TestWalletProcessor_DebitFollowsLotOrder(t *testing.T) {
|
|
e := newWalletTestEnv(t)
|
|
c := e.customer()
|
|
neverOld := e.credit(t, earn(c, 10, nil)).Lots[0]
|
|
late := e.credit(t, earn(c, 10, e.at(48*time.Hour))).Lots[0]
|
|
soon := e.credit(t, earn(c, 10, e.at(1*time.Hour))).Lots[0]
|
|
neverNew := e.credit(t, earn(c, 10, nil)).Lots[0]
|
|
e.credit(t, earn(c, 10, e.at(-1*time.Hour))) // already expired
|
|
|
|
var order []uuid.UUID
|
|
for i := 0; i < 4; i++ {
|
|
res, err := e.p.Debit(e.ctx, pay(c, 10))
|
|
require.NoError(t, err)
|
|
require.Len(t, res.Allocations, 1)
|
|
order = append(order, res.Allocations[0].LotID)
|
|
}
|
|
assert.Equal(t, []uuid.UUID{soon.ID, late.ID, neverOld.ID, neverNew.ID}, order)
|
|
|
|
// The expired lot still counts in the balance until the expiry job removes it,
|
|
// but it cannot be spent (§7.3).
|
|
assert.Equal(t, int64(10), e.balance(t, c))
|
|
_, err := e.p.Debit(e.ctx, pay(c, 10))
|
|
assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance)
|
|
}
|
|
|
|
func TestWalletProcessor_DebitOverBalanceChangesNothing(t *testing.T) {
|
|
e := newWalletTestEnv(t)
|
|
c := e.customer()
|
|
e.credit(t, earn(c, 50, nil))
|
|
|
|
_, err := e.p.Debit(e.ctx, pay(c, 51))
|
|
assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance)
|
|
assert.Equal(t, int64(50), e.balance(t, c))
|
|
assert.Len(t, e.repo.transactions, 1)
|
|
assert.Empty(t, e.repo.allocations)
|
|
|
|
// A customer who never had a wallet has nothing to spend.
|
|
_, err = e.p.Debit(e.ctx, pay(e.customer(), 1))
|
|
assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance)
|
|
}
|
|
|
|
func reversal(customerID uuid.UUID, amount int64, earnRes *WalletResult) WalletDebitInput {
|
|
in := WalletDebitInput{WalletEntry: WalletEntry{
|
|
CustomerID: customerID,
|
|
Currency: constants.WalletCurrencyPoint,
|
|
Type: constants.WalletTxTypeEarnReversal,
|
|
Amount: amount,
|
|
ReferenceType: constants.WalletRefTypeOrder,
|
|
ReferenceID: earnRes.Transaction.ReferenceID,
|
|
ReversesTransactionID: &earnRes.Transaction.ID,
|
|
OutletID: earnRes.Transaction.OutletID,
|
|
Description: "Batal",
|
|
}}
|
|
for _, lot := range earnRes.Lots {
|
|
in.PreferredLotIDs = append(in.PreferredLotIDs, lot.ID)
|
|
}
|
|
return in
|
|
}
|
|
|
|
func TestWalletProcessor_DebitUpToWithShortfall(t *testing.T) {
|
|
e := newWalletTestEnv(t)
|
|
c := e.customer()
|
|
earned := e.credit(t, earn(c, 100, nil))
|
|
_, err := e.p.Debit(e.ctx, pay(c, 70))
|
|
require.NoError(t, err)
|
|
|
|
res, err := e.p.DebitUpTo(e.ctx, reversal(c, 100, earned))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(-30), res.Transaction.Amount)
|
|
assert.Equal(t, int64(70), res.Shortfall)
|
|
assert.Equal(t, int64(100), res.Transaction.Metadata["requested_amount"])
|
|
assert.Equal(t, int64(70), res.Transaction.Metadata["shortfall"])
|
|
assert.Equal(t, int64(0), e.balance(t, c))
|
|
|
|
// Nothing left: no ledger row, the whole amount is shortfall.
|
|
in := reversal(c, 5, earned)
|
|
res, err = e.p.DebitUpTo(e.ctx, in)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, res.Transaction)
|
|
assert.Equal(t, int64(5), res.Shortfall)
|
|
assert.Len(t, e.repo.transactions, 3)
|
|
}
|
|
|
|
// A reversal draws from the lots its EARN created first (F10), even when an older lot
|
|
// would come first in K9 order, and even when that lot has expired.
|
|
func TestWalletProcessor_DebitDrawsPreferredLotsFirst(t *testing.T) {
|
|
e := newWalletTestEnv(t)
|
|
c := e.customer()
|
|
older := e.credit(t, earn(c, 50, e.at(1*time.Hour))).Lots[0]
|
|
earned := e.credit(t, earn(c, 20, e.at(-1*time.Hour)))
|
|
|
|
res, err := e.p.Debit(e.ctx, reversal(c, 30, earned))
|
|
require.NoError(t, err)
|
|
require.Len(t, res.Allocations, 2)
|
|
assert.Equal(t, earned.Lots[0].ID, res.Allocations[0].LotID)
|
|
assert.Equal(t, int64(20), res.Allocations[0].Amount)
|
|
assert.Equal(t, older.ID, res.Allocations[1].LotID)
|
|
assert.Equal(t, int64(10), res.Allocations[1].Amount)
|
|
}
|
|
|
|
func TestWalletProcessor_DebitRejectsSomeoneElsesLot(t *testing.T) {
|
|
e := newWalletTestEnv(t)
|
|
a, b := e.customer(), e.customer()
|
|
e.credit(t, earn(a, 10, nil))
|
|
other := e.credit(t, earn(b, 10, nil))
|
|
|
|
in := pay(a, 5)
|
|
in.PreferredLotIDs = []uuid.UUID{other.Lots[0].ID}
|
|
_, err := e.p.Debit(e.ctx, in)
|
|
assert.ErrorIs(t, err, ErrWalletInvalidEntry)
|
|
|
|
in.PreferredLotIDs = []uuid.UUID{uuid.New()}
|
|
_, err = e.p.Debit(e.ctx, in)
|
|
assert.ErrorIs(t, err, ErrWalletInvalidEntry)
|
|
}
|
|
|
|
func TestWalletProcessor_ExpireDrawsTheExpiredLot(t *testing.T) {
|
|
e := newWalletTestEnv(t)
|
|
c := e.customer()
|
|
e.credit(t, earn(c, 10, nil))
|
|
expired := e.credit(t, earn(c, 25, e.at(-1*time.Hour))).Lots[0]
|
|
|
|
res, err := e.p.Debit(e.ctx, WalletDebitInput{
|
|
WalletEntry: WalletEntry{
|
|
CustomerID: c,
|
|
Currency: constants.WalletCurrencyPoint,
|
|
Type: constants.WalletTxTypeExpire,
|
|
Amount: expired.RemainingAmount,
|
|
ReferenceType: constants.WalletRefTypeLot,
|
|
ReferenceID: expired.ID,
|
|
Description: "Kedaluwarsa",
|
|
IdempotencyKey: "expire:" + expired.ID.String(),
|
|
},
|
|
PreferredLotIDs: []uuid.UUID{expired.ID},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, map[uuid.UUID]int64{expired.ID: 25}, allocationsOf(res))
|
|
assert.Equal(t, int64(10), e.balance(t, c))
|
|
}
|
|
|
|
func TestWalletProcessor_IdempotentCredit(t *testing.T) {
|
|
e := newWalletTestEnv(t)
|
|
c := e.customer()
|
|
in := earn(c, 100, nil)
|
|
in.IdempotencyKey = "earn:order-1"
|
|
|
|
first := e.credit(t, in)
|
|
in.ReferenceID = first.Transaction.ReferenceID
|
|
second := e.credit(t, in)
|
|
|
|
assert.True(t, second.Replayed)
|
|
assert.False(t, first.Replayed)
|
|
assert.Equal(t, first.Transaction.ID, second.Transaction.ID)
|
|
assert.Equal(t, first.Lots[0].ID, second.Lots[0].ID)
|
|
assert.Equal(t, int64(100), e.balance(t, c))
|
|
assert.Len(t, e.repo.transactions, 1)
|
|
}
|
|
|
|
func TestWalletProcessor_IdempotentDebit(t *testing.T) {
|
|
e := newWalletTestEnv(t)
|
|
c := e.customer()
|
|
e.credit(t, earn(c, 30, e.at(time.Hour)))
|
|
e.credit(t, earn(c, 30, nil))
|
|
in := pay(c, 40)
|
|
in.IdempotencyKey = "pay:1"
|
|
|
|
first, err := e.p.Debit(e.ctx, in)
|
|
require.NoError(t, err)
|
|
second, err := e.p.Debit(e.ctx, in)
|
|
require.NoError(t, err)
|
|
|
|
assert.True(t, second.Replayed)
|
|
assert.Equal(t, first.Transaction.ID, second.Transaction.ID)
|
|
assert.ElementsMatch(t, first.Allocations, second.Allocations)
|
|
assert.Equal(t, int64(20), e.balance(t, c))
|
|
}
|
|
|
|
func TestWalletProcessor_IdempotentDebitUpToKeepsShortfall(t *testing.T) {
|
|
e := newWalletTestEnv(t)
|
|
c := e.customer()
|
|
earned := e.credit(t, earn(c, 100, nil))
|
|
_, err := e.p.Debit(e.ctx, pay(c, 60))
|
|
require.NoError(t, err)
|
|
in := reversal(c, 100, earned)
|
|
in.IdempotencyKey = "reverse:order-1"
|
|
|
|
first, err := e.p.DebitUpTo(e.ctx, in)
|
|
require.NoError(t, err)
|
|
e.credit(t, earn(c, 500, nil)) // new balance must not be taken by the retry
|
|
second, err := e.p.DebitUpTo(e.ctx, in)
|
|
require.NoError(t, err)
|
|
|
|
assert.True(t, second.Replayed)
|
|
assert.Equal(t, first.Transaction.ID, second.Transaction.ID)
|
|
assert.Equal(t, int64(60), second.Shortfall)
|
|
assert.Equal(t, int64(500), e.balance(t, c))
|
|
}
|
|
|
|
func TestWalletProcessor_IdempotencyKeyReusedForAnotherOperation(t *testing.T) {
|
|
e := newWalletTestEnv(t)
|
|
c := e.customer()
|
|
in := earn(c, 100, nil)
|
|
in.IdempotencyKey = "k"
|
|
e.credit(t, in)
|
|
|
|
other := earn(c, 99, nil)
|
|
other.IdempotencyKey = "k"
|
|
_, err := e.p.Credit(e.ctx, other)
|
|
assert.ErrorIs(t, err, ErrWalletIdempotencyConflict)
|
|
|
|
debit := pay(c, 100)
|
|
debit.IdempotencyKey = "k"
|
|
_, err = e.p.Debit(e.ctx, debit)
|
|
assert.ErrorIs(t, err, ErrWalletIdempotencyConflict)
|
|
|
|
otherCustomer := earn(e.customer(), 100, nil)
|
|
otherCustomer.IdempotencyKey = "k"
|
|
_, err = e.p.Credit(e.ctx, otherCustomer)
|
|
assert.ErrorIs(t, err, ErrWalletIdempotencyConflict)
|
|
}
|
|
|
|
// A transfer debits the sender and credits the receiver with lots that keep the
|
|
// sender's expiry (K9), following the example in §8.
|
|
func TestWalletProcessor_TransferCarriesExpiry(t *testing.T) {
|
|
e := newWalletTestEnv(t)
|
|
a, b := e.customer(), e.customer()
|
|
dec := e.credit(t, earn(a, 100, e.at(30*24*time.Hour))).Lots[0]
|
|
jan := e.credit(t, earn(a, 50, e.at(60*24*time.Hour))).Lots[0]
|
|
|
|
require.NoError(t, e.p.LockWallets(e.ctx, a, b))
|
|
group, outID, inID := uuid.New(), uuid.New(), uuid.New()
|
|
out, err := e.p.Debit(e.ctx, WalletDebitInput{WalletEntry: WalletEntry{
|
|
TransactionID: outID, CustomerID: a, Currency: constants.WalletCurrencyPoint,
|
|
Type: constants.WalletTxTypeTransferOut, Amount: 120,
|
|
ReferenceType: constants.WalletRefTypeWalletTx, ReferenceID: inID,
|
|
GroupID: &group, CounterpartyCustomerID: &b, Description: "Transfer ke B",
|
|
}})
|
|
require.NoError(t, err)
|
|
received, err := e.p.Credit(e.ctx, WalletCreditInput{
|
|
WalletEntry: WalletEntry{
|
|
TransactionID: inID, CustomerID: b, Currency: constants.WalletCurrencyPoint,
|
|
Type: constants.WalletTxTypeTransferIn, Amount: 120,
|
|
ReferenceType: constants.WalletRefTypeWalletTx, ReferenceID: outID,
|
|
GroupID: &group, CounterpartyCustomerID: &a, Description: "Transfer dari A",
|
|
},
|
|
Lots: out.CarryOver(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, outID, out.Transaction.ID)
|
|
assert.Equal(t, inID, received.Transaction.ID)
|
|
require.Len(t, received.Lots, 2)
|
|
assert.Equal(t, int64(100), received.Lots[0].OriginalAmount)
|
|
assert.Equal(t, dec.ExpiresAt, received.Lots[0].ExpiresAt)
|
|
assert.Equal(t, &dec.ID, received.Lots[0].OriginLotID)
|
|
assert.Equal(t, int64(20), received.Lots[1].OriginalAmount)
|
|
assert.Equal(t, jan.ExpiresAt, received.Lots[1].ExpiresAt)
|
|
assert.Equal(t, &jan.ID, received.Lots[1].OriginLotID)
|
|
assert.Equal(t, int64(30), e.balance(t, a))
|
|
assert.Equal(t, int64(120), e.balance(t, b))
|
|
}
|
|
|
|
func TestWalletProcessor_RejectsEntriesThatBreakTheTypeRules(t *testing.T) {
|
|
c := uuid.New()
|
|
outlet := ptr(uuid.New())
|
|
|
|
credits := map[string]func(*WalletCreditInput){
|
|
"unknown type": func(in *WalletCreditInput) { in.Type = "BONUS" },
|
|
"debit-only type as credit": func(in *WalletCreditInput) {
|
|
in.Type = constants.WalletTxTypePayment
|
|
in.ReferenceType = constants.WalletRefTypePayment
|
|
},
|
|
"unknown currency": func(in *WalletCreditInput) { in.Currency = "GOLD" },
|
|
"zero amount": func(in *WalletCreditInput) { in.Amount = 0; in.Lots = nil },
|
|
"negative amount": func(in *WalletCreditInput) { in.Amount = -5; in.Lots = nil },
|
|
"wrong reference type": func(in *WalletCreditInput) { in.ReferenceType = constants.WalletRefTypeUser },
|
|
"missing reference id": func(in *WalletCreditInput) { in.ReferenceID = uuid.Nil },
|
|
"missing description": func(in *WalletCreditInput) { in.Description = " " },
|
|
"EARN without outlet": func(in *WalletCreditInput) { in.OutletID = nil },
|
|
"missing customer": func(in *WalletCreditInput) { in.CustomerID = uuid.Nil },
|
|
"EXCHANGE_IN in COIN": func(in *WalletCreditInput) {
|
|
in.Type = constants.WalletTxTypeExchangeIn
|
|
in.Currency = constants.WalletCurrencyCoin
|
|
in.ReferenceType = constants.WalletRefTypeWalletTx
|
|
in.GroupID = ptr(uuid.New())
|
|
},
|
|
"TRANSFER_IN to self": func(in *WalletCreditInput) {
|
|
in.Type = constants.WalletTxTypeTransferIn
|
|
in.ReferenceType = constants.WalletRefTypeWalletTx
|
|
in.GroupID = ptr(uuid.New())
|
|
in.CounterpartyCustomerID = &in.CustomerID
|
|
},
|
|
"TRANSFER_IN without group": func(in *WalletCreditInput) {
|
|
in.Type = constants.WalletTxTypeTransferIn
|
|
in.ReferenceType = constants.WalletRefTypeWalletTx
|
|
in.CounterpartyCustomerID = ptr(uuid.New())
|
|
},
|
|
"PAYMENT_REFUND without source": func(in *WalletCreditInput) {
|
|
in.Type = constants.WalletTxTypePaymentRefund
|
|
in.ReferenceType = constants.WalletRefTypePayment
|
|
},
|
|
"ADJUSTMENT without reason": func(in *WalletCreditInput) {
|
|
in.Type = constants.WalletTxTypeAdjustment
|
|
in.ReferenceType = constants.WalletRefTypeUser
|
|
in.CreatedByUser = ptr(uuid.New())
|
|
},
|
|
"ADJUSTMENT blank reason": func(in *WalletCreditInput) {
|
|
in.Type = constants.WalletTxTypeAdjustment
|
|
in.ReferenceType = constants.WalletRefTypeUser
|
|
in.CreatedByUser = ptr(uuid.New())
|
|
in.Reason = ptr(" ")
|
|
},
|
|
"ADJUSTMENT without admin": func(in *WalletCreditInput) {
|
|
in.Type = constants.WalletTxTypeAdjustment
|
|
in.ReferenceType = constants.WalletRefTypeUser
|
|
in.Reason = ptr("komplain")
|
|
},
|
|
"MIGRATION wrong reference": func(in *WalletCreditInput) {
|
|
in.Type = constants.WalletTxTypeMigration
|
|
in.ReferenceType = constants.WalletRefTypeOrder
|
|
},
|
|
}
|
|
for name, mutate := range credits {
|
|
t.Run("credit/"+name, func(t *testing.T) {
|
|
e := newWalletTestEnv(t)
|
|
e.repo.customers[c] = e.org
|
|
in := earn(c, 10, nil)
|
|
in.OutletID = outlet
|
|
mutate(&in)
|
|
_, err := e.p.Credit(e.ctx, in)
|
|
assert.ErrorIs(t, err, ErrWalletInvalidEntry)
|
|
assert.Empty(t, e.repo.transactions)
|
|
})
|
|
}
|
|
|
|
debits := map[string]func(*WalletDebitInput){
|
|
"credit-only type as debit": func(in *WalletDebitInput) {
|
|
in.Type = constants.WalletTxTypeMigration
|
|
in.ReferenceType = constants.WalletRefTypeLegacyPoints
|
|
},
|
|
"PAYMENT in COIN": func(in *WalletDebitInput) { in.Currency = constants.WalletCurrencyCoin },
|
|
"GAME_SPEND in POINT": func(in *WalletDebitInput) {
|
|
in.Type = constants.WalletTxTypeGameSpend
|
|
in.ReferenceType = constants.WalletRefTypeGamePlay
|
|
},
|
|
"EXPIRE not pointing at a lot": func(in *WalletDebitInput) {
|
|
in.Type = constants.WalletTxTypeExpire
|
|
in.ReferenceType = constants.WalletRefTypeOrder
|
|
},
|
|
"EARN_REVERSAL without source": func(in *WalletDebitInput) {
|
|
in.Type = constants.WalletTxTypeEarnReversal
|
|
in.ReferenceType = constants.WalletRefTypeOrder
|
|
},
|
|
"TRANSFER_OUT without counterparty": func(in *WalletDebitInput) {
|
|
in.Type = constants.WalletTxTypeTransferOut
|
|
in.ReferenceType = constants.WalletRefTypeWalletTx
|
|
in.GroupID = ptr(uuid.New())
|
|
},
|
|
"REWARD_REDEEM wrong reference": func(in *WalletDebitInput) {
|
|
in.Type = constants.WalletTxTypeRewardRedeem
|
|
in.ReferenceType = constants.WalletRefTypeOrder
|
|
},
|
|
}
|
|
for name, mutate := range debits {
|
|
t.Run("debit/"+name, func(t *testing.T) {
|
|
e := newWalletTestEnv(t)
|
|
e.repo.customers[c] = e.org
|
|
e.credit(t, earn(c, 100, nil))
|
|
in := pay(c, 10)
|
|
mutate(&in)
|
|
_, err := e.p.Debit(e.ctx, in)
|
|
assert.ErrorIs(t, err, ErrWalletInvalidEntry)
|
|
assert.Len(t, e.repo.transactions, 1)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWalletProcessor_UnknownCustomer(t *testing.T) {
|
|
e := newWalletTestEnv(t)
|
|
_, err := e.p.Credit(e.ctx, earn(uuid.New(), 10, nil))
|
|
assert.ErrorIs(t, err, repository.ErrWalletNotFound)
|
|
}
|