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