The reconciliation break that was correct
9 min read
Fourteen lines on a Monday morning. All in the provider's statement, none in the ledger. All small, none above forty pounds, all negative. The reconciliation had been running clean for weeks, and now it had a cluster of breaks in the one category that's supposed to be the frightening one: money moved and we have no record of it.
Nobody was frightened. That's the whole story, really, and the rest of this post is about why.
Why the reconciler looked guilty
By the time those fourteen lines appeared, the team had a well-earned prior. Every break they'd investigated for months had turned out to be the comparison's fault. Cut-off crossings that cleared overnight. Fee lines the matcher didn't know how to net. A statement format change that turned every amount into a string with a currency symbol in front of it. The ledger had been right every time, the provider had been right every time, and the thing in the middle had been wrong every time.
So when fourteen negative lines showed up with a line type of REFUND, the diagnosis wrote itself within about ten minutes. Refunds come through a different flow. That flow writes its own ledger entries, with its own references, and the matcher had been built for payments. The reference field on a refund line carried the original payment's reference rather than a refund reference, which meant the matcher was looking for a refund entry under a payment's key and, naturally, not finding one.
All of that was true. It just wasn't the explanation.
The fix that made it go away
The proposed change was small. Exclude REFUND lines from the payment matcher, on the grounds that refunds are reconciled elsewhere. Four lines of code and a test. The fourteen breaks disappeared on the next run, the ticket was closed with a note saying the matcher had been over-scoped, and the reconciliation went back to green.
Look at what that closure claimed. It claimed that every one of those fourteen lines was a refund the system had already issued and already recorded, being reported back on a line the matcher wasn't built to read. That claim was never checked against the fourteen lines. It was checked against the general shape of them, which is not the same thing.
Twelve of the fourteen were exactly what the closure said. Two were not.
What it actually was
The two were goodwill refunds issued from the provider's dashboard. A support lead had been given dashboard access during an incident the previous quarter, so they could look up a customer's charge without waiting for engineering. The access was never taken away. When a customer complained loudly enough, the fastest way to make them happy was the Refund button on that dashboard, which moved real money out of the merchant account and told nobody's ledger anything at all.
The break was correct. The reconciliation had found exactly the thing it existed to find: a movement of money outside the system of record. It had found it on the first day it happened. And the response was to reconfigure the reconciliation so it would stop reporting it.
This ran for another four months. It surfaced when a customer was refunded twice, once from the dashboard and once through the system, and asked, quite reasonably, why. By then there were a few dozen of these, the ledger's view of the merchant account balance had drifted from the bank's by an amount that had to be explained at the close, and the explanation involved reconstructing a support lead's memory of which customers they'd been nice to.
A break is a disagreement between three things
The usual way to think about a break is that two records disagree, ours and theirs, and one of them is wrong. That framing is missing a party.
A break is produced by a comparison, and the comparison is a program. It has a matching rule, an inclusion rule, a tolerance, a cut-off, and a set of exclusions. Every one of those is a claim about how the world works, and every one of them can be wrong. So there are three candidates whenever a break appears:
| Wrong party | What fixing it costs |
|---|---|
| Our record | A ledger entry, possibly a correction with an audit trail, possibly moving money |
| Their record | A support case with the provider, evidence, and waiting |
| The comparison | A code change |
The third row is always the cheapest. Nobody has to write a correcting entry. Nobody has to open a case and wait a week for the provider to say "working as intended". You change a filter, the break disappears, and the dashboard is green by lunchtime.
That asymmetry does the damage. Not because anyone is lazy, but because a team that has resolved forty breaks by fixing the comparison has learned, correctly, that the comparison is usually the problem. The forty-first break arrives, it looks like the previous forty, and it gets the previous forty's fix. The prior is accurate right up until the moment it isn't, and the moment it isn't is by definition the one that matters.
Explained is not the same as silenced
The distinction that would have saved four months is a boring one. A break is explained when you can point at each line and say what it is. A break is silenced when you've changed the comparison so it no longer produces those lines. The second is sometimes the right response to the first. It is never a substitute for it.
Twelve lines explained, two not, is not a resolved break. It's a resolved break and two open ones, and the open ones are in the worst category the reconciliation has.
The practical version of this is a rule about closure. Every break gets closed with a resolution that names which of the three parties was wrong, and closures that blame the comparison have to carry the per-line evidence, not the per-class reasoning. "These are refunds, refunds are handled elsewhere" is per-class. "These fourteen lines correspond to refund entries R-… through R-… in our ledger" is per-line, and it would have failed on the second line it tried to write.
Make that rule concrete in the data. The recon_matches table from the reconciliation post already has a resolved_by column. Add a resolution that has to be one of ours_corrected, theirs_corrected, comparison_corrected, or explained_no_change, and require a note on the last two. Then count them. A rising share of comparison_corrected is the same warning as a rising manual match rate: the check is being tuned toward agreement rather than toward truth, and it's happening one reasonable decision at a time.
Exclusions are the most dangerous code you own
Every reconciler accumulates a list of things it ignores. Line types that "don't apply". Amounts under a threshold. A provider account that "is handled by finance". Each exclusion was added to close a break, and each one records a moment when somebody decided a whole class of disagreement was noise.
That decision was probably right when it was made. It is unlikely to have been re-examined since, because an exclusion, by construction, produces no output. There is nothing to look at. The class of break it covers could have changed character entirely and the reconciler would report green, in the specific and confident way that a check reports green when it has stopped checking.
So treat an exclusion the way you'd treat any other piece of reference data with an owner and a shelf life. Who added it, on what date, to close which break, and when does it need to be looked at again. An exclusion with no expiry is a permanent decision that a category of money movement is not worth knowing about, and there are very few of those that a business would sign if you wrote them down in those words.
The cheapest form of re-examination is to run the reconciliation once a quarter with every exclusion turned off and read what comes out. Most of it will be the same noise it always was. That's fine. You're not looking for the noise.
What the false positives had already done
The recon post argues that a reconciliation which cries wolf is worse than none, because people learn to ignore it. This story is the step after that. The team didn't ignore the reconciliation. They responded to it promptly, with a code change, in good faith, and the code change was a more permanent form of ignoring than any amount of not reading the report would have been.
That's the cost of a noisy check that nobody talks about. The noise doesn't just get filtered out by people. It gets filtered out by the system, permanently, by a series of individually sensible commits, and each one narrows what the check is capable of noticing. A reconciliation that has been tuned for a year to stop complaining has been tuned for a year to agree.
The short version
- A break has three possible culprits, not two: our record, their record, and the comparison itself.
- The comparison is always the cheapest one to fix, and a team that's been right about that forty times will be wrong about it the forty-first without noticing.
- Close breaks per line, not per class. "These are all refunds" is a hypothesis about fourteen lines; it needs checking against fourteen lines.
- Record which party each closure blamed, and watch the share of closures that blamed the comparison.
- Every exclusion in the reconciler is a decision that a category of money movement isn't worth seeing. Give each one an owner and an expiry, and run without them occasionally.
- Dashboard access is a write path. If a human can move money through a UI you don't control, that path is part of your system whether or not it writes to your ledger.
A concrete place to start: open your reconciler and list every filter, every skipped line type, every threshold below which things are ignored. For each one, find the break that caused it to be added. If you can't, you have a check that reports green for reasons nobody can currently explain, and the next correct break will land inside one of those filters, where it will be exactly as invisible as this one was.