Files
apskel-pos-backend/internal/processor/wallet_exchange_processor_test.go
T
efrilmandClaude Opus 5.5 bf9651e221 feat(loyalty): notify transfer recipients through FCM
The recipient of a transfer now gets a push through FCM instead of a
WhatsApp message (docs/prd-point-coin.md F5).

Customers had nowhere to keep FCM tokens: user_devices only holds staff
devices. Migration 000096 adds customer_devices, and the customer app
registers with PUT /customer/devices { device_id, fcm_token, platform,
app_version } after login and whenever FCM refreshes the token, and
unregisters with DELETE /customer/devices/:device_id on logout. A token
belongs to one customer only: registering it takes it away from whoever
had it on that phone before, so they stop getting this customer's
notifications.

The push goes to every device of the recipient after the commit, titled
"EnakPoint masuk" or "EnakCoin masuk", with type WALLET_TRANSFER_IN, the
TRANSFER_IN transaction id, the group id, the currency and the amount in
its data so the app can open it. A retried transfer sends nothing again. It
stays best effort: no device, FCM not configured or FCM failing is logged
and never undoes the transfer.

The app builds one FCM client and shares it between staff notifications
and customer pushes.

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

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(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.
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)
}