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>
This commit is contained in:
efrilm
2026-10-07 20:53:14 +07:00
co-authored by Claude Opus 5.5
parent 2c9753fae7
commit 798a36bd6c
92 changed files with 12392 additions and 23 deletions
+124
View File
@@ -0,0 +1,124 @@
package repository
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"apskel-pos-be/internal/entities"
)
// ErrAuditTxRequired is returned when an audit row is written outside a transaction:
// the row must commit or roll back together with the change it records.
var ErrAuditTxRequired = errors.New("audit: write must run inside a transaction")
// AuditLogRepository stores audit_logs (docs/rfc-enakgame.md §5.9).
type AuditLogRepository interface {
// Insert writes a row in the caller's transaction, or returns ErrAuditTxRequired.
Insert(ctx context.Context, entry *entities.AuditLog) error
// ListByEntity returns an entity's rows in an organization, newest first.
ListByEntity(ctx context.Context, organizationID uuid.UUID, entityType string, entityID uuid.UUID, limit int) ([]entities.AuditLog, error)
}
type auditLogRepository struct {
db *gorm.DB
}
func NewAuditLogRepository(db *gorm.DB) AuditLogRepository {
return &auditLogRepository{db: db}
}
func (r *auditLogRepository) Insert(ctx context.Context, entry *entities.AuditLog) error {
tx, ok := ctx.Value(txKey).(*gorm.DB)
if !ok || tx == nil {
return ErrAuditTxRequired
}
if entry.ID == uuid.Nil {
entry.ID = uuid.New()
}
var rows []struct{ CreatedAt time.Time }
err := tx.WithContext(ctx).Raw(`
INSERT INTO audit_logs (id, organization_id, actor_type, actor_id, entity_type, entity_id,
action, before, after, reason, source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb, ?, ?)
RETURNING created_at`,
entry.ID, entry.OrganizationID, entry.ActorType, entry.ActorID, entry.EntityType, entry.EntityID,
entry.Action, jsonOrNull(entry.Before), jsonOrNull(entry.After), entry.Reason, entry.Source).
Scan(&rows).Error
if err != nil {
return fmt.Errorf("failed to write audit log: %w", err)
}
if len(rows) == 1 {
entry.CreatedAt = rows[0].CreatedAt
}
return nil
}
func (r *auditLogRepository) ListByEntity(ctx context.Context, organizationID uuid.UUID, entityType string, entityID uuid.UUID, limit int) ([]entities.AuditLog, error) {
var rows []struct {
ID string
OrganizationID string
ActorType string
ActorID *string
EntityType string
EntityID string
Action string
Before *string
After *string
Reason *string
Source string
CreatedAt time.Time
}
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT id::text AS id, organization_id::text AS organization_id, actor_type, actor_id::text AS actor_id,
entity_type, entity_id::text AS entity_id, action, before::text AS before, after::text AS after,
reason, source, created_at
FROM audit_logs
WHERE organization_id = ? AND entity_type = ? AND entity_id = ?
ORDER BY created_at DESC, id
LIMIT ?`, organizationID, entityType, entityID, limit).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to read audit logs: %w", err)
}
out := make([]entities.AuditLog, 0, len(rows))
for _, row := range rows {
entry := entities.AuditLog{
ActorType: row.ActorType,
EntityType: row.EntityType,
Action: row.Action,
Reason: row.Reason,
Source: row.Source,
CreatedAt: row.CreatedAt,
}
entry.ID, _ = uuid.Parse(row.ID)
entry.OrganizationID, _ = uuid.Parse(row.OrganizationID)
entry.EntityID, _ = uuid.Parse(row.EntityID)
if row.ActorID != nil {
if id, err := uuid.Parse(*row.ActorID); err == nil {
entry.ActorID = &id
}
}
if row.Before != nil {
entry.Before = json.RawMessage(*row.Before)
}
if row.After != nil {
entry.After = json.RawMessage(*row.After)
}
out = append(out, entry)
}
return out, nil
}
// jsonOrNull passes a JSON document as text, or NULL when there is none.
func jsonOrNull(doc json.RawMessage) *string {
if len(doc) == 0 {
return nil
}
s := string(doc)
return &s
}
+237
View File
@@ -0,0 +1,237 @@
package repository
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
)
var (
// ErrEnakGameNotFound means no game with that id in the organization, or, for a
// write, none that may still change: an archived game never does.
ErrEnakGameNotFound = errors.New("enakgame: game not found")
// ErrGameRewardConfigNotFound means no reward configuration with that id in the
// organization, or no active one for the game.
ErrGameRewardConfigNotFound = errors.New("enakgame: reward config not found")
// ErrEnakGameSlugTaken means another game of the organization has the slug.
ErrEnakGameSlugTaken = errors.New("enakgame: slug already used")
)
// EnakGameFilter selects an organization's games.
type EnakGameFilter struct {
OrganizationID uuid.UUID
// Empty for every status.
Statuses []string
// Matched against the name, case-insensitively.
Search string
Offset int
Limit int
}
// EnakGameRepository stores EnakGame games and their reward configurations
// (docs/rfc-enakgame.md §5.1, §5.2). Every read and write is scoped to an
// organization (D8), so the old games, which have none, never show up.
type EnakGameRepository interface {
CreateGame(ctx context.Context, game *entities.Game) error
GetGame(ctx context.Context, organizationID, id uuid.UUID) (*entities.Game, error)
// LockGame is GetGame with the row locked until the transaction ends.
LockGame(ctx context.Context, organizationID, id uuid.UUID) (*entities.Game, error)
// ListGames returns a page of games, newest first, and the total.
ListGames(ctx context.Context, filter EnakGameFilter) ([]entities.Game, int64, error)
// UpdateGame stores the game's editable columns, everything but its organization
// and status.
UpdateGame(ctx context.Context, game *entities.Game) error
SetGameStatus(ctx context.Context, organizationID, id uuid.UUID, status string) error
// CreateRewardConfig stores a configuration as the game's next version and sets
// config.Version.
CreateRewardConfig(ctx context.Context, config *entities.GameRewardConfig) error
GetRewardConfig(ctx context.Context, organizationID, id uuid.UUID) (*entities.GameRewardConfig, error)
GetActiveRewardConfig(ctx context.Context, organizationID, gameID uuid.UUID) (*entities.GameRewardConfig, error)
// ListRewardConfigs returns every version of a game's configuration, newest first.
ListRewardConfigs(ctx context.Context, organizationID, gameID uuid.UUID) ([]entities.GameRewardConfig, error)
// SetRewardConfigStatus moves a configuration from one status to another, the only
// change a configuration allows (D7). It reports false when the configuration was
// not in from.
SetRewardConfigStatus(ctx context.Context, organizationID, id uuid.UUID, from, to string) (bool, error)
}
type enakGameRepository struct {
db *gorm.DB
}
func NewEnakGameRepository(db *gorm.DB) EnakGameRepository {
return &enakGameRepository{db: db}
}
func (r *enakGameRepository) CreateGame(ctx context.Context, game *entities.Game) error {
if err := DBFromContext(ctx, r.db).WithContext(ctx).Omit(clause.Associations).Create(game).Error; err != nil {
if isUniqueViolation(err, "uq_games_org_slug") {
return ErrEnakGameSlugTaken
}
return fmt.Errorf("failed to create game: %w", err)
}
return nil
}
func (r *enakGameRepository) getGame(ctx context.Context, organizationID, id uuid.UUID, lock bool) (*entities.Game, error) {
q := DBFromContext(ctx, r.db).WithContext(ctx).Where("organization_id = ? AND id = ?", organizationID, id)
if lock {
q = q.Clauses(clause.Locking{Strength: "UPDATE"})
}
var game entities.Game
if err := q.First(&game).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrEnakGameNotFound
}
return nil, fmt.Errorf("failed to read game: %w", err)
}
return &game, nil
}
func (r *enakGameRepository) GetGame(ctx context.Context, organizationID, id uuid.UUID) (*entities.Game, error) {
return r.getGame(ctx, organizationID, id, false)
}
func (r *enakGameRepository) LockGame(ctx context.Context, organizationID, id uuid.UUID) (*entities.Game, error) {
return r.getGame(ctx, organizationID, id, true)
}
func (r *enakGameRepository) ListGames(ctx context.Context, filter EnakGameFilter) ([]entities.Game, int64, error) {
q := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.Game{}).Where("organization_id = ?", filter.OrganizationID)
if len(filter.Statuses) > 0 {
q = q.Where("status IN ?", filter.Statuses)
}
if filter.Search != "" {
q = q.Where("name ILIKE ?", "%"+filter.Search+"%")
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("failed to count games: %w", err)
}
var games []entities.Game
if err := q.Order("created_at DESC, id").Offset(filter.Offset).Limit(filter.Limit).Find(&games).Error; err != nil {
return nil, 0, fmt.Errorf("failed to list games: %w", err)
}
return games, total, nil
}
func (r *enakGameRepository) UpdateGame(ctx context.Context, game *entities.Game) error {
if game.OrganizationID == nil {
return ErrEnakGameNotFound
}
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
UPDATE games SET name = ?, type = ?, slug = ?, description = ?, thumbnail_url = ?, game_url = ?,
version = ?, entry_cost = ?, session_ttl_seconds = ?, result_rules = ?, updated_at = NOW()
WHERE organization_id = ? AND id = ? AND status <> ?`,
game.Name, game.Type, game.Slug, game.Description, game.ThumbnailURL, game.GameURL,
game.Version, game.EntryCost, game.SessionTTLSeconds, game.ResultRules,
*game.OrganizationID, game.ID, constants.GameStatusArchived)
if result.Error != nil {
if isUniqueViolation(result.Error, "uq_games_org_slug") {
return ErrEnakGameSlugTaken
}
return fmt.Errorf("failed to update game: %w", result.Error)
}
if result.RowsAffected == 0 {
return ErrEnakGameNotFound
}
return nil
}
func (r *enakGameRepository) SetGameStatus(ctx context.Context, organizationID, id uuid.UUID, status string) error {
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
UPDATE games SET status = ?, updated_at = NOW()
WHERE organization_id = ? AND id = ? AND status <> ?`,
status, organizationID, id, constants.GameStatusArchived)
if result.Error != nil {
return fmt.Errorf("failed to change game status: %w", result.Error)
}
if result.RowsAffected == 0 {
return ErrEnakGameNotFound
}
return nil
}
func (r *enakGameRepository) CreateRewardConfig(ctx context.Context, config *entities.GameRewardConfig) error {
if config.ID == uuid.Nil {
config.ID = uuid.New()
}
if config.Status == "" {
config.Status = constants.GameRewardConfigStatusDraft
}
// The version is computed in the insert. Two inserts at once may compute the same
// one, and the unique (game_id, version) refuses the second.
var rows []struct {
Version int
CreatedAt time.Time
}
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
INSERT INTO game_reward_configs (id, organization_id, game_id, version, reward_type, rules,
max_reward, status, effective_at, created_by, reason)
SELECT ?, g.organization_id, g.id,
COALESCE((SELECT MAX(version) FROM game_reward_configs WHERE game_id = g.id), 0) + 1,
?, ?::jsonb, ?, ?, ?, ?, ?
FROM games g WHERE g.organization_id = ? AND g.id = ?
RETURNING version, created_at`,
config.ID, config.RewardType, config.Rules, config.MaxReward, config.Status, config.EffectiveAt,
config.CreatedBy, config.Reason, config.OrganizationID, config.GameID).Scan(&rows).Error
if err != nil {
return fmt.Errorf("failed to create reward config: %w", err)
}
if len(rows) == 0 {
return ErrEnakGameNotFound
}
config.Version, config.CreatedAt = rows[0].Version, rows[0].CreatedAt
return nil
}
func (r *enakGameRepository) firstRewardConfig(q *gorm.DB) (*entities.GameRewardConfig, error) {
var config entities.GameRewardConfig
if err := q.First(&config).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrGameRewardConfigNotFound
}
return nil, fmt.Errorf("failed to read reward config: %w", err)
}
return &config, nil
}
func (r *enakGameRepository) GetRewardConfig(ctx context.Context, organizationID, id uuid.UUID) (*entities.GameRewardConfig, error) {
return r.firstRewardConfig(DBFromContext(ctx, r.db).WithContext(ctx).
Where("organization_id = ? AND id = ?", organizationID, id))
}
func (r *enakGameRepository) GetActiveRewardConfig(ctx context.Context, organizationID, gameID uuid.UUID) (*entities.GameRewardConfig, error) {
return r.firstRewardConfig(DBFromContext(ctx, r.db).WithContext(ctx).
Where("organization_id = ? AND game_id = ? AND status = ?", organizationID, gameID, constants.GameRewardConfigStatusActive))
}
func (r *enakGameRepository) ListRewardConfigs(ctx context.Context, organizationID, gameID uuid.UUID) ([]entities.GameRewardConfig, error) {
var configs []entities.GameRewardConfig
err := DBFromContext(ctx, r.db).WithContext(ctx).
Where("organization_id = ? AND game_id = ?", organizationID, gameID).
Order("version DESC").Find(&configs).Error
if err != nil {
return nil, fmt.Errorf("failed to list reward configs: %w", err)
}
return configs, nil
}
func (r *enakGameRepository) SetRewardConfigStatus(ctx context.Context, organizationID, id uuid.UUID, from, to string) (bool, error) {
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
UPDATE game_reward_configs SET status = ? WHERE organization_id = ? AND id = ? AND status = ?`,
to, organizationID, id, from)
if result.Error != nil {
return false, fmt.Errorf("failed to change reward config status: %w", result.Error)
}
return result.RowsAffected == 1, nil
}
@@ -0,0 +1,471 @@
package repository
import (
"context"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
)
// EnakGame tables against Postgres (docs/rfc-enakgame.md §5): what the constraints
// refuse, and what the repositories do. Needs TEST_DATABASE_URL; see
// wallet_repository_test.go.
type enakGameFixture struct {
db *gorm.DB
games EnakGameRepository
sessions GameSessionRepository
budgets GameBudgetRepository
org uuid.UUID
other uuid.UUID
customer uuid.UUID
admin uuid.UUID
}
func newEnakGameFixture(t *testing.T) *enakGameFixture {
t.Helper()
db := walletTestDB(t)
f := &enakGameFixture{
db: db, games: NewEnakGameRepository(db), sessions: NewGameSessionRepository(db), budgets: NewGameBudgetRepository(db),
org: uuid.New(), other: uuid.New(), customer: uuid.New(), admin: uuid.New(),
}
orgs := []uuid.UUID{f.org, f.other}
for _, org := range orgs {
require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'enakgame test', 'basic')`, org).Error)
}
require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'enakgame test')`, f.customer, f.org).Error)
t.Cleanup(func() {
for _, q := range []string{
`DELETE FROM game_session_rewards WHERE session_id IN (SELECT id FROM game_sessions WHERE organization_id IN ?)`,
`DELETE FROM game_sessions WHERE organization_id IN ?`,
`DELETE FROM game_reward_configs WHERE organization_id IN ?`,
`DELETE FROM game_budgets WHERE organization_id IN ?`,
`DELETE FROM games WHERE organization_id IN ?`,
`DELETE FROM wallet_transactions WHERE organization_id IN ?`,
`DELETE FROM customers WHERE organization_id IN ?`,
`DELETE FROM organizations WHERE id IN ?`,
} {
db.Exec(q, orgs)
}
})
return f
}
func (f *enakGameFixture) game(t *testing.T, org uuid.UUID, slug string) *entities.Game {
t.Helper()
game := &entities.Game{
Name: "Tap " + slug, Type: entities.GameTypeMinigame, OrganizationID: &org, Slug: &slug,
Status: constants.GameStatusActive, EntryCost: 5, SessionTTLSeconds: 600,
}
require.NoError(t, f.games.CreateGame(context.Background(), game))
return game
}
func (f *enakGameFixture) config(t *testing.T, game *entities.Game) *entities.GameRewardConfig {
t.Helper()
config := &entities.GameRewardConfig{
OrganizationID: *game.OrganizationID, GameID: game.ID, RewardType: constants.GameRewardTypeFixed,
Rules: entities.JSONDocument(`{"amount": 10}`), MaxReward: 10, CreatedBy: f.admin,
}
require.NoError(t, f.games.CreateRewardConfig(context.Background(), config))
return config
}
// walletTx writes a bare ledger row for the session foreign keys to point at.
// reverses is the row a refund reverses, nil otherwise.
func (f *enakGameFixture) walletTx(t *testing.T, txType string, amount int64, reverses *uuid.UUID) uuid.UUID {
t.Helper()
id := uuid.New()
require.NoError(t, f.db.Exec(`
INSERT INTO wallet_transactions (id, organization_id, customer_id, currency, type, amount, balance_after,
reference_type, reference_id, reverses_transaction_id, description)
VALUES (?, ?, ?, 'COIN', ?, ?, 0, 'GAME_SESSION', ?, ?, 'test')`,
id, f.org, f.customer, txType, amount, uuid.New(), reverses).Error)
return id
}
func (f *enakGameFixture) session(t *testing.T, game *entities.Game, config *entities.GameRewardConfig, expiresAt time.Time) *entities.GameSession {
t.Helper()
session := &entities.GameSession{
OrganizationID: f.org, CustomerID: f.customer, GameID: game.ID, RewardConfigID: config.ID,
EntryCost: game.EntryCost, ExpiresAt: expiresAt, SpendTransactionID: f.walletTx(t, constants.WalletTxTypeGameSpend, -game.EntryCost, nil),
}
require.NoError(t, f.sessions.CreateSession(context.Background(), session))
return session
}
func (f *enakGameFixture) budget(t *testing.T, org uuid.UUID, scope string, start, end time.Time) *entities.GameBudget {
t.Helper()
budget := &entities.GameBudget{
OrganizationID: org, Scope: scope, Name: "Oktober", PeriodStart: start, PeriodEnd: end, Amount: 100_000_000, CreatedBy: f.admin,
}
require.NoError(t, f.budgets.CreateBudget(context.Background(), budget))
return budget
}
func date(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) }
func TestEnakGameSchema_RejectsWhatTheRFCForbids(t *testing.T) {
f := newEnakGameFixture(t)
game := f.game(t, f.org, "tap")
config := f.config(t, game)
ctx := context.Background()
rejected := func(t *testing.T, err error, constraint string) {
t.Helper()
require.Error(t, err)
assert.Contains(t, err.Error(), constraint)
}
t.Run("ACTIVE game without organization", func(t *testing.T) {
rejected(t, f.db.Exec(`INSERT INTO games (name, type, slug, entry_cost) VALUES ('x', 'MINIGAME', 'x', 1)`).Error, "chk_games_enakgame_identity")
})
t.Run("ACTIVE game without slug", func(t *testing.T) {
rejected(t, f.db.Exec(`INSERT INTO games (name, type, organization_id, entry_cost) VALUES ('x', 'MINIGAME', ?, 1)`, f.org).Error, "chk_games_enakgame_identity")
})
t.Run("entry cost 0", func(t *testing.T) {
rejected(t, f.db.Exec(`INSERT INTO games (name, type, organization_id, slug, entry_cost) VALUES ('x', 'MINIGAME', ?, 'free', 0)`, f.org).Error, "chk_games_entry_cost")
})
t.Run("no entry cost", func(t *testing.T) {
rejected(t, f.db.Exec(`INSERT INTO games (name, type, organization_id, slug) VALUES ('x', 'MINIGAME', ?, 'none')`, f.org).Error, "entry_cost")
})
t.Run("same slug twice in an organization", func(t *testing.T) {
rejected(t, f.db.Exec(`INSERT INTO games (name, type, organization_id, slug, entry_cost) VALUES ('x', 'MINIGAME', ?, 'tap', 1)`, f.org).Error, "uq_games_org_slug")
f.game(t, f.other, "tap") // another organization may use it
})
t.Run("two ACTIVE configs for a game", func(t *testing.T) {
second := f.config(t, game)
ok, err := f.games.SetRewardConfigStatus(ctx, f.org, config.ID, constants.GameRewardConfigStatusDraft, constants.GameRewardConfigStatusActive)
require.NoError(t, err)
require.True(t, ok)
_, err = f.games.SetRewardConfigStatus(ctx, f.org, second.ID, constants.GameRewardConfigStatusDraft, constants.GameRewardConfigStatusActive)
rejected(t, err, "uq_game_reward_configs_active")
})
t.Run("REFUNDED session without refund transaction", func(t *testing.T) {
session := f.session(t, game, config, time.Now().Add(time.Minute))
rejected(t, f.db.Exec(`UPDATE game_sessions SET status = 'REFUNDED', refund_reason = 'SYSTEM_ERROR' WHERE id = ?`, session.ID).Error, "chk_game_sessions_refund")
rejected(t, f.db.Exec(`UPDATE game_sessions SET refund_transaction_id = spend_transaction_id, refund_reason = 'SYSTEM_ERROR' WHERE id = ?`, session.ID).Error, "chk_game_sessions_refund")
})
t.Run("two GLOBAL budgets with the same start", func(t *testing.T) {
f.budget(t, f.org, constants.GameBudgetScopeGlobal, date(2026, 10, 1), date(2026, 10, 31))
err := f.budgets.CreateBudget(ctx, &entities.GameBudget{
OrganizationID: f.org, Scope: constants.GameBudgetScopeGlobal, Name: "x",
PeriodStart: date(2026, 10, 1), PeriodEnd: date(2026, 10, 15), Amount: 1, CreatedBy: f.admin,
})
assert.ErrorIs(t, err, ErrGameBudgetPeriodTaken, "uq_game_budgets_global_period")
f.budget(t, f.org, constants.GameBudgetScopeEvent, date(2026, 10, 1), date(2026, 10, 7))
f.budget(t, f.other, constants.GameBudgetScopeGlobal, date(2026, 10, 1), date(2026, 10, 31))
})
t.Run("same reward ledger row twice", func(t *testing.T) {
session := f.session(t, game, config, time.Now().Add(time.Minute))
global := f.budget(t, f.org, constants.GameBudgetScopeGlobal, date(2026, 11, 1), date(2026, 11, 30))
event := f.budget(t, f.org, constants.GameBudgetScopeEvent, date(2026, 11, 1), date(2026, 11, 7))
reward := f.walletTx(t, constants.WalletTxTypeGameReward, 10, nil)
err := f.sessions.CreateSessionRewards(ctx, []entities.GameSessionReward{
{SessionID: session.ID, BudgetID: global.ID, Amount: 10, WalletTransactionID: reward},
{SessionID: session.ID, BudgetID: event.ID, Amount: 10, WalletTransactionID: reward},
})
rejected(t, err, "uq_game_session_rewards_wallet_transaction")
})
}
// D4: of two moves out of STARTED at once, exactly one gets the row.
func TestGameSessionRepository_ConcurrentTransitionsOnlyOneWins(t *testing.T) {
f := newEnakGameFixture(t)
game := f.game(t, f.org, "race")
config := f.config(t, game)
txm := NewTxManager(f.db)
for round := 0; round < 5; round++ {
session := f.session(t, game, config, time.Now().Add(time.Minute))
refund := f.walletTx(t, constants.WalletTxTypeGameSpendRefund, game.EntryCost, &session.SpendTransactionID)
moves := []func(ctx context.Context) (bool, error){
func(ctx context.Context) (bool, error) {
return f.sessions.CompleteSession(ctx, session.ID, GameSessionCompletion{
Result: entities.JSONDocument(`{"score": 10}`), RewardTotal: 10, EndedAt: time.Now(),
})
},
func(ctx context.Context) (bool, error) {
return f.sessions.RefundSession(ctx, session.ID, refund, constants.GameSessionRefundSystemError, time.Now())
},
func(ctx context.Context) (bool, error) { return f.sessions.ExpireSession(ctx, session.ID, time.Now()) },
}
var wg sync.WaitGroup
var mu sync.Mutex
won := 0
start := make(chan struct{})
for _, move := range moves {
move := move
wg.Add(1)
go func() {
defer wg.Done()
<-start
err := txm.WithTransaction(context.Background(), func(ctx context.Context) error {
ok, err := move(ctx)
if ok {
mu.Lock()
won++
mu.Unlock()
}
return err
})
assert.NoError(t, err)
}()
}
close(start)
wg.Wait()
assert.Equal(t, 1, won, "round %d", round)
got, err := f.sessions.GetSession(context.Background(), f.org, session.ID)
require.NoError(t, err)
assert.NotEqual(t, constants.GameSessionStatusStarted, got.Status)
assert.NotNil(t, got.EndedAt)
ok, err := f.sessions.MarkCompletionFailed(context.Background(), session.ID, time.Now())
require.NoError(t, err)
assert.False(t, ok, "a session that left STARTED is not marked")
}
}
func TestGameSessionRepository_ReadsAndMoves(t *testing.T) {
f := newEnakGameFixture(t)
game := f.game(t, f.org, "tap")
config := f.config(t, game)
ctx := context.Background()
now := time.Now()
expired := f.session(t, game, config, now.Add(-time.Minute))
open := f.session(t, game, config, now.Add(time.Hour))
got, err := f.sessions.GetCustomerSession(ctx, f.customer, open.ID)
require.NoError(t, err)
assert.Equal(t, constants.GameSessionStatusStarted, got.Status)
assert.Equal(t, game.EntryCost, got.EntryCost)
assert.Equal(t, config.ID, got.RewardConfigID)
_, err = f.sessions.GetCustomerSession(ctx, uuid.New(), open.ID)
assert.ErrorIs(t, err, ErrGameSessionNotFound, "another customer's session")
_, err = f.sessions.GetSession(ctx, f.other, open.ID)
assert.ErrorIs(t, err, ErrGameSessionNotFound, "another organization's session")
bySpend, err := f.sessions.GetSessionBySpendTransaction(ctx, open.SpendTransactionID)
require.NoError(t, err)
assert.Equal(t, open.ID, bySpend.ID)
due, err := f.sessions.ListExpiredSessions(ctx, now, 10)
require.NoError(t, err)
require.Len(t, due, 1)
assert.Equal(t, expired.ID, due[0].ID)
byGame, err := f.sessions.ListOpenSessionsByGame(ctx, f.org, game.ID, 10)
require.NoError(t, err)
assert.Len(t, byGame, 2)
marked, err := f.sessions.MarkCompletionFailed(ctx, open.ID, now)
require.NoError(t, err)
assert.True(t, marked)
ok, err := f.sessions.CompleteSession(ctx, open.ID, GameSessionCompletion{
Result: entities.JSONDocument(`{"score": 120}`), RewardBreakdown: entities.JSONDocument(`{"base": 10}`),
RewardTotal: 10, Flagged: true, EndedAt: now,
})
require.NoError(t, err)
require.True(t, ok)
got, err = f.sessions.GetSession(ctx, f.org, open.ID)
require.NoError(t, err)
assert.Equal(t, constants.GameSessionStatusCompleted, got.Status)
assert.JSONEq(t, `{"score": 120}`, string(got.Result))
assert.JSONEq(t, `{"base": 10}`, string(got.RewardBreakdown))
assert.EqualValues(t, 10, got.RewardTotal)
assert.True(t, got.Flagged)
assert.NotNil(t, got.CompletionFailedAt)
sessions, total, err := f.sessions.ListCustomerSessions(ctx, f.customer, 0, 1)
require.NoError(t, err)
assert.EqualValues(t, 2, total)
assert.Len(t, sessions, 1)
budget := f.budget(t, f.org, constants.GameBudgetScopeGlobal, date(2026, 10, 1), date(2026, 10, 31))
reward := f.walletTx(t, constants.WalletTxTypeGameReward, 10, nil)
require.NoError(t, f.sessions.CreateSessionRewards(ctx, []entities.GameSessionReward{
{SessionID: open.ID, BudgetID: budget.ID, Amount: 10, WalletTransactionID: reward},
}))
rewards, err := f.sessions.ListSessionRewards(ctx, open.ID)
require.NoError(t, err)
assert.Equal(t, []entities.GameSessionReward{{SessionID: open.ID, BudgetID: budget.ID, Amount: 10, WalletTransactionID: reward}}, rewards)
}
func TestEnakGameRepository_GamesAndRewardConfigs(t *testing.T) {
f := newEnakGameFixture(t)
ctx := context.Background()
game := f.game(t, f.org, "tap")
theirs := f.game(t, f.other, "theirs")
max := int64(1000)
game.ResultRules = entities.GameResultRules{MaxScore: &max, Outcomes: []string{"WIN", "LOSE"}}
game.EntryCost = 7
require.NoError(t, f.games.UpdateGame(ctx, game))
got, err := f.games.GetGame(ctx, f.org, game.ID)
require.NoError(t, err)
assert.EqualValues(t, 7, got.EntryCost)
assert.Equal(t, game.ResultRules, got.ResultRules)
_, err = f.games.GetGame(ctx, f.org, theirs.ID)
assert.ErrorIs(t, err, ErrEnakGameNotFound, "another organization's game")
theirs.OrganizationID = &f.org
assert.ErrorIs(t, f.games.UpdateGame(ctx, theirs), ErrEnakGameNotFound)
assert.ErrorIs(t, f.games.SetGameStatus(ctx, f.org, theirs.ID, constants.GameStatusInactive), ErrEnakGameNotFound)
games, total, err := f.games.ListGames(ctx, EnakGameFilter{OrganizationID: f.org, Limit: 10})
require.NoError(t, err)
assert.EqualValues(t, 1, total, "only the organization's games; the archived old ones have none")
assert.Equal(t, game.ID, games[0].ID)
require.NoError(t, f.games.SetGameStatus(ctx, f.org, game.ID, constants.GameStatusArchived))
assert.ErrorIs(t, f.games.SetGameStatus(ctx, f.org, game.ID, constants.GameStatusActive), ErrEnakGameNotFound, "an archived game never changes")
assert.ErrorIs(t, f.games.UpdateGame(ctx, game), ErrEnakGameNotFound)
games, _, err = f.games.ListGames(ctx, EnakGameFilter{OrganizationID: f.org, Statuses: []string{constants.GameStatusActive}, Limit: 10})
require.NoError(t, err)
assert.Empty(t, games)
// Reward configs: versions count up per game, only status ever changes.
other := f.game(t, f.org, "other")
v1, v2 := f.config(t, other), f.config(t, other)
assert.Equal(t, 1, v1.Version)
assert.Equal(t, 2, v2.Version)
assert.Equal(t, 1, f.config(t, game).Version, "per game")
err = f.games.CreateRewardConfig(ctx, &entities.GameRewardConfig{
OrganizationID: f.other, GameID: other.ID, RewardType: constants.GameRewardTypeFixed,
Rules: entities.JSONDocument(`{}`), CreatedBy: f.admin,
})
assert.ErrorIs(t, err, ErrEnakGameNotFound, "a config for another organization's game")
_, err = f.games.GetActiveRewardConfig(ctx, f.org, other.ID)
assert.ErrorIs(t, err, ErrGameRewardConfigNotFound)
ok, err := f.games.SetRewardConfigStatus(ctx, f.org, v2.ID, constants.GameRewardConfigStatusDraft, constants.GameRewardConfigStatusActive)
require.NoError(t, err)
require.True(t, ok)
ok, err = f.games.SetRewardConfigStatus(ctx, f.org, v2.ID, constants.GameRewardConfigStatusDraft, constants.GameRewardConfigStatusActive)
require.NoError(t, err)
assert.False(t, ok, "no longer DRAFT")
ok, err = f.games.SetRewardConfigStatus(ctx, f.other, v1.ID, constants.GameRewardConfigStatusDraft, constants.GameRewardConfigStatusRetired)
require.NoError(t, err)
assert.False(t, ok, "another organization")
active, err := f.games.GetActiveRewardConfig(ctx, f.org, other.ID)
require.NoError(t, err)
assert.Equal(t, v2.ID, active.ID)
assert.JSONEq(t, `{"amount": 10}`, string(active.Rules))
configs, err := f.games.ListRewardConfigs(ctx, f.org, other.ID)
require.NoError(t, err)
require.Len(t, configs, 2)
assert.Equal(t, []int{2, 1}, []int{configs[0].Version, configs[1].Version})
_, err = f.games.GetRewardConfig(ctx, f.other, v1.ID)
assert.ErrorIs(t, err, ErrGameRewardConfigNotFound)
// LockGame takes the row lock: a second locker waits for the first transaction.
txm := NewTxManager(f.db)
locked := make(chan struct{})
release := make(chan struct{})
go func() {
_ = txm.WithTransaction(ctx, func(ctx context.Context) error {
_, err := f.games.LockGame(ctx, f.org, other.ID)
assert.NoError(t, err)
close(locked)
<-release
return nil
})
}()
<-locked
acquired := make(chan struct{})
go func() {
_ = txm.WithTransaction(ctx, func(ctx context.Context) error {
_, err := f.games.LockGame(ctx, f.org, other.ID)
assert.NoError(t, err)
return nil
})
close(acquired)
}()
select {
case <-acquired:
t.Fatal("the second lock did not wait")
case <-time.After(200 * time.Millisecond):
}
close(release)
<-acquired
}
func TestGameBudgetRepository(t *testing.T) {
f := newEnakGameFixture(t)
ctx := context.Background()
// A late evening in Jakarta is still the previous day in UTC: the date is taken as
// given, not converted.
jakarta := time.FixedZone("WIB", 7*3600)
october := f.budget(t, f.org, constants.GameBudgetScopeGlobal,
time.Date(2026, 10, 1, 0, 30, 0, 0, jakarta), time.Date(2026, 10, 31, 23, 30, 0, 0, jakarta))
f.budget(t, f.org, constants.GameBudgetScopeGlobal, date(2026, 11, 1), date(2026, 11, 30))
event := f.budget(t, f.org, constants.GameBudgetScopeEvent, date(2026, 10, 10), date(2026, 10, 20))
f.budget(t, f.other, constants.GameBudgetScopeGlobal, date(2026, 10, 1), date(2026, 10, 31))
got, err := f.budgets.GetBudget(ctx, f.org, october.ID)
require.NoError(t, err)
assert.Equal(t, "2026-10-01", got.PeriodStart.Format("2006-01-02"))
assert.Equal(t, "2026-10-31", got.PeriodEnd.Format("2006-01-02"))
assert.JSONEq(t, `{}`, string(got.Thresholds))
_, err = f.budgets.GetBudget(ctx, f.other, october.ID)
assert.ErrorIs(t, err, ErrGameBudgetNotFound)
for day, want := range map[time.Time]uuid.UUID{
date(2026, 10, 1): october.ID,
date(2026, 10, 15): october.ID,
date(2026, 10, 31): october.ID,
} {
got, err := f.budgets.GetGlobalBudgetOn(ctx, f.org, day)
require.NoError(t, err)
assert.Equal(t, want, got.ID, day)
}
_, err = f.budgets.GetGlobalBudgetOn(ctx, f.org, date(2026, 9, 30))
assert.ErrorIs(t, err, ErrGameBudgetNotFound)
event.Amount = 5_000_000
event.Thresholds = entities.JSONDocument(`{"warning": 70, "critical": 90}`)
require.NoError(t, f.budgets.UpdateBudget(ctx, event))
got, err = f.budgets.GetBudget(ctx, f.org, event.ID)
require.NoError(t, err)
assert.EqualValues(t, 5_000_000, got.Amount)
assert.JSONEq(t, `{"warning": 70, "critical": 90}`, string(got.Thresholds))
event.OrganizationID = f.other
assert.ErrorIs(t, f.budgets.UpdateBudget(ctx, event), ErrGameBudgetNotFound)
budgets, total, err := f.budgets.ListBudgets(ctx, GameBudgetFilter{OrganizationID: f.org, Scope: constants.GameBudgetScopeGlobal, Limit: 10})
require.NoError(t, err)
assert.EqualValues(t, 2, total)
assert.Equal(t, "2026-11-01", budgets[0].PeriodStart.Format("2006-01-02"), "latest period first")
}
// EG-701: what game_events refuses.
func TestGameEventSchema_RejectsWhatTheRFCForbids(t *testing.T) {
f := newEnakGameFixture(t)
event := f.budget(t, f.org, constants.GameBudgetScopeEvent, date(2026, 10, 1), date(2026, 10, 7))
t.Cleanup(func() { f.db.Exec(`DELETE FROM game_events WHERE organization_id = ?`, f.org) })
insert := func(budget any, start, end string) error {
return f.db.Exec(`INSERT INTO game_events (organization_id, name, slug, start_at, end_at, budget_id)
VALUES (?, 'e', ?, ?, ?, ?)`, f.org, uuid.NewString(), start, end, budget).Error
}
err := insert(nil, "2026-10-01T00:00:00Z", "2026-10-02T00:00:00Z")
require.Error(t, err)
assert.Contains(t, err.Error(), "budget_id")
err = insert(event.ID, "2026-10-02T00:00:00Z", "2026-10-02T00:00:00Z")
require.Error(t, err)
assert.Contains(t, err.Error(), "chk_game_events_period")
err = insert(event.ID, "2026-10-02T00:00:00Z", "2026-10-01T00:00:00Z")
require.Error(t, err)
assert.Contains(t, err.Error(), "chk_game_events_period")
require.NoError(t, insert(event.ID, "2026-10-01T00:00:00Z", "2026-10-02T00:00:00Z"))
}
@@ -0,0 +1,95 @@
package repository
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"apskel-pos-be/internal/constants"
)
// GameBudgetMetricsRepository reads what a budget's metrics are computed from
// (docs/rfc-enakgame.md §10), on read, from the tables that hold it.
type GameBudgetMetricsRepository interface {
// RealizedCost sums the voucher costs recognized against a budget at or after from
// and before to; a nil bound is open. EnakPoint not from EnakGame names no budget,
// so it never counts (D5).
RealizedCost(ctx context.Context, budgetID uuid.UUID, from, to *time.Time) (int64, error)
// CoinIssued sums the EnakCoin rewards the budget paid for.
CoinIssued(ctx context.Context, budgetID uuid.UUID) (int64, error)
// Exposure sums, per currency, what is still spendable at asOf of the balances the
// budget's rewards created, following them through transfers and exchanges: the
// most that can still turn into cost.
Exposure(ctx context.Context, budgetID uuid.UUID, asOf time.Time) (coins, points int64, err error)
}
type gameBudgetMetricsRepository struct {
db *gorm.DB
}
func NewGameBudgetMetricsRepository(db *gorm.DB) GameBudgetMetricsRepository {
return &gameBudgetMetricsRepository{db: db}
}
func (r *gameBudgetMetricsRepository) RealizedCost(ctx context.Context, budgetID uuid.UUID, from, to *time.Time) (int64, error) {
q := DBFromContext(ctx, r.db).WithContext(ctx).Table("voucher_redemption_costs").Where("budget_id = ?", budgetID)
if from != nil {
q = q.Where("recognized_at >= ?", *from)
}
if to != nil {
q = q.Where("recognized_at < ?", *to)
}
var total int64
if err := q.Select("COALESCE(SUM(cost), 0)").Scan(&total).Error; err != nil {
return 0, fmt.Errorf("failed to sum realized cost: %w", err)
}
return total, nil
}
func (r *gameBudgetMetricsRepository) CoinIssued(ctx context.Context, budgetID uuid.UUID) (int64, error) {
var total int64
err := DBFromContext(ctx, r.db).WithContext(ctx).Table("game_session_rewards").Where("budget_id = ?", budgetID).
Select("COALESCE(SUM(amount), 0)").Scan(&total).Error
if err != nil {
return 0, fmt.Errorf("failed to sum issued EnakCoin: %w", err)
}
return total, nil
}
func (r *gameBudgetMetricsRepository) Exposure(ctx context.Context, budgetID uuid.UUID, asOf time.Time) (int64, int64, error) {
var rows []struct {
Currency string
Total int64
}
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
WITH RECURSIVE tree AS (
SELECT l.id, l.currency, l.remaining_amount, l.expires_at
FROM game_session_rewards r
JOIN wallet_lots l ON l.source_transaction_id = r.wallet_transaction_id
WHERE r.budget_id = ?
UNION ALL
SELECT c.id, c.currency, c.remaining_amount, c.expires_at
FROM tree t
JOIN wallet_lots c ON c.origin_lot_id = t.id
)
SELECT currency, COALESCE(SUM(remaining_amount), 0) AS total
FROM tree
WHERE remaining_amount > 0 AND (expires_at IS NULL OR expires_at > ?)
GROUP BY currency`, budgetID, asOf).Scan(&rows).Error
if err != nil {
return 0, 0, fmt.Errorf("failed to sum budget exposure: %w", err)
}
var coins, points int64
for _, row := range rows {
switch row.Currency {
case constants.WalletCurrencyCoin:
coins = row.Total
case constants.WalletCurrencyPoint:
points = row.Total
}
}
return coins, points, nil
}
@@ -0,0 +1,233 @@
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
}
@@ -0,0 +1,217 @@
package repository
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
)
var (
// ErrGameEventNotFound means no event with that id in the organization.
ErrGameEventNotFound = errors.New("enakgame: event not found")
// ErrGameEventSlugTaken means another event of the organization has the slug.
ErrGameEventSlugTaken = errors.New("enakgame: event slug already used")
)
// GameEventFilter selects an organization's events.
type GameEventFilter struct {
OrganizationID uuid.UUID
// Empty for every status.
Statuses []string
Offset int
Limit int
}
// GameEventRepository stores EnakGame events and the games they cover
// (docs/rfc-enakgame.md §5.5), always scoped to an organization.
type GameEventRepository interface {
// CreateEvent stores an event and the games it covers.
CreateEvent(ctx context.Context, event *entities.GameEvent, gameIDs []uuid.UUID) error
GetEvent(ctx context.Context, organizationID, id uuid.UUID) (*entities.GameEvent, error)
// LockEvent is GetEvent with the row locked until the transaction ends.
LockEvent(ctx context.Context, organizationID, id uuid.UUID) (*entities.GameEvent, error)
// EventGames returns the games each event covers.
EventGames(ctx context.Context, eventIDs []uuid.UUID) (map[uuid.UUID][]uuid.UUID, error)
// ListEvents returns a page of events, latest start first, and the total.
ListEvents(ctx context.Context, filter GameEventFilter) ([]entities.GameEvent, int64, error)
// UpdateEvent stores everything but the organization and the status, and replaces
// the games it covers.
UpdateEvent(ctx context.Context, event *entities.GameEvent, gameIDs []uuid.UUID) error
SetEventStatus(ctx context.Context, organizationID, id uuid.UUID, status string) error
// ActiveEventsForGame returns the events changing a game's rewards at now: ACTIVE,
// started, not ended. Highest priority first, then oldest.
ActiveEventsForGame(ctx context.Context, organizationID, gameID uuid.UUID, now time.Time) ([]entities.GameEvent, error)
// ActiveEventsByGame is ActiveEventsForGame for every game of the organization.
ActiveEventsByGame(ctx context.Context, organizationID uuid.UUID, now time.Time) (map[uuid.UUID][]entities.GameEvent, error)
}
type gameEventRepository struct {
db *gorm.DB
}
func NewGameEventRepository(db *gorm.DB) GameEventRepository {
return &gameEventRepository{db: db}
}
func (r *gameEventRepository) CreateEvent(ctx context.Context, event *entities.GameEvent, gameIDs []uuid.UUID) error {
db := DBFromContext(ctx, r.db).WithContext(ctx)
if err := db.Create(event).Error; err != nil {
if isUniqueViolation(err, "uq_game_events_slug") {
return ErrGameEventSlugTaken
}
return fmt.Errorf("failed to create game event: %w", err)
}
return r.linkGames(db, event.ID, gameIDs)
}
func (r *gameEventRepository) linkGames(db *gorm.DB, eventID uuid.UUID, gameIDs []uuid.UUID) error {
for _, gameID := range gameIDs {
if err := db.Exec(`INSERT INTO game_event_games (event_id, game_id) VALUES (?, ?) ON CONFLICT DO NOTHING`, eventID, gameID).Error; err != nil {
return fmt.Errorf("failed to link game to event: %w", err)
}
}
return nil
}
func (r *gameEventRepository) getEvent(ctx context.Context, organizationID, id uuid.UUID, lock bool) (*entities.GameEvent, error) {
q := DBFromContext(ctx, r.db).WithContext(ctx).Where("organization_id = ? AND id = ?", organizationID, id)
if lock {
q = q.Clauses(clause.Locking{Strength: "UPDATE"})
}
var event entities.GameEvent
if err := q.First(&event).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrGameEventNotFound
}
return nil, fmt.Errorf("failed to read game event: %w", err)
}
return &event, nil
}
func (r *gameEventRepository) GetEvent(ctx context.Context, organizationID, id uuid.UUID) (*entities.GameEvent, error) {
return r.getEvent(ctx, organizationID, id, false)
}
func (r *gameEventRepository) LockEvent(ctx context.Context, organizationID, id uuid.UUID) (*entities.GameEvent, error) {
return r.getEvent(ctx, organizationID, id, true)
}
func (r *gameEventRepository) EventGames(ctx context.Context, eventIDs []uuid.UUID) (map[uuid.UUID][]uuid.UUID, error) {
out := map[uuid.UUID][]uuid.UUID{}
if len(eventIDs) == 0 {
return out, nil
}
var rows []struct{ EventID, GameID string }
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT event_id::text AS event_id, game_id::text AS game_id FROM game_event_games
WHERE event_id IN ? ORDER BY game_id`, eventIDs).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to read event games: %w", err)
}
for _, row := range rows {
eventID, _ := uuid.Parse(row.EventID)
gameID, _ := uuid.Parse(row.GameID)
out[eventID] = append(out[eventID], gameID)
}
return out, nil
}
func (r *gameEventRepository) ListEvents(ctx context.Context, filter GameEventFilter) ([]entities.GameEvent, int64, error) {
q := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GameEvent{}).Where("organization_id = ?", filter.OrganizationID)
if len(filter.Statuses) > 0 {
q = q.Where("status IN ?", filter.Statuses)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("failed to count game events: %w", err)
}
var events []entities.GameEvent
if err := q.Order("start_at DESC, id").Offset(filter.Offset).Limit(filter.Limit).Find(&events).Error; err != nil {
return nil, 0, fmt.Errorf("failed to list game events: %w", err)
}
return events, total, nil
}
func (r *gameEventRepository) UpdateEvent(ctx context.Context, e *entities.GameEvent, gameIDs []uuid.UUID) error {
db := DBFromContext(ctx, r.db).WithContext(ctx)
result := db.Exec(`
UPDATE game_events SET name = ?, slug = ?, description = ?, banner_url = ?, start_at = ?, end_at = ?,
timezone = ?, priority = ?, multiplier = ?, bonus = ?, budget_id = ?, reward_limit = ?,
user_daily_limit = ?, updated_at = NOW()
WHERE organization_id = ? AND id = ?`,
e.Name, e.Slug, e.Description, e.BannerURL, e.StartAt, e.EndAt, e.Timezone, e.Priority, e.Multiplier,
e.Bonus, e.BudgetID, e.RewardLimit, e.UserDailyLimit, e.OrganizationID, e.ID)
if result.Error != nil {
if isUniqueViolation(result.Error, "uq_game_events_slug") {
return ErrGameEventSlugTaken
}
return fmt.Errorf("failed to update game event: %w", result.Error)
}
if result.RowsAffected == 0 {
return ErrGameEventNotFound
}
if err := db.Exec(`DELETE FROM game_event_games WHERE event_id = ?`, e.ID).Error; err != nil {
return fmt.Errorf("failed to unlink event games: %w", err)
}
return r.linkGames(db, e.ID, gameIDs)
}
func (r *gameEventRepository) SetEventStatus(ctx context.Context, organizationID, id uuid.UUID, status string) error {
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
UPDATE game_events SET status = ?, updated_at = NOW() WHERE organization_id = ? AND id = ?`, status, organizationID, id)
if result.Error != nil {
return fmt.Errorf("failed to change game event status: %w", result.Error)
}
if result.RowsAffected == 0 {
return ErrGameEventNotFound
}
return nil
}
func (r *gameEventRepository) ActiveEventsForGame(ctx context.Context, organizationID, gameID uuid.UUID, now time.Time) ([]entities.GameEvent, error) {
var events []entities.GameEvent
err := DBFromContext(ctx, r.db).WithContext(ctx).
Joins("JOIN game_event_games eg ON eg.event_id = game_events.id").
Where("game_events.organization_id = ? AND eg.game_id = ? AND game_events.status = ? AND game_events.start_at <= ? AND game_events.end_at > ?",
organizationID, gameID, constants.GameEventStatusActive, now, now).
Order("game_events.priority DESC, game_events.created_at, game_events.id").
Find(&events).Error
if err != nil {
return nil, fmt.Errorf("failed to read active game events: %w", err)
}
return events, nil
}
func (r *gameEventRepository) ActiveEventsByGame(ctx context.Context, organizationID uuid.UUID, now time.Time) (map[uuid.UUID][]entities.GameEvent, error) {
db := DBFromContext(ctx, r.db).WithContext(ctx)
var events []entities.GameEvent
err := db.Where("organization_id = ? AND status = ? AND start_at <= ? AND end_at > ?", organizationID, constants.GameEventStatusActive, now, now).
Order("priority DESC, created_at, id").Find(&events).Error
if err != nil {
return nil, fmt.Errorf("failed to read active game events: %w", err)
}
ids := make([]uuid.UUID, 0, len(events))
for _, e := range events {
ids = append(ids, e.ID)
}
games, err := r.EventGames(ctx, ids)
if err != nil {
return nil, err
}
out := map[uuid.UUID][]entities.GameEvent{}
for _, e := range events {
for _, gameID := range games[e.ID] {
out[gameID] = append(out[gameID], e)
}
}
return out, nil
}
@@ -0,0 +1,133 @@
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
}
@@ -0,0 +1,110 @@
package repository
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"apskel-pos-be/internal/constants"
)
// The Economy Guard counters against Postgres (docs/rfc-enakgame.md §9). Needs
// TEST_DATABASE_URL; see wallet_repository_test.go.
func TestGameRewardCounterRepository_ConcurrentConsumeNeverPassesTheLimit(t *testing.T) {
db := walletTestDB(t)
org, game := uuid.New(), uuid.New()
t.Cleanup(func() { db.Exec(`DELETE FROM game_reward_counters WHERE organization_id = ?`, org) })
repo := NewGameRewardCounterRepository(db)
txm := NewTxManager(db)
day := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC)
for _, round := range []struct {
each int64
wantTotal int64
}{{10, 100}, {7, 100}} {
var granted atomic.Int64
var wg sync.WaitGroup
begin := make(chan struct{})
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-begin
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
got, err := repo.Consume(ctx, org, constants.GameRewardScopeGame, game, day, round.each, 100)
granted.Add(got)
return err
}))
}()
}
close(begin)
wg.Wait()
total, err := repo.Get(context.Background(), org, constants.GameRewardScopeGame, game, day)
require.NoError(t, err)
assert.EqualValues(t, round.wantTotal, total)
if round.each == 10 {
assert.EqualValues(t, 100, granted.Load(), "exactly what fits")
} else {
assert.Zero(t, granted.Load(), "nothing is left")
}
}
}
func TestGameRewardCounterRepository(t *testing.T) {
db := walletTestDB(t)
org, customer := uuid.New(), uuid.New()
t.Cleanup(func() { db.Exec(`DELETE FROM game_reward_counters WHERE organization_id = ?`, org) })
repo := NewGameRewardCounterRepository(db)
txm := NewTxManager(db)
ctx := context.Background()
today, tomorrow := time.Date(2026, 10, 7, 23, 0, 0, 0, time.FixedZone("WIB", 7*3600)), time.Date(2026, 10, 8, 0, 0, 0, 0, time.UTC)
consume := func(day time.Time, amount, limit int64) int64 {
t.Helper()
var got int64
require.NoError(t, txm.WithTransaction(ctx, func(ctx context.Context) error {
var err error
got, err = repo.Consume(ctx, org, constants.GameRewardScopeUser, customer, day, amount, limit)
return err
}))
return got
}
assert.EqualValues(t, 30, consume(today, 30, 40))
assert.EqualValues(t, 10, consume(today, 30, 40), "cut to what is left")
assert.EqualValues(t, 0, consume(today, 5, 40))
assert.EqualValues(t, 5, consume(today, 5, 0), "no limit, still counted")
assert.EqualValues(t, 30, consume(tomorrow, 30, 40), "a new day starts over")
got, err := repo.Get(ctx, org, constants.GameRewardScopeUser, customer, today)
require.NoError(t, err)
assert.EqualValues(t, 45, got)
require.NoError(t, txm.WithTransaction(ctx, func(ctx context.Context) error {
return repo.Release(ctx, org, constants.GameRewardScopeUser, customer, today, 15)
}))
got, _ = repo.Get(ctx, org, constants.GameRewardScopeUser, customer, today)
assert.EqualValues(t, 30, got)
assert.Error(t, txm.WithTransaction(ctx, func(ctx context.Context) error {
return repo.Release(ctx, org, constants.GameRewardScopeUser, customer, today, 31)
}), "cannot release more than counted")
// A rolled back reward leaves the counter as it was.
_ = txm.WithTransaction(ctx, func(ctx context.Context) error {
_, err := repo.Consume(ctx, org, constants.GameRewardScopeUser, customer, today, 10, 0)
require.NoError(t, err)
return assert.AnError
})
got, _ = repo.Get(ctx, org, constants.GameRewardScopeUser, customer, today)
assert.EqualValues(t, 30, got)
_, err = repo.Consume(ctx, org, constants.GameRewardScopeUser, customer, today, 1, 0)
assert.ErrorIs(t, err, ErrGameRewardCounterTxRequired)
got, err = repo.Get(ctx, org, constants.GameRewardScopeGlobal, org, today)
require.NoError(t, err)
assert.Zero(t, got)
}
@@ -0,0 +1,239 @@
package repository
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
)
// ErrGameSessionNotFound means no such session, or not one of the customer's.
var ErrGameSessionNotFound = errors.New("enakgame: session not found")
// DueGameSession is a STARTED session the session job has to look at, with the status
// of its game.
type DueGameSession struct {
ID uuid.UUID
OrganizationID uuid.UUID
CustomerID uuid.UUID
GameStatus string
}
// GameSessionCompletion is what completing a session stores.
type GameSessionCompletion struct {
Result entities.JSONDocument
RewardBreakdown entities.JSONDocument
RewardTotal int64
Flagged bool
EndedAt time.Time
}
// GameSessionRepository stores game sessions and the rewards they paid
// (docs/rfc-enakgame.md §5.3, §5.4).
//
// A session leaves STARTED once: CompleteSession, RefundSession and ExpireSession are
// each one UPDATE ... WHERE status = 'STARTED' and report whether they made the move,
// so of two at once on the same session exactly one wins (D4).
type GameSessionRepository interface {
CreateSession(ctx context.Context, session *entities.GameSession) error
GetSession(ctx context.Context, organizationID, id uuid.UUID) (*entities.GameSession, error)
// GetCustomerSession returns ErrGameSessionNotFound for another customer's session.
GetCustomerSession(ctx context.Context, customerID, id uuid.UUID) (*entities.GameSession, error)
// GetSessionBySpendTransaction finds the session an entry cost debit paid for, to
// replay a start sent twice.
GetSessionBySpendTransaction(ctx context.Context, spendTransactionID uuid.UUID) (*entities.GameSession, error)
// ListCustomerSessions returns a page of a customer's sessions, newest first, and
// the total.
ListCustomerSessions(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]entities.GameSession, int64, error)
CompleteSession(ctx context.Context, id uuid.UUID, completion GameSessionCompletion) (bool, error)
RefundSession(ctx context.Context, id, refundTransactionID uuid.UUID, reason string, endedAt time.Time) (bool, error)
ExpireSession(ctx context.Context, id uuid.UUID, endedAt time.Time) (bool, error)
// MarkCompletionFailed records that completing a STARTED session failed on a system
// error (§7.3), and reports false when the session is no longer STARTED.
MarkCompletionFailed(ctx context.Context, id uuid.UUID, at time.Time) (bool, error)
// ListExpiredSessions returns STARTED sessions whose expiry is at or before now,
// oldest expiry first.
ListExpiredSessions(ctx context.Context, now time.Time, limit int) ([]entities.GameSession, error)
// ListDueSessions returns STARTED sessions that have expired at now or whose game is
// no longer ACTIVE (§7.3), oldest expiry first.
ListDueSessions(ctx context.Context, now time.Time, limit int) ([]DueGameSession, error)
// ListOpenSessionsByGame returns a game's STARTED sessions, oldest first.
ListOpenSessionsByGame(ctx context.Context, organizationID, gameID uuid.UUID, limit int) ([]entities.GameSession, error)
CreateSessionRewards(ctx context.Context, rewards []entities.GameSessionReward) error
ListSessionRewards(ctx context.Context, sessionID uuid.UUID) ([]entities.GameSessionReward, error)
}
type gameSessionRepository struct {
db *gorm.DB
}
func NewGameSessionRepository(db *gorm.DB) GameSessionRepository {
return &gameSessionRepository{db: db}
}
func (r *gameSessionRepository) CreateSession(ctx context.Context, session *entities.GameSession) error {
if session.Status == "" {
session.Status = constants.GameSessionStatusStarted
}
if session.StartedAt.IsZero() {
session.StartedAt = time.Now()
}
if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(session).Error; err != nil {
return fmt.Errorf("failed to create game session: %w", err)
}
return nil
}
func (r *gameSessionRepository) first(q *gorm.DB) (*entities.GameSession, error) {
var session entities.GameSession
if err := q.First(&session).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrGameSessionNotFound
}
return nil, fmt.Errorf("failed to read game session: %w", err)
}
return &session, nil
}
func (r *gameSessionRepository) GetSession(ctx context.Context, organizationID, id uuid.UUID) (*entities.GameSession, error) {
return r.first(DBFromContext(ctx, r.db).WithContext(ctx).Where("organization_id = ? AND id = ?", organizationID, id))
}
func (r *gameSessionRepository) GetCustomerSession(ctx context.Context, customerID, id uuid.UUID) (*entities.GameSession, error) {
return r.first(DBFromContext(ctx, r.db).WithContext(ctx).Where("customer_id = ? AND id = ?", customerID, id))
}
func (r *gameSessionRepository) GetSessionBySpendTransaction(ctx context.Context, spendTransactionID uuid.UUID) (*entities.GameSession, error) {
return r.first(DBFromContext(ctx, r.db).WithContext(ctx).Where("spend_transaction_id = ?", spendTransactionID))
}
func (r *gameSessionRepository) ListCustomerSessions(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]entities.GameSession, int64, error) {
q := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GameSession{}).Where("customer_id = ?", customerID)
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("failed to count game sessions: %w", err)
}
var sessions []entities.GameSession
if err := q.Order("started_at DESC, id").Offset(offset).Limit(limit).Find(&sessions).Error; err != nil {
return nil, 0, fmt.Errorf("failed to list game sessions: %w", err)
}
return sessions, total, nil
}
// leaveStarted runs one conditional move out of STARTED and reports whether it
// happened.
func (r *gameSessionRepository) leaveStarted(ctx context.Context, id uuid.UUID, set string, args ...any) (bool, error) {
args = append(args, id, constants.GameSessionStatusStarted)
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(
`UPDATE game_sessions SET `+set+` WHERE id = ? AND status = ?`, args...)
if result.Error != nil {
return false, fmt.Errorf("failed to update game session: %w", result.Error)
}
return result.RowsAffected == 1, nil
}
func (r *gameSessionRepository) CompleteSession(ctx context.Context, id uuid.UUID, c GameSessionCompletion) (bool, error) {
return r.leaveStarted(ctx, id,
`status = ?, result = ?::jsonb, reward_breakdown = ?::jsonb, reward_total = ?, flagged = ?, ended_at = ?`,
constants.GameSessionStatusCompleted, c.Result, c.RewardBreakdown, c.RewardTotal, c.Flagged, c.EndedAt)
}
func (r *gameSessionRepository) RefundSession(ctx context.Context, id, refundTransactionID uuid.UUID, reason string, endedAt time.Time) (bool, error) {
return r.leaveStarted(ctx, id,
`status = ?, refund_transaction_id = ?, refund_reason = ?, ended_at = ?`,
constants.GameSessionStatusRefunded, refundTransactionID, reason, endedAt)
}
func (r *gameSessionRepository) ExpireSession(ctx context.Context, id uuid.UUID, endedAt time.Time) (bool, error) {
return r.leaveStarted(ctx, id, `status = ?, ended_at = ?`, constants.GameSessionStatusExpired, endedAt)
}
func (r *gameSessionRepository) MarkCompletionFailed(ctx context.Context, id uuid.UUID, at time.Time) (bool, error) {
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
UPDATE game_sessions SET completion_failed_at = ? WHERE id = ? AND status = ?`,
at, id, constants.GameSessionStatusStarted)
if result.Error != nil {
return false, fmt.Errorf("failed to mark game session: %w", result.Error)
}
return result.RowsAffected == 1, nil
}
func (r *gameSessionRepository) ListExpiredSessions(ctx context.Context, now time.Time, limit int) ([]entities.GameSession, error) {
var sessions []entities.GameSession
err := DBFromContext(ctx, r.db).WithContext(ctx).
Where("status = ? AND expires_at <= ?", constants.GameSessionStatusStarted, now).
Order("expires_at, id").Limit(limit).Find(&sessions).Error
if err != nil {
return nil, fmt.Errorf("failed to list expired game sessions: %w", err)
}
return sessions, nil
}
func (r *gameSessionRepository) ListOpenSessionsByGame(ctx context.Context, organizationID, gameID uuid.UUID, limit int) ([]entities.GameSession, error) {
var sessions []entities.GameSession
err := DBFromContext(ctx, r.db).WithContext(ctx).
Where("organization_id = ? AND game_id = ? AND status = ?", organizationID, gameID, constants.GameSessionStatusStarted).
Order("started_at, id").Limit(limit).Find(&sessions).Error
if err != nil {
return nil, fmt.Errorf("failed to list open game sessions: %w", err)
}
return sessions, nil
}
func (r *gameSessionRepository) CreateSessionRewards(ctx context.Context, rewards []entities.GameSessionReward) error {
if len(rewards) == 0 {
return nil
}
if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(&rewards).Error; err != nil {
return fmt.Errorf("failed to record game session rewards: %w", err)
}
return nil
}
func (r *gameSessionRepository) ListSessionRewards(ctx context.Context, sessionID uuid.UUID) ([]entities.GameSessionReward, error) {
var rewards []entities.GameSessionReward
err := DBFromContext(ctx, r.db).WithContext(ctx).Where("session_id = ?", sessionID).Order("budget_id").Find(&rewards).Error
if err != nil {
return nil, fmt.Errorf("failed to list game session rewards: %w", err)
}
return rewards, nil
}
func (r *gameSessionRepository) ListDueSessions(ctx context.Context, now time.Time, limit int) ([]DueGameSession, error) {
var rows []struct {
ID string
OrganizationID string
CustomerID string
GameStatus string
}
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT s.id::text AS id, s.organization_id::text AS organization_id, s.customer_id::text AS customer_id,
g.status AS game_status
FROM game_sessions s
JOIN games g ON g.id = s.game_id
WHERE s.status = ? AND (s.expires_at <= ? OR g.status <> ?)
ORDER BY s.expires_at, s.id
LIMIT ?`,
constants.GameSessionStatusStarted, now, constants.GameStatusActive, limit).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to list due game sessions: %w", err)
}
due := make([]DueGameSession, 0, len(rows))
for _, row := range rows {
d := DueGameSession{GameStatus: row.GameStatus}
d.ID, _ = uuid.Parse(row.ID)
d.OrganizationID, _ = uuid.Parse(row.OrganizationID)
d.CustomerID, _ = uuid.Parse(row.CustomerID)
due = append(due, d)
}
return due, nil
}
@@ -25,8 +25,8 @@ type LoyaltySettingChangeFilter struct {
Limit int
}
// LoyaltySettingsRepository stores the raw loyalty settings (the loyalty.* keys of
// outlet_settings and organization_settings) and their history
// LoyaltySettingsRepository stores the raw loyalty settings (the loyalty.* and
// enakgame.* keys of outlet_settings and organization_settings) and their history
// (docs/prd-point-coin.md F1, F2). The loyalty settings processor turns them into
// typed values and is the only writer.
type LoyaltySettingsRepository interface {
@@ -81,7 +81,7 @@ func (r *loyaltySettingsRepository) values(ctx context.Context, table, scopeColu
err := DBFromContext(ctx, r.db).WithContext(ctx).
Table(table).
Select("key, value").
Where(scopeColumn+" = ? AND key LIKE 'loyalty.%' AND value IS NOT NULL", scopeID).
Where(scopeColumn+" = ? AND (key LIKE 'loyalty.%' OR key LIKE 'enakgame.%') AND value IS NOT NULL", scopeID).
Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to read %s: %w", table, err)
+25
View File
@@ -0,0 +1,25 @@
package repository
import (
"errors"
"github.com/jackc/pgx/v5/pgconn"
)
// isConstraintViolation reports whether err is Postgres refusing a row on the named
// constraint or unique index, with the given SQLSTATE.
func isConstraintViolation(err error, code, constraint string) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == code && pgErr.ConstraintName == constraint
}
func isUniqueViolation(err error, constraint string) bool {
return isConstraintViolation(err, "23505", constraint)
}
// isForeignKeyViolation reports whether err is a row still referenced from elsewhere,
// or pointing at one that does not exist, whatever the constraint.
func isForeignKeyViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23503"
}
@@ -0,0 +1,291 @@
package repository
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
)
// RedemptionPointSource is EnakPoint a redemption spent, traced back to the ledger row
// that first created it (D5): GAME_REWARD with the budget that paid for it, or EARN,
// ADJUSTMENT, MIGRATION with none.
type RedemptionPointSource struct {
Points int64
SourceType string
BudgetID *uuid.UUID
}
// CustomerRedemption is a redemption as its customer lists it, with the voucher it was
// for and its code.
type CustomerRedemption struct {
entities.VoucherRedemption
VoucherName string
VoucherImageURL *string
VoucherType string
Code *string
CodeExpiresAt *time.Time
}
// VoucherRedemptionRepository stores redemptions and what they cost each budget
// (docs/rfc-enakgame.md §5.7, §7.4, §7.6).
type VoucherRedemptionRepository interface {
// GetByKey returns a customer's redemption made with an Idempotency-Key, or nil.
GetByKey(ctx context.Context, customerID uuid.UUID, idempotencyKey string) (*entities.VoucherRedemption, error)
// CountForCustomer counts a customer's redemptions of a voucher that went through
// or may still: COMPLETED and PENDING.
CountForCustomer(ctx context.Context, customerID, voucherID uuid.UUID) (int64, error)
CreateRedemption(ctx context.Context, redemption *entities.VoucherRedemption) error
// ListCustomerRedemptions returns a page of a customer's redemptions, newest first,
// and the total.
ListCustomerRedemptions(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]CustomerRedemption, int64, error)
// PointSources follows the lots a debit drew from back to their root lots and
// sums the EnakPoint per source type and budget (§7.6). Exchanges, transfers and
// refunds are walked through, since their lots point at the lots they came from.
PointSources(ctx context.Context, debitTransactionID uuid.UUID) ([]RedemptionPointSource, error)
GetRedemption(ctx context.Context, id uuid.UUID) (*entities.VoucherRedemption, error)
// MarkCompleted settles a PENDING redemption with the provider's voucher, and
// reports false when it was no longer PENDING.
MarkCompleted(ctx context.Context, id uuid.UUID, code, ref *string, at time.Time) (bool, error)
// MarkFailed settles a PENDING redemption as failed, with the refund of its
// EnakPoint, and reports false when it was no longer PENDING.
MarkFailed(ctx context.Context, id, refundTransactionID uuid.UUID, reason string) (bool, error)
// TouchPending counts one more unanswered call to the provider and returns the
// count; 0 when the redemption is no longer PENDING.
TouchPending(ctx context.Context, id uuid.UUID) (int, error)
// ClaimStalePending takes at most limit PENDING redemptions untouched since before,
// oldest first, and touches them, so another instance leaves them alone until they
// go stale again.
ClaimStalePending(ctx context.Context, before time.Time, limit int) ([]entities.VoucherRedemption, error)
CreateCosts(ctx context.Context, costs []entities.VoucherRedemptionCost) error
ListCosts(ctx context.Context, redemptionID uuid.UUID) ([]entities.VoucherRedemptionCost, error)
}
type voucherRedemptionRepository struct {
db *gorm.DB
}
func NewVoucherRedemptionRepository(db *gorm.DB) VoucherRedemptionRepository {
return &voucherRedemptionRepository{db: db}
}
func (r *voucherRedemptionRepository) GetByKey(ctx context.Context, customerID uuid.UUID, idempotencyKey string) (*entities.VoucherRedemption, error) {
var redemption entities.VoucherRedemption
err := DBFromContext(ctx, r.db).WithContext(ctx).
Where("customer_id = ? AND idempotency_key = ?", customerID, idempotencyKey).First(&redemption).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to read voucher redemption: %w", err)
}
return &redemption, nil
}
func (r *voucherRedemptionRepository) CountForCustomer(ctx context.Context, customerID, voucherID uuid.UUID) (int64, error) {
var count int64
err := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.VoucherRedemption{}).
Where("customer_id = ? AND voucher_id = ? AND status IN ?", customerID, voucherID,
[]string{constants.VoucherRedemptionCompleted, constants.VoucherRedemptionPending}).
Count(&count).Error
if err != nil {
return 0, fmt.Errorf("failed to count voucher redemptions: %w", err)
}
return count, nil
}
func (r *voucherRedemptionRepository) CreateRedemption(ctx context.Context, redemption *entities.VoucherRedemption) error {
if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(redemption).Error; err != nil {
return fmt.Errorf("failed to create voucher redemption: %w", err)
}
return nil
}
func (r *voucherRedemptionRepository) ListCustomerRedemptions(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]CustomerRedemption, int64, error) {
db := DBFromContext(ctx, r.db).WithContext(ctx)
var total int64
if err := db.Model(&entities.VoucherRedemption{}).Where("customer_id = ?", customerID).Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("failed to count voucher redemptions: %w", err)
}
var redemptions []entities.VoucherRedemption
err := db.Where("customer_id = ?", customerID).Order("created_at DESC, id").Offset(offset).Limit(limit).Find(&redemptions).Error
if err != nil {
return nil, 0, fmt.Errorf("failed to list voucher redemptions: %w", err)
}
voucherIDs, codeIDs := []uuid.UUID{}, []uuid.UUID{}
for _, red := range redemptions {
voucherIDs = append(voucherIDs, red.VoucherID)
if red.VoucherCodeID != nil {
codeIDs = append(codeIDs, *red.VoucherCodeID)
}
}
vouchers := map[uuid.UUID]entities.Voucher{}
if len(voucherIDs) > 0 {
var rows []entities.Voucher
if err := db.Where("id IN ?", voucherIDs).Find(&rows).Error; err != nil {
return nil, 0, fmt.Errorf("failed to read redeemed vouchers: %w", err)
}
for _, v := range rows {
vouchers[v.ID] = v
}
}
codes := map[uuid.UUID]entities.VoucherCode{}
if len(codeIDs) > 0 {
var rows []entities.VoucherCode
if err := db.Where("id IN ?", codeIDs).Find(&rows).Error; err != nil {
return nil, 0, fmt.Errorf("failed to read redeemed codes: %w", err)
}
for _, c := range rows {
codes[c.ID] = c
}
}
out := make([]CustomerRedemption, 0, len(redemptions))
for _, red := range redemptions {
v := vouchers[red.VoucherID]
item := CustomerRedemption{VoucherRedemption: red, VoucherName: v.Name, VoucherImageURL: v.ImageURL, VoucherType: v.VoucherType, Code: red.ExternalCode}
if red.VoucherCodeID != nil {
if c, ok := codes[*red.VoucherCodeID]; ok {
code := c.Code
item.Code, item.CodeExpiresAt = &code, c.ExpiresAt
}
}
out = append(out, item)
}
return out, total, nil
}
func (r *voucherRedemptionRepository) PointSources(ctx context.Context, debitTransactionID uuid.UUID) ([]RedemptionPointSource, error) {
var rows []struct {
Points int64
SourceType string
BudgetID *string
}
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
WITH RECURSIVE chain AS (
SELECT a.lot_id AS spent_lot, a.amount AS points, l.origin_lot_id, l.source_transaction_id
FROM wallet_lot_allocations a
JOIN wallet_lots l ON l.id = a.lot_id
WHERE a.transaction_id = ?
UNION ALL
SELECT c.spent_lot, c.points, p.origin_lot_id, p.source_transaction_id
FROM chain c
JOIN wallet_lots p ON p.id = c.origin_lot_id
)
SELECT SUM(c.points) AS points, t.type AS source_type, gsr.budget_id::text AS budget_id
FROM chain c
JOIN wallet_transactions t ON t.id = c.source_transaction_id
LEFT JOIN game_session_rewards gsr ON gsr.wallet_transaction_id = t.id
WHERE c.origin_lot_id IS NULL
GROUP BY t.type, gsr.budget_id
ORDER BY t.type, gsr.budget_id`, debitTransactionID).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to trace redeemed EnakPoint: %w", err)
}
out := make([]RedemptionPointSource, 0, len(rows))
for _, row := range rows {
s := RedemptionPointSource{Points: row.Points, SourceType: row.SourceType}
if row.BudgetID != nil {
id, err := uuid.Parse(*row.BudgetID)
if err != nil {
return nil, fmt.Errorf("bad budget id %q: %w", *row.BudgetID, err)
}
s.BudgetID = &id
}
out = append(out, s)
}
return out, nil
}
func (r *voucherRedemptionRepository) CreateCosts(ctx context.Context, costs []entities.VoucherRedemptionCost) error {
if len(costs) == 0 {
return nil
}
if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(&costs).Error; err != nil {
return fmt.Errorf("failed to record redemption costs: %w", err)
}
return nil
}
func (r *voucherRedemptionRepository) ListCosts(ctx context.Context, redemptionID uuid.UUID) ([]entities.VoucherRedemptionCost, error) {
var costs []entities.VoucherRedemptionCost
err := DBFromContext(ctx, r.db).WithContext(ctx).Where("redemption_id = ?", redemptionID).
Order("cost DESC, source_type").Find(&costs).Error
if err != nil {
return nil, fmt.Errorf("failed to list redemption costs: %w", err)
}
return costs, nil
}
func (r *voucherRedemptionRepository) GetRedemption(ctx context.Context, id uuid.UUID) (*entities.VoucherRedemption, error) {
var redemption entities.VoucherRedemption
if err := DBFromContext(ctx, r.db).WithContext(ctx).Where("id = ?", id).First(&redemption).Error; err != nil {
return nil, fmt.Errorf("failed to read voucher redemption: %w", err)
}
return &redemption, nil
}
func (r *voucherRedemptionRepository) MarkCompleted(ctx context.Context, id uuid.UUID, code, ref *string, at time.Time) (bool, error) {
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
UPDATE voucher_redemptions SET status = ?, external_code = ?, external_ref = ?, completed_at = ?,
attempts = attempts + 1, updated_at = NOW()
WHERE id = ? AND status = ?`,
constants.VoucherRedemptionCompleted, code, ref, at, id, constants.VoucherRedemptionPending)
if result.Error != nil {
return false, fmt.Errorf("failed to complete voucher redemption: %w", result.Error)
}
return result.RowsAffected == 1, nil
}
func (r *voucherRedemptionRepository) MarkFailed(ctx context.Context, id, refundTransactionID uuid.UUID, reason string) (bool, error) {
if len(reason) > 255 {
reason = reason[:255]
}
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
UPDATE voucher_redemptions SET status = ?, refund_transaction_id = ?, failure_reason = ?,
attempts = attempts + 1, updated_at = NOW()
WHERE id = ? AND status = ?`,
constants.VoucherRedemptionFailed, refundTransactionID, reason, id, constants.VoucherRedemptionPending)
if result.Error != nil {
return false, fmt.Errorf("failed to fail voucher redemption: %w", result.Error)
}
return result.RowsAffected == 1, nil
}
func (r *voucherRedemptionRepository) TouchPending(ctx context.Context, id uuid.UUID) (int, error) {
var attempts []int
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
UPDATE voucher_redemptions SET attempts = attempts + 1, updated_at = NOW()
WHERE id = ? AND status = ? RETURNING attempts`, id, constants.VoucherRedemptionPending).Scan(&attempts).Error
if err != nil {
return 0, fmt.Errorf("failed to touch voucher redemption: %w", err)
}
if len(attempts) == 0 {
return 0, nil
}
return attempts[0], nil
}
func (r *voucherRedemptionRepository) ClaimStalePending(ctx context.Context, before time.Time, limit int) ([]entities.VoucherRedemption, error) {
var claimed []entities.VoucherRedemption
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
UPDATE voucher_redemptions SET updated_at = NOW()
WHERE id IN (
SELECT id FROM voucher_redemptions
WHERE status = ? AND updated_at <= ?
ORDER BY updated_at, id
LIMIT ?
FOR UPDATE SKIP LOCKED)
RETURNING *`, constants.VoucherRedemptionPending, before, limit).Scan(&claimed).Error
if err != nil {
return nil, fmt.Errorf("failed to claim pending voucher redemptions: %w", err)
}
return claimed, nil
}
+341
View File
@@ -0,0 +1,341 @@
package repository
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
)
// ErrVoucherNotFound means no voucher with that id in the organization.
var ErrVoucherNotFound = errors.New("enakgame: voucher not found")
// voucherCodeImportBatch is how many codes one insert writes.
const voucherCodeImportBatch = 1000
// VoucherFilter selects an organization's vouchers.
type VoucherFilter struct {
OrganizationID uuid.UUID
// Empty for every status.
Statuses []string
Search string
Offset int
Limit int
}
// VoucherCodeImport is one code to add to a pool.
type VoucherCodeImport struct {
Code string
ExpiresAt *time.Time
}
// CatalogVoucher is a voucher a customer can redeem now, with how many are left. Nil
// Available means no counted stock.
type CatalogVoucher struct {
entities.Voucher
Available *int64
}
// VoucherRepository stores EnakGame vouchers and their codes (docs/rfc-enakgame.md
// §5.7), always scoped to an organization.
type VoucherRepository interface {
CreateVoucher(ctx context.Context, voucher *entities.Voucher) error
GetVoucher(ctx context.Context, organizationID, id uuid.UUID) (*entities.Voucher, error)
// LockVoucher is GetVoucher with the row locked until the transaction ends.
LockVoucher(ctx context.Context, organizationID, id uuid.UUID) (*entities.Voucher, error)
ListVouchers(ctx context.Context, filter VoucherFilter) ([]entities.Voucher, int64, error)
// UpdateVoucher stores everything but the organization, the stock mode and the
// status.
UpdateVoucher(ctx context.Context, voucher *entities.Voucher) error
SetVoucherStatus(ctx context.Context, organizationID, id uuid.UUID, status string) error
// TakeStock takes one from a STATIC voucher's stock, and reports false when none is
// left.
TakeStock(ctx context.Context, voucherID uuid.UUID) (bool, error)
// ImportCodes adds codes to a pool and returns those it added; a code the pool
// already holds is skipped.
ImportCodes(ctx context.Context, voucherID uuid.UUID, codes []VoucherCodeImport) ([]string, error)
// CountCodes returns how many codes of a pool are in each status.
CountCodes(ctx context.Context, voucherID uuid.UUID) (map[string]int64, error)
// ListCodes returns a page of a pool's codes, oldest first, and the total.
ListCodes(ctx context.Context, voucherID uuid.UUID, status string, offset, limit int) ([]entities.VoucherCode, int64, error)
GetCode(ctx context.Context, id uuid.UUID) (*entities.VoucherCode, error)
// ClaimCode gives the oldest available, unexpired code of a pool to a redemption,
// skipping codes other redemptions hold at the moment, so two redemptions at once
// get different codes. It returns nil when none is left.
ClaimCode(ctx context.Context, voucherID, redemptionID uuid.UUID, now time.Time) (*entities.VoucherCode, error)
// ExpireCodes moves at most limit available codes past their expiry to EXPIRED
// and returns how many.
ExpireCodes(ctx context.Context, now time.Time, limit int) (int64, error)
// ListCatalog returns the vouchers a customer of the organization can redeem now:
// ACTIVE, within their dates, in a stock mode that can be redeemed.
ListCatalog(ctx context.Context, organizationID uuid.UUID, now time.Time, stockModes []string) ([]CatalogVoucher, error)
}
type voucherRepository struct {
db *gorm.DB
}
func NewVoucherRepository(db *gorm.DB) VoucherRepository {
return &voucherRepository{db: db}
}
func (r *voucherRepository) CreateVoucher(ctx context.Context, voucher *entities.Voucher) error {
if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(voucher).Error; err != nil {
return fmt.Errorf("failed to create voucher: %w", err)
}
return nil
}
func (r *voucherRepository) getVoucher(ctx context.Context, organizationID, id uuid.UUID, lock bool) (*entities.Voucher, error) {
q := DBFromContext(ctx, r.db).WithContext(ctx).Where("organization_id = ? AND id = ?", organizationID, id)
if lock {
q = q.Clauses(clause.Locking{Strength: "UPDATE"})
}
var voucher entities.Voucher
if err := q.First(&voucher).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrVoucherNotFound
}
return nil, fmt.Errorf("failed to read voucher: %w", err)
}
return &voucher, nil
}
func (r *voucherRepository) GetVoucher(ctx context.Context, organizationID, id uuid.UUID) (*entities.Voucher, error) {
return r.getVoucher(ctx, organizationID, id, false)
}
func (r *voucherRepository) LockVoucher(ctx context.Context, organizationID, id uuid.UUID) (*entities.Voucher, error) {
return r.getVoucher(ctx, organizationID, id, true)
}
func (r *voucherRepository) ListVouchers(ctx context.Context, filter VoucherFilter) ([]entities.Voucher, int64, error) {
q := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.Voucher{}).Where("organization_id = ?", filter.OrganizationID)
if len(filter.Statuses) > 0 {
q = q.Where("status IN ?", filter.Statuses)
}
if filter.Search != "" {
q = q.Where("name ILIKE ?", "%"+filter.Search+"%")
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("failed to count vouchers: %w", err)
}
var vouchers []entities.Voucher
if err := q.Order("created_at DESC, id").Offset(filter.Offset).Limit(filter.Limit).Find(&vouchers).Error; err != nil {
return nil, 0, fmt.Errorf("failed to list vouchers: %w", err)
}
return vouchers, total, nil
}
func (r *voucherRepository) UpdateVoucher(ctx context.Context, v *entities.Voucher) error {
terms := v.Terms
if len(terms) == 0 {
terms = entities.JSONDocument(`{}`)
}
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
UPDATE vouchers SET name = ?, description = ?, image_url = ?, voucher_type = ?, face_value = ?,
point_cost = ?, business_cost = ?, stock = ?, provider = ?, provider_ref = ?, max_per_customer = ?,
valid_from = ?, valid_until = ?, terms = ?::jsonb, updated_at = NOW()
WHERE organization_id = ? AND id = ?`,
v.Name, v.Description, v.ImageURL, v.VoucherType, v.FaceValue, v.PointCost, v.BusinessCost, v.Stock,
v.Provider, v.ProviderRef, v.MaxPerCustomer, v.ValidFrom, v.ValidUntil, terms, v.OrganizationID, v.ID)
if result.Error != nil {
return fmt.Errorf("failed to update voucher: %w", result.Error)
}
if result.RowsAffected == 0 {
return ErrVoucherNotFound
}
return nil
}
func (r *voucherRepository) SetVoucherStatus(ctx context.Context, organizationID, id uuid.UUID, status string) error {
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
UPDATE vouchers SET status = ?, updated_at = NOW() WHERE organization_id = ? AND id = ?`, status, organizationID, id)
if result.Error != nil {
return fmt.Errorf("failed to change voucher status: %w", result.Error)
}
if result.RowsAffected == 0 {
return ErrVoucherNotFound
}
return nil
}
func (r *voucherRepository) TakeStock(ctx context.Context, voucherID uuid.UUID) (bool, error) {
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
UPDATE vouchers SET stock = stock - 1, updated_at = NOW() WHERE id = ? AND stock > 0`, voucherID)
if result.Error != nil {
return false, fmt.Errorf("failed to take voucher stock: %w", result.Error)
}
return result.RowsAffected == 1, nil
}
func (r *voucherRepository) ImportCodes(ctx context.Context, voucherID uuid.UUID, codes []VoucherCodeImport) ([]string, error) {
db := DBFromContext(ctx, r.db).WithContext(ctx)
var added []string
for start := 0; start < len(codes); start += voucherCodeImportBatch {
end := start + voucherCodeImportBatch
if end > len(codes) {
end = len(codes)
}
rows := make([]entities.VoucherCode, 0, end-start)
for _, c := range codes[start:end] {
rows = append(rows, entities.VoucherCode{
ID: uuid.New(), VoucherID: voucherID, Code: c.Code, Status: constants.VoucherCodeAvailable, ExpiresAt: c.ExpiresAt,
})
}
err := db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "voucher_id"}, {Name: "code"}}, DoNothing: true}).
Create(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to import voucher codes: %w", err)
}
// A skipped code kept the row of the pool's earlier copy, so only the new ids
// are in the table.
ids := make([]uuid.UUID, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.ID)
}
var created []string
if err := db.Model(&entities.VoucherCode{}).Where("id IN ?", ids).Order("code").Pluck("code", &created).Error; err != nil {
return nil, fmt.Errorf("failed to read imported voucher codes: %w", err)
}
added = append(added, created...)
}
return added, nil
}
func (r *voucherRepository) CountCodes(ctx context.Context, voucherID uuid.UUID) (map[string]int64, error) {
var rows []struct {
Status string
Count int64
}
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT status, COUNT(*) AS count FROM voucher_codes WHERE voucher_id = ? GROUP BY status`, voucherID).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to count voucher codes: %w", err)
}
counts := map[string]int64{}
for _, row := range rows {
counts[row.Status] = row.Count
}
return counts, nil
}
func (r *voucherRepository) ListCodes(ctx context.Context, voucherID uuid.UUID, status string, offset, limit int) ([]entities.VoucherCode, int64, error) {
q := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.VoucherCode{}).Where("voucher_id = ?", voucherID)
if status != "" {
q = q.Where("status = ?", status)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("failed to count voucher codes: %w", err)
}
var codes []entities.VoucherCode
if err := q.Order("created_at, code").Offset(offset).Limit(limit).Find(&codes).Error; err != nil {
return nil, 0, fmt.Errorf("failed to list voucher codes: %w", err)
}
return codes, total, nil
}
func (r *voucherRepository) GetCode(ctx context.Context, id uuid.UUID) (*entities.VoucherCode, error) {
var code entities.VoucherCode
if err := DBFromContext(ctx, r.db).WithContext(ctx).Where("id = ?", id).First(&code).Error; err != nil {
return nil, fmt.Errorf("failed to read voucher code: %w", err)
}
return &code, nil
}
func (r *voucherRepository) ClaimCode(ctx context.Context, voucherID, redemptionID uuid.UUID, now time.Time) (*entities.VoucherCode, error) {
var claimed []entities.VoucherCode
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
UPDATE voucher_codes SET status = ?, redemption_id = ?, updated_at = NOW()
WHERE id = (
SELECT id FROM voucher_codes
WHERE voucher_id = ? AND status = ? AND (expires_at IS NULL OR expires_at > ?)
ORDER BY created_at, id
LIMIT 1
FOR UPDATE SKIP LOCKED)
RETURNING *`,
constants.VoucherCodeRedeemed, redemptionID, voucherID, constants.VoucherCodeAvailable, now).Scan(&claimed).Error
if err != nil {
return nil, fmt.Errorf("failed to claim voucher code: %w", err)
}
if len(claimed) == 0 {
return nil, nil
}
return &claimed[0], nil
}
func (r *voucherRepository) ExpireCodes(ctx context.Context, now time.Time, limit int) (int64, error) {
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
UPDATE voucher_codes SET status = ?, updated_at = NOW()
WHERE id IN (
SELECT id FROM voucher_codes
WHERE status = ? AND expires_at IS NOT NULL AND expires_at <= ?
ORDER BY expires_at
LIMIT ?
FOR UPDATE SKIP LOCKED)`,
constants.VoucherCodeExpired, constants.VoucherCodeAvailable, now, limit)
if result.Error != nil {
return 0, fmt.Errorf("failed to expire voucher codes: %w", result.Error)
}
return result.RowsAffected, nil
}
func (r *voucherRepository) ListCatalog(ctx context.Context, organizationID uuid.UUID, now time.Time, stockModes []string) ([]CatalogVoucher, error) {
db := DBFromContext(ctx, r.db).WithContext(ctx)
var vouchers []entities.Voucher
err := db.Where(`organization_id = ? AND status = ? AND stock_mode IN ?
AND (valid_from IS NULL OR valid_from <= ?) AND (valid_until IS NULL OR valid_until > ?)`,
organizationID, constants.VoucherStatusActive, stockModes, now, now).
Order("point_cost, name, id").Find(&vouchers).Error
if err != nil {
return nil, fmt.Errorf("failed to list voucher catalog: %w", err)
}
var pools []uuid.UUID
for _, v := range vouchers {
if v.StockMode == constants.VoucherStockCodePool {
pools = append(pools, v.ID)
}
}
available := map[uuid.UUID]int64{}
if len(pools) > 0 {
var rows []struct {
VoucherID string
Count int64
}
err := db.Raw(`
SELECT voucher_id::text AS voucher_id, COUNT(*) AS count FROM voucher_codes
WHERE voucher_id IN ? AND status = ? AND (expires_at IS NULL OR expires_at > ?)
GROUP BY voucher_id`, pools, constants.VoucherCodeAvailable, now).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to count available voucher codes: %w", err)
}
for _, row := range rows {
id, _ := uuid.Parse(row.VoucherID)
available[id] = row.Count
}
}
out := make([]CatalogVoucher, 0, len(vouchers))
for _, v := range vouchers {
c := CatalogVoucher{Voucher: v}
switch v.StockMode {
case constants.VoucherStockStatic:
c.Available = v.Stock
case constants.VoucherStockCodePool:
n := available[v.ID]
c.Available = &n
}
out = append(out, c)
}
return out, nil
}
@@ -0,0 +1,70 @@
package repository
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// EG-501: what the voucher tables refuse (docs/rfc-enakgame.md §5.7). Needs
// TEST_DATABASE_URL; see wallet_repository_test.go.
func TestVoucherSchema_RejectsWhatTheRFCForbids(t *testing.T) {
db := walletTestDB(t)
org, customer := uuid.New(), uuid.New()
require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'voucher test', 'basic')`, org).Error)
require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'v')`, customer, org).Error)
t.Cleanup(func() {
db.Exec(`DELETE FROM voucher_redemptions WHERE organization_id = ?`, org)
db.Exec(`DELETE FROM voucher_codes WHERE voucher_id IN (SELECT id FROM vouchers WHERE organization_id = ?)`, org)
db.Exec(`DELETE FROM vouchers WHERE organization_id = ?`, org)
db.Exec(`DELETE FROM wallet_transactions WHERE organization_id = ?`, org)
db.Exec(`DELETE FROM customers WHERE id = ?`, customer)
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
})
voucher := func(mode string, stock any, provider any) error {
return db.Exec(`INSERT INTO vouchers (organization_id, name, voucher_type, face_value, point_cost, stock_mode, stock, provider)
VALUES (?, 'v', 'FIXED_VALUE', 10000, 8000, ?, ?, ?)`, org, mode, stock, provider).Error
}
rejected := func(t *testing.T, err error, constraint string) {
t.Helper()
require.Error(t, err)
assert.Contains(t, err.Error(), constraint)
}
rejected(t, voucher("STATIC", nil, nil), "chk_vouchers_static_stock")
rejected(t, voucher("CODE_POOL", 5, nil), "chk_vouchers_static_stock")
rejected(t, voucher("EXTERNAL", nil, nil), "chk_vouchers_external_provider")
rejected(t, voucher("STATIC", 5, "acme"), "chk_vouchers_external_provider")
rejected(t, voucher("STATIC", -1, nil), "chk_vouchers_stock")
require.NoError(t, voucher("STATIC", 5, nil))
require.NoError(t, voucher("EXTERNAL", nil, "acme"))
poolID := uuid.New()
require.NoError(t, db.Exec(`INSERT INTO vouchers (id, organization_id, name, voucher_type, face_value, point_cost, stock_mode)
VALUES (?, ?, 'pool', 'FIXED_VALUE', 10000, 8000, 'CODE_POOL')`, poolID, org).Error)
rejected(t, db.Exec(`INSERT INTO voucher_codes (voucher_id, code, status) VALUES (?, 'A', 'REDEEMED')`, poolID).Error, "chk_voucher_codes_redemption")
rejected(t, db.Exec(`INSERT INTO voucher_codes (voucher_id, code, status, redemption_id) VALUES (?, 'B', 'AVAILABLE', ?)`, poolID, uuid.New()).Error, "chk_voucher_codes_redemption")
require.NoError(t, db.Exec(`INSERT INTO voucher_codes (voucher_id, code) VALUES (?, 'C')`, poolID).Error)
rejected(t, db.Exec(`INSERT INTO voucher_codes (voucher_id, code) VALUES (?, 'C')`, poolID).Error, "uq_voucher_codes_code")
debit := func() uuid.UUID {
id := uuid.New()
require.NoError(t, db.Exec(`INSERT INTO wallet_transactions (id, organization_id, customer_id, currency, type, amount, balance_after, reference_type, reference_id, description)
VALUES (?, ?, ?, 'POINT', 'REWARD_REDEEM', -8000, 0, 'REWARD_REDEMPTION', ?, 'x')`, id, org, customer, uuid.New()).Error)
return id
}
redemption := func(key string, status string, completed bool) error {
var completedAt any
if completed {
completedAt = "2026-10-07T00:00:00Z"
}
return db.Exec(`INSERT INTO voucher_redemptions (organization_id, customer_id, voucher_id, idempotency_key, status, face_value, point_cost, debit_transaction_id, completed_at)
VALUES (?, ?, ?, ?, ?, 10000, 8000, ?, ?)`, org, customer, poolID, key, status, debit(), completedAt).Error
}
require.NoError(t, redemption("k1", "COMPLETED", true))
rejected(t, redemption("k1", "COMPLETED", true), "uq_voucher_redemptions_key")
rejected(t, redemption("k2", "FAILED", false), "chk_voucher_redemptions_refund")
rejected(t, redemption("k3", "COMPLETED", false), "chk_voucher_redemptions_completed")
}