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>
62 lines
2.9 KiB
Go
62 lines
2.9 KiB
Go
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)
|
||
}
|