monia
A personal finance ledger that builds itself from bank email. I bank in Colombia across five institutions and five currencies, and no aggregator reaches all five, so no product could show me one ledger. monia reads the mailbox instead: it parses the banks' transaction alerts and their monthly statement PDFs into ledger rows. The user never types a transaction.
- Status
- Live, public demo account
- Built
- Seven days, 271 commits
- Role
- Sole engineer, agentic workflow
- Scope
- Schema to production deploy



$ cat ORIGIN.md
No aggregator reaches all five of my accounts, so I built the one that reads their email.
My money lives in Bancolombia (a savings account and a Visa credit card), plus Nequi, Payoneer, Meru and Binance. It moves in COP, USD, USDC, USDT and EUR. Plaid and its peers do not reach Colombian banks, and the banks publish no API I can use. So the standard answer of connecting your accounts to see one ledger does not exist for me.
What the banks do send is email. Every transaction produces an alert. Every month produces a statement PDF. That is a complete, timestamped record of my finances, sitting in Gmail in a format no ledger reads. monia reads it. The mailbox is the API.
$ ./bin/report --totals
Counts read off the repository on 15 August 2026. The integration and end-to-end figures are counted from the spec files, not estimated. The unit suite finishes in 1.45 seconds, because the domain layer imports neither the database nor React.
$ git log --since="7 days ago" --stat
Seven days, in the order the dependencies forced.
The schema, and the arithmetic under it.
More surface, then the first audit.
A deployment rather than a demo.
The review day.
Statements, and the overlap between two sources.
The long day.
Naming where the money comes from.
$ ls -la ./engineering
Four problems carried the build: the arithmetic, the mailbox, a join that skipped the mailbox it existed for, and a column nothing read.
One module does all arithmetic on amounts. An amount is a bigint in the minor units of its own currency, and the scale comes from a currencies.decimals column rather than a hardcoded constant: pesos are configured at two decimals here because Bancolombia reports centavos on tax and interest lines. No JavaScript number ever touches an amount, not to parse it, not to convert it, not to format it. Rounding is half away from zero, so a debit and a credit of the same size round to the same size. Asymmetric rounding would bias expenses against income.
There is a catch-up pass for new mail, which is small and runs daily, and a backfill walk that goes backwards through history, is large, and takes days. Every serverless route has a duration cap, so a server-side loop that runs past that cap dies mid-flight and reports nothing at all. Instead each request is bounded and lands its own work: it writes its rows and moves the history floor before it returns. A run that dies, whether from a closed tab or a deploy, loses at most the chunk in flight. The browser loop reads 800 messages a chunk; the cron reads 80.
Two tables carry the import state. One holds a row per message the importer has looked at, with the outcome it reached and the evidence it had. The other holds one row per user with the oldest day read so far. When a user adds an account or a template, the app deletes the seen-rows the change unblocks, so the next import reads them again. Without that step, mail that was one account away from landing stays marked seen forever. The trap: a user with no backfill row has never walked backwards at all, which is the same state as an unfinished walk, and it belongs to the newest account, the one the job exists for. The query that picks up that user needs a LEFT JOIN.
reporting_currency existed as a column, appeared on the settings screen, and was read by nothing. Every page hardcoded the storage base, so a Colombian user with 7,749 peso rows could only ever see dollars. The fix is a SQL expression with three cases, in order of cheapness. If the reporting currency equals the storage base, return the frozen column untouched, so today's numbers equal yesterday's by construction. If the row is already in the reporting currency, return its native amount, because a round trip through USD loses a centavo per row for nothing. Otherwise divide by the rate for that row's own date, never today's rate. A verification script then walked the rows that actually needed converting, comparing them one at a time: zero mismatches across all 184 of them.
$ cat DECISIONS.md
The calls I would defend in an interview, and what each one cost.
Every row's USD base amount is written once at insert and never recomputed, so historical totals are stable by construction, and so the base currency can never change without invalidating every stored row. The per-user reporting currency costs nothing to change, because nothing is stored in it.
Ask a language model how much you spent on food in March and it produces a confident, plausible, wrong figure. In a ledger a plausible wrong figure is worse than no answer, because the user cannot tell it from a right one. So the application computes every figure first, with the same typed queries the screens use, and hands the model a fact sheet; the model says which figures answer the question and writes the sentence around them. I rejected three designs in writing first. Raw SQL for the model is one malformed query from a wrong total and one DELETE from worse. Query tools are better, but the model still picks the window and the filter, so it still owns the number. Retrieval over transaction text answers "did I pay X" well and "how much" badly.
I would rather ship a narrower feature that is always right.
Signing in with Google asks for identity only. gmail.readonly is requested separately, from Settings, and only by the accounts that import mail. The reason is regulatory rather than ergonomic: it is a Google restricted scope, and attaching it to sign-in puts the whole application under the restricted-scope regime. Publishing the consent screen then requires an annual third-party CASA Tier 2 assessment, and until that passes Google expires every refresh token after seven days, which breaks the daily import cron. The cost is one extra consent step, paid only by the users who need it.
A serverless loop that runs past the duration cap dies mid-flight and reports nothing, so no request is allowed to depend on the next one. Each writes its rows and moves the history floor before it returns, and a dropped connection loses at most one chunk. The trade is chunked progress and more requests, and on this platform the long run was never on offer.
When a row has no exchange rate the conversion returns NULL, and sum() skips it without a word. The app counts those rows and shows the count beside the total. The alternative is a clean-looking number over incomplete data, which is the kind of bug a user never files.

