skip to content

Project write-up

Ahorrin

Ahorrin landing page

Ahorrin is a personal-finance app for Uruguay. You upload a statement from your bank, every transaction gets a category, and you get charts by month and category plus a chat assistant that answers questions about your own spending. It is my own product, built and run on my own time since October 2025.

The problem

Uruguayan banks do not agree on anything. Statements arrive as PDF, CSV, Excel, OFX, QFX, QIF or JSON, and each bank’s PDF has its own layout. The app has to turn all of that into one table of transactions, and then decide what each line is, without asking the user to do it by hand every month.

Decisions

Parse deterministically first, and only then ask a model. The parser layer sniffs the format from the extension and the first kilobyte, then routes to a dedicated parser per format. For PDFs there are six hand-written extractors, one per bank layout I could get statements for (BBVA, Scotiabank, three Itaú products and Santander), with a detector that reads the bank name off the text. A PDF from a bank without an extractor falls through to a last-resort parser that pulls the text out and asks a small model to fill a typed schema. That path exists so an unknown bank still works, not as the main road.

Categorisation is a cascade, not a prompt. A transaction goes through four stages in order: the user’s own rules, a cache of merchants already seen, a trigram-similarity match against known merchants, and finally “unmatched”. Each row records which stage decided it, so the app can show why a category was chosen and the user can correct it once and have the correction apply next month. The work runs as background jobs in a table drained by a worker, with a daily cron that re-queues anything stuck.

The model only where it earns its cost. Language models are used in three places: reading a photographed receipt, the fallback PDF parser, and the chat assistant. Everything else is rules and SQL. Around the model calls there is a per-user rate limit, plan gating between free and pro, and time caps on the routes that call out.

The hard part

One string has to be normalised identically in two languages. The merchant cache and the similarity search join on a vendor key, and that key is produced by a Postgres function on one side and a TypeScript function on the other. If they disagree by a single character, the cache silently misses and every transaction looks new. The two implementations live next to a comment that says to keep them in sync, and they have their own tests.

The other one was invisible until it was measured: the marketing site was rendering nothing for crawlers, because the app’s context providers gated the whole tree behind a client-side mount. Every page went through server rendering after that, and mobile load time on the blog got its own fix later.

Stack

Next.js 15 and React 19 on Vercel, Supabase Postgres with eleven tables under forty-seven migrations, the Vercel AI SDK for the model calls, ECharts behind a small theming layer for the eleven chart components, Mercado Pago for billing. The parsers and the categorisation rules have a small test suite run with the Node test runner.

ahorrin.app · Source

Ahorrin