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:
co-authored by
Claude Opus 5.5
parent
2c9753fae7
commit
798a36bd6c
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user