Reapply "feat(loyalty): EnakPoint & EnakCoin" (#32)
This reverts commit 4e24f9bbb0.
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user