Personal Project · Expense Splitting
CoBudget
A mobile-first, offline-first PWA for splitting shared expenses among friends and roommates — flexible splits, a realtime feed, and a close-based ledger that settles up in the fewest transfers.
- TypeScript
- React
- Vite
- Supabase
- PostgreSQL
- TanStack Query
- TanStack Router
- Tailwind CSS
- PWA
- Vercel
Overview
CoBudget started with a real situation: a trip where everyone fronts different expenses and nobody wants to do spreadsheet math at the end. It’s a small, mobile-first PWA where a group logs shared expenses, splits each one across whichever members it applies to, and settles up through a close-based ledger that computes the minimum set of transfers needed to make everyone even.
The actual money movement happens outside the app — cash, a bank transfer, Venmo, whatever — CoBudget just keeps the books and tells you the shortest way to square up. And because it’s built to be lived in on a phone, you can install it to the home screen and keep using it on a weak connection: expenses created offline queue locally and sync the moment you reconnect.
Architecture
CoBudget is a single-page PWA that talks directly to Supabase from the browser — there is no custom API tier. Authorization is enforced server-side by Postgres Row Level Security (RLS), so the database itself is the trust boundary rather than a middle layer I’d have to write and secure.
React 19 SPA (Vite)
routes/ thin file-based routes — loaders + auth guards
app/ screen views & feature widgets
lib/queries · lib/mutations · lib/actions
lib/supabase singleton client
│ HTTPS + WebSocket (Realtime)
▼
Supabase — Postgres + RLS + Auth + Realtime
A few conventions keep that workable as the app grows:
- Thin routes. Files in
src/routes/(TanStack Router, file-based) only define the route, validate search params, kick off data loading withqueryClient.ensureQueryData(...), and render a view. A single_authlayout route guards every authenticated screen and renders the mobile tab bar or desktop sidebar. - Centralized data access. Reads live in
lib/queries, writes (with cache invalidation) inlib/mutations, and imperative helpers inlib/actions— all resolving to one shared Supabase client. Server state is owned by TanStack Query; I don’t hand-roll caching. - RLS as the boundary. Policies lean on a
get_my_group_ids()SQL helper so a member can only ever read or write data for groups they belong to — and routing membership through that helper sidesteps the recursive-policy trap you hit when a table’s policy needs to query the same table. - Pure settlement math. All the money logic lives in
src/lib/ledger.tswith no I/O, so it’s trivial to unit-test and reason about in isolation.
Key features
Two modes and flexible splitting
Every group is either Split (tracks who owes whom and computes settlements) or Track (totals-only spending, no debts). Within a split group, each expense picks exactly which members share it, plus a note, date, and category — so a dinner four people attended doesn’t get charged to the two who skipped it.
A realtime shared feed
New expenses from other members appear instantly through Supabase Realtime — a Postgres change feed over a WebSocket — so the group’s ledger stays live without anyone pulling to refresh.
Sign-in that meets people where they are
Four entry paths: email one-time code (OTP), email + password, Google OAuth, and instant anonymous guest access. Guests can try the whole app and later upgrade to a real account without losing any data.
Shareable invites
Groups are joined through multi-use, expiring invite links with optional member approval. A recipient can open the link and begin the join flow even before they have an account.
Personalization and i18n
Six color themes, light/dark/system mode, configurable currency symbol and position, a “centless” mode that hides cents, optional haptics and sound, and selectable tab-bar animations. Everything ships in English and Burmese out of the box (i18next), and push notifications back configurable meal reminders (breakfast / lunch / dinner).
How settlement works
The settlement engine is the heart of the app, and it’s deliberately pure code in src/lib/ledger.ts:
calculateNetBalanceswalks every expense, credits the payer the full amount, and debits each split member an equal share (rounded to cents), producing anet = totalPaid − totalOwedfor each person.simplifySettlementsgreedily matches the largest creditor against the largest debtor, collapsing the web of “who owes whom” into the fewest possible transfers.
The classic example: if A owes B $10 and B owes C $10, the naive plan is two payments — but simplification produces a single transfer, A pays C $10.
Settlement is wrapped in a close workflow so a group can agree on the books for a period:
- A member starts a close; the period’s expenses are locked, settlements are computed and stored, and every member is asked to confirm.
- All confirm → the close is Settled. Anyone disputes → Disputed. The initiator can undo → Cancelled.
- In Track mode there are no debts, so a close settles automatically with no acknowledgment step.
Because the math is pure and the close states are explicit, the UI is always just a render of where a close actually is — there’s no hidden derived state to drift out of sync.
Offline-first sync
A phone on a trip is exactly when connectivity is worst, so offline isn’t an afterthought:
- An expense created offline is written to an IndexedDB queue with a
pendingstatus and an amber treatment in the UI, so you can see what hasn’t synced yet. - On reconnect, Background Sync (with a foreground sync manager as a fallback) flushes the queue to Supabase.
- Each item resolves as synced, retryable-failed (up to a max), or flagged — and pending items can still be edited before they ever reach the server.
The Serwist service worker also precaches the app shell and serves an offline.html fallback for navigations, so the app opens even with no network at all.
Data model
Every table enforces RLS. The core of the schema:
groups+group_members— a shared group (with its split/track mode) and its membership.expenses+expense_members— an expense and the subset of members it’s split across; expenses publish to Realtime.closes,close_settlements,close_acknowledgments— a settlement period, its computed “who pays whom” transfers, and each member’s confirm/dispute status.group_invites— multi-use, expiring join tokens with optional approval.profiles,user_categories,notification_preferences,push_subscriptions— per-user identity, custom categories, and notification wiring.
Alongside the tables, a few SQL functions do real work: get_my_group_ids() powers the RLS policies, and is_expense_settled() blocks edits to an expense once its period has been closed — enforcing the “locked books” rule in the database rather than trusting the client.
Tech stack
- Build & UI — Vite 7, React 19, TypeScript.
- Routing & data — TanStack Router (file-based, code-split) and TanStack Query for all server state.
- Backend — Supabase: Postgres, Auth, Realtime, and Row Level Security — no bespoke API server.
- Styling & components — Tailwind CSS v4, shadcn/ui on Radix primitives, Framer Motion for animation, Vaul for drawers, Recharts for spending charts, Phosphor icons, Sonner toasts.
- PWA — Serwist for the service worker (precache, push, background sync); web-haptics for tactile feedback.
- i18n — i18next + react-i18next (English + Burmese).
- Tooling — Vitest + Testing Library for units, Playwright for E2E.
Testing and deployment
The pure settlement logic is the highest-value unit target, so src/lib/ledger.ts is covered with Vitest in a jsdom environment. Playwright drives end-to-end flows — auth, group, expense, and ledger — against a Mobile Chrome (Pixel 5) profile, which matches how the app is actually used.
It deploys to Vercel as a Vite project: / serves a static marketing landing page, and every other non-asset path falls back to index.html for SPA routing. Only VITE_-prefixed env vars are exposed to the browser; the service-role key used for seeding stays server-side and out of the bundle.
What I took away
CoBudget is the project where I leaned hardest on the database as the backend. Pushing authorization into RLS instead of a custom API removed a whole tier I’d otherwise own — but it demanded real discipline about policies (and taught me the recursive-policy lesson firsthand). Keeping the settlement math pure made the scariest part of a money app the easiest part to trust, and treating offline as a first-class state rather than an error case is what makes it genuinely pleasant to use on the road.