Files
apskel-pos-backend/internal/processor/game_session_reward.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

223 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package processor
import (
"context"
"math"
"sort"
"time"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
)
// The kinds of reward component (D6).
const (
// The game's own reward, paid by the global budget.
gameRewardKindBase = "BASE"
// What one event adds, paid by the event's budget.
gameRewardKindEvent = "EVENT"
)
// The event limits (PRD §35), by the name the breakdown and the response give them.
const (
gameLimitEvent = "EVENT_LIMIT"
gameLimitEventUserDaily = "EVENT_USER_DAILY"
)
// eventLifetimeDay is the day of an event's counter over its whole run (§5.8).
var eventLifetimeDay = time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)
// eventUserScope is the counter of one customer in one event: a name derived from
// both, since a counter has one scope id (§5.8).
func eventUserScope(eventID, customerID uuid.UUID) uuid.UUID {
return uuid.NewSHA1(eventID, customerID[:])
}
// eventExtra is what an event adds to a base reward: base × (multiplier − 1), rounded
// down to whole EnakCoin, plus the bonus. Each event works on the base, so events do
// not multiply each other (PRD §16, "controlled stacking").
func eventExtra(base int64, e *entities.GameEvent) int64 {
var extra int64
if e.Multiplier != nil {
hundredths := int64(math.Round(*e.Multiplier * 100))
extra = base * (hundredths - 100) / 100
}
if e.Bonus != nil {
extra += *e.Bonus
}
return extra
}
// rewardComponents prices a believed base reward into its parts: the base, capped by
// max_reward, then what each active event adds, highest priority first. The cap holds
// for the total: what passes it is cut from the lowest priority event first. An event
// adds nothing to a play that earned nothing.
func rewardComponents(base, maxReward int64, events []entities.GameEvent) ([]gameRewardComponent, bool) {
capped := false
if maxReward > 0 && base > maxReward {
base, capped = maxReward, true
}
if base <= 0 {
return nil, capped
}
parts := []gameRewardComponent{{Kind: gameRewardKindBase, Earned: base, Amount: base}}
total := base
for i := range events {
e := &events[i]
extra := eventExtra(base, e)
if extra <= 0 {
continue
}
id := e.ID
parts = append(parts, gameRewardComponent{
Kind: gameRewardKindEvent, BudgetID: e.BudgetID, EventID: &id, Multiplier: e.Multiplier, Bonus: e.Bonus,
Earned: extra, Amount: extra,
})
total += extra
}
if maxReward > 0 && total > maxReward {
capped = true
trimFromEnd(parts, total-maxReward, nil)
}
return parts, capped
}
// trimFromEnd takes cut off the parts, lowest priority event first and the base last.
// release, when given, is told what came off each part.
func trimFromEnd(parts []gameRewardComponent, cut int64, release func(*gameRewardComponent, int64) error) error {
for i := len(parts) - 1; i >= 0 && cut > 0; i-- {
d := parts[i].Amount
if d > cut {
d = cut
}
parts[i].Amount -= d
cut -= d
if release != nil && d > 0 {
if err := release(&parts[i], d); err != nil {
return err
}
}
}
return nil
}
// guardReward runs the parts of a reward through every limit (§9, PRD §35): first
// each event's own limits on what it adds, then the daily limits on the total, which
// cut events before the base. It returns the limits that cut.
//
// Counters are taken in one order for every completion (events by id, then user,
// game, organization), so no two completions wait on each other the other way round.
func (p *GameSessionProcessor) guardReward(ctx context.Context, session *entities.GameSession, game *entities.Game, events []entities.GameEvent,
settings *models.OrganizationLoyaltySettings, now time.Time, parts []gameRewardComponent) ([]gameGuardCut, error) {
byID := map[uuid.UUID]*entities.GameEvent{}
for i := range events {
byID[events[i].ID] = &events[i]
}
day := walletDay(now)
order := make([]int, 0, len(parts))
for i := range parts {
if parts[i].Kind == gameRewardKindEvent {
order = append(order, i)
}
}
sort.Slice(order, func(a, b int) bool { return parts[order[a]].EventID.String() < parts[order[b]].EventID.String() })
var cuts []gameGuardCut
for _, i := range order {
part := &parts[i]
e := byID[*part.EventID]
asked := part.Amount
lifetime, err := p.counters.Consume(ctx, session.OrganizationID, constants.GameRewardScopeEvent, e.ID, eventLifetimeDay, asked, optionalLimit(e.RewardLimit))
if err != nil {
return nil, err
}
if lifetime < asked {
cuts = append(cuts, gameGuardCut{Limit: gameLimitEvent, EventID: part.EventID, Value: optionalLimit(e.RewardLimit), Asked: asked, Allowed: lifetime})
part.LimitedBy = append(part.LimitedBy, gameLimitEvent)
}
perUser, err := p.counters.Consume(ctx, session.OrganizationID, constants.GameRewardScopeEvent, eventUserScope(e.ID, session.CustomerID), day, lifetime, optionalLimit(e.UserDailyLimit))
if err != nil {
return nil, err
}
if perUser < lifetime {
cuts = append(cuts, gameGuardCut{Limit: gameLimitEventUserDaily, EventID: part.EventID, Value: optionalLimit(e.UserDailyLimit), Asked: lifetime, Allowed: perUser})
part.LimitedBy = append(part.LimitedBy, gameLimitEventUserDaily)
if err := p.counters.Release(ctx, session.OrganizationID, constants.GameRewardScopeEvent, e.ID, eventLifetimeDay, lifetime-perUser); err != nil {
return nil, err
}
}
part.Amount = perUser
}
total := sumComponents(parts)
if total == 0 {
return cuts, nil
}
allowed, daily, err := p.applyGuard(ctx, session, game, settings, now, total)
if err != nil {
return nil, err
}
cuts = append(cuts, daily...)
// The event counters took what the daily limits then cut: give it back.
err = trimFromEnd(parts, total-allowed, func(part *gameRewardComponent, d int64) error {
if part.Kind != gameRewardKindEvent {
return nil
}
if err := p.counters.Release(ctx, session.OrganizationID, constants.GameRewardScopeEvent, *part.EventID, eventLifetimeDay, d); err != nil {
return err
}
return p.counters.Release(ctx, session.OrganizationID, constants.GameRewardScopeEvent, eventUserScope(*part.EventID, session.CustomerID), day, d)
})
return cuts, err
}
func optionalLimit(limit *int64) int64 {
if limit == nil {
return 0
}
return *limit
}
func sumComponents(parts []gameRewardComponent) int64 {
var total int64
for _, part := range parts {
total += part.Amount
}
return total
}
// rewardParts sums a stored breakdown's components into what the customer sees.
func rewardParts(b gameRewardBreakdown) models.GameSessionRewardParts {
var out models.GameSessionRewardParts
for _, c := range b.Components {
if c.Kind == gameRewardKindEvent {
out.Event += c.Amount
} else {
out.Base += c.Amount
}
}
return out
}
// paymentsByBudget merges the components paid by the same budget, in order, since a
// budget pays one ledger row per session (D6).
func paymentsByBudget(parts []gameRewardComponent) []gameRewardComponent {
var out []gameRewardComponent
index := map[uuid.UUID]int{}
for _, part := range parts {
if part.Amount <= 0 {
continue
}
if i, ok := index[part.BudgetID]; ok {
out[i].Amount += part.Amount
continue
}
index[part.BudgetID] = len(out)
out = append(out, gameRewardComponent{BudgetID: part.BudgetID, Kind: part.Kind, Amount: part.Amount})
}
return out
}