feat(enakgame): budget controller recommendations and analytics
EnakGame phase 9 of docs/tasks-enakgame.md (EG-901 to EG-903). Budget Controller (EG-901, EG-902) - GET /marketing/enakgame/budgets/:id/recommendation, GLOBAL budgets only: the multiplier (budget − realized) / (forecast − realized), within one step of 1, rounded down to two decimals, either way. Shows each game's new rules. - POST .../recommendation/accept with the multiplier the admin saw: recomputed in the transaction, then one new ACTIVE version per game, the old one RETIRED, audited with source budget_controller and RECOMMENDATION_ACCEPTED on the budget. - Migration 000112: base_config_id, multiplier and budget_id on game_reward_configs. Rules are always scaled from the admin's last version, so rounding does not compound and min/max are against what the admin set. - Guardrails in game_budgets.thresholds: max_step_percent 10, min/max multiplier 50-150%, cooldown_days 7 per organization. Provisional pending RFC §19.2 #4. - RewardCalculator.Scale for the four reward types: amounts only, rounded down. Analytics (EG-903) - GET /marketing/enakgame/analytics/games and /analytics/economy over a range of Asia/Jakarta days (at most 366), from game_sessions and the wallet ledger. - Migrations 000113 (game_sessions by organization and start) and 000114 (wallet_transactions by organization and time, CONCURRENTLY). The Postgres tests for accepting and analytics were not run: no test database here. Migrations 000112-000114 have not been run anywhere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
798a36bd6c
commit
296708244e
+27
-2
@@ -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
|
||||
|
||||
+8
-3
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
|
||||
+165
-2
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS idx_game_sessions_org_started;
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX CONCURRENTLY IF EXISTS idx_wallet_transactions_org_created;
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user