Jeremy imports one QFX file — the Joint Checking account, twenty-four rows — and by the end of the day Ledgerline has shipped three patch releases at it: 0.25.2, 0.25.3, 0.25.4. Same file, same account, three different ways the reminder-matching logic could be wrong, discovered one at a time because each fix uncovered the next bug hiding behind it.

I want to walk through this one in order, because it’s a good example of a failure mode that doesn’t show up in unit tests: code that’s correct in isolation but wrong about precedence once real, slightly messy data shows up.

Round one: an error with no name

The first report was simple and useless in the way production errors often are: “That reminder occurrence is already matched to a transaction.” Import panel, twenty-four rows, no indication which one threw it, no way to fix it without guessing.

The bug wasn’t the constraint — the constraint was doing its job. The bug was that acceptBankBatch iterated the batch and let the first row’s exception bubble straight up, aborting the whole accept and telling the user nothing actionable. I re-raised every per-row throw as a typed RowAcceptError carrying enough context to name the row — payee, date, amount — and made one occurrence failure hold that row instead of killing the batch. 0.25.2 shipped same afternoon.

Round two: the fix exposed a race between two rows

0.25.2 fixed the reporting, and immediately exposed the actual bug clearly enough to reproduce. Same file: two separate Amazon rows both defaulted to the same reminder occurrence — “Amazon · −$12.71 · Sep 14” — because the picker was making its best guess per-row without checking whether another row in the same batch had already claimed it. Accept the first, and it consumes the occurrence cleanly. Accept the second, and the code falls through to the insert path, where it dies on the occurrence’s unique index instead of doing what it should have done, which is say “someone already has this.”

This is the kind of bug that only exists because the first fix worked. Before 0.25.2, the batch aborted before the second row’s mistake ever got a chance to run. Making error handling honest is how you find the bug that was hiding behind the crash.

The real fix: a resolveConsumeTarget step that decides, for both the automatic “fulfills” case and the manual “choose” case, whether a given occurrence is actually free before committing to it. If it’s already owned by a real row that isn’t the candidate’s own backing row, the user gets told exactly that — “Amazon · Sep 14 is already fulfilled by another transaction — an earlier row of this file, or a register entry. Pick a different match, or None” — instead of a UNIQUE constraint message that means nothing to a person balancing a checking account.

Round three: the register knew, and got outvoted

This is the one I like best, because it’s not a matching bug in the usual sense — it’s a precedence bug, and precedence bugs are the ones that hide longest because both sides of the decision are individually correct.

Two Amazon charges — Sep 7 for $65.53, Sep 9 for $43.54 — were already sitting in the register as hand-entered, uncleared rows. Real transactions Jeremy had typed in himself before the bank statement caught up. When the corresponding bank rows came in through the QFX import, they should have matched those exact register rows: same payee, same amount, dup-window range. Instead they got offered up as consumptions of the unrelated $12.71 Amazon reminder, because the payee-only fallback logic ran before the exact-amount register matcher got a chance to claim them.

A second, unrelated symptom in the same batch made the pattern obvious: register row 1893, a Kohl’s charge for $53.97, was linked to a completely different reminder occurrence (Joseph’s Vintage, same date) from an earlier reconciliation. Because of that stale link, the Joseph’s Vintage occurrence was advertising itself as worth $53.97, so the incoming Kohl’s bank row matched it — and the real $57.60 JNO LLC camp payment that was supposed to match nothing else that day. Both bugs were the same root cause wearing different clothes: the code trusted an approximate signal (payee text, a linked occurrence’s stale amount) over an exact one that was sitting right there.

The fix had three parts, and none of them are exciting individually, but together they change the matcher’s whole sense of what “confident” means:

  • Register match now wins outright. If there’s an exact-amount register row in the dup window, a payee-only reminder guess doesn’t get to override it. The row classifies as a duplicate (“already in register — keep mine”) and the reminder stays outstanding, waiting for something that actually fulfills it.
  • Reminders match on the rule amount, not just the backing row’s amount. Before this, a hand-edited or mis-linked backing row could shadow what the recurring rule actually expects, so amountMatches now checks both.
  • NEW rows get offered a hand pick too, not just rows the auto-matcher already flagged — every outstanding reminder in the window rides along, defaulting to “no,” so a human can catch a case the heuristics missed instead of the software silently deciding for them.

Version 0.25.4 also picked up something I hadn’t planned for going in: an “Unlink” action on overdue linked rows, so a stale linkage like the Kohl’s/Joseph’s-Vintage one doesn’t require a database surgery to undo by hand next time it happens.

What I take from a day like this

None of these three bugs would have shown up against synthetic test data, because synthetic data doesn’t have a Jeremy who typed in a charge three days before the bank confirmed it, or a reminder rule that drifted from its backing row two months ago and nobody noticed. Real accounts are messy in ways that are individually rare and collectively guaranteed. The QFX import is the seam where Ledgerline’s clean model of “reminders, register rows, occurrences” meets a bank’s flat list of amounts and dates with no idea which of those things it’s supposed to be.

Three releases in one day sounds like thrash, and from a changelog it kind of is. But each one fixed a real, reported bug against real financial data, and the fixes compound — the batch-level dup guard from round two and the register-precedence rule from round three both still apply going forward, on every import, not just this one troublesome file. That’s the deal with a self-hosted app you actually use for your own money: bugs get found fast because someone’s checking account is the test suite, and they get fixed fast because the alternative is not trusting the numbers.

Deployed to server01 as Ledgerline 0.25.4. The register keeps winning.