Solo product · Deployed on the web
Settle
Money that has to reconcile to the cent, offline, from one codebase
Context
Splitting expenses inside a group is a deceptively unforgiving problem. The interface looks like a list of receipts, but underneath it is a ledger, and a ledger is either exactly right or it is worthless. If the app tells four people they are square and one of them is out by a few piastres, the app has not saved an argument - it has caused one.
Settle records who paid for what across a group, converts everything into a single base currency, and then works out a short set of payments that makes everyone even. It is bilingual in English and Arabic with full right-to-left layout, and its currency list is weighted towards the region it was built in - which turns out to matter more than it sounds.
My role
Solo, and mine from the idea onwards. I decided what the product was, wrote the spec, chose the settlement approach, then built all of it: the platform strategy, the entire codebase, the data model, the authorisation policies in Postgres, the sync layer, the test strategy and the deployment.
The most useful thing about building alone is that nothing can be someone else’s problem. The rounding rule, the row-level security policy that rejects a write, and the reason an onboarding screen renders as a two-pixel hairline are all the same person’s to find.
Ownership, layer by layer
Where this work sat in the stack, and how much of each layer was mine.
Requirements
BuiltMy own idea. I defined the product, wrote the spec it was built to, and chose how settlement had to work.
Interface
BuiltThe entire application: one Expo codebase for web, iOS and Android, bilingual with full right-to-left layout.
Integration
BuiltLocal-first sync, three-way roster merge, tombstone deletes, per-row quarantine and a hardened fetch path.
Services
BuiltThe settlement engine - integer minor units, largest-remainder distribution, greedy transfer resolution.
Data & cloud
BuiltPostgres schema, row-level security policies, SECURITY DEFINER helpers, delete trigger and hashed invite tokens.
Delivery
BuiltStatic web export and deployment, Android build profiles, and the engine and end-to-end test suites.
- Built Designed and implemented this myself.
What I built
Interface
One codebase, three targets
Expo and React Native with react-native-web, targeting iOS, Android and the web from a single source - with expo-router giving the web build real URLs while the same screens present as native modals on device. The web build is the one that is deployed; Android build profiles are configured for internal distribution.
Bilingual, genuinely
English and Arabic with full right-to-left layout rather than a translated string table in a left-to-right shell.
The ways people actually split things
Multiple payers on a single expense, itemised splits, percentage and share-based splits, standalone IOUs outside any group, five kinds of group, and a Quick Split path for the case where nobody wants to create a group at all.
Services
The settlement engine
Pure TypeScript with no dependency on the UI: each person’s net position is what they paid, minus what they owe, plus what has already been settled - then a greedy pass repeatedly matches the largest debtor against the largest creditor. That is a heuristic rather than a true minimiser: it settles everyone in at most one payment fewer than there are people, which is the guarantee worth making, and finding a provably minimal set is NP-hard.
Balances are computed, never stored
Expenses are the single source of truth. Balances and transfers are derived selectors, so there is no second copy of the truth that can drift away from the first.
Data & cloud
Authorisation in the database
Access rules live in Postgres as per-command row-level security policies with SECURITY DEFINER helpers - not in application code where a forgotten check becomes a data leak.
Invites that cannot be replayed
Invite tokens are stored only as SHA-256 hashes, accepted under a row lock, and a unique partial index guarantees a roster slot can be claimed by at most one account.
Deletes that survive sync
Deletions propagate as tombstone updates rather than removals, and owner-only deletion is enforced by a trigger rather than a policy - because a soft delete arrives at the database as an UPDATE, where a DELETE policy would never fire.
Integration
Local-first sync
The app is fully usable with no network. Sync is a debounced pull-then-push cycle against Supabase, with a three-way merge for the group roster and per-row quarantine so one rejected record cannot wedge the queue.
A fetch wrapper that refuses to guess
Non-GET writes on the REST data path that carry the anonymous key are rejected client-side, because the client library silently falls back to that key when a session cannot be resolved - and the server then returns the same error code as a genuine ownership conflict, which makes the real bug invisible. Stored procedures are exempt by design, since they are POSTs by protocol, and are guarded in the database instead.
Delivery
Shipped and reachable
Static web export deployed and live. Android build profiles are configured for internal distribution; no store submission has been made, which is a deliberate scope decision rather than an unfinished one.
Tests that run against the real build
Fifty-six engine tests with no test framework at all, plus an end-to-end suite that runs against the exported static site rather than a dev server.
The decisions that were actually hard
ProblemFloating-point arithmetic cannot be trusted with money, and the usual fix - work in cents - quietly assumes every currency has two decimal places. The Japanese yen, the Lebanese pound and the Iraqi dinar have none; the Gulf dinars have three.
ResolutionEvery split and settlement computation runs in integer minor units of that currency - converted in at the top of the pass and back out at the bottom - and the number of decimals is threaded through every entry point of the engine rather than assumed at the edges. A Kuwaiti dinar is a thousand fils, and the engine is told so.
ProblemDividing a bill three ways rarely divides evenly. Round each share independently and the parts no longer add up to the whole - so the message someone shares and the group’s own settle-up screen disagree, which destroys trust in both.
ResolutionEqual splits use largest-remainder distribution so the parts always reconcile exactly to the total, and every other split mode - percentage, shares, the multi-payer breakdown, Quick Split - reuses that same distribution rather than reimplementing it.
ProblemAfter currency conversion, the net positions can still miss zero by a rounding unit. Silently absorbing that is tempting and dangerous: it would also absorb a real structural bug that breaks the same invariant much more seriously.
ResolutionThe residual is folded into the largest balance so users never see an unsettleable fraction - but it raises a loud warning in development, because anything larger than a rounding unit means the nets genuinely do not sum to zero, and hiding that is how a ledger bug survives to production.
ProblemThe group roster was stored as a single JSON column that each device replaced wholesale. Two people adding a member at the same time meant one of them vanished - and because balances are only computed for people currently on the roster, a lost member does not produce an error. It produces confident, wrong settle-up amounts.
ResolutionSync was inverted from push-then-pull to pull-then-push, because pushing first destroys the evidence needed to reconcile. The roster now merges through a three-state comparison against a recorded baseline, which can tell "I added this person" apart from "they removed this person" - the distinction the naive merge could not make. It is covered by its own dedicated unit tests.
ProblemEvery newly created group failed to sync with a permission error. The cause was not the insert: chaining a result read onto a write makes the database evaluate the read policy against the brand-new row, and that policy performs a lookup which cannot yet see it.
ResolutionWrites no longer request their own result back. Beyond that, a bulk rejection falls back to row-by-row retry with per-row quarantine, so a single unacceptable record degrades to one skipped row instead of a permanently stuck sync queue.
ProblemType checking and the engine tests both passed while an onboarding screen rendered as a two-pixel hairline. Neither could see it, because neither looks at the thing the user looks at.
ResolutionThe end-to-end suite runs against the exported production build rather than a dev server, asserts on the real accessibility tree - the web compatibility layer drops some accessibility state silently, which source review cannot detect - and enforces a minimum number of interactive controls per screen, because a test that passes on an empty page is worse than no test.
Technology
Built with
- TypeScript
- Expo
- React Native
- react-native-web
- expo-router
- Supabase
- PostgreSQL
- Row-level security
- Playwright
- Progressive web app
Where it landed
Settle is deployed and reachable on the web, from a codebase that also targets iOS and Android. It works with no connection at all, and reconciles when one returns. The invariant the whole product rests on - that everyone’s positions sum to zero - is enforced in the engine, tested directly, and defended by the sync layer rather than assumed by it.
The part I would point at is not a feature. It is that the hardest bug in the project was not in the settlement mathematics, which is the interesting-looking part, but in a merge strategy for a JSON column - and that it presented as correct output rather than as an error. Building it alone is what made that findable.