Manage Expense
Product lab
A personal finance app I use daily — built local-first, with every cloud feature opt-in and every uncertain state (captured, reviewed, recorded, synced, settled) kept visibly distinct.
The problem
Every expense app I tried was built for someone else’s money. They assumed a US bank feed, a card-first spending life and a single person’s ledger — none of which describe how a household in India actually runs: UPI for almost everything, cash that never appears in a statement, EMIs and bills on different dates, and money split across people who each hold their own accounts.
The apps that did import transactions wanted permanent read access to a bank aggregator. The ones that were private were notebooks with a total at the bottom. I wanted the third thing: something that captures spending nearly automatically, keeps the ledger on my own device, and is completely explicit about the moments it cannot be certain.
The approach
Manage Expense is a Flutter app for iPhone and iPad built local-first: a 22-table SQLite database on the device is the source of truth, and every screen — budgets, bills, EMIs, debts, goals, investments, trips, tax, the assistant — works with the network off. Nothing is uploaded unless a specific feature is switched on.
Capture is opportunistic rather than authoritative. A read-only Gmail scope reads bank and credit-card alert emails and parses them on the device; a UPI QR can be scanned and handed to the user’s own UPI app; a CSV can be imported. None of those paths post a transaction by themselves — they all land in a review queue that the person confirms, and confirmation is what teaches the merchant rules that make the next one automatic.
The parts that touch other people or other servers are the parts I spent the most time being careful about: an owner-scoped household sync with an explicit conflict prompt, an end-to-end encrypted cloud backup the server cannot read, and a payment handoff that refuses to invent a result it never received.
My role — Sole designer and developer — product, Flutter app, local schema and migrations, Supabase policies, encryption, tests and the security review remediation. No PSP, NPCI or bank integration is claimed.
How it works
Card spend capture — an email in, a reviewed expense out
The implemented Gmail path. Nothing here posts a transaction on its own; the person confirming is what makes it real. Dummy values shown.
Swipe or scroll to explore the full diagram →
01Grant read-only mail access
You sign in with Google and give the app permission to read mail — nothing more. It cannot send or delete anything, and the permission can be withdrawn from your Google account at any time.
Select any node in the diagram to inspect it.
- Connected system
- Internal layer
- This project
- Data at rest
- Person
- Planned (not built yet)
- Pending
- Success
Settling over UPI — a handoff the app refuses to trust
Scan-and-pay and household settlement share one path. The app never claims a payment succeeded, because it is never told that it did. Dummy VPAs and amounts.
Swipe or scroll to explore the full diagram →
01Work out what’s owed
Either you’re paying a shop, or the household ledger has already worked out who owes whom and how much.
Select any node in the diagram to inspect it.
- Person
- Internal layer
- This project
- Connected system
- Data at rest
- Success
- Pending
- Info
Example output
Where the data actually lives
Local by default · every cloud path opt-in · only one of them is end-to-end encrypted
- SQLite on the device
- always — 22 tables, works fully offline
- Device secure storage
- backup passphrase, auth session, app-lock state
- Encrypted cloud backup
- opt-in — AES-256-GCM ciphertext; the server cannot read it
- Personal multi-device sync
- opt-in — rows stored as plaintext JSON, isolated per user by RLS
- Household sharing
- opt-in — shared accounts only, plaintext, visible to that household
- Receipt images
- never uploaded — they stay on the device that captured them
- Bank alert emails
- read read-only, parsed on-device; the resulting row syncs only if sync is on
The honest version, not the marketing version. Each row is a different level of exposure, and the app’s settings never present them as the same thing.
Architecture
Architecture
One local database at the centre; every cloud service hangs off the side as something you switch on.
Swipe or scroll to explore the full diagram →
- Connected system
- Internal layer
- This project
- Data at rest
- Connected system
- Internal layer
- This project
- Data at rest
The one structural decision everything else follows from: the local database is never a cache of the server. It is the record. Sync is a feature layered on top, which is why the app is fully usable with no account at all — and why a failed sync is an inconvenience rather than data loss.
How the sync layer got simpler
The first working version of multi-device sync mirrored each local table into its own typed remote table — sixteen of them, each with its own columns, policies and migration whenever the local schema moved. It worked and it was miserable to change. The current design collapses all of it into one table that carries whole rows as JSON keyed by their own UUID, with a kind discriminator, tombstone rows for deletions and an updated-at trigger for incremental pulls. Adding a new synced entity is now a constant, not a migration — and there is exactly one row-level-security policy to reason about instead of sixteen.
Key engineering decisions
- Local-first ledger
- Email spend capture
- Import review queue
- UPI scan & pay handoff
- Household sharing
- Encrypted cloud backup
- India tax estimator
- Offline finance assistant
- iOS home-screen widgets
Switch to the technical view for the engineering trade-offs behind these.
Challenges & limitations
- Personal sync and household sharing are not end-to-end encrypted: rows are stored as plaintext in Postgres and isolated by row-level security. Only the cloud backup is encrypted in a way the server cannot read, and the app says so where you switch each one on.
- Not on any app store. It is built from source and installed on my own devices; there is no distribution, no crash reporting and no analytics backend.
- Email capture is regex over Indian bank and card alert formats. A bank that changes its template silently stops matching until the pattern is updated — which is exactly why every capture is a reviewable candidate rather than a posted transaction. SMS capture on Android is planned, not built.
- There is no UPI, PSP or NPCI integration. The app builds and validates a deep link and hands it to an installed UPI app; it cannot verify a bank result and does not pretend to.
- The tax estimator is an estimate for individual taxpayers, hand-encoded per financial year. It is not a payroll calculation and not a filing tool.
- A full source security review was run against an earlier build and drove concrete fixes — account-bound sync cleanup, a preference allowlist, local-only receipt paths, CSV formula neutralization, secure session storage and a navigation reset before app lock — backed by a small regression suite. Server-side policy verification and native-side checks are still manual steps, not automated gates.
What I learned
Writing an app about my own money made me much stricter about certainty. Captured, reviewed, recorded, synced and settled are five different states, and almost every design mistake I made was some version of rounding one up to the next — an optimistic payment result, a silent merge, a parser trusted with the ledger, or the word “private” stretched to cover a feature that uploads. Being explicit costs one extra tap and buys a ledger I actually believe.
Project status
Version 2.25.4+65: ~49k lines of Dart across 136 files, 25 test files (the project’s own verification run recorded 95 passing tests plus an isolated 7-test security regression suite), a strict premium-UI audit closed at zero findings on an iPhone 17 Pro simulator, and a full source security review whose findings were remediated in source. Built from the tree and installed on my own devices — no store distribution.