package processor import ( "context" "encoding/json" "errors" "fmt" "time" "github.com/google/uuid" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/logger" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" ) // gameResultDataLimit bounds the free-form data a client may attach to a result. const gameResultDataLimit = 16 * 1024 // gameRewardBreakdown is how a session's reward was reached, kept in // game_sessions.reward_breakdown. It is internal: the customer only sees the totals. type gameRewardBreakdown struct { RewardConfigID uuid.UUID `json:"reward_config_id"` ConfigVersion int `json:"config_version"` RewardType string `json:"reward_type"` // Why the result was not believed, if it was not (§7.2 step 5). Rejections []string `json:"rejections,omitempty"` Base int64 `json:"base"` Detail map[string]any `json:"detail,omitempty"` MaxReward int64 `json:"max_reward"` Capped bool `json:"capped"` // The events running on the game when it completed, highest priority first. Events []uuid.UUID `json:"events,omitempty"` // The limits that cut the reward (§9), in the order they were applied. Guard []gameGuardCut `json:"guard,omitempty"` LimitedBy []string `json:"limited_by,omitempty"` // The base and what each event adds, each paid by its budget (D6). Components []gameRewardComponent `json:"components"` Total int64 `json:"total"` } type gameRewardComponent struct { // BASE or EVENT. Kind string `json:"kind"` BudgetID uuid.UUID `json:"budget_id"` // EVENT only: the event and what it applied. EventID *uuid.UUID `json:"event_id,omitempty"` Multiplier *float64 `json:"multiplier,omitempty"` Bonus *int64 `json:"bonus,omitempty"` // Before the cap and the limits, and what was paid. Earned int64 `json:"earned"` Amount int64 `json:"amount"` LimitedBy []string `json:"limited_by,omitempty"` } // gameGuardCut is one limit that cut a reward: how much it allowed of what was asked. type gameGuardCut struct { Limit string `json:"limit"` // For an event's own limits. EventID *uuid.UUID `json:"event_id,omitempty"` Value int64 `json:"value"` Asked int64 `json:"asked"` Allowed int64 `json:"allowed"` } // The limits of §5.10 and §9, by the name the breakdown and the response give them. const ( gameLimitUserDaily = "USER_DAILY" gameLimitGameDaily = "GAME_DAILY" gameLimitGlobalDaily = "GLOBAL_DAILY" ) // storedGameResult is what is kept of the client's result: the data only (P1). type storedGameResult struct { Score *int64 `json:"score,omitempty"` Outcome *string `json:"outcome,omitempty"` Data json.RawMessage `json:"data,omitempty"` } // Complete ends a session with the client's result and pays its reward, in one // transaction (docs/rfc-enakgame.md §7.2). The backend alone decides the reward // (P1): the result is checked against the game's result_rules, priced with the // configuration frozen at start, and capped by its max_reward. A result that is not // believed completes the session with no reward and flags it; the entry cost stays // spent. // // Completing again returns the first completion. When the game was turned off during // the play, the entry cost is refunded instead. A failure that is not the customer's // marks the session, in a transaction of its own, so the session job refunds it once // it expires (§7.3). func (p *GameSessionProcessor) Complete(ctx context.Context, customerID, sessionID uuid.UUID, in models.GameSessionCompleteInput) (*models.GameSessionCompletion, error) { if len(in.Data) > gameResultDataLimit { return nil, gameSessionRejected("data must be at most %d bytes", gameResultDataLimit) } session, err := p.complete(ctx, customerID, sessionID, in) if errors.Is(err, errGameSessionMoved) { // The session job finished it between our read and our update: answer with // what it is now. session, err = p.complete(ctx, customerID, sessionID, in) } if err != nil { if !isGameSessionBusinessError(err) { p.markCompletionFailed(ctx, customerID, sessionID) } return nil, err } balances, err := p.spendable.SpendableBalances(ctx, customerID, p.now()) if err != nil { return nil, err } out := &models.GameSessionCompletion{ SessionID: session.ID, Status: session.Status, RefundReason: session.RefundReason, RewardTotal: session.RewardTotal, CoinBalance: balances[constants.WalletCurrencyCoin], } // Read back from the stored breakdown, so a repeated completion answers the same. if len(session.RewardBreakdown) > 0 { var stored gameRewardBreakdown if err := json.Unmarshal(session.RewardBreakdown, &stored); err == nil { out.LimitedBy = stored.LimitedBy out.Reward = rewardParts(stored) } } return out, nil } func (p *GameSessionProcessor) complete(ctx context.Context, customerID, sessionID uuid.UUID, in models.GameSessionCompleteInput) (*entities.GameSession, error) { var session *entities.GameSession err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { if err := p.wallet.LockWallet(ctx, customerID); err != nil { return err } var err error session, err = p.sessions.GetCustomerSession(ctx, customerID, sessionID) if err != nil { return err } switch session.Status { case constants.GameSessionStatusCompleted, constants.GameSessionStatusRefunded: // Already settled: the same answer again (PRD §20). return nil case constants.GameSessionStatusExpired: return gameSessionRejected("the session has expired") } now := p.now() if now.After(session.ExpiresAt) { // Left for the session job, which knows whether to refund it. return gameSessionRejected("the session has expired") } game, err := p.games.GetGame(ctx, session.OrganizationID, session.GameID) if err != nil { return err } if game.Status != constants.GameStatusActive { _, err := p.refundLocked(ctx, session, constants.GameSessionRefundGameDeactivated, constants.AuditSourceCustomerAPI) return err } config, err := p.games.GetRewardConfig(ctx, session.OrganizationID, session.RewardConfigID) if err != nil { return fmt.Errorf("reward config of session %s: %w", session.ID, err) } result := SessionResult{Score: in.Score, Outcome: in.Outcome} breakdown := gameRewardBreakdown{ RewardConfigID: config.ID, ConfigVersion: config.Version, RewardType: config.RewardType, MaxReward: config.MaxReward, Components: []gameRewardComponent{}, } breakdown.Rejections = ValidateResult(game.ResultRules, result, session.StartedAt, now) if len(breakdown.Rejections) == 0 { calculator, err := RewardCalculatorFor(config.RewardType) if err != nil { return err } breakdown.Base, breakdown.Detail, err = calculator.Calculate(json.RawMessage(config.Rules), result, p.rng) switch { case errors.Is(err, ErrRewardResultUnusable): breakdown.Rejections = append(breakdown.Rejections, err.Error()) breakdown.Base = 0 case err != nil: return err } } var events []entities.GameEvent if breakdown.Base > 0 { if events, err = p.events.ActiveEventsForGame(ctx, session.OrganizationID, session.GameID, now); err != nil { return err } for _, e := range events { breakdown.Events = append(breakdown.Events, e.ID) } } parts, capped := rewardComponents(breakdown.Base, config.MaxReward, events) breakdown.Capped = capped var settings *models.OrganizationLoyaltySettings if sumComponents(parts) > 0 { if settings, err = p.settings.Organization(ctx, session.OrganizationID); err != nil { return err } if breakdown.Guard, err = p.guardReward(ctx, session, game, events, settings, now, parts); err != nil { return err } for _, cut := range breakdown.Guard { if !containsString(breakdown.LimitedBy, cut.Limit) { breakdown.LimitedBy = append(breakdown.LimitedBy, cut.Limit) } } } if len(parts) > 0 && parts[0].Amount > 0 { budget, err := p.rewardBudget(ctx, session, now) if err != nil { return err } parts[0].BudgetID = budget.ID } breakdown.Components = []gameRewardComponent{} for _, part := range parts { if part.Amount > 0 { breakdown.Components = append(breakdown.Components, part) } } total := sumComponents(breakdown.Components) breakdown.Total = total resultDoc, err := json.Marshal(storedGameResult{Score: in.Score, Outcome: in.Outcome, Data: in.Data}) if err != nil { return gameSessionRejected("data must be valid JSON") } breakdownDoc, err := json.Marshal(breakdown) if err != nil { return err } flagged := len(breakdown.Rejections) > 0 moved, err := p.sessions.CompleteSession(ctx, session.ID, repository.GameSessionCompletion{ Result: entities.JSONDocument(resultDoc), RewardBreakdown: entities.JSONDocument(breakdownDoc), RewardTotal: total, Flagged: flagged, EndedAt: now, }) if err != nil { return err } if !moved { return errGameSessionMoved } session.Status, session.RewardTotal, session.Flagged, session.EndedAt = constants.GameSessionStatusCompleted, total, flagged, &now session.Result, session.RewardBreakdown = entities.JSONDocument(resultDoc), entities.JSONDocument(breakdownDoc) for _, payment := range paymentsByBudget(breakdown.Components) { if err := p.payReward(ctx, session, game, settings, payment, now); err != nil { return err } } return nil }) return session, err } // rewardBudget is the global budget paying a session's base reward: the one running // today, or, across a month end without a budget yet, the one the session started // under. func (p *GameSessionProcessor) rewardBudget(ctx context.Context, session *entities.GameSession, now time.Time) (*entities.GameBudget, error) { budget, err := p.budgets.GetGlobalBudgetOn(ctx, session.OrganizationID, walletDay(now)) if errors.Is(err, repository.ErrGameBudgetNotFound) { budget, err = p.budgets.GetGlobalBudgetOn(ctx, session.OrganizationID, walletDay(session.StartedAt)) } if err != nil { return nil, fmt.Errorf("no global budget for session %s: %w", session.ID, err) } return budget, nil } // payReward credits one budget's part of a reward as its own GAME_REWARD row and lot // (D6), and records which budget paid it. func (p *GameSessionProcessor) payReward(ctx context.Context, session *entities.GameSession, game *entities.Game, settings *models.OrganizationLoyaltySettings, c gameRewardComponent, now time.Time) error { credit, err := p.wallet.Credit(ctx, WalletCreditInput{ WalletEntry: WalletEntry{ CustomerID: session.CustomerID, Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeGameReward, Amount: c.Amount, ReferenceType: constants.WalletRefTypeGameSession, ReferenceID: session.ID, Description: truncateDescription("Hadiah " + game.Name), Metadata: entities.Metadata{"game_id": game.ID.String(), "budget_id": c.BudgetID.String(), "kind": c.Kind}, IdempotencyKey: fmt.Sprintf("game-reward:%s:%s", session.ID, c.BudgetID), }, Lots: []WalletLotInput{{Amount: c.Amount, ExpiresAt: ComputeExpiry(settings.CoinExpiry, now)}}, }) if err != nil { return err } return p.sessions.CreateSessionRewards(ctx, []entities.GameSessionReward{{ SessionID: session.ID, BudgetID: c.BudgetID, Amount: c.Amount, WalletTransactionID: credit.Transaction.ID, }}) } // applyGuard runs a reward through the daily limits (§9): what one customer may get, // what the game may give, what the whole organization may give, each counted per day // in Asia/Jakarta. A reward over a limit is cut to what is left of it, down to 0. It // returns what may be paid and the limits that cut it. // // Every counter takes the reward even when its limit is off, so a limit switched on // during the day counts what was already given. The counters are taken in a fixed // order, so two completions never wait on each other's rows the other way round; a // counter that took more than a later limit allowed gives the difference back. func (p *GameSessionProcessor) applyGuard(ctx context.Context, session *entities.GameSession, game *entities.Game, settings *models.OrganizationLoyaltySettings, now time.Time, amount int64) (int64, []gameGuardCut, error) { gameDaily := int64(0) if game.ResultRules.DailyRewardLimit != nil { gameDaily = *game.ResultRules.DailyRewardLimit } limits := []struct { name, scope string scopeID uuid.UUID value int64 }{ {gameLimitUserDaily, constants.GameRewardScopeUser, session.CustomerID, settings.EnakGame.UserDailyLimit}, {gameLimitGameDaily, constants.GameRewardScopeGame, session.GameID, gameDaily}, {gameLimitGlobalDaily, constants.GameRewardScopeGlobal, session.OrganizationID, settings.EnakGame.GlobalDailyLimit}, } day := walletDay(now) taken := make([]int64, len(limits)) var cuts []gameGuardCut for i, l := range limits { got, err := p.counters.Consume(ctx, session.OrganizationID, l.scope, l.scopeID, day, amount, l.value) if err != nil { return 0, nil, err } taken[i] = got if got < amount { cuts = append(cuts, gameGuardCut{Limit: l.name, Value: l.value, Asked: amount, Allowed: got}) amount = got } if amount == 0 { break } } for i, l := range limits { if extra := taken[i] - amount; extra > 0 { if err := p.counters.Release(ctx, session.OrganizationID, l.scope, l.scopeID, day, extra); err != nil { return 0, nil, err } } } return amount, cuts, nil } // markCompletionFailed records, outside the failed transaction, that completing the // customer's session failed on a system error (§7.3). func (p *GameSessionProcessor) markCompletionFailed(ctx context.Context, customerID, sessionID uuid.UUID) { ctx = repository.DetachTransaction(ctx) if _, err := p.sessions.GetCustomerSession(ctx, customerID, sessionID); err != nil { return } if _, err := p.sessions.MarkCompletionFailed(ctx, sessionID, p.now()); err != nil { logger.FromContext(ctx).WithError(err).Error("GameSessionProcessor::Complete -> failed to mark the session") } } // isGameSessionBusinessError tells a refusal the customer caused from a failure of // the system. func isGameSessionBusinessError(err error) bool { return errors.Is(err, ErrGameSessionRejected) || errors.Is(err, repository.ErrGameSessionNotFound) || errors.Is(err, repository.ErrWalletNotFound) }