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>
307 lines
12 KiB
Go
307 lines
12 KiB
Go
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,
|
|
}
|
|
}
|