feat(enakgame): game sessions, rewards, vouchers, budgets and events

EnakGame phases 1-8 of docs/tasks-enakgame.md (EG-101 to EG-803), built on the
existing EnakPoint/EnakCoin wallet (docs/rfc-enakgame.md).

Foundation (phase 1)
- Migrations 000103-000106: games extended with organization, slug, status,
  entry cost and result rules, old games archived (not deleted); budgets,
  versioned reward configs, sessions and session rewards; the ledger types
  GAME_SPEND_REFUND, GAME_REWARD and REWARD_REDEEM_REFUND; audit_logs.
- AuditLogger writes in the caller's transaction only.
- enakgame.limit.user_daily and global_daily organization settings.

Games and sessions (phases 2-4)
- Admin /marketing/enakgame: games, reward config versions (immutable but for
  status, one ACTIVE per game), budgets with non-overlapping global periods and
  a daily job opening the next month.
- Customer /customer/enakgame: start (Idempotency-Key, entry cost and config
  frozen on the session), complete (result validation, reward engine, max_reward
  cap, daily limits via game_reward_counters, one GAME_REWARD per budget),
  automatic refunds for system errors and deactivated games, and a session job.
- Reward engine: FIXED, SCORE_BASED, OUTCOME_BASED, PROBABILITY (crypto/rand),
  rounded down.

Vouchers and budgets (phases 5-6)
- Migration 000108 and 000107: vouchers, codes, redemptions, cost attribution;
  Economy Guard counters.
- STATIC and CODE_POOL redemption in one transaction with the REDEEM PIN action;
  realized cost traced through the lots to the budget that paid the reward.
- Budget metrics: realized cost, forecast, exposure and status. Migrations
  000109-000110 add the wallet_lots indexes they need, built CONCURRENTLY.

Events (phase 7)
- Migration 000111: game events, each with its own EVENT budget. Event extras
  stack per PRD §16 defaults, with event and per-customer limits.

External vouchers (phase 8)
- VoucherProvider contract, two-step PENDING redemption and a recovery job,
  tested with a fake provider. No provider adapter is registered yet, so
  EXTERNAL vouchers stay out of the catalog.

Not yet decided before release: reward rounding, event stacking, budget
exhaustion policy and thresholds (RFC §19.2). Migrations 000103-000111 have
not been run on any shared database.

