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

258 lines
9.2 KiB
Go

package processor
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// GameBudgetProcessor manages what an organization may spend on EnakGame rewards
// (docs/rfc-enakgame.md §5.6, §12). Global budgets of an organization never share a
// day; every change is audited in its transaction (§13).
type GameBudgetProcessor struct {
budgets repository.GameBudgetRepository
audit *AuditLogger
tx TxRunner
now func() time.Time
}
func NewGameBudgetProcessor(budgets repository.GameBudgetRepository, audit *AuditLogger, tx TxRunner) *GameBudgetProcessor {
return &GameBudgetProcessor{budgets: budgets, audit: audit, tx: tx, now: time.Now}
}
// GameBudgetInputFrom is a budget's current values as an input, for a change that only
// sends the fields it changes.
func GameBudgetInputFrom(b *models.GameBudget) models.GameBudgetInput {
return models.GameBudgetInput{
Scope: b.Scope, Name: b.Name, PeriodStart: b.PeriodStart, PeriodEnd: b.PeriodEnd,
Amount: b.Amount, Thresholds: b.Thresholds,
}
}
func (p *GameBudgetProcessor) record(ctx context.Context, organizationID, actor, id uuid.UUID, action string, before, after any) error {
return p.audit.Record(ctx, AuditEntry{
OrganizationID: organizationID, ActorType: constants.AuditActorUser, ActorID: &actor,
EntityType: constants.AuditEntityGameBudget, EntityID: id, Action: action,
Before: before, After: after, Source: constants.AuditSourceAdminAPI,
})
}
func (p *GameBudgetProcessor) CreateBudget(ctx context.Context, organizationID, actor uuid.UUID, in models.GameBudgetInput) (*models.GameBudget, error) {
budget, err := gameBudgetFromInput(in)
if err != nil {
return nil, err
}
budget.OrganizationID, budget.CreatedBy = organizationID, actor
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
if err := p.checkGlobalPeriod(ctx, budget, nil); err != nil {
return err
}
if err := p.budgets.CreateBudget(ctx, budget); err != nil {
return err
}
return p.record(ctx, organizationID, actor, budget.ID, "CREATED", nil, gameBudgetModel(budget))
})
if err != nil {
return nil, gameBudgetError(err)
}
return gameBudgetModel(budget), nil
}
func (p *GameBudgetProcessor) GetBudget(ctx context.Context, organizationID, id uuid.UUID) (*models.GameBudget, error) {
budget, err := p.budgets.GetBudget(ctx, organizationID, id)
if err != nil {
return nil, err
}
return gameBudgetModel(budget), nil
}
func (p *GameBudgetProcessor) ListBudgets(ctx context.Context, organizationID uuid.UUID, q models.GameBudgetListQuery) (*models.PaginatedResponse[models.GameBudget], error) {
page, limit := enakGamePage(q.Page, q.Limit)
scope := strings.ToUpper(strings.TrimSpace(q.Scope))
if scope != "" && scope != constants.GameBudgetScopeGlobal && scope != constants.GameBudgetScopeEvent {
return nil, enakGameRejected("scope must be GLOBAL or EVENT")
}
budgets, total, err := p.budgets.ListBudgets(ctx, repository.GameBudgetFilter{
OrganizationID: organizationID, Scope: scope, Offset: (page - 1) * limit, Limit: limit,
})
if err != nil {
return nil, err
}
items := make([]models.GameBudget, 0, len(budgets))
for i := range budgets {
items = append(items, *gameBudgetModel(&budgets[i]))
}
return &models.PaginatedResponse[models.GameBudget]{Data: items, Pagination: enakGamePagination(page, limit, total)}, nil
}
// UpdateBudget changes a budget's name, period, amount and thresholds. Its scope stays.
func (p *GameBudgetProcessor) UpdateBudget(ctx context.Context, organizationID, actor, id uuid.UUID, in models.GameBudgetInput) (*models.GameBudget, error) {
next, err := gameBudgetFromInput(in)
if err != nil {
return nil, err
}
var after *models.GameBudget
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
current, err := p.budgets.GetBudget(ctx, organizationID, id)
if err != nil {
return err
}
if next.Scope != current.Scope {
return enakGameRejected("the scope of a budget cannot change")
}
next.ID, next.OrganizationID, next.CreatedBy, next.CreatedAt = current.ID, current.OrganizationID, current.CreatedBy, current.CreatedAt
if err := p.checkGlobalPeriod(ctx, next, &id); err != nil {
return err
}
if err := p.budgets.UpdateBudget(ctx, next); err != nil {
return err
}
saved, err := p.budgets.GetBudget(ctx, organizationID, id)
if err != nil {
return err
}
after = gameBudgetModel(saved)
return p.record(ctx, organizationID, actor, id, "UPDATED", gameBudgetModel(current), after)
})
if err != nil {
return nil, gameBudgetError(err)
}
return after, nil
}
// DeleteBudget removes a budget that no reward or event points at.
func (p *GameBudgetProcessor) DeleteBudget(ctx context.Context, organizationID, actor, id uuid.UUID) error {
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
current, err := p.budgets.GetBudget(ctx, organizationID, id)
if err != nil {
return err
}
if err := p.budgets.DeleteBudget(ctx, organizationID, id); err != nil {
return err
}
return p.record(ctx, organizationID, actor, id, "DELETED", gameBudgetModel(current), nil)
})
return gameBudgetError(err)
}
// CreateNextPeriods gives each global budget running today its successor for the
// following month, when there is none yet (§12). Running it again creates nothing
// more. It returns how many budgets it created.
func (p *GameBudgetProcessor) CreateNextPeriods(ctx context.Context) (int, error) {
var created []entities.GameBudget
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
var err error
created, err = p.budgets.CreateNextGlobalBudgets(ctx, walletDay(p.now()))
if err != nil {
return err
}
for i := range created {
err := p.audit.Record(ctx, AuditEntry{
OrganizationID: created[i].OrganizationID, ActorType: constants.AuditActorSystem,
EntityType: constants.AuditEntityGameBudget, EntityID: created[i].ID, Action: "CREATED",
After: gameBudgetModel(&created[i]), Source: constants.AuditSourceBudgetPeriodJob,
})
if err != nil {
return err
}
}
return nil
})
if err != nil {
return 0, err
}
return len(created), nil
}
// checkGlobalPeriod refuses a global budget sharing a day with another of the
// organization. The lock makes two saves at once see each other.
func (p *GameBudgetProcessor) checkGlobalPeriod(ctx context.Context, b *entities.GameBudget, except *uuid.UUID) error {
if b.Scope != constants.GameBudgetScopeGlobal {
return nil
}
if err := p.budgets.LockGlobalBudgets(ctx, b.OrganizationID); err != nil {
return err
}
overlaps, err := p.budgets.GlobalBudgetOverlaps(ctx, b.OrganizationID, b.PeriodStart, b.PeriodEnd, except)
if err != nil {
return err
}
if overlaps {
return enakGameRejected("another global budget already covers part of this period")
}
return nil
}
func gameBudgetError(err error) error {
switch {
case errors.Is(err, repository.ErrGameBudgetPeriodTaken):
return enakGameRejected("another global budget already starts on that day")
case errors.Is(err, repository.ErrGameBudgetInUse):
return enakGameRejected("rewards or events use this budget, so it cannot be deleted")
}
return err
}
func gameBudgetFromInput(in models.GameBudgetInput) (*entities.GameBudget, error) {
scope := strings.ToUpper(strings.TrimSpace(in.Scope))
name := strings.TrimSpace(in.Name)
if scope != constants.GameBudgetScopeGlobal && scope != constants.GameBudgetScopeEvent {
return nil, enakGameRejected("scope must be GLOBAL or EVENT")
}
if name == "" || len(name) > 255 {
return nil, enakGameRejected("name is required, at most 255 characters")
}
start, err := time.Parse("2006-01-02", strings.TrimSpace(in.PeriodStart))
if err != nil {
return nil, enakGameRejected("period_start must be a date like 2026-10-01")
}
end, err := time.Parse("2006-01-02", strings.TrimSpace(in.PeriodEnd))
if err != nil {
return nil, enakGameRejected("period_end must be a date like 2026-10-31")
}
if end.Before(start) {
return nil, enakGameRejected("period_end cannot be before period_start")
}
if in.Amount <= 0 {
return nil, enakGameRejected("amount must be more than 0")
}
t := in.Thresholds
for field, v := range map[string]*int64{"warning": t.Warning, "critical": t.Critical} {
if v != nil && (*v < 0 || *v > 100) {
return nil, enakGameRejected("thresholds.%s must be between 0 and 100", field)
}
}
if t.Warning != nil && t.Critical != nil && *t.Warning > *t.Critical {
return nil, enakGameRejected("thresholds.warning cannot be above thresholds.critical")
}
thresholds, err := json.Marshal(t)
if err != nil {
return nil, err
}
return &entities.GameBudget{
Scope: scope, Name: name, PeriodStart: start, PeriodEnd: end, Amount: in.Amount,
Thresholds: entities.JSONDocument(thresholds),
}, nil
}
func gameBudgetModel(b *entities.GameBudget) *models.GameBudget {
m := &models.GameBudget{
ID: b.ID, Scope: b.Scope, Name: b.Name,
PeriodStart: b.PeriodStart.Format("2006-01-02"), PeriodEnd: b.PeriodEnd.Format("2006-01-02"),
Amount: b.Amount, CreatedBy: b.CreatedBy, CreatedAt: b.CreatedAt, UpdatedAt: b.UpdatedAt,
}
if len(b.Thresholds) > 0 {
_ = json.Unmarshal(b.Thresholds, &m.Thresholds)
}
return m
}