
The duplicate that leaves no fingerprint
If your software imports bank statements and sums up money, here is a failure mode worth knowing about before it finds you: a single real transaction can get counted twice, and the usual way of catching duplicates will never flag it — because there is nothing left to match against.
We hit this in Zyfin, our personal-finance app, inside the bank-statement importer (the part that reads a downloaded statement and reconciles it against what the app already recorded). On the owner’s own real statement data, the importer silently double-counted ₹12,497.25 across 27 rows, plus a separate ₹38 row from an HDFC statement. No duplicate was ever flagged. The totals were just quietly wrong.
The reason it stayed invisible is the interesting part, and it is not a sloppy comparison. It is that the entire strategy of matching by reference ID has two blind spots that no better matching can close.
Blind spot one: the bank re-dates the entry, so there’s nothing to match
Banks sometimes reissue an old entry as a “correction.” The catch: by the time the correction goes out, the original month is already closed on the ledger side, so the bank can’t post it back into its original period. Instead it reposts the row dated into the current period.
That reposted row carries no reference that ties it to the entry you already recorded. From the importer’s point of view it is a brand-new transaction with a brand-new date and no prior match — so dedup logic that works by matching a reference or RRN (the retrieval reference number banks stamp on a transaction) lets it straight through as fresh money. There is no fingerprint to match because the bank, in effect, issued a new fingerprint for an old event.
Blind spot two: the same bank formats the same reference two different ways
Independently of the re-dating problem, we found that HDFC’s statement zero-pads its RRN differently from the RRN in the bank’s own live transaction alert. Same transaction, same bank, two documents — two string representations of the one reference.
An exact-string comparison between them silently fails. The transactions are otherwise identical, but the matcher sees two different strings and concludes they are two different events. So even when a reference does exist to match against, the source can’t be trusted to format it consistently across its own documents.
Two separate bugs, same root assumption: that an external reference ID is a reliable identity for a transaction. It isn’t.
The fix: check against something the bank can’t fudge
Instead of leaning harder on reference matching, we stopped relying on it as the sole signal. The new check, alreadyCountedByBalance, uses an invariant the bank itself publishes on every statement: its running balance.
For each row, it brackets the row’s date between the bank’s own stated running-balance checkpoints, and asks a different question — does the ledger already reconcile with the bank’s stated balance across that span? If it does, the money is already accounted for, and the row is treated as already counted whether or not any reference matches.
That catches both blind spots at once. A re-dated correction with no matching reference still can’t make the balance stop reconciling. A reference that’s zero-padded differently in two documents doesn’t matter, because the balance check never looks at the reference. We then ran a repair pass to retroactively remove the rows that the old reference-only logic had already double-counted — which is how the owner’s ₹12,497.25 and the stray ₹38 got cleaned up rather than just stopped going forward.
The same build also fixed an unrelated HDFC PDF parser bug worth naming here, because it is a cousin of the same lesson: the parser was dropping description lines that wrapped onto a second row, reading 2 of 18 rows on one statement. It now reads all 18. Statements don’t arrive in the shape you expect, and “the document says so” is not the same as “the parser read all of it.”
The transferable point
If you are building anything that deduplicates financial transactions — reconciliation, expense tooling, accounting imports, payment matching — matching on an external reference ID is fragile in two ways that are structural, not fixable with cleverer string logic:
- The source can format the same reference inconsistently across its own documents (the zero-padding here), so identical transactions look different.
- The source can retroactively reissue or re-date an entry, so there is no reference to match against at all.
The durable move is to check against an independent invariant the source itself publishes. For bank statements, the running balance is exactly that — a figure the bank commits to that you can reconcile against regardless of how it labels any individual row. An invariant catches duplicates that ID-matching cannot see, because it doesn’t depend on the source being consistent or honest about identity.
If you’re relying on reference IDs to keep money from being counted twice, it’s worth asking what independent check would still hold when those IDs don’t.










