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