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,153 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user