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>
203 lines
8.3 KiB
Go
203 lines
8.3 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"`
|
|
// 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" }
|
|
|
|
// 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" }
|