aug 20264 min read

How we get a trustworthy number out of a bank with no API

In Mexico, a bank account does not come with an API. The data arrives as a PDF, once a month.

We build and run a members club called Compound, at compounding.club. It tells a member where their money actually is. There is no bank sync to plug into here, and handing an aggregator your banking credentials is a trade we were not willing to make. So the input is the statement itself.

Reading a PDF into numbers is exactly the kind of job a model is good at. Trusting those numbers is a different problem, and it is the one worth writing about.

Where the model belongs

The model transcribes. It reads the document and writes down what is printed on it: the date, the description as it appears, the amount, and whether the row is a charge, a fee, interest, a payment to the card, or a refund. Layouts differ between banks and shift between months. That ambiguity is the job.

It stops there. It does not decide what any of it means.

Where it does not

Categories come from ordinary code. A fixed list of merchant rules maps a description to a category, first match wins. The model may propose a category, and its proposal is used only for merchants the rules have never seen. A rule always outranks it. Installment plans are read from the markers the bank prints, never from a guess.

The reason is boring and it matters. Rules are inspectable. When a category comes out wrong you can find the line that made it wrong and change it. A model that quietly recategorizes a year of spending leaves you nothing to fix.

The gate

Then the arithmetic, which is where the trust actually comes from. A card statement prints its own summary: previous balance, total charges, total payments, new balance. The transcribed rows have to reproduce those figures within one centavo. If they do not, nothing from that file is imported. Not the rows that look fine. Nothing.

A fabricated row breaks the charges total. A dropped row breaks it the other way. The bank already did this arithmetic and printed the answer, so we use its answer as the test.

Partial imports are the trap. Keeping whatever parsed cleanly feels helpful, and it produces books that are almost right, with nothing marking which part is not. A refusal is annoying for a minute. A quietly wrong balance costs you the product.

The rest of the discipline

Two habits fall out of the same idea. The raw file is never stored: the bytes are read in memory and discarded, and only the date, merchant, amount and category survive. Long runs of digits are scrubbed out of every string before a row exists, so a card or account number cannot reach storage even when the statement printed one.

The checks are plain too. Nine self-check files, ordinary assertions, no test framework. One of them takes a statement that balances, adds a charge that never happened, and asserts the import refuses it.

This transfers to any AI feature standing near a number that matters. Give the model the part that is genuinely ambiguous, which is reading something messy. Keep the deterministic parts deterministic, especially the decision about whether to write anything at all. An import that refuses is better than an import that is quietly wrong. In a money path, the refusal is the feature.

Building something like this?

Talk to a founder

A founder replies within one business day.