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:
co-authored by
Claude Opus 5.5
parent
2c9753fae7
commit
798a36bd6c
@@ -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"`
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user