A household ledger that fills itself from bank alert emails. Your bank already emails you every transaction, so the ledger reads those emails, files each one under a category, and sends a summary every Monday. Cash and e-wallet spending takes three taps on the phone. It runs on Cloudflare's free tier, and your statements never pass through a third-party aggregator.
The screens above run on fictional data from Example Bank, the sample parser that ships with the template.
flowchart LR
bank[Bank alert email] --> gmail[Gmail filter forwards it]
gmail --> route[Cloudflare Email Routing]
route --> worker[Worker: parse, categorize, dedupe]
worker --> d1[(D1 database)]
d1 --> pwa[Phone app, installed from Safari or Chrome]
pwa --> worker
cron[Monday cron] --> worker
worker --> report[Weekly summary email]
- Parse. One small parser per bank reads the amount, direction, time and description from the alert. Emails that are not transaction alerts are ignored. An alert whose format changed is logged as an error, so nothing disappears silently.
- Categorize. Keyword rules, first match wins, ignoring case and Vietnamese accents:
phuc longmatchesPHÚC LONG COFFEE. Unmatched spending under a threshold goes to Small spending, so only the big items need a decision. Transfers between the household's own accounts are Internal transfer and do not count as spending. - Foreign currency. Card charges in USD and other currencies are converted at the day's rate, with a manual fallback rate in Settings.
- Dedupe. Each email's Message-ID is stored, so a re-forwarded alert is never counted twice.
- Sign-in. Type an email, receive a 6-digit code, stay signed in for a year. Only addresses on an allowlist get a code, and every other address gets the same answer, so the sign-in page reveals nothing.
Budget apps that sync with a bank usually need your bank login, or a third-party aggregator that holds it. Bank alert emails are already in your inbox, usually arrive within seconds, and need no credentials at all. Forwarding them to an address you own keeps the whole pipeline in your own Cloudflare account.
The cost is zero because every piece fits in Cloudflare's free tier at household volume: a Worker, a D1 database, Email Routing, and the email binding that sends sign-in codes and the weekly summary.
You need a domain on Cloudflare and Node 20 or later.
npm install
npx wrangler login
npx wrangler d1 create inbox-ledger # paste the printed database_id into wrangler.jsonc
npm run db:migrate
npx wrangler secret put ALLOWED_EMAILS # comma-separated addresses that may sign in- In
wrangler.jsonc, set your domain inroutes,APP_URLandMAIL_FROM. - In Cloudflare Email Routing for your domain: enable it and add each member's address as a verified destination (sign-in codes are only sent to verified addresses). Then create a custom address such as
bank@yourdomain.comwith the action Send to a Worker,inbox-ledger. npm run deploy.- In each member's Gmail: Settings, Forwarding, add
bank@yourdomain.com. Gmail sends a confirmation code, which shows up in the app under Settings, Bank emails received. Then add a filter on your bank's alert sender that forwards to that address. - Open the app, go to Settings, and set each member's name and role. On a phone, add it to the home screen to use it like an app.
The template ships with one parser, for the fictional Example Bank, in src/core/parsers/example-bank.ts. To read your bank's alerts:
- Copy it, match on your bank's alert subject line, and pull out the amount, direction, time and description.
- Add a test with one of your real alerts, after replacing the account number, names and balance with made-up values.
- Register it in
src/core/parsers/index.ts, with the member role (PRIMARYorPARTNER) its emails belong to.
printf 'DEV_EMAIL=you@example.com\n' > .dev.vars # skip sign-in on your machine
npm run build
npm run db:migrate:local
npm run dev:worker # http://localhost:8787Send it an email the way Email Routing would:
curl -X POST 'http://localhost:8787/cdn-cgi/handler/email?from=alerts%40example-bank.test&to=bank%40ledger.example.com' \
--data-binary $'Subject: Example Bank: balance change alert\r\nMessage-ID: <1@test>\r\n\r\nAccount 0123xxx789 changed by -65,000 VND at 21/09/2026 08:10.\nDescription: HIGHLANDS COFFEE.\n'npm test runs the parser, amount, categorization and email tests with Vitest.
- Never commit a real bank email. Fixtures use made-up account numbers, names, balances and amounts.
- Never commit
.dev.vars. - The only outside call the Worker makes is for exchange rates (open.er-api.com). It sends a currency code and nothing else.
Cloudflare Workers with Hono, D1 (SQLite), Email Routing and the email binding, postal-mime for parsing, React with Vite for the phone app, Vitest for tests.
MIT. Built by Thinh Nguyen (Danny). It started as a ledger for one household and became this template.
