diff --git a/docs/rfc-enakgame.md b/docs/rfc-enakgame.md index 1b85d96..70ae45f 100644 --- a/docs/rfc-enakgame.md +++ b/docs/rfc-enakgame.md @@ -804,6 +804,29 @@ membuat forecast = budget, lalu batasi dengan step maksimum dan min/max multipli §31). Rekomendasi ditampilkan ke admin. Bila disetujui, admin membuat **reward config versi baru** (§5.2). Tidak ada perubahan reward tanpa versi baru dan tanpa audit. +Implementasi (EG-901, EG-902): + +- Hanya budget `GLOBAL` (yang membayar base reward). Budget `EVENT` ditolak; tambahan + event diatur di event-nya. +- Target = (budget − realized) / (forecast − realized), karena hanya biaya ke depan yang + ikut berubah bila reward diubah. Target dibatasi ±step dari 1 lalu dibulatkan ke bawah + ke dua desimal. Bisa turun **atau naik**. +- Multiplier tiap game diukur terhadap **base**: versi terakhir yang ditulis admin. + Versi buatan Budget Controller menyimpan `base_config_id`, `multiplier`, dan + `budget_id` (migrasi 000112), dan aturannya selalu dihitung ulang dari base (dibulatkan + ke bawah), jadi pembulatan tidak menumpuk. Min/max berlaku untuk multiplier kumulatif + ini. Admin yang menulis versi baru memulai base baru di 1. +- Cooldown per organisasi: setelah rekomendasi diterima, rekomendasi berikutnya baru bisa + diterima setelah `cooldown_days`. +- Guardrail disimpan di `game_budgets.thresholds` bersama warning/critical: + `max_step_percent` (default 10), `min_multiplier_percent` (50), + `max_multiplier_percent` (150), `cooldown_days` (7). Nilai default ini **sementara**, + menunggu §19.2 #4. +- Terima: `POST /budgets/:id/recommendation/accept` dengan `multiplier` yang dilihat + admin. Rekomendasi dihitung ulang di dalam transaksi; bila berbeda, tidak ada yang + berubah. Satu transaksi: versi lama `RETIRED`, versi baru langsung `ACTIVE`, audit + `source = budget_controller` per config dan `RECOMMENDATION_ACCEPTED` di budget. + `GET` metrik cukup cepat untuk dashboard selama index di §5.7 ada. Bila nanti lambat, tambahkan snapshot harian, bukan cache yang di-invalidate. @@ -833,7 +856,8 @@ Prefix `/enakgame` dipakai karena `/customer/games` sudah dipakai alur spin lama | Games | CRUD, `PUT /:id/status` | | Reward configs | `POST /games/:id/reward-configs` (versi baru), `POST /reward-configs/:id/activate`, `GET` daftar versi | | Events | CRUD, `PUT /:id/status` | -| Budgets | CRUD, `GET /:id/metrics`, `GET /:id/recommendation` | +| Budgets | CRUD, `GET /:id/metrics`, `GET /:id/recommendation`, `POST /:id/recommendation/accept` | +| Analytics | `GET /analytics/games?from=&to=&game_id=`, `GET /analytics/economy?from=&to=` (tanggal Asia/Jakarta, maks 366 hari) | | Vouchers | CRUD, `POST /:id/codes` (impor CSV), `GET /:id/codes` | | Redemptions | `GET` list, `GET /:id` dengan atribusi cost | | Sessions | `GET` list + filter `flagged` | @@ -974,7 +998,8 @@ Dari PRD §43: 1. **Pembulatan reward.** Usulan §8: bulatkan ke bawah, mengikuti K6. 2. **Event stacking.** Sementara memakai default PRD §16. 3. **Budget exhaustion policy**, untuk budget global dan budget event. -4. **Threshold Budget Controller.** +4. **Threshold Budget Controller.** Sementara memakai default di §10 (step 10%, + multiplier 50%–150%, cooldown 7 hari), bisa diubah per budget. 5. **Timeout reservasi voucher.** Dengan §7.4, hanya relevan untuk voucher eksternal. Tidak ada yang memblokir langkah 1–7 di §17. Nomor 3 dan 4 harus diputuskan sebelum langkah diff --git a/internal/app/app.go b/internal/app/app.go index 36fd6fc..a1b7a2f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -604,6 +604,8 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con inventoryService := service.NewInventoryService(processors.inventoryProcessor) orderService := service.NewOrderServiceImpl(processors.orderProcessor, repos.tableRepo, nil, processors.orderIngredientTransactionProcessor, *repos.productRecipeRepo, repos.txManager, repos.sessionRepo, processors.notificationProcessor, repos.userRepo) // Will be updated after orderIngredientTransactionService is created paymentMethodService := service.NewPaymentMethodService(processors.paymentMethodProcessor) + enakGameAudit := processor.NewAuditLogger(repository.NewAuditLogRepository(a.db)) + gameBudgetMetrics := processor.NewGameBudgetMetricsProcessor(repository.NewGameBudgetRepository(a.db), repository.NewGameBudgetMetricsRepository(a.db)) fileService := service.NewFileServiceImpl(processors.fileProcessor) var customerService service.CustomerService = service.NewCustomerService(processors.customerProcessor) analyticsService := service.NewAnalyticsServiceImpl(processors.analyticsProcessor) @@ -679,10 +681,13 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con customerOutletService: service.NewCustomerOutletService(processors.customerOutletProcessor), customerOrderService: service.NewCustomerOrderService(processors.customerOrderProcessor), enakGameAdminService: service.NewEnakGameAdminService(processors.enakGameAdminProcessor, processors.gameBudgetProcessor, - processor.NewGameBudgetMetricsProcessor(repository.NewGameBudgetRepository(a.db), repository.NewGameBudgetMetricsRepository(a.db)), + gameBudgetMetrics, + processor.NewGameBudgetControllerProcessor(repository.NewGameBudgetRepository(a.db), gameBudgetMetrics, repository.NewEnakGameRepository(a.db), + enakGameAudit, repos.txManager), processor.NewGameEventProcessor(repository.NewGameEventRepository(a.db), repository.NewEnakGameRepository(a.db), repository.NewGameBudgetRepository(a.db), - processor.NewAuditLogger(repository.NewAuditLogRepository(a.db)), repos.txManager), - processors.voucherAdminProcessor), + enakGameAudit, repos.txManager), + processors.voucherAdminProcessor, + processor.NewEnakGameAnalyticsProcessor(repository.NewEnakGameAnalyticsRepository(a.db))), enakGameCustomerService: service.NewEnakGameCustomerService(processors.gameSessionProcessor, processors.voucherRedemptionProcessor), } } diff --git a/internal/constants/enakgame.go b/internal/constants/enakgame.go index 9cee3b6..d0ae9fc 100644 --- a/internal/constants/enakgame.go +++ b/internal/constants/enakgame.go @@ -72,6 +72,9 @@ const ( AuditEntityVoucher = "VOUCHER" ) +// audit_logs.action on a GAME_BUDGET whose recommendation was accepted. +const AuditActionRecommendationAccepted = "RECOMMENDATION_ACCEPTED" + // game_reward_counters.scope_type: what an Economy Guard counter counts for (§5.8). const ( GameRewardScopeUser = "USER" @@ -144,3 +147,32 @@ const ( GameEventStatusEnded = "ENDED" GameEventStatusCancelled = "CANCELLED" ) + +// Budget Controller guardrails of a budget that sets none (PRD §31). Pending RFC +// §19.2 #4. +const ( + // Percent a recommendation may move rewards by, either way. + GameBudgetMaxStepDefault = int64(10) + // Bounds of a game's multiplier, in percent of the configuration its admin wrote. + GameBudgetMinMultiplierDefault = int64(50) + GameBudgetMaxMultiplierDefault = int64(150) + // Days after an accepted recommendation before the next one, in the organization. + // The same as the forecast window, so the next one sees the effect of the last. + GameBudgetCooldownDaysDefault = int64(7) +) + +// What a Budget Controller recommendation says (docs/rfc-enakgame.md §10). Only +// RECOMMENDED can be accepted. +const ( + GameBudgetRecommended = "RECOMMENDED" + // The forecast meets the budget closely enough that rewards stay. + GameBudgetRecommendationNoChange = "NO_CHANGE" + // The last accepted recommendation of the organization is too recent. + GameBudgetRecommendationCooldown = "COOLDOWN" + // Every game is already at its min or max multiplier. + GameBudgetRecommendationAtLimit = "AT_LIMIT" + // No cost in the forecast window to extrapolate from. + GameBudgetRecommendationNoData = "INSUFFICIENT_DATA" + // The budget's period has not started or has no day left after today. + GameBudgetRecommendationOutOfPeriod = "OUT_OF_PERIOD" +) diff --git a/internal/entities/enakgame.go b/internal/entities/enakgame.go index 797595b..290e632 100644 --- a/internal/entities/enakgame.go +++ b/internal/entities/enakgame.go @@ -97,7 +97,12 @@ type GameRewardConfig struct { EffectiveAt *time.Time `json:"effective_at"` CreatedBy uuid.UUID `gorm:"type:uuid;not null" json:"created_by"` Reason *string `gorm:"size:255" json:"reason"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + // Set together on a version made by accepting a Budget Controller recommendation + // (§10): the admin's version it scales, by how much, and for which budget. + BaseConfigID *uuid.UUID `gorm:"type:uuid" json:"base_config_id"` + Multiplier *float64 `gorm:"type:numeric(6,4)" json:"multiplier"` + BudgetID *uuid.UUID `gorm:"type:uuid" json:"budget_id"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` } func (GameRewardConfig) TableName() string { return "game_reward_configs" } diff --git a/internal/handler/enakgame_admin_handler.go b/internal/handler/enakgame_admin_handler.go index c91869c..e228eaa 100644 --- a/internal/handler/enakgame_admin_handler.go +++ b/internal/handler/enakgame_admin_handler.go @@ -237,6 +237,54 @@ func (h *EnakGameAdminHandler) BudgetMetrics(c *gin.Context) { util.HandleResponse(c.Writer, c.Request, h.service.BudgetMetrics(ctx, appcontext.FromGinContext(ctx), id), method) } +// BudgetRecommendation is GET /marketing/enakgame/budgets/:id/recommendation. +func (h *EnakGameAdminHandler) BudgetRecommendation(c *gin.Context) { + const method = "EnakGameAdminHandler::BudgetRecommendation" + id, ok := pathID(c, "id", method) + if !ok { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.BudgetRecommendation(ctx, appcontext.FromGinContext(ctx), id), method) +} + +// AcceptBudgetRecommendation is POST /marketing/enakgame/budgets/:id/recommendation/accept. +func (h *EnakGameAdminHandler) AcceptBudgetRecommendation(c *gin.Context) { + const method = "EnakGameAdminHandler::AcceptBudgetRecommendation" + id, ok := pathID(c, "id", method) + if !ok { + return + } + body, ok := rawBody(c, method) + if !ok { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.AcceptBudgetRecommendation(ctx, appcontext.FromGinContext(ctx), id, body), method) +} + +// GameAnalytics is GET /marketing/enakgame/analytics/games?from=&to=&game_id=. +func (h *EnakGameAdminHandler) GameAnalytics(c *gin.Context) { + const method = "EnakGameAdminHandler::GameAnalytics" + var q models.EnakGameAnalyticsQuery + if !bindQuery(c, &q, method) { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.GameAnalytics(ctx, appcontext.FromGinContext(ctx), q), method) +} + +// EconomyAnalytics is GET /marketing/enakgame/analytics/economy?from=&to=. +func (h *EnakGameAdminHandler) EconomyAnalytics(c *gin.Context) { + const method = "EnakGameAdminHandler::EconomyAnalytics" + var q models.EnakGameAnalyticsQuery + if !bindQuery(c, &q, method) { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.EconomyAnalytics(ctx, appcontext.FromGinContext(ctx), q), method) +} + // CreateVoucher is POST /marketing/enakgame/vouchers. func (h *EnakGameAdminHandler) CreateVoucher(c *gin.Context) { const method = "EnakGameAdminHandler::CreateVoucher" diff --git a/internal/handler/enakgame_db_test.go b/internal/handler/enakgame_db_test.go index 5377ac0..6802818 100644 --- a/internal/handler/enakgame_db_test.go +++ b/internal/handler/enakgame_db_test.go @@ -38,11 +38,13 @@ func enakGameHandlers(db *gorm.DB) (*EnakGameAdminHandler, *EnakGameCustomerHand wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db)) customers := repository.NewWalletMoveRepository(db) spendable := repository.NewWalletQueryRepository(db) + metrics := processor.NewGameBudgetMetricsProcessor(budgets, repository.NewGameBudgetMetricsRepository(db)) admin := NewEnakGameAdminHandler(service.NewEnakGameAdminService( - processor.NewEnakGameAdminProcessor(games, audit, txm), processor.NewGameBudgetProcessor(budgets, audit, txm), - processor.NewGameBudgetMetricsProcessor(budgets, repository.NewGameBudgetMetricsRepository(db)), + processor.NewEnakGameAdminProcessor(games, audit, txm), processor.NewGameBudgetProcessor(budgets, audit, txm), metrics, + processor.NewGameBudgetControllerProcessor(budgets, metrics, games, audit, txm), processor.NewGameEventProcessor(repository.NewGameEventRepository(db), games, budgets, audit, txm), - processor.NewVoucherAdminProcessor(vouchers, audit, txm))) + processor.NewVoucherAdminProcessor(vouchers, audit, txm), + processor.NewEnakGameAnalyticsProcessor(repository.NewEnakGameAnalyticsRepository(db)))) customer := NewEnakGameCustomerHandler(service.NewEnakGameCustomerService( processor.NewGameSessionProcessor(customers, games, repository.NewGameSessionRepository(db), budgets, repository.NewGameEventRepository(db), repository.NewGameRewardCounterRepository(db), processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm), diff --git a/internal/models/enakgame.go b/internal/models/enakgame.go index affa1fb..c1d9bd3 100644 --- a/internal/models/enakgame.go +++ b/internal/models/enakgame.go @@ -84,13 +84,26 @@ type GameRewardConfig struct { EffectiveAt *time.Time `json:"effective_at"` CreatedBy uuid.UUID `json:"created_by"` Reason *string `json:"reason"` - CreatedAt time.Time `json:"created_at"` + // Set on a version made by accepting a Budget Controller recommendation: the + // admin's version it scales, by how much, and for which budget. + BaseConfigID *uuid.UUID `json:"base_config_id"` + Multiplier *float64 `json:"multiplier"` + BudgetID *uuid.UUID `json:"budget_id"` + CreatedAt time.Time `json:"created_at"` } -// GameBudgetThresholds are percents of utilization or forecast (PRD §8, §32). +// GameBudgetThresholds are percents of utilization or forecast (PRD §8, §32), and the +// guardrails of the Budget Controller (PRD §31). type GameBudgetThresholds struct { Warning *int64 `json:"warning,omitempty"` Critical *int64 `json:"critical,omitempty"` + // Percent one recommendation may move rewards by, either way. + MaxStepPercent *int64 `json:"max_step_percent,omitempty"` + // Bounds of a game's multiplier, in percent of the configuration its admin wrote. + MinMultiplierPercent *int64 `json:"min_multiplier_percent,omitempty"` + MaxMultiplierPercent *int64 `json:"max_multiplier_percent,omitempty"` + // Days after an accepted recommendation before the organization gets another. + CooldownDays *int64 `json:"cooldown_days,omitempty"` } // GameBudgetInput creates or changes a budget (§5.6). Dates are YYYY-MM-DD and @@ -368,6 +381,156 @@ type GameBudgetExposure struct { Points int64 `json:"points"` } +// GameBudgetRecommendation is what the Budget Controller suggests for a global budget +// (docs/rfc-enakgame.md §10, PRD §29–§31): a multiplier on every game's reward that +// brings the forecast to the budget, within the guardrails. +type GameBudgetRecommendation struct { + BudgetID uuid.UUID `json:"budget_id"` + // RECOMMENDED, NO_CHANGE, COOLDOWN, AT_LIMIT, INSUFFICIENT_DATA or OUT_OF_PERIOD. + // Only RECOMMENDED can be accepted. + State string `json:"state"` + Message string `json:"message"` + + Metrics GameBudgetMetrics `json:"metrics"` + // The guardrails used: the budget's own, or the defaults for those it does not set. + Guardrails GameBudgetThresholds `json:"guardrails"` + + // The multiplier that would make the forecast meet the budget, before the + // guardrails; nil when there is no cost to extrapolate. + TargetMultiplier *float64 `json:"target_multiplier"` + // The target within one step, rounded down to two decimals. Accepting sends it back. + Multiplier float64 `json:"multiplier"` + // When the organization may accept again, during a cooldown. + CooldownUntil *time.Time `json:"cooldown_until,omitempty"` + // The games whose reward would change, each with its new version. + Games []GameRewardAdjustment `json:"games"` +} + +// GameRewardAdjustment is how accepting a recommendation changes a game's reward: a +// new version of its active configuration, with the base's amounts scaled. +type GameRewardAdjustment struct { + GameID uuid.UUID `json:"game_id"` + GameName string `json:"game_name"` + // The active version, and the admin's version both are measured against. + RewardConfigID uuid.UUID `json:"reward_config_id"` + Version int `json:"version"` + BaseConfigID uuid.UUID `json:"base_config_id"` + RewardType string `json:"reward_type"` + + CurrentMultiplier float64 `json:"current_multiplier"` + NewMultiplier float64 `json:"new_multiplier"` + CurrentRules json.RawMessage `json:"current_rules"` + NewRules json.RawMessage `json:"new_rules"` + CurrentMaxReward int64 `json:"current_max_reward"` + NewMaxReward int64 `json:"new_max_reward"` +} + +// GameBudgetRecommendationAcceptInput accepts the recommendation the admin saw. When +// the recommendation changed since, nothing is applied. +type GameBudgetRecommendationAcceptInput struct { + Multiplier *float64 `json:"multiplier"` + Reason *string `json:"reason"` +} + +// GameBudgetRecommendationAccepted is what accepting made: one active version per +// game. +type GameBudgetRecommendationAccepted struct { + BudgetID uuid.UUID `json:"budget_id"` + Multiplier float64 `json:"multiplier"` + RewardConfigs []GameRewardConfig `json:"reward_configs"` +} + +// EnakGameAnalyticsQuery is a range of days in Asia/Jakarta, both ends included. +type EnakGameAnalyticsQuery struct { + From string `form:"from"` + To string `form:"to"` + // Games analytics only: one game instead of all. + GameID string `form:"game_id"` +} + +// EnakGameAnalytics is how an organization's games were played over a range of days +// (PRD §36 Game), by the day each session started. +type EnakGameAnalytics struct { + From string `json:"from"` + To string `json:"to"` + Totals EnakGameStats `json:"totals"` + Games []EnakGameGameStats `json:"games"` +} + +type EnakGameGameStats struct { + GameID uuid.UUID `json:"game_id"` + GameName string `json:"game_name"` + EnakGameStats +} + +type EnakGameStats struct { + // Sessions started, whatever became of them. + Plays int64 `json:"plays"` + Completed int64 `json:"completed"` + Refunded int64 `json:"refunded"` + Expired int64 `json:"expired"` + Flagged int64 `json:"flagged"` + // Customers who started at least one session. + Players int64 `json:"players"` + // Over completed sessions that reported a score; nil when none did. + AverageScore *float64 `json:"average_score"` + // EnakCoin per completed session, and per play. + AverageReward float64 `json:"average_reward"` + RewardPerPlay float64 `json:"reward_per_play"` + CoinIssued int64 `json:"coin_issued"` + // EnakCoin paid to start, and the part refunded. + EntryCostPaid int64 `json:"entry_cost_paid"` + CoinRefunded int64 `json:"coin_refunded"` +} + +// EnakGameEconomyAnalytics is how EnakCoin and EnakPoint moved in an organization over +// a range of days (PRD §36 Economy), from the ledger. +type EnakGameEconomyAnalytics struct { + From string `json:"from"` + To string `json:"to"` + Coin EnakGameCoinFlows `json:"coin"` + Point EnakGamePointFlows `json:"point"` + // Every ledger type that moved, for what the headline numbers leave out. + ByType []WalletFlowTotals `json:"by_type"` +} + +type EnakGameCoinFlows struct { + // New EnakCoin: game rewards, earning less its reversals, migration and upward + // adjustments. + Generated int64 `json:"generated"` + GameRewards int64 `json:"game_rewards"` + // Entry costs, less the refunded ones. + SpentOnGames int64 `json:"spent_on_games"` + // Exchanged into EnakPoint. + Exchanged int64 `json:"exchanged"` + // SpentOnGames + Exchanged. + Spent int64 `json:"spent"` + Expired int64 `json:"expired"` + // Held by customers at the end of the range. + Outstanding int64 `json:"outstanding"` +} + +type EnakGamePointFlows struct { + // From shopping, less its reversals. + Earned int64 `json:"earned"` + // Received from exchanging EnakCoin. + Exchanged int64 `json:"exchanged"` + // Spent on vouchers, less the refunded redemptions. + Redeemed int64 `json:"redeemed"` + Expired int64 `json:"expired"` + // Held by customers at the end of the range. + Balance int64 `json:"balance"` +} + +// WalletFlowTotals is what one ledger type moved in one currency. +type WalletFlowTotals struct { + Currency string `json:"currency"` + Type string `json:"type"` + Credit int64 `json:"credit"` + Debit int64 `json:"debit"` + Transactions int64 `json:"transactions"` +} + // GameEventInput creates or changes an event (§5.5). On a change, fields left out // keep their value. type GameEventInput struct { diff --git a/internal/processor/enakgame_admin_processor.go b/internal/processor/enakgame_admin_processor.go index e7e54ef..3097aa6 100644 --- a/internal/processor/enakgame_admin_processor.go +++ b/internal/processor/enakgame_admin_processor.go @@ -443,7 +443,7 @@ func rewardConfigModel(c *entities.GameRewardConfig) *models.GameRewardConfig { return &models.GameRewardConfig{ ID: c.ID, GameID: c.GameID, Version: c.Version, RewardType: c.RewardType, Rules: json.RawMessage(c.Rules), MaxReward: c.MaxReward, Status: c.Status, EffectiveAt: c.EffectiveAt, CreatedBy: c.CreatedBy, - Reason: c.Reason, CreatedAt: c.CreatedAt, + Reason: c.Reason, BaseConfigID: c.BaseConfigID, Multiplier: c.Multiplier, BudgetID: c.BudgetID, CreatedAt: c.CreatedAt, } } diff --git a/internal/processor/enakgame_analytics_db_test.go b/internal/processor/enakgame_analytics_db_test.go new file mode 100644 index 0000000..481fe69 --- /dev/null +++ b/internal/processor/enakgame_analytics_db_test.go @@ -0,0 +1,80 @@ +package processor + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +// EG-903 against Postgres. Needs TEST_DATABASE_URL; see +// internal/repository/wallet_repository_test.go. +func TestEnakGameAnalytics_AgainstPostgres(t *testing.T) { + e := newEnakGameEnv(t) + ctx := e.ctx() + analytics := NewEnakGameAnalyticsProcessor(repository.NewEnakGameAnalyticsRepository(e.db)) + + // Sessions and ledger rows are stamped with the test clock and the database's, + // both now: a range around today holds them all. + today := walletDay(e.now) + q := models.EnakGameAnalyticsQuery{From: today.AddDate(0, 0, -1).Format("2006-01-02"), To: today.AddDate(0, 0, 1).Format("2006-01-02")} + + tap := e.gameWith("tap", entities.GameResultRules{}, constants.GameRewardTypeFixed, `{"amount": 10}`, 10) + run := e.gameWith("run", entities.GameResultRules{}, constants.GameRewardTypeScoreBased, + `{"bands": [{"min": 0, "max": 100, "amount": 5}, {"min": 101, "amount": 20}]}`, 20) + e.coins(e.alice, 10, nil) + e.coins(e.bob, 10, nil) + for i, key := range []string{"tap-1", "tap-2"} { + started, err := e.sessions.Start(ctx, e.alice, tap.ID, key) + require.NoError(t, err, i) + _, err = e.sessions.Complete(ctx, e.alice, started.SessionID, models.GameSessionCompleteInput{}) + require.NoError(t, err, i) + } + _, err := e.sessions.Start(ctx, e.bob, tap.ID, "tap-3") // left open + require.NoError(t, err) + started, err := e.sessions.Start(ctx, e.alice, run.ID, "run-1") + require.NoError(t, err) + _, err = e.sessions.Complete(ctx, e.alice, started.SessionID, models.GameSessionCompleteInput{Score: ptr(int64(150))}) + require.NoError(t, err) + + games, err := analytics.Games(ctx, e.orgA, q) + require.NoError(t, err) + assert.Equal(t, models.EnakGameStats{ + Plays: 4, Completed: 3, Players: 2, AverageScore: ptr(150.0), + AverageReward: 13.33, RewardPerPlay: 10, CoinIssued: 40, EntryCostPaid: 8, + }, games.Totals) + require.Len(t, games.Games, 2) + assert.Equal(t, tap.ID, games.Games[0].GameID, "most played first") + assert.EqualValues(t, 3, games.Games[0].Plays) + assert.EqualValues(t, 2, games.Games[0].Players) + assert.Nil(t, games.Games[0].AverageScore, "FIXED reports no score") + assert.EqualValues(t, 20, games.Games[1].CoinIssued) + + one, err := analytics.Games(ctx, e.orgA, models.EnakGameAnalyticsQuery{From: q.From, To: q.To, GameID: run.ID.String()}) + require.NoError(t, err) + assert.EqualValues(t, 1, one.Totals.Plays) + require.Len(t, one.Games, 1) + + other, err := analytics.Games(ctx, e.orgB, q) + require.NoError(t, err) + assert.Zero(t, other.Totals.Plays, "another organization's sessions never show") + assert.Empty(t, other.Games) + + economy, err := analytics.Economy(ctx, e.orgA, q) + require.NoError(t, err) + assert.Equal(t, models.EnakGameCoinFlows{ + Generated: 60, GameRewards: 40, SpentOnGames: 8, Spent: 8, Outstanding: 52, + }, economy.Coin) + assert.Zero(t, economy.Point) + + // A range before anything happened. + empty, err := analytics.Economy(ctx, e.orgA, models.EnakGameAnalyticsQuery{From: "2020-01-01", To: "2020-01-31"}) + require.NoError(t, err) + assert.Zero(t, empty.Coin) + assert.Empty(t, empty.ByType) +} diff --git a/internal/processor/enakgame_analytics_processor.go b/internal/processor/enakgame_analytics_processor.go new file mode 100644 index 0000000..63e2aea --- /dev/null +++ b/internal/processor/enakgame_analytics_processor.go @@ -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 +} diff --git a/internal/processor/enakgame_analytics_processor_test.go b/internal/processor/enakgame_analytics_processor_test.go new file mode 100644 index 0000000..3217c76 --- /dev/null +++ b/internal/processor/enakgame_analytics_processor_test.go @@ -0,0 +1,78 @@ +package processor + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +func TestAnalyticsRange(t *testing.T) { + start, end, err := analyticsRange(models.EnakGameAnalyticsQuery{From: "2026-10-01", To: "2026-10-31"}) + require.NoError(t, err) + assert.Equal(t, time.Date(2026, 9, 30, 17, 0, 0, 0, time.UTC), start.UTC(), "midnight in Jakarta") + assert.Equal(t, time.Date(2026, 10, 31, 17, 0, 0, 0, time.UTC), end.UTC(), "the last day included") + + _, _, err = analyticsRange(models.EnakGameAnalyticsQuery{From: "2026-01-01", To: "2027-01-01"}) + require.NoError(t, err, "366 days") + for name, q := range map[string]models.EnakGameAnalyticsQuery{ + "no from": {To: "2026-10-31"}, + "no to": {From: "2026-10-01"}, + "backwards": {From: "2026-10-31", To: "2026-10-01"}, + "too long": {From: "2026-01-01", To: "2027-01-02"}, + "not dates": {From: "1 Oct", To: "31 Oct"}, + } { + _, _, err := analyticsRange(q) + assert.ErrorIs(t, err, ErrEnakGameRejected, name) + } +} + +func TestGameStats(t *testing.T) { + s := gameStats(repository.GameSessionStats{Plays: 3, Completed: 2, CoinIssued: 25, EntryCost: 6, CoinRefunded: 2}) + assert.EqualValues(t, 12.5, s.AverageReward) + assert.InDelta(t, 8.33, s.RewardPerPlay, 1e-9) + assert.EqualValues(t, 6, s.EntryCostPaid) + + s = gameStats(repository.GameSessionStats{}) + assert.Zero(t, s.AverageReward, "no division by zero") + assert.Zero(t, s.RewardPerPlay) + assert.Nil(t, s.AverageScore) +} + +// EG-903: the economy's headline numbers from the ledger's totals. +func TestEconomy(t *testing.T) { + coin, point := constants.WalletCurrencyCoin, constants.WalletCurrencyPoint + flows := []repository.WalletFlow{ + {Currency: coin, Type: constants.WalletTxTypeGameReward, Credit: 500, Transactions: 40}, + {Currency: coin, Type: constants.WalletTxTypeMigration, Credit: 100, Transactions: 2}, + {Currency: coin, Type: constants.WalletTxTypeAdjustment, Credit: 30, Debit: 10, Transactions: 2}, + {Currency: coin, Type: constants.WalletTxTypeGameSpend, Debit: 200, Transactions: 50}, + {Currency: coin, Type: constants.WalletTxTypeGameSpendRefund, Credit: 8, Transactions: 2}, + {Currency: coin, Type: constants.WalletTxTypeExchangeOut, Debit: 120, Transactions: 3}, + {Currency: coin, Type: constants.WalletTxTypeExpire, Debit: 15, Transactions: 1}, + {Currency: coin, Type: constants.WalletTxTypeTransferOut, Debit: 50, Transactions: 1}, + {Currency: coin, Type: constants.WalletTxTypeTransferIn, Credit: 50, Transactions: 1}, + {Currency: point, Type: constants.WalletTxTypeEarn, Credit: 1_000, Transactions: 9}, + {Currency: point, Type: constants.WalletTxTypeEarnReversal, Debit: 100, Transactions: 1}, + {Currency: point, Type: constants.WalletTxTypeExchangeIn, Credit: 120, Transactions: 3}, + {Currency: point, Type: constants.WalletTxTypeRewardRedeem, Debit: 700, Transactions: 7}, + {Currency: point, Type: constants.WalletTxTypeRewardRedeemRefund, Credit: 100, Transactions: 1}, + {Currency: point, Type: constants.WalletTxTypeExpire, Debit: 40, Transactions: 2}, + } + e := economy(flows, map[string]int64{coin: 900, point: 2_500}) + + assert.Equal(t, models.EnakGameCoinFlows{ + Generated: 630, GameRewards: 500, SpentOnGames: 192, Exchanged: 120, Spent: 312, Expired: 15, Outstanding: 900, + }, e.Coin, "transfers count nowhere") + assert.Equal(t, models.EnakGamePointFlows{Earned: 900, Exchanged: 120, Redeemed: 600, Expired: 40, Balance: 2_500}, e.Point) + assert.Len(t, e.ByType, len(flows)) + + empty := economy(nil, map[string]int64{}) + assert.Zero(t, empty.Coin) + assert.NotNil(t, empty.ByType, "an empty list, not null") +} diff --git a/internal/processor/game_budget_controller_db_test.go b/internal/processor/game_budget_controller_db_test.go new file mode 100644 index 0000000..b0a309d --- /dev/null +++ b/internal/processor/game_budget_controller_db_test.go @@ -0,0 +1,164 @@ +package processor + +import ( + "encoding/json" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +// realizedCost records a completed redemption whose face value the budget paid, +// recognized at at. +func (e *enakGameEnv) realizedCost(voucherID, budgetID uuid.UUID, amount int64, at time.Time) { + e.t.Helper() + debit, redemption := uuid.New(), uuid.New() + require.NoError(e.t, e.db.Exec(`INSERT INTO wallet_transactions (id, organization_id, customer_id, currency, type, amount, balance_after, reference_type, reference_id, description) + VALUES (?, ?, ?, 'POINT', 'REWARD_REDEEM', -1, 0, 'REWARD_REDEMPTION', ?, 'x')`, debit, e.orgA, e.bob, redemption).Error) + require.NoError(e.t, e.db.Exec(`INSERT INTO voucher_redemptions (id, organization_id, customer_id, voucher_id, idempotency_key, status, face_value, point_cost, debit_transaction_id, completed_at) + VALUES (?, ?, ?, ?, ?, 'COMPLETED', ?, 1, ?, ?)`, redemption, e.orgA, e.bob, voucherID, redemption.String(), amount, debit, at).Error) + require.NoError(e.t, e.db.Exec(`INSERT INTO voucher_redemption_costs (redemption_id, budget_id, source_type, points, cost, recognized_at) VALUES (?, ?, 'GAME_REWARD', 1, ?, ?)`, + redemption, budgetID, amount, at).Error) +} + +func (e *enakGameEnv) activeConfig(gameID uuid.UUID) *entities.GameRewardConfig { + e.t.Helper() + c, err := repository.NewEnakGameRepository(e.db).GetActiveRewardConfig(e.ctx(), e.orgA, gameID) + require.NoError(e.t, err) + return c +} + +// EG-901 and EG-902 against Postgres, from the PRD §30 numbers. Needs +// TEST_DATABASE_URL; see internal/repository/wallet_repository_test.go. +func TestGameBudgetController_AgainstPostgres(t *testing.T) { + e := newEnakGameEnv(t) + ctx := e.ctx() + e.now = time.Date(2026, 10, 21, 12, 0, 0, 0, walletDisplayLocation) + budgets := repository.NewGameBudgetRepository(e.db) + metrics := NewGameBudgetMetricsProcessor(budgets, repository.NewGameBudgetMetricsRepository(e.db)) + controller := NewGameBudgetControllerProcessor(budgets, metrics, repository.NewEnakGameRepository(e.db), + NewAuditLogger(repository.NewAuditLogRepository(e.db)), e.txm) + controller.now = func() time.Time { return e.now } + + october := e.globalBudget(e.orgA, e.now) + tap := e.gameWith("tap", entities.GameResultRules{}, constants.GameRewardTypeFixed, `{"amount": 10}`, 10) + run := e.gameWith("run", entities.GameResultRules{}, constants.GameRewardTypeScoreBased, + `{"bands": [{"min": 0, "max": 100, "amount": 5}, {"min": 101, "amount": 20}]}`, 20) + tapV1, runV1 := e.activeConfig(tap.ID), e.activeConfig(run.ID) + + // Rp60M realized, Rp5,5M a day over the last week, 10 days left → Rp115M. + voucher := e.staticVoucher(1_000, 1, 1) + e.realizedCost(voucher.ID, october.ID, 21_500_000, time.Date(2026, 10, 5, 10, 0, 0, 0, walletDisplayLocation)) + for day := 15; day <= 21; day++ { + e.realizedCost(voucher.ID, october.ID, 5_500_000, time.Date(2026, 10, day, 0, 0, 0, 0, walletDisplayLocation)) + } + + rec, err := controller.Recommendation(ctx, e.orgA, october.ID) + require.NoError(t, err) + assert.Equal(t, constants.GameBudgetRecommended, rec.State) + assert.EqualValues(t, 115_000_000, rec.Metrics.ForecastCost) + assert.InDelta(t, 0.7272, *rec.TargetMultiplier, 1e-9) + assert.EqualValues(t, 0.9, rec.Multiplier, "one step of 10%") + assert.EqualValues(t, 10, *rec.Guardrails.MaxStepPercent) + require.Len(t, rec.Games, 2) + assert.Equal(t, run.ID, rec.Games[0].GameID, "by game name") + assert.JSONEq(t, `{"bands":[{"min":0,"max":100,"amount":4},{"min":101,"amount":18}]}`, string(rec.Games[0].NewRules)) + assert.EqualValues(t, 18, rec.Games[0].NewMaxReward) + assert.JSONEq(t, `{"amount":9}`, string(rec.Games[1].NewRules)) + assert.Zero(t, e.count(`SELECT COUNT(*) FROM game_reward_configs WHERE organization_id = ? AND budget_id IS NOT NULL`, e.orgA), "showing changes nothing") + + // Only what the admin saw is applied. + _, err = controller.Accept(ctx, e.orgA, e.manager, october.ID, models.GameBudgetRecommendationAcceptInput{}) + assert.ErrorIs(t, err, ErrEnakGameRejected) + _, err = controller.Accept(ctx, e.orgA, e.manager, october.ID, models.GameBudgetRecommendationAcceptInput{Multiplier: ptr(0.85)}) + assert.ErrorIs(t, err, ErrEnakGameRejected) + assert.Zero(t, e.count(`SELECT COUNT(*) FROM game_reward_configs WHERE organization_id = ? AND budget_id IS NOT NULL`, e.orgA)) + + accepted, err := controller.Accept(ctx, e.orgA, e.manager, october.ID, models.GameBudgetRecommendationAcceptInput{ + Multiplier: ptr(0.9), Reason: ptr("Burn rate terlalu tinggi"), + }) + require.NoError(t, err) + require.Len(t, accepted.RewardConfigs, 2) + tapV2 := e.activeConfig(tap.ID) + assert.Equal(t, 2, tapV2.Version) + assert.JSONEq(t, `{"amount":9}`, string(tapV2.Rules)) + assert.EqualValues(t, 9, tapV2.MaxReward) + assert.EqualValues(t, 0.9, *tapV2.Multiplier) + assert.Equal(t, tapV1.ID, *tapV2.BaseConfigID) + assert.Equal(t, october.ID, *tapV2.BudgetID) + assert.Equal(t, "Burn rate terlalu tinggi", *tapV2.Reason) + assert.EqualValues(t, 1, e.count(`SELECT COUNT(*) FROM game_reward_configs WHERE id = ? AND status = 'RETIRED'`, tapV1.ID)) + assert.EqualValues(t, 1, e.count(`SELECT COUNT(*) FROM game_reward_configs WHERE id = ? AND status = 'RETIRED'`, runV1.ID)) + assert.Equal(t, []string{"CREATED", constants.AuditActionRecommendationAccepted}, e.auditActions(constants.AuditEntityGameBudget, october.ID)) + assert.Equal(t, []string{"ACTIVATED", "CREATED"}, e.auditActions(constants.AuditEntityGameRewardConfig, tapV2.ID)) + assert.EqualValues(t, 7, e.count(`SELECT COUNT(*) FROM audit_logs WHERE organization_id = ? AND source = ?`, e.orgA, constants.AuditSourceBudgetController), + "per game: retired, created, activated; and the budget") + + // The game pays the new amount. + e.coins(e.alice, 2, nil) + started, err := e.sessions.Start(ctx, e.alice, tap.ID, "after") + require.NoError(t, err) + done, err := e.sessions.Complete(ctx, e.alice, started.SessionID, models.GameSessionCompleteInput{}) + require.NoError(t, err) + assert.EqualValues(t, 9, done.RewardTotal) + + // Not again within the cooldown, though the forecast is still over. + rec, err = controller.Recommendation(ctx, e.orgA, october.ID) + require.NoError(t, err) + assert.Equal(t, constants.GameBudgetRecommendationCooldown, rec.State) + require.NotNil(t, rec.CooldownUntil) + assert.WithinDuration(t, e.now.AddDate(0, 0, 7), *rec.CooldownUntil, time.Second) + assert.EqualValues(t, 0.81, rec.Games[1].NewMultiplier, "what it would be") + _, err = controller.Accept(ctx, e.orgA, e.manager, october.ID, models.GameBudgetRecommendationAcceptInput{Multiplier: ptr(0.9)}) + assert.ErrorIs(t, err, ErrEnakGameRejected) + + // No cooldown and a floor of 85%: 0.81 stops at 0.85, scaled from version 1. + current, err := e.budgets.GetBudget(ctx, e.orgA, october.ID) + require.NoError(t, err) + in := GameBudgetInputFrom(current) + in.Thresholds.CooldownDays, in.Thresholds.MinMultiplierPercent = ptr(int64(0)), ptr(int64(85)) + _, err = e.budgets.UpdateBudget(ctx, e.orgA, e.manager, october.ID, in) + require.NoError(t, err) + rec, err = controller.Recommendation(ctx, e.orgA, october.ID) + require.NoError(t, err) + require.Equal(t, constants.GameBudgetRecommended, rec.State) + _, err = controller.Accept(ctx, e.orgA, e.manager, october.ID, models.GameBudgetRecommendationAcceptInput{Multiplier: ptr(0.9)}) + require.NoError(t, err) + tapV3 := e.activeConfig(tap.ID) + assert.EqualValues(t, 0.85, *tapV3.Multiplier) + assert.JSONEq(t, `{"amount":8}`, string(tapV3.Rules), "10 × 0.85, rounded down") + assert.Equal(t, tapV1.ID, *tapV3.BaseConfigID, "always from the admin's version") + + rec, err = controller.Recommendation(ctx, e.orgA, october.ID) + require.NoError(t, err) + assert.Equal(t, constants.GameBudgetRecommendationAtLimit, rec.State) + assert.Empty(t, rec.Games) + + // An admin's new version starts over from 1. + config, err := e.admin.CreateRewardConfig(ctx, e.orgA, e.manager, tap.ID, models.GameRewardConfigInput{ + RewardType: constants.GameRewardTypeFixed, Rules: json.RawMessage(`{"amount": 12}`), MaxReward: 12, + }) + require.NoError(t, err) + _, err = e.admin.ActivateRewardConfig(ctx, e.orgA, e.manager, config.ID, models.GameRewardConfigActivateInput{}) + require.NoError(t, err) + rec, err = controller.Recommendation(ctx, e.orgA, october.ID) + require.NoError(t, err) + require.Equal(t, constants.GameBudgetRecommended, rec.State) + require.Len(t, rec.Games, 1) + assert.Equal(t, config.ID, rec.Games[0].BaseConfigID) + assert.JSONEq(t, `{"amount":10}`, string(rec.Games[0].NewRules), "12 × 0.9, rounded down") + + // Event budgets pay for what events add, which is set on the event. + event := e.eventBudget(e.orgA, "Ramadan") + _, err = controller.Recommendation(ctx, e.orgA, event.ID) + assert.ErrorIs(t, err, ErrEnakGameRejected) + _, err = controller.Recommendation(ctx, e.orgB, october.ID) + assert.ErrorIs(t, err, repository.ErrGameBudgetNotFound) +} diff --git a/internal/processor/game_budget_controller_processor.go b/internal/processor/game_budget_controller_processor.go new file mode 100644 index 0000000..bae43ad --- /dev/null +++ b/internal/processor/game_budget_controller_processor.go @@ -0,0 +1,368 @@ +package processor + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "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" +) + +// GameBudgetControllerProcessor is the Budget Controller in recommendation mode +// (docs/rfc-enakgame.md §10, D9; PRD §29–§33). For a global budget it computes the +// multiplier that brings the forecast to the budget, keeps it within the guardrails, +// and shows what every game's reward would become. Nothing changes until an admin +// accepts; accepting writes a new active version of each game's configuration, audited +// with source budget_controller. +// +// A game's multiplier is kept against its base, the version its admin wrote last, so +// adjustments do not compound their rounding and min/max mean "of what the admin +// set". An admin writing a new version starts a new base at 1. +type GameBudgetControllerProcessor struct { + budgets repository.GameBudgetRepository + metrics *GameBudgetMetricsProcessor + games repository.EnakGameRepository + audit *AuditLogger + tx TxRunner + now func() time.Time +} + +func NewGameBudgetControllerProcessor(budgets repository.GameBudgetRepository, metrics *GameBudgetMetricsProcessor, games repository.EnakGameRepository, + audit *AuditLogger, tx TxRunner) *GameBudgetControllerProcessor { + return &GameBudgetControllerProcessor{budgets: budgets, metrics: metrics, games: games, audit: audit, tx: tx, now: time.Now} +} + +// budgetGuardrails are a budget's guardrails in percent (PRD §31), with the defaults +// for those it does not set. +type budgetGuardrails struct { + step, min, max, cooldownDays int64 +} + +func guardrailsOf(b *entities.GameBudget) budgetGuardrails { + g := budgetGuardrails{ + step: constants.GameBudgetMaxStepDefault, min: constants.GameBudgetMinMultiplierDefault, + max: constants.GameBudgetMaxMultiplierDefault, cooldownDays: constants.GameBudgetCooldownDaysDefault, + } + var set models.GameBudgetThresholds + if len(b.Thresholds) > 0 { + _ = json.Unmarshal(b.Thresholds, &set) + } + for _, v := range []struct { + from *int64 + to *int64 + }{{set.MaxStepPercent, &g.step}, {set.MinMultiplierPercent, &g.min}, {set.MaxMultiplierPercent, &g.max}, {set.CooldownDays, &g.cooldownDays}} { + if v.from != nil { + *v.to = *v.from + } + } + return g +} + +func (g budgetGuardrails) model() models.GameBudgetThresholds { + step, lo, hi, cooldown := g.step, g.min, g.max, g.cooldownDays + return models.GameBudgetThresholds{MaxStepPercent: &step, MinMultiplierPercent: &lo, MaxMultiplierPercent: &hi, CooldownDays: &cooldown} +} + +// budgetStepMultiplier is the multiplier, in ten-thousandths, that one accepted +// recommendation applies to every game's reward. target is what would make the +// forecast meet the budget, nil when there is no cost to extrapolate from. step is +// target within one step of 1, rounded down to two decimals: down never pays more than +// the forecast allows. state is set when there is nothing to recommend. +// +// The forecast is realized + burn × days left, and only the future part follows the +// rewards, so the target is (budget − realized) / (forecast − realized). +func budgetStepMultiplier(m models.GameBudgetMetrics, g budgetGuardrails) (target *int64, step int64, state string) { + one := rewardMultiplierOne + if m.WindowDays == 0 || m.RemainingDays == 0 { + return nil, one, constants.GameBudgetRecommendationOutOfPeriod + } + future := m.ForecastCost - m.RealizedCost + if future <= 0 { + return nil, one, constants.GameBudgetRecommendationNoData + } + t := int64(math.Floor(float64(m.Amount-m.RealizedCost) * float64(one) / float64(future))) + t = max(t, 0) + step = min(max(t, one-g.step*100), one+g.step*100) + step -= step % 100 + if step == one { + return &t, one, constants.GameBudgetRecommendationNoChange + } + return &t, step, "" +} + +// nextGameMultiplier is a game's multiplier after a step: current × step, rounded +// down, within min and max. A game outside min and max, because they changed since +// its last adjustment, is brought inside, but never moved against the step. +func nextGameMultiplier(current, step int64, g budgetGuardrails) int64 { + next := current * step / rewardMultiplierOne + next = min(max(next, g.min*100), g.max*100) + if (step < rewardMultiplierOne && next > current) || (step > rewardMultiplierOne && next < current) { + return current + } + return next +} + +// rewardAdjustment is a game's part of a recommendation, with what accepting it needs. +type rewardAdjustment struct { + model models.GameRewardAdjustment + active *entities.GameRewardConfig + base *entities.GameRewardConfig + multiplier int64 + rules json.RawMessage + maxReward int64 +} + +func multiplierOf(c *entities.GameRewardConfig) int64 { + if c.Multiplier == nil { + return rewardMultiplierOne + } + return int64(math.Round(*c.Multiplier * float64(rewardMultiplierOne))) +} + +func multiplierValue(m int64) float64 { + return float64(m) / float64(rewardMultiplierOne) +} + +// Recommendation is GET /marketing/enakgame/budgets/:id/recommendation. +func (p *GameBudgetControllerProcessor) Recommendation(ctx context.Context, organizationID, budgetID uuid.UUID) (*models.GameBudgetRecommendation, error) { + budget, err := p.budgets.GetBudget(ctx, organizationID, budgetID) + if err != nil { + return nil, err + } + rec, _, err := p.recommend(ctx, budget, p.now()) + return rec, err +} + +func (p *GameBudgetControllerProcessor) recommend(ctx context.Context, budget *entities.GameBudget, now time.Time) (*models.GameBudgetRecommendation, []rewardAdjustment, error) { + if budget.Scope != constants.GameBudgetScopeGlobal { + return nil, nil, enakGameRejected("the Budget Controller only adjusts base rewards, paid by GLOBAL budgets; an event's extra is set on the event") + } + metrics, err := p.metrics.metricsAt(ctx, budget, now) + if err != nil { + return nil, nil, err + } + g := guardrailsOf(budget) + target, step, state := budgetStepMultiplier(*metrics, g) + rec := &models.GameBudgetRecommendation{ + BudgetID: budget.ID, Metrics: *metrics, Guardrails: g.model(), Multiplier: multiplierValue(step), + Games: []models.GameRewardAdjustment{}, + } + if target != nil { + t := multiplierValue(*target) + rec.TargetMultiplier = &t + } + switch state { + case constants.GameBudgetRecommendationOutOfPeriod: + rec.State, rec.Message = state, "the budget's period has not started, or has no day left after today" + return rec, nil, nil + case constants.GameBudgetRecommendationNoData: + rec.State, rec.Message = state, fmt.Sprintf("no voucher cost in the last %d days to forecast from", metrics.WindowDays) + return rec, nil, nil + case constants.GameBudgetRecommendationNoChange: + rec.State, rec.Message = state, "the forecast meets the budget; rewards stay" + return rec, nil, nil + } + + configs, err := p.games.ListActiveRewardConfigs(ctx, budget.OrganizationID) + if err != nil { + return nil, nil, err + } + var adjustments []rewardAdjustment + for i := range configs { + active := &configs[i].GameRewardConfig + current := multiplierOf(active) + next := nextGameMultiplier(current, step, g) + if next == current { + continue + } + base := active + if active.BaseConfigID != nil { + if base, err = p.games.GetRewardConfig(ctx, budget.OrganizationID, *active.BaseConfigID); err != nil { + return nil, nil, err + } + } + a, err := adjust(configs[i].GameName, active, base, next) + if err != nil { + return nil, nil, err + } + adjustments = append(adjustments, a) + rec.Games = append(rec.Games, a.model) + } + + last, err := p.games.LastBudgetControllerChange(ctx, budget.OrganizationID) + if err != nil { + return nil, nil, err + } + var until *time.Time + if last != nil && g.cooldownDays > 0 { + if u := last.AddDate(0, 0, int(g.cooldownDays)); now.Before(u) { + until = &u + } + } + switch { + case len(adjustments) == 0: + rec.State, rec.Message = constants.GameBudgetRecommendationAtLimit, "every game is already at its min or max multiplier" + case until != nil: + rec.State, rec.CooldownUntil = constants.GameBudgetRecommendationCooldown, until + rec.Message = fmt.Sprintf("a recommendation was accepted less than %d days ago", g.cooldownDays) + default: + rec.State = constants.GameBudgetRecommended + rec.Message = fmt.Sprintf("forecast Rp%d against a budget of Rp%d: multiply rewards by %.2f", metrics.ForecastCost, metrics.Amount, rec.Multiplier) + } + return rec, adjustments, nil +} + +// adjust scales a game's base configuration to a multiplier. +func adjust(gameName string, active, base *entities.GameRewardConfig, multiplier int64) (rewardAdjustment, error) { + calculator, err := RewardCalculatorFor(base.RewardType) + if err != nil { + return rewardAdjustment{}, err + } + rules, err := calculator.Scale(json.RawMessage(base.Rules), multiplier) + if err != nil { + return rewardAdjustment{}, enakGameRejected("cannot scale the reward of %s: %v", gameName, err) + } + maxReward, err := scaleRewardAmount(base.MaxReward, multiplier) + if err != nil { + return rewardAdjustment{}, enakGameRejected("cannot scale the max_reward of %s: %v", gameName, err) + } + return rewardAdjustment{ + model: models.GameRewardAdjustment{ + GameID: active.GameID, GameName: gameName, RewardConfigID: active.ID, Version: active.Version, + BaseConfigID: base.ID, RewardType: base.RewardType, + CurrentMultiplier: multiplierValue(multiplierOf(active)), NewMultiplier: multiplierValue(multiplier), + CurrentRules: json.RawMessage(active.Rules), NewRules: rules, + CurrentMaxReward: active.MaxReward, NewMaxReward: maxReward, + }, + active: active, base: base, multiplier: multiplier, rules: rules, maxReward: maxReward, + }, nil +} + +// Accept applies the recommendation the admin saw: for every game it changes, a new +// active version of the configuration replaces the active one, in one transaction. +// When the recommendation is no longer what the admin saw, nothing changes. +func (p *GameBudgetControllerProcessor) Accept(ctx context.Context, organizationID, actor, budgetID uuid.UUID, in models.GameBudgetRecommendationAcceptInput) (*models.GameBudgetRecommendationAccepted, error) { + if in.Multiplier == nil { + return nil, enakGameRejected("multiplier is required: the one the recommendation showed") + } + if err := validateReason(in.Reason); err != nil { + return nil, err + } + seen := int64(math.Round(*in.Multiplier * float64(rewardMultiplierOne))) + + var out *models.GameBudgetRecommendationAccepted + err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { + // One acceptance at a time in the organization, so two cannot both pass the + // cooldown, and none while a budget is being changed. + if err := p.budgets.LockGlobalBudgets(ctx, organizationID); err != nil { + return err + } + budget, err := p.budgets.GetBudget(ctx, organizationID, budgetID) + if err != nil { + return err + } + now := p.now() + rec, adjustments, err := p.recommend(ctx, budget, now) + if err != nil { + return err + } + if rec.State != constants.GameBudgetRecommended { + return enakGameRejected("nothing to accept: %s", rec.Message) + } + if seen != int64(math.Round(rec.Multiplier*float64(rewardMultiplierOne))) { + return enakGameRejected("the recommendation is now %.2f; review it again", rec.Multiplier) + } + reason := in.Reason + if reason == nil { + r := fmt.Sprintf("Budget Controller: rewards × %.2f for budget %s", rec.Multiplier, budget.ID) + reason = &r + } + + out = &models.GameBudgetRecommendationAccepted{BudgetID: budget.ID, Multiplier: rec.Multiplier} + configIDs := make([]uuid.UUID, 0, len(adjustments)) + for _, a := range adjustments { + config, err := p.apply(ctx, organizationID, actor, budget.ID, a, now, reason) + if err != nil { + return err + } + out.RewardConfigs = append(out.RewardConfigs, *rewardConfigModel(config)) + configIDs = append(configIDs, config.ID) + } + return p.record(ctx, organizationID, actor, constants.AuditEntityGameBudget, budget.ID, constants.AuditActionRecommendationAccepted, nil, + map[string]any{ + "multiplier": rec.Multiplier, "target_multiplier": rec.TargetMultiplier, "amount": rec.Metrics.Amount, + "realized_cost": rec.Metrics.RealizedCost, "forecast_cost": rec.Metrics.ForecastCost, + "guardrails": rec.Guardrails, "reward_config_ids": configIDs, + }, reason) + }) + if err != nil { + return nil, err + } + return out, nil +} + +// apply retires a game's active configuration and activates its adjusted version. +func (p *GameBudgetControllerProcessor) apply(ctx context.Context, organizationID, actor, budgetID uuid.UUID, a rewardAdjustment, now time.Time, reason *string) (*entities.GameRewardConfig, error) { + // Waits for an admin changing the same game's configuration, then checks the + // recommendation was made from the version still active. + changed := enakGameRejected("%s changed meanwhile; review the recommendation again", a.model.GameName) + game, err := p.games.LockGame(ctx, organizationID, a.active.GameID) + if err != nil { + return nil, err + } + if game.Status == constants.GameStatusArchived { + return nil, changed + } + active, err := p.games.GetActiveRewardConfig(ctx, organizationID, a.active.GameID) + if errors.Is(err, repository.ErrGameRewardConfigNotFound) { + return nil, changed + } + if err != nil { + return nil, err + } + if active.ID != a.active.ID { + return nil, changed + } + moved, err := p.games.SetRewardConfigStatus(ctx, organizationID, active.ID, constants.GameRewardConfigStatusActive, constants.GameRewardConfigStatusRetired) + if err != nil { + return nil, err + } + if !moved { + return nil, changed + } + if err := p.record(ctx, organizationID, actor, constants.AuditEntityGameRewardConfig, active.ID, "RETIRED", + map[string]string{"status": constants.GameRewardConfigStatusActive}, map[string]string{"status": constants.GameRewardConfigStatusRetired}, reason); err != nil { + return nil, err + } + + multiplier, baseID, budget := multiplierValue(a.multiplier), a.base.ID, budgetID + config := &entities.GameRewardConfig{ + OrganizationID: organizationID, GameID: active.GameID, RewardType: a.base.RewardType, + Rules: entities.JSONDocument(a.rules), MaxReward: a.maxReward, Status: constants.GameRewardConfigStatusActive, + EffectiveAt: &now, CreatedBy: actor, Reason: reason, BaseConfigID: &baseID, Multiplier: &multiplier, BudgetID: &budget, + } + if err := p.games.CreateRewardConfig(ctx, config); err != nil { + return nil, err + } + if err := p.record(ctx, organizationID, actor, constants.AuditEntityGameRewardConfig, config.ID, "CREATED", nil, rewardConfigModel(config), reason); err != nil { + return nil, err + } + return config, p.record(ctx, organizationID, actor, constants.AuditEntityGameRewardConfig, config.ID, "ACTIVATED", + nil, map[string]string{"status": constants.GameRewardConfigStatusActive}, reason) +} + +func (p *GameBudgetControllerProcessor) record(ctx context.Context, organizationID, actor uuid.UUID, entityType string, entityID uuid.UUID, action string, before, after any, reason *string) error { + return p.audit.Record(ctx, AuditEntry{ + OrganizationID: organizationID, ActorType: constants.AuditActorUser, ActorID: &actor, + EntityType: entityType, EntityID: entityID, Action: action, + Before: before, After: after, Reason: reason, Source: constants.AuditSourceBudgetController, + }) +} diff --git a/internal/processor/game_budget_controller_processor_test.go b/internal/processor/game_budget_controller_processor_test.go new file mode 100644 index 0000000..1135f17 --- /dev/null +++ b/internal/processor/game_budget_controller_processor_test.go @@ -0,0 +1,101 @@ +package processor + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" +) + +var defaultGuardrails = budgetGuardrails{step: 10, min: 50, max: 150, cooldownDays: 7} + +// EG-901: the multiplier of one recommendation. +func TestBudgetStepMultiplier(t *testing.T) { + // metrics is a running budget of Rp100M: realized so far, forecast at period end. + metrics := func(realized, forecast int64) models.GameBudgetMetrics { + return models.GameBudgetMetrics{Amount: 100_000_000, RealizedCost: realized, ForecastCost: forecast, WindowDays: 7, RemainingDays: 10} + } + for name, tc := range map[string]struct { + m models.GameBudgetMetrics + g budgetGuardrails + wantTarget *int64 + wantStep int64 + wantState string + }{ + // PRD §30: Rp40M left for Rp55M forecast → 0.7272, one step down. + "PRD §30": {metrics(60_000_000, 115_000_000), defaultGuardrails, ptr(int64(7272)), 9_000, ""}, + "wider step": {metrics(60_000_000, 115_000_000), budgetGuardrails{step: 30, min: 50, max: 150}, ptr(int64(7272)), 7_200, ""}, + "slightly over": {metrics(60_000_000, 100_400_000), defaultGuardrails, ptr(int64(9900)), 9_900, ""}, + "under budget": {metrics(20_000_000, 60_000_000), defaultGuardrails, ptr(int64(20000)), 11_000, ""}, + "slightly under": {metrics(60_000_000, 99_000_000), defaultGuardrails, ptr(int64(10256)), 10_200, ""}, + "on budget": {metrics(60_000_000, 100_000_000), defaultGuardrails, ptr(int64(10000)), 10_000, constants.GameBudgetRecommendationNoChange}, + "a hair under": {metrics(60_000_000, 99_900_000), defaultGuardrails, ptr(int64(10025)), 10_000, constants.GameBudgetRecommendationNoChange}, + "already over budget": {metrics(110_000_000, 120_000_000), defaultGuardrails, ptr(int64(0)), 9_000, ""}, + "no recent cost": {metrics(60_000_000, 60_000_000), defaultGuardrails, nil, 10_000, constants.GameBudgetRecommendationNoData}, + "not started": {models.GameBudgetMetrics{Amount: 100_000_000}, defaultGuardrails, + nil, 10_000, constants.GameBudgetRecommendationOutOfPeriod}, + "last day": {models.GameBudgetMetrics{Amount: 100_000_000, RealizedCost: 1, ForecastCost: 1, WindowDays: 7}, defaultGuardrails, + nil, 10_000, constants.GameBudgetRecommendationOutOfPeriod}, + } { + target, step, state := budgetStepMultiplier(tc.m, tc.g) + assert.Equal(t, tc.wantTarget, target, name) + assert.Equal(t, tc.wantStep, step, name) + assert.Equal(t, tc.wantState, state, name) + } +} + +// EG-901: a game's multiplier after a step, against its admin's configuration. +func TestNextGameMultiplier(t *testing.T) { + for name, tc := range map[string]struct { + current, step, want int64 + g budgetGuardrails + }{ + "first step down": {10_000, 9_000, 9_000, defaultGuardrails}, + "compounds": {9_000, 9_000, 8_100, defaultGuardrails}, + "rounds down": {8_100, 9_900, 8_019, defaultGuardrails}, + "stops at min": {5_500, 9_000, 5_000, defaultGuardrails}, + "stays at min": {5_000, 9_000, 5_000, defaultGuardrails}, + "stops at max": {14_000, 11_000, 15_000, defaultGuardrails}, + "up from a cut": {8_100, 11_000, 8_910, defaultGuardrails}, + "min raised since: up": {4_000, 11_000, 5_000, defaultGuardrails}, + "min raised since: stays": {4_000, 9_000, 4_000, defaultGuardrails}, + "max lowered since: down": {20_000, 9_000, 15_000, defaultGuardrails}, + "max lowered since: stay": {20_000, 11_000, 20_000, defaultGuardrails}, + "own bounds": {10_000, 9_000, 9_500, budgetGuardrails{step: 10, min: 95, max: 100}}, + } { + assert.Equal(t, tc.want, nextGameMultiplier(tc.current, tc.step, tc.g), name) + } +} + +func TestGuardrailsOf(t *testing.T) { + assert.Equal(t, defaultGuardrails, guardrailsOf(&entities.GameBudget{}), "the defaults") + assert.Equal(t, defaultGuardrails, guardrailsOf(&entities.GameBudget{Thresholds: entities.JSONDocument(`{"warning": 50}`)})) + assert.Equal(t, budgetGuardrails{step: 5, min: 80, max: 150, cooldownDays: 0}, + guardrailsOf(&entities.GameBudget{Thresholds: entities.JSONDocument(`{"max_step_percent": 5, "min_multiplier_percent": 80, "cooldown_days": 0}`)})) +} + +func TestGameBudgetGuardrailValidation(t *testing.T) { + in := func(th models.GameBudgetThresholds) models.GameBudgetInput { + return models.GameBudgetInput{Scope: "GLOBAL", Name: "Oktober", PeriodStart: "2026-10-01", PeriodEnd: "2026-10-31", Amount: 1, Thresholds: th} + } + _, err := gameBudgetFromInput(in(models.GameBudgetThresholds{ + MaxStepPercent: ptr(int64(50)), MinMultiplierPercent: ptr(int64(1)), MaxMultiplierPercent: ptr(int64(1000)), CooldownDays: ptr(int64(0)), + })) + assert.NoError(t, err) + for name, th := range map[string]models.GameBudgetThresholds{ + "step 0": {MaxStepPercent: ptr(int64(0))}, + "step 51": {MaxStepPercent: ptr(int64(51))}, + "min 0": {MinMultiplierPercent: ptr(int64(0))}, + "min above 1x": {MinMultiplierPercent: ptr(int64(101))}, + "max below 1x": {MaxMultiplierPercent: ptr(int64(99))}, + "max too high": {MaxMultiplierPercent: ptr(int64(1001))}, + "cooldown -1": {CooldownDays: ptr(int64(-1))}, + "cooldown 91": {CooldownDays: ptr(int64(91))}, + } { + _, err := gameBudgetFromInput(in(th)) + assert.ErrorIs(t, err, ErrEnakGameRejected, name) + } +} diff --git a/internal/processor/game_budget_metrics_processor.go b/internal/processor/game_budget_metrics_processor.go index 3e5bc1d..a89c8e8 100644 --- a/internal/processor/game_budget_metrics_processor.go +++ b/internal/processor/game_budget_metrics_processor.go @@ -49,7 +49,12 @@ func (p *GameBudgetMetricsProcessor) Metrics(ctx context.Context, organizationID if err != nil { return nil, err } - now := p.now() + 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) diff --git a/internal/processor/game_budget_processor.go b/internal/processor/game_budget_processor.go index a0d616a..d97a373 100644 --- a/internal/processor/game_budget_processor.go +++ b/internal/processor/game_budget_processor.go @@ -25,6 +25,9 @@ type GameBudgetProcessor struct { now func() time.Time } +// gameBudgetMaxMultiplierLimit is the highest max_multiplier_percent a budget may set. +const gameBudgetMaxMultiplierLimit = 1000 + func NewGameBudgetProcessor(budgets repository.GameBudgetRepository, audit *AuditLogger, tx TxRunner) *GameBudgetProcessor { return &GameBudgetProcessor{budgets: budgets, audit: audit, tx: tx, now: time.Now} } @@ -234,6 +237,18 @@ func gameBudgetFromInput(in models.GameBudgetInput) (*entities.GameBudget, error if t.Warning != nil && t.Critical != nil && *t.Warning > *t.Critical { return nil, enakGameRejected("thresholds.warning cannot be above thresholds.critical") } + switch { + case t.MaxStepPercent != nil && (*t.MaxStepPercent < 1 || *t.MaxStepPercent > 50): + return nil, enakGameRejected("thresholds.max_step_percent must be between 1 and 50") + case t.MinMultiplierPercent != nil && (*t.MinMultiplierPercent < 1 || *t.MinMultiplierPercent > 100): + return nil, enakGameRejected("thresholds.min_multiplier_percent must be between 1 and 100") + case t.MaxMultiplierPercent != nil && (*t.MaxMultiplierPercent < 100 || *t.MaxMultiplierPercent > gameBudgetMaxMultiplierLimit): + // NUMERIC(6,4) on game_reward_configs.multiplier holds far more; this keeps a + // typo from multiplying rewards a hundredfold. + return nil, enakGameRejected("thresholds.max_multiplier_percent must be between 100 and %d", gameBudgetMaxMultiplierLimit) + case t.CooldownDays != nil && (*t.CooldownDays < 0 || *t.CooldownDays > 90): + return nil, enakGameRejected("thresholds.cooldown_days must be between 0 and 90") + } thresholds, err := json.Marshal(t) if err != nil { return nil, err diff --git a/internal/processor/reward_calculator.go b/internal/processor/reward_calculator.go index 10922e9..68ddb90 100644 --- a/internal/processor/reward_calculator.go +++ b/internal/processor/reward_calculator.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "math" "math/big" "strings" @@ -60,6 +61,43 @@ type RewardCalculator interface { // Calculate prices a result under rules that passed Validate. detail says how the // amount was reached, for reward_breakdown. Calculate(rules json.RawMessage, result SessionResult, rng RewardRNG) (base int64, detail map[string]any, err error) + // Scale multiplies every amount in rules by multiplier/10000, rounded down, for + // the Budget Controller (§10). Everything else, such as score bands and weights, + // stays. + Scale(rules json.RawMessage, multiplier int64) (json.RawMessage, error) +} + +// rewardMultiplierOne is a multiplier of 1 in the ten-thousandths Scale takes. +const rewardMultiplierOne = int64(10_000) + +// scaleRewardAmount is amount × multiplier/10000, rounded down (RFC §19.2 #1). +func scaleRewardAmount(amount, multiplier int64) (int64, error) { + if multiplier < 0 { + return 0, invalidRules("multiplier cannot be negative") + } + if amount != 0 && multiplier > math.MaxInt64/amount { + return 0, invalidRules("amount %d is too large to scale", amount) + } + return amount * multiplier / rewardMultiplierOne, nil +} + +func scaleRewardAmounts(multiplier int64, amounts ...*int64) error { + for _, a := range amounts { + scaled, err := scaleRewardAmount(*a, multiplier) + if err != nil { + return err + } + *a = scaled + } + return nil +} + +func marshalRules(v any) (json.RawMessage, error) { + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + return b, nil } var rewardCalculators = map[string]RewardCalculator{ @@ -131,14 +169,26 @@ func (f fixedReward) Calculate(rules json.RawMessage, _ SessionResult, _ RewardR return *r.Amount, map[string]any{"reward_type": constants.GameRewardTypeFixed}, nil } +func (f fixedReward) Scale(rules json.RawMessage, multiplier int64) (json.RawMessage, error) { + r, err := f.parse(rules) + if err != nil { + return nil, err + } + if err := scaleRewardAmounts(multiplier, r.Amount); err != nil { + return nil, err + } + return marshalRules(r) +} + // SCORE_BASED: {"bands": [{"min": 0, "max": 100, "amount": 1}, {"min": 101, "amount": 20}]}. // Bands run in order from 0, each starting right after the previous one ends; only the // last may leave max out, to cover every higher score. type scoreBasedReward struct{} type scoreBand struct { - Min *int64 `json:"min"` - Max *int64 `json:"max"` + Min *int64 `json:"min"` + // Left out of the last band, also when scaled rules are written back. + Max *int64 `json:"max,omitempty"` Amount *int64 `json:"amount"` } @@ -206,6 +256,19 @@ func (s scoreBasedReward) Calculate(rules json.RawMessage, result SessionResult, return 0, nil, unusableResult("score %d is in no band", score) } +func (s scoreBasedReward) Scale(rules json.RawMessage, multiplier int64) (json.RawMessage, error) { + r, err := s.parse(rules) + if err != nil { + return nil, err + } + for _, b := range r.Bands { + if err := scaleRewardAmounts(multiplier, b.Amount); err != nil { + return nil, err + } + } + return marshalRules(r) +} + // OUTCOME_BASED: {"outcomes": {"PERFECT": 20, "GOOD": 10, "FAIL": 0}}. type outcomeBasedReward struct{} @@ -252,6 +315,21 @@ func (o outcomeBasedReward) Calculate(rules json.RawMessage, result SessionResul return amount, map[string]any{"reward_type": constants.GameRewardTypeOutcomeBased, "outcome": *result.Outcome}, nil } +func (o outcomeBasedReward) Scale(rules json.RawMessage, multiplier int64) (json.RawMessage, error) { + r, err := o.parse(rules) + if err != nil { + return nil, err + } + for outcome, amount := range r.Outcomes { + scaled, err := scaleRewardAmount(amount, multiplier) + if err != nil { + return nil, err + } + r.Outcomes[outcome] = scaled + } + return marshalRules(r) +} + // PROBABILITY: {"table": [{"weight": 1, "amount": 1000}, {"weight": 999, "amount": 0}]}. // Weights are whole numbers, so no check depends on floating point (§8). The draw is // kept in the detail for audit. @@ -318,3 +396,17 @@ func (p probabilityReward) Calculate(rules json.RawMessage, _ SessionResult, rng } return 0, nil, fmt.Errorf("draw %d is outside the total weight %d", roll, total) } + +// Scale changes the prizes, not their odds. +func (p probabilityReward) Scale(rules json.RawMessage, multiplier int64) (json.RawMessage, error) { + r, _, err := p.parse(rules) + if err != nil { + return nil, err + } + for _, e := range r.Table { + if err := scaleRewardAmounts(multiplier, e.Amount); err != nil { + return nil, err + } + } + return marshalRules(r) +} diff --git a/internal/processor/reward_calculator_test.go b/internal/processor/reward_calculator_test.go index 5f6c12e..4646030 100644 --- a/internal/processor/reward_calculator_test.go +++ b/internal/processor/reward_calculator_test.go @@ -144,3 +144,38 @@ func TestRewardCalculator_RejectsInvalidRules(t *testing.T) { assert.ErrorIs(t, err, ErrInvalidRewardRules, name) } } + +// EG-902: the Budget Controller scales amounts, rounded down, and nothing else. +func TestRewardCalculator_Scale(t *testing.T) { + for name, tc := range map[string]struct { + rewardType, rules string + multiplier int64 + want string + }{ + "FIXED ×0.9": {constants.GameRewardTypeFixed, `{"amount": 10}`, 9_000, `{"amount":9}`}, + "FIXED rounds": {constants.GameRewardTypeFixed, `{"amount": 5}`, 9_000, `{"amount":4}`}, + "FIXED to zero": {constants.GameRewardTypeFixed, `{"amount": 1}`, 9_000, `{"amount":0}`}, + "FIXED ×1.1": {constants.GameRewardTypeFixed, `{"amount": 10}`, 11_000, `{"amount":11}`}, + "FIXED ×1": {constants.GameRewardTypeFixed, `{"amount": 7}`, 10_000, `{"amount":7}`}, + "SCORE_BASED": {constants.GameRewardTypeScoreBased, `{"bands": [{"min": 0, "max": 100, "amount": 1}, {"min": 101, "amount": 20}]}`, 8_100, `{"bands":[{"min":0,"max":100,"amount":0},{"min":101,"amount":16}]}`}, + "OUTCOME_BASED": {constants.GameRewardTypeOutcomeBased, `{"outcomes": {"PERFECT": 20, "GOOD": 10, "FAIL": 0}}`, 9_000, `{"outcomes":{"FAIL":0,"GOOD":9,"PERFECT":18}}`}, + "PROBABILITY": {constants.GameRewardTypeProbability, `{"table": [{"weight": 1, "amount": 1000}, {"weight": 999, "amount": 0}]}`, 8_500, `{"table":[{"weight":1,"amount":850},{"weight":999,"amount":0}]}`}, + } { + c, err := RewardCalculatorFor(tc.rewardType) + require.NoError(t, err) + scaled, err := c.Scale(json.RawMessage(tc.rules), tc.multiplier) + require.NoError(t, err, name) + assert.JSONEq(t, tc.want, string(scaled), name) + assert.NoError(t, c.Validate(scaled), "%s: scaled rules stay valid", name) + } + + c, _ := RewardCalculatorFor(constants.GameRewardTypeFixed) + _, err := c.Scale(json.RawMessage(`{"amount": 9223372036854775807}`), 15_000) + assert.ErrorIs(t, err, ErrInvalidRewardRules, "overflow is refused, not wrapped") + _, err = c.Scale(json.RawMessage(`{"amount": -1}`), 9_000) + assert.ErrorIs(t, err, ErrInvalidRewardRules) + + v, err := scaleRewardAmount(math.MaxInt64/15_000, 15_000) + require.NoError(t, err) + assert.EqualValues(t, math.MaxInt64/15_000*15_000/10_000, v) +} diff --git a/internal/repository/enakgame_analytics_repository.go b/internal/repository/enakgame_analytics_repository.go new file mode 100644 index 0000000..6a261e6 --- /dev/null +++ b/internal/repository/enakgame_analytics_repository.go @@ -0,0 +1,129 @@ +package repository + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// GameSessionStats is what an organization's sessions in a range add up to, for one +// game, or for all of them when GameID is nil. +type GameSessionStats struct { + GameID *uuid.UUID + GameName *string + Plays int64 + Completed int64 + Refunded int64 + Expired int64 + Flagged int64 + Players int64 + AverageScore *float64 + CoinIssued int64 + EntryCost int64 + CoinRefunded int64 +} + +// WalletFlow is what one ledger type moved in one currency over a range. +type WalletFlow struct { + Currency string + Type string + Credit int64 + Debit int64 + Transactions int64 +} + +// EnakGameAnalyticsRepository reads EnakGame analytics (docs/tasks-enakgame.md +// EG-903, PRD §36) from the tables that hold the facts, on read. Ranges are [from, to). +type EnakGameAnalyticsRepository interface { + // SessionStats returns one row per game with a session started in the range, + // most played first, then the row for all of them. gameID narrows it to one game. + SessionStats(ctx context.Context, organizationID uuid.UUID, from, to time.Time, gameID *uuid.UUID) ([]GameSessionStats, error) + // WalletFlows sums the organization's ledger in the range per currency and type. + WalletFlows(ctx context.Context, organizationID uuid.UUID, from, to time.Time) ([]WalletFlow, error) + // BalancesAt sums what the organization's customers held just before at. + BalancesAt(ctx context.Context, organizationID uuid.UUID, at time.Time) (map[string]int64, error) +} + +type enakGameAnalyticsRepository struct { + db *gorm.DB +} + +func NewEnakGameAnalyticsRepository(db *gorm.DB) EnakGameAnalyticsRepository { + return &enakGameAnalyticsRepository{db: db} +} + +func (r *enakGameAnalyticsRepository) SessionStats(ctx context.Context, organizationID uuid.UUID, from, to time.Time, gameID *uuid.UUID) ([]GameSessionStats, error) { + filter := "" + args := []any{organizationID, from, to} + if gameID != nil { + filter = "AND s.game_id = ?" + args = append(args, *gameID) + } + // The empty grouping set adds the row for all games, also when there is no + // session at all. + var rows []GameSessionStats + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT s.game_id, g.name AS game_name, + COUNT(*) AS plays, + COUNT(*) FILTER (WHERE s.status = 'COMPLETED') AS completed, + COUNT(*) FILTER (WHERE s.status = 'REFUNDED') AS refunded, + COUNT(*) FILTER (WHERE s.status = 'EXPIRED') AS expired, + COUNT(*) FILTER (WHERE s.flagged) AS flagged, + COUNT(DISTINCT s.customer_id) AS players, + AVG((s.result->>'score')::float8) + FILTER (WHERE s.status = 'COMPLETED' AND jsonb_typeof(s.result->'score') = 'number') AS average_score, + COALESCE(SUM(s.reward_total) FILTER (WHERE s.status = 'COMPLETED'), 0) AS coin_issued, + COALESCE(SUM(s.entry_cost), 0) AS entry_cost, + COALESCE(SUM(s.entry_cost) FILTER (WHERE s.status = 'REFUNDED'), 0) AS coin_refunded + FROM game_sessions s + JOIN games g ON g.id = s.game_id + WHERE s.organization_id = ? AND s.started_at >= ? AND s.started_at < ? `+filter+` + GROUP BY GROUPING SETS ((s.game_id, g.name), ()) + ORDER BY GROUPING(s.game_id), plays DESC, g.name`, args...).Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to read game session stats: %w", err) + } + return rows, nil +} + +func (r *enakGameAnalyticsRepository) WalletFlows(ctx context.Context, organizationID uuid.UUID, from, to time.Time) ([]WalletFlow, error) { + var rows []WalletFlow + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT currency, type, + COALESCE(SUM(amount) FILTER (WHERE amount > 0), 0) AS credit, + COALESCE(-SUM(amount) FILTER (WHERE amount < 0), 0) AS debit, + COUNT(*) AS transactions + FROM wallet_transactions + WHERE organization_id = ? AND created_at >= ? AND created_at < ? + GROUP BY currency, type + ORDER BY currency, type`, organizationID, from, to).Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to sum wallet flows: %w", err) + } + return rows, nil +} + +func (r *enakGameAnalyticsRepository) BalancesAt(ctx context.Context, organizationID uuid.UUID, at time.Time) (map[string]int64, error) { + // The ledger is signed and append-only, so what it adds up to before at is what + // customers held then. + var rows []struct { + Currency string + Total int64 + } + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT currency, COALESCE(SUM(amount), 0) AS total + FROM wallet_transactions + WHERE organization_id = ? AND created_at < ? + GROUP BY currency`, organizationID, at).Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to sum wallet balances: %w", err) + } + out := make(map[string]int64, len(rows)) + for _, row := range rows { + out[row.Currency] = row.Total + } + return out, nil +} diff --git a/internal/repository/enakgame_repository.go b/internal/repository/enakgame_repository.go index 9c30ef2..403133a 100644 --- a/internal/repository/enakgame_repository.go +++ b/internal/repository/enakgame_repository.go @@ -62,6 +62,19 @@ type EnakGameRepository interface { // change a configuration allows (D7). It reports false when the configuration was // not in from. SetRewardConfigStatus(ctx context.Context, organizationID, id uuid.UUID, from, to string) (bool, error) + + // ListActiveRewardConfigs returns the active configuration of every game of the + // organization that is not archived, by game name. + ListActiveRewardConfigs(ctx context.Context, organizationID uuid.UUID) ([]ActiveRewardConfig, error) + // LastBudgetControllerChange is when the organization last accepted a Budget + // Controller recommendation, or nil when it never did. + LastBudgetControllerChange(ctx context.Context, organizationID uuid.UUID) (*time.Time, error) +} + +// ActiveRewardConfig is a game's active configuration with the game's name. +type ActiveRewardConfig struct { + entities.GameRewardConfig `gorm:"embedded"` + GameName string } type enakGameRepository struct { @@ -176,14 +189,15 @@ func (r *enakGameRepository) CreateRewardConfig(ctx context.Context, config *ent } err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` INSERT INTO game_reward_configs (id, organization_id, game_id, version, reward_type, rules, - max_reward, status, effective_at, created_by, reason) + max_reward, status, effective_at, created_by, reason, base_config_id, multiplier, budget_id) SELECT ?, g.organization_id, g.id, COALESCE((SELECT MAX(version) FROM game_reward_configs WHERE game_id = g.id), 0) + 1, - ?, ?::jsonb, ?, ?, ?, ?, ? + ?, ?::jsonb, ?, ?, ?, ?, ?, ?, ?, ? FROM games g WHERE g.organization_id = ? AND g.id = ? RETURNING version, created_at`, config.ID, config.RewardType, config.Rules, config.MaxReward, config.Status, config.EffectiveAt, - config.CreatedBy, config.Reason, config.OrganizationID, config.GameID).Scan(&rows).Error + config.CreatedBy, config.Reason, config.BaseConfigID, config.Multiplier, config.BudgetID, + config.OrganizationID, config.GameID).Scan(&rows).Error if err != nil { return fmt.Errorf("failed to create reward config: %w", err) } @@ -235,3 +249,35 @@ func (r *enakGameRepository) SetRewardConfigStatus(ctx context.Context, organiza } return result.RowsAffected == 1, nil } + +func (r *enakGameRepository) ListActiveRewardConfigs(ctx context.Context, organizationID uuid.UUID) ([]ActiveRewardConfig, error) { + var configs []ActiveRewardConfig + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT c.*, g.name AS game_name + FROM game_reward_configs c + JOIN games g ON g.id = c.game_id + WHERE c.organization_id = ? AND c.status = ? AND g.status <> ? + ORDER BY g.name, g.id`, + organizationID, constants.GameRewardConfigStatusActive, constants.GameStatusArchived).Scan(&configs).Error + if err != nil { + return nil, fmt.Errorf("failed to list active reward configs: %w", err) + } + return configs, nil +} + +func (r *enakGameRepository) LastBudgetControllerChange(ctx context.Context, organizationID uuid.UUID) (*time.Time, error) { + // effective_at, set by the Budget Controller from its clock, not created_at from + // the database's. + var last []time.Time + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT effective_at FROM game_reward_configs + WHERE organization_id = ? AND budget_id IS NOT NULL + ORDER BY effective_at DESC LIMIT 1`, organizationID).Scan(&last).Error + if err != nil { + return nil, fmt.Errorf("failed to read the last Budget Controller change: %w", err) + } + if len(last) == 0 { + return nil, nil + } + return &last[0], nil +} diff --git a/internal/router/router.go b/internal/router/router.go index 0f097bc..6f6f666 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -672,6 +672,12 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { enakGame.POST("/budgets", r.authMiddleware.RequireLoyaltyManager(), r.enakGameAdminHandler.CreateBudget) enakGame.PUT("/budgets/:id", r.authMiddleware.RequireLoyaltyManager(), r.enakGameAdminHandler.UpdateBudget) enakGame.DELETE("/budgets/:id", r.authMiddleware.RequireLoyaltyManager(), r.enakGameAdminHandler.DeleteBudget) + // Accepting writes new reward configuration versions. + enakGame.GET("/budgets/:id/recommendation", r.enakGameAdminHandler.BudgetRecommendation) + enakGame.POST("/budgets/:id/recommendation/accept", r.authMiddleware.RequireLoyaltyManager(), r.enakGameAdminHandler.AcceptBudgetRecommendation) + + enakGame.GET("/analytics/games", r.enakGameAdminHandler.GameAnalytics) + enakGame.GET("/analytics/economy", r.enakGameAdminHandler.EconomyAnalytics) // Events change what rewards cost, like budgets. enakGame.GET("/events", r.enakGameAdminHandler.ListEvents) diff --git a/internal/service/enakgame_service.go b/internal/service/enakgame_service.go index 1e5daa5..b240d78 100644 --- a/internal/service/enakgame_service.go +++ b/internal/service/enakgame_service.go @@ -41,6 +41,13 @@ type EnakGameAdminService interface { UpdateBudget(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response DeleteBudget(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response BudgetMetrics(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response + BudgetRecommendation(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response + // AcceptBudgetRecommendation applies the recommendation whose multiplier the body + // sends back. + AcceptBudgetRecommendation(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response + + GameAnalytics(ctx context.Context, apctx *appcontext.ContextInfo, q models.EnakGameAnalyticsQuery) *contract.Response + EconomyAnalytics(ctx context.Context, apctx *appcontext.ContextInfo, q models.EnakGameAnalyticsQuery) *contract.Response CreateEvent(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response ListEvents(ctx context.Context, apctx *appcontext.ContextInfo, q models.GameEventListQuery) *contract.Response @@ -74,16 +81,21 @@ type EnakGameCustomerService interface { } type EnakGameAdminServiceImpl struct { - games *processor.EnakGameAdminProcessor - budgets *processor.GameBudgetProcessor - metrics *processor.GameBudgetMetricsProcessor - events *processor.GameEventProcessor - vouchers *processor.VoucherAdminProcessor + games *processor.EnakGameAdminProcessor + budgets *processor.GameBudgetProcessor + metrics *processor.GameBudgetMetricsProcessor + controller *processor.GameBudgetControllerProcessor + events *processor.GameEventProcessor + vouchers *processor.VoucherAdminProcessor + analytics *processor.EnakGameAnalyticsProcessor } func NewEnakGameAdminService(games *processor.EnakGameAdminProcessor, budgets *processor.GameBudgetProcessor, metrics *processor.GameBudgetMetricsProcessor, - events *processor.GameEventProcessor, vouchers *processor.VoucherAdminProcessor) *EnakGameAdminServiceImpl { - return &EnakGameAdminServiceImpl{games: games, budgets: budgets, metrics: metrics, events: events, vouchers: vouchers} + controller *processor.GameBudgetControllerProcessor, events *processor.GameEventProcessor, vouchers *processor.VoucherAdminProcessor, + analytics *processor.EnakGameAnalyticsProcessor) *EnakGameAdminServiceImpl { + return &EnakGameAdminServiceImpl{ + games: games, budgets: budgets, metrics: metrics, controller: controller, events: events, vouchers: vouchers, analytics: analytics, + } } type EnakGameCustomerServiceImpl struct { @@ -213,6 +225,30 @@ func (s *EnakGameAdminServiceImpl) BudgetMetrics(ctx context.Context, apctx *app return respond(ctx, metrics, err) } +func (s *EnakGameAdminServiceImpl) BudgetRecommendation(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response { + rec, err := s.controller.Recommendation(ctx, apctx.OrganizationID, id) + return respond(ctx, rec, err) +} + +func (s *EnakGameAdminServiceImpl) AcceptBudgetRecommendation(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response { + var in models.GameBudgetRecommendationAcceptInput + if resp := decodeStrict(body, &in); resp != nil { + return resp + } + accepted, err := s.controller.Accept(ctx, apctx.OrganizationID, apctx.UserID, id, in) + return respond(ctx, accepted, err) +} + +func (s *EnakGameAdminServiceImpl) GameAnalytics(ctx context.Context, apctx *appcontext.ContextInfo, q models.EnakGameAnalyticsQuery) *contract.Response { + stats, err := s.analytics.Games(ctx, apctx.OrganizationID, q) + return respond(ctx, stats, err) +} + +func (s *EnakGameAdminServiceImpl) EconomyAnalytics(ctx context.Context, apctx *appcontext.ContextInfo, q models.EnakGameAnalyticsQuery) *contract.Response { + stats, err := s.analytics.Economy(ctx, apctx.OrganizationID, q) + return respond(ctx, stats, err) +} + func (s *EnakGameAdminServiceImpl) CreateEvent(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response { var in models.GameEventInput if resp := decodeStrict(body, &in); resp != nil { diff --git a/migrations/000112_add_budget_controller_to_reward_configs.down.sql b/migrations/000112_add_budget_controller_to_reward_configs.down.sql new file mode 100644 index 0000000..e4a8fdb --- /dev/null +++ b/migrations/000112_add_budget_controller_to_reward_configs.down.sql @@ -0,0 +1,7 @@ +DROP INDEX IF EXISTS idx_game_reward_configs_controller; + +ALTER TABLE game_reward_configs + DROP CONSTRAINT IF EXISTS chk_game_reward_configs_controller, + DROP COLUMN IF EXISTS budget_id, + DROP COLUMN IF EXISTS multiplier, + DROP COLUMN IF EXISTS base_config_id; diff --git a/migrations/000112_add_budget_controller_to_reward_configs.up.sql b/migrations/000112_add_budget_controller_to_reward_configs.up.sql new file mode 100644 index 0000000..7de230d --- /dev/null +++ b/migrations/000112_add_budget_controller_to_reward_configs.up.sql @@ -0,0 +1,19 @@ +-- Reward configurations made by accepting a Budget Controller recommendation +-- (docs/rfc-enakgame.md §10, PRD §29–§31). Such a version scales the amounts of the +-- version an admin wrote last, its base, so a run of adjustments never compounds its +-- rounding and min/max multiplier are measured against what the admin set. A version +-- an admin writes has none of the three, and becomes the base of the next adjustments. +ALTER TABLE game_reward_configs + ADD COLUMN base_config_id UUID REFERENCES game_reward_configs(id), + -- Applied to every amount of the base, rounded down: 0.8100 pays 81%. + ADD COLUMN multiplier NUMERIC(6,4), + -- The global budget whose recommendation was accepted. + ADD COLUMN budget_id UUID REFERENCES game_budgets(id), + ADD CONSTRAINT chk_game_reward_configs_controller CHECK ( + (base_config_id IS NULL AND multiplier IS NULL AND budget_id IS NULL) + OR (base_config_id IS NOT NULL AND multiplier > 0 AND budget_id IS NOT NULL AND effective_at IS NOT NULL)); + +-- The last adjustment of an organization, for the cooldown. effective_at is when the +-- Budget Controller made it, by the application clock. +CREATE INDEX idx_game_reward_configs_controller + ON game_reward_configs(organization_id, effective_at DESC) WHERE budget_id IS NOT NULL; diff --git a/migrations/000113_index_game_sessions_org_started.down.sql b/migrations/000113_index_game_sessions_org_started.down.sql new file mode 100644 index 0000000..cfca100 --- /dev/null +++ b/migrations/000113_index_game_sessions_org_started.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS idx_game_sessions_org_started; diff --git a/migrations/000113_index_game_sessions_org_started.up.sql b/migrations/000113_index_game_sessions_org_started.up.sql new file mode 100644 index 0000000..de5327c --- /dev/null +++ b/migrations/000113_index_game_sessions_org_started.up.sql @@ -0,0 +1,3 @@ +-- EnakGame analytics read an organization's sessions by start time +-- (docs/tasks-enakgame.md EG-903). +CREATE INDEX IF NOT EXISTS idx_game_sessions_org_started ON game_sessions(organization_id, started_at); diff --git a/migrations/000114_index_wallet_transactions_org_created.down.sql b/migrations/000114_index_wallet_transactions_org_created.down.sql new file mode 100644 index 0000000..1fefcc2 --- /dev/null +++ b/migrations/000114_index_wallet_transactions_org_created.down.sql @@ -0,0 +1 @@ +DROP INDEX CONCURRENTLY IF EXISTS idx_wallet_transactions_org_created; diff --git a/migrations/000114_index_wallet_transactions_org_created.up.sql b/migrations/000114_index_wallet_transactions_org_created.up.sql new file mode 100644 index 0000000..eb66392 --- /dev/null +++ b/migrations/000114_index_wallet_transactions_org_created.up.sql @@ -0,0 +1,3 @@ +-- EnakGame economy analytics sum an organization's ledger over a date range +-- (docs/tasks-enakgame.md EG-903). One statement, for CONCURRENTLY. +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_wallet_transactions_org_created ON wallet_transactions(organization_id, created_at);