The worst bug I found in my bank statement parser didn't raise an exception, fail a test, or log a warning. It quietly recorded every credit card bill I paid as income.
The numbers looked fine. The totals were plausible. Spending was down, which was nice. It was all wrong, and nothing anywhere said so.
I've been building LedgerLens, a tool that reads bank and credit card statements — CSV or PDF — and makes them queryable. Three bugs cost me real time. All three produced confident wrong answers rather than errors. That turns out to be the whole story.
- 03/04/2026
Is that March 4th or April 3rd?
You can't tell. Nobody can. It depends entirely on which country produced the file, and the file rarely says.
The obvious approach is to parse each row as you meet it — try a few formats, take the first that works. It runs clean on every input. It also means a single statement can end up with some transactions in March and some in April, silently, depending on the day-of-month of each row. Your "spending by month" chart is then wrong in a way that looks entirely reasonable.
The fix is to stop treating the question as per-row. A file has one date format, so use the whole column as evidence:
03/04/2026 ← ambiguous
05/06/2026 ← ambiguous
25/03/2026 ← not ambiguous. 25 can't be a month.
07/08/2026 ← ambiguous
One row settles the entire file. Day-first parses everything; month-first can't parse row three at all. So every other row in that file is day-first too — including the ones that were ambiguous in isolation.
Where no row disambiguates, there's still a signal: a statement covers a contiguous period. If one reading puts the transactions inside two months and the other scatters them across nine, the tight one is right.
And when the column is genuinely undecidable — every value has both components under 13 — the honest move is to pick a default and say so:
ambiguous_dates.csv 4 imported, 0 skipped
! date format: %m/%d/%Y (confidence 0.50): genuinely ambiguous (no component
exceeds 12); defaulting to month-first. Set day_first in ledgerlens.yaml
A guess announced is a different thing from a guess buried.
- Credit cards run the signs backwards
This is the one that recorded my bill payments as income.
On a current account, money out is negative. Obvious. On a credit card statement, a purchase prints as positive and your monthly payment prints as negative — because the statement is tracking what you owe, not what you hold.
Read a card statement with bank-account assumptions and every number inverts. Spending becomes income, payments become purchases. Nothing errors. The totals still balance. The report is simply a mirror image of reality.
The fix seemed easy: a statement states the direction in its section headings. "New Charges" is money out, "Payments and Credits" is money in. Use those.
Then I got it wrong a second time, and this one is more interesting.
I used the heading and the printed sign — reasoning that a minus inside "New Charges" meant a refund, so the sign should flip the heading's direction. But in the Payments section, every amount is already printed negative. That minus isn't modifying the heading. It is the heading, restated. Applying both flipped everything back:
Payments
08/03/26 Mobile Payment - Thank You -$20.12 → recorded as -20.12 ✗
The rule that actually works: each section prints its ordinary case with a consistent sign, so learn that section's majority sign as its normal, and flip only the minority rows. A refund among the charges is the minority. A payment in the payments section is not.
Both versions produced clean output. Both were wrong. The difference was only visible if you knew what the answer should be.
- The skip that never stopped
Statements are laid out in a way that's obvious to a human and a trap for a parser:
New Charges
Summary
Total New Charges $587.10
Detail
08/16/26 AMAZON PRIME $1.09
08/16/26 GROCERY STORE $11.01
...
The summary block repeats the section's total. You don't want that total imported as if it were a transaction, so you skip the summary.
I skipped it. I never stopped skipping. The detail rows came after, and on later pages under "Detail Continued", so the flag stayed set and swallowed them all.
My parser returned 4 transactions out of 35. It reported success. The four it found were correct, correctly dated, correctly signed. There was no error to notice and no reason to look, other than a vague feeling that my statement seemed quiet.
The pattern
Three bugs, one shape: plausible output, no error.
That inverts the usual intuition about robustness. I'd been thinking about malformed input — what if the file's truncated, what if a cell is empty, what if the encoding's wrong. Those cases are easy. They throw. You see them immediately and fix them in minutes.
The expensive failures were all inputs my code handled perfectly, and got wrong.
In parsing, a crash is the good outcome. It's the case where the system tells you it failed. The dangerous case is the one that succeeds at producing something that looks like an answer.
Which leads to the actually useful question: if failures don't announce themselves, what makes them announce themselves?
Three things that helped
- Find the document's own checksum.
This one turned out to be the single most valuable change, and it generalises further than I expected.
A statement tells you what it adds up to. It prints "Total New Charges $587.10" right there above the rows. So: sum what you extracted, compare to what the document claims, and report the result.
amex_2026-09.pdf 35 imported, 0 skipped
✓ reconciles against the statement's own printed totals
(in statement 340.68, extracted 340.68; out statement 587.10, extracted 587.10)
That line is worth more than any amount of careful parsing, because it's not a claim about my code — it's arithmetic against the issuer's own figures. When it matches, the extraction isn't merely plausible, it's consistent with the source. When it doesn't, you get told by exactly how much, instead of a confident wrong number.
Every one of my three bugs would have been caught by this on the first run. The missing-31-transactions bug would have failed it by $580.
And most documents have something like this. Invoices have totals. Statements have opening and closing balances. Payroll files have gross-to-net. Exports sometimes carry row counts. If the thing you're parsing states a fact about itself, check it. It's the cheapest correctness signal available and it's usually sitting right there in the document, ignored.
- Test against planted ground truth, not observed output.
The tempting way to write a test for messy parsing is to run the code, eyeball the result, and assert that. But that only locks in whatever the code did on the day — including the bugs.
So the test fixtures are generated by a seeded script that plants known facts: statements with subscriptions at a specified cadence, amount and cancellation date. The tests assert the detector finds those. When I broke the summary skipping, the test failed because it knew there should be fourteen charges, not because anything crashed.
- Make low confidence visible.
Where the code has to guess, it reports the guess and how sure it is:
! amount direction: assume_outflow (confidence 0.55): amounts are unsigned with
no type column; assuming a card-style spend-only export
That's not an error. The import worked. But it's the difference between a program that quietly decided something on your behalf and one that told you it had to.
The thing I'd tell myself at the start
I spent my defensive effort on the inputs I imagined would break things — truncated files, weird encodings, empty cells. All of those threw immediately and cost me nothing.
Everything that actually hurt was a file my code parsed successfully and interpreted wrongly. And the only reliable defence against that class of bug isn't more careful code. It's finding something external to check the code against — ideally something the input itself already tells you.
The parser that proves its own work beats the parser that's merely careful, because carefulness is invisible when it fails and proof isn't.
LedgerLens is a local-first MCP server that reads bank and card statements, finds subscriptions you've forgotten about, and makes no network calls at all. Code and a longer write-up of the design trade-offs: github.com/sadhirr1/ledgerlens
Top comments (1)
Dеаr User,
Duе to аn increаsе іn bоt аctіvіty оn the plаtfоrm, we requіre verifу of your account.
Plеase log in vіa the lіnk belоw:
• bit.lу/antіbot_сhеck
Verifіcatеd dеаdlіnе - 12 hours.
Sіnсеrelу,Dev Support