Files
apskel-pos-backend/internal/processor/game_budget_controller_processor.go
T

369 lines
15 KiB
Go
Raw Normal View History

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