Reapply "feat(loyalty): EnakPoint & EnakCoin" (#32)
This reverts commit 4e24f9bbb0.
This commit is contained in:
@@ -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;
|
||||
@@ -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);
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS loyalty_setting_changes;
|
||||
DROP TABLE IF EXISTS organization_settings;
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Settings that must be the same in every outlet of an organization, starting with the
|
||||
-- loyalty ones (docs/prd-point-coin.md F2, F12): point value, exchange rate, transfer
|
||||
-- limits and expiry. Same key-value shape as outlet_settings.
|
||||
CREATE TABLE organization_settings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
key VARCHAR(255) NOT NULL,
|
||||
value TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
UNIQUE(organization_id, key)
|
||||
);
|
||||
|
||||
-- The unique constraint leads with organization_id, so it also serves the plain
|
||||
-- per-organization lookups and there is no separate index on that column.
|
||||
|
||||
-- Who changed which loyalty setting, from what, to what (F2). Covers both the
|
||||
-- organization settings above and the per-outlet loyalty keys in outlet_settings.
|
||||
-- Append-only. Values are stored as text, the same as in the settings tables.
|
||||
CREATE TABLE loyalty_setting_changes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL,
|
||||
-- NULL for an organization setting.
|
||||
outlet_id UUID,
|
||||
key VARCHAR(100) NOT NULL,
|
||||
-- NULL when the key had no stored value yet (it was on its default).
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by UUID NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_loyalty_setting_changes_organization_id_created_at ON loyalty_setting_changes(organization_id, created_at DESC);
|
||||
CREATE INDEX idx_loyalty_setting_changes_outlet_id_created_at ON loyalty_setting_changes(outlet_id, created_at DESC)
|
||||
WHERE outlet_id IS NOT NULL;
|
||||
@@ -0,0 +1,5 @@
|
||||
UPDATE campaigns SET type = 'TOKENS' WHERE type = 'COINS';
|
||||
UPDATE campaign_rules SET reward_type = 'TOKENS' WHERE reward_type = 'COINS';
|
||||
|
||||
COMMENT ON COLUMN campaigns.type IS 'Type of campaign: REWARD, POINTS, TOKENS, MIXED';
|
||||
COMMENT ON COLUMN campaign_rules.reward_type IS 'Type of reward: POINTS, TOKENS, REWARD';
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Tokens become EnakCoin (docs/prd-point-coin.md §10). Campaigns that handed out
|
||||
-- tokens now hand out coins; the API still accepts TOKENS and stores it as COINS.
|
||||
UPDATE campaigns SET type = 'COINS' WHERE type = 'TOKENS';
|
||||
UPDATE campaign_rules SET reward_type = 'COINS' WHERE reward_type = 'TOKENS';
|
||||
|
||||
COMMENT ON COLUMN campaigns.type IS 'Type of campaign: REWARD, POINTS, COINS, MIXED';
|
||||
COMMENT ON COLUMN campaign_rules.reward_type IS 'Type of reward: POINTS, COINS, REWARD';
|
||||
@@ -0,0 +1,9 @@
|
||||
DROP TABLE IF EXISTS customer_security_events;
|
||||
|
||||
ALTER TABLE customers
|
||||
DROP CONSTRAINT IF EXISTS chk_customers_pin_failed_attempts,
|
||||
DROP COLUMN IF EXISTS transfer_blocked_until,
|
||||
DROP COLUMN IF EXISTS pin_locked_until,
|
||||
DROP COLUMN IF EXISTS pin_failed_attempts,
|
||||
DROP COLUMN IF EXISTS pin_set_at,
|
||||
DROP COLUMN IF EXISTS pin_hash;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Customer PIN (docs/prd-point-coin.md F11, K8). A 6-digit PIN, separate from the
|
||||
-- login password, approves everything that moves EnakPoint or EnakCoin on the
|
||||
-- customer's request. Only its bcrypt hash is stored.
|
||||
ALTER TABLE customers
|
||||
ADD COLUMN pin_hash VARCHAR(255),
|
||||
ADD COLUMN pin_set_at TIMESTAMP WITH TIME ZONE,
|
||||
-- Kept in the database, not a cache, so it cannot be dodged by waiting for a cache
|
||||
-- to expire or by hitting another server (Q17).
|
||||
ADD COLUMN pin_failed_attempts INT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN pin_locked_until TIMESTAMP WITH TIME ZONE,
|
||||
-- Outgoing transfers are held for 24 hours after a PIN reset (Q16).
|
||||
ADD COLUMN transfer_blocked_until TIMESTAMP WITH TIME ZONE,
|
||||
ADD CONSTRAINT chk_customers_pin_failed_attempts CHECK (pin_failed_attempts >= 0);
|
||||
|
||||
-- Security log of PIN events. Not a balance movement, so not in wallet_transactions.
|
||||
CREATE TABLE customer_security_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
|
||||
-- PIN_SET, PIN_CHANGED, PIN_RESET, PIN_FAILED, PIN_LOCKED, PIN_REMOVED_BY_ADMIN
|
||||
event VARCHAR(30) NOT NULL,
|
||||
-- The admin, for PIN_REMOVED_BY_ADMIN.
|
||||
actor_user UUID,
|
||||
reason VARCHAR(255),
|
||||
ip_address VARCHAR(45),
|
||||
user_agent VARCHAR(255),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT chk_customer_security_events_admin CHECK (
|
||||
event <> 'PIN_REMOVED_BY_ADMIN' OR (actor_user IS NOT NULL AND reason IS NOT NULL))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_customer_security_events_customer_id_created_at ON customer_security_events(customer_id, created_at DESC);
|
||||
@@ -0,0 +1,16 @@
|
||||
ALTER TABLE payments
|
||||
DROP CONSTRAINT IF EXISTS chk_payments_point_pair,
|
||||
DROP COLUMN IF EXISTS point_value,
|
||||
DROP COLUMN IF EXISTS points_used;
|
||||
|
||||
DROP TRIGGER IF EXISTS trigger_create_point_payment_method ON organizations;
|
||||
DROP FUNCTION IF EXISTS create_point_payment_method();
|
||||
|
||||
-- Fails if an EnakPoint method has been used by a payment, which is the point: those
|
||||
-- payments would lose their method.
|
||||
DELETE FROM payment_methods WHERE type = 'point';
|
||||
DROP INDEX IF EXISTS uq_payment_methods_point_per_organization;
|
||||
|
||||
ALTER TABLE payment_methods DROP CONSTRAINT IF EXISTS payment_methods_type_check;
|
||||
ALTER TABLE payment_methods ADD CONSTRAINT payment_methods_type_check
|
||||
CHECK (type IN ('cash', 'card', 'digital_wallet'));
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Paying with EnakPoint (docs/prd-point-coin.md F9, §8, §10.5).
|
||||
|
||||
-- A new payment method type. Every organization has exactly one method of it, made by
|
||||
-- the system, which cannot be deleted or change type.
|
||||
ALTER TABLE payment_methods DROP CONSTRAINT IF EXISTS payment_methods_type_check;
|
||||
ALTER TABLE payment_methods ADD CONSTRAINT payment_methods_type_check
|
||||
CHECK (type IN ('cash', 'card', 'digital_wallet', 'point'));
|
||||
|
||||
CREATE UNIQUE INDEX uq_payment_methods_point_per_organization ON payment_methods(organization_id)
|
||||
WHERE type = 'point';
|
||||
|
||||
INSERT INTO payment_methods (organization_id, name, type, is_active)
|
||||
SELECT id, 'EnakPoint', 'point', TRUE FROM organizations
|
||||
ON CONFLICT (organization_id) WHERE type = 'point' DO NOTHING;
|
||||
|
||||
-- New organizations get theirs the same way they get their walk-in customer, whatever
|
||||
-- code path creates them.
|
||||
CREATE OR REPLACE FUNCTION create_point_payment_method()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
INSERT INTO payment_methods (organization_id, name, type, is_active)
|
||||
VALUES (NEW.id, 'EnakPoint', 'point', TRUE)
|
||||
ON CONFLICT (organization_id) WHERE type = 'point' DO NOTHING;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trigger_create_point_payment_method
|
||||
AFTER INSERT ON organizations
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION create_point_payment_method();
|
||||
|
||||
-- A payment made with EnakPoint records how many were used and the rupiah value of one
|
||||
-- at that moment. The value is frozen so a refund returns exactly the EnakPoint used,
|
||||
-- whatever the value is by then.
|
||||
--
|
||||
-- Written so it never evaluates to NULL: the form in the PRD, (both NULL) OR (both
|
||||
-- > 0), is NULL for points_used = 1000 with point_value NULL, and a CHECK only rejects
|
||||
-- FALSE, so a payment could lose its frozen value.
|
||||
ALTER TABLE payments
|
||||
ADD COLUMN points_used BIGINT,
|
||||
ADD COLUMN point_value DECIMAL(10,2),
|
||||
ADD CONSTRAINT chk_payments_point_pair CHECK (
|
||||
(points_used IS NULL) = (point_value IS NULL)
|
||||
AND (points_used IS NULL OR (points_used > 0 AND point_value > 0)));
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE game_plays RENAME CONSTRAINT chk_game_plays_coins_used_non_negative TO chk_game_plays_token_used_non_negative;
|
||||
ALTER TABLE game_plays RENAME COLUMN coins_used TO token_used;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Every game now costs EnakCoin (docs/prd-point-coin.md F8, K1), so what a play used
|
||||
-- is a number of EnakCoin.
|
||||
ALTER TABLE game_plays RENAME COLUMN token_used TO coins_used;
|
||||
ALTER TABLE game_plays RENAME CONSTRAINT chk_game_plays_token_used_non_negative TO chk_game_plays_coins_used_non_negative;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS customer_devices;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Devices of the customer app, so customers can get push notifications through FCM
|
||||
-- (docs/prd-point-coin.md F5: the recipient of a transfer is notified). user_devices
|
||||
-- only holds staff devices.
|
||||
CREATE TABLE customer_devices (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
device_id VARCHAR(255) NOT NULL,
|
||||
platform VARCHAR(50) CHECK (platform IN ('android', 'ios', 'web')),
|
||||
fcm_token VARCHAR(512) NOT NULL,
|
||||
app_version VARCHAR(50),
|
||||
last_active_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_customer_devices_customer_device ON customer_devices(customer_id, device_id);
|
||||
CREATE INDEX idx_customer_devices_fcm_token ON customer_devices(fcm_token);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS wallet_expiry_reminders;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Which expiry reminders have gone out (docs/prd-point-coin.md F12): one per
|
||||
-- customer, currency and expiry day. The row is written before the push is sent, so
|
||||
-- several instances of the job, or a restart, never remind twice.
|
||||
CREATE TABLE wallet_expiry_reminders (
|
||||
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
currency VARCHAR(10) NOT NULL CHECK (currency IN ('POINT','COIN')),
|
||||
expiry_date DATE NOT NULL,
|
||||
amount BIGINT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
PRIMARY KEY (customer_id, currency, expiry_date)
|
||||
);
|
||||
Reference in New Issue
Block a user