Also fixes a leftover PAYMENT filter in a wallet test and a data race in a
test PIN fake.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-10-07 20:53:14 +07:00
co-authored by Claude Opus 5.5
parent 2c9753fae7
commit 798a36bd6c
92 changed files with 12392 additions and 23 deletions
+435
View File
@@ -0,0 +1,435 @@
package models
import (
"encoding/json"
"time"
"github.com/google/uuid"
"apskel-pos-be/internal/entities"
)
// EnakGameInput is what an admin sends to create or change a game
// (docs/rfc-enakgame.md §11). On a change, fields left out keep their value.
type EnakGameInput struct {
Name string `json:"name"`
// SPIN, RAFFLE or MINIGAME; MINIGAME when left out on create.
Type string `json:"type"`
Slug string `json:"slug"`
Description *string `json:"description"`
ThumbnailURL *string `json:"thumbnail_url"`
GameURL *string `json:"game_url"`
Version *string `json:"version"`
// Create only: DRAFT (default), ACTIVE or INACTIVE. Changed later through the
// status endpoint.
Status string `json:"status"`
EntryCost int64 `json:"entry_cost"`
// 600 when left out on create.
SessionTTLSeconds int `json:"session_ttl_seconds"`
ResultRules entities.GameResultRules `json:"result_rules"`
}
type EnakGame struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Slug string `json:"slug"`
Description *string `json:"description"`
ThumbnailURL *string `json:"thumbnail_url"`
GameURL *string `json:"game_url"`
Version *string `json:"version"`
Status string `json:"status"`
EntryCost int64 `json:"entry_cost"`
SessionTTLSeconds int `json:"session_ttl_seconds"`
ResultRules entities.GameResultRules `json:"result_rules"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// EnakGameListQuery filters the admin game list. ARCHIVED games are left out unless
// asked for by status.
type EnakGameListQuery struct {
Status string `form:"status"`
Search string `form:"search"`
Page int `form:"page"`
Limit int `form:"limit"`
}
type EnakGameStatusInput struct {
Status string `json:"status"`
Reason *string `json:"reason"`
}
// GameRewardConfigInput is a new version of a game's reward configuration (§5.2).
type GameRewardConfigInput struct {
RewardType string `json:"reward_type"`
Rules json.RawMessage `json:"rules"`
MaxReward int64 `json:"max_reward"`
EffectiveAt *time.Time `json:"effective_at"`
Reason *string `json:"reason"`
}
type GameRewardConfigActivateInput struct {
Reason *string `json:"reason"`
}
type GameRewardConfig struct {
ID uuid.UUID `json:"id"`
GameID uuid.UUID `json:"game_id"`
Version int `json:"version"`
RewardType string `json:"reward_type"`
Rules json.RawMessage `json:"rules"`
MaxReward int64 `json:"max_reward"`
Status string `json:"status"`
EffectiveAt *time.Time `json:"effective_at"`
CreatedBy uuid.UUID `json:"created_by"`
Reason *string `json:"reason"`
CreatedAt time.Time `json:"created_at"`
}
// GameBudgetThresholds are percents of utilization or forecast (PRD §8, §32).
type GameBudgetThresholds struct {
Warning *int64 `json:"warning,omitempty"`
Critical *int64 `json:"critical,omitempty"`
}
// GameBudgetInput creates or changes a budget (§5.6). Dates are YYYY-MM-DD and
// inclusive. On a change, fields left out keep their value; the scope never changes.
type GameBudgetInput struct {
Scope string `json:"scope"`
Name string `json:"name"`
PeriodStart string `json:"period_start"`
PeriodEnd string `json:"period_end"`
Amount int64 `json:"amount"`
Thresholds GameBudgetThresholds `json:"thresholds"`
}
type GameBudget struct {
ID uuid.UUID `json:"id"`
Scope string `json:"scope"`
Name string `json:"name"`
PeriodStart string `json:"period_start"`
PeriodEnd string `json:"period_end"`
Amount int64 `json:"amount"`
Thresholds GameBudgetThresholds `json:"thresholds"`
CreatedBy uuid.UUID `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type GameBudgetListQuery struct {
Scope string `form:"scope"`
Page int `form:"page"`
Limit int `form:"limit"`
}
// CustomerEnakGame is a game as the customer app sees it: what it costs and where to
// load it, without the validation limits.
type CustomerEnakGame struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description *string `json:"description"`
ThumbnailURL *string `json:"thumbnail_url"`
GameURL *string `json:"game_url"`
Version *string `json:"version"`
EntryCost int64 `json:"entry_cost"`
SessionTTLSeconds int `json:"session_ttl_seconds"`
// The events making the game pay more right now, highest priority first.
Events []CustomerGameEvent `json:"events"`
}
// GameSessionStart is the response of starting a game (§7.1).
type GameSessionStart struct {
SessionID uuid.UUID `json:"session_id"`
GameID uuid.UUID `json:"game_id"`
EntryCost int64 `json:"entry_cost"`
ExpiresAt time.Time `json:"expires_at"`
CoinBalance int64 `json:"coin_balance"`
// True when this repeats an earlier start with the same Idempotency-Key.
Replayed bool `json:"replayed"`
}
// CustomerGameSession is a session as its customer sees it. The reward breakdown,
// the RNG draw and whether it was flagged stay internal.
type CustomerGameSession struct {
ID uuid.UUID `json:"id"`
GameID uuid.UUID `json:"game_id"`
Status string `json:"status"`
EntryCost int64 `json:"entry_cost"`
RewardTotal int64 `json:"reward_total"`
StartedAt time.Time `json:"started_at"`
ExpiresAt time.Time `json:"expires_at"`
EndedAt *time.Time `json:"ended_at"`
RefundReason *string `json:"refund_reason"`
}
// GameSessionCompleteInput is what the client reports at the end of a play (§7.2): data
// only. Anything else it sends, a reward amount above all, is ignored (P1).
type GameSessionCompleteInput struct {
Score *int64 `json:"score"`
Outcome *string `json:"outcome"`
Data json.RawMessage `json:"data"`
}
// GameSessionCompletion is the response of completing a session. Sending the same
// completion again returns the same response.
type GameSessionCompletion struct {
SessionID uuid.UUID `json:"session_id"`
// COMPLETED, or REFUNDED when the game was turned off during the play.
Status string `json:"status"`
RefundReason *string `json:"refund_reason,omitempty"`
RewardTotal int64 `json:"reward_total"`
// The parts of the reward that are safe to show.
Reward GameSessionRewardParts `json:"reward"`
CoinBalance int64 `json:"coin_balance"`
// The daily limits that made the reward smaller than earned: USER_DAILY,
// GAME_DAILY or GLOBAL_DAILY.
LimitedBy []string `json:"limited_by,omitempty"`
}
type GameSessionRewardParts struct {
Base int64 `json:"base"`
// Added by events; 0 until events exist.
Event int64 `json:"event"`
}
// VoucherInput creates or changes a voucher (§5.7). On a change, fields left out keep
// their value; the stock mode never changes.
type VoucherInput struct {
Name string `json:"name"`
Description *string `json:"description"`
ImageURL *string `json:"image_url"`
VoucherType string `json:"voucher_type"`
FaceValue int64 `json:"face_value"`
PointCost int64 `json:"point_cost"`
BusinessCost *int64 `json:"business_cost"`
StockMode string `json:"stock_mode"`
// STATIC only.
Stock *int64 `json:"stock"`
// EXTERNAL only.
Provider *string `json:"provider"`
ProviderRef *string `json:"provider_ref"`
MaxPerCustomer *int `json:"max_per_customer"`
ValidFrom *time.Time `json:"valid_from"`
ValidUntil *time.Time `json:"valid_until"`
Terms json.RawMessage `json:"terms"`
// Create only: DRAFT (default), ACTIVE or INACTIVE.
Status string `json:"status"`
}
type Voucher struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description *string `json:"description"`
ImageURL *string `json:"image_url"`
VoucherType string `json:"voucher_type"`
FaceValue int64 `json:"face_value"`
PointCost int64 `json:"point_cost"`
BusinessCost *int64 `json:"business_cost"`
StockMode string `json:"stock_mode"`
Stock *int64 `json:"stock"`
Provider *string `json:"provider"`
ProviderRef *string `json:"provider_ref"`
MaxPerCustomer *int `json:"max_per_customer"`
ValidFrom *time.Time `json:"valid_from"`
ValidUntil *time.Time `json:"valid_until"`
Terms json.RawMessage `json:"terms"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type VoucherListQuery struct {
Status string `form:"status"`
Search string `form:"search"`
Page int `form:"page"`
Limit int `form:"limit"`
}
type VoucherStatusInput struct {
Status string `json:"status"`
Reason *string `json:"reason"`
}
// VoucherCodeImportResult says what an import of codes did.
type VoucherCodeImportResult struct {
Imported int `json:"imported"`
// Codes already in the pool or repeated in the file, skipped.
DuplicateCount int `json:"duplicate_count"`
Duplicates []string `json:"duplicates"`
// Lines that could not be read, skipped.
Invalid []VoucherCodeImportProblem `json:"invalid"`
}
type VoucherCodeImportProblem struct {
Line int `json:"line"`
Reason string `json:"reason"`
}
type VoucherCodeListQuery struct {
Status string `form:"status"`
Page int `form:"page"`
Limit int `form:"limit"`
}
type VoucherCode struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
Status string `json:"status"`
RedemptionID *uuid.UUID `json:"redemption_id"`
ExpiresAt *time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
}
// VoucherCodes is a pool's codes: how many in each status, and one page of them.
type VoucherCodes struct {
Counts map[string]int64 `json:"counts"`
Codes PaginatedResponse[VoucherCode] `json:"codes"`
}
// CustomerVoucher is a voucher in the customer app's catalog. Available is how many
// are left, without saying how many were redeemed or expired.
type CustomerVoucher struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description *string `json:"description"`
ImageURL *string `json:"image_url"`
VoucherType string `json:"voucher_type"`
FaceValue int64 `json:"face_value"`
PointCost int64 `json:"point_cost"`
MaxPerCustomer *int `json:"max_per_customer"`
ValidUntil *time.Time `json:"valid_until"`
Terms json.RawMessage `json:"terms"`
Available *int64 `json:"available"`
}
// CustomerVoucherRedemption is a redemption as its customer sees it, with the code to
// use. The response of a redeem adds the EnakPoint balance.
type CustomerVoucherRedemption struct {
ID uuid.UUID `json:"id"`
VoucherID uuid.UUID `json:"voucher_id"`
VoucherName string `json:"voucher_name"`
VoucherImageURL *string `json:"voucher_image_url"`
VoucherType string `json:"voucher_type"`
Status string `json:"status"`
FaceValue int64 `json:"face_value"`
PointCost int64 `json:"point_cost"`
Code *string `json:"code"`
CodeExpiresAt *time.Time `json:"code_expires_at"`
CompletedAt *time.Time `json:"completed_at"`
CreatedAt time.Time `json:"created_at"`
}
type VoucherRedeemResult struct {
CustomerVoucherRedemption
PointBalance int64 `json:"point_balance"`
// True when this repeats an earlier redeem with the same Idempotency-Key.
Replayed bool `json:"replayed"`
}
// GameBudgetMetrics is how a budget stands (docs/rfc-enakgame.md §10, PRD §8). Money is
// in rupiah, percents have two decimals.
type GameBudgetMetrics struct {
BudgetID uuid.UUID `json:"budget_id"`
Scope string `json:"scope"`
PeriodStart string `json:"period_start"`
PeriodEnd string `json:"period_end"`
// The day, in Asia/Jakarta, the metrics are for.
AsOf string `json:"as_of"`
Amount int64 `json:"amount"`
RealizedCost int64 `json:"realized_cost"`
Remaining int64 `json:"remaining"`
UtilizationPercent float64 `json:"utilization_percent"`
// Average realized cost per day over the last WindowDays days, today included.
DailyBurn int64 `json:"daily_burn"`
WindowDays int64 `json:"window_days"`
// Days of the period after today.
RemainingDays int64 `json:"remaining_days"`
ForecastCost int64 `json:"forecast_cost"`
ForecastRemaining int64 `json:"forecast_remaining"`
ForecastUtilizationPercent float64 `json:"forecast_utilization_percent"`
CoinIssued int64 `json:"coin_issued"`
// What the budget's rewards still hold, spendable: the most that can still turn
// into cost.
Exposure GameBudgetExposure `json:"exposure"`
// The thresholds used: the budget's own, or the defaults for those it does not set.
Thresholds GameBudgetThresholds `json:"thresholds"`
// HEALTHY, WARNING, CRITICAL or EXHAUSTED.
Status string `json:"status"`
}
type GameBudgetExposure struct {
Coins int64 `json:"coins"`
Points int64 `json:"points"`
}
// GameEventInput creates or changes an event (§5.5). On a change, fields left out
// keep their value.
type GameEventInput struct {
Name string `json:"name"`
Slug string `json:"slug"`
Description *string `json:"description"`
BannerURL *string `json:"banner_url"`
StartAt time.Time `json:"start_at"`
EndAt time.Time `json:"end_at"`
// Asia/Jakarta when left out.
Timezone string `json:"timezone"`
Priority int `json:"priority"`
// At least 1, two decimals at most. 2 adds the base reward once more.
Multiplier *float64 `json:"multiplier"`
Bonus *int64 `json:"bonus"`
// An EVENT budget of the organization; it pays what the event adds.
BudgetID uuid.UUID `json:"budget_id"`
RewardLimit *int64 `json:"reward_limit"`
UserDailyLimit *int64 `json:"user_daily_limit"`
GameIDs []uuid.UUID `json:"game_ids"`
// Create only: DRAFT (default) or ACTIVE.
Status string `json:"status"`
}
type GameEvent struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description *string `json:"description"`
BannerURL *string `json:"banner_url"`
StartAt time.Time `json:"start_at"`
EndAt time.Time `json:"end_at"`
Timezone string `json:"timezone"`
Status string `json:"status"`
Priority int `json:"priority"`
Multiplier *float64 `json:"multiplier"`
Bonus *int64 `json:"bonus"`
BudgetID uuid.UUID `json:"budget_id"`
RewardLimit *int64 `json:"reward_limit"`
UserDailyLimit *int64 `json:"user_daily_limit"`
GameIDs []uuid.UUID `json:"game_ids"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type GameEventListQuery struct {
Status string `form:"status"`
Page int `form:"page"`
Limit int `form:"limit"`
}
type GameEventStatusInput struct {
Status string `json:"status"`
Reason *string `json:"reason"`
}
// CustomerGameEvent is an event running on a game, as the customer app shows it.
type CustomerGameEvent struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
BannerURL *string `json:"banner_url"`
Multiplier *float64 `json:"multiplier"`
Bonus *int64 `json:"bonus"`
EndAt time.Time `json:"end_at"`
}
+10
View File
@@ -44,6 +44,16 @@ type OrganizationLoyaltySettings struct {
Transfer LoyaltyTransferSettings `json:"transfer"`
PointExpiry LoyaltyExpirySettings `json:"point_expiry"`
CoinExpiry LoyaltyExpirySettings `json:"coin_expiry"`
EnakGame EnakGameLimitSettings `json:"enakgame"`
}
// EnakGameLimitSettings caps the EnakCoin EnakGame rewards per day, in Asia/Jakarta
// (docs/rfc-enakgame.md §5.10). 0 means no limit.
type EnakGameLimitSettings struct {
// What one customer may receive.
UserDailyLimit int64 `json:"user_daily_limit"`
// What the whole organization may give out.
GlobalDailyLimit int64 `json:"global_daily_limit"`
}
type LoyaltyExchangeSettings struct {