Files
apskel-pos-backend/internal/processor/wallet_exchange_processor_test.go
T
efrilmandClaude Opus 5.5 ab3425070b feat(loyalty): exchange EnakCoin into EnakPoint
Adds GET /customer/wallet/exchange/preview?coins= and
POST /customer/wallet/exchange (docs/prd-point-coin.md F4, K3, PC-401).

The customer exchanges a multiple of the organization's coin_amount and
gets (coins / coin_amount) x point_amount EnakPoint, approved by their PIN
(K8). A malformed amount is refused before the PIN is checked, so it costs
no attempt. In one transaction the wallet is locked, EXCHANGE_OUT takes the
EnakCoin in K9 order and EXCHANGE_IN adds the EnakPoint; the two rows share
a group, point at each other and both freeze the rate in their metadata.

The EnakPoint are split over the EnakCoin lots they came from, each part
keeping its lot's expiry and pointing back at it, so exchanging cannot
extend a balance's life. The split takes floor(coins so far x rate) per
lot, which adds up exactly because the total is a multiple of coin_amount.
EnakPoint have no validity of their own until the expiry model is decided
(N4), so the EnakCoin lot is for now the only bound.

The Idempotency-Key header (or X-Idempotency-Key) is required. A retry
with the same key is recognised under the wallet lock and replayed with
the ids and rate the first attempt froze, even if the rate has changed
since; the same key for another amount is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:09:53 +07:00

306 lines
11 KiB
Go

