feat(enakgame): budget controller recommendations and analytics

EnakGame phase 9 of docs/tasks-enakgame.md (EG-901 to EG-903).

Budget Controller (EG-901, EG-902)
- GET /marketing/enakgame/budgets/:id/recommendation, GLOBAL budgets only: the
  multiplier (budget − realized) / (forecast − realized), within one step of 1,
  rounded down to two decimals, either way. Shows each game's new rules.
- POST .../recommendation/accept with the multiplier the admin saw: recomputed in the
  transaction, then one new ACTIVE version per game, the old one RETIRED, audited
  with source budget_controller and RECOMMENDATION_ACCEPTED on the budget.
- Migration 000112: base_config_id, multiplier and budget_id on
  game_reward_configs. Rules are always scaled from the admin's last version, so
  rounding does not compound and min/max are against what the admin set.
- Guardrails in game_budgets.thresholds: max_step_percent 10, min/max multiplier
  50-150%, cooldown_days 7 per organization. Provisional pending RFC §19.2 #4.
- RewardCalculator.Scale for the four reward types: amounts only, rounded down.

Analytics (EG-903)
- GET /marketing/enakgame/analytics/games and /analytics/economy over a range of
  Asia/Jakarta days (at most 366), from game_sessions and the wallet ledger.
- Migrations 000113 (game_sessions by organization and start) and 000114
  (wallet_transactions by organization and time, CONCURRENTLY).

