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>
This commit is contained in:
efrilm
2026-09-30 08:47:37 +07:00
co-authored by Claude Opus 5.5
parent fc5eecb68a
commit 2eb590caab
5 changed files with 1492 additions and 0 deletions
+527
View File
@@ -0,0 +1,527 @@
package processor
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/repository"
)
var (
// ErrWalletInvalidEntry wraps every rejection of an entry that breaks the rules in
// docs/prd-point-coin.md §8.1. The database enforces most of them too; checking
// here first gives callers a readable error instead of a constraint name.
ErrWalletInvalidEntry = errors.New("wallet: invalid entry")
// ErrWalletIdempotencyConflict means an idempotency key was reused for a different
// operation. Retrying the same operation with the same key is not a conflict.
ErrWalletIdempotencyConflict = errors.New("wallet: idempotency key already used for a different operation")
)
// WalletEntry is what every ledger row needs, whichever way it moves the balance.
// Which of the optional fields a type requires is listed in §8.1.
type WalletEntry struct {
// Optional. Set it when another row must reference this one before it exists, as
// the two rows of an exchange or a transfer do.
TransactionID uuid.UUID
CustomerID uuid.UUID
Currency string
Type string
// Always positive: Credit adds it, Debit takes it away.
Amount int64
ReferenceType string
ReferenceID uuid.UUID
GroupID *uuid.UUID
CounterpartyCustomerID *uuid.UUID
ReversesTransactionID *uuid.UUID
OutletID *uuid.UUID
CreatedByUser *uuid.UUID
Reason *string
Description string
Metadata entities.Metadata
// Optional. A retry with the same key returns the first result without moving
// anything again.
IdempotencyKey string
}
// WalletLotInput is one lot a credit creates.
type WalletLotInput struct {
Amount int64
// Nil means the lot never expires.
ExpiresAt *time.Time
// The lot this one was carried over from, for transfers, exchanges and refunds.
OriginLotID *uuid.UUID
}
type WalletCreditInput struct {
WalletEntry
// How the credit is split into lots. Their amounts must add up to Amount. Leave
// empty for a single lot that never expires.
Lots []WalletLotInput
}
type WalletDebitInput struct {
WalletEntry
// Lots to draw from first, in this order, before falling back to the K9 order.
// A reversal names the lots its EARN created (F10), and the expiry job names the
// lot that expired. These lots are used even if they have already expired.
PreferredLotIDs []uuid.UUID
}
// WalletAllocation is how much a debit took from one lot. It carries the lot's
// expiry, so a transfer or exchange can give the receiving lot the same expiry (K9).
type WalletAllocation struct {
LotID uuid.UUID
Amount int64
ExpiresAt *time.Time
}
type WalletResult struct {
// Nil only when DebitUpTo found nothing to take.
Transaction *entities.WalletTransaction
// The lots a credit created.
Lots []entities.WalletLot
// The lots a debit drew from, in the order they were used.
Allocations []WalletAllocation
// What DebitUpTo could not take because the balance ran out.
Shortfall int64
// True when the idempotency key had already been used and nothing moved.
Replayed bool
}
// CarryOver turns a debit's allocations into lots for the receiving side of a
// transfer or exchange. Each lot keeps the expiry of the lot it came from and points
// back at it, so a balance cannot be kept alive by moving it around (K9).
func (r *WalletResult) CarryOver() []WalletLotInput {
lots := make([]WalletLotInput, 0, len(r.Allocations))
for _, a := range r.Allocations {
lotID := a.LotID
lots = append(lots, WalletLotInput{Amount: a.Amount, ExpiresAt: a.ExpiresAt, OriginLotID: &lotID})
}
return lots
}
// WalletProcessor is the only code allowed to change a wallet balance. Every change
// writes the balance, the ledger row and the lots or allocations together, which is
// what keeps SUM(ledger) = balance = SUM(lot remaining) (§7.5).
//
// Every method must run inside a transaction from TxManager, and the repository
// refuses otherwise. Each method locks the customer's wallet itself, so a single-wallet
// caller needs nothing more. A caller touching two wallets, such as a transfer, must
// call LockWallets first so the locks are always taken in the same order.
type WalletProcessor struct {
repo repository.WalletRepository
now func() time.Time
}
func NewWalletProcessor(repo repository.WalletRepository) *WalletProcessor {
return &WalletProcessor{repo: repo, now: time.Now}
}
// LockWallet locks one customer's wallet, creating it if needed. Credit and Debit do
// this themselves; call it when something must be read under the lock first.
func (p *WalletProcessor) LockWallet(ctx context.Context, customerID uuid.UUID) error {
_, err := p.repo.LockWallet(ctx, customerID)
return err
}
// LockWallets locks two customers' wallets in a fixed order. Call it before touching
// both wallets in one transaction.
func (p *WalletProcessor) LockWallets(ctx context.Context, a, b uuid.UUID) error {
_, _, err := p.repo.LockWallets(ctx, a, b)
return err
}
// Credit adds Amount to the wallet and creates its lots.
func (p *WalletProcessor) Credit(ctx context.Context, in WalletCreditInput) (*WalletResult, error) {
if err := validateWalletEntry(&in.WalletEntry, true); err != nil {
return nil, err
}
lots := in.Lots
if len(lots) == 0 {
lots = []WalletLotInput{{Amount: in.Amount}}
}
var total int64
for _, lot := range lots {
if lot.Amount <= 0 {
return nil, fmt.Errorf("%w: lot amount must be positive, got %d", ErrWalletInvalidEntry, lot.Amount)
}
total += lot.Amount
}
if total != in.Amount {
return nil, fmt.Errorf("%w: lots add up to %d, not %d", ErrWalletInvalidEntry, total, in.Amount)
}
wallet, err := p.repo.LockWallet(ctx, in.CustomerID)
if err != nil {
return nil, err
}
if replay, err := p.replay(ctx, &in.WalletEntry, true, true); replay != nil || err != nil {
return replay, err
}
balance, err := p.repo.AddBalance(ctx, in.CustomerID, in.Currency, in.Amount)
if err != nil {
return nil, err
}
walletTx := newWalletTransaction(wallet, &in.WalletEntry, in.Amount, balance, nil)
if err := p.repo.CreateTransaction(ctx, walletTx); err != nil {
return nil, fmt.Errorf("failed to create wallet transaction: %w", err)
}
result := &WalletResult{Transaction: walletTx}
for _, lotIn := range lots {
lot := entities.WalletLot{
OrganizationID: wallet.OrganizationID,
CustomerID: in.CustomerID,
Currency: in.Currency,
SourceTransactionID: walletTx.ID,
OriginLotID: lotIn.OriginLotID,
OriginalAmount: lotIn.Amount,
RemainingAmount: lotIn.Amount,
ExpiresAt: lotIn.ExpiresAt,
}
if err := p.repo.CreateLot(ctx, &lot); err != nil {
return nil, fmt.Errorf("failed to create wallet lot: %w", err)
}
result.Lots = append(result.Lots, lot)
}
return result, nil
}
// Debit takes exactly Amount from the wallet, or nothing at all with
// repository.ErrWalletInsufficientBalance if the usable balance is short.
func (p *WalletProcessor) Debit(ctx context.Context, in WalletDebitInput) (*WalletResult, error) {
return p.debit(ctx, in, false)
}
// DebitUpTo takes as much of Amount as the wallet has and reports the rest as
// Shortfall. It is for reversing earnings the customer has already spent (F10, Q3).
// When there is nothing to take, no ledger row is written and Transaction is nil;
// such a call leaves no trace, so a retry with the same key takes whatever the
// balance holds by then.
func (p *WalletProcessor) DebitUpTo(ctx context.Context, in WalletDebitInput) (*WalletResult, error) {
return p.debit(ctx, in, true)
}
func (p *WalletProcessor) debit(ctx context.Context, in WalletDebitInput, upTo bool) (*WalletResult, error) {
if err := validateWalletEntry(&in.WalletEntry, false); err != nil {
return nil, err
}
wallet, err := p.repo.LockWallet(ctx, in.CustomerID)
if err != nil {
return nil, err
}
// DebitUpTo may have taken less than asked, so the amount cannot be compared.
if replay, err := p.replay(ctx, &in.WalletEntry, false, !upTo); replay != nil || err != nil {
return replay, err
}
lots, err := p.spendableLots(ctx, &in)
if err != nil {
return nil, err
}
var available int64
for _, lot := range lots {
available += lot.RemainingAmount
}
take := in.Amount
if available < take {
if !upTo {
return nil, repository.ErrWalletInsufficientBalance
}
take = available
}
result := &WalletResult{Shortfall: in.Amount - take}
if take == 0 {
return result, nil
}
var metadata entities.Metadata
if upTo {
metadata = entities.Metadata{"requested_amount": in.Amount, "shortfall": result.Shortfall}
}
balance, err := p.repo.AddBalance(ctx, in.CustomerID, in.Currency, -take)
if err != nil {
return nil, err
}
walletTx := newWalletTransaction(wallet, &in.WalletEntry, -take, balance, metadata)
if err := p.repo.CreateTransaction(ctx, walletTx); err != nil {
return nil, fmt.Errorf("failed to create wallet transaction: %w", err)
}
result.Transaction = walletTx
var allocations []entities.WalletLotAllocation
remaining := take
for _, lot := range lots {
if remaining == 0 {
break
}
amount := min(lot.RemainingAmount, remaining)
remaining -= amount
if err := p.repo.ConsumeLot(ctx, lot.ID, amount); err != nil {
return nil, err
}
allocations = append(allocations, entities.WalletLotAllocation{TransactionID: walletTx.ID, LotID: lot.ID, Amount: amount})
result.Allocations = append(result.Allocations, WalletAllocation{LotID: lot.ID, Amount: amount, ExpiresAt: lot.ExpiresAt})
}
if err := p.repo.CreateAllocations(ctx, allocations); err != nil {
return nil, fmt.Errorf("failed to create wallet lot allocations: %w", err)
}
return result, nil
}
// spendableLots returns the lots a debit may draw from, in the order it draws: the
// preferred lots first, then the unexpired lots in K9 order.
func (p *WalletProcessor) spendableLots(ctx context.Context, in *WalletDebitInput) ([]entities.WalletLot, error) {
var lots []entities.WalletLot
preferred := make(map[uuid.UUID]bool, len(in.PreferredLotIDs))
if len(in.PreferredLotIDs) > 0 {
found, err := p.repo.GetLotsByIDs(ctx, in.PreferredLotIDs)
if err != nil {
return nil, err
}
byID := make(map[uuid.UUID]entities.WalletLot, len(found))
for _, lot := range found {
byID[lot.ID] = lot
}
for _, id := range in.PreferredLotIDs {
lot, ok := byID[id]
if !ok || lot.CustomerID != in.CustomerID || lot.Currency != in.Currency {
return nil, fmt.Errorf("%w: lot %s is not a %s lot of this customer", ErrWalletInvalidEntry, id, in.Currency)
}
if preferred[id] {
continue
}
preferred[id] = true
if lot.RemainingAmount > 0 {
lots = append(lots, lot)
}
}
}
active, err := p.repo.ListActiveLots(ctx, in.CustomerID, in.Currency, p.now())
if err != nil {
return nil, err
}
for _, lot := range active {
if !preferred[lot.ID] {
lots = append(lots, lot)
}
}
return lots, nil
}
// replay returns the first result for an idempotency key that has already been used,
// or nil when the key is new. It runs after the wallet lock, so a concurrent request
// with the same key has either committed its row or not started.
func (p *WalletProcessor) replay(ctx context.Context, in *WalletEntry, credit, compareAmount bool) (*WalletResult, error) {
if in.IdempotencyKey == "" {
return nil, nil
}
walletTx, err := p.repo.GetTransactionByIdempotencyKey(ctx, in.IdempotencyKey)
if err != nil || walletTx == nil {
return nil, err
}
sameDirection := (walletTx.Amount > 0) == credit
sameAmount := !compareAmount || abs(walletTx.Amount) == in.Amount
if walletTx.CustomerID != in.CustomerID || walletTx.Currency != in.Currency ||
walletTx.Type != in.Type || !sameDirection || !sameAmount {
return nil, ErrWalletIdempotencyConflict
}
result := &WalletResult{Transaction: walletTx, Replayed: true}
if credit {
result.Lots, err = p.repo.ListLotsBySourceTransaction(ctx, walletTx.ID)
return result, err
}
// JSON numbers come back from JSONB as float64.
switch shortfall := walletTx.Metadata["shortfall"].(type) {
case float64:
result.Shortfall = int64(shortfall)
case int64:
result.Shortfall = shortfall
}
allocations, err := p.repo.ListAllocationsByTransaction(ctx, walletTx.ID)
if err != nil {
return nil, err
}
ids := make([]uuid.UUID, 0, len(allocations))
for _, a := range allocations {
ids = append(ids, a.LotID)
}
lots, err := p.repo.GetLotsByIDs(ctx, ids)
if err != nil {
return nil, err
}
expiry := make(map[uuid.UUID]*time.Time, len(lots))
for _, lot := range lots {
expiry[lot.ID] = lot.ExpiresAt
}
for _, a := range allocations {
result.Allocations = append(result.Allocations, WalletAllocation{LotID: a.LotID, Amount: a.Amount, ExpiresAt: expiry[a.LotID]})
}
return result, nil
}
func newWalletTransaction(wallet *entities.CustomerWallet, in *WalletEntry, amount, balance int64, extra entities.Metadata) *entities.WalletTransaction {
metadata := entities.Metadata{}
for k, v := range in.Metadata {
metadata[k] = v
}
for k, v := range extra {
metadata[k] = v
}
var key *string
if in.IdempotencyKey != "" {
k := in.IdempotencyKey
key = &k
}
return &entities.WalletTransaction{
ID: in.TransactionID,
OrganizationID: wallet.OrganizationID,
CustomerID: in.CustomerID,
Currency: in.Currency,
Type: in.Type,
Amount: amount,
BalanceAfter: balance,
GroupID: in.GroupID,
ReferenceType: in.ReferenceType,
ReferenceID: in.ReferenceID,
CounterpartyCustomerID: in.CounterpartyCustomerID,
ReversesTransactionID: in.ReversesTransactionID,
OutletID: in.OutletID,
CreatedByUser: in.CreatedByUser,
Reason: in.Reason,
Description: in.Description,
Metadata: metadata,
IdempotencyKey: key,
}
}
// walletTypeRule is one row of §8.1.
type walletTypeRule struct {
credit, debit bool
currency string // empty: either currency
referenceTypes []string
needsOutlet bool
needsReverses bool
needsGroup bool
needsCounter bool
needsActor bool
}
var walletTypeRules = map[string]walletTypeRule{
constants.WalletTxTypeEarn: {credit: true, referenceTypes: []string{constants.WalletRefTypeOrder}, needsOutlet: true},
constants.WalletTxTypeEarnReversal: {debit: true, referenceTypes: []string{constants.WalletRefTypeOrder}, needsOutlet: true, needsReverses: true},
constants.WalletTxTypePayment: {debit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypePayment}, needsOutlet: true},
constants.WalletTxTypePaymentRefund: {credit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypePayment}, needsOutlet: true, needsReverses: true},
constants.WalletTxTypeExchangeOut: {debit: true, currency: constants.WalletCurrencyCoin, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true},
constants.WalletTxTypeExchangeIn: {credit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true},
constants.WalletTxTypeTransferOut: {debit: true, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true, needsCounter: true},
constants.WalletTxTypeTransferIn: {credit: true, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true, needsCounter: true},
constants.WalletTxTypeGameSpend: {debit: true, currency: constants.WalletCurrencyCoin, referenceTypes: []string{constants.WalletRefTypeGamePlay}},
constants.WalletTxTypeExpire: {debit: true, referenceTypes: []string{constants.WalletRefTypeLot}},
constants.WalletTxTypeAdjustment: {credit: true, debit: true, referenceTypes: []string{constants.WalletRefTypeUser}, needsActor: true},
constants.WalletTxTypeMigration: {credit: true, referenceTypes: []string{constants.WalletRefTypeLegacyPoints, constants.WalletRefTypeLegacyTokens}},
constants.WalletTxTypeRewardRedeem: {debit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypeRewardRedemption}},
}
func validateWalletEntry(in *WalletEntry, credit bool) error {
invalid := func(format string, args ...any) error {
return fmt.Errorf("%w: %s", ErrWalletInvalidEntry, fmt.Sprintf(format, args...))
}
rule, ok := walletTypeRules[in.Type]
if !ok {
return invalid("unknown type %q", in.Type)
}
if credit && !rule.credit {
return invalid("%s cannot add to a balance", in.Type)
}
if !credit && !rule.debit {
return invalid("%s cannot take from a balance", in.Type)
}
if in.CustomerID == uuid.Nil {
return invalid("customer is required")
}
if !constants.IsValidWalletCurrency(in.Currency) {
return invalid("unknown currency %q", in.Currency)
}
if rule.currency != "" && in.Currency != rule.currency {
return invalid("%s must be in %s", in.Type, rule.currency)
}
if in.Amount <= 0 {
return invalid("amount must be positive, got %d", in.Amount)
}
if !containsString(rule.referenceTypes, in.ReferenceType) {
return invalid("%s must reference %s, got %q", in.Type, strings.Join(rule.referenceTypes, " or "), in.ReferenceType)
}
if in.ReferenceID == uuid.Nil {
return invalid("reference id is required")
}
if strings.TrimSpace(in.Description) == "" {
return invalid("description is required")
}
if rule.needsOutlet && isNilID(in.OutletID) {
return invalid("%s requires an outlet", in.Type)
}
if rule.needsReverses && isNilID(in.ReversesTransactionID) {
return invalid("%s requires the transaction it reverses", in.Type)
}
if rule.needsGroup && isNilID(in.GroupID) {
return invalid("%s requires a group id", in.Type)
}
if rule.needsCounter {
if isNilID(in.CounterpartyCustomerID) {
return invalid("%s requires a counterparty", in.Type)
}
if *in.CounterpartyCustomerID == in.CustomerID {
return invalid("%s cannot go to the same customer", in.Type)
}
}
if rule.needsActor {
if isNilID(in.CreatedByUser) {
return invalid("%s requires the admin who made it", in.Type)
}
if in.Reason == nil || strings.TrimSpace(*in.Reason) == "" {
return invalid("%s requires a reason", in.Type)
}
}
return nil
}
func isNilID(id *uuid.UUID) bool {
return id == nil || *id == uuid.Nil
}
func containsString(values []string, v string) bool {
for _, value := range values {
if value == v {
return true
}
}
return false
}
func abs(v int64) int64 {
if v < 0 {
return -v
}
return v
}
@@ -0,0 +1,142 @@
package processor
import (
"context"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/repository"
)
// Runs the engine against Postgres, to show the rows it writes pass the database
// constraints and reconcile the way §7.5 requires. Needs TEST_DATABASE_URL pointing
// at a migrated database; see internal/repository/wallet_repository_test.go.
func TestWalletProcessor_AgainstPostgres(t *testing.T) {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
org, a, b := uuid.New(), uuid.New(), uuid.New()
require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'wallet test', 'basic')`, org).Error)
require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'A'), (?, ?, 'B')`, a, org, b, org).Error)
customers := []uuid.UUID{a, b}
t.Cleanup(func() {
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id IN ?)`, customers)
db.Exec(`DELETE FROM wallet_lots WHERE customer_id IN ?`, customers)
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id IN ?`, customers)
db.Exec(`DELETE FROM customer_wallets WHERE customer_id IN ?`, customers)
db.Exec(`DELETE FROM customers WHERE id IN ?`, customers)
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
})
p := NewWalletProcessor(repository.NewWalletRepository(db))
txm := repository.NewTxManager(db)
now := time.Now()
inTx := func(fn func(ctx context.Context) error) {
t.Helper()
require.NoError(t, txm.WithTransaction(context.Background(), fn))
}
// Without a transaction nothing moves.
_, err = p.Credit(context.Background(), earn(a, 10, nil))
assert.ErrorIs(t, err, repository.ErrWalletTxRequired)
var earned *WalletResult
inTx(func(ctx context.Context) error {
soon := now.Add(time.Hour)
earned, err = p.Credit(ctx, earn(a, 100, &soon))
require.NoError(t, err)
_, err = p.Credit(ctx, earn(a, 50, nil))
return err
})
// Transfer 120 from A to B, spanning both of A's lots.
inTx(func(ctx context.Context) error {
require.NoError(t, p.LockWallets(ctx, a, b))
group, outID, inID := uuid.New(), uuid.New(), uuid.New()
out, err := p.Debit(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)
_, err = p.Credit(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(),
})
return err
})
// Reversing the 100 earned leaves A 30 short. The retry reads the shortfall back
// out of JSONB and takes nothing more.
rev := reversal(a, 100, earned)
rev.IdempotencyKey = "reverse:" + earned.Transaction.ID.String()
var first, second *WalletResult
inTx(func(ctx context.Context) error {
first, err = p.DebitUpTo(ctx, rev)
return err
})
inTx(func(ctx context.Context) error {
second, err = p.DebitUpTo(ctx, rev)
return err
})
assert.Equal(t, int64(-30), first.Transaction.Amount)
assert.Equal(t, int64(70), first.Shortfall)
assert.True(t, second.Replayed)
assert.Equal(t, int64(70), second.Shortfall)
assert.Equal(t, first.Transaction.ID, second.Transaction.ID)
// Overdraw fails and rolls back cleanly.
err = txm.WithTransaction(context.Background(), func(ctx context.Context) error {
_, err := p.Debit(ctx, pay(b, 121))
return err
})
assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance)
var balances []struct {
CustomerID uuid.UUID
PointBalance int64
}
require.NoError(t, db.Raw(`SELECT customer_id, point_balance FROM customer_wallets WHERE customer_id IN ?`, customers).Scan(&balances).Error)
got := map[uuid.UUID]int64{}
for _, row := range balances {
got[row.CustomerID] = row.PointBalance
}
assert.Equal(t, map[uuid.UUID]int64{a: 0, b: 120}, got)
// §7.5, straight from the tables.
var broken []string
require.NoError(t, db.Raw(`
SELECT 'wallet ' || w.customer_id FROM customer_wallets w
WHERE w.customer_id IN ? AND (
w.point_balance <> (SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions t WHERE t.customer_id = w.customer_id AND t.currency = 'POINT')
OR w.point_balance <> (SELECT COALESCE(SUM(remaining_amount), 0) FROM wallet_lots l WHERE l.customer_id = w.customer_id AND l.currency = 'POINT'))
UNION ALL
SELECT 'lot ' || l.id FROM wallet_lots l
WHERE l.customer_id IN ? AND l.original_amount - l.remaining_amount
<> (SELECT COALESCE(SUM(amount), 0) FROM wallet_lot_allocations a WHERE a.lot_id = l.id)
UNION ALL
SELECT 'debit ' || t.id FROM wallet_transactions t
WHERE t.customer_id IN ? AND t.amount < 0
AND -t.amount <> (SELECT COALESCE(SUM(amount), 0) FROM wallet_lot_allocations a WHERE a.transaction_id = t.id)`,
customers, customers, customers).Scan(&broken).Error)
assert.Empty(t, broken)
}
+788
View File
@@ -0,0 +1,788 @@
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)
}