Files
apskel-pos-backend/internal/processor/voucher_db_test.go
T
efrilmandClaude Opus 5.5 798a36bd6c feat(enakgame): game sessions, rewards, vouchers, budgets and events
EnakGame phases 1-8 of docs/tasks-enakgame.md (EG-101 to EG-803), built on the
existing EnakPoint/EnakCoin wallet (docs/rfc-enakgame.md).

Foundation (phase 1)
- Migrations 000103-000106: games extended with organization, slug, status,
  entry cost and result rules, old games archived (not deleted); budgets,
  versioned reward configs, sessions and session rewards; the ledger types
  GAME_SPEND_REFUND, GAME_REWARD and REWARD_REDEEM_REFUND; audit_logs.
- AuditLogger writes in the caller's transaction only.
- enakgame.limit.user_daily and global_daily organization settings.

Games and sessions (phases 2-4)
- Admin /marketing/enakgame: games, reward config versions (immutable but for
  status, one ACTIVE per game), budgets with non-overlapping global periods and
  a daily job opening the next month.
- Customer /customer/enakgame: start (Idempotency-Key, entry cost and config
  frozen on the session), complete (result validation, reward engine, max_reward
  cap, daily limits via game_reward_counters, one GAME_REWARD per budget),
  automatic refunds for system errors and deactivated games, and a session job.
- Reward engine: FIXED, SCORE_BASED, OUTCOME_BASED, PROBABILITY (crypto/rand),
  rounded down.

Vouchers and budgets (phases 5-6)
- Migration 000108 and 000107: vouchers, codes, redemptions, cost attribution;
  Economy Guard counters.
- STATIC and CODE_POOL redemption in one transaction with the REDEEM PIN action;
  realized cost traced through the lots to the budget that paid the reward.
- Budget metrics: realized cost, forecast, exposure and status. Migrations
  000109-000110 add the wallet_lots indexes they need, built CONCURRENTLY.

Events (phase 7)
- Migration 000111: game events, each with its own EVENT budget. Event extras
  stack per PRD §16 defaults, with event and per-customer limits.

External vouchers (phase 8)
- VoucherProvider contract, two-step PENDING redemption and a recovery job,
  tested with a fake provider. No provider adapter is registered yet, so
  EXTERNAL vouchers stay out of the catalog.

Not yet decided before release: reward rounding, event stacking, budget
exhaustion policy and thresholds (RFC §19.2). Migrations 000103-000111 have
not been run on any shared database.

Also fixes a leftover PAYMENT filter in a wallet test and a data race in a
test PIN fake.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:53:14 +07:00

478 lines
22 KiB
Go

