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