Reconciliation Ledger Schema

The reconciliation ledger is where the freight-audit program’s dollars become defensible. Every stage upstream produces a claim — an overcharge flagged, a dispute filed, a credit expected — but a claim is not money until it is booked, balanced, and reconciled against what the carrier actually paid or credited. This is the layer that turns a spreadsheet of “we think we’re owed $412K” into a per-invoice, per-account trail that survives a controller’s review and a SOX walkthrough. It sits at the settlement end of Discrepancy Resolution & Dispute Routing: variance is classified, disputes are routed to carriers, and the financial consequences of both land here as journal entries.

The design constraint that shapes everything on this page is that AP must be able to prove the recovered-dollars number to the cent, months later, without trusting a single mutable field. That rules out the naive “audit_results” table where a status column flips from open to recovered and the original amount is overwritten. Instead the ledger is double-entry — every financial event books equal debits and credits so the books always balance — and append-only — nothing is ever updated or deleted, only reversed by a compensating entry. Five event types drive the ledger: an invoice booked as a liability, a dispute filed against it, a credit received from the carrier, a short-pay applied at remittance time, and a write-off when a claim is abandoned. Each becomes a balanced transaction whose legs post to accounts whose running balances are the audit’s ground truth.

Prerequisites

This stage consumes the output of the classification and dispute tiers and writes to the same Postgres instance that settlement and reporting read from. Before wiring the ledger, confirm the following are in place:

Prerequisite Source Why the ledger needs it
Classified variance rows Charge Variance Classification Supplies the disputed amount and reason code that a dispute entry books
Filed-dispute identifiers Carrier Dispute API Integration The dispute_id that ties a ledger entry back to a carrier claim
Short-pay / deduction records Short-Pay & Deduction Management The applied deduction amount that closes or partially closes a claim
A resolved invoice_id and contract_version_id upstream ingestion + Freight Contract Architecture & Rate Mapping The immutable anchor every entry references
Postgres 14+ with numeric money columns infrastructure Exact decimal arithmetic; never float for money

On the Python side the posting API depends on psycopg 3.x for server-side parameterization and pydantic 2.x for validating an entry before it is written. Currency is handled with decimal.Decimal end to end — the ledger never sees a binary float. One config key governs correctness: ledger.balance_tolerance, the maximum residual (default 0.00) tolerated between summed debits and credits. Unlike a validation tolerance, this one is zero by design: a ledger that does not balance exactly is not a ledger.

Ledger data model and field contract

The schema is three tables. ledger_account is the chart of accounts — the named buckets money moves between. journal_entry is the header for one financial event, carrying the business context (event type, source ids, timestamp). journal_leg is the append-only body: two or more rows per entry, each a debit or a credit against one account, constrained so the entry balances. The field contract below is what every downstream consumer — settlement, reporting, an auditor’s ad-hoc query — is allowed to depend on.

Table Column Type Contract
ledger_account account_code text PK Stable code, e.g. AP_CONTROL, DISPUTES_RECV, RECOVERY_INCOME
ledger_account normal_balance text DEBIT or CREDIT; sign convention for the account
journal_entry entry_id bigint PK Monotonic, assigned at post time; never reused
journal_entry event_type text One of INVOICE_BOOKED, DISPUTE_FILED, CREDIT_RECEIVED, SHORT_PAY, WRITE_OFF
journal_entry invoice_id text Anchors the entry to one freight movement
journal_entry dispute_id text NULL Present when the event relates to a filed claim
journal_entry posted_at timestamptz Wall-clock post time; immutable
journal_entry reverses_entry_id bigint NULL Set only on compensating (reversal) entries
journal_leg leg_id bigint PK Append-only; one row per debit or credit
journal_leg entry_id bigint FK Groups legs into a balanced transaction
journal_leg account_code text FK The account this leg moves
journal_leg direction text DEBIT or CREDIT
journal_leg amount numeric(14,4) Always positive; direction carries the sign

The invariant that makes the whole thing trustworthy is that, for every entry_id, the sum of amount where direction = 'DEBIT' equals the sum where direction = 'CREDIT'. Running account balances are never stored as a mutable number; they are derived by summing legs, so there is no counter to drift out of sync with history.

