~/henry-cabello
CVEspañol
← back to ./work
Live in production

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

Open the demo ↗

The monia ledger: transaction rows with payee, account, category and amount, a cash flow panel and spending by category
The monia overview screen with balances across accounts and currenciesThe Sources screen: a card per bank showing what monia has read and what it is waiting on
Ledger, overview and sources. Every amount keeps the currency it happened in and shows the reporting figure beside it. Each panel names its currency once, in the corner, rather than repeating a code beside every number.

$ 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

271Commits
7Days
59Pull requests
21Tables
1,028Unit tests
186Integration
173End to end
222Findings triaged

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.

2026-08-0840 commits

The schema, and the arithmetic under it.

2026-08-0934 commits

More surface, then the first audit.

2026-08-1032 commits

A deployment rather than a demo.

2026-08-1152 commits

The review day.

2026-08-1218 commits

Statements, and the overlap between two sources.

2026-08-1386 commits

The long day.

2026-08-149 commits

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.

The money layer went first, because every other decision sits on top of it

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.

Reading a mailbox is two jobs, not one

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.

An INNER JOIN silently skipped the exact mailbox the job existed for

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.

The largest single bug was invisible from the schema

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.

Next.js 16React 19TypeScriptPostgresNeonDrizzleBetter AuthTailwind 4Base UIGmail APIpdfjs-distBinance APIVitestPlaywrightVercel

$ cat DECISIONS.md

The calls I would defend in an interview, and what each one cost.

Froze the storage base, and made the reporting currency a read-time view

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.

The assistant never supplies a number

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.

Kept the Gmail scope off the sign-in grant

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.

Made every ingestion request land its own work

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.

Made a total over a subset say so

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.

The monia assistant answering a spending question, citing the figure it was given
The assistant, and its one rule. The answer cites the figure it used, and that figure matches the accounts panel to the centavo, because the application computed it and handed it over. The model chose which number answered the question. It did not calculate one.

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

adb0c6efix: half a transfer is not a transfer
229c44efix: an Uber authorisation hold is not a charge
5c8d454fix: a $0.00 card authorisation is not a transaction
bc334cefix: 97 of 400 emails needed a template; six actually did
17bac55fix: a mis-scaled crypto row put $10.3M on a Visa card
53eae42fix: accented weekdays dropped 26 purchases
89d8f38fix: 584 messages were stuck with nothing offered to unstick them
5e03048test: the import rollback is proved, not asserted
e335e0bfeat: the mailbox reads itself, without a tab held open
62e8927fix: the app was rendering in Times New Roman

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

← back to ./work