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>
161 lines
5.6 KiB
Go
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))
|
|
}
|