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 } // gameBudgetMaxMultiplierLimit is the highest max_multiplier_percent a budget may set. const gameBudgetMaxMultiplierLimit = 1000 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") } switch { case t.MaxStepPercent != nil && (*t.MaxStepPercent < 1 || *t.MaxStepPercent > 50): return nil, enakGameRejected("thresholds.max_step_percent must be between 1 and 50") case t.MinMultiplierPercent != nil && (*t.MinMultiplierPercent < 1 || *t.MinMultiplierPercent > 100): return nil, enakGameRejected("thresholds.min_multiplier_percent must be between 1 and 100") case t.MaxMultiplierPercent != nil && (*t.MaxMultiplierPercent < 100 || *t.MaxMultiplierPercent > gameBudgetMaxMultiplierLimit): // NUMERIC(6,4) on game_reward_configs.multiplier holds far more; this keeps a // typo from multiplying rewards a hundredfold. return nil, enakGameRejected("thresholds.max_multiplier_percent must be between 100 and %d", gameBudgetMaxMultiplierLimit) case t.CooldownDays != nil && (*t.CooldownDays < 0 || *t.CooldownDays > 90): return nil, enakGameRejected("thresholds.cooldown_days must be between 0 and 90") } 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 }