DEV Community

Cover image for Three-way reconciliation as a data integrity check for law firm trust accounts
Damin Mutti
Damin Mutti

Posted on Originally published at zerabooks.com

Three-way reconciliation as a data integrity check for law firm trust accounts

A three-way trust reconciliation compares the trust bank statement's closing balance, the trust liability in the general ledger, and the sum of the per-client trust ledgers. All three must be equal at month end. Treated as data, it's an invariant across three independent sources, and when one disagrees, the pattern of who disagrees tells you which record is bad.

Most state bars expect this check monthly, and the rules vary by state, so read this as a data model, not legal advice. I run ZeraBooks, and this is how we think about the books side of it. Figures are made up.

Separate the money in the chart of accounts

A lot of small firm ledgers have one "Bank" account. Trust money needs its own asset account and a matching liability so every trust transaction touches both:

Type Account Rule
Asset Operating checking 2204 Firm money only
Asset IOLTA trust 7730 Client money only
Liability Client trust funds Equals the IOLTA balance at month end
Asset Client costs advanced Firm-paid costs to bill back
Revenue Legal fees Posted only when earned

The design goal: IOLTA 7730 and Client trust funds move together. A retainer deposit debits the asset and credits the liability. A disbursement to a client reverses both. Any row that hits 7730 without hitting the liability breaks the invariant, so it's worth a constraint, or at least a nightly query.

-- every journal entry that touches trust must touch the liability by the same amount
SELECT j.entry_id
FROM journal_lines j
GROUP BY j.entry_id
HAVING SUM(CASE WHEN j.account = 'IOLTA trust 7730'   THEN j.debit - j.credit ELSE 0 END)
    <> SUM(CASE WHEN j.account = 'Client trust funds' THEN j.credit - j.debit ELSE 0 END);
Enter fullscreen mode Exit fullscreen mode

Two kinds of entry legitimately break this on their own. The transfer of earned fees from trust to operating moves cash without touching the liability, and the earned-fee journal then moves the same amount from the liability to revenue. Each is lopsided alone and together they balance, so tag both with a type, exclude them here, and check that each month's transfers equal that month's earned-fee journals.

The month-end test is three sums

Assume journal_lines(entry_id, account, debit, credit, date) and a client_ledger(client, balance) export from practice management software:

-- 1. Bank: the closing balance printed on the statement. An input, never computed.

-- 2. Ledger liability
SELECT SUM(credit) - SUM(debit) AS trust_liability
FROM journal_lines
WHERE account = 'Client trust funds' AND date <= DATE '2026-09-30';

-- 3. Client ledgers
SELECT SUM(balance) AS client_total FROM client_ledger;

-- and no client may ever be negative
SELECT client, balance FROM client_ledger WHERE balance < 0;
Enter fullscreen mode Exit fullscreen mode

The statement balance has to be an outside number. A balance derived from your own transactions can only prove your records agree with themselves.

Reading a mismatch

Illustrative September:

Source Amount
Statement closing balance 31,165.00
Client trust funds (ledger) 31,200.00
Client ledgers (12,000.00 + 9,700.00 + 9,500.00) 31,200.00

The ledger and the client ledgers agree and the bank is $35.00 short. A small difference on the bank side alone usually means the bank took a fee out of trust that nobody recorded. That's a real problem, because it spends client money on a firm expense. The fix is to put $35.00 back from operating and get the bank to charge fees to operating from now on.

The general pattern, as a function:

from decimal import Decimal

def diagnose(bank: Decimal, ledger: Decimal, clients: Decimal) -> str:
    if bank == ledger == clients:
        return "tied"
    if ledger == clients:
        return f"bank off by {bank - ledger}: unrecorded bank charge or deposit"
    if bank == clients:
        return f"ledger off by {ledger - bank}: trust line coded to revenue, or a transfer without its earned-fee entry"
    if bank == ledger:
        return f"client ledgers off by {clients - bank}: deposit on the wrong client, invoice applied twice, or a fee billed and never moved"
    return "all three differ: fix the statement import first"
Enter fullscreen mode Exit fullscreen mode

Prove the statement before you trust it

The comparison is only as good as its inputs. If the import of the trust statement dropped a page, every number downstream is wrong in the same direction. So before reconciling, check the statement itself: the opening balance plus every line equals the printed closing balance, and the opening balance equals last month's printed close.

def statement_ok(opening, lines, closing, previous_closing):
    return opening + sum(lines, Decimal("0")) == closing and opening == previous_closing
Enter fullscreen mode Exit fullscreen mode

Where ZeraBooks fits

ZeraBooks is an AI-native general ledger, bank statement converter and done-for-you bookkeeping service for small businesses and accounting firms. Its ledger runs those statement checks on every uploaded PDF or CSV and keeps the IOLTA account and the Client trust funds liability proven against the statement. It doesn't keep per-client trust ledgers, so the third number comes from your trust software. No bank feed, no Clio or QuickBooks sync. The full chart of accounts and a worked month are in the guide to bookkeeping for law firms, and the done-for-you option is on ZeraBooks bookkeeping services.

When the numbers disagree, the odd one out is where you start looking.

Top comments (0)