Reapply "feat(loyalty): EnakPoint & EnakCoin" (#32)
This reverts commit 4e24f9bbb0.
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// 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", "081234561234")
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user