diff --git a/migrations/000090_create_wallet_tables.down.sql b/migrations/000090_create_wallet_tables.down.sql new file mode 100644 index 0000000..2369658 --- /dev/null +++ b/migrations/000090_create_wallet_tables.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS wallet_lot_allocations; +DROP TABLE IF EXISTS wallet_lots; +DROP TABLE IF EXISTS wallet_transactions; +DROP TABLE IF EXISTS customer_wallets; diff --git a/migrations/000090_create_wallet_tables.up.sql b/migrations/000090_create_wallet_tables.up.sql new file mode 100644 index 0000000..4dcee08 --- /dev/null +++ b/migrations/000090_create_wallet_tables.up.sql @@ -0,0 +1,131 @@ +-- EnakPoint & EnakCoin wallet (docs/prd-point-coin.md §8). Replaces customer_points +-- and customer_tokens; the old tables stay until their data is migrated (PC-105). +-- +-- Balances are only ever changed together with a ledger row, in one transaction, and +-- every ledger row must name where the value came from or went to (K5). The CHECKs +-- below enforce that at the database so a bug in the application cannot skip it. + +-- One row per customer. Besides holding the balances, this row is the lock every +-- wallet operation for the customer takes first (SELECT ... FOR UPDATE), so +-- concurrent operations on the same customer queue up instead of spending twice. +CREATE TABLE customer_wallets ( + customer_id UUID PRIMARY KEY REFERENCES customers(id) ON DELETE RESTRICT, + organization_id UUID NOT NULL REFERENCES organizations(id), + point_balance BIGINT NOT NULL DEFAULT 0, + coin_balance BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + CONSTRAINT chk_customer_wallets_point_balance CHECK (point_balance >= 0), + CONSTRAINT chk_customer_wallets_coin_balance CHECK (coin_balance >= 0) +); + +-- The ledger. Append-only: rows are never updated or deleted, a correction is a new +-- row (EARN_REVERSAL, PAYMENT_REFUND or ADJUSTMENT) pointing at the one it corrects. +-- ON DELETE RESTRICT on customers means a customer with history can only be +-- deactivated, not hard-deleted. +CREATE TABLE wallet_transactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT, + currency VARCHAR(10) NOT NULL, + type VARCHAR(30) NOT NULL, + -- Signed: positive credits the wallet, negative debits it. + amount BIGINT NOT NULL, + balance_after BIGINT NOT NULL, + -- Ties the two rows of an exchange or a transfer together. + group_id UUID, + + -- Where the value came from (amount > 0) or went to (amount < 0). Required for + -- every type; §8.1 lists which reference_type each type uses. + reference_type VARCHAR(30) NOT NULL, + reference_id UUID NOT NULL, + + counterparty_customer_id UUID REFERENCES customers(id), + reverses_transaction_id UUID REFERENCES wallet_transactions(id), + outlet_id UUID, + -- The admin for ADJUSTMENT, the cashier for PAYMENT / PAYMENT_REFUND via POS. + created_by_user UUID, + reason VARCHAR(255), + + -- Display text frozen at creation, so a later rename of an outlet or a customer + -- does not rewrite history (same idea as the price snapshot on order_items). + description VARCHAR(255) NOT NULL, + -- Snapshot of whatever was used to compute the row: settings, point value, + -- exchange rate, reversal shortfall. + metadata JSONB DEFAULT '{}', + idempotency_key VARCHAR(100) UNIQUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + CONSTRAINT chk_wallet_transactions_currency CHECK (currency IN ('POINT', 'COIN')), + CONSTRAINT chk_wallet_transactions_amount CHECK (amount <> 0), + + -- Only EnakPoint can pay (K2); spending on games and exchanging out are EnakCoin only. + CONSTRAINT chk_wallet_transactions_point_only_types CHECK ( + type NOT IN ('PAYMENT', 'PAYMENT_REFUND', 'EXCHANGE_IN', 'REWARD_REDEEM') + OR currency = 'POINT'), + CONSTRAINT chk_wallet_transactions_coin_only_types CHECK ( + type NOT IN ('EXCHANGE_OUT', 'GAME_SPEND') OR currency = 'COIN'), + + CONSTRAINT chk_wallet_transactions_transfer_counterparty CHECK ( + type NOT IN ('TRANSFER_IN', 'TRANSFER_OUT') OR counterparty_customer_id IS NOT NULL), + CONSTRAINT chk_wallet_transactions_reversal_source CHECK ( + type NOT IN ('EARN_REVERSAL', 'PAYMENT_REFUND') OR reverses_transaction_id IS NOT NULL), + CONSTRAINT chk_wallet_transactions_adjustment_actor CHECK ( + type <> 'ADJUSTMENT' OR (created_by_user IS NOT NULL AND reason IS NOT NULL)), + CONSTRAINT chk_wallet_transactions_expire_lot CHECK ( + type <> 'EXPIRE' OR reference_type = 'LOT') +); + +CREATE INDEX idx_wallet_transactions_customer_id_created_at ON wallet_transactions(customer_id, created_at DESC); +CREATE INDEX idx_wallet_transactions_reference ON wallet_transactions(reference_type, reference_id); +CREATE INDEX idx_wallet_transactions_group_id ON wallet_transactions(group_id); +CREATE INDEX idx_wallet_transactions_counterparty_customer_id ON wallet_transactions(counterparty_customer_id); +CREATE INDEX idx_wallet_transactions_reverses_transaction_id ON wallet_transactions(reverses_transaction_id); + +-- Balance kept per lot (K9). Every credit creates one or more lots with their own +-- expiry, and every debit draws from the lots that expire soonest. A transfer, +-- exchange or refund carries the expiry of the lot it came from and points back at it +-- through origin_lot_id, so each unit can be traced to the EARN, ADJUSTMENT or +-- MIGRATION that first created it. +CREATE TABLE wallet_lots ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT, + currency VARCHAR(10) NOT NULL, + -- The incoming ledger row that created this lot. + source_transaction_id UUID NOT NULL REFERENCES wallet_transactions(id), + origin_lot_id UUID REFERENCES wallet_lots(id), + original_amount BIGINT NOT NULL, + -- The only column in the wallet tables that is ever updated. It is a cached + -- original_amount - SUM(wallet_lot_allocations.amount), kept for fast spending, + -- and the reconciliation job (§7.5) checks it against the allocations. + remaining_amount BIGINT NOT NULL, + -- NULL means the lot never expires. + expires_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + CONSTRAINT chk_wallet_lots_currency CHECK (currency IN ('POINT', 'COIN')), + CONSTRAINT chk_wallet_lots_original_amount CHECK (original_amount > 0), + CONSTRAINT chk_wallet_lots_remaining_amount CHECK ( + remaining_amount >= 0 AND remaining_amount <= original_amount) +); + +-- Spending order (K9): soonest expiry first, lots without an expiry last. +CREATE INDEX idx_wallet_lots_consume ON wallet_lots(customer_id, currency, expires_at NULLS LAST, created_at) + WHERE remaining_amount > 0; +CREATE INDEX idx_wallet_lots_expiry ON wallet_lots(expires_at) WHERE remaining_amount > 0; + +-- Which lots each outgoing ledger row drew from, and how much from each. +CREATE TABLE wallet_lot_allocations ( + transaction_id UUID NOT NULL REFERENCES wallet_transactions(id), + lot_id UUID NOT NULL REFERENCES wallet_lots(id), + amount BIGINT NOT NULL, + + PRIMARY KEY (transaction_id, lot_id), + CONSTRAINT chk_wallet_lot_allocations_amount CHECK (amount > 0) +); + +-- Not in §8: the primary key cannot serve lookups by lot, which the reconciliation +-- job needs to sum each lot's allocations. +CREATE INDEX idx_wallet_lot_allocations_lot_id ON wallet_lot_allocations(lot_id);