Files
apskel-pos-backend/internal/processor/game_session_complete.go
T
efrilmandClaude Opus 5.5 798a36bd6c 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>
2026-10-07 20:53:14 +07:00

370 lines
14 KiB
Go

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