feat(enakgame): game sessions, rewards, vouchers, budgets and events

EnakGame phases 1-8 of docs/tasks-enakgame.md (EG-101 to EG-803), built on the
existing EnakPoint/EnakCoin wallet (docs/rfc-enakgame.md).

Foundation (phase 1)
- Migrations 000103-000106: games extended with organization, slug, status,
  entry cost and result rules, old games archived (not deleted); budgets,
  versioned reward configs, sessions and session rewards; the ledger types
  GAME_SPEND_REFUND, GAME_REWARD and REWARD_REDEEM_REFUND; audit_logs.
- AuditLogger writes in the caller's transaction only.
- enakgame.limit.user_daily and global_daily organization settings.

Games and sessions (phases 2-4)
- Admin /marketing/enakgame: games, reward config versions (immutable but for
  status, one ACTIVE per game), budgets with non-overlapping global periods and
  a daily job opening the next month.
- Customer /customer/enakgame: start (Idempotency-Key, entry cost and config
  frozen on the session), complete (result validation, reward engine, max_reward
  cap, daily limits via game_reward_counters, one GAME_REWARD per budget),
  automatic refunds for system errors and deactivated games, and a session job.
- Reward engine: FIXED, SCORE_BASED, OUTCOME_BASED, PROBABILITY (crypto/rand),
  rounded down.

Vouchers and budgets (phases 5-6)
- Migration 000108 and 000107: vouchers, codes, redemptions, cost attribution;
  Economy Guard counters.
- STATIC and CODE_POOL redemption in one transaction with the REDEEM PIN action;
  realized cost traced through the lots to the budget that paid the reward.
- Budget metrics: realized cost, forecast, exposure and status. Migrations
  000109-000110 add the wallet_lots indexes they need, built CONCURRENTLY.

Events (phase 7)
- Migration 000111: game events, each with its own EVENT budget. Event extras
  stack per PRD §16 defaults, with event and per-customer limits.

External vouchers (phase 8)
- VoucherProvider contract, two-step PENDING redemption and a recovery job,
  tested with a fake provider. No provider adapter is registered yet, so
  EXTERNAL vouchers stay out of the catalog.

Not yet decided before release: reward rounding, event stacking, budget
exhaustion policy and thresholds (RFC §19.2). Migrations 000103-000111 have
not been run on any shared database.

