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>
198 lines
7.9 KiB
Go
198 lines
7.9 KiB
Go
package entities
|
|
|
|
import (
|
|
"database/sql/driver"
|
|
"encoding/json"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// JSONDocument is a JSONB column kept as raw JSON, for documents whose shape depends
|
|
// on another column or is only read back as a whole. Empty is SQL NULL.
|
|
type JSONDocument json.RawMessage
|
|
|
|
func (d JSONDocument) Value() (driver.Value, error) {
|
|
if len(d) == 0 {
|
|
return nil, nil
|
|
}
|
|
return string(d), nil
|
|
}
|
|
|
|
func (d *JSONDocument) Scan(value interface{}) error {
|
|
switch v := value.(type) {
|
|
case nil:
|
|
*d = nil
|
|
case []byte:
|
|
*d = append(JSONDocument(nil), v...)
|
|
case string:
|
|
*d = JSONDocument(v)
|
|
default:
|
|
return errors.New("JSONDocument: unsupported type")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (d JSONDocument) MarshalJSON() ([]byte, error) {
|
|
if len(d) == 0 {
|
|
return []byte("null"), nil
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
func (d *JSONDocument) UnmarshalJSON(data []byte) error {
|
|
*d = append(JSONDocument(nil), data...)
|
|
return nil
|
|
}
|
|
|
|
// GameResultRules are a game's limits on a result (docs/rfc-enakgame.md §5.1, §7.2)
|
|
// and its daily reward limit (§5.10, §9). A limit left out does not apply.
|
|
type GameResultRules struct {
|
|
MaxScore *int64 `json:"max_score,omitempty"`
|
|
MinDurationSeconds *int64 `json:"min_duration_seconds,omitempty"`
|
|
MaxScorePerSecond *float64 `json:"max_score_per_second,omitempty"`
|
|
// The outcomes a result may report. Empty accepts any.
|
|
Outcomes []string `json:"outcomes,omitempty"`
|
|
// EnakCoin the game may give out per day, in Asia/Jakarta.
|
|
DailyRewardLimit *int64 `json:"daily_reward_limit,omitempty"`
|
|
}
|
|
|
|
func (r GameResultRules) Value() (driver.Value, error) {
|
|
b, err := json.Marshal(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return string(b), nil
|
|
}
|
|
|
|
func (r *GameResultRules) Scan(value interface{}) error {
|
|
*r = GameResultRules{}
|
|
switch v := value.(type) {
|
|
case nil:
|
|
return nil
|
|
case []byte:
|
|
return json.Unmarshal(v, r)
|
|
case string:
|
|
return json.Unmarshal([]byte(v), r)
|
|
default:
|
|
return errors.New("GameResultRules: unsupported type")
|
|
}
|
|
}
|
|
|
|
// GameRewardConfig is one version of how a game computes its base reward (§5.2, §8).
|
|
// Never updated apart from Status (D7): a change is a new version.
|
|
type GameRewardConfig struct {
|
|
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
|
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
|
|
GameID uuid.UUID `gorm:"type:uuid;not null" json:"game_id"`
|
|
Version int `gorm:"not null" json:"version"`
|
|
// FIXED, SCORE_BASED, OUTCOME_BASED or PROBABILITY.
|
|
RewardType string `gorm:"size:30;not null" json:"reward_type"`
|
|
// Shape per RewardType in §8.
|
|
Rules JSONDocument `gorm:"type:jsonb;not null" json:"rules"`
|
|
MaxReward int64 `gorm:"not null" json:"max_reward"`
|
|
// DRAFT, ACTIVE or RETIRED.
|
|
Status string `gorm:"size:20;not null" json:"status"`
|
|
EffectiveAt *time.Time `json:"effective_at"`
|
|
CreatedBy uuid.UUID `gorm:"type:uuid;not null" json:"created_by"`
|
|
Reason *string `gorm:"size:255" json:"reason"`
|
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
|
}
|
|
|
|
func (GameRewardConfig) TableName() string { return "game_reward_configs" }
|
|
|
|
// GameSession is one play of a game by a customer (§5.3): STARTED, then COMPLETED,
|
|
// REFUNDED or EXPIRED, each move a conditional update (D4).
|
|
type GameSession struct {
|
|
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
|
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
|
|
CustomerID uuid.UUID `gorm:"type:uuid;not null" json:"customer_id"`
|
|
GameID uuid.UUID `gorm:"type:uuid;not null" json:"game_id"`
|
|
// Snapshots taken at start (D7, P3).
|
|
RewardConfigID uuid.UUID `gorm:"type:uuid;not null" json:"reward_config_id"`
|
|
EntryCost int64 `gorm:"not null" json:"entry_cost"`
|
|
|
|
Status string `gorm:"size:20;not null" json:"status"`
|
|
StartedAt time.Time `gorm:"not null" json:"started_at"`
|
|
ExpiresAt time.Time `gorm:"not null" json:"expires_at"`
|
|
EndedAt *time.Time `json:"ended_at"`
|
|
|
|
// What the client sent: data only, never a reward amount (P1).
|
|
Result JSONDocument `gorm:"type:jsonb" json:"result"`
|
|
RewardBreakdown JSONDocument `gorm:"type:jsonb" json:"reward_breakdown"`
|
|
RewardTotal int64 `gorm:"not null;default:0" json:"reward_total"`
|
|
Flagged bool `gorm:"not null;default:false" json:"flagged"`
|
|
|
|
SpendTransactionID uuid.UUID `gorm:"type:uuid;not null" json:"spend_transaction_id"`
|
|
RefundTransactionID *uuid.UUID `gorm:"type:uuid" json:"refund_transaction_id"`
|
|
// SYSTEM_ERROR or GAME_DEACTIVATED.
|
|
RefundReason *string `gorm:"size:30" json:"refund_reason"`
|
|
CompletionFailedAt *time.Time `json:"completion_failed_at"`
|
|
|
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
|
}
|
|
|
|
func (GameSession) TableName() string { return "game_sessions" }
|
|
|
|
// GameSessionReward is the part of a session's reward one budget paid for, with its
|
|
// own ledger row (D6).
|
|
type GameSessionReward struct {
|
|
SessionID uuid.UUID `gorm:"type:uuid;primary_key" json:"session_id"`
|
|
BudgetID uuid.UUID `gorm:"type:uuid;primary_key" json:"budget_id"`
|
|
Amount int64 `gorm:"not null" json:"amount"`
|
|
WalletTransactionID uuid.UUID `gorm:"type:uuid;not null" json:"wallet_transaction_id"`
|
|
}
|
|
|
|
func (GameSessionReward) TableName() string { return "game_session_rewards" }
|
|
|
|
// GameBudget is what an organization may spend on EnakGame rewards over a period
|
|
// (§5.6): GLOBAL for base rewards, EVENT for what an event adds.
|
|
type GameBudget struct {
|
|
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
|
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
|
|
// GLOBAL or EVENT.
|
|
Scope string `gorm:"size:20;not null" json:"scope"`
|
|
Name string `gorm:"size:255;not null" json:"name"`
|
|
// Dates, inclusive at both ends.
|
|
PeriodStart time.Time `gorm:"type:date;not null" json:"period_start"`
|
|
PeriodEnd time.Time `gorm:"type:date;not null" json:"period_end"`
|
|
// Rupiah.
|
|
Amount int64 `gorm:"not null" json:"amount"`
|
|
// {"warning": 70, "critical": 90}.
|
|
Thresholds JSONDocument `gorm:"type:jsonb;not null;default:'{}'" json:"thresholds"`
|
|
ExhaustionPolicy *string `gorm:"size:30" json:"exhaustion_policy"`
|
|
CreatedBy uuid.UUID `gorm:"type:uuid;not null" json:"created_by"`
|
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
|
}
|
|
|
|
func (GameBudget) TableName() string { return "game_budgets" }
|
|
|
|
// GameEvent is a period in which some games pay more, the extra paid by the event's
|
|
// own budget (docs/rfc-enakgame.md §5.5, D6). Event and campaign are the same thing.
|
|
type GameEvent struct {
|
|
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
|
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
|
|
Name string `gorm:"size:255;not null" json:"name"`
|
|
Slug string `gorm:"size:100;not null" json:"slug"`
|
|
Description *string `gorm:"type:text" json:"description"`
|
|
BannerURL *string `gorm:"column:banner_url;size:500" json:"banner_url"`
|
|
StartAt time.Time `gorm:"not null" json:"start_at"`
|
|
EndAt time.Time `gorm:"not null" json:"end_at"`
|
|
Timezone string `gorm:"size:50;not null" json:"timezone"`
|
|
// DRAFT, ACTIVE, ENDED or CANCELLED.
|
|
Status string `gorm:"size:20;not null" json:"status"`
|
|
Priority int `gorm:"not null" json:"priority"`
|
|
// NUMERIC(5,2): at most two decimals.
|
|
Multiplier *float64 `gorm:"type:numeric(5,2)" json:"multiplier"`
|
|
Bonus *int64 `json:"bonus"`
|
|
BudgetID uuid.UUID `gorm:"type:uuid;not null" json:"budget_id"`
|
|
RewardLimit *int64 `json:"reward_limit"`
|
|
UserDailyLimit *int64 `json:"user_daily_limit"`
|
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
|
}
|
|
|
|
func (GameEvent) TableName() string { return "game_events" }
|