Naive balance updates were bleeding money—race conditions enabled double-spending, and direct mutations made forensic accounting impossible. Every audit was a nightmare.
A proper double-entry bookkeeping system with append-only ledger entries, optimistic locking, and ACID-compliant transactions. Every cent is traceable and immutable.
High-level overview of the transaction processing pipeline.
Entity-Relationship diagram showing the double-entry ledger structure.
Concurrent transactions could read stale balances and both succeed, overdrawing the account—a classic race condition.
Implemented optimistic locking with version checks, plus PostgreSQL advisory locks for account-level serialization during balance updates.
# Optimistic locking with version check
async def debit_account(account_id: str, amount: Decimal, version: int):
async with db.transaction():
# Acquire advisory lock for this account
await db.execute(f"SELECT pg_advisory_xact_lock({hash(account_id)})")
account = await db.fetchone(
"SELECT * FROM accounts WHERE id = $1", account_id
)
if account.version != version:
raise ConcurrencyError("Account modified by another transaction")
if account.balance < amount:
raise InsufficientFundsError(f"Balance: {account.balance}")
# Create ledger entry (append-only)
await db.execute("""
INSERT INTO ledger_entries
(transaction_id, account_id, entry_type, amount, running_balance)
VALUES ($1, $2, 'debit', $3, $4)
""", tx_id, account_id, amount, account.balance - amount)
Traditional UPDATE statements made audit trails incomplete. Any modification destroyed historical accuracy.
Ledger entries are INSERT-only. Corrections create reversing entries. Database triggers prevent any UPDATE or DELETE operations on the ledger table.
-- Prevent modifications to ledger entries
CREATE OR REPLACE FUNCTION prevent_ledger_modification()
RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'Ledger entries are immutable. Create a reversing entry instead.';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER ledger_immutability
BEFORE UPDATE OR DELETE ON ledger_entries
FOR EACH ROW
EXECUTE FUNCTION prevent_ledger_modification();
-- Reversing entry for corrections
CREATE OR REPLACE FUNCTION reverse_transaction(original_tx_id UUID)
RETURNS UUID AS $$
DECLARE
reversal_id UUID := gen_random_uuid();
BEGIN
INSERT INTO transactions (id, reference, description, reversed_by)
SELECT reversal_id, 'REV-' || reference, 'Reversal of ' || description, original_tx_id
FROM transactions WHERE id = original_tx_id;
-- Create opposite entries
INSERT INTO ledger_entries (transaction_id, account_id, entry_type, amount)
SELECT reversal_id, account_id,
CASE WHEN entry_type = 'debit' THEN 'credit' ELSE 'debit' END,
amount
FROM ledger_entries WHERE transaction_id = original_tx_id;
RETURN reversal_id;
END;
$$ LANGUAGE plpgsql;
Calculating real-time balances by summing all entries was expensive—millions of rows per account made queries slow.
Maintain running balances on each entry (immutable snapshot). Periodic reconciliation jobs verify integrity and create balance snapshots for fast historical queries.
# Daily reconciliation job
async def reconcile_accounts():
accounts = await db.fetch("SELECT id FROM accounts WHERE is_active")
for account in accounts:
# Calculate balance from entries
calculated = await db.fetchval("""
SELECT COALESCE(
SUM(CASE WHEN entry_type = 'credit' THEN amount ELSE -amount END), 0
) FROM ledger_entries WHERE account_id = $1
""", account.id)
# Get last running balance
last_entry = await db.fetchone("""
SELECT running_balance FROM ledger_entries
WHERE account_id = $1 ORDER BY created_at DESC LIMIT 1
""", account.id)
if calculated != last_entry.running_balance:
await alert_discrepancy(account.id, calculated, last_entry.running_balance)
# Create daily snapshot
await db.execute("""
INSERT INTO balance_snapshots (account_id, balance, snapshot_date)
VALUES ($1, $2, CURRENT_DATE)
""", account.id, calculated)
Every debit has a credit. This accounting fundamental catches errors instantly—if they don't balance, something is wrong.
Append-only ledgers with database-enforced immutability mean auditors trust the data. No one can silently alter history.
Network retries in distributed systems can duplicate requests. Idempotency keys ensure each transaction processes exactly once.
Pre-computing running balances on each entry trades storage for query speed—worth it for real-time balance lookups.
Check out the full source code or reach out to discuss the architecture.