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
|
§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.
|
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,
|
`GET` metrik cukup cepat untuk dashboard selama index di §5.7 ada. Bila nanti lambat,
|
||||||
tambahkan snapshot harian, bukan cache yang di-invalidate.
|
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` |
|
| Games | CRUD, `PUT /:id/status` |
|
||||||
| Reward configs | `POST /games/:id/reward-configs` (versi baru), `POST /reward-configs/:id/activate`, `GET` daftar versi |
|
| Reward configs | `POST /games/:id/reward-configs` (versi baru), `POST /reward-configs/:id/activate`, `GET` daftar versi |
|
||||||
| Events | CRUD, `PUT /:id/status` |
|
| 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` |
|
| Vouchers | CRUD, `POST /:id/codes` (impor CSV), `GET /:id/codes` |
|
||||||
| Redemptions | `GET` list, `GET /:id` dengan atribusi cost |
|
| Redemptions | `GET` list, `GET /:id` dengan atribusi cost |
|
||||||
| Sessions | `GET` list + filter `flagged` |
|
| Sessions | `GET` list + filter `flagged` |
|
||||||
@@ -974,7 +998,8 @@ Dari PRD §43:
|
|||||||
1. **Pembulatan reward.** Usulan §8: bulatkan ke bawah, mengikuti K6.
|
1. **Pembulatan reward.** Usulan §8: bulatkan ke bawah, mengikuti K6.
|
||||||
2. **Event stacking.** Sementara memakai default PRD §16.
|
2. **Event stacking.** Sementara memakai default PRD §16.
|
||||||
3. **Budget exhaustion policy**, untuk budget global dan budget event.
|
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.
|
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
|
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)
|
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
|
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)
|
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)
|
fileService := service.NewFileServiceImpl(processors.fileProcessor)
|
||||||
var customerService service.CustomerService = service.NewCustomerService(processors.customerProcessor)
|
var customerService service.CustomerService = service.NewCustomerService(processors.customerProcessor)
|
||||||
analyticsService := service.NewAnalyticsServiceImpl(processors.analyticsProcessor)
|
analyticsService := service.NewAnalyticsServiceImpl(processors.analyticsProcessor)
|
||||||
@@ -679,10 +681,13 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
|||||||
customerOutletService: service.NewCustomerOutletService(processors.customerOutletProcessor),
|
customerOutletService: service.NewCustomerOutletService(processors.customerOutletProcessor),
|
||||||
customerOrderService: service.NewCustomerOrderService(processors.customerOrderProcessor),
|
customerOrderService: service.NewCustomerOrderService(processors.customerOrderProcessor),
|
||||||
enakGameAdminService: service.NewEnakGameAdminService(processors.enakGameAdminProcessor, processors.gameBudgetProcessor,
|
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.NewGameEventProcessor(repository.NewGameEventRepository(a.db), repository.NewEnakGameRepository(a.db), repository.NewGameBudgetRepository(a.db),
|
||||||
processor.NewAuditLogger(repository.NewAuditLogRepository(a.db)), repos.txManager),
|
enakGameAudit, repos.txManager),
|
||||||
processors.voucherAdminProcessor),
|
processors.voucherAdminProcessor,
|
||||||
|
processor.NewEnakGameAnalyticsProcessor(repository.NewEnakGameAnalyticsRepository(a.db))),
|
||||||
enakGameCustomerService: service.NewEnakGameCustomerService(processors.gameSessionProcessor, processors.voucherRedemptionProcessor),
|
enakGameCustomerService: service.NewEnakGameCustomerService(processors.gameSessionProcessor, processors.voucherRedemptionProcessor),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ const (
|
|||||||
AuditEntityVoucher = "VOUCHER"
|
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).
|
// game_reward_counters.scope_type: what an Economy Guard counter counts for (§5.8).
|
||||||
const (
|
const (
|
||||||
GameRewardScopeUser = "USER"
|
GameRewardScopeUser = "USER"
|
||||||
@@ -144,3 +147,32 @@ const (
|
|||||||
GameEventStatusEnded = "ENDED"
|
GameEventStatusEnded = "ENDED"
|
||||||
GameEventStatusCancelled = "CANCELLED"
|
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"`
|
EffectiveAt *time.Time `json:"effective_at"`
|
||||||
CreatedBy uuid.UUID `gorm:"type:uuid;not null" json:"created_by"`
|
CreatedBy uuid.UUID `gorm:"type:uuid;not null" json:"created_by"`
|
||||||
Reason *string `gorm:"size:255" json:"reason"`
|
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" }
|
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)
|
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.
|
// CreateVoucher is POST /marketing/enakgame/vouchers.
|
||||||
func (h *EnakGameAdminHandler) CreateVoucher(c *gin.Context) {
|
func (h *EnakGameAdminHandler) CreateVoucher(c *gin.Context) {
|
||||||
const method = "EnakGameAdminHandler::CreateVoucher"
|
const method = "EnakGameAdminHandler::CreateVoucher"
|
||||||
|
|||||||
@@ -38,11 +38,13 @@ func enakGameHandlers(db *gorm.DB) (*EnakGameAdminHandler, *EnakGameCustomerHand
|
|||||||
wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db))
|
wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db))
|
||||||
customers := repository.NewWalletMoveRepository(db)
|
customers := repository.NewWalletMoveRepository(db)
|
||||||
spendable := repository.NewWalletQueryRepository(db)
|
spendable := repository.NewWalletQueryRepository(db)
|
||||||
|
metrics := processor.NewGameBudgetMetricsProcessor(budgets, repository.NewGameBudgetMetricsRepository(db))
|
||||||
admin := NewEnakGameAdminHandler(service.NewEnakGameAdminService(
|
admin := NewEnakGameAdminHandler(service.NewEnakGameAdminService(
|
||||||
processor.NewEnakGameAdminProcessor(games, audit, txm), processor.NewGameBudgetProcessor(budgets, audit, txm),
|
processor.NewEnakGameAdminProcessor(games, audit, txm), processor.NewGameBudgetProcessor(budgets, audit, txm), metrics,
|
||||||
processor.NewGameBudgetMetricsProcessor(budgets, repository.NewGameBudgetMetricsRepository(db)),
|
processor.NewGameBudgetControllerProcessor(budgets, metrics, games, audit, txm),
|
||||||
processor.NewGameEventProcessor(repository.NewGameEventRepository(db), games, budgets, 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(
|
customer := NewEnakGameCustomerHandler(service.NewEnakGameCustomerService(
|
||||||
processor.NewGameSessionProcessor(customers, games, repository.NewGameSessionRepository(db), budgets,
|
processor.NewGameSessionProcessor(customers, games, repository.NewGameSessionRepository(db), budgets,
|
||||||
repository.NewGameEventRepository(db), repository.NewGameRewardCounterRepository(db), processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm),
|
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"`
|
EffectiveAt *time.Time `json:"effective_at"`
|
||||||
CreatedBy uuid.UUID `json:"created_by"`
|
CreatedBy uuid.UUID `json:"created_by"`
|
||||||
Reason *string `json:"reason"`
|
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 {
|
type GameBudgetThresholds struct {
|
||||||
Warning *int64 `json:"warning,omitempty"`
|
Warning *int64 `json:"warning,omitempty"`
|
||||||
Critical *int64 `json:"critical,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
|
// 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"`
|
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
|
// GameEventInput creates or changes an event (§5.5). On a change, fields left out
|
||||||
// keep their value.
|
// keep their value.
|
||||||
type GameEventInput struct {
|
type GameEventInput struct {
|
||||||
|
|||||||
@@ -443,7 +443,7 @@ func rewardConfigModel(c *entities.GameRewardConfig) *models.GameRewardConfig {
|
|||||||
return &models.GameRewardConfig{
|
return &models.GameRewardConfig{
|
||||||
ID: c.ID, GameID: c.GameID, Version: c.Version, RewardType: c.RewardType, Rules: json.RawMessage(c.Rules),
|
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,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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))
|
today := civilDay(walletDay(now))
|
||||||
start, end := civilDay(budget.PeriodStart), civilDay(budget.PeriodEnd)
|
start, end := civilDay(budget.PeriodStart), civilDay(budget.PeriodEnd)
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ type GameBudgetProcessor struct {
|
|||||||
now func() time.Time
|
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 {
|
func NewGameBudgetProcessor(budgets repository.GameBudgetRepository, audit *AuditLogger, tx TxRunner) *GameBudgetProcessor {
|
||||||
return &GameBudgetProcessor{budgets: budgets, audit: audit, tx: tx, now: time.Now}
|
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 {
|
if t.Warning != nil && t.Critical != nil && *t.Warning > *t.Critical {
|
||||||
return nil, enakGameRejected("thresholds.warning cannot be above thresholds.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)
|
thresholds, err := json.Marshal(t)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"math/big"
|
"math/big"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -60,6 +61,43 @@ type RewardCalculator interface {
|
|||||||
// Calculate prices a result under rules that passed Validate. detail says how the
|
// Calculate prices a result under rules that passed Validate. detail says how the
|
||||||
// amount was reached, for reward_breakdown.
|
// amount was reached, for reward_breakdown.
|
||||||
Calculate(rules json.RawMessage, result SessionResult, rng RewardRNG) (base int64, detail map[string]any, err error)
|
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{
|
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
|
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}]}.
|
// 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
|
// 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.
|
// last may leave max out, to cover every higher score.
|
||||||
type scoreBasedReward struct{}
|
type scoreBasedReward struct{}
|
||||||
|
|
||||||
type scoreBand struct {
|
type scoreBand struct {
|
||||||
Min *int64 `json:"min"`
|
Min *int64 `json:"min"`
|
||||||
Max *int64 `json:"max"`
|
// Left out of the last band, also when scaled rules are written back.
|
||||||
|
Max *int64 `json:"max,omitempty"`
|
||||||
Amount *int64 `json:"amount"`
|
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)
|
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}}.
|
// OUTCOME_BASED: {"outcomes": {"PERFECT": 20, "GOOD": 10, "FAIL": 0}}.
|
||||||
type outcomeBasedReward struct{}
|
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
|
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}]}.
|
// 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
|
// Weights are whole numbers, so no check depends on floating point (§8). The draw is
|
||||||
// kept in the detail for audit.
|
// 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)
|
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)
|
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
|
// change a configuration allows (D7). It reports false when the configuration was
|
||||||
// not in from.
|
// not in from.
|
||||||
SetRewardConfigStatus(ctx context.Context, organizationID, id uuid.UUID, from, to string) (bool, error)
|
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 {
|
type enakGameRepository struct {
|
||||||
@@ -176,14 +189,15 @@ func (r *enakGameRepository) CreateRewardConfig(ctx context.Context, config *ent
|
|||||||
}
|
}
|
||||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||||
INSERT INTO game_reward_configs (id, organization_id, game_id, version, reward_type, rules,
|
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,
|
SELECT ?, g.organization_id, g.id,
|
||||||
COALESCE((SELECT MAX(version) FROM game_reward_configs WHERE game_id = g.id), 0) + 1,
|
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 = ?
|
FROM games g WHERE g.organization_id = ? AND g.id = ?
|
||||||
RETURNING version, created_at`,
|
RETURNING version, created_at`,
|
||||||
config.ID, config.RewardType, config.Rules, config.MaxReward, config.Status, config.EffectiveAt,
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create reward config: %w", err)
|
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
|
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.POST("/budgets", r.authMiddleware.RequireLoyaltyManager(), r.enakGameAdminHandler.CreateBudget)
|
||||||
enakGame.PUT("/budgets/:id", r.authMiddleware.RequireLoyaltyManager(), r.enakGameAdminHandler.UpdateBudget)
|
enakGame.PUT("/budgets/:id", r.authMiddleware.RequireLoyaltyManager(), r.enakGameAdminHandler.UpdateBudget)
|
||||||
enakGame.DELETE("/budgets/:id", r.authMiddleware.RequireLoyaltyManager(), r.enakGameAdminHandler.DeleteBudget)
|
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.
|
// Events change what rewards cost, like budgets.
|
||||||
enakGame.GET("/events", r.enakGameAdminHandler.ListEvents)
|
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
|
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
|
DeleteBudget(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response
|
||||||
BudgetMetrics(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
|
CreateEvent(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response
|
||||||
ListEvents(ctx context.Context, apctx *appcontext.ContextInfo, q models.GameEventListQuery) *contract.Response
|
ListEvents(ctx context.Context, apctx *appcontext.ContextInfo, q models.GameEventListQuery) *contract.Response
|
||||||
@@ -74,16 +81,21 @@ type EnakGameCustomerService interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type EnakGameAdminServiceImpl struct {
|
type EnakGameAdminServiceImpl struct {
|
||||||
games *processor.EnakGameAdminProcessor
|
games *processor.EnakGameAdminProcessor
|
||||||
budgets *processor.GameBudgetProcessor
|
budgets *processor.GameBudgetProcessor
|
||||||
metrics *processor.GameBudgetMetricsProcessor
|
metrics *processor.GameBudgetMetricsProcessor
|
||||||
events *processor.GameEventProcessor
|
controller *processor.GameBudgetControllerProcessor
|
||||||
vouchers *processor.VoucherAdminProcessor
|
events *processor.GameEventProcessor
|
||||||
|
vouchers *processor.VoucherAdminProcessor
|
||||||
|
analytics *processor.EnakGameAnalyticsProcessor
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEnakGameAdminService(games *processor.EnakGameAdminProcessor, budgets *processor.GameBudgetProcessor, metrics *processor.GameBudgetMetricsProcessor,
|
func NewEnakGameAdminService(games *processor.EnakGameAdminProcessor, budgets *processor.GameBudgetProcessor, metrics *processor.GameBudgetMetricsProcessor,
|
||||||
events *processor.GameEventProcessor, vouchers *processor.VoucherAdminProcessor) *EnakGameAdminServiceImpl {
|
controller *processor.GameBudgetControllerProcessor, events *processor.GameEventProcessor, vouchers *processor.VoucherAdminProcessor,
|
||||||
return &EnakGameAdminServiceImpl{games: games, budgets: budgets, metrics: metrics, events: events, vouchers: vouchers}
|
analytics *processor.EnakGameAnalyticsProcessor) *EnakGameAdminServiceImpl {
|
||||||
|
return &EnakGameAdminServiceImpl{
|
||||||
|
games: games, budgets: budgets, metrics: metrics, controller: controller, events: events, vouchers: vouchers, analytics: analytics,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type EnakGameCustomerServiceImpl struct {
|
type EnakGameCustomerServiceImpl struct {
|
||||||
@@ -213,6 +225,30 @@ func (s *EnakGameAdminServiceImpl) BudgetMetrics(ctx context.Context, apctx *app
|
|||||||
return respond(ctx, metrics, err)
|
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 {
|
func (s *EnakGameAdminServiceImpl) CreateEvent(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response {
|
||||||
var in models.GameEventInput
|
var in models.GameEventInput
|
||||||
if resp := decodeStrict(body, &in); resp != nil {
|
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