Ledger architecture

The diagram traces one dollar of recovery from event to proof. Freight-audit events on the left each become a balanced journal entry; the balance invariant gates every post; balanced entries update derived account balances; unbalanced ones are rejected and rolled back before anything is written. The account balances are what settlement draws down and what reporting sums.

Append-only double-entry reconciliation ledger flow Five freight-audit events on the left — invoice booked, dispute filed, credit received, short-pay applied, and write-off — each feed into an append-only journal that composes a balanced entry of debit and credit legs. Every entry passes through a balance-invariant gate that checks the sum of debits equals the sum of credits. Balanced entries post and update derived account balances on the right: AP control, disputes receivable, and recovery income, each carrying a running balance derived by summing legs. Unbalanced entries branch downward to a rollback path and are never written. The derived balances feed two consumers at the bottom: settlement drawdown and audit reporting. EVENTS APPEND-ONLY JOURNAL DERIVED BALANCES Invoice booked Dispute filed Credit received Short-pay applied Write-off journal_entry + legs one event = one balanced entry DEBIT legs DISPUTES_RECV 412.00 Σ = 412.00 CREDIT legs RECOVERY_INCOME 412.00 Σ = 412.00 balance invariant Σ debits = Σ credits ? tolerance = 0.00 balanced unbalanced ROLLBACK never written to history Derived account balances Σ legs — never a stored counter AP_CONTROL −128,400.00 liability owed to carriers DISPUTES_RECV 41,207.50 open claims against carriers RECOVERY_INCOME 87,192.50 recovered to date Settlement drawdown against balance Reporting recovered-dollars proof
Each freight-audit event becomes one balanced journal entry; the balance invariant gates every post so an unbalanced entry rolls back before it touches history; balanced entries update account balances derived by summing legs, which settlement draws down and reporting proves.

Step-by-step implementation

Step 1 — Define the chart of accounts and append-only journal

Start with the schema. The account table is small and seeded once; the journal tables are where volume lands. The constraints do the heavy lifting: direction and event_type are checked, amount must be positive, and there is no UPDATE-friendly status column anywhere — state is expressed by posting new entries.

-- schema/ledger.sql
CREATE TABLE ledger_account (
    account_code   text PRIMARY KEY,
    normal_balance text NOT NULL CHECK (normal_balance IN ('DEBIT', 'CREDIT')),
    description    text NOT NULL
);

INSERT INTO ledger_account (account_code, normal_balance, description) VALUES
    ('AP_CONTROL',      'CREDIT', 'Liability owed to carriers for booked invoices'),
    ('DISPUTES_RECV',   'DEBIT',  'Open freight-audit claims against carriers'),
    ('RECOVERY_INCOME', 'CREDIT', 'Recovered dollars realized to date'),
    ('WRITE_OFF_EXP',   'DEBIT',  'Abandoned claims expensed');

CREATE TABLE journal_entry (
    entry_id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    event_type        text NOT NULL CHECK (event_type IN
                          ('INVOICE_BOOKED','DISPUTE_FILED','CREDIT_RECEIVED',
                           'SHORT_PAY','WRITE_OFF')),
    invoice_id        text NOT NULL,
    dispute_id        text,
    posted_at         timestamptz NOT NULL DEFAULT now(),
    reverses_entry_id bigint REFERENCES journal_entry (entry_id)
);

CREATE TABLE journal_leg (
    leg_id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    entry_id     bigint NOT NULL REFERENCES journal_entry (entry_id),
    account_code text NOT NULL REFERENCES ledger_account (account_code),
    direction    text NOT NULL CHECK (direction IN ('DEBIT', 'CREDIT')),
    amount       numeric(14,4) NOT NULL CHECK (amount > 0)
);

CREATE INDEX idx_leg_entry   ON journal_leg (entry_id);
CREATE INDEX idx_leg_account ON journal_leg (account_code);
CREATE INDEX idx_entry_inv   ON journal_entry (invoice_id);

