IOU
Group-trip expenses and rounds, settled without the spreadsheet.

- Year
- 2025
- Role
- Design and engineering
- Stack
- TypeScript · Expo · Express · Supabase
The problem
Splitting costs on a group trip is a solved problem that keeps being solved badly. Most tools handle the arithmetic and ignore the social part: nobody wants to be the person chasing four friends for £11.
The approach
A shared ledger for trips and rounds, with the settlement maths done continuously rather than at the end.
- Full stack across mobile and backend: Expo client, Express API, Supabase.
- Rounds modelled as first-class objects, not just another expense category.
What was hard
Bills and rounds are two different financial models, not two icons on one form. A bill settles real money and feeds the settlement maths; a round never touches that ledger and only ever produces a social nudge. Every other splitting app collapses the two. Doing that would mean either overstating what people owe or diluting what a bill means. A round is a social contract with fuzzy fairness, not a debt.
The other half was currency. Every row stores its original amount, its base-currency amount, and the rate that converted them, locked at creation, because a trip runs for a week and rates move daily, and re-deriving on read would let a Lisbon bill from day one quietly reprice itself by day seven.
What I'd change
The blanket claim I nearly published here was wrong, so here is the narrow
one. Every mutation that changes shared state (claiming a profile, completing
one, soft-deleting or restoring a bill) is a Postgres SECURITY DEFINER
function that opens with SELECT ... FOR UPDATE on the target row. That is a
deliberate lock, not the default; the usual shape is a client-side update()
straight from the Supabase client. Two people tapping "claim" on the same
profile within a second is not hypothetical in a five-person group chat.
The gap is the one endpoint where it mattered most. Trip finalisation reads the trip status, computes settlements, deletes and re-inserts them, then flips the trip to locked. None of it is in a transaction or behind a row lock, and the settlements table has no unique constraint on the member pair. A double-tap on "End trip" passes the status check twice and writes duplicate settlement rows on the one screen where the numbers are meant to be final.
So the fix is one Postgres function shaped like all the others: lock the trip, compute, write, flip the status, in a single transaction. It is the single change rather than the easy one because it closes the distance between what this codebase's own pattern says finalisation should look like and what it actually does. The easy change would be pagination.
Then the unglamorous three. Nothing paginates: every bill, round and message for a trip loads in full on every open and after every mutation. FX conversion is a single uncached external call with no way to enter a rate by hand, so a cold start at the provider blocks every non-base-currency bill outright. And there are no tests at all, on money maths that is pure functions and needs no UI to exercise.
What I would reverse is the single 2,635-line schema file meant to be pasted into the Supabase editor by hand, with three rounds of later changes appended as comment-delimited sections. It worked while I was the only one running it, and stopped being defensible the moment I wanted CI or a rollback story. It is now three timestamped migrations, verified byte-identical against the original.