DEV Community

Borino88
Borino88

Posted on

Double-Entry Ledger Design for Software Engineers

Double-Entry Ledger Design for Software Engineers

When building financial systems, billing engines, or wallet platforms, the standard "single column balance" approach (e.g., updating a balance column in a users table) is a recipe for disaster. It fails audits, makes reconciliation impossible, and cannot survive concurrency failures.

To build a system that is secure, auditable, and resilient to failures, you must implement a Double-Entry Ledger.

The Core Principles of Double-Entry

In double-entry bookkeeping, money never appears or disappears; it is only transferred from one account to another. Every transaction consists of a set of entries where:

$$\text{Total Debits} = \text{Total Credits}$$

An account type determines whether a debit increases or decreases its value:

  • Assets / Expenses: Increased by debits, decreased by credits.
  • Liabilities / Equity / Revenue: Increased by credits, decreased by debits.

Relational Database Schema Design

A robust ledger database requires three primary tables:

  1. Accounts: The containers holding balances of specific types.
  2. Transactions: The events representing a business operation.
  3. Entries: The individual credit or debit allocations mapped to accounts.

Here is the PostgreSQL schema:

CREATE TABLE accounts (
    id UUID PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    type VARCHAR(50) NOT NULL CHECK (type IN ('asset', 'liability', 'equity', 'revenue', 'expense')),
    currency VARCHAR(3) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE transactions (
    id UUID PRIMARY KEY,
    description TEXT NOT NULL,
    metadata JSONB,
    posted_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE entries (
    id UUID PRIMARY KEY,
    transaction_id UUID REFERENCES transactions(id) ON DELETE CASCADE,
    account_id UUID REFERENCES accounts(id),
    amount NUMERIC(18, 4) NOT NULL, -- Positive for debits/credits depending on context
    direction VARCHAR(2) NOT NULL CHECK (direction IN ('DB', 'CR')),
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_entries_account ON entries(account_id);
CREATE INDEX idx_entries_transaction ON entries(transaction_id);
Enter fullscreen mode Exit fullscreen mode

Implementing Verification Logic in Python

To guarantee the integrity of your ledger, your application must verify that all transactions balance before persisting them. Here is a Python implementation utilizing SQL transactions and sanity checks:

import uuid
from decimal import Decimal
from typing import List, Dict

class LedgerError(Exception):
    pass

class LedgerEntry:
    def __init__(self, account_id: str, amount: Decimal, direction: str):
        self.account_id = account_id
        self.amount = amount
        self.direction = direction

def verify_and_save_transaction(db_connection, description: str, entries: List[LedgerEntry]):
    # 1. Calculate and verify zero-sum balance
    total_debits = sum(e.amount for e in entries if e.direction == 'DB')
    total_credits = sum(e.amount for e in entries if e.direction == 'CR')

    if total_debits != total_credits:
        raise LedgerError(f"Transaction does not balance. Debits: {total_debits}, Credits: {total_credits}")

    tx_id = str(uuid.uuid4())

    with db_connection.cursor() as cursor:
        # 2. Insert transaction
        cursor.execute(
            "INSERT INTO transactions (id, description) VALUES (%s, %s)",
            (tx_id, description)
        )

        # 3. Insert matching entries
        for entry in entries:
            cursor.execute(
                """
                INSERT INTO entries (id, transaction_id, account_id, amount, direction)
                VALUES (%s, %s, %s, %s, %s)
                """,
                (str(uuid.uuid4()), tx_id, entry.account_id, entry.amount, entry.direction)
            )

    print(f"Successfully posted transaction {tx_id} to database.")
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  • Immutability: Never use SQL UPDATE on ledger entries. If a mistake is made, post a reversing transaction.
  • Strong Typing: Use arbitrary-precision numeric types (NUMERIC or DECIMAL) for monetary values—never float.
  • Zero-Sum Guarantees: Balance checks should be enforced both at the application tier and via database triggers or constraints.

Top comments (1)

Collapse
 
topstar_ai profile image
Luis Cruz

I particularly appreciated the emphasis on the core principles of double-entry bookkeeping, where every transaction is represented by a set of entries with total debits equaling total credits. The PostgreSQL schema design you provided is robust and well-structured, with the use of separate tables for accounts, transactions, and entries allowing for efficient querying and auditing. One potential improvement could be to consider adding a column to the entries table to store the running balance for each account after each transaction, which would facilitate reconciliation and debugging. Have you considered implementing any additional validation or sanity checks in the verify_and_save_transaction function to handle edge cases, such as duplicate transactions or concurrent modifications to the same account?