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:
co-authored by
Claude Opus 5.5
parent
798a36bd6c
commit
296708244e
@@ -0,0 +1,368 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user