Files
apskel-pos-backend/internal/repository/game_reward_counter_repository.go
T
efrilmandClaude Opus 5.5 798a36bd6c feat(enakgame): game sessions, rewards, vouchers, budgets and events
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>
2026-10-07 20:53:14 +07:00

134 lines
4.8 KiB
Go

package repository
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ErrGameRewardCounterTxRequired is returned outside a transaction: the counter row
// stays locked until the reward it counts commits or rolls back.
var ErrGameRewardCounterTxRequired = errors.New("enakgame: reward counter must change inside a transaction")
// GameRewardCounterRepository keeps the Economy Guard counters (docs/rfc-enakgame.md
// §5.8, §9). Days are dates in Asia/Jakarta, taken as given.
type GameRewardCounterRepository interface {
// Consume adds up to amount to a counter without taking it past limit, and returns
// what it added: amount, what was left under the limit, or 0. A limit of 0 or less
// is no limit, and the counter still counts. The counter row stays locked until
// the transaction ends, so rewards at the same time never pass the limit together.
Consume(ctx context.Context, organizationID uuid.UUID, scopeType string, scopeID uuid.UUID, day time.Time, amount, limit int64) (int64, error)
// Release takes back what Consume added and was not given out after all.
Release(ctx context.Context, organizationID uuid.UUID, scopeType string, scopeID uuid.UUID, day time.Time, amount int64) error
// Get returns a counter, 0 when it has never counted anything.
Get(ctx context.Context, organizationID uuid.UUID, scopeType string, scopeID uuid.UUID, day time.Time) (int64, error)
}
type gameRewardCounterRepository struct {
db *gorm.DB
}
func NewGameRewardCounterRepository(db *gorm.DB) GameRewardCounterRepository {
return &gameRewardCounterRepository{db: db}
}
func counterDay(t time.Time) string { return t.Format("2006-01-02") }
func (r *gameRewardCounterRepository) txDB(ctx context.Context) (*gorm.DB, error) {
if tx, ok := ctx.Value(txKey).(*gorm.DB); ok && tx != nil {
return tx.WithContext(ctx), nil
}
return nil, ErrGameRewardCounterTxRequired
}
func (r *gameRewardCounterRepository) Consume(ctx context.Context, organizationID uuid.UUID, scopeType string, scopeID uuid.UUID, day time.Time, amount, limit int64) (int64, error) {
if amount <= 0 {
return 0, nil
}
db, err := r.txDB(ctx)
if err != nil {
return 0, err
}
d := counterDay(day)
// Make sure the row exists, then lock it: a waiter reads the amount the holder
// committed, so the check below always sees every reward counted before it.
err = db.Exec(`
INSERT INTO game_reward_counters (organization_id, scope_type, scope_id, day, amount)
VALUES (?, ?, ?, ?::date, 0) ON CONFLICT DO NOTHING`, organizationID, scopeType, scopeID, d).Error
if err != nil {
return 0, fmt.Errorf("failed to create reward counter: %w", err)
}
var current []int64
err = db.Raw(`
SELECT amount FROM game_reward_counters
WHERE organization_id = ? AND scope_type = ? AND scope_id = ? AND day = ?::date
FOR UPDATE`, organizationID, scopeType, scopeID, d).Scan(&current).Error
if err != nil {
return 0, fmt.Errorf("failed to lock reward counter: %w", err)
}
if len(current) != 1 {
return 0, fmt.Errorf("reward counter %s %s %s is missing", scopeType, scopeID, d)
}
granted := amount
if limit > 0 {
left := limit - current[0]
if left < 0 {
left = 0
}
if granted > left {
granted = left
}
}
if granted == 0 {
return 0, nil
}
err = db.Exec(`
UPDATE game_reward_counters SET amount = amount + ?
WHERE organization_id = ? AND scope_type = ? AND scope_id = ? AND day = ?::date`,
granted, organizationID, scopeType, scopeID, d).Error
if err != nil {
return 0, fmt.Errorf("failed to count reward: %w", err)
}
return granted, nil
}
func (r *gameRewardCounterRepository) Release(ctx context.Context, organizationID uuid.UUID, scopeType string, scopeID uuid.UUID, day time.Time, amount int64) error {
if amount <= 0 {
return nil
}
db, err := r.txDB(ctx)
if err != nil {
return err
}
result := db.Exec(`
UPDATE game_reward_counters SET amount = amount - ?
WHERE organization_id = ? AND scope_type = ? AND scope_id = ? AND day = ?::date AND amount >= ?`,
amount, organizationID, scopeType, scopeID, counterDay(day), amount)
if result.Error != nil {
return fmt.Errorf("failed to release reward counter: %w", result.Error)
}
if result.RowsAffected != 1 {
return fmt.Errorf("reward counter %s %s holds less than %d to release", scopeType, scopeID, amount)
}
return nil
}
func (r *gameRewardCounterRepository) Get(ctx context.Context, organizationID uuid.UUID, scopeType string, scopeID uuid.UUID, day time.Time) (int64, error) {
var amounts []int64
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT amount FROM game_reward_counters
WHERE organization_id = ? AND scope_type = ? AND scope_id = ? AND day = ?::date`,
organizationID, scopeType, scopeID, counterDay(day)).Scan(&amounts).Error
if err != nil {
return 0, fmt.Errorf("failed to read reward counter: %w", err)
}
if len(amounts) == 0 {
return 0, nil
}
return amounts[0], nil
}