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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2c9753fae7
commit
798a36bd6c
@@ -0,0 +1,289 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"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"
|
||||
)
|
||||
|
||||
// EnakGame events against Postgres (docs/rfc-enakgame.md §5.5, §7.2 step 7). Needs
|
||||
// TEST_DATABASE_URL; see internal/repository/wallet_repository_test.go.
|
||||
|
||||
func (e *enakGameEnv) eventBudget(org uuid.UUID, name string) *models.GameBudget {
|
||||
e.t.Helper()
|
||||
b, err := e.budgets.CreateBudget(e.ctx(), org, e.manager, models.GameBudgetInput{
|
||||
Scope: "EVENT", Name: name, PeriodStart: "2026-01-01", PeriodEnd: "2026-12-31", Amount: 10_000_000,
|
||||
})
|
||||
require.NoError(e.t, err)
|
||||
return b
|
||||
}
|
||||
|
||||
func (e *enakGameEnv) event(in models.GameEventInput) *models.GameEvent {
|
||||
e.t.Helper()
|
||||
if in.Name == "" {
|
||||
in.Name = "Event"
|
||||
}
|
||||
if in.Slug == "" {
|
||||
in.Slug = "event-" + uuid.NewString()[:8]
|
||||
}
|
||||
if in.StartAt.IsZero() {
|
||||
in.StartAt, in.EndAt = e.now.Add(-time.Hour), e.now.Add(24*time.Hour)
|
||||
}
|
||||
if in.Status == "" {
|
||||
in.Status = constants.GameEventStatusActive
|
||||
}
|
||||
ev, err := e.events.CreateEvent(e.ctx(), e.orgA, e.manager, in)
|
||||
require.NoError(e.t, err)
|
||||
return ev
|
||||
}
|
||||
|
||||
// EG-702
|
||||
func TestGameEventAdmin(t *testing.T) {
|
||||
e := newEnakGameEnv(t)
|
||||
ctx := e.ctx()
|
||||
game := e.playableGame(e.orgA, "runner", 1)
|
||||
theirGame := e.playableGame(e.orgB, "theirs", 1)
|
||||
budget := e.eventBudget(e.orgA, "Ramadan")
|
||||
theirBudget := e.eventBudget(e.orgB, "Theirs")
|
||||
var global string
|
||||
require.NoError(t, e.db.Raw(`SELECT id::text FROM game_budgets WHERE organization_id = ? AND scope = 'GLOBAL'`, e.orgA).Scan(&global).Error)
|
||||
valid := func() models.GameEventInput {
|
||||
return models.GameEventInput{Name: "Ramadan", Slug: "ramadan", StartAt: e.now, EndAt: e.now.Add(time.Hour),
|
||||
Multiplier: ptr(2.0), BudgetID: budget.ID, GameIDs: []uuid.UUID{game.ID}}
|
||||
}
|
||||
|
||||
for name, mutate := range map[string]func(*models.GameEventInput){
|
||||
"global budget": func(in *models.GameEventInput) { in.BudgetID = uuid.MustParse(global) },
|
||||
"another org's budget": func(in *models.GameEventInput) { in.BudgetID = theirBudget.ID },
|
||||
"another org's game": func(in *models.GameEventInput) { in.GameIDs = []uuid.UUID{theirGame.ID} },
|
||||
"no games": func(in *models.GameEventInput) { in.GameIDs = nil },
|
||||
"ends before start": func(in *models.GameEventInput) { in.EndAt = in.StartAt },
|
||||
"nothing to add": func(in *models.GameEventInput) { in.Multiplier = ptr(1.0) },
|
||||
"multiplier below 1": func(in *models.GameEventInput) { in.Multiplier = ptr(0.5) },
|
||||
"three decimals": func(in *models.GameEventInput) { in.Multiplier = ptr(1.125) },
|
||||
"zero bonus": func(in *models.GameEventInput) { in.Bonus = ptr(int64(0)) },
|
||||
"unknown timezone": func(in *models.GameEventInput) { in.Timezone = "Mars/Olympus" },
|
||||
"ended on create": func(in *models.GameEventInput) { in.Status = "ENDED" },
|
||||
} {
|
||||
in := valid()
|
||||
mutate(&in)
|
||||
_, err := e.events.CreateEvent(ctx, e.orgA, e.manager, in)
|
||||
assert.ErrorIs(t, err, ErrEnakGameRejected, name)
|
||||
}
|
||||
|
||||
ev, err := e.events.CreateEvent(ctx, e.orgA, e.manager, valid())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, constants.GameEventStatusDraft, ev.Status)
|
||||
assert.Equal(t, "Asia/Jakarta", ev.Timezone)
|
||||
assert.Equal(t, []uuid.UUID{game.ID}, ev.GameIDs)
|
||||
_, err = e.events.CreateEvent(ctx, e.orgA, e.manager, valid())
|
||||
assert.ErrorIs(t, err, ErrEnakGameRejected, "slug in use")
|
||||
_, err = e.events.GetEvent(ctx, e.orgB, ev.ID)
|
||||
assert.ErrorIs(t, err, repository.ErrGameEventNotFound)
|
||||
|
||||
in := GameEventInputFrom(ev)
|
||||
in.Bonus = ptr(int64(5))
|
||||
updated, err := e.events.UpdateEvent(ctx, e.orgA, e.manager, ev.ID, in)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 5, *updated.Bonus)
|
||||
assert.EqualValues(t, 2, *updated.Multiplier)
|
||||
|
||||
for _, step := range []struct {
|
||||
to string
|
||||
ok bool
|
||||
}{{"ENDED", false}, {"ACTIVE", true}, {"DRAFT", false}, {"ENDED", true}, {"ACTIVE", false}, {"CANCELLED", false}} {
|
||||
_, err := e.events.SetEventStatus(ctx, e.orgA, e.manager, ev.ID, models.GameEventStatusInput{Status: step.to})
|
||||
if step.ok {
|
||||
assert.NoError(t, err, step.to)
|
||||
} else {
|
||||
assert.ErrorIs(t, err, ErrEnakGameRejected, step.to)
|
||||
}
|
||||
}
|
||||
_, err = e.events.UpdateEvent(ctx, e.orgA, e.manager, ev.ID, in)
|
||||
assert.ErrorIs(t, err, ErrEnakGameRejected, "an ended event never changes")
|
||||
assert.Equal(t, []string{"CREATED", "UPDATED", "STATUS_CHANGED", "STATUS_CHANGED"}, e.auditActions(constants.AuditEntityGameEvent, ev.ID))
|
||||
|
||||
// A budget an event points at cannot be deleted.
|
||||
assert.ErrorIs(t, e.budgets.DeleteBudget(ctx, e.orgA, e.manager, budget.ID), ErrEnakGameRejected)
|
||||
}
|
||||
|
||||
// EG-703, EG-704
|
||||
func TestGameEvent_RewardsAndLimits(t *testing.T) {
|
||||
e := newEnakGameEnv(t)
|
||||
ctx := e.ctx()
|
||||
ten := e.gameWith("ten", entities.GameResultRules{}, constants.GameRewardTypeFixed, `{"amount": 10}`, 0)
|
||||
other := e.gameWith("other", entities.GameResultRules{}, constants.GameRewardTypeFixed, `{"amount": 10}`, 0)
|
||||
e.coins(e.alice, 100, nil)
|
||||
e.coins(e.bob, 100, nil)
|
||||
var global string
|
||||
require.NoError(t, e.db.Raw(`SELECT id::text FROM game_budgets WHERE organization_id = ? AND scope = 'GLOBAL'`, e.orgA).Scan(&global).Error)
|
||||
play := func(customer, gameID uuid.UUID) (*models.GameSessionCompletion, uuid.UUID) {
|
||||
t.Helper()
|
||||
s, err := e.sessions.Start(ctx, customer, gameID, uuid.NewString())
|
||||
require.NoError(t, err)
|
||||
out, err := e.sessions.Complete(ctx, customer, s.SessionID, models.GameSessionCompleteInput{})
|
||||
require.NoError(t, err)
|
||||
return out, s.SessionID
|
||||
}
|
||||
rewards := func(session uuid.UUID) map[string]int64 {
|
||||
t.Helper()
|
||||
var rows []struct {
|
||||
BudgetID string
|
||||
Amount int64
|
||||
Ledger int64
|
||||
}
|
||||
require.NoError(t, e.db.Raw(`SELECT r.budget_id::text AS budget_id, r.amount, t.amount AS ledger
|
||||
FROM game_session_rewards r JOIN wallet_transactions t ON t.id = r.wallet_transaction_id
|
||||
WHERE r.session_id = ? AND t.type = 'GAME_REWARD'`, session).Scan(&rows).Error)
|
||||
out := map[string]int64{}
|
||||
for _, row := range rows {
|
||||
assert.Equal(t, row.Amount, row.Ledger)
|
||||
out[row.BudgetID] = row.Amount
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PRD §7: 10 Coin with Ramadan 2x is two GAME_REWARD rows, 10 from each budget.
|
||||
ramadanBudget := e.eventBudget(e.orgA, "Ramadan")
|
||||
ramadan := e.event(models.GameEventInput{Name: "Ramadan", Multiplier: ptr(2.0), BudgetID: ramadanBudget.ID, GameIDs: []uuid.UUID{ten.ID}, Priority: 10})
|
||||
out, id := play(e.alice, ten.ID)
|
||||
assert.EqualValues(t, 20, out.RewardTotal)
|
||||
assert.Equal(t, models.GameSessionRewardParts{Base: 10, Event: 10}, out.Reward)
|
||||
assert.Equal(t, map[string]int64{global: 10, ramadanBudget.ID.String(): 10}, rewards(id))
|
||||
|
||||
// The other game is not in the event.
|
||||
out, id = play(e.alice, other.ID)
|
||||
assert.EqualValues(t, 10, out.RewardTotal)
|
||||
assert.Equal(t, map[string]int64{global: 10}, rewards(id))
|
||||
|
||||
// Events that are not running change nothing: not started, over, a draft.
|
||||
later := e.eventBudget(e.orgA, "Later")
|
||||
e.event(models.GameEventInput{Bonus: ptr(int64(50)), BudgetID: later.ID, GameIDs: []uuid.UUID{other.ID}, StartAt: e.now.Add(time.Hour), EndAt: e.now.Add(2 * time.Hour)})
|
||||
e.event(models.GameEventInput{Bonus: ptr(int64(50)), BudgetID: later.ID, GameIDs: []uuid.UUID{other.ID}, StartAt: e.now.Add(-2 * time.Hour), EndAt: e.now.Add(-time.Minute)})
|
||||
e.event(models.GameEventInput{Bonus: ptr(int64(50)), BudgetID: later.ID, GameIDs: []uuid.UUID{other.ID}, Status: "DRAFT"})
|
||||
out, _ = play(e.alice, other.ID)
|
||||
assert.EqualValues(t, 10, out.RewardTotal)
|
||||
|
||||
// The customer list shows the event on its game only.
|
||||
games, err := e.sessions.ListGames(ctx, e.alice)
|
||||
require.NoError(t, err)
|
||||
for _, g := range games {
|
||||
if g.ID == ten.ID {
|
||||
require.Len(t, g.Events, 1)
|
||||
assert.Equal(t, "Ramadan", g.Events[0].Name)
|
||||
assert.EqualValues(t, 2, *g.Events[0].Multiplier)
|
||||
assert.Equal(t, ramadan.EndAt.Unix(), g.Events[0].EndAt.Unix())
|
||||
} else {
|
||||
assert.Empty(t, g.Events, g.Slug)
|
||||
}
|
||||
}
|
||||
|
||||
// A second event on the same game: its bonus is counted on its own; its limit over
|
||||
// the whole event cuts it, then a customer's daily limit within it.
|
||||
bonusBudget := e.eventBudget(e.orgA, "Bonus")
|
||||
bonus := e.event(models.GameEventInput{Name: "Bonus", Bonus: ptr(int64(5)), BudgetID: bonusBudget.ID, GameIDs: []uuid.UUID{ten.ID},
|
||||
RewardLimit: ptr(int64(12)), UserDailyLimit: ptr(int64(7))})
|
||||
out, id = play(e.bob, ten.ID)
|
||||
assert.EqualValues(t, 25, out.RewardTotal, "10 + 10 + 5")
|
||||
assert.Equal(t, map[string]int64{global: 10, ramadanBudget.ID.String(): 10, bonusBudget.ID.String(): 5}, rewards(id))
|
||||
out, _ = play(e.bob, ten.ID)
|
||||
assert.EqualValues(t, 22, out.RewardTotal, "Bob has 2 left of his 7 today")
|
||||
assert.Equal(t, []string{"EVENT_USER_DAILY"}, out.LimitedBy)
|
||||
out, _ = play(e.alice, ten.ID)
|
||||
assert.EqualValues(t, 25, out.RewardTotal, "Alice has her own 7")
|
||||
out, _ = play(e.alice, ten.ID)
|
||||
assert.EqualValues(t, 20, out.RewardTotal, "the event's 12 are given out: 5 + 2 + 5")
|
||||
assert.Equal(t, []string{"EVENT_LIMIT"}, out.LimitedBy)
|
||||
counter := repository.NewGameRewardCounterRepository(e.db)
|
||||
got, err := counter.Get(ctx, e.orgA, constants.GameRewardScopeEvent, bonus.ID, eventLifetimeDay)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 12, got)
|
||||
|
||||
// The daily limit of the customer cuts events before the base, and the event
|
||||
// counters give back what was cut.
|
||||
carla := e.newCustomer(e.orgA)
|
||||
e.coins(carla, 10, nil)
|
||||
settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(e.db), e.txm)
|
||||
t.Cleanup(func() {
|
||||
e.db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, e.orgA)
|
||||
e.db.Exec(`DELETE FROM organization_settings WHERE organization_id = ?`, e.orgA)
|
||||
})
|
||||
s, err := settings.Organization(ctx, e.orgA)
|
||||
require.NoError(t, err)
|
||||
s.EnakGame.UserDailyLimit = 14
|
||||
_, _, err = settings.UpdateOrganization(ctx, e.orgA, e.manager, *s)
|
||||
require.NoError(t, err)
|
||||
before, err := counter.Get(ctx, e.orgA, constants.GameRewardScopeEvent, ramadan.ID, eventLifetimeDay)
|
||||
require.NoError(t, err)
|
||||
out, id = play(carla, ten.ID)
|
||||
assert.EqualValues(t, 14, out.RewardTotal)
|
||||
assert.Equal(t, models.GameSessionRewardParts{Base: 10, Event: 4}, out.Reward, "the base first, the event gets what is left")
|
||||
assert.Equal(t, []string{"EVENT_LIMIT", "USER_DAILY"}, out.LimitedBy, "Bonus is used up; then her daily 14")
|
||||
assert.Equal(t, map[string]int64{global: 10, ramadanBudget.ID.String(): 4}, rewards(id))
|
||||
after, err := counter.Get(ctx, e.orgA, constants.GameRewardScopeEvent, ramadan.ID, eventLifetimeDay)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 4, after-before, "the event counter holds what was paid")
|
||||
|
||||
// D5: EnakPoint from the event's part of a reward cost the event's budget when
|
||||
// redeemed, the base part the global budget.
|
||||
s.EnakGame.UserDailyLimit = 0
|
||||
_, _, err = settings.UpdateOrganization(ctx, e.orgA, e.manager, *s)
|
||||
require.NoError(t, err)
|
||||
dina := e.newCustomer(e.orgA)
|
||||
e.coins(dina, 2, nil)
|
||||
_, id = play(dina, ten.ID)
|
||||
assert.Equal(t, map[string]int64{global: 10, ramadanBudget.ID.String(): 10}, rewards(id), "Bonus is used up")
|
||||
e.exchangeCoins(dina, 20)
|
||||
voucher := e.staticVoucher(5, 2_000, 20)
|
||||
res, err := e.redeemVoucher(dina, voucher.ID, "event-cost")
|
||||
require.NoError(t, err)
|
||||
r := ramadanBudget.ID.String()
|
||||
assert.ElementsMatch(t, []costRow{
|
||||
{BudgetID: &global, SourceType: "GAME_REWARD", Points: 10, Cost: 1_000},
|
||||
{BudgetID: &r, SourceType: "GAME_REWARD", Points: 10, Cost: 1_000},
|
||||
}, e.costs(res.ID))
|
||||
}
|
||||
|
||||
// Customers completing at once never take an event past its limit.
|
||||
func TestGameEvent_LimitHoldsUnderConcurrency(t *testing.T) {
|
||||
e := newEnakGameEnv(t)
|
||||
ctx := e.ctx()
|
||||
game := e.gameWith("rush", entities.GameResultRules{}, constants.GameRewardTypeFixed, `{"amount": 10}`, 0)
|
||||
budget := e.eventBudget(e.orgA, "Rush")
|
||||
e.event(models.GameEventInput{Bonus: ptr(int64(5)), BudgetID: budget.ID, GameIDs: []uuid.UUID{game.ID}, RewardLimit: ptr(int64(12))})
|
||||
|
||||
sessions := map[uuid.UUID]uuid.UUID{}
|
||||
for i := 0; i < 6; i++ {
|
||||
c := e.newCustomer(e.orgA)
|
||||
e.coins(c, 2, nil)
|
||||
s, err := e.sessions.Start(ctx, c, game.ID, "rush")
|
||||
require.NoError(t, err)
|
||||
sessions[c] = s.SessionID
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
begin := make(chan struct{})
|
||||
for c, id := range sessions {
|
||||
c, id := c, id
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-begin
|
||||
_, err := e.sessions.Complete(ctx, c, id, models.GameSessionCompleteInput{})
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
}
|
||||
close(begin)
|
||||
wg.Wait()
|
||||
assert.Equal(t, int64(12), e.count(`SELECT COALESCE(SUM(amount), 0) FROM game_session_rewards WHERE budget_id = ?`, budget.ID))
|
||||
assert.Equal(t, int64(60), e.count(`SELECT COALESCE(SUM(r.amount), 0) FROM game_session_rewards r JOIN game_budgets b ON b.id = r.budget_id
|
||||
WHERE b.scope = 'GLOBAL' AND r.session_id IN (SELECT id FROM game_sessions WHERE game_id = ?)`, game.ID), "every base paid")
|
||||
}
|
||||
Reference in New Issue
Block a user