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:
co-authored by
Claude Opus 5.5
parent
2c9753fae7
commit
798a36bd6c
@@ -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
|
||||
}
|
||||
@@ -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(¤t).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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user