Files
apskel-pos-backend/internal/processor/wallet_processor_test.go
efrilmandClaude Opus 5.5 2eb590caab feat(wallet): add wallet engine as the only way to change a balance
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>
2026-09-30 08:47:37 +07:00

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