Files
apskel-pos-backend/internal/processor/game_budget_processor.go
T

273 lines
10 KiB
Go
Raw Normal View History

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
}