Author
October 19, 2026
5 min read

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 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 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.
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.
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.
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.