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

154 lines
5.8 KiB
Go

package processor
import (
"context"
"math"
"strings"
"time"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// enakGameAnalyticsMaxDays bounds a range, so one request cannot scan years of ledger.
const enakGameAnalyticsMaxDays = 366
// EnakGameAnalyticsProcessor answers the EnakGame dashboards (docs/tasks-enakgame.md
// EG-903, PRD §36) for an organization and a range of days in Asia/Jakarta.
type EnakGameAnalyticsProcessor struct {
analytics repository.EnakGameAnalyticsRepository
}
func NewEnakGameAnalyticsProcessor(analytics repository.EnakGameAnalyticsRepository) *EnakGameAnalyticsProcessor {
return &EnakGameAnalyticsProcessor{analytics: analytics}
}
// analyticsRange reads from and to, both days included, as [start, end).
func analyticsRange(q models.EnakGameAnalyticsQuery) (start, end time.Time, err error) {
from, err := time.Parse("2006-01-02", strings.TrimSpace(q.From))
if err != nil {
return start, end, enakGameRejected("from must be a date like 2026-10-01")
}
to, err := time.Parse("2006-01-02", strings.TrimSpace(q.To))
if err != nil {
return start, end, enakGameRejected("to must be a date like 2026-10-31")
}
if to.Before(from) {
return start, end, enakGameRejected("to cannot be before from")
}
if daysBetween(from, to)+1 > enakGameAnalyticsMaxDays {
return start, end, enakGameRejected("the range can span at most %d days", enakGameAnalyticsMaxDays)
}
return jakartaMidnight(from), jakartaMidnight(to.AddDate(0, 0, 1)), nil
}
// Games is how the organization's games were played, by the day each session
// started.
func (p *EnakGameAnalyticsProcessor) Games(ctx context.Context, organizationID uuid.UUID, q models.EnakGameAnalyticsQuery) (*models.EnakGameAnalytics, error) {
start, end, err := analyticsRange(q)
if err != nil {
return nil, err
}
var gameID *uuid.UUID
if s := strings.TrimSpace(q.GameID); s != "" {
id, err := uuid.Parse(s)
if err != nil {
return nil, enakGameRejected("game_id must be a UUID")
}
gameID = &id
}
rows, err := p.analytics.SessionStats(ctx, organizationID, start, end, gameID)
if err != nil {
return nil, err
}
out := &models.EnakGameAnalytics{From: strings.TrimSpace(q.From), To: strings.TrimSpace(q.To), Games: []models.EnakGameGameStats{}}
for _, row := range rows {
if row.GameID == nil {
out.Totals = gameStats(row)
continue
}
g := models.EnakGameGameStats{GameID: *row.GameID, EnakGameStats: gameStats(row)}
if row.GameName != nil {
g.GameName = *row.GameName
}
out.Games = append(out.Games, g)
}
return out, nil
}
func gameStats(r repository.GameSessionStats) models.EnakGameStats {
s := models.EnakGameStats{
Plays: r.Plays, Completed: r.Completed, Refunded: r.Refunded, Expired: r.Expired, Flagged: r.Flagged,
Players: r.Players, AverageScore: r.AverageScore, CoinIssued: r.CoinIssued,
EntryCostPaid: r.EntryCost, CoinRefunded: r.CoinRefunded,
}
if r.Completed > 0 {
s.AverageReward = ratio(r.CoinIssued, r.Completed)
}
if r.Plays > 0 {
s.RewardPerPlay = ratio(r.CoinIssued, r.Plays)
}
return s
}
// ratio is part / whole with two decimals.
func ratio(part, whole int64) float64 {
return math.Round(float64(part)*100/float64(whole)) / 100
}
// Economy is how EnakCoin and EnakPoint moved in the organization, from the ledger.
func (p *EnakGameAnalyticsProcessor) Economy(ctx context.Context, organizationID uuid.UUID, q models.EnakGameAnalyticsQuery) (*models.EnakGameEconomyAnalytics, error) {
start, end, err := analyticsRange(q)
if err != nil {
return nil, err
}
flows, err := p.analytics.WalletFlows(ctx, organizationID, start, end)
if err != nil {
return nil, err
}
balances, err := p.analytics.BalancesAt(ctx, organizationID, end)
if err != nil {
return nil, err
}
out := economy(flows, balances)
out.From, out.To = strings.TrimSpace(q.From), strings.TrimSpace(q.To)
return out, nil
}
// economy turns ledger totals into the headline numbers. Transfers move value between
// customers and count in none of them; ByType still lists them.
func economy(flows []repository.WalletFlow, balances map[string]int64) *models.EnakGameEconomyAnalytics {
type key struct{ currency, txType string }
byKey := make(map[key]repository.WalletFlow, len(flows))
out := &models.EnakGameEconomyAnalytics{ByType: make([]models.WalletFlowTotals, 0, len(flows))}
for _, f := range flows {
byKey[key{f.Currency, f.Type}] = f
out.ByType = append(out.ByType, models.WalletFlowTotals{
Currency: f.Currency, Type: f.Type, Credit: f.Credit, Debit: f.Debit, Transactions: f.Transactions,
})
}
coin := func(txType string) repository.WalletFlow { return byKey[key{constants.WalletCurrencyCoin, txType}] }
point := func(txType string) repository.WalletFlow { return byKey[key{constants.WalletCurrencyPoint, txType}] }
c := &out.Coin
c.GameRewards = coin(constants.WalletTxTypeGameReward).Credit
c.Generated = c.GameRewards + coin(constants.WalletTxTypeEarn).Credit - coin(constants.WalletTxTypeEarnReversal).Debit +
coin(constants.WalletTxTypeMigration).Credit + coin(constants.WalletTxTypeAdjustment).Credit
c.SpentOnGames = coin(constants.WalletTxTypeGameSpend).Debit - coin(constants.WalletTxTypeGameSpendRefund).Credit
c.Exchanged = coin(constants.WalletTxTypeExchangeOut).Debit
c.Spent = c.SpentOnGames + c.Exchanged
c.Expired = coin(constants.WalletTxTypeExpire).Debit
c.Outstanding = balances[constants.WalletCurrencyCoin]
pt := &out.Point
pt.Earned = point(constants.WalletTxTypeEarn).Credit - point(constants.WalletTxTypeEarnReversal).Debit
pt.Exchanged = point(constants.WalletTxTypeExchangeIn).Credit
pt.Redeemed = point(constants.WalletTxTypeRewardRedeem).Debit - point(constants.WalletTxTypeRewardRedeemRefund).Credit
pt.Expired = point(constants.WalletTxTypeExpire).Debit
pt.Balance = balances[constants.WalletCurrencyPoint]
return out
}