package repository import ( "context" "errors" "fmt" "time" "github.com/google/uuid" "gorm.io/gorm" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" ) var ( // ErrGameBudgetNotFound means no budget with that id in the organization, or no // global budget covering the day asked for. ErrGameBudgetNotFound = errors.New("enakgame: budget not found") // ErrGameBudgetPeriodTaken means a global budget of the organization already // starts on that day. ErrGameBudgetPeriodTaken = errors.New("enakgame: a global budget already starts on that day") // ErrGameBudgetInUse means rewards or events point at the budget, so it stays. ErrGameBudgetInUse = errors.New("enakgame: budget is in use") // ErrGameBudgetTxRequired is returned by LockGlobalBudgets outside a transaction, // where the lock would be released at once. ErrGameBudgetTxRequired = errors.New("enakgame: budget lock must run inside a transaction") ) // GameBudgetFilter selects an organization's budgets. type GameBudgetFilter struct { OrganizationID uuid.UUID // Empty for both scopes. Scope string Offset int Limit int } // GameBudgetRepository stores EnakGame budgets (docs/rfc-enakgame.md §5.6), always // scoped to an organization. // // Period dates are written and compared as dates (YYYY-MM-DD of the time given), so // the database session's time zone cannot move them to another day. type GameBudgetRepository interface { CreateBudget(ctx context.Context, budget *entities.GameBudget) error GetBudget(ctx context.Context, organizationID, id uuid.UUID) (*entities.GameBudget, error) // UpdateBudget stores the name, period, amount, thresholds and exhaustion policy. UpdateBudget(ctx context.Context, budget *entities.GameBudget) error // ListBudgets returns a page of budgets, latest period first, and the total. ListBudgets(ctx context.Context, filter GameBudgetFilter) ([]entities.GameBudget, int64, error) // GetGlobalBudgetOn returns the organization's global budget whose period holds // day, a date in Asia/Jakarta. GetGlobalBudgetOn(ctx context.Context, organizationID uuid.UUID, day time.Time) (*entities.GameBudget, error) // DeleteBudget removes a budget nothing points at; otherwise ErrGameBudgetInUse. DeleteBudget(ctx context.Context, organizationID, id uuid.UUID) error // LockGlobalBudgets serializes the writers of an organization's global budgets // until the transaction ends, so two of them cannot both find a period free. LockGlobalBudgets(ctx context.Context, organizationID uuid.UUID) error // GlobalBudgetOverlaps reports whether another global budget of the organization // shares a day with start..end. except is the budget being changed, if any. GlobalBudgetOverlaps(ctx context.Context, organizationID uuid.UUID, start, end time.Time, except *uuid.UUID) (bool, error) // CreateNextGlobalBudgets gives every global budget holding today, whose next day // no global budget holds yet, a successor: from that next day to the end of its // month, with the same amount, thresholds and policy (§12). It returns what it // made. CreateNextGlobalBudgets(ctx context.Context, today time.Time) ([]entities.GameBudget, error) } type gameBudgetRepository struct { db *gorm.DB } func NewGameBudgetRepository(db *gorm.DB) GameBudgetRepository { return &gameBudgetRepository{db: db} } func budgetDate(t time.Time) string { return t.Format("2006-01-02") } func (r *gameBudgetRepository) CreateBudget(ctx context.Context, budget *entities.GameBudget) error { if budget.ID == uuid.Nil { budget.ID = uuid.New() } thresholds := budget.Thresholds if len(thresholds) == 0 { thresholds = entities.JSONDocument(`{}`) } var rows []struct{ CreatedAt, UpdatedAt time.Time } err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` INSERT INTO game_budgets (id, organization_id, scope, name, period_start, period_end, amount, thresholds, exhaustion_policy, created_by) VALUES (?, ?, ?, ?, ?::date, ?::date, ?, ?::jsonb, ?, ?) RETURNING created_at, updated_at`, budget.ID, budget.OrganizationID, budget.Scope, budget.Name, budgetDate(budget.PeriodStart), budgetDate(budget.PeriodEnd), budget.Amount, thresholds, budget.ExhaustionPolicy, budget.CreatedBy).Scan(&rows).Error if err != nil { if isUniqueViolation(err, "uq_game_budgets_global_period") { return ErrGameBudgetPeriodTaken } return fmt.Errorf("failed to create game budget: %w", err) } budget.Thresholds = thresholds budget.CreatedAt, budget.UpdatedAt = rows[0].CreatedAt, rows[0].UpdatedAt return nil } func (r *gameBudgetRepository) first(q *gorm.DB) (*entities.GameBudget, error) { var budget entities.GameBudget if err := q.First(&budget).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrGameBudgetNotFound } return nil, fmt.Errorf("failed to read game budget: %w", err) } return &budget, nil } func (r *gameBudgetRepository) GetBudget(ctx context.Context, organizationID, id uuid.UUID) (*entities.GameBudget, error) { return r.first(DBFromContext(ctx, r.db).WithContext(ctx).Where("organization_id = ? AND id = ?", organizationID, id)) } func (r *gameBudgetRepository) UpdateBudget(ctx context.Context, budget *entities.GameBudget) error { thresholds := budget.Thresholds if len(thresholds) == 0 { thresholds = entities.JSONDocument(`{}`) } result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(` UPDATE game_budgets SET name = ?, period_start = ?::date, period_end = ?::date, amount = ?, thresholds = ?::jsonb, exhaustion_policy = ?, updated_at = NOW() WHERE organization_id = ? AND id = ?`, budget.Name, budgetDate(budget.PeriodStart), budgetDate(budget.PeriodEnd), budget.Amount, thresholds, budget.ExhaustionPolicy, budget.OrganizationID, budget.ID) if result.Error != nil { if isUniqueViolation(result.Error, "uq_game_budgets_global_period") { return ErrGameBudgetPeriodTaken } return fmt.Errorf("failed to update game budget: %w", result.Error) } if result.RowsAffected == 0 { return ErrGameBudgetNotFound } budget.Thresholds = thresholds return nil } func (r *gameBudgetRepository) ListBudgets(ctx context.Context, filter GameBudgetFilter) ([]entities.GameBudget, int64, error) { q := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GameBudget{}).Where("organization_id = ?", filter.OrganizationID) if filter.Scope != "" { q = q.Where("scope = ?", filter.Scope) } var total int64 if err := q.Count(&total).Error; err != nil { return nil, 0, fmt.Errorf("failed to count game budgets: %w", err) } var budgets []entities.GameBudget if err := q.Order("period_start DESC, created_at DESC, id").Offset(filter.Offset).Limit(filter.Limit).Find(&budgets).Error; err != nil { return nil, 0, fmt.Errorf("failed to list game budgets: %w", err) } return budgets, total, nil } func (r *gameBudgetRepository) GetGlobalBudgetOn(ctx context.Context, organizationID uuid.UUID, day time.Time) (*entities.GameBudget, error) { d := budgetDate(day) return r.first(DBFromContext(ctx, r.db).WithContext(ctx). Where("organization_id = ? AND scope = ? AND period_start <= ?::date AND period_end >= ?::date", organizationID, constants.GameBudgetScopeGlobal, d, d). Order("period_start DESC")) } func (r *gameBudgetRepository) DeleteBudget(ctx context.Context, organizationID, id uuid.UUID) error { result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`DELETE FROM game_budgets WHERE organization_id = ? AND id = ?`, organizationID, id) if result.Error != nil { if isForeignKeyViolation(result.Error) { return ErrGameBudgetInUse } return fmt.Errorf("failed to delete game budget: %w", result.Error) } if result.RowsAffected == 0 { return ErrGameBudgetNotFound } return nil } func (r *gameBudgetRepository) LockGlobalBudgets(ctx context.Context, organizationID uuid.UUID) error { tx, ok := ctx.Value(txKey).(*gorm.DB) if !ok || tx == nil { return ErrGameBudgetTxRequired } err := tx.WithContext(ctx).Exec(`SELECT pg_advisory_xact_lock(hashtextextended(?, 0))`, "game_budgets:global:"+organizationID.String()).Error if err != nil { return fmt.Errorf("failed to lock game budgets: %w", err) } return nil } func (r *gameBudgetRepository) GlobalBudgetOverlaps(ctx context.Context, organizationID uuid.UUID, start, end time.Time, except *uuid.UUID) (bool, error) { q := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GameBudget{}). Where("organization_id = ? AND scope = ? AND period_start <= ?::date AND period_end >= ?::date", organizationID, constants.GameBudgetScopeGlobal, budgetDate(end), budgetDate(start)) if except != nil { q = q.Where("id <> ?", *except) } var count int64 if err := q.Count(&count).Error; err != nil { return false, fmt.Errorf("failed to check game budget periods: %w", err) } return count > 0, nil } func (r *gameBudgetRepository) CreateNextGlobalBudgets(ctx context.Context, today time.Time) ([]entities.GameBudget, error) { d := budgetDate(today) var created []entities.GameBudget // ON CONFLICT covers another instance creating the same successor at once. err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` INSERT INTO game_budgets (organization_id, scope, name, period_start, period_end, amount, thresholds, exhaustion_policy, created_by) SELECT b.organization_id, b.scope, 'Budget global ' || to_char(b.period_end + 1, 'YYYY-MM'), b.period_end + 1, (date_trunc('month', b.period_end + 1) + INTERVAL '1 month - 1 day')::date, b.amount, b.thresholds, b.exhaustion_policy, b.created_by FROM game_budgets b WHERE b.scope = ? AND b.period_start <= ?::date AND b.period_end >= ?::date AND NOT EXISTS ( SELECT 1 FROM game_budgets n WHERE n.organization_id = b.organization_id AND n.scope = b.scope AND n.period_start <= b.period_end + 1 AND n.period_end >= b.period_end + 1) ON CONFLICT DO NOTHING RETURNING *`, constants.GameBudgetScopeGlobal, d, d).Scan(&created).Error if err != nil { return nil, fmt.Errorf("failed to create next game budgets: %w", err) } return created, nil }