BudgettinEngineeringProduct

What two real bank statements taught us about importing money

J
Joseph

Author

October 19, 2026

5 min read

What two real bank statements taught us about importing money

Budgettin already had an importer before we pointed it at real statements. It read CSV, OFX and QIF, and it could pull transactions out of a PDF with a language model, show them in a review screen, and post them only after confirmation. On fixtures it worked.

Then we imported a real Revolut CSV and a real Zenith Bank PDF — a 14-year file with 531 transactions — and the built-in import would have failed or, worse, silently mis-imported both. This is what went wrong and what it changed about the product.

The CSV that was really four accounts

The Revolut export had 255 rows and ten columns: type, product, started date, completed date, description, amount, fee, currency, state, balance. Two things about it were invisible to our parser.

The first was the state column. Six rows were marked REVERTED — reversed payments that must never reach the ledger. Our column auto-detection looked for dates, descriptions and amounts; it had no notion of a state field, so all six would have imported as ordinary spending. Three of them were already in the ledger as phantom expenses from earlier work, and we removed those too.

The second was that one export covered four currency sub-accounts: GBP 77 completed rows, TRY 111, USD 47 and EUR 14. Our importer treated a statement as one account in one currency. Routing rows to per-currency accounts, and booking each into the organization's currency, is not a formatting concern — it is a decision about what a row means.

We settled it with fixed booked rates (GBP 1.2337, EUR 1.09, TRY 0.028, USD 1.0, recorded with a fixed-rate source) so that each account's balance exactly matched the statement's own per-currency net. A historical-rate engine that picks a rate per row would have produced a "more accurate" number and a balance that did not reconcile with the bank. For a bulk import, reconciling with the statement wins.

The category mapping needed Turkish keywords, too. A budgeting app that ships in English and calls everything else "Other" is not usable by the person holding that statement.

The PDF that was not a table

The Zenith file was harder. It covered 14 years and 531 transactions, and its text layer did not preserve reading order. The PDF contained all the dates, then all the descriptions, then all the amounts — a layout-blind text extraction produced a scrambled stream that neither regex nor a language model could reliably turn back into rows.

It also exceeded two guards we had set for good reasons: a 100-row cap and a 30-second, 50-page limit. Those limits exist to stop one import from taking down a worker, but a genuine statement walked straight through them.

What worked was positional parsing: extract each text run with its x/y coordinates and rebuild rows from their geometry, rather than trusting the order the text arrived in. Then validate the result against the statement's running balance — if the imported rows do not reproduce the balance after every transaction, the parse is wrong somewhere, and the review screen should say so before anything posts.

What we changed our minds about

The first importer was built as a parser: take a file, produce rows, show the rows. The real statements made it clear that the hard part starts after parsing.

  • State filtering is part of reading a statement. A reversed row is not a transaction.
  • Currency routing is part of reading a statement. One file can be several accounts.
  • Deduplication and reconciliation are part of reading a statement. Importing the same month twice must not double the ledger, and the running balance is the check that proves the parse.
  • Fixed-rate policy is a product decision, not a default. The user needs to know which rate a bulk import booked at, and why the balance matches.
  • Ambiguity is not an error to swallow. A row that cannot be classified should arrive in review with its uncertainty visible. Nothing posts without confirmation, and a person always sees the import before it becomes their history.

The LLM extraction we already had is still useful — for PDFs whose text layer is sane. It is not a substitute for geometry when the layout is the only thing carrying the meaning.

What is still not built

The honest version: the built-in importer still cannot handle these two files unaided. The wins above came from scripts written around the product, and the roadmap is to turn each of those tricks into a feature — state columns, per-currency routing with an explicit rate policy, running-balance validation, dedup, and positional PDF parsing with the row and time limits raised for statements.

Until then, the product does the thing that matters most: it shows you what it read, and it does not post anything you have not seen. An import that is merely slow is recoverable. One that silently invents a transaction is not.

Share the insight

Build log

Back to Budgettin