137 lines
4.0 KiB
PL/PgSQL
137 lines
4.0 KiB
PL/PgSQL
-- 005_post_ledger_entry.sql
|
|
-- Atomic ledger posting function with balance updates
|
|
-- Enforces idempotency, hash chaining, and balance integrity at DB level
|
|
|
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
|
|
CREATE OR REPLACE FUNCTION post_ledger_entry(
|
|
p_ledger_id text,
|
|
p_debit_account_id text,
|
|
p_credit_account_id text,
|
|
p_amount numeric,
|
|
p_currency_code text,
|
|
p_asset_type text,
|
|
p_transaction_type text,
|
|
p_reference_id text,
|
|
p_fx_rate numeric DEFAULT NULL,
|
|
p_metadata jsonb DEFAULT NULL
|
|
) RETURNS TABLE(
|
|
entry_id text,
|
|
block_hash text,
|
|
debit_balance numeric,
|
|
credit_balance numeric
|
|
) AS $$
|
|
DECLARE
|
|
v_entry_id text := gen_random_uuid()::text;
|
|
v_prev_hash text;
|
|
v_now timestamptz := now();
|
|
v_payload text;
|
|
v_block_hash text;
|
|
v_debit record;
|
|
v_credit record;
|
|
a1 text;
|
|
a2 text;
|
|
BEGIN
|
|
-- Validate amount
|
|
IF p_amount IS NULL OR p_amount <= 0 THEN
|
|
RAISE EXCEPTION 'Amount must be > 0';
|
|
END IF;
|
|
|
|
-- Idempotency check
|
|
IF EXISTS (
|
|
SELECT 1 FROM ledger_entries
|
|
WHERE ledger_id = p_ledger_id
|
|
AND reference_id = p_reference_id
|
|
) THEN
|
|
RAISE EXCEPTION 'Duplicate reference_id for ledger: %', p_reference_id;
|
|
END IF;
|
|
|
|
-- Lock ledger stream (prevents hash-chain races)
|
|
PERFORM pg_advisory_xact_lock(hashtext(p_ledger_id));
|
|
|
|
-- Deadlock-safe lock ordering (always lock accounts in id order)
|
|
a1 := LEAST(p_debit_account_id, p_credit_account_id);
|
|
a2 := GREATEST(p_debit_account_id, p_credit_account_id);
|
|
|
|
PERFORM 1 FROM bank_accounts WHERE id = a1 FOR UPDATE;
|
|
PERFORM 1 FROM bank_accounts WHERE id = a2 FOR UPDATE;
|
|
|
|
-- Fetch accounts (already locked)
|
|
SELECT * INTO v_debit FROM bank_accounts WHERE id = p_debit_account_id;
|
|
SELECT * INTO v_credit FROM bank_accounts WHERE id = p_credit_account_id;
|
|
|
|
IF v_debit.id IS NULL OR v_credit.id IS NULL THEN
|
|
RAISE EXCEPTION 'Account not found';
|
|
END IF;
|
|
|
|
-- Currency validation
|
|
IF v_debit.currency_code <> p_currency_code OR v_credit.currency_code <> p_currency_code THEN
|
|
RAISE EXCEPTION 'Currency mismatch';
|
|
END IF;
|
|
|
|
-- Sufficient funds check
|
|
IF v_debit.available_balance < p_amount THEN
|
|
RAISE EXCEPTION 'Insufficient balance: available=%, requested=%',
|
|
v_debit.available_balance, p_amount;
|
|
END IF;
|
|
|
|
-- Get previous hash for chain
|
|
SELECT block_hash INTO v_prev_hash
|
|
FROM ledger_entries
|
|
WHERE ledger_id = p_ledger_id
|
|
ORDER BY timestamp_utc DESC
|
|
LIMIT 1;
|
|
|
|
-- Compute canonical payload for block hash
|
|
v_payload :=
|
|
COALESCE(v_prev_hash,'') || '|' ||
|
|
v_entry_id || '|' ||
|
|
p_ledger_id || '|' ||
|
|
p_debit_account_id || '|' ||
|
|
p_credit_account_id || '|' ||
|
|
p_amount::text || '|' ||
|
|
p_currency_code || '|' ||
|
|
p_asset_type || '|' ||
|
|
p_transaction_type || '|' ||
|
|
p_reference_id || '|' ||
|
|
v_now::text;
|
|
|
|
-- Compute block hash
|
|
v_block_hash := encode(digest(v_payload, 'sha256'), 'hex');
|
|
|
|
-- Insert ledger entry
|
|
INSERT INTO ledger_entries (
|
|
id, ledger_id, debit_account_id, credit_account_id,
|
|
amount, currency_code, fx_rate, asset_type, transaction_type,
|
|
reference_id, timestamp_utc, block_hash, previous_hash,
|
|
status, metadata, created_at, updated_at
|
|
) VALUES (
|
|
v_entry_id, p_ledger_id, p_debit_account_id, p_credit_account_id,
|
|
p_amount, p_currency_code, p_fx_rate, p_asset_type, p_transaction_type,
|
|
p_reference_id, v_now, v_block_hash, v_prev_hash,
|
|
'POSTED', p_metadata, v_now, v_now
|
|
);
|
|
|
|
-- Update balances atomically
|
|
UPDATE bank_accounts
|
|
SET balance = balance - p_amount,
|
|
available_balance = available_balance - p_amount,
|
|
updated_at = v_now
|
|
WHERE id = p_debit_account_id;
|
|
|
|
UPDATE bank_accounts
|
|
SET balance = balance + p_amount,
|
|
available_balance = available_balance + p_amount,
|
|
updated_at = v_now
|
|
WHERE id = p_credit_account_id;
|
|
|
|
-- Return result
|
|
RETURN QUERY
|
|
SELECT
|
|
v_entry_id,
|
|
v_block_hash,
|
|
(SELECT balance FROM bank_accounts WHERE id = p_debit_account_id),
|
|
(SELECT balance FROM bank_accounts WHERE id = p_credit_account_id);
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|