How Wallet Split works.
What happens between logging an expense and the money actually settling: where the data lives, how debts get simplified, how it works with no signal, and who can see what.
Two apps, five services
The mobile app never talks to the database, the file bucket, or Firebase directly. It only ever talks to one API — that API is the only thing that touches everything else.
One API, always
The app never talks to Postgres, B2, or Firebase Realtime DB directly for money data — everything routes through NestJS, so there's one place that enforces the rules.
Local-first by design
Drift is the source of truth on the phone. The server is a background synchroniser, not something the UI blocks on.
Money gets a paper trail
Every write carries an idempotency key, so a request that gets retried after a dropped connection can never double-charge anyone.
One record, settled the smart way
Every expense — a coffee, rent, a group trip — is the same underlying record. What makes it personal or shared is a single field.
Log it
transactionsEvery expense is a Transaction row — expense, income, debt, or transfer. The account it's charged to can even stay null, for a group expense someone else paid, until that payer assigns one.
Personal or shared
groupsexpense-splitsNo groupId and it only ever touches your own balance. Set one and expense-splits fans it out across every member — equally, by percentage, or exact custom amounts per person.
Settle up
debt-simplification.util.tssimplifyDebts() nets everyone's balance, then greedily matches the largest creditor against the largest debtor, repeated until nobody owes anyone. At most n − 1 transfers for n people — provably minimal.
Works before it's online
Every screen used to assume the network was the source of truth. Offline-first flips that: the local database goes first, the network catches up after.
Write locally
DriftEvery create, edit, or delete lands in the local SQLite database first. The UI reads from there, so nothing ever waits on a request — even mid-flight.
Queue it
outbox_service.dartWith no connection, the same write also drops into an outbox table — entity, operation, payload, and a stable idempotency key.
Replay & dedupe
GET /sync/changesBack online, the outbox replays oldest-first against the real API. The server dedupes by that key, so a flaky retry never fires twice.
Remap the IDs
_remapEverywhere()An offline-created account carries a temp UUID. The moment the server hands back a real one, every local table — and every other still-queued write referencing it — gets rewritten in one pass.
What goes where
Group chat and receipts share the same instinct: keep the fast stuff fast, and keep private files actually private.
One tap, three channels
Send Reminder fans out through notifications.service.ts to all three channels at once — whichever one they actually check, they see it.
Tokens that expire on purpose
A short-lived token does the actual work; a longer one silently renews it. Steal one and it's already useless in 15 minutes.
Log in
JWTGoogle Sign-InEmail + password or Google Sign-In. A separate OTP flow (6-digit code, 10-minute expiry) handles email verification and password reset — never login itself.
Access token
15 minDoes the actual work — sent on every request, validated by a Passport-JWT guard.
Refresh token
7 days · rotatesStored server-side and rotated on every use. Presenting one that's already been rotated fails immediately.