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,30 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AuditLog is one change to EnakGame configuration or state: who made it, to what,
|
||||
// from what, to what (docs/rfc-enakgame.md §5.9, §13). Append-only.
|
||||
type AuditLog struct {
|
||||
ID uuid.UUID
|
||||
OrganizationID uuid.UUID
|
||||
// USER or SYSTEM.
|
||||
ActorType string
|
||||
// The admin for USER; nil for SYSTEM.
|
||||
ActorID *uuid.UUID
|
||||
EntityType string
|
||||
EntityID uuid.UUID
|
||||
Action string
|
||||
// JSON snapshots of the entity; nil when there was nothing before (a create) or
|
||||
// after (a delete).
|
||||
Before json.RawMessage
|
||||
After json.RawMessage
|
||||
Reason *string
|
||||
// admin_api, budget_controller, session_job, ...
|
||||
Source string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
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" }
|
||||
@@ -16,11 +16,27 @@ const (
|
||||
)
|
||||
|
||||
type Game struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
Name string `gorm:"type:varchar(255);not null" json:"name" validate:"required"`
|
||||
Type GameType `gorm:"type:varchar(50);not null" json:"type" validate:"required,oneof=SPIN RAFFLE MINIGAME"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
Name string `gorm:"type:varchar(255);not null" json:"name" validate:"required"`
|
||||
Type GameType `gorm:"type:varchar(50);not null" json:"type" validate:"required,oneof=SPIN RAFFLE MINIGAME"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
|
||||
// EnakGame (docs/rfc-enakgame.md §5.1). Only an ARCHIVED game, which every old game
|
||||
// is, may lack an organization and a slug.
|
||||
OrganizationID *uuid.UUID `gorm:"type:uuid" json:"organization_id"`
|
||||
Slug *string `gorm:"size:100" json:"slug"`
|
||||
Description *string `gorm:"type:text" json:"description"`
|
||||
ThumbnailURL *string `gorm:"column:thumbnail_url;size:500" json:"thumbnail_url"`
|
||||
GameURL *string `gorm:"column:game_url;size:500" json:"game_url"`
|
||||
Version *string `gorm:"size:50" json:"version"`
|
||||
// DRAFT, ACTIVE, INACTIVE or ARCHIVED.
|
||||
Status string `gorm:"size:20;not null;default:ACTIVE" json:"status"`
|
||||
// EnakCoin a session costs; at least 1.
|
||||
EntryCost int64 `gorm:"not null" json:"entry_cost"`
|
||||
SessionTTLSeconds int `gorm:"column:session_ttl_seconds;not null;default:600" json:"session_ttl_seconds"`
|
||||
ResultRules GameResultRules `gorm:"type:jsonb;not null;default:'{}'" json:"result_rules"`
|
||||
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Voucher is what EnakPoint is redeemed for (docs/rfc-enakgame.md §5.7).
|
||||
type Voucher 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"`
|
||||
Description *string `gorm:"type:text" json:"description"`
|
||||
ImageURL *string `gorm:"column:image_url;size:500" json:"image_url"`
|
||||
VoucherType string `gorm:"size:30;not null" json:"voucher_type"`
|
||||
// Rupiah: the basis of realized cost.
|
||||
FaceValue int64 `gorm:"not null" json:"face_value"`
|
||||
PointCost int64 `gorm:"not null" json:"point_cost"`
|
||||
// Reporting only.
|
||||
BusinessCost *int64 `json:"business_cost"`
|
||||
StockMode string `gorm:"size:20;not null" json:"stock_mode"`
|
||||
// STATIC only.
|
||||
Stock *int64 `json:"stock"`
|
||||
// EXTERNAL only.
|
||||
Provider *string `gorm:"size:50" json:"provider"`
|
||||
ProviderRef *string `gorm:"size:255" json:"provider_ref"`
|
||||
MaxPerCustomer *int `json:"max_per_customer"`
|
||||
ValidFrom *time.Time `json:"valid_from"`
|
||||
ValidUntil *time.Time `json:"valid_until"`
|
||||
Terms JSONDocument `gorm:"type:jsonb;not null;default:'{}'" json:"terms"`
|
||||
Status string `gorm:"size:20;not null" json:"status"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Voucher) TableName() string { return "vouchers" }
|
||||
|
||||
// VoucherCode is one code of a CODE_POOL voucher.
|
||||
type VoucherCode struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
VoucherID uuid.UUID `gorm:"type:uuid;not null" json:"voucher_id"`
|
||||
Code string `gorm:"size:255;not null" json:"code"`
|
||||
Status string `gorm:"size:20;not null" json:"status"`
|
||||
RedemptionID *uuid.UUID `gorm:"type:uuid" json:"redemption_id"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (VoucherCode) TableName() string { return "voucher_codes" }
|
||||
|
||||
// VoucherRedemption is one redemption, with the voucher's numbers frozen (P3).
|
||||
type VoucherRedemption 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"`
|
||||
VoucherID uuid.UUID `gorm:"type:uuid;not null" json:"voucher_id"`
|
||||
IdempotencyKey string `gorm:"size:100;not null" json:"idempotency_key"`
|
||||
Status string `gorm:"size:20;not null" json:"status"`
|
||||
FaceValue int64 `gorm:"not null" json:"face_value"`
|
||||
PointCost int64 `gorm:"not null" json:"point_cost"`
|
||||
VoucherCodeID *uuid.UUID `gorm:"type:uuid" json:"voucher_code_id"`
|
||||
ExternalCode *string `gorm:"size:255" json:"external_code"`
|
||||
ExternalRef *string `gorm:"size:255" json:"external_ref"`
|
||||
DebitTransactionID uuid.UUID `gorm:"type:uuid;not null" json:"debit_transaction_id"`
|
||||
RefundTransactionID *uuid.UUID `gorm:"type:uuid" json:"refund_transaction_id"`
|
||||
FailureReason *string `gorm:"size:255" json:"failure_reason"`
|
||||
Attempts int `gorm:"not null;default:0" json:"attempts"`
|
||||
CompletedAt *time.Time `json:"completed_at"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (VoucherRedemption) TableName() string { return "voucher_redemptions" }
|
||||
|
||||
// VoucherRedemptionCost is the part of a redemption's face value one source of its
|
||||
// EnakPoint accounts for (D5). BudgetID is nil for EnakPoint not from EnakGame.
|
||||
type VoucherRedemptionCost struct {
|
||||
RedemptionID uuid.UUID `gorm:"type:uuid;not null" json:"redemption_id"`
|
||||
BudgetID *uuid.UUID `gorm:"type:uuid" json:"budget_id"`
|
||||
SourceType string `gorm:"size:30;not null" json:"source_type"`
|
||||
Points int64 `gorm:"not null" json:"points"`
|
||||
Cost int64 `gorm:"not null" json:"cost"`
|
||||
RecognizedAt time.Time `gorm:"not null" json:"recognized_at"`
|
||||
}
|
||||
|
||||
func (VoucherRedemptionCost) TableName() string { return "voucher_redemption_costs" }
|
||||
Reference in New Issue
Block a user