The problem #
Every few months I’d tell myself I was going to sit down, open a bank statement PDF, and actually figure out where my money went. Every few months I wouldn’t, because the honest version of that task is: open the PDF, squint at a table that doesn’t copy-paste cleanly, manually bucket forty-something transactions into categories, and build a spreadsheet from scratch. It’s exactly the kind of task that’s tedious enough to skip and specific enough that no generic budgeting app solves it well — most of them want you to connect a bank account via Plaid-style integrations that don’t cover every institution, especially outside the US.
So I built the thing I actually wanted: upload the PDF, get the breakdown.
What it does #
You upload a bank statement PDF. Behind the scenes, Claude extracts every transaction, categorizes each one, and writes a short plain-language summary of the statement — the kind of thing you’d want a knowledgeable friend to tell you after they’d actually read your bank statement for you. That feeds a dashboard: spend by category, spend over time, your top merchants, and the summary up top.
Architecture #
The system is three services, not one, and that split was deliberate:
apps/web React (Vite) + Clerk auth + Recharts — the dashboard UI
apps/api Hono (Node/TS) — auth, statement/transaction CRUD, dashboard endpoints
services/pdf-service Python (FastAPI) — PDF decrypt/extract, Claude extraction +
categorization + summary, reconciliation, direct Postgres writes
The web app never talks to Claude or touches a raw PDF directly — it uploads to apps/api, which encrypts the file, writes a statements row, and fires an internal, token-authenticated call to the Python service to run the pipeline. The client doesn’t block on that call; it polls the statement’s status until it flips to done or failed. That separation exists mostly because PDF parsing and LLM orchestration are a different problem shape than CRUD and auth, and Python’s PDF tooling is simply better than anything in the Node ecosystem — there was no good reason to force that work into the same service as the API.
Postgres holds transactions and statement metadata, migrations are plain numbered SQL files, and the API and the PDF service are deployed as separate Railway services, each with its own Dockerfile, while the web app ships to Cloudflare Pages. Uploaded PDFs live in Cloudflare R2.
Where Claude does the heavy lifting #
The core bet of this project is that an LLM is now a better bank-statement parser than a hand-written one. Bank statement layouts vary wildly by institution, and a regex- or template-based extractor breaks the moment a bank tweaks its PDF export. Claude generalizes across formats without me having to write and maintain a parser per bank — it reads the extracted text, pulls out structured transactions, assigns categories, and writes the summary, all from the same pipeline.
That’s also the app’s main v1 limitation: it currently only handles text-based PDFs, not scanned statements, since there’s no OCR step yet. A single statement, one currency, no multi-bank template detection beyond “let the model generalize” — deliberate scope cuts to get something real shipped in a weekend rather than something theoretically complete.
Privacy, because it’s financial data #
None of the above matters if the security story is an afterthought, so this got real attention up front rather than being bolted on later:
- Passwords are never persisted. If a statement PDF is password-protected, the password is held in memory only for the single request that decrypts it — never written to a database row, a queue payload, or a log line. A failed decrypt means re-entering the password via a reprocess endpoint, not a retry from something stored.
- Encryption at rest. Raw PDF bytes are encrypted with AES-256-GCM before they’re written to object storage, layered on top of whatever R2 does natively.
- Redaction before the LLM ever sees anything. Account, card, and IBAN-like number sequences are stripped out of the extracted statement text before it’s sent to Claude for extraction or categorization. It’s best-effort rather than exhaustive — dates and transaction amounts are deliberately left alone since the categorization actually needs them — but the goal is that sensitive identifiers never leave the pipeline in the first place.
- Retention. Raw PDFs are meant to be purged after 30 days, keeping only the extracted transaction data.
- Per-user isolation. Every query in the API filters by the authenticated user, and the PDF service’s internal endpoint only ever acts on the exact statement ID it’s handed — it has no way to reach across users even if asked to.
A small extra: loan tracking over Telegram #
One feature that came out of actually using the app on myself: sometimes a “transaction” isn’t spending, it’s a loan to or from someone. There’s a Telegram bot that can start a short conversation when a payment looks like a loan — asking about repayment status and expected date — and tracks it in a “loan book” so it can nudge you (and the other person) when it’s due, and again if it goes overdue.
What’s next #
OCR for scanned statements, multi-currency support, and CSV upload as an alternative to PDF are the obvious next steps. The two scheduled reminder jobs and the retention cleanup script exist but aren’t wired up to run automatically yet — they’re meant to run on a schedule via Railway cron.
Try it #
Live: spendy.site