Common mistake: adding a balance column to ledger_account and updating it on every post. That single mutable counter is the first thing to drift, and once it disagrees with the legs you can no longer tell which is right. Derive the balance; never store it.

Step 2 — Post a balanced transaction atomically

The posting API is the only writer. It accepts an event and a set of legs, validates that debits equal credits before opening a transaction, then writes the entry and all its legs in one atomic unit. If the legs do not balance, nothing is written — the gate in the diagram.

# ledger/posting.py
from dataclasses import dataclass
from decimal import Decimal
from typing import Literal, Sequence
import psycopg

Direction = Literal["DEBIT", "CREDIT"]


@dataclass(frozen=True)
class Leg:
    account_code: str
    direction: Direction
    amount: Decimal  # always positive; direction carries the sign


class UnbalancedEntryError(ValueError):
    """Raised when a transaction's debits and credits do not net to zero."""


def _assert_balanced(legs: Sequence[Leg], tolerance: Decimal = Decimal("0.00")) -> None:
    debits = sum((l.amount for l in legs if l.direction == "DEBIT"), Decimal("0"))
    credits = sum((l.amount for l in legs if l.direction == "CREDIT"), Decimal("0"))
    residual = abs(debits - credits)
    if residual > tolerance:
        raise UnbalancedEntryError(
            f"debits {debits} != credits {credits} (residual {residual})"
        )


def post_entry(
    conn: psycopg.Connection,
    event_type: str,
    invoice_id: str,
    legs: Sequence[Leg],
    dispute_id: str | None = None,
    reverses_entry_id: int | None = None,
) -> int:
    """Write one balanced entry and its legs atomically; return the entry_id."""
    if len(legs) < 2:
        raise UnbalancedEntryError("an entry needs at least one debit and one credit")
    _assert_balanced(legs)  # gate before any write

    with conn.transaction():  # all-or-nothing; a failure rolls back every leg
        entry_id = conn.execute(
            """
            INSERT INTO journal_entry (event_type, invoice_id, dispute_id, reverses_entry_id)
            VALUES (%s, %s, %s, %s) RETURNING entry_id
            """,
            (event_type, invoice_id, dispute_id, reverses_entry_id),
        ).fetchone()[0]
        conn.cursor().executemany(
            """
            INSERT INTO journal_leg (entry_id, account_code, direction, amount)
            VALUES (%s, %s, %s, %s)
            """,
            [(entry_id, l.account_code, l.direction, l.amount) for l in legs],
        )
    return entry_id

Common mistake: validating balance inside the transaction after the inserts and relying on a rollback to undo a bad write. It works, but it churns entry-id sequence values and hides the error behind a database round-trip. Check in Python first; let the transaction guarantee atomicity, not correctness.

Step 3 — Express each event as the right pair of legs

The five event types map to fixed leg templates. Booking an invoice credits the AP liability and debits nothing yet (the offset is the goods/services already received, booked upstream). Filing a dispute moves the disputed amount into a receivable. A credit received or a short-pay applied realizes recovery income and draws the receivable down. A write-off expenses the abandoned claim. The helper below keeps the accounting rules in one place so callers cannot invent an unbalanced pairing.

# ledger/events.py
from decimal import Decimal
from .posting import Leg, post_entry


def file_dispute(conn, invoice_id: str, dispute_id: str, amount: Decimal) -> int:
    """Move a disputed amount into receivable: DR DISPUTES_RECV / CR AP_CONTROL."""
    return post_entry(
        conn,
        event_type="DISPUTE_FILED",
        invoice_id=invoice_id,
        dispute_id=dispute_id,
        legs=[
            Leg("DISPUTES_RECV", "DEBIT", amount),
            Leg("AP_CONTROL", "CREDIT", amount),
        ],
    )


def apply_recovery(conn, invoice_id: str, dispute_id: str, amount: Decimal,
                   event_type: str = "CREDIT_RECEIVED") -> int:
    """Realize recovery and draw down the receivable.

    Used for both CREDIT_RECEIVED (carrier issues a credit memo) and
    SHORT_PAY (the deduction is applied at remittance). Same legs, distinct
    event_type so reporting can tell how the dollar came back.
    """
    return post_entry(
        conn,
        event_type=event_type,
        invoice_id=invoice_id,
        dispute_id=dispute_id,
        legs=[
            Leg("RECOVERY_INCOME", "CREDIT", amount),
            Leg("DISPUTES_RECV", "DEBIT", amount),  # DR reduces the receivable balance
        ],
    )