Also fixes a leftover PAYMENT filter in a wallet test and a data race in a
test PIN fake.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-10-07 20:53:14 +07:00
co-authored by Claude Opus 5.5
parent 2c9753fae7
commit 798a36bd6c
92 changed files with 12392 additions and 23 deletions
@@ -0,0 +1,19 @@
-- The old games stay inactive: the up migration does not keep their is_active, and
-- their coin cost is still in metadata.coin_cost.
DROP INDEX IF EXISTS idx_games_org_status;
DROP INDEX IF EXISTS uq_games_org_slug;
ALTER TABLE games
DROP CONSTRAINT IF EXISTS chk_games_enakgame_identity,
DROP CONSTRAINT IF EXISTS chk_games_entry_cost,
DROP COLUMN IF EXISTS result_rules,
DROP COLUMN IF EXISTS session_ttl_seconds,
DROP COLUMN IF EXISTS entry_cost,
DROP COLUMN IF EXISTS status,
DROP COLUMN IF EXISTS version,
DROP COLUMN IF EXISTS game_url,
DROP COLUMN IF EXISTS thumbnail_url,
DROP COLUMN IF EXISTS description,
DROP COLUMN IF EXISTS slug,
DROP COLUMN IF EXISTS organization_id;
@@ -0,0 +1,42 @@
-- EnakGame game catalog (docs/rfc-enakgame.md §5.1). games is extended rather than
-- replaced, because the customer app already reads it.
ALTER TABLE games
ADD COLUMN organization_id UUID,
ADD COLUMN slug VARCHAR(100),
ADD COLUMN description TEXT,
ADD COLUMN thumbnail_url VARCHAR(500),
ADD COLUMN game_url VARCHAR(500),
ADD COLUMN version VARCHAR(50),
ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE'
CONSTRAINT chk_games_status CHECK (status IN ('DRAFT', 'ACTIVE', 'INACTIVE', 'ARCHIVED')),
ADD COLUMN entry_cost BIGINT,
ADD COLUMN session_ttl_seconds INT NOT NULL DEFAULT 600
CONSTRAINT chk_games_session_ttl_seconds CHECK (session_ttl_seconds > 0),
-- Limits the Result Validator checks a result against: max_score,
-- min_duration_seconds, max_score_per_second, the valid outcomes, and the game's
-- daily reward limit (§5.10, §7.2).
ADD COLUMN result_rules JSONB NOT NULL DEFAULT '{}';
-- The old games leave the product: archived, never deleted (§14). game_plays.game_id
-- is ON DELETE CASCADE, and the old GAME_SPEND ledger rows point at game_plays.
-- Their coin cost becomes the entry cost; one that is missing, not a whole number or
-- below 1 becomes 1, so the CHECK below holds.
UPDATE games SET
status = 'ARCHIVED',
is_active = FALSE,
entry_cost = CASE
WHEN metadata->>'coin_cost' ~ '^[0-9]{1,18}$' THEN GREATEST((metadata->>'coin_cost')::BIGINT, 1)
ELSE 1 END;
ALTER TABLE games
ALTER COLUMN entry_cost SET NOT NULL,
-- PRD §10.1: no game is free.
ADD CONSTRAINT chk_games_entry_cost CHECK (entry_cost >= 1),
-- Every EnakGame game has an organization and a slug. Only the archived old games
-- may lack them.
ADD CONSTRAINT chk_games_enakgame_identity CHECK (
status = 'ARCHIVED' OR (organization_id IS NOT NULL AND slug IS NOT NULL));
CREATE UNIQUE INDEX uq_games_org_slug ON games(organization_id, slug) WHERE slug IS NOT NULL;
CREATE INDEX idx_games_org_status ON games(organization_id, status);
@@ -0,0 +1,4 @@
DROP TABLE IF EXISTS game_session_rewards;
DROP TABLE IF EXISTS game_sessions;
DROP TABLE IF EXISTS game_reward_configs;
DROP TABLE IF EXISTS game_budgets;
@@ -0,0 +1,120 @@
-- EnakGame budgets, reward configurations and sessions (docs/rfc-enakgame.md §5.2,
-- §5.3, §5.4, §5.6), in foreign key order.
-- What an organization may spend on EnakGame rewards (§5.6). The global budget pays for
-- base rewards, one per period; an event budget pays for what its event adds.
CREATE TABLE game_budgets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
scope VARCHAR(20) NOT NULL,
name VARCHAR(255) NOT NULL,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
-- Rupiah.
amount BIGINT NOT NULL,
-- {"warning": 70, "critical": 90}: percent of utilization or forecast (PRD §8, §32).
thresholds JSONB NOT NULL DEFAULT '{}',
-- PRD §34, pending a decision (RFC §19.2).
exhaustion_policy VARCHAR(30),
created_by UUID NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT chk_game_budgets_scope CHECK (scope IN ('GLOBAL', 'EVENT')),
CONSTRAINT chk_game_budgets_amount CHECK (amount > 0),
CONSTRAINT chk_game_budgets_period CHECK (period_end >= period_start)
);
-- Global budgets of an organization do not overlap: one row per period.
CREATE UNIQUE INDEX uq_game_budgets_global_period
ON game_budgets(organization_id, period_start) WHERE scope = 'GLOBAL';
CREATE INDEX idx_game_budgets_org_scope ON game_budgets(organization_id, scope, period_start DESC);
-- How a game computes its base reward (§5.2, §8). Immutable apart from status (D7): a
-- change is a new row with the next version, and a session keeps the one it started
-- with.
CREATE TABLE game_reward_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
game_id UUID NOT NULL REFERENCES games(id) ON DELETE RESTRICT,
version INT NOT NULL,
reward_type VARCHAR(30) NOT NULL,
-- Shape per reward_type in §8.
rules JSONB NOT NULL,
max_reward BIGINT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
effective_at TIMESTAMP WITH TIME ZONE,
created_by UUID NOT NULL,
reason VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT uq_game_reward_configs_version UNIQUE (game_id, version),
CONSTRAINT chk_game_reward_configs_version CHECK (version >= 1),
CONSTRAINT chk_game_reward_configs_reward_type
CHECK (reward_type IN ('FIXED', 'SCORE_BASED', 'OUTCOME_BASED', 'PROBABILITY')),
CONSTRAINT chk_game_reward_configs_max_reward CHECK (max_reward >= 0),
CONSTRAINT chk_game_reward_configs_status CHECK (status IN ('DRAFT', 'ACTIVE', 'RETIRED'))
);
-- One active configuration per game.
CREATE UNIQUE INDEX uq_game_reward_configs_active
ON game_reward_configs(game_id) WHERE status = 'ACTIVE';
-- One play of a game by a customer (§5.3). STARTED → COMPLETED | REFUNDED | EXPIRED,
-- each move an UPDATE ... WHERE status = 'STARTED' (D4).
CREATE TABLE game_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
game_id UUID NOT NULL REFERENCES games(id) ON DELETE RESTRICT,
-- Snapshots taken at start (D7, P3).
reward_config_id UUID NOT NULL REFERENCES game_reward_configs(id),
entry_cost BIGINT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'STARTED',
started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
ended_at TIMESTAMP WITH TIME ZONE,
-- The result the client sent: data only, never a reward amount (P1).
result JSONB,
-- Validation and calculation: base, event modifiers, guard caps, rejection reasons,
-- RNG roll.
reward_breakdown JSONB,
reward_total BIGINT NOT NULL DEFAULT 0,
flagged BOOLEAN NOT NULL DEFAULT FALSE,
spend_transaction_id UUID NOT NULL REFERENCES wallet_transactions(id),
refund_transaction_id UUID REFERENCES wallet_transactions(id),
refund_reason VARCHAR(30),
-- Set, in a transaction of its own, when completing failed on a system error (§7.3).
completion_failed_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT uq_game_sessions_spend_transaction UNIQUE (spend_transaction_id),
CONSTRAINT chk_game_sessions_entry_cost CHECK (entry_cost >= 1),
CONSTRAINT chk_game_sessions_status CHECK (status IN ('STARTED', 'COMPLETED', 'REFUNDED', 'EXPIRED')),
CONSTRAINT chk_game_sessions_reward_total CHECK (reward_total >= 0),
CONSTRAINT chk_game_sessions_refund_reason CHECK (refund_reason IN ('SYSTEM_ERROR', 'GAME_DEACTIVATED')),
CONSTRAINT chk_game_sessions_refund CHECK (
(status = 'REFUNDED') = (refund_transaction_id IS NOT NULL AND refund_reason IS NOT NULL))
);
CREATE INDEX idx_game_sessions_customer ON game_sessions(customer_id, started_at DESC);
CREATE INDEX idx_game_sessions_open ON game_sessions(expires_at) WHERE status = 'STARTED';
CREATE INDEX idx_game_sessions_game_open ON game_sessions(game_id) WHERE status = 'STARTED';
-- One row per budget paying for a session's reward (D6), each with its own ledger row.
CREATE TABLE game_session_rewards (
session_id UUID NOT NULL REFERENCES game_sessions(id),
budget_id UUID NOT NULL REFERENCES game_budgets(id),
amount BIGINT NOT NULL,
wallet_transaction_id UUID NOT NULL REFERENCES wallet_transactions(id),
PRIMARY KEY (session_id, budget_id),
CONSTRAINT uq_game_session_rewards_wallet_transaction UNIQUE (wallet_transaction_id),
CONSTRAINT chk_game_session_rewards_amount CHECK (amount > 0)
);
CREATE INDEX idx_game_session_rewards_budget ON game_session_rewards(budget_id);
@@ -0,0 +1,16 @@
-- The restored constraints do not mention the EnakGame types, so rows of those types
-- stay valid; only the engine stops writing them.
ALTER TABLE wallet_transactions
DROP CONSTRAINT chk_wallet_transactions_point_only_types,
DROP CONSTRAINT chk_wallet_transactions_coin_only_types,
DROP CONSTRAINT chk_wallet_transactions_reversal_source;
ALTER TABLE wallet_transactions
ADD CONSTRAINT chk_wallet_transactions_point_only_types CHECK (
type NOT IN ('PAYMENT', 'PAYMENT_REFUND', 'EXCHANGE_IN', 'REWARD_REDEEM')
OR currency = 'POINT'),
ADD CONSTRAINT chk_wallet_transactions_coin_only_types CHECK (
type NOT IN ('EXCHANGE_OUT', 'GAME_SPEND') OR currency = 'COIN'),
ADD CONSTRAINT chk_wallet_transactions_reversal_source CHECK (
type NOT IN ('EARN_REVERSAL', 'PAYMENT_REFUND') OR reverses_transaction_id IS NOT NULL);
@@ -0,0 +1,19 @@
-- EnakGame ledger types (docs/rfc-enakgame.md §6). Must match walletTypeRules in
-- internal/processor/wallet_processor.go. PAYMENT and PAYMENT_REFUND stay allowed here
-- although the engine no longer writes them (000102).
ALTER TABLE wallet_transactions
DROP CONSTRAINT chk_wallet_transactions_point_only_types,
DROP CONSTRAINT chk_wallet_transactions_coin_only_types,
DROP CONSTRAINT chk_wallet_transactions_reversal_source;
ALTER TABLE wallet_transactions
ADD CONSTRAINT chk_wallet_transactions_point_only_types CHECK (
type NOT IN ('PAYMENT', 'PAYMENT_REFUND', 'EXCHANGE_IN', 'REWARD_REDEEM', 'REWARD_REDEEM_REFUND')
OR currency = 'POINT'),
ADD CONSTRAINT chk_wallet_transactions_coin_only_types CHECK (
type NOT IN ('EXCHANGE_OUT', 'GAME_SPEND', 'GAME_SPEND_REFUND', 'GAME_REWARD')
OR currency = 'COIN'),
ADD CONSTRAINT chk_wallet_transactions_reversal_source CHECK (
type NOT IN ('EARN_REVERSAL', 'PAYMENT_REFUND', 'GAME_SPEND_REFUND', 'REWARD_REDEEM_REFUND')
OR reverses_transaction_id IS NOT NULL);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS audit_logs;
@@ -0,0 +1,25 @@
-- Who changed what in EnakGame, from what, to what (docs/rfc-enakgame.md §5.9, §13).
-- Written in the same transaction as the change it records, so no change commits
-- without its row. Append-only.
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
actor_type VARCHAR(20) NOT NULL,
-- The admin for USER; NULL for SYSTEM.
actor_id UUID,
entity_type VARCHAR(50) NOT NULL,
entity_id UUID NOT NULL,
action VARCHAR(50) NOT NULL,
before JSONB,
after JSONB,
reason VARCHAR(255),
-- 'admin_api', 'budget_controller', 'session_job', ...
source VARCHAR(50) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT chk_audit_logs_actor_type CHECK (actor_type IN ('USER', 'SYSTEM')),
CONSTRAINT chk_audit_logs_actor CHECK (actor_type = 'SYSTEM' OR actor_id IS NOT NULL)
);
CREATE INDEX idx_audit_logs_entity ON audit_logs(entity_type, entity_id, created_at DESC);
CREATE INDEX idx_audit_logs_organization ON audit_logs(organization_id, created_at DESC);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS game_reward_counters;
@@ -0,0 +1,16 @@
-- EnakCoin given out as EnakGame rewards per scope per day, in Asia/Jakarta: the
-- Economy Guard's counters (docs/rfc-enakgame.md §5.8, §9). A reward over a limit is
-- cut to what is left of it.
CREATE TABLE game_reward_counters (
organization_id UUID NOT NULL,
scope_type VARCHAR(20) NOT NULL,
-- The customer, game, event or organization the scope_type names.
scope_id UUID NOT NULL,
-- 0001-01-01 for an event's limit over its whole run (PRD §35 Event Limit).
day DATE NOT NULL,
amount BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (organization_id, scope_type, scope_id, day),
CONSTRAINT chk_game_reward_counters_scope_type CHECK (scope_type IN ('USER', 'GAME', 'EVENT', 'GLOBAL')),
CONSTRAINT chk_game_reward_counters_amount CHECK (amount >= 0)
);
@@ -0,0 +1,4 @@
DROP TABLE IF EXISTS voucher_redemption_costs;
DROP TABLE IF EXISTS voucher_redemptions;
DROP TABLE IF EXISTS voucher_codes;
DROP TABLE IF EXISTS vouchers;
@@ -0,0 +1,123 @@
-- EnakGame vouchers, what EnakPoint is redeemed for, and what each redemption cost
-- which budget (docs/rfc-enakgame.md §5.7, D5). Replaces the unused rewards table for
-- EnakGame; rewards itself stays.
CREATE TABLE vouchers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
image_url VARCHAR(500),
voucher_type VARCHAR(30) NOT NULL,
-- Rupiah: what a redemption costs, the basis of realized cost.
face_value BIGINT NOT NULL,
-- EnakPoint asked for it; may differ from face_value (PRD §22).
point_cost BIGINT NOT NULL,
-- Reporting only, never counted against a budget.
business_cost BIGINT,
stock_mode VARCHAR(20) NOT NULL,
-- STATIC only.
stock BIGINT,
-- EXTERNAL only.
provider VARCHAR(50),
provider_ref VARCHAR(255),
max_per_customer INT,
valid_from TIMESTAMP WITH TIME ZONE,
valid_until TIMESTAMP WITH TIME ZONE,
terms JSONB NOT NULL DEFAULT '{}',
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT chk_vouchers_voucher_type
CHECK (voucher_type IN ('FIXED_VALUE', 'PERCENTAGE', 'FREE_ITEM', 'MERCHANT_BENEFIT')),
CONSTRAINT chk_vouchers_face_value CHECK (face_value > 0),
CONSTRAINT chk_vouchers_point_cost CHECK (point_cost > 0),
CONSTRAINT chk_vouchers_business_cost CHECK (business_cost IS NULL OR business_cost >= 0),
CONSTRAINT chk_vouchers_stock_mode CHECK (stock_mode IN ('STATIC', 'CODE_POOL', 'EXTERNAL')),
CONSTRAINT chk_vouchers_stock CHECK (stock IS NULL OR stock >= 0),
CONSTRAINT chk_vouchers_static_stock CHECK ((stock_mode = 'STATIC') = (stock IS NOT NULL)),
CONSTRAINT chk_vouchers_external_provider CHECK ((stock_mode = 'EXTERNAL') = (provider IS NOT NULL)),
CONSTRAINT chk_vouchers_max_per_customer CHECK (max_per_customer IS NULL OR max_per_customer >= 1),
CONSTRAINT chk_vouchers_validity CHECK (valid_from IS NULL OR valid_until IS NULL OR valid_until > valid_from),
CONSTRAINT chk_vouchers_status CHECK (status IN ('DRAFT', 'ACTIVE', 'INACTIVE', 'ARCHIVED'))
);
CREATE INDEX idx_vouchers_org_status ON vouchers(organization_id, status);
-- The codes of a CODE_POOL voucher, one per redemption.
CREATE TABLE voucher_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
voucher_id UUID NOT NULL REFERENCES vouchers(id) ON DELETE RESTRICT,
code VARCHAR(255) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE',
-- No foreign key: the code is taken before its redemption row is written.
redemption_id UUID,
expires_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT uq_voucher_codes_code UNIQUE (voucher_id, code),
CONSTRAINT chk_voucher_codes_status
CHECK (status IN ('AVAILABLE', 'RESERVED', 'REDEEMED', 'EXPIRED', 'CANCELLED')),
CONSTRAINT chk_voucher_codes_redemption
CHECK ((status IN ('RESERVED', 'REDEEMED')) = (redemption_id IS NOT NULL))
);
CREATE INDEX idx_voucher_codes_available ON voucher_codes(voucher_id, created_at)
WHERE status = 'AVAILABLE';
CREATE INDEX idx_voucher_codes_expiring ON voucher_codes(expires_at)
WHERE status = 'AVAILABLE' AND expires_at IS NOT NULL;
-- One redemption of a voucher by a customer, with the voucher's numbers frozen (P3).
CREATE TABLE voucher_redemptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
voucher_id UUID NOT NULL REFERENCES vouchers(id),
idempotency_key VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL,
face_value BIGINT NOT NULL,
point_cost BIGINT NOT NULL,
voucher_code_id UUID REFERENCES voucher_codes(id),
external_code VARCHAR(255),
external_ref VARCHAR(255),
debit_transaction_id UUID NOT NULL REFERENCES wallet_transactions(id),
refund_transaction_id UUID REFERENCES wallet_transactions(id),
failure_reason VARCHAR(255),
attempts INT NOT NULL DEFAULT 0,
completed_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT uq_voucher_redemptions_key UNIQUE (customer_id, idempotency_key),
CONSTRAINT uq_voucher_redemptions_debit UNIQUE (debit_transaction_id),
CONSTRAINT chk_voucher_redemptions_status CHECK (status IN ('PENDING', 'COMPLETED', 'FAILED')),
CONSTRAINT chk_voucher_redemptions_refund CHECK ((status = 'FAILED') = (refund_transaction_id IS NOT NULL)),
CONSTRAINT chk_voucher_redemptions_completed CHECK ((status = 'COMPLETED') = (completed_at IS NOT NULL))
);
CREATE INDEX idx_voucher_redemptions_customer ON voucher_redemptions(customer_id, created_at DESC);
CREATE INDEX idx_voucher_redemptions_customer_voucher ON voucher_redemptions(customer_id, voucher_id);
CREATE INDEX idx_voucher_redemptions_pending ON voucher_redemptions(updated_at)
WHERE status = 'PENDING';
-- What a redemption cost each budget, frozen when it completes (D5).
CREATE TABLE voucher_redemption_costs (
redemption_id UUID NOT NULL REFERENCES voucher_redemptions(id),
-- NULL: EnakPoint that did not come from EnakGame (EARN, ADJUSTMENT, MIGRATION).
budget_id UUID REFERENCES game_budgets(id),
-- Ledger type of the root lot the EnakPoint came from.
source_type VARCHAR(30) NOT NULL,
points BIGINT NOT NULL,
-- Rupiah, a share of face_value.
cost BIGINT NOT NULL,
-- = completed_at, what puts the cost in a budget period.
recognized_at TIMESTAMP WITH TIME ZONE NOT NULL,
CONSTRAINT uq_voucher_redemption_costs UNIQUE (redemption_id, budget_id, source_type),
CONSTRAINT chk_voucher_redemption_costs_points CHECK (points > 0),
CONSTRAINT chk_voucher_redemption_costs_cost CHECK (cost >= 0)
);
CREATE INDEX idx_voucher_redemption_costs_budget ON voucher_redemption_costs(budget_id, recognized_at);
@@ -0,0 +1 @@
DROP INDEX CONCURRENTLY IF EXISTS idx_wallet_lots_origin_lot_id;
@@ -0,0 +1,4 @@
-- Walks from a lot to the lots carried over from it (transfers, exchanges, refunds),
-- for the exposure of an EnakGame budget (docs/rfc-enakgame.md §10). CONCURRENTLY
-- keeps wallet writes going while it builds, so this file holds one statement.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_wallet_lots_origin_lot_id ON wallet_lots(origin_lot_id) WHERE origin_lot_id IS NOT NULL;
@@ -0,0 +1 @@
DROP INDEX CONCURRENTLY IF EXISTS idx_wallet_lots_source_transaction_id;
@@ -0,0 +1,3 @@
-- Finds the lots a ledger row created, such as the lot of an EnakGame reward
-- (docs/rfc-enakgame.md §10). One statement, for CONCURRENTLY.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_wallet_lots_source_transaction_id ON wallet_lots(source_transaction_id);
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS game_event_games;
DROP TABLE IF EXISTS game_events;
@@ -0,0 +1,47 @@
-- EnakGame events, also called campaigns (docs/rfc-enakgame.md §5.5, D6): a period in
-- which some games pay more, the extra paid by the event's own budget.
CREATE TABLE game_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) NOT NULL,
description TEXT,
banner_url VARCHAR(500),
start_at TIMESTAMP WITH TIME ZONE NOT NULL,
end_at TIMESTAMP WITH TIME ZONE NOT NULL,
-- Shown with the dates; start_at and end_at are instants already.
timezone VARCHAR(50) NOT NULL DEFAULT 'Asia/Jakarta',
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
-- Higher goes first; when the reward cap cuts, the lowest goes first.
priority INT NOT NULL DEFAULT 0,
-- Applied to the base reward: 2.00 adds the base once more.
multiplier NUMERIC(5,2),
-- Added on top, per completed session.
bonus BIGINT,
-- Pays what this event adds. A budget of scope EVENT, checked by the processor.
budget_id UUID NOT NULL REFERENCES game_budgets(id),
-- EnakCoin this event may add over its whole run (PRD §35 Event Limit).
reward_limit BIGINT,
-- EnakCoin this event may add for one customer per day.
user_daily_limit BIGINT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT uq_game_events_slug UNIQUE (organization_id, slug),
CONSTRAINT chk_game_events_period CHECK (end_at > start_at),
CONSTRAINT chk_game_events_status CHECK (status IN ('DRAFT', 'ACTIVE', 'ENDED', 'CANCELLED')),
CONSTRAINT chk_game_events_multiplier CHECK (multiplier IS NULL OR multiplier > 0),
CONSTRAINT chk_game_events_bonus CHECK (bonus IS NULL OR bonus > 0),
CONSTRAINT chk_game_events_reward_limit CHECK (reward_limit IS NULL OR reward_limit > 0),
CONSTRAINT chk_game_events_user_daily_limit CHECK (user_daily_limit IS NULL OR user_daily_limit > 0)
);
CREATE INDEX idx_game_events_org_active ON game_events(organization_id, start_at, end_at) WHERE status = 'ACTIVE';
CREATE TABLE game_event_games (
event_id UUID NOT NULL REFERENCES game_events(id) ON DELETE CASCADE,
game_id UUID NOT NULL REFERENCES games(id) ON DELETE RESTRICT,
PRIMARY KEY (event_id, game_id)
);
CREATE INDEX idx_game_event_games_game ON game_event_games(game_id);