2026-10-07 20:53:14 +07:00
|
|
|
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)
|
2026-10-07 21:31:56 +07:00
|
|
|
out.Prize = drawnPrize(stored.RewardType, stored.Base, stored.Detail)
|
2026-10-07 20:53:14 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
|
}
|