How it works

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.

The system

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.

Client
Flutter · Riverpod
Mobile app
Feature-first — presentation / domain / data per feature. One state-management pattern, no exceptions.
↓
Drift (SQLite)
Local database
Source of truth for the UI. Every screen reads from here first, online or not.
Server
NestJS · TypeORM
API
Modular, DI-driven. Passport-JWT guards on every protected route.
↓
Postgres
Source of truth
Every syncable row carries updatedAt plus a soft-delete tombstone for sync.
Services
Firebase
Auth · push · live chat
Google Sign-In, FCM push, and the Realtime Database behind group chat.
↓
Backblaze B2
Files
Receipts and chat photos — private bucket, S3-compatible API.
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.

Expenses & debts

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.

01

Log it

transactions

Every 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.

02

Personal or shared

groupsexpense-splits

No 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.

03

Settle up

debt-simplification.util.ts

simplifyDebts() 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.

Before — 4 raw debts
After — 2 transfers
A owes B · $20
A owes C · $10
B owes C · $20
D owes A · $5
C owes A · $10
—
D owes A · $15
—
Sync & offline

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.

01

Write locally

Drift

Every 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.

02

Queue it

outbox_service.dart

With no connection, the same write also drops into an outbox table — entity, operation, payload, and a stable idempotency key.

03

Replay & dedupe

GET /sync/changes

Back online, the outbox replays oldest-first against the real API. The server dedupes by that key, so a flaky retry never fires twice.

04

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.

Chat & storage

What goes where

Group chat and receipts share the same instinct: keep the fast stuff fast, and keep private files actually private.

Channel
Handled by
Notes
Text message
Firebase Realtime DB
delivered live, no polling
Photo message
Backblaze B2
same private pipeline as receipts
Receipt photo
Backblaze B2
the database stores only the file key
Viewing a photo
Signed URL
minted on demand, expires in 1 hour
Notifications

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.

fcm.service.ts
Push
Firebase Admin SDK, sent per-device.
email-notification.service.ts
Email
Templated email via Nodemailer.
notifications.service.ts
In-app
Persisted row, shows as a bell badge.
Auth

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.

01

Log in

JWTGoogle Sign-In

Email + password or Google Sign-In. A separate OTP flow (6-digit code, 10-minute expiry) handles email verification and password reset — never login itself.

02

Access token

15 min

Does the actual work — sent on every request, validated by a Passport-JWT guard.

03

Refresh token

7 days · rotates

Stored server-side and rotated on every use. Presenting one that's already been rotated fails immediately.

← Back to Wallet Split