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:
efrilm
2026-10-07 20:53:14 +07:00
co-authored by Claude Opus 5.5
parent 2c9753fae7
commit 798a36bd6c
92 changed files with 12392 additions and 23 deletions
@@ -0,0 +1,61 @@
package processor
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
)
func TestBudgetMetrics(t *testing.T) {
october := &entities.GameBudget{
Scope: constants.GameBudgetScopeGlobal, Amount: 100_000_000,
PeriodStart: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), PeriodEnd: time.Date(2026, 10, 31, 0, 0, 0, 0, time.UTC),
}
today := time.Date(2026, 10, 21, 0, 0, 0, 0, time.UTC)
// PRD §30: Rp100M, Rp60M realized, 10 days left, burning Rp5,5M a day → Rp115M.
m := budgetMetrics(october, today, budgetMetricInputs{Realized: 60_000_000, RealizedWindow: 38_500_000, WindowDays: 7, RemainingDays: 10})
assert.EqualValues(t, 40_000_000, m.Remaining)
assert.EqualValues(t, 60, m.UtilizationPercent)
assert.EqualValues(t, 5_500_000, m.DailyBurn)
assert.EqualValues(t, 115_000_000, m.ForecastCost)
assert.EqualValues(t, -15_000_000, m.ForecastRemaining)
assert.EqualValues(t, 115, m.ForecastUtilizationPercent)
assert.Equal(t, constants.GameBudgetCritical, m.Status, "the forecast passes the budget")
assert.EqualValues(t, 70, *m.Thresholds.Warning, "the default when the budget sets none")
assert.EqualValues(t, 90, *m.Thresholds.Critical)
assert.Equal(t, "2026-10-21", m.AsOf)
for name, tc := range map[string]struct {
thresholds string
realized, burn int64
want string
}{
"quiet": {"", 10_000_000, 0, constants.GameBudgetHealthy},
"forecast at warning": {"", 30_000_000, 4_000_000, constants.GameBudgetWarning}, // 30 + 4 × 10 = 70%
"realized at critical": {"", 90_000_000, 0, constants.GameBudgetCritical}, // 90%
"spent": {"", 100_000_000, 0, constants.GameBudgetExhausted}, // 100%
"over": {"", 120_000_000, 0, constants.GameBudgetExhausted}, // 120%
"own thresholds": {`{"warning": 20, "critical": 50}`, 25_000_000, 0, constants.GameBudgetWarning},
"own critical only": {`{"critical": 30}`, 31_000_000, 0, constants.GameBudgetCritical},
"own warning above default": {`{"warning": 80}`, 75_000_000, 0, constants.GameBudgetHealthy},
} {
b := *october
b.Thresholds = entities.JSONDocument(tc.thresholds)
m := budgetMetrics(&b, today, budgetMetricInputs{Realized: tc.realized, RealizedWindow: tc.burn * 7, WindowDays: 7, RemainingDays: 10})
assert.Equal(t, tc.want, m.Status, name)
}
// Not started yet: nothing to average, the forecast is what is realized.
m = budgetMetrics(october, time.Date(2026, 9, 20, 0, 0, 0, 0, time.UTC), budgetMetricInputs{})
assert.Zero(t, m.ForecastCost)
assert.Equal(t, constants.GameBudgetHealthy, m.Status)
// A third of a rupiah per day is not lost: 10 over 3 days, 3 days left → 10 more.
m = budgetMetrics(october, today, budgetMetricInputs{Realized: 10, RealizedWindow: 10, WindowDays: 3, RemainingDays: 3})
assert.EqualValues(t, 20, m.ForecastCost)
assert.EqualValues(t, 3, m.DailyBurn)
}