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
+165 -2
View File
@@ -84,13 +84,26 @@ type GameRewardConfig struct {
EffectiveAt *time.Time `json:"effective_at"`
CreatedBy uuid.UUID `json:"created_by"`
Reason *string `json:"reason"`
CreatedAt time.Time `json:"created_at"`
// Set on a version made by accepting a Budget Controller recommendation: the
// admin's version it scales, by how much, and for which budget.
BaseConfigID *uuid.UUID `json:"base_config_id"`
Multiplier *float64 `json:"multiplier"`
BudgetID *uuid.UUID `json:"budget_id"`
CreatedAt time.Time `json:"created_at"`
}
// GameBudgetThresholds are percents of utilization or forecast (PRD §8, §32).
// GameBudgetThresholds are percents of utilization or forecast (PRD §8, §32), and the
// guardrails of the Budget Controller (PRD §31).
type GameBudgetThresholds struct {
Warning *int64 `json:"warning,omitempty"`
Critical *int64 `json:"critical,omitempty"`
// Percent one recommendation may move rewards by, either way.
MaxStepPercent *int64 `json:"max_step_percent,omitempty"`
// Bounds of a game's multiplier, in percent of the configuration its admin wrote.
MinMultiplierPercent *int64 `json:"min_multiplier_percent,omitempty"`
MaxMultiplierPercent *int64 `json:"max_multiplier_percent,omitempty"`
// Days after an accepted recommendation before the organization gets another.
CooldownDays *int64 `json:"cooldown_days,omitempty"`
}
// GameBudgetInput creates or changes a budget (§5.6). Dates are YYYY-MM-DD and
@@ -368,6 +381,156 @@ type GameBudgetExposure struct {
Points int64 `json:"points"`
}
// GameBudgetRecommendation is what the Budget Controller suggests for a global budget
// (docs/rfc-enakgame.md §10, PRD §29–§31): a multiplier on every game's reward that
// brings the forecast to the budget, within the guardrails.
type GameBudgetRecommendation struct {
BudgetID uuid.UUID `json:"budget_id"`
// RECOMMENDED, NO_CHANGE, COOLDOWN, AT_LIMIT, INSUFFICIENT_DATA or OUT_OF_PERIOD.
// Only RECOMMENDED can be accepted.
State string `json:"state"`
Message string `json:"message"`
Metrics GameBudgetMetrics `json:"metrics"`
// The guardrails used: the budget's own, or the defaults for those it does not set.
Guardrails GameBudgetThresholds `json:"guardrails"`
// The multiplier that would make the forecast meet the budget, before the
// guardrails; nil when there is no cost to extrapolate.
TargetMultiplier *float64 `json:"target_multiplier"`
// The target within one step, rounded down to two decimals. Accepting sends it back.
Multiplier float64 `json:"multiplier"`
// When the organization may accept again, during a cooldown.
CooldownUntil *time.Time `json:"cooldown_until,omitempty"`
// The games whose reward would change, each with its new version.
Games []GameRewardAdjustment `json:"games"`
}
// GameRewardAdjustment is how accepting a recommendation changes a game's reward: a
// new version of its active configuration, with the base's amounts scaled.
type GameRewardAdjustment struct {
GameID uuid.UUID `json:"game_id"`
GameName string `json:"game_name"`
// The active version, and the admin's version both are measured against.
RewardConfigID uuid.UUID `json:"reward_config_id"`
Version int `json:"version"`
BaseConfigID uuid.UUID `json:"base_config_id"`
RewardType string `json:"reward_type"`
CurrentMultiplier float64 `json:"current_multiplier"`
NewMultiplier float64 `json:"new_multiplier"`
CurrentRules json.RawMessage `json:"current_rules"`
NewRules json.RawMessage `json:"new_rules"`
CurrentMaxReward int64 `json:"current_max_reward"`
NewMaxReward int64 `json:"new_max_reward"`
}
// GameBudgetRecommendationAcceptInput accepts the recommendation the admin saw. When
// the recommendation changed since, nothing is applied.
type GameBudgetRecommendationAcceptInput struct {
Multiplier *float64 `json:"multiplier"`
Reason *string `json:"reason"`
}
// GameBudgetRecommendationAccepted is what accepting made: one active version per
// game.
type GameBudgetRecommendationAccepted struct {
BudgetID uuid.UUID `json:"budget_id"`
Multiplier float64 `json:"multiplier"`
RewardConfigs []GameRewardConfig `json:"reward_configs"`
}
// EnakGameAnalyticsQuery is a range of days in Asia/Jakarta, both ends included.
type EnakGameAnalyticsQuery struct {
From string `form:"from"`
To string `form:"to"`
// Games analytics only: one game instead of all.
GameID string `form:"game_id"`
}
// EnakGameAnalytics is how an organization's games were played over a range of days
// (PRD §36 Game), by the day each session started.
type EnakGameAnalytics struct {
From string `json:"from"`
To string `json:"to"`
Totals EnakGameStats `json:"totals"`
Games []EnakGameGameStats `json:"games"`
}
type EnakGameGameStats struct {
GameID uuid.UUID `json:"game_id"`
GameName string `json:"game_name"`
EnakGameStats
}
type EnakGameStats struct {
// Sessions started, whatever became of them.
Plays int64 `json:"plays"`
Completed int64 `json:"completed"`
Refunded int64 `json:"refunded"`
Expired int64 `json:"expired"`
Flagged int64 `json:"flagged"`
// Customers who started at least one session.
Players int64 `json:"players"`
// Over completed sessions that reported a score; nil when none did.
AverageScore *float64 `json:"average_score"`
// EnakCoin per completed session, and per play.
AverageReward float64 `json:"average_reward"`
RewardPerPlay float64 `json:"reward_per_play"`
CoinIssued int64 `json:"coin_issued"`
// EnakCoin paid to start, and the part refunded.
EntryCostPaid int64 `json:"entry_cost_paid"`
CoinRefunded int64 `json:"coin_refunded"`
}
// EnakGameEconomyAnalytics is how EnakCoin and EnakPoint moved in an organization over
// a range of days (PRD §36 Economy), from the ledger.
type EnakGameEconomyAnalytics struct {
From string `json:"from"`
To string `json:"to"`
Coin EnakGameCoinFlows `json:"coin"`
Point EnakGamePointFlows `json:"point"`
// Every ledger type that moved, for what the headline numbers leave out.
ByType []WalletFlowTotals `json:"by_type"`
}
type EnakGameCoinFlows struct {
// New EnakCoin: game rewards, earning less its reversals, migration and upward
// adjustments.
Generated int64 `json:"generated"`
GameRewards int64 `json:"game_rewards"`
// Entry costs, less the refunded ones.
SpentOnGames int64 `json:"spent_on_games"`
// Exchanged into EnakPoint.
Exchanged int64 `json:"exchanged"`
// SpentOnGames + Exchanged.
Spent int64 `json:"spent"`
Expired int64 `json:"expired"`
// Held by customers at the end of the range.
Outstanding int64 `json:"outstanding"`
}
type EnakGamePointFlows struct {
// From shopping, less its reversals.
Earned int64 `json:"earned"`
// Received from exchanging EnakCoin.
Exchanged int64 `json:"exchanged"`
// Spent on vouchers, less the refunded redemptions.
Redeemed int64 `json:"redeemed"`
Expired int64 `json:"expired"`
// Held by customers at the end of the range.
Balance int64 `json:"balance"`
}
// WalletFlowTotals is what one ledger type moved in one currency.
type WalletFlowTotals struct {
Currency string `json:"currency"`
Type string `json:"type"`
Credit int64 `json:"credit"`
Debit int64 `json:"debit"`
Transactions int64 `json:"transactions"`
}
// GameEventInput creates or changes an event (§5.5). On a change, fields left out
// keep their value.
type GameEventInput struct {