$ cat method.md
271 commits in seven days, and the guardrail that earned its place.
An agentic workflow, run the way the rest of my work is run: blind review, a non-Anthropic second opinion, and every finding triaged rather than adopted. What monia added to that was a measurement.
A reviewer deleted the Gmail cursor's write path, its dry-run guard and its invalidation, all three at once, and the 678 unit tests that existed on day four stayed green. A test that passes with the fix removed is not coverage. That single result is why CI now runs a second job against a real Postgres service container, and why the reason for it sits in a comment at the top of the CI file.
It also set the working rule for the rest of the build: mutate, do not read. Nearly every high-value finding after that came from reverting a line and watching what stayed green. Five rounds produced 222 findings, filed at 21 High, 83 Medium, 86 Low and 32 Nit.
The reviewer gets the diff and the acceptance criteria and nothing else: no pull-request title, no commit message, no branch name, no prior verdict. A controlled study (arXiv:2603.18740) holds the code constant and varies only the surrounding narrative. Framing a change as bug-free cuts defect detection by 16 to 93 percentage points, asymmetric toward false negatives, and redacting the description alone restored detection in 70% of the missed cases. My reasoning produces ratification, so my reasoning stays out of the brief.
The tooling gets the same treatment, because it has been wrong in ways that mattered. A caching shell proxy replayed a stale test result and reported 8 failures in a file that no longer existed on disk; later it summarised a failing run as no tests collected. And during one review I called a cited file fabricated because a truncated git ls-tree did not show it. It was the fourth entry. Two rules came out of that pair: never let a summarising tool be the sole source of a load-bearing fact, and never truncate the command that proves a negative.
The discipline is what tells me which claims to verify and which green test suite to distrust.
$ cat commits.log
A commit message names the effect on the person using the app.
$ cat KNOWN-ISSUES.md
Known issues
- GapThe Google consent screen is still in Testing. Gmail import therefore works only for listed test users, and their refresh tokens expire weekly. Publishing it means the CASA assessment described above.
- GapA cron on Vercel's Hobby plan fires anywhere inside its hour. A schedule here is an hour, not a minute, and an hourly expression fails the deployment outright. That is why one job is registered four times at spread hours instead of once.
- Not builtOne mailbox at volume. monia is multi-tenant from the schema up and the tenancy filters are covered by tests, but the only mailbox it has read thousands of messages from is mine.