Adds GET /customer/wallet/transfer/recipient?phone= and
POST /customer/wallet/transfer (docs/prd-point-coin.md F5, Q4, Q16,
PC-402).
The recipient is found by phone number and must be an active customer of
the same organization, not the walk-in customer and not the sender. A
number of another organization answers 404 like an unknown one, so the
check does not reveal who uses the app elsewhere. The recipient check
returns the name and number masked ("Bu*** Sa***", "08**-****-1234").
The organization's transfer settings apply: transfers turned off, the
minimum, the maximum per transaction and the daily limit per currency,
which starts over at midnight WIB. Everything the request alone can get
wrong is refused before the PIN, so it costs no attempt; the PIN then
refuses a transfer held for 24 hours after a PIN reset.
Both wallets are locked in customer_id order, so transfers in opposite
directions cannot deadlock, and the daily limit is summed under the lock.
TRANSFER_OUT takes from the sender's lots in K9 order and TRANSFER_IN
gives the recipient lots with exactly the same expiries, pointing back at
the sender's lots. The rows share a group, reference each other and name
the other customer; descriptions carry only the masked name.
The Idempotency-Key header is required. A retry is recognised under the
lock before the daily limit, so it replays instead of counting twice; the
same key towards another recipient is refused.
The recipient is told by WhatsApp after the commit, as PIN locks are:
NotificationService only reaches staff devices, there is no push channel
to customers yet. A failure to send is logged, never undoes the transfer.
Transfers must not be released before note N3 (legal) is closed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
335 lines
12 KiB
Go
335 lines
12 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"},
|
|
}
|
|
e.customers.ledger = e.repo
|
|
// Ledger rows are stamped from now on, so "today" is the day of e.now.
|
|
e.repo.clock = e.now
|
|
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) transfers(messenger walletMessenger) *WalletTransferProcessor {
|
|
p := NewWalletTransferProcessor(e.customers, e, e, e.pins, e.p, txRunnerFake{}, messenger)
|
|
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
|
|
ledger *walletRepoFake
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (f *walletMoveRepoFake) FindCustomerByPhone(ctx context.Context, phone string) (*repository.WalletMoveCustomer, error) {
|
|
for id, c := range f.byID {
|
|
if c.PhoneNumber != nil && *c.PhoneNumber == phone {
|
|
return f.GetCustomer(ctx, id)
|
|
}
|
|
}
|
|
return nil, repository.ErrWalletNotFound
|
|
}
|
|
|
|
func (f *walletMoveRepoFake) TransferredOutSince(_ context.Context, customerID uuid.UUID, currency string, since time.Time) (int64, error) {
|
|
var total int64
|
|
for _, tx := range f.ledger.transactions {
|
|
if tx.CustomerID == customerID && tx.Currency == currency && tx.Type == constants.WalletTxTypeTransferOut && !tx.CreatedAt.Before(since) {
|
|
total -= tx.Amount
|
|
}
|
|
}
|
|
return total, 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)
|
|
}
|