package processor
import (
"context"
"errors"
"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/models"
"apskel-pos-be/internal/repository"
)
// walletMoveEnv runs exchanges and transfers on the in-memory wallet, so every test
// also checks the ยง7.5 invariants when it ends.
type walletMoveEnv struct {
*walletTestEnv
customers *walletMoveRepoFake
settings *models.OrganizationLoyaltySettings
pins *movePinFake
}
func newWalletMoveEnv(t *testing.T) *walletMoveEnv {
e := &walletMoveEnv{
walletTestEnv: newWalletTestEnv(t),
customers: &walletMoveRepoFake{byID: map[uuid.UUID]*repository.WalletMoveCustomer{}},
settings: &models.OrganizationLoyaltySettings{
PointValue: 1,
Exchange: models.LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1},
Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1},
},
pins: &movePinFake{good: "482913"},
}
return e
}
// member adds an active customer of the organization.
func (e *walletMoveEnv) member(name, phone string) uuid.UUID {
id := e.customer()
e.customers.byID[id] = &repository.WalletMoveCustomer{
ID: id, OrganizationID: e.org, Name: name, PhoneNumber: &phone, IsActive: true,
}
return id
}
func (e *walletMoveEnv) exchanges() *WalletExchangeProcessor {
p := NewWalletExchangeProcessor(e.customers, e, e, e.pins, e.p, txRunnerFake{})
p.now = func() time.Time { return e.now }
return p
}
func (e *walletMoveEnv) Organization(context.Context, uuid.UUID) (*models.OrganizationLoyaltySettings, error) {
s := *e.settings
return &s, nil
}
// SpendableBalances sums the unexpired lots, as the real query does.
func (e *walletMoveEnv) SpendableBalances(_ context.Context, customerID uuid.UUID, asOf time.Time) (map[string]int64, error) {
out := map[string]int64{}
for _, lot := range e.repo.lots {
if lot.CustomerID == customerID && (lot.ExpiresAt == nil || lot.ExpiresAt.After(asOf)) {
out[lot.Currency] += lot.RemainingAmount
}
}
return out, nil
}
// earnCoins gives a customer an EnakCoin lot.
func (e *walletMoveEnv) earnCoins(t *testing.T, customerID uuid.UUID, amount int64, expiresAt *time.Time) *WalletResult {
t.Helper()
in := earn(customerID, amount, expiresAt)
in.Currency = constants.WalletCurrencyCoin
return e.credit(t, in)
}
func (e *walletMoveEnv) coinBalance(t *testing.T, customerID uuid.UUID) int64 {
t.Helper()
w, err := e.repo.GetWallet(e.ctx, customerID)
require.NoError(t, err)
return w.CoinBalance
}
type walletMoveRepoFake struct {
byID map[uuid.UUID]*repository.WalletMoveCustomer
}
func (f *walletMoveRepoFake) GetCustomer(_ context.Context, id uuid.UUID) (*repository.WalletMoveCustomer, error) {
c, ok := f.byID[id]
if !ok {
return nil, repository.ErrWalletNotFound
}
copied := *c
return &copied, nil
}
// movePinFake accepts one PIN and records the actions it was asked to approve.
type movePinFake struct {
good string
err error
actions []PinAction
}
func (f *movePinFake) VerifyPin(_ context.Context, _ uuid.UUID, pin string, action PinAction, _ models.CustomerPinRequestInfo) error {
f.actions = append(f.actions, action)
if f.err != nil {
return f.err
}
if pin != f.good {
return &PinError{Code: PinErrInvalid, RemainingAttempts: 4}
}
return nil
}
func TestWalletExchange_DefaultRateIsOneToOne(t *testing.T) {
e := newWalletMoveEnv(t)
c := e.member("Budi Santoso", "081234561234")
e.earnCoins(t, c, 50, nil)
res, err := e.exchanges().Exchange(e.ctx, c, 50, "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
assert.Equal(t, int64(50), res.Points)
assert.Equal(t, int64(0), res.CoinBalance)
assert.Equal(t, int64(50), res.PointBalance)
assert.Equal(t, []PinAction{PinActionExchange}, e.pins.actions)
out, in := e.repo.transactions[1], e.repo.transactions[2]
assert.Equal(t, constants.WalletTxTypeExchangeOut, out.Type)
assert.Equal(t, constants.WalletCurrencyCoin, out.Currency)
assert.Equal(t, int64(-50), out.Amount)
assert.Equal(t, constants.WalletTxTypeExchangeIn, in.Type)
assert.Equal(t, constants.WalletCurrencyPoint, in.Currency)
assert.Equal(t, int64(50), in.Amount)
// The pair shares a group and each row points at the other (ยง8.1).
assert.Equal(t, *out.GroupID, *in.GroupID)
assert.Equal(t, res.GroupID, *out.GroupID)
assert.Equal(t, in.ID, out.ReferenceID)
assert.Equal(t, out.ID, in.ReferenceID)
assert.Equal(t, "Tukar 50 EnakCoin ke EnakPoint", out.Description)
assert.Equal(t, "Dari tukar 50 EnakCoin", in.Description)
// Both rows freeze the rate.
for _, row := range []*entities.WalletTransaction{out, in} {
assert.Equal(t, int64(1), row.Metadata["coin_amount"])
assert.Equal(t, int64(1), row.Metadata["point_amount"])
}
}
func TestWalletExchange_TenCoinsForThreePoints(t *testing.T) {
e := newWalletMoveEnv(t)
e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3}
c := e.member("Budi", "081234561234")
e.earnCoins(t, c, 35, nil)
preview, err := e.exchanges().Preview(e.ctx, c, 30)
require.NoError(t, err)
assert.True(t, preview.Valid)
assert.Equal(t, int64(9), preview.Points)
assert.Equal(t, int64(35), preview.CoinBalance)
res, err := e.exchanges().Exchange(e.ctx, c, 30, "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
assert.Equal(t, int64(9), res.Points)
assert.Equal(t, int64(5), res.CoinBalance)
assert.Equal(t, int64(9), res.PointBalance)
}
func TestWalletExchange_RefusesAmountsThatAreNotAMultiple(t *testing.T) {
e := newWalletMoveEnv(t)
e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3}
c := e.member("Budi", "081234561234")
e.earnCoins(t, c, 50, nil)
preview, err := e.exchanges().Preview(e.ctx, c, 25)
require.NoError(t, err)
assert.False(t, preview.Valid)
assert.Contains(t, preview.Reason, "multiples of 10")
_, err = e.exchanges().Exchange(e.ctx, c, 25, "482913", "key-1", models.CustomerPinRequestInfo{})
assert.ErrorIs(t, err, ErrWalletMoveRejected)
// Refused before the PIN, so a typo costs no attempt.
assert.Empty(t, e.pins.actions)
assert.Equal(t, int64(50), e.coinBalance(t, c))
_, err = e.exchanges().Exchange(e.ctx, c, 0, "482913", "key-2", models.CustomerPinRequestInfo{})
assert.ErrorIs(t, err, ErrWalletMoveRejected)
}
func TestWalletExchange_NeverOutlivesTheCoinLot(t *testing.T) {
e := newWalletMoveEnv(t)
e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3}
c := e.member("Budi", "081234561234")
soon, later := e.at(24*time.Hour), e.at(48*time.Hour)
first := e.earnCoins(t, c, 15, soon)
second := e.earnCoins(t, c, 15, later)
third := e.earnCoins(t, c, 10, nil)
// 40 EnakCoin take 15 from the lot expiring soonest, 15 from the next and 10 from
// the one that never expires, giving 12 EnakPoint split 4 + 5 + 3.
res, err := e.exchanges().Exchange(e.ctx, c, 40, "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
require.Len(t, res.Lots, 3)
assert.Equal(t, models.WalletMovedLot{Amount: 4, ExpiresAt: soon}, res.Lots[0])
assert.Equal(t, models.WalletMovedLot{Amount: 5, ExpiresAt: later}, res.Lots[1])
assert.Equal(t, models.WalletMovedLot{Amount: 3}, res.Lots[2])
origins := map[uuid.UUID]*time.Time{}
for _, lot := range e.repo.lots {
if lot.Currency == constants.WalletCurrencyPoint {
require.NotNil(t, lot.OriginLotID)
origins[*lot.OriginLotID] = lot.ExpiresAt
}
}
assert.Equal(t, map[uuid.UUID]*time.Time{
first.Lots[0].ID: soon, second.Lots[0].ID: later, third.Lots[0].ID: nil,
}, origins)
for _, lot := range e.repo.lots {
if lot.Currency != constants.WalletCurrencyPoint || lot.OriginLotID == nil {
continue
}
for _, coinLot := range e.repo.lots {
if coinLot.ID == *lot.OriginLotID && coinLot.ExpiresAt != nil {
require.NotNil(t, lot.ExpiresAt, "a lot that expires cannot become one that does not")
assert.False(t, lot.ExpiresAt.After(*coinLot.ExpiresAt))
}
}
}
}
func TestWalletExchange_LotTooSmallForAWholePointGivesNone(t *testing.T) {
e := newWalletMoveEnv(t)
e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 1}
c := e.member("Budi", "081234561234")
e.earnCoins(t, c, 5, e.at(time.Hour))
e.earnCoins(t, c, 5, nil)
// Neither lot is worth a whole EnakPoint alone; the one that completes the 10
// carries it.
res, err := e.exchanges().Exchange(e.ctx, c, 10, "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
assert.Equal(t, []models.WalletMovedLot{{Amount: 1}}, res.Lots)
}
func TestWalletExchange_NotEnoughCoins(t *testing.T) {
e := newWalletMoveEnv(t)
c := e.member("Budi", "081234561234")
e.earnCoins(t, c, 5, nil)
preview, err := e.exchanges().Preview(e.ctx, c, 6)
require.NoError(t, err)
assert.False(t, preview.Valid)
_, err = e.exchanges().Exchange(e.ctx, c, 6, "482913", "key-1", models.CustomerPinRequestInfo{})
assert.ErrorIs(t, err, ErrWalletMoveRejected)
assert.Equal(t, int64(5), e.coinBalance(t, c))
}
func TestWalletExchange_WrongPinMovesNothing(t *testing.T) {
e := newWalletMoveEnv(t)
c := e.member("Budi", "081234561234")
e.earnCoins(t, c, 5, nil)
_, err := e.exchanges().Exchange(e.ctx, c, 5, "000000", "key-1", models.CustomerPinRequestInfo{})
var pinErr *PinError
require.True(t, errors.As(err, &pinErr))
assert.Equal(t, PinErrInvalid, pinErr.Code)
assert.Equal(t, int64(5), e.coinBalance(t, c))
}
func TestWalletExchange_RetryReturnsTheFirstExchangeAtItsRate(t *testing.T) {
e := newWalletMoveEnv(t)
c := e.member("Budi", "081234561234")
e.earnCoins(t, c, 100, nil)
first, err := e.exchanges().Exchange(e.ctx, c, 40, "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
// The rate changes before the retry arrives; the retry still gets the first result.
e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 2}
again, err := e.exchanges().Exchange(e.ctx, c, 40, "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
assert.True(t, again.Replayed)
assert.Equal(t, first.GroupID, again.GroupID)
assert.Equal(t, int64(40), again.Points)
assert.Equal(t, int64(60), e.coinBalance(t, c))
assert.Len(t, e.repo.transactions, 3)
// The same key for a different amount is not a retry.
_, err = e.exchanges().Exchange(e.ctx, c, 20, "482913", "key-1", models.CustomerPinRequestInfo{})
assert.ErrorIs(t, err, ErrWalletIdempotencyConflict)
}
func TestWalletExchange_RequiresAnIdempotencyKey(t *testing.T) {
e := newWalletMoveEnv(t)
c := e.member("Budi", "081234561234")
e.earnCoins(t, c, 5, nil)
_, err := e.exchanges().Exchange(e.ctx, c, 5, "482913", " ", models.CustomerPinRequestInfo{})
assert.ErrorIs(t, err, ErrWalletMoveRejected)
assert.Empty(t, e.pins.actions)
}