A self-hosted, offline-first PWA for shared group expenses — per-currency balances kept side by side, debt simplification, receipts, push notifications, and a full audit trail with revert.
- End-to-end encrypted — expenses, payments, comments and receipt images are sealed on the device under a per-group key the server never holds. A database dump is opaque. See "Encryption" below for what that achieves.
- Groups & invites — username + password; join by showing a QR code to a member in person, or by a 128-bit capability invite link an admin approves. A joiner can take over a name already in the ledger — a placeholder somebody typed, or their own membership on an account they have lost. Picking one is the easiest thing in the flow to get wrong, because it happens seconds after following a link, for a group nobody can see yet: so the pick stays editable while the request is undecided, and afterwards the same question can be asked from inside the group, where the entries are on screen. Either way an admin approves it, since a takeover moves everybody's balances.
- Expenses — equal / exact / percentage / shares splits, multiple payers, notes, categories, multi-photo receipts (compressed + EXIF-stripped on device). The category is suggested from what the entry is called — a word list per category in both languages, read together and forgiving of a typo — and stops suggesting the moment somebody picks one themselves.
- Parallel multi-currency — balances per currency side by side; settle a specific currency's debt (incl. cross-currency at a frozen rate); bulk convert old entries at an editable ECB-suggested rate.
- Offline-first — the UI reads only a local IndexedDB mirror; edits queue
in an outbox and sync via one
/api/syncround-trip (client-generated ids, idempotent mutations, LWW with full audit trail). Photos queue too. - Settle up — per-currency greedy debt simplification (≤ n−1 transfers), one-click payment recording.
- History — per-group activity feed with revert-to-any-version for expenses and payments, and undelete for expenses, payments and receipts. Version snapshots are sealed under the group key like everything else, so the audit trail survives encryption without the server holding a readable copy of anything.
- Insight — per-person spending, category breakdown, monthly trend (per currency or display-converted); CSV export.
- Push notifications — Web Push on expense/payment/member events. The server cannot tell who an entry names, so it still sends every one of them to the whole group; the device opens the entry and decides. Something you are in arrives as it always did — including being taken off one, which moves your balance and is judged against the entry either side of the change, not only as it ended up. Something you are in neither version of says so, silently, folded into one line per group — never dropped, because the subscription promises the browser that every push puts something on screen.
- English and German — chosen in settings, guessed from the browser on first run, and remembered. Money and dates follow the chosen language rather than the browser's locale. The server sends error codes, never prose, so every word a user reads comes from the client — including inside the service worker, which renders notification bodies itself. The privacy policy is English only, deliberately: it is a legal text and a machine translation of one would be worse than none.
- All money is integer minor units; split math is largest-remainder exact. The client re-validates every invariant on read and on write — the server cannot, because it cannot see inside an expense.
packages/shared— zod schemas, sync protocol, money/split/balance/ simplification/conversion logic. Runs identically in client and server.apps/server— Fastify + Drizzle (MySQL 8) API.apps/web— React + Vite PWA (Dexie local mirror, custom service worker).
pnpm install
pnpm test # unit and property tests
pnpm typecheck
# The invite tests talk to a real database and skip without one. They are the
# only place the token hashing and the single-use race are checked end to end.
DATABASE_URL=mysql://spendapp:spendapp@127.0.0.1:3306/spendapp \
pnpm --filter server test
# server (needs MySQL 8)
cp apps/server/.env.example apps/server/.env # edit DATABASE_URL etc.
pnpm --filter server db:push # create tables
pnpm dev:server # api on :3000
# web
pnpm dev:web # vite on :5173, proxies /apiOptional integrations (see apps/server/.env.example):
- Push:
npx web-push generate-vapid-keys→VAPID_*vars. - FX rates: automatic (key-free ECB via frankfurter.dev), cached daily.
Expenses, payments, comments, receipt images and group names never reach the server readable. The password derives a master key on the device (Argon2id); that splits into an auth key the server does verify and a wrapping key that never leaves. Each group has a key per epoch, wrapped to each member's X25519 public key, and every entity is sealed with AES-GCM bound to its own id, group and epoch.
A database dump, a stolen backup or a curious operator reading tables gets ciphertext. What stays readable is the metadata the server must route on: who is in which group, entry counts, sizes and timestamps.
The group name is sealed under the group's newest epoch key and re-sealed at every rotation, so a member admitted from today onwards can still read it. Two things used to need it readable and now do without: the invite landing page gets the name from the link itself (the inviting device writes it into the fragment beside the token, which never reaches a server), and a push notification names the group by id and the device titles it from its own mirror.
Two things anchor a key to a person rather than to whatever the server hands over. On a join, the digits both sides read aloud authenticate the joiner's public key to the admin approving them. And every device records, under a key derived from its own account and never sent anywhere, what each epoch's key really was — so a second device, or one whose cache was cleared, can tell a genuine hand-over from a substituted one and refuses the substitute out loud. The members tab also shows a key check number: every member holding the same keys sees the same digits, which is how two people confirm it by voice.
Consequences worth knowing before you deploy:
- A forgotten password loses the data. There is no reset and deliberately no recovery code. A shared group survives socially — another member re-wraps its keys to a fresh account, which is what the join flow already does — but a group of one is unrecoverable.
- The server validates no money. It cannot see a split, so a modified client can write a corrupt entry into a shared group. Clients check on read and refuse it, and the group is told which entry and who wrote it.
- No server-side search, reporting or aggregation, permanently.
- Notifications fan out to everybody, and are filtered on arrival. Sending only to the people an entry names would mean telling the server who they are, which is the participant graph — who shares expenses with whom, and how often — and worth more than the notification it would buy. So the filtering is a quieter phone, not a smaller leak: every member's device is still woken by every entry. Anything a device cannot settle — offline, no key for that entry, not signed in, too slow — is shown in full, because a missed alert about your own money is the worse failure.
- Keys are cached unwrapped in IndexedDB, so the app works offline from a cold start. This protects data on the server, not on an unlocked stolen phone.
- The very first key a brand-new account is given is still taken on trust. There is nothing recorded yet to check it against, which is what the key check digits are for — compare them with another member once, out of band, and every delivery after that is anchored.
Do this once after deploying, and again after any migration that touches the sealed tables. It is the only check that tests the actual claim:
mysqldump spendapp > /tmp/check.sql
grep -i 'a description you know is in there' /tmp/check.sql # must find nothingpnpm --filter server test pins the sealed tables to explicit column lists, so
a plaintext column reappearing fails in CI rather than in the dump.
A deployment from before names were sealed migrates in two steps, like
per-entry keys did: 0009 adds the sealed columns, every group is sealed by
the first member to sync holding its newest key, and 0010 — once
select id from groups where name_ct is null comes back empty — makes the
sealed columns required and drops the readable one. Old backups keep the old
names; nothing can take those back.
Prefer versioned migrations over db:push once you have real data:
pnpm --filter server db:generate # write a migration from the schema diff
pnpm --filter server db:migrate # apply itdb:push --force skips drizzle's data-loss confirmation and can drop and
recreate a table for some column-type changes — back up first
(mysqldump spendapp > backup.sql).
Deployment is scripted — see deploy/README.md. Run both
on the server, from a checkout:
./deploy/setup.sh https://spend.example.com # once: provision the host
./deploy/deploy.sh # each releasedeploy.sh exports the current commit into a timestamped release directory,
builds it, backs up and migrates the database, then flips a current symlink
and restarts the systemd unit — rolling back if the health check fails.
Serving the result is left to whatever web server the host already runs:
point it at /opt/spendapp/current/apps/web/dist with an SPA fallback to
index.html, and proxy /api to 127.0.0.1:3000. HTTPS is required for the
service worker, installability, and push. Back up the MySQL database and the
RECEIPTS_DIR directory.
Write a privacy policy to PRIVACY_PATH before letting anyone else sign up —
registration will not complete without one being shown and accepted, and until
the file exists the app serves a placeholder that says so. deploy/README.md
covers the file, its version marker, and why the access log should stay off.
Both rights people are most likely to exercise are self-serve, in Settings, so neither needs the operator:
- Download my data builds a ZIP on the device: the account and membership data the server holds, plus every expense, payment, comment and receipt, decrypted. It has to be assembled client-side — the server holds ciphertext, so it could never produce a readable copy, and an archive of ciphertext would not be portable in any useful sense.
- Delete my account asks for the password again, leaves every group
(handing on admin, and destroying any group where they were the last member),
then clears the credentials, keys, sessions, push subscriptions, invites,
every key wrap and grant addressed to them, and the consent record. The row
survives as a tombstone holding only an id and a display name: the id is
written inside sealed splits that nothing can rewrite, and the name is what
keeps other members' balances legible. Two tests pin this — one that every
other column of
usersis cleared, and one that every table is either emptied or excused in writing, so adding either fails until deletion accounts for it.