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) }