The Postgres tests for accepting and analytics were not run: no test database here.
Migrations 000112-000114 have not been run anywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-10-07 21:18:31 +07:00
co-authored by Claude Opus 5.5
parent 798a36bd6c
commit 296708244e
28 changed files with 1647 additions and 25 deletions
@@ -443,7 +443,7 @@ func rewardConfigModel(c *entities.GameRewardConfig) *models.GameRewardConfig {
return &models.GameRewardConfig{
ID: c.ID, GameID: c.GameID, Version: c.Version, RewardType: c.RewardType, Rules: json.RawMessage(c.Rules),
MaxReward: c.MaxReward, Status: c.Status, EffectiveAt: c.EffectiveAt, CreatedBy: c.CreatedBy,
Reason: c.Reason, CreatedAt: c.CreatedAt,
Reason: c.Reason, BaseConfigID: c.BaseConfigID, Multiplier: c.Multiplier, BudgetID: c.BudgetID, CreatedAt: c.CreatedAt,
}
}
@@ -0,0 +1,80 @@
package processor
import (
"testing"
"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"
)
// EG-903 against Postgres. Needs TEST_DATABASE_URL; see
// internal/repository/wallet_repository_test.go.
func TestEnakGameAnalytics_AgainstPostgres(t *testing.T) {
e := newEnakGameEnv(t)
ctx := e.ctx()
analytics := NewEnakGameAnalyticsProcessor(repository.NewEnakGameAnalyticsRepository(e.db))
// Sessions and ledger rows are stamped with the test clock and the database's,
// both now: a range around today holds them all.
today := walletDay(e.now)
q := models.EnakGameAnalyticsQuery{From: today.AddDate(0, 0, -1).Format("2006-01-02"), To: today.AddDate(0, 0, 1).Format("2006-01-02")}
tap := e.gameWith("tap", entities.GameResultRules{}, constants.GameRewardTypeFixed, `{"amount": 10}`, 10)
run := e.gameWith("run", entities.GameResultRules{}, constants.GameRewardTypeScoreBased,
`{"bands": [{"min": 0, "max": 100, "amount": 5}, {"min": 101, "amount": 20}]}`, 20)
e.coins(e.alice, 10, nil)
e.coins(e.bob, 10, nil)
for i, key := range []string{"tap-1", "tap-2"} {
started, err := e.sessions.Start(ctx, e.alice, tap.ID, key)
require.NoError(t, err, i)
_, err = e.sessions.Complete(ctx, e.alice, started.SessionID, models.GameSessionCompleteInput{})
require.NoError(t, err, i)
}
_, err := e.sessions.Start(ctx, e.bob, tap.ID, "tap-3") // left open
require.NoError(t, err)
started, err := e.sessions.Start(ctx, e.alice, run.ID, "run-1")
require.NoError(t, err)
_, err = e.sessions.Complete(ctx, e.alice, started.SessionID, models.GameSessionCompleteInput{Score: ptr(int64(150))})
require.NoError(t, err)
games, err := analytics.Games(ctx, e.orgA, q)
require.NoError(t, err)
assert.Equal(t, models.EnakGameStats{
Plays: 4, Completed: 3, Players: 2, AverageScore: ptr(150.0),
AverageReward: 13.33, RewardPerPlay: 10, CoinIssued: 40, EntryCostPaid: 8,
}, games.Totals)
require.Len(t, games.Games, 2)
assert.Equal(t, tap.ID, games.Games[0].GameID, "most played first")
assert.EqualValues(t, 3, games.Games[0].Plays)
assert.EqualValues(t, 2, games.Games[0].Players)
assert.Nil(t, games.Games[0].AverageScore, "FIXED reports no score")
assert.EqualValues(t, 20, games.Games[1].CoinIssued)
one, err := analytics.Games(ctx, e.orgA, models.EnakGameAnalyticsQuery{From: q.From, To: q.To, GameID: run.ID.String()})
require.NoError(t, err)
assert.EqualValues(t, 1, one.Totals.Plays)
require.Len(t, one.Games, 1)
other, err := analytics.Games(ctx, e.orgB, q)
require.NoError(t, err)
assert.Zero(t, other.Totals.Plays, "another organization's sessions never show")
assert.Empty(t, other.Games)
economy, err := analytics.Economy(ctx, e.orgA, q)
require.NoError(t, err)
assert.Equal(t, models.EnakGameCoinFlows{
Generated: 60, GameRewards: 40, SpentOnGames: 8, Spent: 8, Outstanding: 52,
}, economy.Coin)
assert.Zero(t, economy.Point)
// A range before anything happened.
empty, err := analytics.Economy(ctx, e.orgA, models.EnakGameAnalyticsQuery{From: "2020-01-01", To: "2020-01-31"})
require.NoError(t, err)
assert.Zero(t, empty.Coin)
assert.Empty(t, empty.ByType)
}
@@ -0,0 +1,153 @@
package processor
import (
"context"
"math"
"strings"
"time"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// enakGameAnalyticsMaxDays bounds a range, so one request cannot scan years of ledger.
const enakGameAnalyticsMaxDays = 366
// EnakGameAnalyticsProcessor answers the EnakGame dashboards (docs/tasks-enakgame.md
// EG-903, PRD §36) for an organization and a range of days in Asia/Jakarta.
type EnakGameAnalyticsProcessor struct {
analytics repository.EnakGameAnalyticsRepository
}
func NewEnakGameAnalyticsProcessor(analytics repository.EnakGameAnalyticsRepository) *EnakGameAnalyticsProcessor {
return &EnakGameAnalyticsProcessor{analytics: analytics}
}
// analyticsRange reads from and to, both days included, as [start, end).
func analyticsRange(q models.EnakGameAnalyticsQuery) (start, end time.Time, err error) {
from, err := time.Parse("2006-01-02", strings.TrimSpace(q.From))
if err != nil {
return start, end, enakGameRejected("from must be a date like 2026-10-01")
}
to, err := time.Parse("2006-01-02", strings.TrimSpace(q.To))
if err != nil {
return start, end, enakGameRejected("to must be a date like 2026-10-31")
}
if to.Before(from) {
return start, end, enakGameRejected("to cannot be before from")
}
if daysBetween(from, to)+1 > enakGameAnalyticsMaxDays {
return start, end, enakGameRejected("the range can span at most %d days", enakGameAnalyticsMaxDays)
}
return jakartaMidnight(from), jakartaMidnight(to.AddDate(0, 0, 1)), nil
}
// Games is how the organization's games were played, by the day each session
// started.
func (p *EnakGameAnalyticsProcessor) Games(ctx context.Context, organizationID uuid.UUID, q models.EnakGameAnalyticsQuery) (*models.EnakGameAnalytics, error) {
start, end, err := analyticsRange(q)
if err != nil {
return nil, err
}
var gameID *uuid.UUID
if s := strings.TrimSpace(q.GameID); s != "" {
id, err := uuid.Parse(s)
if err != nil {
return nil, enakGameRejected("game_id must be a UUID")
}
gameID = &id
}
rows, err := p.analytics.SessionStats(ctx, organizationID, start, end, gameID)
if err != nil {
return nil, err
}
out := &models.EnakGameAnalytics{From: strings.TrimSpace(q.From), To: strings.TrimSpace(q.To), Games: []models.EnakGameGameStats{}}
for _, row := range rows {
if row.GameID == nil {
out.Totals = gameStats(row)
continue
}
g := models.EnakGameGameStats{GameID: *row.GameID, EnakGameStats: gameStats(row)}
if row.GameName != nil {
g.GameName = *row.GameName
}
out.Games = append(out.Games, g)
}
return out, nil
}
func gameStats(r repository.GameSessionStats) models.EnakGameStats {
s := models.EnakGameStats{
Plays: r.Plays, Completed: r.Completed, Refunded: r.Refunded, Expired: r.Expired, Flagged: r.Flagged,
Players: r.Players, AverageScore: r.AverageScore, CoinIssued: r.CoinIssued,
EntryCostPaid: r.EntryCost, CoinRefunded: r.CoinRefunded,
}
if r.Completed > 0 {
s.AverageReward = ratio(r.CoinIssued, r.Completed)
}
if r.Plays > 0 {
s.RewardPerPlay = ratio(r.CoinIssued, r.Plays)
}
return s
}
// ratio is part / whole with two decimals.
func ratio(part, whole int64) float64 {
return math.Round(float64(part)*100/float64(whole)) / 100
}
// Economy is how EnakCoin and EnakPoint moved in the organization, from the ledger.
func (p *EnakGameAnalyticsProcessor) Economy(ctx context.Context, organizationID uuid.UUID, q models.EnakGameAnalyticsQuery) (*models.EnakGameEconomyAnalytics, error) {
start, end, err := analyticsRange(q)
if err != nil {
return nil, err
}
flows, err := p.analytics.WalletFlows(ctx, organizationID, start, end)
if err != nil {
return nil, err
}
balances, err := p.analytics.BalancesAt(ctx, organizationID, end)
if err != nil {
return nil, err
}
out := economy(flows, balances)
out.From, out.To = strings.TrimSpace(q.From), strings.TrimSpace(q.To)
return out, nil
}
// economy turns ledger totals into the headline numbers. Transfers move value between
// customers and count in none of them; ByType still lists them.
func economy(flows []repository.WalletFlow, balances map[string]int64) *models.EnakGameEconomyAnalytics {
type key struct{ currency, txType string }
byKey := make(map[key]repository.WalletFlow, len(flows))
out := &models.EnakGameEconomyAnalytics{ByType: make([]models.WalletFlowTotals, 0, len(flows))}
for _, f := range flows {
byKey[key{f.Currency, f.Type}] = f
out.ByType = append(out.ByType, models.WalletFlowTotals{
Currency: f.Currency, Type: f.Type, Credit: f.Credit, Debit: f.Debit, Transactions: f.Transactions,
})
}
coin := func(txType string) repository.WalletFlow { return byKey[key{constants.WalletCurrencyCoin, txType}] }
point := func(txType string) repository.WalletFlow { return byKey[key{constants.WalletCurrencyPoint, txType}] }
c := &out.Coin
c.GameRewards = coin(constants.WalletTxTypeGameReward).Credit
c.Generated = c.GameRewards + coin(constants.WalletTxTypeEarn).Credit - coin(constants.WalletTxTypeEarnReversal).Debit +
coin(constants.WalletTxTypeMigration).Credit + coin(constants.WalletTxTypeAdjustment).Credit
c.SpentOnGames = coin(constants.WalletTxTypeGameSpend).Debit - coin(constants.WalletTxTypeGameSpendRefund).Credit
c.Exchanged = coin(constants.WalletTxTypeExchangeOut).Debit
c.Spent = c.SpentOnGames + c.Exchanged
c.Expired = coin(constants.WalletTxTypeExpire).Debit
c.Outstanding = balances[constants.WalletCurrencyCoin]
pt := &out.Point
pt.Earned = point(constants.WalletTxTypeEarn).Credit - point(constants.WalletTxTypeEarnReversal).Debit
pt.Exchanged = point(constants.WalletTxTypeExchangeIn).Credit
pt.Redeemed = point(constants.WalletTxTypeRewardRedeem).Debit - point(constants.WalletTxTypeRewardRedeemRefund).Credit
pt.Expired = point(constants.WalletTxTypeExpire).Debit
pt.Balance = balances[constants.WalletCurrencyPoint]
return out
}
@@ -0,0 +1,78 @@
package processor
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
func TestAnalyticsRange(t *testing.T) {
start, end, err := analyticsRange(models.EnakGameAnalyticsQuery{From: "2026-10-01", To: "2026-10-31"})
require.NoError(t, err)
assert.Equal(t, time.Date(2026, 9, 30, 17, 0, 0, 0, time.UTC), start.UTC(), "midnight in Jakarta")
assert.Equal(t, time.Date(2026, 10, 31, 17, 0, 0, 0, time.UTC), end.UTC(), "the last day included")
_, _, err = analyticsRange(models.EnakGameAnalyticsQuery{From: "2026-01-01", To: "2027-01-01"})
require.NoError(t, err, "366 days")
for name, q := range map[string]models.EnakGameAnalyticsQuery{
"no from": {To: "2026-10-31"},
"no to": {From: "2026-10-01"},
"backwards": {From: "2026-10-31", To: "2026-10-01"},
"too long": {From: "2026-01-01", To: "2027-01-02"},
"not dates": {From: "1 Oct", To: "31 Oct"},
} {
_, _, err := analyticsRange(q)
assert.ErrorIs(t, err, ErrEnakGameRejected, name)
}
}
func TestGameStats(t *testing.T) {
s := gameStats(repository.GameSessionStats{Plays: 3, Completed: 2, CoinIssued: 25, EntryCost: 6, CoinRefunded: 2})
assert.EqualValues(t, 12.5, s.AverageReward)
assert.InDelta(t, 8.33, s.RewardPerPlay, 1e-9)
assert.EqualValues(t, 6, s.EntryCostPaid)
s = gameStats(repository.GameSessionStats{})
assert.Zero(t, s.AverageReward, "no division by zero")
assert.Zero(t, s.RewardPerPlay)
assert.Nil(t, s.AverageScore)
}
// EG-903: the economy's headline numbers from the ledger's totals.
func TestEconomy(t *testing.T) {
coin, point := constants.WalletCurrencyCoin, constants.WalletCurrencyPoint
flows := []repository.WalletFlow{
{Currency: coin, Type: constants.WalletTxTypeGameReward, Credit: 500, Transactions: 40},
{Currency: coin, Type: constants.WalletTxTypeMigration, Credit: 100, Transactions: 2},
{Currency: coin, Type: constants.WalletTxTypeAdjustment, Credit: 30, Debit: 10, Transactions: 2},
{Currency: coin, Type: constants.WalletTxTypeGameSpend, Debit: 200, Transactions: 50},
{Currency: coin, Type: constants.WalletTxTypeGameSpendRefund, Credit: 8, Transactions: 2},
{Currency: coin, Type: constants.WalletTxTypeExchangeOut, Debit: 120, Transactions: 3},
{Currency: coin, Type: constants.WalletTxTypeExpire, Debit: 15, Transactions: 1},
{Currency: coin, Type: constants.WalletTxTypeTransferOut, Debit: 50, Transactions: 1},
{Currency: coin, Type: constants.WalletTxTypeTransferIn, Credit: 50, Transactions: 1},
{Currency: point, Type: constants.WalletTxTypeEarn, Credit: 1_000, Transactions: 9},
{Currency: point, Type: constants.WalletTxTypeEarnReversal, Debit: 100, Transactions: 1},
{Currency: point, Type: constants.WalletTxTypeExchangeIn, Credit: 120, Transactions: 3},
{Currency: point, Type: constants.WalletTxTypeRewardRedeem, Debit: 700, Transactions: 7},
{Currency: point, Type: constants.WalletTxTypeRewardRedeemRefund, Credit: 100, Transactions: 1},
{Currency: point, Type: constants.WalletTxTypeExpire, Debit: 40, Transactions: 2},
}
e := economy(flows, map[string]int64{coin: 900, point: 2_500})
assert.Equal(t, models.EnakGameCoinFlows{
Generated: 630, GameRewards: 500, SpentOnGames: 192, Exchanged: 120, Spent: 312, Expired: 15, Outstanding: 900,
}, e.Coin, "transfers count nowhere")
assert.Equal(t, models.EnakGamePointFlows{Earned: 900, Exchanged: 120, Redeemed: 600, Expired: 40, Balance: 2_500}, e.Point)
assert.Len(t, e.ByType, len(flows))
empty := economy(nil, map[string]int64{})
assert.Zero(t, empty.Coin)
assert.NotNil(t, empty.ByType, "an empty list, not null")
}
@@ -0,0 +1,164 @@
package processor
import (
"encoding/json"
"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"
)
// realizedCost records a completed redemption whose face value the budget paid,
// recognized at at.
func (e *enakGameEnv) realizedCost(voucherID, budgetID uuid.UUID, amount int64, at time.Time) {
e.t.Helper()
debit, redemption := uuid.New(), uuid.New()
require.NoError(e.t, e.db.Exec(`INSERT INTO wallet_transactions (id, organization_id, customer_id, currency, type, amount, balance_after, reference_type, reference_id, description)
VALUES (?, ?, ?, 'POINT', 'REWARD_REDEEM', -1, 0, 'REWARD_REDEMPTION', ?, 'x')`, debit, e.orgA, e.bob, redemption).Error)
require.NoError(e.t, e.db.Exec(`INSERT INTO voucher_redemptions (id, organization_id, customer_id, voucher_id, idempotency_key, status, face_value, point_cost, debit_transaction_id, completed_at)
VALUES (?, ?, ?, ?, ?, 'COMPLETED', ?, 1, ?, ?)`, redemption, e.orgA, e.bob, voucherID, redemption.String(), amount, debit, at).Error)
require.NoError(e.t, e.db.Exec(`INSERT INTO voucher_redemption_costs (redemption_id, budget_id, source_type, points, cost, recognized_at) VALUES (?, ?, 'GAME_REWARD', 1, ?, ?)`,
redemption, budgetID, amount, at).Error)
}
func (e *enakGameEnv) activeConfig(gameID uuid.UUID) *entities.GameRewardConfig {
e.t.Helper()
c, err := repository.NewEnakGameRepository(e.db).GetActiveRewardConfig(e.ctx(), e.orgA, gameID)
require.NoError(e.t, err)
return c
}
// EG-901 and EG-902 against Postgres, from the PRD §30 numbers. Needs
// TEST_DATABASE_URL; see internal/repository/wallet_repository_test.go.
func TestGameBudgetController_AgainstPostgres(t *testing.T) {
e := newEnakGameEnv(t)
ctx := e.ctx()
e.now = time.Date(2026, 10, 21, 12, 0, 0, 0, walletDisplayLocation)
budgets := repository.NewGameBudgetRepository(e.db)
metrics := NewGameBudgetMetricsProcessor(budgets, repository.NewGameBudgetMetricsRepository(e.db))
controller := NewGameBudgetControllerProcessor(budgets, metrics, repository.NewEnakGameRepository(e.db),
NewAuditLogger(repository.NewAuditLogRepository(e.db)), e.txm)
controller.now = func() time.Time { return e.now }
october := e.globalBudget(e.orgA, e.now)
tap := e.gameWith("tap", entities.GameResultRules{}, constants.GameRewardTypeFixed, `{"amount": 10}`, 10)
run := e.gameWith("run", entities.GameResultRules{}, constants.GameRewardTypeScoreBased,
`{"bands": [{"min": 0, "max": 100, "amount": 5}, {"min": 101, "amount": 20}]}`, 20)
tapV1, runV1 := e.activeConfig(tap.ID), e.activeConfig(run.ID)
// Rp60M realized, Rp5,5M a day over the last week, 10 days left → Rp115M.
voucher := e.staticVoucher(1_000, 1, 1)
e.realizedCost(voucher.ID, october.ID, 21_500_000, time.Date(2026, 10, 5, 10, 0, 0, 0, walletDisplayLocation))
for day := 15; day <= 21; day++ {
e.realizedCost(voucher.ID, october.ID, 5_500_000, time.Date(2026, 10, day, 0, 0, 0, 0, walletDisplayLocation))
}
rec, err := controller.Recommendation(ctx, e.orgA, october.ID)
require.NoError(t, err)
assert.Equal(t, constants.GameBudgetRecommended, rec.State)
assert.EqualValues(t, 115_000_000, rec.Metrics.ForecastCost)
assert.InDelta(t, 0.7272, *rec.TargetMultiplier, 1e-9)
assert.EqualValues(t, 0.9, rec.Multiplier, "one step of 10%")
assert.EqualValues(t, 10, *rec.Guardrails.MaxStepPercent)
require.Len(t, rec.Games, 2)
assert.Equal(t, run.ID, rec.Games[0].GameID, "by game name")
assert.JSONEq(t, `{"bands":[{"min":0,"max":100,"amount":4},{"min":101,"amount":18}]}`, string(rec.Games[0].NewRules))
assert.EqualValues(t, 18, rec.Games[0].NewMaxReward)
assert.JSONEq(t, `{"amount":9}`, string(rec.Games[1].NewRules))
assert.Zero(t, e.count(`SELECT COUNT(*) FROM game_reward_configs WHERE organization_id = ? AND budget_id IS NOT NULL`, e.orgA), "showing changes nothing")
// Only what the admin saw is applied.
_, err = controller.Accept(ctx, e.orgA, e.manager, october.ID, models.GameBudgetRecommendationAcceptInput{})
assert.ErrorIs(t, err, ErrEnakGameRejected)
_, err = controller.Accept(ctx, e.orgA, e.manager, october.ID, models.GameBudgetRecommendationAcceptInput{Multiplier: ptr(0.85)})
assert.ErrorIs(t, err, ErrEnakGameRejected)
assert.Zero(t, e.count(`SELECT COUNT(*) FROM game_reward_configs WHERE organization_id = ? AND budget_id IS NOT NULL`, e.orgA))
accepted, err := controller.Accept(ctx, e.orgA, e.manager, october.ID, models.GameBudgetRecommendationAcceptInput{
Multiplier: ptr(0.9), Reason: ptr("Burn rate terlalu tinggi"),
})
require.NoError(t, err)
require.Len(t, accepted.RewardConfigs, 2)
tapV2 := e.activeConfig(tap.ID)
assert.Equal(t, 2, tapV2.Version)
assert.JSONEq(t, `{"amount":9}`, string(tapV2.Rules))
assert.EqualValues(t, 9, tapV2.MaxReward)
assert.EqualValues(t, 0.9, *tapV2.Multiplier)
assert.Equal(t, tapV1.ID, *tapV2.BaseConfigID)
assert.Equal(t, october.ID, *tapV2.BudgetID)
assert.Equal(t, "Burn rate terlalu tinggi", *tapV2.Reason)
assert.EqualValues(t, 1, e.count(`SELECT COUNT(*) FROM game_reward_configs WHERE id = ? AND status = 'RETIRED'`, tapV1.ID))
assert.EqualValues(t, 1, e.count(`SELECT COUNT(*) FROM game_reward_configs WHERE id = ? AND status = 'RETIRED'`, runV1.ID))
assert.Equal(t, []string{"CREATED", constants.AuditActionRecommendationAccepted}, e.auditActions(constants.AuditEntityGameBudget, october.ID))
assert.Equal(t, []string{"ACTIVATED", "CREATED"}, e.auditActions(constants.AuditEntityGameRewardConfig, tapV2.ID))
assert.EqualValues(t, 7, e.count(`SELECT COUNT(*) FROM audit_logs WHERE organization_id = ? AND source = ?`, e.orgA, constants.AuditSourceBudgetController),
"per game: retired, created, activated; and the budget")
// The game pays the new amount.
e.coins(e.alice, 2, nil)
started, err := e.sessions.Start(ctx, e.alice, tap.ID, "after")
require.NoError(t, err)
done, err := e.sessions.Complete(ctx, e.alice, started.SessionID, models.GameSessionCompleteInput{})
require.NoError(t, err)
assert.EqualValues(t, 9, done.RewardTotal)
// Not again within the cooldown, though the forecast is still over.
rec, err = controller.Recommendation(ctx, e.orgA, october.ID)
require.NoError(t, err)
assert.Equal(t, constants.GameBudgetRecommendationCooldown, rec.State)
require.NotNil(t, rec.CooldownUntil)
assert.WithinDuration(t, e.now.AddDate(0, 0, 7), *rec.CooldownUntil, time.Second)
assert.EqualValues(t, 0.81, rec.Games[1].NewMultiplier, "what it would be")
_, err = controller.Accept(ctx, e.orgA, e.manager, october.ID, models.GameBudgetRecommendationAcceptInput{Multiplier: ptr(0.9)})
assert.ErrorIs(t, err, ErrEnakGameRejected)
// No cooldown and a floor of 85%: 0.81 stops at 0.85, scaled from version 1.
current, err := e.budgets.GetBudget(ctx, e.orgA, october.ID)
require.NoError(t, err)
in := GameBudgetInputFrom(current)
in.Thresholds.CooldownDays, in.Thresholds.MinMultiplierPercent = ptr(int64(0)), ptr(int64(85))
_, err = e.budgets.UpdateBudget(ctx, e.orgA, e.manager, october.ID, in)
require.NoError(t, err)
rec, err = controller.Recommendation(ctx, e.orgA, october.ID)
require.NoError(t, err)
require.Equal(t, constants.GameBudgetRecommended, rec.State)
_, err = controller.Accept(ctx, e.orgA, e.manager, october.ID, models.GameBudgetRecommendationAcceptInput{Multiplier: ptr(0.9)})
require.NoError(t, err)
tapV3 := e.activeConfig(tap.ID)
assert.EqualValues(t, 0.85, *tapV3.Multiplier)
assert.JSONEq(t, `{"amount":8}`, string(tapV3.Rules), "10 × 0.85, rounded down")
assert.Equal(t, tapV1.ID, *tapV3.BaseConfigID, "always from the admin's version")
rec, err = controller.Recommendation(ctx, e.orgA, october.ID)
require.NoError(t, err)
assert.Equal(t, constants.GameBudgetRecommendationAtLimit, rec.State)
assert.Empty(t, rec.Games)
// An admin's new version starts over from 1.
config, err := e.admin.CreateRewardConfig(ctx, e.orgA, e.manager, tap.ID, models.GameRewardConfigInput{
RewardType: constants.GameRewardTypeFixed, Rules: json.RawMessage(`{"amount": 12}`), MaxReward: 12,
})
require.NoError(t, err)
_, err = e.admin.ActivateRewardConfig(ctx, e.orgA, e.manager, config.ID, models.GameRewardConfigActivateInput{})
require.NoError(t, err)
rec, err = controller.Recommendation(ctx, e.orgA, october.ID)
require.NoError(t, err)
require.Equal(t, constants.GameBudgetRecommended, rec.State)
require.Len(t, rec.Games, 1)
assert.Equal(t, config.ID, rec.Games[0].BaseConfigID)
assert.JSONEq(t, `{"amount":10}`, string(rec.Games[0].NewRules), "12 × 0.9, rounded down")
// Event budgets pay for what events add, which is set on the event.
event := e.eventBudget(e.orgA, "Ramadan")
_, err = controller.Recommendation(ctx, e.orgA, event.ID)
assert.ErrorIs(t, err, ErrEnakGameRejected)
_, err = controller.Recommendation(ctx, e.orgB, october.ID)
assert.ErrorIs(t, err, repository.ErrGameBudgetNotFound)
}
@@ -0,0 +1,368 @@
package processor
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"time"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// GameBudgetControllerProcessor is the Budget Controller in recommendation mode
// (docs/rfc-enakgame.md §10, D9; PRD §29–§33). For a global budget it computes the
// multiplier that brings the forecast to the budget, keeps it within the guardrails,
// and shows what every game's reward would become. Nothing changes until an admin
// accepts; accepting writes a new active version of each game's configuration, audited
// with source budget_controller.
//
// A game's multiplier is kept against its base, the version its admin wrote last, so
// adjustments do not compound their rounding and min/max mean "of what the admin
// set". An admin writing a new version starts a new base at 1.
type GameBudgetControllerProcessor struct {
budgets repository.GameBudgetRepository
metrics *GameBudgetMetricsProcessor
games repository.EnakGameRepository
audit *AuditLogger
tx TxRunner
now func() time.Time
}
func NewGameBudgetControllerProcessor(budgets repository.GameBudgetRepository, metrics *GameBudgetMetricsProcessor, games repository.EnakGameRepository,
audit *AuditLogger, tx TxRunner) *GameBudgetControllerProcessor {
return &GameBudgetControllerProcessor{budgets: budgets, metrics: metrics, games: games, audit: audit, tx: tx, now: time.Now}
}
// budgetGuardrails are a budget's guardrails in percent (PRD §31), with the defaults
// for those it does not set.
type budgetGuardrails struct {
step, min, max, cooldownDays int64
}
func guardrailsOf(b *entities.GameBudget) budgetGuardrails {
g := budgetGuardrails{
step: constants.GameBudgetMaxStepDefault, min: constants.GameBudgetMinMultiplierDefault,
max: constants.GameBudgetMaxMultiplierDefault, cooldownDays: constants.GameBudgetCooldownDaysDefault,
}
var set models.GameBudgetThresholds
if len(b.Thresholds) > 0 {
_ = json.Unmarshal(b.Thresholds, &set)
}
for _, v := range []struct {
from *int64
to *int64
}{{set.MaxStepPercent, &g.step}, {set.MinMultiplierPercent, &g.min}, {set.MaxMultiplierPercent, &g.max}, {set.CooldownDays, &g.cooldownDays}} {
if v.from != nil {
*v.to = *v.from
}
}
return g
}
func (g budgetGuardrails) model() models.GameBudgetThresholds {
step, lo, hi, cooldown := g.step, g.min, g.max, g.cooldownDays
return models.GameBudgetThresholds{MaxStepPercent: &step, MinMultiplierPercent: &lo, MaxMultiplierPercent: &hi, CooldownDays: &cooldown}
}
// budgetStepMultiplier is the multiplier, in ten-thousandths, that one accepted
// recommendation applies to every game's reward. target is what would make the
// forecast meet the budget, nil when there is no cost to extrapolate from. step is
// target within one step of 1, rounded down to two decimals: down never pays more than
// the forecast allows. state is set when there is nothing to recommend.
//
// The forecast is realized + burn × days left, and only the future part follows the
// rewards, so the target is (budget − realized) / (forecast − realized).
func budgetStepMultiplier(m models.GameBudgetMetrics, g budgetGuardrails) (target *int64, step int64, state string) {
one := rewardMultiplierOne
if m.WindowDays == 0 || m.RemainingDays == 0 {
return nil, one, constants.GameBudgetRecommendationOutOfPeriod
}
future := m.ForecastCost - m.RealizedCost
if future <= 0 {
return nil, one, constants.GameBudgetRecommendationNoData
}
t := int64(math.Floor(float64(m.Amount-m.RealizedCost) * float64(one) / float64(future)))
t = max(t, 0)
step = min(max(t, one-g.step*100), one+g.step*100)
step -= step % 100
if step == one {
return &t, one, constants.GameBudgetRecommendationNoChange
}
return &t, step, ""
}
// nextGameMultiplier is a game's multiplier after a step: current × step, rounded
// down, within min and max. A game outside min and max, because they changed since
// its last adjustment, is brought inside, but never moved against the step.
func nextGameMultiplier(current, step int64, g budgetGuardrails) int64 {
next := current * step / rewardMultiplierOne
next = min(max(next, g.min*100), g.max*100)
if (step < rewardMultiplierOne && next > current) || (step > rewardMultiplierOne && next < current) {
return current
}
return next
}
// rewardAdjustment is a game's part of a recommendation, with what accepting it needs.
type rewardAdjustment struct {
model models.GameRewardAdjustment
active *entities.GameRewardConfig
base *entities.GameRewardConfig
multiplier int64
rules json.RawMessage
maxReward int64
}
func multiplierOf(c *entities.GameRewardConfig) int64 {
if c.Multiplier == nil {
return rewardMultiplierOne
}
return int64(math.Round(*c.Multiplier * float64(rewardMultiplierOne)))
}
func multiplierValue(m int64) float64 {
return float64(m) / float64(rewardMultiplierOne)
}
// Recommendation is GET /marketing/enakgame/budgets/:id/recommendation.
func (p *GameBudgetControllerProcessor) Recommendation(ctx context.Context, organizationID, budgetID uuid.UUID) (*models.GameBudgetRecommendation, error) {
budget, err := p.budgets.GetBudget(ctx, organizationID, budgetID)
if err != nil {
return nil, err
}
rec, _, err := p.recommend(ctx, budget, p.now())
return rec, err
}
func (p *GameBudgetControllerProcessor) recommend(ctx context.Context, budget *entities.GameBudget, now time.Time) (*models.GameBudgetRecommendation, []rewardAdjustment, error) {
if budget.Scope != constants.GameBudgetScopeGlobal {
return nil, nil, enakGameRejected("the Budget Controller only adjusts base rewards, paid by GLOBAL budgets; an event's extra is set on the event")
}
metrics, err := p.metrics.metricsAt(ctx, budget, now)
if err != nil {
return nil, nil, err
}
g := guardrailsOf(budget)
target, step, state := budgetStepMultiplier(*metrics, g)
rec := &models.GameBudgetRecommendation{
BudgetID: budget.ID, Metrics: *metrics, Guardrails: g.model(), Multiplier: multiplierValue(step),
Games: []models.GameRewardAdjustment{},
}
if target != nil {
t := multiplierValue(*target)
rec.TargetMultiplier = &t
}
switch state {
case constants.GameBudgetRecommendationOutOfPeriod:
rec.State, rec.Message = state, "the budget's period has not started, or has no day left after today"
return rec, nil, nil
case constants.GameBudgetRecommendationNoData:
rec.State, rec.Message = state, fmt.Sprintf("no voucher cost in the last %d days to forecast from", metrics.WindowDays)
return rec, nil, nil
case constants.GameBudgetRecommendationNoChange:
rec.State, rec.Message = state, "the forecast meets the budget; rewards stay"
return rec, nil, nil
}
configs, err := p.games.ListActiveRewardConfigs(ctx, budget.OrganizationID)
if err != nil {
return nil, nil, err
}
var adjustments []rewardAdjustment
for i := range configs {
active := &configs[i].GameRewardConfig
current := multiplierOf(active)
next := nextGameMultiplier(current, step, g)
if next == current {
continue
}
base := active
if active.BaseConfigID != nil {
if base, err = p.games.GetRewardConfig(ctx, budget.OrganizationID, *active.BaseConfigID); err != nil {
return nil, nil, err
}
}
a, err := adjust(configs[i].GameName, active, base, next)
if err != nil {
return nil, nil, err
}
adjustments = append(adjustments, a)
rec.Games = append(rec.Games, a.model)
}
last, err := p.games.LastBudgetControllerChange(ctx, budget.OrganizationID)
if err != nil {
return nil, nil, err
}
var until *time.Time
if last != nil && g.cooldownDays > 0 {
if u := last.AddDate(0, 0, int(g.cooldownDays)); now.Before(u) {
until = &u
}
}
switch {
case len(adjustments) == 0:
rec.State, rec.Message = constants.GameBudgetRecommendationAtLimit, "every game is already at its min or max multiplier"
case until != nil:
rec.State, rec.CooldownUntil = constants.GameBudgetRecommendationCooldown, until
rec.Message = fmt.Sprintf("a recommendation was accepted less than %d days ago", g.cooldownDays)
default:
rec.State = constants.GameBudgetRecommended
rec.Message = fmt.Sprintf("forecast Rp%d against a budget of Rp%d: multiply rewards by %.2f", metrics.ForecastCost, metrics.Amount, rec.Multiplier)
}
return rec, adjustments, nil
}
// adjust scales a game's base configuration to a multiplier.
func adjust(gameName string, active, base *entities.GameRewardConfig, multiplier int64) (rewardAdjustment, error) {
calculator, err := RewardCalculatorFor(base.RewardType)
if err != nil {
return rewardAdjustment{}, err
}
rules, err := calculator.Scale(json.RawMessage(base.Rules), multiplier)
if err != nil {
return rewardAdjustment{}, enakGameRejected("cannot scale the reward of %s: %v", gameName, err)
}
maxReward, err := scaleRewardAmount(base.MaxReward, multiplier)
if err != nil {
return rewardAdjustment{}, enakGameRejected("cannot scale the max_reward of %s: %v", gameName, err)
}
return rewardAdjustment{
model: models.GameRewardAdjustment{
GameID: active.GameID, GameName: gameName, RewardConfigID: active.ID, Version: active.Version,
BaseConfigID: base.ID, RewardType: base.RewardType,
CurrentMultiplier: multiplierValue(multiplierOf(active)), NewMultiplier: multiplierValue(multiplier),
CurrentRules: json.RawMessage(active.Rules), NewRules: rules,
CurrentMaxReward: active.MaxReward, NewMaxReward: maxReward,
},
active: active, base: base, multiplier: multiplier, rules: rules, maxReward: maxReward,
}, nil
}
// Accept applies the recommendation the admin saw: for every game it changes, a new
// active version of the configuration replaces the active one, in one transaction.
// When the recommendation is no longer what the admin saw, nothing changes.
func (p *GameBudgetControllerProcessor) Accept(ctx context.Context, organizationID, actor, budgetID uuid.UUID, in models.GameBudgetRecommendationAcceptInput) (*models.GameBudgetRecommendationAccepted, error) {
if in.Multiplier == nil {
return nil, enakGameRejected("multiplier is required: the one the recommendation showed")
}
if err := validateReason(in.Reason); err != nil {
return nil, err
}
seen := int64(math.Round(*in.Multiplier * float64(rewardMultiplierOne)))
var out *models.GameBudgetRecommendationAccepted
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
// One acceptance at a time in the organization, so two cannot both pass the
// cooldown, and none while a budget is being changed.
if err := p.budgets.LockGlobalBudgets(ctx, organizationID); err != nil {
return err
}
budget, err := p.budgets.GetBudget(ctx, organizationID, budgetID)
if err != nil {
return err
}
now := p.now()
rec, adjustments, err := p.recommend(ctx, budget, now)
if err != nil {
return err
}
if rec.State != constants.GameBudgetRecommended {
return enakGameRejected("nothing to accept: %s", rec.Message)
}
if seen != int64(math.Round(rec.Multiplier*float64(rewardMultiplierOne))) {
return enakGameRejected("the recommendation is now %.2f; review it again", rec.Multiplier)
}
reason := in.Reason
if reason == nil {
r := fmt.Sprintf("Budget Controller: rewards × %.2f for budget %s", rec.Multiplier, budget.ID)
reason = &r
}
out = &models.GameBudgetRecommendationAccepted{BudgetID: budget.ID, Multiplier: rec.Multiplier}
configIDs := make([]uuid.UUID, 0, len(adjustments))
for _, a := range adjustments {
config, err := p.apply(ctx, organizationID, actor, budget.ID, a, now, reason)
if err != nil {
return err
}
out.RewardConfigs = append(out.RewardConfigs, *rewardConfigModel(config))
configIDs = append(configIDs, config.ID)
}
return p.record(ctx, organizationID, actor, constants.AuditEntityGameBudget, budget.ID, constants.AuditActionRecommendationAccepted, nil,
map[string]any{
"multiplier": rec.Multiplier, "target_multiplier": rec.TargetMultiplier, "amount": rec.Metrics.Amount,
"realized_cost": rec.Metrics.RealizedCost, "forecast_cost": rec.Metrics.ForecastCost,
"guardrails": rec.Guardrails, "reward_config_ids": configIDs,
}, reason)
})
if err != nil {
return nil, err
}
return out, nil
}
// apply retires a game's active configuration and activates its adjusted version.
func (p *GameBudgetControllerProcessor) apply(ctx context.Context, organizationID, actor, budgetID uuid.UUID, a rewardAdjustment, now time.Time, reason *string) (*entities.GameRewardConfig, error) {
// Waits for an admin changing the same game's configuration, then checks the
// recommendation was made from the version still active.
changed := enakGameRejected("%s changed meanwhile; review the recommendation again", a.model.GameName)
game, err := p.games.LockGame(ctx, organizationID, a.active.GameID)
if err != nil {
return nil, err
}
if game.Status == constants.GameStatusArchived {
return nil, changed
}
active, err := p.games.GetActiveRewardConfig(ctx, organizationID, a.active.GameID)
if errors.Is(err, repository.ErrGameRewardConfigNotFound) {
return nil, changed
}
if err != nil {
return nil, err
}
if active.ID != a.active.ID {
return nil, changed
}
moved, err := p.games.SetRewardConfigStatus(ctx, organizationID, active.ID, constants.GameRewardConfigStatusActive, constants.GameRewardConfigStatusRetired)
if err != nil {
return nil, err
}
if !moved {
return nil, changed
}
if err := p.record(ctx, organizationID, actor, constants.AuditEntityGameRewardConfig, active.ID, "RETIRED",
map[string]string{"status": constants.GameRewardConfigStatusActive}, map[string]string{"status": constants.GameRewardConfigStatusRetired}, reason); err != nil {
return nil, err
}
multiplier, baseID, budget := multiplierValue(a.multiplier), a.base.ID, budgetID
config := &entities.GameRewardConfig{
OrganizationID: organizationID, GameID: active.GameID, RewardType: a.base.RewardType,
Rules: entities.JSONDocument(a.rules), MaxReward: a.maxReward, Status: constants.GameRewardConfigStatusActive,
EffectiveAt: &now, CreatedBy: actor, Reason: reason, BaseConfigID: &baseID, Multiplier: &multiplier, BudgetID: &budget,
}
if err := p.games.CreateRewardConfig(ctx, config); err != nil {
return nil, err
}
if err := p.record(ctx, organizationID, actor, constants.AuditEntityGameRewardConfig, config.ID, "CREATED", nil, rewardConfigModel(config), reason); err != nil {
return nil, err
}
return config, p.record(ctx, organizationID, actor, constants.AuditEntityGameRewardConfig, config.ID, "ACTIVATED",
nil, map[string]string{"status": constants.GameRewardConfigStatusActive}, reason)
}
func (p *GameBudgetControllerProcessor) record(ctx context.Context, organizationID, actor uuid.UUID, entityType string, entityID uuid.UUID, action string, before, after any, reason *string) error {
return p.audit.Record(ctx, AuditEntry{
OrganizationID: organizationID, ActorType: constants.AuditActorUser, ActorID: &actor,
EntityType: entityType, EntityID: entityID, Action: action,
Before: before, After: after, Reason: reason, Source: constants.AuditSourceBudgetController,
})
}
@@ -0,0 +1,101 @@
package processor
import (
"testing"
"github.com/stretchr/testify/assert"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
)
var defaultGuardrails = budgetGuardrails{step: 10, min: 50, max: 150, cooldownDays: 7}
// EG-901: the multiplier of one recommendation.
func TestBudgetStepMultiplier(t *testing.T) {
// metrics is a running budget of Rp100M: realized so far, forecast at period end.
metrics := func(realized, forecast int64) models.GameBudgetMetrics {
return models.GameBudgetMetrics{Amount: 100_000_000, RealizedCost: realized, ForecastCost: forecast, WindowDays: 7, RemainingDays: 10}
}
for name, tc := range map[string]struct {
m models.GameBudgetMetrics
g budgetGuardrails
wantTarget *int64
wantStep int64
wantState string
}{
// PRD §30: Rp40M left for Rp55M forecast → 0.7272, one step down.
"PRD §30": {metrics(60_000_000, 115_000_000), defaultGuardrails, ptr(int64(7272)), 9_000, ""},
"wider step": {metrics(60_000_000, 115_000_000), budgetGuardrails{step: 30, min: 50, max: 150}, ptr(int64(7272)), 7_200, ""},
"slightly over": {metrics(60_000_000, 100_400_000), defaultGuardrails, ptr(int64(9900)), 9_900, ""},
"under budget": {metrics(20_000_000, 60_000_000), defaultGuardrails, ptr(int64(20000)), 11_000, ""},
"slightly under": {metrics(60_000_000, 99_000_000), defaultGuardrails, ptr(int64(10256)), 10_200, ""},
"on budget": {metrics(60_000_000, 100_000_000), defaultGuardrails, ptr(int64(10000)), 10_000, constants.GameBudgetRecommendationNoChange},
"a hair under": {metrics(60_000_000, 99_900_000), defaultGuardrails, ptr(int64(10025)), 10_000, constants.GameBudgetRecommendationNoChange},
"already over budget": {metrics(110_000_000, 120_000_000), defaultGuardrails, ptr(int64(0)), 9_000, ""},
"no recent cost": {metrics(60_000_000, 60_000_000), defaultGuardrails, nil, 10_000, constants.GameBudgetRecommendationNoData},
"not started": {models.GameBudgetMetrics{Amount: 100_000_000}, defaultGuardrails,
nil, 10_000, constants.GameBudgetRecommendationOutOfPeriod},
"last day": {models.GameBudgetMetrics{Amount: 100_000_000, RealizedCost: 1, ForecastCost: 1, WindowDays: 7}, defaultGuardrails,
nil, 10_000, constants.GameBudgetRecommendationOutOfPeriod},
} {
target, step, state := budgetStepMultiplier(tc.m, tc.g)
assert.Equal(t, tc.wantTarget, target, name)
assert.Equal(t, tc.wantStep, step, name)
assert.Equal(t, tc.wantState, state, name)
}
}
// EG-901: a game's multiplier after a step, against its admin's configuration.
func TestNextGameMultiplier(t *testing.T) {
for name, tc := range map[string]struct {
current, step, want int64
g budgetGuardrails
}{
"first step down": {10_000, 9_000, 9_000, defaultGuardrails},
"compounds": {9_000, 9_000, 8_100, defaultGuardrails},
"rounds down": {8_100, 9_900, 8_019, defaultGuardrails},
"stops at min": {5_500, 9_000, 5_000, defaultGuardrails},
"stays at min": {5_000, 9_000, 5_000, defaultGuardrails},
"stops at max": {14_000, 11_000, 15_000, defaultGuardrails},
"up from a cut": {8_100, 11_000, 8_910, defaultGuardrails},
"min raised since: up": {4_000, 11_000, 5_000, defaultGuardrails},
"min raised since: stays": {4_000, 9_000, 4_000, defaultGuardrails},
"max lowered since: down": {20_000, 9_000, 15_000, defaultGuardrails},
"max lowered since: stay": {20_000, 11_000, 20_000, defaultGuardrails},
"own bounds": {10_000, 9_000, 9_500, budgetGuardrails{step: 10, min: 95, max: 100}},
} {
assert.Equal(t, tc.want, nextGameMultiplier(tc.current, tc.step, tc.g), name)
}
}
func TestGuardrailsOf(t *testing.T) {
assert.Equal(t, defaultGuardrails, guardrailsOf(&entities.GameBudget{}), "the defaults")
assert.Equal(t, defaultGuardrails, guardrailsOf(&entities.GameBudget{Thresholds: entities.JSONDocument(`{"warning": 50}`)}))
assert.Equal(t, budgetGuardrails{step: 5, min: 80, max: 150, cooldownDays: 0},
guardrailsOf(&entities.GameBudget{Thresholds: entities.JSONDocument(`{"max_step_percent": 5, "min_multiplier_percent": 80, "cooldown_days": 0}`)}))
}
func TestGameBudgetGuardrailValidation(t *testing.T) {
in := func(th models.GameBudgetThresholds) models.GameBudgetInput {
return models.GameBudgetInput{Scope: "GLOBAL", Name: "Oktober", PeriodStart: "2026-10-01", PeriodEnd: "2026-10-31", Amount: 1, Thresholds: th}
}
_, err := gameBudgetFromInput(in(models.GameBudgetThresholds{
MaxStepPercent: ptr(int64(50)), MinMultiplierPercent: ptr(int64(1)), MaxMultiplierPercent: ptr(int64(1000)), CooldownDays: ptr(int64(0)),
}))
assert.NoError(t, err)
for name, th := range map[string]models.GameBudgetThresholds{
"step 0": {MaxStepPercent: ptr(int64(0))},
"step 51": {MaxStepPercent: ptr(int64(51))},
"min 0": {MinMultiplierPercent: ptr(int64(0))},
"min above 1x": {MinMultiplierPercent: ptr(int64(101))},
"max below 1x": {MaxMultiplierPercent: ptr(int64(99))},
"max too high": {MaxMultiplierPercent: ptr(int64(1001))},
"cooldown -1": {CooldownDays: ptr(int64(-1))},
"cooldown 91": {CooldownDays: ptr(int64(91))},
} {
_, err := gameBudgetFromInput(in(th))
assert.ErrorIs(t, err, ErrEnakGameRejected, name)
}
}
@@ -49,7 +49,12 @@ func (p *GameBudgetMetricsProcessor) Metrics(ctx context.Context, organizationID
if err != nil {
return nil, err
}
now := p.now()
return p.metricsAt(ctx, budget, p.now())
}
// metricsAt computes a budget's metrics as of now.
func (p *GameBudgetMetricsProcessor) metricsAt(ctx context.Context, budget *entities.GameBudget, now time.Time) (*models.GameBudgetMetrics, error) {
var err error
today := civilDay(walletDay(now))
start, end := civilDay(budget.PeriodStart), civilDay(budget.PeriodEnd)
@@ -25,6 +25,9 @@ type GameBudgetProcessor struct {
now func() time.Time
}
// gameBudgetMaxMultiplierLimit is the highest max_multiplier_percent a budget may set.
const gameBudgetMaxMultiplierLimit = 1000
func NewGameBudgetProcessor(budgets repository.GameBudgetRepository, audit *AuditLogger, tx TxRunner) *GameBudgetProcessor {
return &GameBudgetProcessor{budgets: budgets, audit: audit, tx: tx, now: time.Now}
}
@@ -234,6 +237,18 @@ func gameBudgetFromInput(in models.GameBudgetInput) (*entities.GameBudget, error
if t.Warning != nil && t.Critical != nil && *t.Warning > *t.Critical {
return nil, enakGameRejected("thresholds.warning cannot be above thresholds.critical")
}
switch {
case t.MaxStepPercent != nil && (*t.MaxStepPercent < 1 || *t.MaxStepPercent > 50):
return nil, enakGameRejected("thresholds.max_step_percent must be between 1 and 50")
case t.MinMultiplierPercent != nil && (*t.MinMultiplierPercent < 1 || *t.MinMultiplierPercent > 100):
return nil, enakGameRejected("thresholds.min_multiplier_percent must be between 1 and 100")
case t.MaxMultiplierPercent != nil && (*t.MaxMultiplierPercent < 100 || *t.MaxMultiplierPercent > gameBudgetMaxMultiplierLimit):
// NUMERIC(6,4) on game_reward_configs.multiplier holds far more; this keeps a
// typo from multiplying rewards a hundredfold.
return nil, enakGameRejected("thresholds.max_multiplier_percent must be between 100 and %d", gameBudgetMaxMultiplierLimit)
case t.CooldownDays != nil && (*t.CooldownDays < 0 || *t.CooldownDays > 90):
return nil, enakGameRejected("thresholds.cooldown_days must be between 0 and 90")
}
thresholds, err := json.Marshal(t)
if err != nil {
return nil, err
+94 -2
View File
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"io"
"math"
"math/big"
"strings"
@@ -60,6 +61,43 @@ type RewardCalculator interface {
// Calculate prices a result under rules that passed Validate. detail says how the
// amount was reached, for reward_breakdown.
Calculate(rules json.RawMessage, result SessionResult, rng RewardRNG) (base int64, detail map[string]any, err error)
// Scale multiplies every amount in rules by multiplier/10000, rounded down, for
// the Budget Controller (§10). Everything else, such as score bands and weights,
// stays.
Scale(rules json.RawMessage, multiplier int64) (json.RawMessage, error)
}
// rewardMultiplierOne is a multiplier of 1 in the ten-thousandths Scale takes.
const rewardMultiplierOne = int64(10_000)
// scaleRewardAmount is amount × multiplier/10000, rounded down (RFC §19.2 #1).
func scaleRewardAmount(amount, multiplier int64) (int64, error) {
if multiplier < 0 {
return 0, invalidRules("multiplier cannot be negative")
}
if amount != 0 && multiplier > math.MaxInt64/amount {
return 0, invalidRules("amount %d is too large to scale", amount)
}
return amount * multiplier / rewardMultiplierOne, nil
}
func scaleRewardAmounts(multiplier int64, amounts ...*int64) error {
for _, a := range amounts {
scaled, err := scaleRewardAmount(*a, multiplier)
if err != nil {
return err
}
*a = scaled
}
return nil
}
func marshalRules(v any) (json.RawMessage, error) {
b, err := json.Marshal(v)
if err != nil {
return nil, err
}
return b, nil
}
var rewardCalculators = map[string]RewardCalculator{
@@ -131,14 +169,26 @@ func (f fixedReward) Calculate(rules json.RawMessage, _ SessionResult, _ RewardR
return *r.Amount, map[string]any{"reward_type": constants.GameRewardTypeFixed}, nil
}
func (f fixedReward) Scale(rules json.RawMessage, multiplier int64) (json.RawMessage, error) {
r, err := f.parse(rules)
if err != nil {
return nil, err
}
if err := scaleRewardAmounts(multiplier, r.Amount); err != nil {
return nil, err
}
return marshalRules(r)
}
// SCORE_BASED: {"bands": [{"min": 0, "max": 100, "amount": 1}, {"min": 101, "amount": 20}]}.
// Bands run in order from 0, each starting right after the previous one ends; only the
// last may leave max out, to cover every higher score.
type scoreBasedReward struct{}
type scoreBand struct {
Min *int64 `json:"min"`
Max *int64 `json:"max"`
Min *int64 `json:"min"`
// Left out of the last band, also when scaled rules are written back.
Max *int64 `json:"max,omitempty"`
Amount *int64 `json:"amount"`
}
@@ -206,6 +256,19 @@ func (s scoreBasedReward) Calculate(rules json.RawMessage, result SessionResult,
return 0, nil, unusableResult("score %d is in no band", score)
}
func (s scoreBasedReward) Scale(rules json.RawMessage, multiplier int64) (json.RawMessage, error) {
r, err := s.parse(rules)
if err != nil {
return nil, err
}
for _, b := range r.Bands {
if err := scaleRewardAmounts(multiplier, b.Amount); err != nil {
return nil, err
}
}
return marshalRules(r)
}
// OUTCOME_BASED: {"outcomes": {"PERFECT": 20, "GOOD": 10, "FAIL": 0}}.
type outcomeBasedReward struct{}
@@ -252,6 +315,21 @@ func (o outcomeBasedReward) Calculate(rules json.RawMessage, result SessionResul
return amount, map[string]any{"reward_type": constants.GameRewardTypeOutcomeBased, "outcome": *result.Outcome}, nil
}
func (o outcomeBasedReward) Scale(rules json.RawMessage, multiplier int64) (json.RawMessage, error) {
r, err := o.parse(rules)
if err != nil {
return nil, err
}
for outcome, amount := range r.Outcomes {
scaled, err := scaleRewardAmount(amount, multiplier)
if err != nil {
return nil, err
}
r.Outcomes[outcome] = scaled
}
return marshalRules(r)
}
// PROBABILITY: {"table": [{"weight": 1, "amount": 1000}, {"weight": 999, "amount": 0}]}.
// Weights are whole numbers, so no check depends on floating point (§8). The draw is
// kept in the detail for audit.
@@ -318,3 +396,17 @@ func (p probabilityReward) Calculate(rules json.RawMessage, _ SessionResult, rng
}
return 0, nil, fmt.Errorf("draw %d is outside the total weight %d", roll, total)
}
// Scale changes the prizes, not their odds.
func (p probabilityReward) Scale(rules json.RawMessage, multiplier int64) (json.RawMessage, error) {
r, _, err := p.parse(rules)
if err != nil {
return nil, err
}
for _, e := range r.Table {
if err := scaleRewardAmounts(multiplier, e.Amount); err != nil {
return nil, err
}
}
return marshalRules(r)
}
@@ -144,3 +144,38 @@ func TestRewardCalculator_RejectsInvalidRules(t *testing.T) {
assert.ErrorIs(t, err, ErrInvalidRewardRules, name)
}
}
// EG-902: the Budget Controller scales amounts, rounded down, and nothing else.
func TestRewardCalculator_Scale(t *testing.T) {
for name, tc := range map[string]struct {
rewardType, rules string
multiplier int64
want string
}{
"FIXED ×0.9": {constants.GameRewardTypeFixed, `{"amount": 10}`, 9_000, `{"amount":9}`},
"FIXED rounds": {constants.GameRewardTypeFixed, `{"amount": 5}`, 9_000, `{"amount":4}`},
"FIXED to zero": {constants.GameRewardTypeFixed, `{"amount": 1}`, 9_000, `{"amount":0}`},
"FIXED ×1.1": {constants.GameRewardTypeFixed, `{"amount": 10}`, 11_000, `{"amount":11}`},
"FIXED ×1": {constants.GameRewardTypeFixed, `{"amount": 7}`, 10_000, `{"amount":7}`},
"SCORE_BASED": {constants.GameRewardTypeScoreBased, `{"bands": [{"min": 0, "max": 100, "amount": 1}, {"min": 101, "amount": 20}]}`, 8_100, `{"bands":[{"min":0,"max":100,"amount":0},{"min":101,"amount":16}]}`},
"OUTCOME_BASED": {constants.GameRewardTypeOutcomeBased, `{"outcomes": {"PERFECT": 20, "GOOD": 10, "FAIL": 0}}`, 9_000, `{"outcomes":{"FAIL":0,"GOOD":9,"PERFECT":18}}`},
"PROBABILITY": {constants.GameRewardTypeProbability, `{"table": [{"weight": 1, "amount": 1000}, {"weight": 999, "amount": 0}]}`, 8_500, `{"table":[{"weight":1,"amount":850},{"weight":999,"amount":0}]}`},
} {
c, err := RewardCalculatorFor(tc.rewardType)
require.NoError(t, err)
scaled, err := c.Scale(json.RawMessage(tc.rules), tc.multiplier)
require.NoError(t, err, name)
assert.JSONEq(t, tc.want, string(scaled), name)
assert.NoError(t, c.Validate(scaled), "%s: scaled rules stay valid", name)
}
c, _ := RewardCalculatorFor(constants.GameRewardTypeFixed)
_, err := c.Scale(json.RawMessage(`{"amount": 9223372036854775807}`), 15_000)
assert.ErrorIs(t, err, ErrInvalidRewardRules, "overflow is refused, not wrapped")
_, err = c.Scale(json.RawMessage(`{"amount": -1}`), 9_000)
assert.ErrorIs(t, err, ErrInvalidRewardRules)
v, err := scaleRewardAmount(math.MaxInt64/15_000, 15_000)
require.NoError(t, err)
assert.EqualValues(t, math.MaxInt64/15_000*15_000/10_000, v)
}