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:
efrilm
2026-10-07 20:53:14 +07:00
co-authored by Claude Opus 5.5
parent 2c9753fae7
commit 798a36bd6c
92 changed files with 12392 additions and 23 deletions
+306
View File
@@ -0,0 +1,306 @@
package processor
import (
"context"
"errors"
"math"
"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"
)
// gameEventMaxMultiplier is the largest multiplier NUMERIC(5,2) holds.
const gameEventMaxMultiplier = 999.99
// GameEventProcessor manages an organization's EnakGame events
// (docs/rfc-enakgame.md §5.5, §11). Every change is audited in its transaction.
type GameEventProcessor struct {
events repository.GameEventRepository
games repository.EnakGameRepository
budgets repository.GameBudgetRepository
audit *AuditLogger
tx TxRunner
}
func NewGameEventProcessor(events repository.GameEventRepository, games repository.EnakGameRepository, budgets repository.GameBudgetRepository, audit *AuditLogger, tx TxRunner) *GameEventProcessor {
return &GameEventProcessor{events: events, games: games, budgets: budgets, audit: audit, tx: tx}
}
func (p *GameEventProcessor) record(ctx context.Context, organizationID, actor, id uuid.UUID, action string, before, after any, reason *string) error {
return p.audit.Record(ctx, AuditEntry{
OrganizationID: organizationID, ActorType: constants.AuditActorUser, ActorID: &actor,
EntityType: constants.AuditEntityGameEvent, EntityID: id, Action: action,
Before: before, After: after, Reason: reason, Source: constants.AuditSourceAdminAPI,
})
}
// GameEventInputFrom is an event's current values as an input, for a change that only
// sends the fields it changes.
func GameEventInputFrom(e *models.GameEvent) models.GameEventInput {
return models.GameEventInput{
Name: e.Name, Slug: e.Slug, Description: e.Description, BannerURL: e.BannerURL, StartAt: e.StartAt, EndAt: e.EndAt,
Timezone: e.Timezone, Priority: e.Priority, Multiplier: e.Multiplier, Bonus: e.Bonus, BudgetID: e.BudgetID,
RewardLimit: e.RewardLimit, UserDailyLimit: e.UserDailyLimit, GameIDs: e.GameIDs, Status: e.Status,
}
}
func (p *GameEventProcessor) CreateEvent(ctx context.Context, organizationID, actor uuid.UUID, in models.GameEventInput) (*models.GameEvent, error) {
status := strings.ToUpper(strings.TrimSpace(in.Status))
if status == "" {
status = constants.GameEventStatusDraft
}
if status != constants.GameEventStatusDraft && status != constants.GameEventStatusActive {
return nil, enakGameRejected("a new event must be DRAFT or ACTIVE")
}
event := &entities.GameEvent{OrganizationID: organizationID, Status: status}
gameIDs, err := applyGameEventInput(event, in)
if err != nil {
return nil, err
}
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
if err := p.checkReferences(ctx, organizationID, event.BudgetID, gameIDs); err != nil {
return err
}
if err := p.events.CreateEvent(ctx, event, gameIDs); err != nil {
return err
}
return p.record(ctx, organizationID, actor, event.ID, "CREATED", nil, gameEventModel(event, gameIDs), nil)
})
if err != nil {
return nil, gameEventError(err)
}
return gameEventModel(event, gameIDs), nil
}
func (p *GameEventProcessor) GetEvent(ctx context.Context, organizationID, id uuid.UUID) (*models.GameEvent, error) {
event, err := p.events.GetEvent(ctx, organizationID, id)
if err != nil {
return nil, err
}
games, err := p.events.EventGames(ctx, []uuid.UUID{id})
if err != nil {
return nil, err
}
return gameEventModel(event, games[id]), nil
}
func (p *GameEventProcessor) ListEvents(ctx context.Context, organizationID uuid.UUID, q models.GameEventListQuery) (*models.PaginatedResponse[models.GameEvent], error) {
page, limit := enakGamePage(q.Page, q.Limit)
var statuses []string
if q.Status != "" {
status := strings.ToUpper(strings.TrimSpace(q.Status))
if !isGameEventStatus(status) {
return nil, enakGameRejected("unknown status %q", q.Status)
}
statuses = []string{status}
}
events, total, err := p.events.ListEvents(ctx, repository.GameEventFilter{
OrganizationID: organizationID, Statuses: statuses, Offset: (page - 1) * limit, Limit: limit,
})
if err != nil {
return nil, err
}
ids := make([]uuid.UUID, 0, len(events))
for _, e := range events {
ids = append(ids, e.ID)
}
games, err := p.events.EventGames(ctx, ids)
if err != nil {
return nil, err
}
items := make([]models.GameEvent, 0, len(events))
for i := range events {
items = append(items, *gameEventModel(&events[i], games[events[i].ID]))
}
return &models.PaginatedResponse[models.GameEvent]{Data: items, Pagination: enakGamePagination(page, limit, total)}, nil
}
// UpdateEvent changes everything but the status. An event that ended or was cancelled
// never changes.
func (p *GameEventProcessor) UpdateEvent(ctx context.Context, organizationID, actor, id uuid.UUID, in models.GameEventInput) (*models.GameEvent, error) {
var after *models.GameEvent
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
event, err := p.events.LockEvent(ctx, organizationID, id)
if err != nil {
return err
}
if event.Status == constants.GameEventStatusEnded || event.Status == constants.GameEventStatusCancelled {
return enakGameRejected("an event that ended or was cancelled cannot change")
}
games, err := p.events.EventGames(ctx, []uuid.UUID{id})
if err != nil {
return err
}
before := gameEventModel(event, games[id])
gameIDs, err := applyGameEventInput(event, in)
if err != nil {
return err
}
if err := p.checkReferences(ctx, organizationID, event.BudgetID, gameIDs); err != nil {
return err
}
if err := p.events.UpdateEvent(ctx, event, gameIDs); err != nil {
return err
}
saved, err := p.events.GetEvent(ctx, organizationID, id)
if err != nil {
return err
}
after = gameEventModel(saved, gameIDs)
return p.record(ctx, organizationID, actor, id, "UPDATED", before, after, nil)
})
if err != nil {
return nil, gameEventError(err)
}
return after, nil
}
// SetEventStatus moves an event: DRAFT to ACTIVE or CANCELLED, ACTIVE to ENDED or
// CANCELLED. ENDED and CANCELLED are final.
func (p *GameEventProcessor) SetEventStatus(ctx context.Context, organizationID, actor, id uuid.UUID, in models.GameEventStatusInput) (*models.GameEvent, error) {
status := strings.ToUpper(strings.TrimSpace(in.Status))
if !isGameEventStatus(status) {
return nil, enakGameRejected("status must be DRAFT, ACTIVE, ENDED or CANCELLED")
}
if err := validateReason(in.Reason); err != nil {
return nil, err
}
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
event, err := p.events.LockEvent(ctx, organizationID, id)
if err != nil {
return err
}
if event.Status == status {
return nil
}
allowed := map[string][]string{
constants.GameEventStatusDraft: {constants.GameEventStatusActive, constants.GameEventStatusCancelled},
constants.GameEventStatusActive: {constants.GameEventStatusEnded, constants.GameEventStatusCancelled},
}[event.Status]
if !containsString(allowed, status) {
return enakGameRejected("an event cannot go from %s to %s", event.Status, status)
}
if err := p.events.SetEventStatus(ctx, organizationID, id, status); err != nil {
return err
}
return p.record(ctx, organizationID, actor, id, "STATUS_CHANGED",
map[string]string{"status": event.Status}, map[string]string{"status": status}, in.Reason)
})
if err != nil {
return nil, err
}
return p.GetEvent(ctx, organizationID, id)
}
// checkReferences refuses a budget that is not an EVENT budget of the organization,
// and games that are not the organization's or are archived.
func (p *GameEventProcessor) checkReferences(ctx context.Context, organizationID, budgetID uuid.UUID, gameIDs []uuid.UUID) error {
budget, err := p.budgets.GetBudget(ctx, organizationID, budgetID)
if errors.Is(err, repository.ErrGameBudgetNotFound) {
return enakGameRejected("budget_id is not a budget of this organization")
}
if err != nil {
return err
}
if budget.Scope != constants.GameBudgetScopeEvent {
return enakGameRejected("an event is paid by an EVENT budget, not a %s one", budget.Scope)
}
for _, gameID := range gameIDs {
game, err := p.games.GetGame(ctx, organizationID, gameID)
if errors.Is(err, repository.ErrEnakGameNotFound) {
return enakGameRejected("game %s is not a game of this organization", gameID)
}
if err != nil {
return err
}
if game.Status == constants.GameStatusArchived {
return enakGameRejected("game %s is archived", gameID)
}
}
return nil
}
func gameEventError(err error) error {
if errors.Is(err, repository.ErrGameEventSlugTaken) {
return enakGameRejected("another event already uses this slug")
}
return err
}
func isGameEventStatus(s string) bool {
switch s {
case constants.GameEventStatusDraft, constants.GameEventStatusActive, constants.GameEventStatusEnded, constants.GameEventStatusCancelled:
return true
}
return false
}
// applyGameEventInput checks an input and copies it onto the event, leaving its
// organization and status alone. It returns the games, without repeats.
func applyGameEventInput(e *entities.GameEvent, in models.GameEventInput) ([]uuid.UUID, error) {
name, slug := strings.TrimSpace(in.Name), strings.TrimSpace(in.Slug)
timezone := strings.TrimSpace(in.Timezone)
if timezone == "" {
timezone = "Asia/Jakarta"
}
switch {
case name == "" || len(name) > 255:
return nil, enakGameRejected("name is required, at most 255 characters")
case len(slug) > 100 || !enakGameSlugPattern.MatchString(slug):
return nil, enakGameRejected("slug must be lowercase letters, digits and single dashes, at most 100 characters")
case in.StartAt.IsZero() || !in.EndAt.After(in.StartAt):
return nil, enakGameRejected("start_at is required and end_at must be after it")
case in.Multiplier != nil && (*in.Multiplier < 1 || *in.Multiplier > gameEventMaxMultiplier):
return nil, enakGameRejected("multiplier must be between 1 and %g", gameEventMaxMultiplier)
case in.Multiplier != nil && math.Abs(*in.Multiplier*100-math.Round(*in.Multiplier*100)) > 1e-9:
return nil, enakGameRejected("multiplier must have at most two decimals")
case in.Bonus != nil && *in.Bonus < 1:
return nil, enakGameRejected("bonus must be at least 1")
case (in.Multiplier == nil || *in.Multiplier == 1) && in.Bonus == nil:
return nil, enakGameRejected("an event needs a multiplier above 1 or a bonus")
case in.RewardLimit != nil && *in.RewardLimit < 1:
return nil, enakGameRejected("reward_limit must be at least 1, or left out for no limit")
case in.UserDailyLimit != nil && *in.UserDailyLimit < 1:
return nil, enakGameRejected("user_daily_limit must be at least 1, or left out for no limit")
case in.BudgetID == uuid.Nil:
return nil, enakGameRejected("budget_id is required")
case len(in.GameIDs) == 0:
return nil, enakGameRejected("game_ids needs at least one game")
case in.BannerURL != nil && len(*in.BannerURL) > 500:
return nil, enakGameRejected("banner_url must be at most 500 characters")
}
if _, err := time.LoadLocation(timezone); err != nil || len(timezone) > 50 {
return nil, enakGameRejected("timezone %q is not a known time zone", timezone)
}
seen := map[uuid.UUID]bool{}
var gameIDs []uuid.UUID
for _, id := range in.GameIDs {
if !seen[id] {
seen[id] = true
gameIDs = append(gameIDs, id)
}
}
e.Name, e.Slug, e.Description, e.BannerURL = name, slug, in.Description, in.BannerURL
e.StartAt, e.EndAt, e.Timezone, e.Priority = in.StartAt, in.EndAt, timezone, in.Priority
e.Multiplier, e.Bonus, e.BudgetID = in.Multiplier, in.Bonus, in.BudgetID
e.RewardLimit, e.UserDailyLimit = in.RewardLimit, in.UserDailyLimit
return gameIDs, nil
}
func gameEventModel(e *entities.GameEvent, gameIDs []uuid.UUID) *models.GameEvent {
if gameIDs == nil {
gameIDs = []uuid.UUID{}
}
return &models.GameEvent{
ID: e.ID, Name: e.Name, Slug: e.Slug, Description: e.Description, BannerURL: e.BannerURL, StartAt: e.StartAt,
EndAt: e.EndAt, Timezone: e.Timezone, Status: e.Status, Priority: e.Priority, Multiplier: e.Multiplier,
Bonus: e.Bonus, BudgetID: e.BudgetID, RewardLimit: e.RewardLimit, UserDailyLimit: e.UserDailyLimit,
GameIDs: gameIDs, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt,
}
}