package processor
import (
"context"
"encoding/json"
"fmt"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// EnakGame vouchers and redemptions against Postgres (docs/rfc-enakgame.md §7.4, §7.6).
// Needs TEST_DATABASE_URL; see internal/repository/wallet_repository_test.go.
func (e *enakGameEnv) voucher(org uuid.UUID, in models.VoucherInput) *models.Voucher {
e.t.Helper()
if in.Name == "" {
in.Name = "Voucher"
}
if in.VoucherType == "" {
in.VoucherType = constants.VoucherTypeFixedValue
}
if in.Status == "" {
in.Status = constants.VoucherStatusActive
}
v, err := e.vouchers.CreateVoucher(e.ctx(), org, e.manager, in)
require.NoError(e.t, err)
return v
}
func (e *enakGameEnv) staticVoucher(stock, faceValue, pointCost int64) *models.Voucher {
return e.voucher(e.orgA, models.VoucherInput{FaceValue: faceValue, PointCost: pointCost, StockMode: constants.VoucherStockStatic, Stock: &stock})
}
// points gives a customer EnakPoint that did not come from EnakGame.
func (e *enakGameEnv) points(customer uuid.UUID, txType string, amount int64) {
e.t.Helper()
entry := WalletEntry{
CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: txType, Amount: amount,
ReferenceID: uuid.New(), Description: txType,
}
switch txType {
case constants.WalletTxTypeEarn:
entry.ReferenceType, entry.OutletID = constants.WalletRefTypeOrder, ptr(uuid.New())
default:
entry.ReferenceType, entry.CreatedByUser, entry.Reason = constants.WalletRefTypeUser, &e.manager, ptr("test")
}
require.NoError(e.t, e.txm.WithTransaction(e.ctx(), func(ctx context.Context) error {
_, err := e.wallet.Credit(ctx, WalletCreditInput{WalletEntry: entry})
return err
}))
}
// rewardCoins plays a FIXED game of org A paying amount and returns the global budget
// that paid it.
func (e *enakGameEnv) rewardCoins(customer uuid.UUID, amount int64) uuid.UUID {
e.t.Helper()
slug := fmt.Sprintf("pay-%d-%s", amount, uuid.NewString()[:8])
game := e.gameWith(slug, entities.GameResultRules{}, constants.GameRewardTypeFixed, fmt.Sprintf(`{"amount": %d}`, amount), 0)
e.coins(customer, 2, nil)
started, err := e.sessions.Start(e.ctx(), customer, game.ID, slug)
require.NoError(e.t, err)
out, err := e.sessions.Complete(e.ctx(), customer, started.SessionID, models.GameSessionCompleteInput{})
require.NoError(e.t, err)
require.EqualValues(e.t, amount, out.RewardTotal)
var budget string
require.NoError(e.t, e.db.Raw(`SELECT budget_id::text FROM game_session_rewards WHERE session_id = ?`, started.SessionID).Scan(&budget).Error)
return uuid.MustParse(budget)
}
func (e *enakGameEnv) exchangeCoins(customer uuid.UUID, coins int64) {
e.t.Helper()
_, err := e.exchange.Exchange(e.ctx(), customer, coins, testPin, uuid.NewString(), models.CustomerPinRequestInfo{})
require.NoError(e.t, err)
}
func (e *enakGameEnv) pointBalance(customer uuid.UUID) int64 {
e.t.Helper()
var balance int64
require.NoError(e.t, e.db.Raw(`SELECT COALESCE((SELECT point_balance FROM customer_wallets WHERE customer_id = ?), 0)`, customer).Scan(&balance).Error)
return balance
}
func (e *enakGameEnv) redeemVoucher(customer, voucherID uuid.UUID, key string) (*models.VoucherRedeemResult, error) {
return e.redeem.Redeem(e.ctx(), customer, voucherID, testPin, key, models.CustomerPinRequestInfo{})
}
type costRow struct {
BudgetID *string
SourceType string
Points int64
Cost int64
}
func (e *enakGameEnv) costs(redemptionID uuid.UUID) []costRow {
e.t.Helper()
var rows []costRow
require.NoError(e.t, e.db.Raw(`SELECT budget_id::text AS budget_id, source_type, points, cost FROM voucher_redemption_costs
WHERE redemption_id = ? ORDER BY cost DESC`, redemptionID).Scan(&rows).Error)
return rows
}
// EG-502
func TestVoucherAdmin(t *testing.T) {
e := newEnakGameEnv(t)
ctx := e.ctx()
for name, in := range map[string]models.VoucherInput{
"no name": {VoucherType: "FIXED_VALUE", FaceValue: 1, PointCost: 1, StockMode: "STATIC", Stock: ptr(int64(1))},
"unknown type": {Name: "v", VoucherType: "CASHBACK", FaceValue: 1, PointCost: 1, StockMode: "STATIC", Stock: ptr(int64(1))},
"no face value": {Name: "v", VoucherType: "FIXED_VALUE", PointCost: 1, StockMode: "STATIC", Stock: ptr(int64(1))},
"no point cost": {Name: "v", VoucherType: "FIXED_VALUE", FaceValue: 1, StockMode: "STATIC", Stock: ptr(int64(1))},
"static no stock": {Name: "v", VoucherType: "FIXED_VALUE", FaceValue: 1, PointCost: 1, StockMode: "STATIC"},
"pool with stock": {Name: "v", VoucherType: "FIXED_VALUE", FaceValue: 1, PointCost: 1, StockMode: "CODE_POOL", Stock: ptr(int64(1))},
"external no provdr": {Name: "v", VoucherType: "FIXED_VALUE", FaceValue: 1, PointCost: 1, StockMode: "EXTERNAL"},
"max per customer 0": {Name: "v", VoucherType: "FIXED_VALUE", FaceValue: 1, PointCost: 1, StockMode: "CODE_POOL", MaxPerCustomer: ptr(0)},
"ends before it ends": {Name: "v", VoucherType: "FIXED_VALUE", FaceValue: 1, PointCost: 1, StockMode: "CODE_POOL", ValidFrom: ptr(e.now), ValidUntil: ptr(e.now)},
"terms not an object": {Name: "v", VoucherType: "FIXED_VALUE", FaceValue: 1, PointCost: 1, StockMode: "CODE_POOL", Terms: json.RawMessage(`[1]`)},
"archived on create": {Name: "v", VoucherType: "FIXED_VALUE", FaceValue: 1, PointCost: 1, StockMode: "CODE_POOL", Status: "ARCHIVED"},
} {
_, err := e.vouchers.CreateVoucher(ctx, e.orgA, e.manager, in)
assert.ErrorIs(t, err, ErrEnakGameRejected, name)
}
pool := e.voucher(e.orgA, models.VoucherInput{Name: "Kopi", FaceValue: 20_000, PointCost: 15_000, StockMode: "code_pool",
Terms: json.RawMessage(`{"outlet": "semua"}`), Status: "DRAFT"})
assert.Equal(t, constants.VoucherStockCodePool, pool.StockMode)
assert.JSONEq(t, `{"outlet": "semua"}`, string(pool.Terms))
// Importing the same file twice adds nothing the second time.
csv := []byte("code,expires_at\nKOPI-1,2030-12-31\nKOPI-2\nKOPI-2\n,\nKOPI-3,31/12/2030\nKOPI-4\n")
result, err := e.vouchers.ImportCodes(ctx, e.orgA, e.manager, pool.ID, csv)
require.NoError(t, err)
assert.Equal(t, 3, result.Imported)
assert.Equal(t, []string{"KOPI-2"}, result.Duplicates, "repeated in the file")
require.Len(t, result.Invalid, 1)
assert.Equal(t, 6, result.Invalid[0].Line)
result, err = e.vouchers.ImportCodes(ctx, e.orgA, e.manager, pool.ID, csv)
require.NoError(t, err)
assert.Zero(t, result.Imported)
assert.Equal(t, 4, result.DuplicateCount)
codes, err := e.vouchers.Codes(ctx, e.orgA, pool.ID, models.VoucherCodeListQuery{})
require.NoError(t, err)
assert.Equal(t, map[string]int64{"AVAILABLE": 3}, codes.Counts)
require.Len(t, codes.Codes.Data, 3)
var expiring *time.Time
for _, c := range codes.Codes.Data {
if c.Code == "KOPI-1" {
expiring = c.ExpiresAt
}
}
require.NotNil(t, expiring)
assert.Equal(t, "2030-12-31 23:59:59", expiring.In(walletDisplayLocation).Format("2006-01-02 15:04:05"), "the end of that day in Jakarta")
static := e.staticVoucher(5, 10_000, 8_000)
_, err = e.vouchers.ImportCodes(ctx, e.orgA, e.manager, static.ID, csv)
assert.ErrorIs(t, err, ErrEnakGameRejected, "a STATIC voucher holds no codes")
_, err = e.vouchers.ImportCodes(ctx, e.orgB, e.manager, pool.ID, csv)
assert.ErrorIs(t, err, repository.ErrVoucherNotFound, "another organization's voucher")
_, err = e.vouchers.GetVoucher(ctx, e.orgB, pool.ID)
assert.ErrorIs(t, err, repository.ErrVoucherNotFound)
in := VoucherInputFrom(static)
in.Stock, in.PointCost = ptr(int64(9)), 7_500
updated, err := e.vouchers.UpdateVoucher(ctx, e.orgA, e.manager, static.ID, in)
require.NoError(t, err)
assert.EqualValues(t, 9, *updated.Stock)
in.StockMode = constants.VoucherStockCodePool
_, err = e.vouchers.UpdateVoucher(ctx, e.orgA, e.manager, static.ID, in)
assert.ErrorIs(t, err, ErrEnakGameRejected, "the stock mode stays")
_, err = e.vouchers.SetVoucherStatus(ctx, e.orgA, e.manager, static.ID, models.VoucherStatusInput{Status: "ARCHIVED"})
require.NoError(t, err)
_, err = e.vouchers.UpdateVoucher(ctx, e.orgA, e.manager, static.ID, VoucherInputFrom(updated))
assert.ErrorIs(t, err, ErrEnakGameRejected)
list, err := e.vouchers.ListVouchers(ctx, e.orgA, models.VoucherListQuery{})
require.NoError(t, err)
assert.Len(t, list.Data, 1, "archived left out")
assert.Equal(t, []string{"CREATED", "UPDATED", "STATUS_CHANGED"}, e.auditActions(constants.AuditEntityVoucher, static.ID))
assert.Equal(t, []string{"CREATED", "CODES_IMPORTED", "CODES_IMPORTED"}, e.auditActions(constants.AuditEntityVoucher, pool.ID))
}
// EG-504
func TestVoucherRedemption(t *testing.T) {
e := newEnakGameEnv(t)
ctx := e.ctx()
e.points(e.alice, constants.WalletTxTypeAdjustment, 100_000)
e.points(e.bob, constants.WalletTxTypeAdjustment, 100_000)
t.Run("STATIC: the last one goes to exactly one of two customers", func(t *testing.T) {
last := e.staticVoucher(1, 10_000, 8_000)
var wg sync.WaitGroup
results := make([]error, 2)
for i, customer := range []uuid.UUID{e.alice, e.bob} {
i, customer := i, customer
wg.Add(1)
go func() {
defer wg.Done()
_, results[i] = e.redeemVoucher(customer, last.ID, "last")
}()
}
wg.Wait()
won := 0
for _, err := range results {
if err == nil {
won++
} else {
assert.ErrorIs(t, err, ErrVoucherRedemptionRejected)
}
}
assert.Equal(t, 1, won)
assert.Equal(t, int64(1), e.count(`SELECT COUNT(*) FROM voucher_redemptions WHERE voucher_id = ?`, last.ID))
assert.EqualValues(t, 200_000-8_000, e.pointBalance(e.alice)+e.pointBalance(e.bob), "only the winner paid")
assert.Zero(t, e.count(`SELECT stock FROM vouchers WHERE id = ?`, last.ID))
})
t.Run("CODE_POOL: redemptions at once get different codes", func(t *testing.T) {
pool := e.voucher(e.orgA, models.VoucherInput{FaceValue: 5_000, PointCost: 1_000, StockMode: constants.VoucherStockCodePool})
_, err := e.vouchers.ImportCodes(ctx, e.orgA, e.manager, pool.ID, []byte("A1\nA2\nA3\nA4\nA5\nA6"))
require.NoError(t, err)
var wg sync.WaitGroup
var mu sync.Mutex
got := map[string]int{}
for i := 0; i < 6; i++ {
customer := []uuid.UUID{e.alice, e.bob}[i%2]
key := fmt.Sprintf("pool-%d", i)
wg.Add(1)
go func() {
defer wg.Done()
res, err := e.redeemVoucher(customer, pool.ID, key)
if assert.NoError(t, err) {
mu.Lock()
got[*res.Code]++
mu.Unlock()
}
}()
}
wg.Wait()
assert.Len(t, got, 6, "six different codes")
for code, n := range got {
assert.Equal(t, 1, n, code)
}
_, err = e.redeemVoucher(e.alice, pool.ID, "pool-empty")
assert.ErrorIs(t, err, ErrVoucherRedemptionRejected, "the pool is empty")
})
t.Run("the same key twice: one debit, one code", func(t *testing.T) {
pool := e.voucher(e.orgA, models.VoucherInput{FaceValue: 5_000, PointCost: 1_000, StockMode: constants.VoucherStockCodePool})
_, err := e.vouchers.ImportCodes(ctx, e.orgA, e.manager, pool.ID, []byte("B1\nB2"))
require.NoError(t, err)
before := e.pointBalance(e.alice)
first, err := e.redeemVoucher(e.alice, pool.ID, "twice")
require.NoError(t, err)
assert.False(t, first.Replayed)
require.NotNil(t, first.Code)
assert.EqualValues(t, before-1_000, first.PointBalance)
// The voucher is turned off meanwhile; the retry still gets its code.
_, err = e.vouchers.SetVoucherStatus(ctx, e.orgA, e.manager, pool.ID, models.VoucherStatusInput{Status: "INACTIVE"})
require.NoError(t, err)
second, err := e.redeemVoucher(e.alice, pool.ID, "twice")
require.NoError(t, err)
assert.True(t, second.Replayed)
assert.Equal(t, first.ID, second.ID)
assert.Equal(t, *first.Code, *second.Code)
assert.EqualValues(t, before-1_000, e.pointBalance(e.alice))
assert.Equal(t, int64(1), e.count(`SELECT COUNT(*) FROM wallet_transactions WHERE customer_id = ? AND type = 'REWARD_REDEEM' AND reference_id = ?`, e.alice, first.ID))
_, err = e.redeemVoucher(e.alice, uuid.New(), "twice")
assert.ErrorIs(t, err, ErrVoucherRedemptionRejected, "the key redeemed another voucher")
})
t.Run("refusals record nothing", func(t *testing.T) {
pricey := e.staticVoucher(5, 10_000, 1_000_000)
limited := e.voucher(e.orgA, models.VoucherInput{FaceValue: 1_000, PointCost: 10, StockMode: "STATIC", Stock: ptr(int64(5)), MaxPerCustomer: ptr(1)})
later := e.voucher(e.orgA, models.VoucherInput{FaceValue: 1_000, PointCost: 10, StockMode: "STATIC", Stock: ptr(int64(5)), ValidFrom: ptr(e.now.Add(time.Hour))})
ended := e.voucher(e.orgA, models.VoucherInput{FaceValue: 1_000, PointCost: 10, StockMode: "STATIC", Stock: ptr(int64(5)), ValidUntil: ptr(e.now.Add(-time.Second))})
draft := e.voucher(e.orgA, models.VoucherInput{FaceValue: 1_000, PointCost: 10, StockMode: "STATIC", Stock: ptr(int64(5)), Status: "DRAFT"})
external := e.voucher(e.orgA, models.VoucherInput{FaceValue: 1_000, PointCost: 10, StockMode: "EXTERNAL", Provider: ptr("no-adapter")})
theirs := e.voucher(e.orgB, models.VoucherInput{FaceValue: 1_000, PointCost: 10, StockMode: "STATIC", Stock: ptr(int64(5))})
_, err := e.redeemVoucher(e.bob, limited.ID, "limit-1")
require.NoError(t, err)
count := func() int64 {
return e.count(`SELECT COUNT(*) FROM voucher_redemptions WHERE organization_id IN (?, ?)`, e.orgA, e.orgB) +
e.count(`SELECT COUNT(*) FROM wallet_transactions WHERE type = 'REWARD_REDEEM' AND organization_id IN (?, ?)`, e.orgA, e.orgB)
}
before, balance := count(), e.pointBalance(e.bob)
for name, tc := range map[string]struct {
voucher uuid.UUID
pin string
want error
}{
"not enough EnakPoint": {pricey.ID, testPin, ErrVoucherRedemptionRejected},
"max per customer": {limited.ID, testPin, ErrVoucherRedemptionRejected},
"not started": {later.ID, testPin, ErrVoucherRedemptionRejected},
"ended": {ended.ID, testPin, ErrVoucherRedemptionRejected},
"draft": {draft.ID, testPin, ErrVoucherRedemptionRejected},
"external, no adapter": {external.ID, testPin, ErrVoucherRedemptionRejected},
"another org": {theirs.ID, testPin, repository.ErrVoucherNotFound},
"wrong PIN": {limited.ID, "000000", nil},
} {
if name == "wrong PIN" {
// limited is used up for Bob; Alice has it free, so only the PIN stands in the way.
_, err := e.redeem.Redeem(ctx, e.alice, tc.voucher, tc.pin, "pin", models.CustomerPinRequestInfo{})
var pinErr *PinError
assert.ErrorAs(t, err, &pinErr, name)
continue
}
_, err := e.redeem.Redeem(ctx, e.bob, tc.voucher, tc.pin, "refused-"+name, models.CustomerPinRequestInfo{})
assert.ErrorIs(t, err, tc.want, name)
}
assert.Equal(t, before, count(), "no redemption, no ledger row")
assert.Equal(t, balance, e.pointBalance(e.bob))
assert.EqualValues(t, 5, e.count(`SELECT stock FROM vouchers WHERE id = ?`, pricey.ID), "the stock taken for a refused redemption is back")
assert.Zero(t, e.count(`SELECT pin_failed_attempts FROM customers WHERE id = ?`, e.bob), "a refusal before the PIN costs no attempt")
})
}
// EG-505
func TestVoucherRedemption_CostAttribution(t *testing.T) {
e := newEnakGameEnv(t)
voucher := e.staticVoucher(100, 10_000, 8_000)
t.Run("GAME_REWARD → exchange → redeem", func(t *testing.T) {
budget := e.rewardCoins(e.alice, 8_000)
e.exchangeCoins(e.alice, 8_000)
res, err := e.redeemVoucher(e.alice, voucher.ID, "chain-1")
require.NoError(t, err)
b := budget.String()
assert.Equal(t, []costRow{{BudgetID: &b, SourceType: "GAME_REWARD", Points: 8_000, Cost: 10_000}}, e.costs(res.ID))
})
t.Run("GAME_REWARD → transfer → exchange → redeem", func(t *testing.T) {
budget := e.rewardCoins(e.alice, 8_000)
// Alice sends the reward to Bob, who exchanges and redeems it.
require.NoError(t, e.txm.WithTransaction(e.ctx(), func(ctx context.Context) error {
require.NoError(t, e.wallet.LockWallets(ctx, e.alice, e.bob))
group, outID, inID := uuid.New(), uuid.New(), uuid.New()
out, err := e.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{
TransactionID: outID, CustomerID: e.alice, Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeTransferOut,
Amount: 8_000, ReferenceType: constants.WalletRefTypeWalletTx, ReferenceID: inID, GroupID: &group,
CounterpartyCustomerID: &e.bob, Description: "ke Bob",
}})
require.NoError(t, err)
_, err = e.wallet.Credit(ctx, WalletCreditInput{
WalletEntry: WalletEntry{
TransactionID: inID, CustomerID: e.bob, Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeTransferIn,
Amount: 8_000, ReferenceType: constants.WalletRefTypeWalletTx, ReferenceID: outID, GroupID: &group,
CounterpartyCustomerID: &e.alice, Description: "dari Alice",
},
Lots: out.CarryOver(),
})
return err
}))
e.exchangeCoins(e.bob, 8_000)
res, err := e.redeemVoucher(e.bob, voucher.ID, "chain-2")
require.NoError(t, err)
b := budget.String()
assert.Equal(t, []costRow{{BudgetID: &b, SourceType: "GAME_REWARD", Points: 8_000, Cost: 10_000}}, e.costs(res.ID))
})
t.Run("GAME_REWARD + EARN: only the reward counts against the budget", func(t *testing.T) {
// RFC §7.6: Rp10.000 for 8.000 EnakPoint, 6.000 from EnakGame and 2.000 from
// shopping: Rp7.500 to the budget, Rp2.500 to none.
dina := e.newCustomer(e.orgA)
budget := e.rewardCoins(dina, 6_000)
e.exchangeCoins(dina, 6_000)
e.points(dina, constants.WalletTxTypeEarn, 2_000)
res, err := e.redeemVoucher(dina, voucher.ID, "mixed")
require.NoError(t, err)
b := budget.String()
assert.Equal(t, []costRow{
{BudgetID: &b, SourceType: "GAME_REWARD", Points: 6_000, Cost: 7_500},
{BudgetID: nil, SourceType: "EARN", Points: 2_000, Cost: 2_500},
}, e.costs(res.ID))
var recognized time.Time
require.NoError(t, e.db.Raw(`SELECT recognized_at FROM voucher_redemption_costs WHERE redemption_id = ? LIMIT 1`, res.ID).Scan(&recognized).Error)
assert.WithinDuration(t, *res.CompletedAt, recognized, time.Millisecond, "recognized when the redemption completed")
})
t.Run("the costs add up to the face value when they do not divide evenly", func(t *testing.T) {
odd := e.staticVoucher(10, 1_000, 3)
eko := e.newCustomer(e.orgA)
budget := e.rewardCoins(eko, 1)
e.exchangeCoins(eko, 1)
e.points(eko, constants.WalletTxTypeEarn, 1)
e.points(eko, constants.WalletTxTypeAdjustment, 1)
res, err := e.redeemVoucher(eko, odd.ID, "odd")
require.NoError(t, err)
// 1.000 over three equal parts: 333 each, the rupiah left over to the first of
// equals, in source order.
b := budget.String()
assert.ElementsMatch(t, []costRow{
{BudgetID: nil, SourceType: "ADJUSTMENT", Points: 1, Cost: 334},
{BudgetID: nil, SourceType: "EARN", Points: 1, Cost: 333},
{BudgetID: &b, SourceType: "GAME_REWARD", Points: 1, Cost: 333},
}, e.costs(res.ID))
})
}
// newCustomer adds a customer with the test PIN to org.
func (e *enakGameEnv) newCustomer(org uuid.UUID) uuid.UUID {
e.t.Helper()
id := uuid.New()
hash, err := bcrypt.GenerateFromPassword([]byte(testPin), bcrypt.MinCost)
require.NoError(e.t, err)
require.NoError(e.t, e.db.Exec(`INSERT INTO customers (id, organization_id, name, pin_hash, pin_set_at) VALUES (?, ?, 'extra', ?, NOW())`, id, org, string(hash)).Error)
return id
}
// EG-506, EG-507
func TestVoucherCatalogRedemptionsAndExpiry(t *testing.T) {
e := newEnakGameEnv(t)
ctx := e.ctx()
e.points(e.alice, constants.WalletTxTypeAdjustment, 10_000)
static := e.voucher(e.orgA, models.VoucherInput{Name: "A Static", FaceValue: 10_000, PointCost: 100, StockMode: "STATIC", Stock: ptr(int64(3))})
pool := e.voucher(e.orgA, models.VoucherInput{Name: "B Pool", FaceValue: 5_000, PointCost: 200, StockMode: "CODE_POOL"})
_, err := e.vouchers.ImportCodes(ctx, e.orgA, e.manager, pool.ID, []byte("P1\nP2\nP3,2020-01-01"))
require.NoError(t, err)
e.voucher(e.orgA, models.VoucherInput{Name: "Draft", FaceValue: 1, PointCost: 1, StockMode: "STATIC", Stock: ptr(int64(1)), Status: "DRAFT"})
e.voucher(e.orgA, models.VoucherInput{Name: "External", FaceValue: 1, PointCost: 1, StockMode: "EXTERNAL", Provider: ptr("no-adapter")})
e.voucher(e.orgA, models.VoucherInput{Name: "Ended", FaceValue: 1, PointCost: 1, StockMode: "STATIC", Stock: ptr(int64(1)), ValidUntil: ptr(e.now.Add(-time.Hour))})
e.voucher(e.orgB, models.VoucherInput{Name: "Theirs", FaceValue: 1, PointCost: 1, StockMode: "STATIC", Stock: ptr(int64(1))})
catalog, err := e.redeem.Catalog(ctx, e.alice)
require.NoError(t, err)
require.Len(t, catalog, 2, "only ACTIVE vouchers of the organization within their dates that can be redeemed now")
byName := map[string]models.CustomerVoucher{}
for _, v := range catalog {
byName[v.Name] = v
}
assert.EqualValues(t, 3, *byName["A Static"].Available)
assert.EqualValues(t, 2, *byName["B Pool"].Available, "the expired code is not available")
raw, err := json.Marshal(catalog)
require.NoError(t, err)
for _, internal := range []string{"business_cost", "provider", "stock_mode", "REDEEMED", "EXPIRED", "counts"} {
assert.NotContains(t, string(raw), internal)
}
_, err = e.redeemVoucher(e.alice, static.ID, "s1")
require.NoError(t, err)
got, err := e.redeemVoucher(e.alice, pool.ID, "p1")
require.NoError(t, err)
list, err := e.redeem.ListRedemptions(ctx, e.alice, 1, 10)
require.NoError(t, err)
require.Len(t, list.Data, 2)
assert.Equal(t, got.ID, list.Data[0].ID, "newest first")
assert.Equal(t, *got.Code, *list.Data[0].Code)
assert.Contains(t, []string{"P1", "P2"}, *got.Code, "never the expired P3")
assert.Equal(t, "B Pool", list.Data[0].VoucherName)
assert.Nil(t, list.Data[1].Code, "a STATIC voucher has no code")
other, err := e.redeem.ListRedemptions(ctx, e.bob, 1, 10)
require.NoError(t, err)
assert.Empty(t, other.Data)
// The expiry job moves the expired code out of AVAILABLE, once.
expired, err := e.vouchers.ExpireCodes(ctx, e.now)
require.NoError(t, err)
assert.GreaterOrEqual(t, expired, int64(1))
codes, err := e.vouchers.Codes(ctx, e.orgA, pool.ID, models.VoucherCodeListQuery{})
require.NoError(t, err)
assert.Equal(t, map[string]int64{"AVAILABLE": 1, "REDEEMED": 1, "EXPIRED": 1}, codes.Counts)
expired, err = e.vouchers.ExpireCodes(ctx, e.now)
require.NoError(t, err)
assert.Zero(t, e.count(`SELECT COUNT(*) FROM voucher_codes WHERE voucher_id = ? AND status = 'AVAILABLE' AND expires_at <= NOW()`, pool.ID))
_ = expired
}