Fintech

LedgerX

Double-Entry Bookkeeping System

2024 Python, FastAPI, PostgreSQL Production Ready

Project Overview

The Problem

Naive balance updates were bleeding money—race conditions enabled double-spending, and direct mutations made forensic accounting impossible. Every audit was a nightmare.

The Solution

A proper double-entry bookkeeping system with append-only ledger entries, optimistic locking, and ACID-compliant transactions. Every cent is traceable and immutable.

Key Metrics

  • 💰 Zero balance discrepancies since deployment
  • ⚡ 10,000+ transactions/second throughput
  • 📊 Complete audit trail for every monetary movement

System Architecture

High-level overview of the transaction processing pipeline.

flowchart TB subgraph Client["Client Layer"] API[API Requests] BATCH[Batch Processor] end subgraph Gateway["API Gateway"] FASTAPI[FastAPI Server] VALIDATE[Request Validator] IDEMPOTENCY[Idempotency Check] end subgraph Core["Transaction Engine"] TX_HANDLER[Transaction Handler] DOUBLE_ENTRY[Double-Entry Processor] BALANCE[Balance Calculator] end subgraph Ledger["Ledger Service"] JOURNAL[Journal Writer] ENTRY[Entry Creator] AUDIT[Audit Logger] end subgraph Data["Data Layer"] POSTGRES[(PostgreSQL)] LOCK[Advisory Locks] end API --> FASTAPI BATCH --> FASTAPI FASTAPI --> VALIDATE VALIDATE --> IDEMPOTENCY IDEMPOTENCY --> TX_HANDLER TX_HANDLER --> DOUBLE_ENTRY DOUBLE_ENTRY --> BALANCE DOUBLE_ENTRY --> JOURNAL JOURNAL --> ENTRY ENTRY --> AUDIT BALANCE --> POSTGRES ENTRY --> POSTGRES AUDIT --> POSTGRES TX_HANDLER --> LOCK LOCK --> POSTGRES

Database Schema

Entity-Relationship diagram showing the double-entry ledger structure.

erDiagram ACCOUNTS ||--o{ LEDGER_ENTRIES : contains TRANSACTIONS ||--|{ LEDGER_ENTRIES : creates TRANSACTIONS ||--o| IDEMPOTENCY_KEYS : uses ACCOUNTS ||--o{ BALANCE_SNAPSHOTS : has ACCOUNTS { uuid id PK string account_number UK string name enum type "asset|liability|equity|revenue|expense" string currency boolean is_active timestamp created_at } TRANSACTIONS { uuid id PK string reference UK string description decimal total_amount string currency enum status "pending|completed|failed|reversed" uuid reversed_by FK json metadata timestamp created_at } LEDGER_ENTRIES { uuid id PK uuid transaction_id FK uuid account_id FK enum entry_type "debit|credit" decimal amount decimal running_balance timestamp created_at } IDEMPOTENCY_KEYS { string key PK uuid transaction_id FK timestamp expires_at } BALANCE_SNAPSHOTS { uuid id PK uuid account_id FK decimal balance date snapshot_date timestamp created_at }

Engineering Challenges

01

Preventing Double-Spending

Problem

Concurrent transactions could read stale balances and both succeed, overdrawing the account—a classic race condition.

Solution

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)
02

Immutable Append-Only Ledger

Problem

Traditional UPDATE statements made audit trails incomplete. Any modification destroyed historical accuracy.

Solution

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

Balance Reconciliation at Scale

Problem

Calculating real-time balances by summing all entries was expensive—millions of rows per account made queries slow.

Solution

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)

Key Takeaways

📒

Double-Entry is Non-Negotiable

Every debit has a credit. This accounting fundamental catches errors instantly—if they don't balance, something is wrong.

🔒

Immutability Enables Trust

Append-only ledgers with database-enforced immutability mean auditors trust the data. No one can silently alter history.

Idempotency Prevents Disasters

Network retries in distributed systems can duplicate requests. Idempotency keys ensure each transaction processes exactly once.

📊

Running Balances Scale

Pre-computing running balances on each entry trades storage for query speed—worth it for real-time balance lookups.

Interested in the Technical Details?

Check out the full source code or reach out to discuss the architecture.