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,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);
|
||||
Reference in New Issue
Block a user