def write_off(conn, invoice_id: str, dispute_id: str, amount: Decimal) -> int:
    """Abandon a claim: DR WRITE_OFF_EXP / CR DISPUTES_RECV."""
    return post_entry(
        conn,
        event_type="WRITE_OFF",
        invoice_id=invoice_id,
        dispute_id=dispute_id,
        legs=[
            Leg("WRITE_OFF_EXP", "DEBIT", amount),
            Leg("DISPUTES_RECV", "CREDIT", amount),
        ],
    )

Common mistake: correcting a mis-posted amount with an UPDATE journal_leg. The append-only rule is absolute — to fix an entry you post a reversal (reverses_entry_id set, legs mirrored) and then the correct entry, so history shows exactly what happened and when.

Step 4 — Derive running balances and the reconciled position

Balances are a query, not a table. Summing the legs with the account’s normal-balance sign gives the current position; grouping by invoice_id gives the reconciled state of any single claim. Because nothing is ever updated, this query is deterministic and reproducible at any point in time by filtering on posted_at.

-- reporting/balances.sql
-- Current balance per account, signed by its normal balance.
CREATE VIEW account_balance AS
SELECT a.account_code,
       a.normal_balance,
       COALESCE(SUM(
           CASE
               WHEN l.direction = a.normal_balance THEN l.amount
               ELSE -l.amount
           END
       ), 0)::numeric(14,4) AS balance
FROM ledger_account a
LEFT JOIN journal_leg l ON l.account_code = a.account_code
GROUP BY a.account_code, a.normal_balance;

-- Reconciled position of one claim: what is still open on the receivable.
CREATE VIEW claim_position AS
SELECT e.invoice_id,
       e.dispute_id,
       SUM(CASE WHEN l.account_code = 'DISPUTES_RECV' AND l.direction = 'DEBIT'
                THEN l.amount ELSE 0 END) AS opened,
       SUM(CASE WHEN l.account_code = 'DISPUTES_RECV' AND l.direction = 'CREDIT'
                THEN l.amount ELSE 0 END) AS closed
FROM journal_entry e
JOIN journal_leg l ON l.entry_id = e.entry_id
GROUP BY e.invoice_id, e.dispute_id;

The deeper mechanics of the schema itself — the immutability trigger and the constraint that enforces the balance invariant at the database layer — are worked end to end in Designing a Double-Entry Reconciliation Ledger in Postgres.

Validation and testing

The ledger has exactly one invariant that must never break, so the test suite asserts it directly: after any sequence of posts, every entry balances and no account balance can be reconstructed except by summing legs. Property-style tests that post random balanced and unbalanced entries catch regressions the happy path misses.

# tests/test_ledger.py
from decimal import Decimal
import pytest
from ledger.posting import Leg, post_entry, UnbalancedEntryError
from ledger.events import file_dispute, apply_recovery


def test_unbalanced_entry_is_rejected(conn):
    with pytest.raises(UnbalancedEntryError):
        post_entry(conn, "DISPUTE_FILED", "INV-9001",
                   legs=[Leg("DISPUTES_RECV", "DEBIT", Decimal("100.00")),
                         Leg("AP_CONTROL", "CREDIT", Decimal("99.99"))])


def test_every_entry_balances(conn):
    file_dispute(conn, "INV-9002", "DSP-1", Decimal("412.00"))
    apply_recovery(conn, "INV-9002", "DSP-1", Decimal("412.00"))
    rows = conn.execute("""
        SELECT entry_id,
               SUM(CASE WHEN direction='DEBIT'  THEN amount ELSE 0 END) AS dr,
               SUM(CASE WHEN direction='CREDIT' THEN amount ELSE 0 END) AS cr
        FROM journal_leg GROUP BY entry_id
    """).fetchall()
    for _entry_id, dr, cr in rows:
        assert dr == cr  # invariant: debits equal credits for every entry


