-- 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);