Files
apskel-pos-backend/internal/processor/game_budget_controller_processor.go
efrilmandClaude Opus 5.5 296708244e 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>
2026-10-07 21:18:31 +07:00

369 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
})
}