A customer's phone number (customers.phone_number, the one they log in with) has one stored form, 62 followed by the number without its 0: 6281234561234. util.NormalizePhoneNumber rewrites 0812…, +62 812…, 62812…, 812… and +62 0812…, with spaces, dashes, dots or parentheses, to it, and refuses anything that is not an Indonesian mobile number (628 and 7 to 11 more digits). It is applied wherever a customer types their number: check-phone, register, login and resend-OTP (the validator rewrites the request), and the transfer recipient. Before, the number was matched as typed and a leading 0 was refused, so the same customer written another way was not found. The masked recipient stays 08**-****-1234 as customers write numbers. Migration 000116 rewrites the numbers already stored in customers and otp_sessions. When several become the same number, the customer that already has it keeps it, else a registered one, else the oldest; the others, and numbers that are not Indonesian mobile numbers, are left as they are and cannot log in until fixed by hand (the query to find them is in the migration). Down does nothing. The migration was run, twice, against Postgres with a reduced customers and otp_sessions schema and sample numbers; not against the real schema. The Postgres tests were not run. customers.phone (the POS contact number) is not touched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
360 lines
13 KiB
Go
360 lines
13 KiB
Go
package processor
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"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(notifier customerNotifier) *WalletTransferProcessor {
|
|
p := NewWalletTransferProcessor(e.customers, e, e, e.pins, e.p, txRunnerFake{}, notifier)
|
|
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. Tests
|
|
// call it from several goroutines at once.
|
|
type movePinFake struct {
|
|
good string
|
|
err error
|
|
mu sync.Mutex
|
|
actions []PinAction
|
|
}
|
|
|
|
func (f *movePinFake) VerifyPin(_ context.Context, _ uuid.UUID, pin string, action PinAction, _ models.CustomerPinRequestInfo) error {
|
|
f.mu.Lock()
|
|
f.actions = append(f.actions, action)
|
|
f.mu.Unlock()
|
|
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", "6281234561234")
|
|
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", "6281234561234")
|
|
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", "6281234561234")
|
|
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", "6281234561234")
|
|
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", "6281234561234")
|
|
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", "6281234561234")
|
|
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", "6281234561234")
|
|
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", "6281234561234")
|
|
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", "6281234561234")
|
|
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)
|
|
}
|
|
|
|
// F4: EnakPoint from an exchange expire at the sooner of the EnakCoin lot's expiry and
|
|
// when EnakPoint received now would expire.
|
|
func TestWalletExchange_CappedByThePointExpiry(t *testing.T) {
|
|
e := newWalletMoveEnv(t)
|
|
e.now = wib(2026, 6, 1, 10, 0)
|
|
e.settings.PointExpiry = rolling(30, "DAY", false)
|
|
c := e.member("Budi", "6281234561234")
|
|
soon, later := e.at(24*time.Hour), e.at(90*24*time.Hour)
|
|
e.earnCoins(t, c, 10, soon)
|
|
e.earnCoins(t, c, 10, later)
|
|
e.earnCoins(t, c, 10, nil)
|
|
|
|
res, err := e.exchanges().Exchange(e.ctx, c, 30, "482913", "key-1", models.CustomerPinRequestInfo{})
|
|
require.NoError(t, err)
|
|
require.Len(t, res.Lots, 3)
|
|
assert.Equal(t, soon, res.Lots[0].ExpiresAt, "the EnakCoin lot expires first")
|
|
assert.Equal(t, "2026-07-01", expiryDay(t, res.Lots[1].ExpiresAt), "30 days of EnakPoint come first")
|
|
assert.Equal(t, "2026-07-01", expiryDay(t, res.Lots[2].ExpiresAt), "a lot that never expired gets the EnakPoint expiry")
|
|
}
|