Files
apskel-pos-backend/internal/models/enakgame.go
T
efrilmandClaude Opus 5.5 52e8fe11c6 feat(enakgame): filter play history by game and status
GET /customer/enakgame/sessions takes optional game_id and status, so a game
reloaded mid-play finds the session it was running (status=STARTED) instead
of starting a new one and charging EnakCoin again. An invalid game_id or
status is refused.

integration-enakgame.md §4.4 now describes recovery after a reload: keep the
session_id in sessionStorage, continue a STARTED session before expires_at,
and call complete again for a COMPLETED one to get the full answer, prize
included. The mobile guide and RFC §11 mention the filters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:51:39 +07:00

624 lines
24 KiB
Go

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"`
// Set on a version made by accepting a Budget Controller recommendation: the
// admin's version it scales, by how much, and for which budget.
BaseConfigID *uuid.UUID `json:"base_config_id"`
Multiplier *float64 `json:"multiplier"`
BudgetID *uuid.UUID `json:"budget_id"`
CreatedAt time.Time `json:"created_at"`
}
// GameBudgetThresholds are percents of utilization or forecast (PRD §8, §32), and the
// guardrails of the Budget Controller (PRD §31).
type GameBudgetThresholds struct {
Warning *int64 `json:"warning,omitempty"`
Critical *int64 `json:"critical,omitempty"`
// Percent one recommendation may move rewards by, either way.
MaxStepPercent *int64 `json:"max_step_percent,omitempty"`
// Bounds of a game's multiplier, in percent of the configuration its admin wrote.
MinMultiplierPercent *int64 `json:"min_multiplier_percent,omitempty"`
MaxMultiplierPercent *int64 `json:"max_multiplier_percent,omitempty"`
// Days after an accepted recommendation before the organization gets another.
CooldownDays *int64 `json:"cooldown_days,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"`
// What a PROBABILITY game, such as a spin wheel, can pay, in the order of its
// configuration. Left out for the other reward types.
Prizes []GamePrize `json:"prizes,omitempty"`
}
// GamePrize is one entry of a PROBABILITY game, such as a segment of a spin wheel, as
// the customer sees it: never its odds.
type GamePrize struct {
// 1 for the first entry of the configuration.
Entry int `json:"entry"`
Label *string `json:"label"`
Amount int64 `json:"amount"`
}
// 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"`
}
// GameSessionListQuery filters a customer's play history. The game client uses
// game_id with status=STARTED to find the play it was running before a reload.
type GameSessionListQuery struct {
GameID string `form:"game_id"`
// STARTED, COMPLETED, REFUNDED or EXPIRED; empty for all.
Status string `form:"status"`
Page int `form:"page"`
Limit int `form:"limit"`
}
// 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"`
// The entry a PROBABILITY game drew, for a spin wheel to stop on. Its amount is
// the base reward, before events and limits.
Prize *GamePrize `json:"prize,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"`
}
// GameBudgetRecommendation is what the Budget Controller suggests for a global budget
// (docs/rfc-enakgame.md §10, PRD §29–§31): a multiplier on every game's reward that
// brings the forecast to the budget, within the guardrails.
type GameBudgetRecommendation struct {
BudgetID uuid.UUID `json:"budget_id"`
// RECOMMENDED, NO_CHANGE, COOLDOWN, AT_LIMIT, INSUFFICIENT_DATA or OUT_OF_PERIOD.
// Only RECOMMENDED can be accepted.
State string `json:"state"`
Message string `json:"message"`
Metrics GameBudgetMetrics `json:"metrics"`
// The guardrails used: the budget's own, or the defaults for those it does not set.
Guardrails GameBudgetThresholds `json:"guardrails"`
// The multiplier that would make the forecast meet the budget, before the
// guardrails; nil when there is no cost to extrapolate.
TargetMultiplier *float64 `json:"target_multiplier"`
// The target within one step, rounded down to two decimals. Accepting sends it back.
Multiplier float64 `json:"multiplier"`
// When the organization may accept again, during a cooldown.
CooldownUntil *time.Time `json:"cooldown_until,omitempty"`
// The games whose reward would change, each with its new version.
Games []GameRewardAdjustment `json:"games"`
}
// GameRewardAdjustment is how accepting a recommendation changes a game's reward: a
// new version of its active configuration, with the base's amounts scaled.
type GameRewardAdjustment struct {
GameID uuid.UUID `json:"game_id"`
GameName string `json:"game_name"`
// The active version, and the admin's version both are measured against.
RewardConfigID uuid.UUID `json:"reward_config_id"`
Version int `json:"version"`
BaseConfigID uuid.UUID `json:"base_config_id"`
RewardType string `json:"reward_type"`
CurrentMultiplier float64 `json:"current_multiplier"`
NewMultiplier float64 `json:"new_multiplier"`
CurrentRules json.RawMessage `json:"current_rules"`
NewRules json.RawMessage `json:"new_rules"`
CurrentMaxReward int64 `json:"current_max_reward"`
NewMaxReward int64 `json:"new_max_reward"`
}
// GameBudgetRecommendationAcceptInput accepts the recommendation the admin saw. When
// the recommendation changed since, nothing is applied.
type GameBudgetRecommendationAcceptInput struct {
Multiplier *float64 `json:"multiplier"`
Reason *string `json:"reason"`
}
// GameBudgetRecommendationAccepted is what accepting made: one active version per
// game.
type GameBudgetRecommendationAccepted struct {
BudgetID uuid.UUID `json:"budget_id"`
Multiplier float64 `json:"multiplier"`
RewardConfigs []GameRewardConfig `json:"reward_configs"`
}
// EnakGameAnalyticsQuery is a range of days in Asia/Jakarta, both ends included.
type EnakGameAnalyticsQuery struct {
From string `form:"from"`
To string `form:"to"`
// Games analytics only: one game instead of all.
GameID string `form:"game_id"`
}
// EnakGameAnalytics is how an organization's games were played over a range of days
// (PRD §36 Game), by the day each session started.
type EnakGameAnalytics struct {
From string `json:"from"`
To string `json:"to"`
Totals EnakGameStats `json:"totals"`
Games []EnakGameGameStats `json:"games"`
}
type EnakGameGameStats struct {
GameID uuid.UUID `json:"game_id"`
GameName string `json:"game_name"`
EnakGameStats
}
type EnakGameStats struct {
// Sessions started, whatever became of them.
Plays int64 `json:"plays"`
Completed int64 `json:"completed"`
Refunded int64 `json:"refunded"`
Expired int64 `json:"expired"`
Flagged int64 `json:"flagged"`
// Customers who started at least one session.
Players int64 `json:"players"`
// Over completed sessions that reported a score; nil when none did.
AverageScore *float64 `json:"average_score"`
// EnakCoin per completed session, and per play.
AverageReward float64 `json:"average_reward"`
RewardPerPlay float64 `json:"reward_per_play"`
CoinIssued int64 `json:"coin_issued"`
// EnakCoin paid to start, and the part refunded.
EntryCostPaid int64 `json:"entry_cost_paid"`
CoinRefunded int64 `json:"coin_refunded"`
}
// EnakGameEconomyAnalytics is how EnakCoin and EnakPoint moved in an organization over
// a range of days (PRD §36 Economy), from the ledger.
type EnakGameEconomyAnalytics struct {
From string `json:"from"`
To string `json:"to"`
Coin EnakGameCoinFlows `json:"coin"`
Point EnakGamePointFlows `json:"point"`
// Every ledger type that moved, for what the headline numbers leave out.
ByType []WalletFlowTotals `json:"by_type"`
}
type EnakGameCoinFlows struct {
// New EnakCoin: game rewards, earning less its reversals, migration and upward
// adjustments.
Generated int64 `json:"generated"`
GameRewards int64 `json:"game_rewards"`
// Entry costs, less the refunded ones.
SpentOnGames int64 `json:"spent_on_games"`
// Exchanged into EnakPoint.
Exchanged int64 `json:"exchanged"`
// SpentOnGames + Exchanged.
Spent int64 `json:"spent"`
Expired int64 `json:"expired"`
// Held by customers at the end of the range.
Outstanding int64 `json:"outstanding"`
}
type EnakGamePointFlows struct {
// From shopping, less its reversals.
Earned int64 `json:"earned"`
// Received from exchanging EnakCoin.
Exchanged int64 `json:"exchanged"`
// Spent on vouchers, less the refunded redemptions.
Redeemed int64 `json:"redeemed"`
Expired int64 `json:"expired"`
// Held by customers at the end of the range.
Balance int64 `json:"balance"`
}
// WalletFlowTotals is what one ledger type moved in one currency.
type WalletFlowTotals struct {
Currency string `json:"currency"`
Type string `json:"type"`
Credit int64 `json:"credit"`
Debit int64 `json:"debit"`
Transactions int64 `json:"transactions"`
}
// 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"`
}