Why I Built TripLedger
A travel budget and expense-splitting app that has to work at a counter with no signal. What I wanted from it, the offline-first rules that fell out of that (no stored balances, shares_revision, anonymous-first sign-in), and what changed after launch.
What it is
TripLedger is a travel budget and expense-splitting app. You create a trip, set a budget, log what you spend in any currency, and split costs with the people you're travelling with. It's on Google Play and the App Store.
There are plenty of expense-splitting apps. This post is about why I built another one anyway, and how a few plain requirements ended up deciding almost every technical choice in it.
The moment that matters
Before writing any code I wrote a spec. Its first paragraph about users says they are travellers abroad, often with no data or on expensive roaming, and that logging an expense is the most-used thing in the app, often done standing at a counter.
Picture that moment. You're paying for a ฿900 scooter rental, your SIM has no data, and there's a queue behind you. In that moment the app can't show a spinner. It can't say “no connection”. It can't ask you to create an account first. And it definitely can't lose the entry.
That gave me the list of what I wanted:
- Works with zero signal. Not “has an offline mode”. Fully works, everywhere, and syncs later.
- Fast entry. Amount, currency, category, who paid, how it splits, in a few taps.
- No account to start. Open the app, make a trip, log the first expense.
- Shared trips with balances you can trust. Even when four people have all been adding expenses offline for three days.
- Honest currency conversion. Yen converted at the rate on the day you spent it, not today's rate.
- Every feature free. No in-app purchases, no subscription, no paid tier. That one is written into the spec in bold.
- Runs well on the phones people actually carry. 2 to 3 GB of RAM, Android 10 to 13, and Xiaomi, Oppo, Vivo and Realme ROMs that kill background work without asking.
There's a second reason too. My pitch to clients is that I make apps hold up at scale. TripLedger is a product I design, ship and run end to end, where every architecture call is mine. If I say offline sync and multi-user data are hard, I should have something live that shows how I handle them.
Turning wants into rules
Each item on that list turned into a hard rule in the project's CLAUDE.md. Here are the ones that shaped the app most.
The phone's database is the app, not a cache
The app is Flutter. Drift over SQLite is the single source of truth for every read. Every screen reads through Drift's .watch() streams, and every write lands in SQLite first and shows up instantly. PowerSync replicates between that SQLite file and Postgres in the background.
The rule that keeps this honest is a blunt one: no primary screen may ever show a network loading state. If a screen needs the network to draw, it's built wrong.
Sync runs when the app comes to the foreground, when connectivity comes back, and on pull-to-refresh. It never relies on Android background tasks. Those ROMs kill background work regardless of WorkManager, so the app has to be fully correct even if background sync never fires.
Offline, multi-user, and no conflict-resolution code
This is the part I find most interesting. Two people on the same trip, both offline. One changes how an expense is split. The other changes its total. They both come back online. What should everyone's balance screen say?
Instead of writing clever merge code, the data model makes the problem mostly disappear:
- Never store a balance. No totals, no “owes” columns, no cached sums, anywhere. Balances are computed on read from expense, share and settlement rows. If every row eventually arrives, every phone computes the same numbers no matter what order they arrived in.
- Splits never half-merge. Each expense has a
shares_revision. Editing a split bumps the revision and writes a fresh, complete set of share rows. Reads only use shares that match the current revision. A concurrent edit loses cleanly instead of leaving shares that add up to the wrong total. - Settlements are append-only. A correction is a tombstone plus a new row.
- IDs are made on the phone. Client-generated UUIDv7 keys, so a phone that's been offline for three days still creates records with final, stable IDs.
- Soft deletes only, because a hard delete can never reach a phone that was offline when it happened.
- The server sets
updated_at. Phone clocks are wrong, sometimes by years.
Money is stored as integer minor units, and exchange rates as decimal strings. No floats anywhere near money. I made the two-people-offline scenario a required checkpoint before the schema was approved: walk through it concretely and show what every member's balance screen says afterwards.
The rate you actually paid
Every expense stores the exchange rate it used and the converted amount, frozen. It's the one deliberate stored value in the schema, because it's history, not a derived number. A backend job fetches rates once a day from Frankfurter, and the app never calls a third-party rate API itself.
Anonymous first
A new user creates real data with no account. Sign-in is only offered when you want to invite someone or back up your trips, and it attaches everything already on the phone to the new identity. That had to be in the schema and sync rules from day one. Bolting it on later is painful.
Free to use means cheap to run
If nobody pays, the infrastructure has to cost close to nothing. So: PowerSync Cloud's free tier, Supabase for Postgres, auth and receipt storage, and Firebase on the free Spark plan for Crashlytics and push only. The backend is a deliberately thin Hono service running as a Supabase Edge Function. It only does what the client can't be trusted with: fetching rates, invite tokens, and account deletion. No CRUD layer, because PowerSync already owns the data path.
Free tiers have edges, though. PowerSync's free plan shuts an instance down after 7 days with no client connections. So a pg_cron job calls the backend at 06:00 UTC every day, which opens one PowerSync connection that syncs nothing and hangs up.
There's an exit plan too. All auth and sync wiring sits behind five interfaces, chosen in one file. Moving to self-hosted PowerSync with my own JWTs is a build flag plus new implementations of those interfaces. No feature code changes.
How it got built
I built it with Claude Code, one phase at a time: schema, then sync and auth, then screens, charts, backend and hardening. Each phase ends with a summary and a list of decisions that weren't in the spec, and nothing moves until I've reviewed it.
The most useful line in the whole CLAUDE.md is this one: the architecture is fixed, and if you think a choice is wrong, say so in one paragraph, then build it as specified. It kept the useful pushback and stopped the quiet substitutions.
Hardening found the kind of bug I like. PowerSync uploads the write queue strictly in order and retries a failing write forever. That's right for a flaky network. But if the server permanently rejects one write, everything queued behind it waits forever too, and the whole phone silently stops syncing. The fix sorts failures into retryable and fatal. When unsure, it calls the failure retryable, since a wrong “fatal” drops a real write. Fatal ones are skipped and shown on the Sync screen instead of being swallowed.
The first commit was on September 13. Less than two months later, it was in both app stores.
What changed after launch
- Exchange rates broke anonymous-first. Rates first came down as a synced PowerSync table. But signed-out users have no PowerSync token, so they never got rates, and auto-conversion didn't work on their first foreign expense. That's exactly the user the app is built for. Rates now come from a public, cached
GET /ratesendpoint into a local-only table, refreshed at most every 6 hours. The expense form never waits on it. - A hotel stay is one expense. An expense can now span several days. The amount is spread evenly across the days for per-day charts at read time, while totals and balances count it once. Still nothing derived is stored.
- iOS needed Sign in with Apple. Once an app offers Google sign-in, App Store Guideline 4.8 effectively requires it. Deleting an Apple-backed account also has to revoke its Apple tokens. Apple's first reply to the submission was an information request for new developer accounts, including a screen recording on a real iPhone.
- Privacy work. In-app account deletion, and usage analytics that are opt-in in the EEA, UK and Switzerland.
What I learned
- Write down who it's for, in one sentence. “Standing at a counter with no signal” settled more arguments than any architecture doc.
- In offline multi-user apps, never store what you can compute. Stored totals are how these apps end up with silently wrong numbers.
- Anonymous-first touches everything. The rates bug only existed because one piece of data assumed a signed-in user.
- Free tiers are a design input. Know their limits before launch, not after the instance gets shut down.
- Tell your AI pair what's fixed. A locked architecture plus “say so in one paragraph” is the best prompt rule I've written.
If you travel with friends or family, give it a try on your next trip: