Files
apskel-pos-backend/internal/entities/enakgame.go
T
efrilmandClaude Opus 5.5 296708244e feat(enakgame): budget controller recommendations and analytics
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>
2026-10-07 21:18:31 +07:00

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" }