Files
apskel-pos-backend/internal/processor/game_budget_metrics_processor.go
T
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

161 lines
5.6 KiB
Go

package processor
import (
"context"
"encoding/json"
"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"
)
// budgetForecastWindowDays is how many days of realized cost the forecast averages
// (docs/rfc-enakgame.md §10).
const budgetForecastWindowDays = 7
// GameBudgetMetricsProcessor computes how a budget stands (§10, PRD §8, §32), on read.
//
// Realized cost is what redeemed vouchers cost the budget: for a global budget, what
// was recognized within its period; for an event budget, whenever it was recognized,
// because the cost was born from that event's rewards. The forecast adds the average
// daily cost of the last seven days, today included, for each day left.
type GameBudgetMetricsProcessor struct {
budgets repository.GameBudgetRepository
metrics repository.GameBudgetMetricsRepository
now func() time.Time
}
func NewGameBudgetMetricsProcessor(budgets repository.GameBudgetRepository, metrics repository.GameBudgetMetricsRepository) *GameBudgetMetricsProcessor {
return &GameBudgetMetricsProcessor{budgets: budgets, metrics: metrics, now: time.Now}
}
// budgetMetricInputs is what the metrics are computed from.
type budgetMetricInputs struct {
Realized int64
RealizedWindow int64
WindowDays int64
RemainingDays int64
CoinIssued int64
Coins, Points int64
}
func (p *GameBudgetMetricsProcessor) Metrics(ctx context.Context, organizationID, budgetID uuid.UUID) (*models.GameBudgetMetrics, error) {
budget, err := p.budgets.GetBudget(ctx, organizationID, budgetID)
if err != nil {
return nil, err
}
return p.metricsAt(ctx, budget, p.now())
}
// metricsAt computes a budget's metrics as of now.
func (p *GameBudgetMetricsProcessor) metricsAt(ctx context.Context, budget *entities.GameBudget, now time.Time) (*models.GameBudgetMetrics, error) {
var err error
today := civilDay(walletDay(now))
start, end := civilDay(budget.PeriodStart), civilDay(budget.PeriodEnd)
var in budgetMetricInputs
var from, to *time.Time
if budget.Scope == constants.GameBudgetScopeGlobal {
f, t := jakartaMidnight(start), jakartaMidnight(end.AddDate(0, 0, 1))
from, to = &f, &t
}
if in.Realized, err = p.metrics.RealizedCost(ctx, budget.ID, from, to); err != nil {
return nil, err
}
if !today.Before(start) {
windowStart := today.AddDate(0, 0, 1-budgetForecastWindowDays)
if windowStart.Before(start) {
windowStart = start
}
in.WindowDays = daysBetween(windowStart, today) + 1
w := jakartaMidnight(windowStart)
if in.RealizedWindow, err = p.metrics.RealizedCost(ctx, budget.ID, &w, to); err != nil {
return nil, err
}
if in.RemainingDays = daysBetween(today, end); in.RemainingDays < 0 {
in.RemainingDays = 0
}
}
if in.CoinIssued, err = p.metrics.CoinIssued(ctx, budget.ID); err != nil {
return nil, err
}
if in.Coins, in.Points, err = p.metrics.Exposure(ctx, budget.ID, now); err != nil {
return nil, err
}
m := budgetMetrics(budget, today, in)
return &m, nil
}
// budgetMetrics computes the metrics of a budget on a day from what was read.
func budgetMetrics(b *entities.GameBudget, today time.Time, in budgetMetricInputs) models.GameBudgetMetrics {
m := models.GameBudgetMetrics{
BudgetID: b.ID, Scope: b.Scope, Amount: b.Amount,
PeriodStart: b.PeriodStart.Format("2006-01-02"), PeriodEnd: b.PeriodEnd.Format("2006-01-02"), AsOf: today.Format("2006-01-02"),
RealizedCost: in.Realized, Remaining: b.Amount - in.Realized,
WindowDays: in.WindowDays, RemainingDays: in.RemainingDays, ForecastCost: in.Realized,
CoinIssued: in.CoinIssued, Exposure: models.GameBudgetExposure{Coins: in.Coins, Points: in.Points},
}
if in.WindowDays > 0 {
m.DailyBurn = in.RealizedWindow / in.WindowDays
// Computed from the window's total, not the rounded daily average, so whole
// rupiah are not lost day after day.
m.ForecastCost += in.RealizedWindow * in.RemainingDays / in.WindowDays
}
m.ForecastRemaining = b.Amount - m.ForecastCost
m.UtilizationPercent = percentOf(m.RealizedCost, b.Amount)
m.ForecastUtilizationPercent = percentOf(m.ForecastCost, b.Amount)
warning, critical := constants.GameBudgetWarningDefault, constants.GameBudgetCriticalDefault
var set models.GameBudgetThresholds
if len(b.Thresholds) > 0 {
_ = json.Unmarshal(b.Thresholds, &set)
}
if set.Warning != nil {
warning = *set.Warning
}
if set.Critical != nil {
critical = *set.Critical
}
m.Thresholds = models.GameBudgetThresholds{Warning: &warning, Critical: &critical}
highest := math.Max(m.UtilizationPercent, m.ForecastUtilizationPercent)
switch {
case m.RealizedCost >= b.Amount:
m.Status = constants.GameBudgetExhausted
case m.ForecastCost > b.Amount, highest >= float64(critical):
m.Status = constants.GameBudgetCritical
case highest >= float64(warning):
m.Status = constants.GameBudgetWarning
default:
m.Status = constants.GameBudgetHealthy
}
return m
}
// percentOf is part as a percent of whole, with two decimals.
func percentOf(part, whole int64) float64 {
if whole <= 0 {
return 0
}
return math.Round(float64(part)*10000/float64(whole)) / 100
}
// civilDay is the calendar date of t, as midnight UTC, for counting days.
func civilDay(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
}
// jakartaMidnight is when a calendar date starts in Asia/Jakarta.
func jakartaMidnight(day time.Time) time.Time {
return time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, walletDisplayLocation)
}
func daysBetween(a, b time.Time) int64 {
return int64(math.Round(b.Sub(a).Hours() / 24))
}