Reapply "feat(loyalty): EnakPoint & EnakCoin" (#32)
This reverts commit 4e24f9bbb0.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user