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>
478 lines
22 KiB
Go
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
|
|
}
|