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>
258 lines
9.2 KiB
Go
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
|
|
}
|