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>
369 lines
15 KiB
Go
369 lines
15 KiB
Go
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,
|
||
})
|
||
}
|