EnakGame phase 9 of docs/tasks-enakgame.md (EG-901 to EG-903). Budget Controller (EG-901, EG-902) - GET /marketing/enakgame/budgets/:id/recommendation, GLOBAL budgets only: the multiplier (budget − realized) / (forecast − realized), within one step of 1, rounded down to two decimals, either way. Shows each game's new rules. - POST .../recommendation/accept with the multiplier the admin saw: recomputed in the transaction, then one new ACTIVE version per game, the old one RETIRED, audited with source budget_controller and RECOMMENDATION_ACCEPTED on the budget. - Migration 000112: base_config_id, multiplier and budget_id on game_reward_configs. Rules are always scaled from the admin's last version, so rounding does not compound and min/max are against what the admin set. - Guardrails in game_budgets.thresholds: max_step_percent 10, min/max multiplier 50-150%, cooldown_days 7 per organization. Provisional pending RFC §19.2 #4. - RewardCalculator.Scale for the four reward types: amounts only, rounded down. Analytics (EG-903) - GET /marketing/enakgame/analytics/games and /analytics/economy over a range of Asia/Jakarta days (at most 366), from game_sessions and the wallet ledger. - Migrations 000113 (game_sessions by organization and start) and 000114 (wallet_transactions by organization and time, CONCURRENTLY). The Postgres tests for accepting and analytics were not run: no test database here. Migrations 000112-000114 have not been run anywhere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
273 lines
10 KiB
Go
273 lines
10 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
|
|
}
|
|
|
|
// 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
|
|
}
|