def test_claim_nets_to_zero_when_fully_recovered(conn):
    file_dispute(conn, "INV-9003", "DSP-2", Decimal("250.00"))
    apply_recovery(conn, "INV-9003", "DSP-2", Decimal("250.00"))
    opened, closed = conn.execute("""
        SELECT opened, closed FROM claim_position WHERE dispute_id = 'DSP-2'
    """).fetchone()
    assert opened == closed  # receivable fully drawn down

The fixture matrix worth keeping: a partially recovered claim (recovery < disputed), an over-credit (carrier returns more than claimed), a reversal of a mis-posted entry, and a write-off followed by an unexpected late credit. Each has a known reconciled position, so a schema change that breaks the arithmetic surfaces as a failing assertion, not a controller’s question at quarter-end.

Performance and tuning

The journal grows without bound because it is append-only, so the tuning story is about keeping the balance queries fast as history accumulates. Range-partitioning journal_leg by posted_at keeps a monthly report scanning one partition instead of the whole table, and materializing balances on a cadence avoids re-summing years of legs for a dashboard.

Knob Typical setting Effect Watch for
journal_leg partition Monthly by posted_at Balance/report queries prune to recent partitions Cross-month claims still need the parent scan
Materialized account_balance Refresh every 5–15 min Dashboards read a table, not a live SUM Staleness during a heavy posting window
Posting batch size 200–500 entries/txn Amortizes commit overhead on bulk backfills Long transactions hold locks; keep under a few seconds
idx_leg_account Always present Per-account balance stays index-scan Bloat on high-churn accounts; reindex periodically

For a program processing tens of thousands of claims a month, a single Postgres node summing an indexed, monthly-partitioned journal_leg returns account balances in single-digit milliseconds cold, and a materialized view makes the reporting dashboard effectively free. The append-only design pays off here: because rows are never updated, the partitions are write-once and highly compressible, and autovacuum has almost nothing to do.

Failure modes

Symptom Root cause Resolution
Post rejected with UnbalancedEntryError Legs constructed by hand with debits ≠ credits Use the event helpers in Step 3; never assemble legs ad hoc
Recovered-dollars total drifts from source A stored balance counter was updated out of band Delete the counter; derive from account_balance; reconcile once
History shows a changed amount An UPDATE/DELETE slipped past the append-only rule Revoke UPDATE/DELETE; correct via reversal entries only
Claim shows a phantom open balance Recovery posted under a different dispute_id than the filing Normalize the match key before posting; see the short-pay reconciliation guide
Sub-cent residual on some entries float money crept into an amount upstream Enforce Decimal end to end; quantize to 4 places at the boundary
Report totals shift on re-run Balances summed against a moving now() window Pin the report to a posted_at cutoff; the ledger is reproducible point-in-time

The currency-rounding failure is the subtle one: because the balance invariant tolerance is 0.00, a single float that introduces 0.00000001 of drift will reject an otherwise-correct entry. Quantize every amount to numeric(14,4) at the API boundary and the class of bug disappears.

Integration points

The ledger’s output is two derived views with a stable contract. Settlement draws down account_balance when a carrier actually pays, and reporting sums RECOVERY_INCOME and claim_position to produce the recovered-dollars figure AP reports upward. Neither consumer is allowed to write to the ledger — they read the derived state only.

Consumer Reads Contract guarantee
Settlement account_balance (AP_CONTROL, DISPUTES_RECV) Balances derived from legs; never a stored counter
Reporting RECOVERY_INCOME balance, claim_position Reproducible for any posted_at cutoff
Audit / SOX Full journal_entry + journal_leg history Append-only; every change is a visible entry
Short-Pay & Deduction Management claim_position.closed Tells the deduction engine what is already reconciled

The reconciliation that closes short-pays against a carrier’s remittance — where the identifiers and amounts rarely line up cleanly — is its own problem, worked in Reconciling Short-Pays Against Carrier Remittance. It writes back into this ledger through the same posting API, so the balance invariant holds regardless of how messy the match was.

In this section


Up: Discrepancy Resolution & Dispute Routing