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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2c9753fae7
commit
798a36bd6c
@@ -0,0 +1,369 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user