Eighteen months of unreconciled accounts across four data sources. The lesson is not that agents can do bookkeeping — it is that reconciliation has an arithmetic proof, and once you insist on that proof, automation stops being dangerous.
A company stops reconciling in October. By the following August, a dozen bank and card accounts have drifted. The obvious story is neglect: someone got busy, the ledger rotted, and now every month needs picking through by hand.
The likelier story is structural. In our case a new business bank account opened in late September, and a week later most card spending moved onto a corporate card platform. Neither had a matching ledger account, so those transactions had nowhere to go. The bookkeeping did not decay gradually — it hit a wall on a date, because the stack changed and the chart of accounts did not follow.
That changes the work. Gradual decay means grinding through twelve months of small errors. A structural break means creating the missing accounts, importing the missing streams, and finding the few real mistakes hiding in the noise.
In the run this series is drawn from, roughly two thousand seven hundred bank transactions across fourteen accounts and eighteen months reduced to about a thousand that had never been entered — and seven ledger entries that were actively wrong. All seven were made in the same week.
The point of automation is not to categorize faster. It is to narrow thousands of transactions to the few that are actually wrong.
The instinct with messy books is to start labelling: open the oldest uncategorized transaction, decide it is a software expense, move on. It feels like progress because the queue shrinks.
It is the wrong order, for a reason worth internalising: categorization cannot detect a missing transaction. If a payment never reached the ledger, no amount of labelling the transactions that are there will reveal it. You can categorize a set of books perfectly and still have them be wrong by six figures.
Reconciliation can. It asks "is everything here?" rather than "what is this?", and answers with arithmetic rather than judgement:
The bank's closing balance, minus the ledger's closing balance, equals the sum of everything missing from the ledger. If those two numbers do not match, you have not found everything. If they do, you have — and no judgement was involved.
That converts "did we find everything?" from an opinion into a check. A list that sums to the variance is provably complete; one that does not is provably incomplete, however plausible each line looks.
Only once the balances tie is categorization safe: now you are labelling a set you know is whole. Part three covers that step. In the other order you are decorating an unknown.
First you need the transactions. Most of the surprises live here, all boring practical limits.
An aggregator's first sync returns whatever the bank makes immediately available — often about a month — with backfill arriving over the following hours. Query too early and you conclude the bank holds thirty days. The maximum is a parameter, defaults low, and changing it means re-linking.
Direct downloads stop at a hard wall instead: eighteen months is common, measured from today rather than a calendar boundary. Nine months into a year, the prior year's earliest months are already unreachable; the only route left is PDF statements.
An OFX or Web Connect file gives each transaction a bank-assigned identifier, which makes an import idempotent: run it twice and the second books nothing. A CSV has no such field, so duplicate protection falls back to amount, date and description — which breaks the first time someone buys two identical coffees three days apart. Where a bank offers only a spreadsheet, accept the weaker guarantee knowingly.
Export menus offer delimited variants — comma, tab, pipe, space — and a parser written for one reads another as zero transactions: unrecognised header, no rows, a clean success, nothing booked.
Guard the import: a fetched file must parse to more than zero rows before it replaces the previous one. Where the export states totals, check them against the parsed rows; where it does not — most consumer downloads — running balances do the same job: prior close plus parsed rows equals the stated closing balance. A failing file goes to quarantine; the known-good previous export stays put. The automation guide's report counts rows per source for the same reason.
The instinct is to pick the best feed and standardise on it. That instinct is wrong, and the cost of following it is measured in entire missing years.
Take two sources for one bank: an aggregator API, and the bank's own downloadable export. On wires, the aggregator wins outright. The bank's export truncates a wire to its reference numbers, while the aggregator names the counterparty — the sender on an inbound wire, the beneficiary on an outbound one, sometimes with the invoice number attached. Fifteen five- and six-figure wires in our books looked permanently unidentifiable from the export and were fully named in the aggregator feed.
On credit-card purchases, the same aggregator loses catastrophically. For those cards it returned only payments to the card, finance charges and statement credits — not one individual purchase. Eighteen records where the bank's own export held four hundred and sixteen.
Same institution, same link, same month. One source knows who sent the money; the other knows what was bought. Neither knows both.
Notice which way that error runs. The aggregator's card data is not obviously broken — real transactions, correctly dated, correctly signed. A reconciliation built on it computes a card movement from payments alone, and the number looks entirely plausible. You find out at year end, from a tax preparer.
So the rule is not "use the better source". It is merge every source, keep every field, and record which source supplied each value.
Those are the columns in a credit-card export. Green is what our importer kept. Red is what it silently dropped on the way into the database — for months.
The parser read every one of them correctly. The loss happened one layer further in, at the INSERT: the table had columns for a description and an amount, so that is what got written. No error, no warning, no missing rows. Just a quietly poorer copy of a file that was still sitting on disk.
What was thrown away turned out to be the best categorization signal in the entire pipeline. MCC is a standardised merchant category code. Merchant category is a clean vendor name — no descriptor parsing, no token boundaries, no drift when a company renames itself. Expense category is the bank's own classification, and across three and a half thousand transactions it used just eighteen values. Cardholder answers "whose personal charge is this" without anyone guessing.
The fix took an afternoon. The lesson is worth more than the fix: a feed row should be stored whole. Storage is the cheapest thing in the system, and a field you discard is a field you will later reconstruct by inference, badly. Add columns as the source offers them, keep the raw payload alongside, and let the consuming code decide what it needs.
None of it is difficult, and all of it is invisible until it bites — each silently changing what "complete" means. Treat collection as its own phase with its own verification, the way you would a context window you are trying not to overflow.
The naive approach is to match on amount and date and call the remainder "missing". That produces a list, and the list is wrong in four specific, repeatable ways.
The simulation below runs a matcher over one account. Each pass claims what it can explain — note that the naive pass alone would have you booking transactions already in the ledger.
| Date | Amount | Description | Status |
|---|
All figures in these simulations are invented for illustration.
Ledgers nest. A corporate card with two cardholders is often one parent account with a child per person, and the spending posts to the children. Search the parent by name and you find nothing, then conclude every charge is missing. Match against the account and all of its descendants.
When money moves between two accounts you own, the bank reports it twice — a debit in one, a credit in the other. Most accounting systems represent it as a single transfer object moving both sides at once.
That object appears in a transaction report once, filed under the sending account, with the receiver named only in a "split" column. Query the receiving account and the money is not there. It arrived, the balance is right, but no row carries its name.
Consequence: a per-account matcher reports every inbound transfer as missing. Book them and you double the money.
Related, and subtler. A bookkeeper records money arriving: a deposit on the receiving account whose offset is the sender. One entry, two accounts moved, filed under the receiver. The sender's bank debit now has no row under its own name — and never will, because the entry exists and is correct.
A matcher requiring each ledger entry to be consumed by one bank row cannot see these: the entry was already consumed by the other side. One entry legitimately satisfies two bank rows. Encoding that is one line, and genuinely counter-intuitive.
Every source signs money differently. Aggregator feeds commonly report outflows as positive. Bank export files sign them from the account holder's perspective, so outflows are negative. Card exports report spending as positive debits. And a credit card's balance is stated as the amount owed — a liability, positive — while its transactions are signed like everything else.
Invert once too often and every expense becomes income. The books still balance internally, every total is wrong, and nothing looks broken. Convert at the boundary, once per source, and write a test that pins the direction with a real example. Sign errors are not caught by reading code; they are caught by asserting that a known expense comes out negative.
The test again, because it is the whole discipline: the unmatched bank rows must sum to the account's variance. Both sides come from somewhere authoritative — the bank's statement, the ledger's balance sheet — and neither is computed by the thing being tested.
Click transactions to include them and watch the meter. The account is only reconciled when the bar sits on the centre line.
Notice what "plausible guess" does: it picks the transactions a reasonable person would, and lands close to the target without hitting it. Close is indistinguishable from correct by eye and completely different in a filing.
That test is right and also incomplete. A ledger can disagree with a bank because transactions happened and were never recorded, or because entries were recorded that never happened. Only the first can appear in a list of bank rows missing from the ledger — the second has no bank row to find.
So on an account carrying a phantom entry the test can never pass. Chase it and you either force-fit something or call the account broken when it is understood perfectly. The identity needs both terms:
bank rows missing from the ledger + ledger entries with no bank event = variance
One card in our run demonstrated it cleanly: its list overshot the variance, and the excess was a handful of ledger entries sharing a single date, none with a counterpart on any account. Two agents worked it independently and both refused to close the gap by trimming rows. That refusal is the useful behaviour — a check that can fail loudly while the work underneath is correct is worth more than one that always passes.
Several bad entries across several accounts, all inside one week, is not carelessness. It is one bad session at a keyboard. Errors cluster in time because their causes do, which makes when a better first question than what.
Pointing a capable model at a pile of transactions and asking it to reconcile them works badly, for a reason that generalises past bookkeeping: it produces an answer that looks right, and looking right is not the standard.
The division that worked was blunt. Anything with a provable answer is code — balance arithmetic, matching passes, the completeness check. Judgement is where agents earn their place: a dozen accounts each had a residual variance and no obvious explanation, which is real diagnosis — hypothesis, query, check, discard, repeat. A dozen agents ran that loop in parallel, one per account.
What mattered more than the parallelism was the success criterion: your list must sum to the variance, and if you cannot get there, say so. An agent told to reconcile an account will reconcile it, dropping an inconvenient row if necessary. One told that an honest "unresolved, and here is the exact unexplained amount" beats a padded answer will tell you when it is stuck — and one did, surfacing a duplicate no amount of importing would have fixed.
Each finding then went to a second agent instructed only to disprove it. That catches the failure mode that matters — not a wrong answer but a plausible one. Same shape as running several agents against one repository: independent workers, a shared substrate, a check that does not depend on any of them being honest.
Two entries looked like phantoms — no corresponding bank transaction anywhere in the collected data — so the obvious move was to delete them. The accounting system refused: both were matched to downloads in accounts that had never been collected. The reasoning had been "absent from our data, therefore absent from reality", which holds only if the data is complete. It was not. A confident conclusion drawn from a partial dataset is indistinguishable from a correct one until something external contradicts it.
This work is long-running, partly interactive, and needs to survive a closed laptop — the agent investigating account nine should not die because a phone locked.
TermRoam gives each project persistent terminal sessions that outlive the browser, so a run started at a desk can be checked from a phone an hour later and resumed next morning in the same state. Agents run inside those sessions; you attach and detach at will. When the work is a queue of judgement calls rather than a script, that persistence is the difference between one continuous effort and a dozen restarts.
Next: automating the monthly close so this never has to be a catch-up project again.