I spent two weeks writing a bank-statement parser that runs inside the browser tab. No upload, no server, no account. Paste a CSV export from your card provider, get back the recurring charges hiding in it.
The part I underestimated was not the recurring-charge logic. It was the file format. Every bank invents its own dialect of CSV, and each dialect exists because someone in 2009 thought it was obvious. Here are the traps that broke my first parser, in the order they broke it, with the rule I ended up shipping.
1. The header is not on line one
Almost every export opens with a preamble: account holder, masked card number, statement period, a blank line, sometimes a marketing sentence. My first version assumed lines[0] was the header and produced zero rows on half of the samples.
The fix is to stop assuming and start searching. I scan the first ten lines, split each one, and ask whether it looks like a header: it needs a date-ish column, a description-ish column, and either an amount column or a debit/credit pair.
const DATE_ALIASES = ['date', 'transaction date', 'posted date', 'post date', 'trans date']; const DESC_ALIASES = ['description', 'merchant', 'payee', 'name', 'memo', 'details']; const AMOUNT_ALIASES = ['amount', 'transaction amount']; const DEBIT_ALIASES = ['debit', 'withdrawal', 'withdrawals', 'charges']; const CREDIT_ALIASES = ['credit', 'deposit', 'deposits', 'payments']; One subtlety matters more than the aliases: a transaction line that appears above the header must be reported instead of being silently skipped with the preamble. If a row above the header parses as a date, I emit an issue pointing at that line. Quietly ignoring money is the worst possible parser behaviour.
2. The delimiter is not always a comma
European exports use semicolons. Some accounting tools use tabs. And the sample rule I first wrote, "look at the first line and count separators", fails for exactly the files that need it, because the first line is usually the preamble sentence and it contains no delimiters at all.
So the sniffing window is the first twenty lines instead of just the first one:
const sample = lines.slice(0, 20).join('\n'); for (const candidate of ['\t', ';', ',']) { const count = sample.split(candidate).length - 1; if (count > bestCount) { best = candidate; bestCount = count; } } Tab wins ties by virtue of being tested first. That is a deliberate choice: a tab-delimited file usually has no commas inside fields, while a comma file with a semicolon in a merchant name is common.
The splitter itself has to respect quoting, including the doubled-quote escape, or a merchant called "SMITH, J LLC" becomes two columns and the amount lands in the wrong place.
3. Negative money has at least four spellings
A charge is positive money leaving the account. Depending on the bank, the same refund looks like any of these:
-12.99-
12.99-(trailing minus, common in mainframe-era exports) -
(12.99)(accounting parentheses) - a debit column and a credit column, where the sign lives in the column header
My parser normalises all four into integer cents before anything else touches the value. Integers instead of floats: 0.1 + 0.2 is a well-known way to make a totals column disagree with itself by a cent, and a subscription audit that is one cent off still fails the only test a user runs, which is whether the number matches their statement.
4. The same character means two different things
1,234.56 and 1.234,56 are the same amount. 12,50 and 1,250 are not, and they are both plausible inputs. The rule I ended up with:
- if the string contains a comma and no dot, and exactly two digits follow the last comma, treat the comma as a decimal separator
- otherwise strip every comma
if (value.includes(',') && !value.includes('.')) { const parts = value.split(','); value = (parts.length === 2 && parts[1].length === 2) ? `${parts[0]}.${parts[1]}` : value.replace(/,/g, ''); } else { value = value.replace(/,/g, ''); } Currency symbols, non-breaking spaces and stray letters get stripped before that check. If what remains does not match ^\d+(\.\d{1,2})?$, the amount is rejected and the line is reported as an issue. Rejecting is cheap; guessing is not, because a misread amount corrupts every downstream total silently.
5. Dates are ambiguous, and guessing is worse than refusing
03/04/2026 is March 4th or April 3rd depending on where the file was produced. Six months of a subscription billed on the 4th can look like a monthly cadence or like two weeks of chaos.
The parser accepts ISO dates and short numeric dates, and exposes a single dayFirst flag. There is no automatic detection, because automatic detection here means inferring a locale from a file that does not declare one. A user who sees garbled dates can flip one switch; a user who does not notice they are garbled now has a wrong answer they trust.
6. Merchant descriptors are a compressed format
Raw descriptors look like this:
SP *SPOTIFY USA PAYPAL *STEAM SQ *BLUE BOTTLE POS DEBIT 4821 WHOLEFDS MKT They are written for a 22-character field, so payment processors prepend their own prefixes and banks append store numbers. Grouping by the raw string produces one subscription per location.
Normalisation does three things: strip known prefixes, unify punctuation, then drop trailing pure-digit tokens (store numbers) and truncate.
The prefix list has one hard constraint that cost me an afternoon:
// the separator is required, otherwise "SP\s*" eats the first two letters of SPOTIFY /^SP[\s*]+/i A permissive ^SP turns every Spotify charge into OTIFY USA, and it groups correctly by accident while being wrong about the merchant name. Requiring at least one separator after the prefix fixes it, and the same pattern applies to SQ, TST and PAYPAL.
7. Turning rows into cadence: why the median wins
Once every row belongs to a normalised merchant, recurrence is a question about intervals. I collect the positive charges, compute the day gaps between consecutive ones, and take the median.
Median because a single missed month should not move the answer. If a charge lands on the 3rd, the 5th, and then the 2nd of the month after a skipped cycle, the mean interval is inflated and a monthly subscription scores as irregular; the median still says thirty.
The classification is deliberately narrow:
- monthly: median gap 25–35 days, at least 3 charges
- annual: median gap 350–380 days, at least 2 charges
- weekly: median gap 6–8 days, at least 4 charges
- anything else: one-off
The counts are not decorative. Two charges 30 days apart are as likely to be a coincidence as a subscription, so monthly needs a third observation before it earns a row.
Confidence is a separate field, and it is where the honesty lives. Monthly is high confidence at four or more charges with every interval within five days of the median; annual needs three charges within ten days; weekly needs six within two. Everything else is medium. A high-confidence row is a claim the tool is willing to defend; a medium row is a hint.
The monthly equivalent is what makes an annual bill comparable to a monthly one: annual divided by twelve, weekly multiplied by 52 and divided by 12. Sorting by that number is the whole point of the audit, because it answers one question in a fixed order: which line costs the most per month.
8. What the audit refuses to claim
Two limits are worth stating, because users find them before you document them.
The first is aggregated billers. APPLE.COM/BILL and GOOGLE *... collapse an entire ecosystem into a single descriptor. The parser flags those rows, and the audit shows them as one line, because the statement does not contain the information needed to split them. Pretending otherwise would produce a confident, wrong breakdown.
The second is the trial that converts. A subscription billed once has one observation. Until the second charge arrives, it is a one-off, and the tool says so. There is no setting that makes it guess.
What comes out
The output is a priority list sorted by monthly equivalent, each row carrying the cadence, the last charge date, the next expected date (last charge plus the median interval), the annual equivalent, and the charge count. Two exports come with it: a CSV and a Markdown table of the same list, so the audit can be pasted into whatever the user actually uses to cancel things.
And a print stylesheet, because the moment this becomes real is the moment someone holds it next to a phone call.
The parser is about 500 lines and does not need a backend. If you want to try it on your own export, it runs on the statement audit page. Paste a redacted export; nothing leaves the tab.
If you have a bank export that this parser mangles, send me the header line and one broken row (with the amounts changed). Format bugs are the ones I can actually fix.