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, }) }