WhoWhat — shared expenses for groups, couples and roommates
A web app (PWA) for splitting expenses within a household — who paid what, who owes whom, with history, stats and settlements. Supports any number of people per household and any number of households per user. Mobile-first, installable on your phone, works partially offline. Available in Polish and English, with more languages easy to add.
Features
- Dashboard — "who owes whom" tiles (automatically simplified to the minimum number of transfers for the whole group) with a "Settle up" button, a pie chart of expenses by category, and a month summary.
- Add expense — large amount field, categories with icons, payer selection, three split modes: equally (among all members), exact amounts, or the full amount to one person.
- History — expense list with filters (month/category/payer), edit and delete.
- Stats — month-over-month expenses, comparison of household members' spending, category ranking.
- Multiple households — a user can belong to several households at once and switch between them (Settings → "Your households"); each household can have any number of members, who join with the same invite code.
- Settings — switch and manage households (rename/change currency, remove a member, delete a household, invites), custom categories (rename/re-icon), light/dark/system theme, language switcher (Polish/English), email notifications, CSV export.
- Account — email/password registration, login, rename, change password, password reminder/reset by email, account deletion.
- PWA / offline — installable on your phone's home screen, cached views, expenses added offline are queued and sync automatically once the connection is back.
Tech stack
| Layer | Technology |
|---|---|
| Frontend | React (Vite), react-router-dom, TanStack Query, Recharts, vite-plugin-pwa, Material Symbols (Google Fonts) |
| Backend | Node.js + Express, better-sqlite3, JWT (jsonwebtoken), bcryptjs, nodemailer |
| Database | SQLite (a file on a bind mount at ./sqlite) |
| Infrastructure | Docker Compose, nginx (serves the frontend + proxies /api), optionally Traefik (TLS + domain routing) |
Internationalization (i18n)
The UI ships in Polish and English out of the box, switchable at any time from Settings → Language (persisted to localStorage, and to the user's account once logged in, so transactional emails and CSV exports match their preference too). Backend API errors are returned as stable machine-readable codes (e.g. invalid_credentials) and translated client-side — the server never hardcodes user-facing language.
Frontend translations live in frontend/src/i18n/locales/<lang>/<namespace>.json, one JSON file per component/page, auto-discovered at build time (no registration step). To add a new language: copy the locales/en/ directory to locales/<code>/, translate every value, and add { code, label } to frontend/src/i18n/languages.js. Backend-rendered content (emails, default category names, CSV headers) lives in backend/src/i18n.js — add the new language code there too if you want those translated as well.
Project structure
whowhat/
├── docker-compose.yaml # the only file needed to run everything
├── .env # configuration (secrets, domain, SMTP) — do NOT commit
├── .env.example # configuration template to copy
├── sqlite/ # bind mount — app.db lives here (data persistence)
├── backend/
│ ├── Dockerfile
│ └── src/
│ ├── index.js # Express app, router mounting
│ ├── i18n.js # server-rendered translations (emails, CSV, default categories)
│ ├── db/ # schema.sql + better-sqlite3 connection
│ ├── middleware/auth.js # JWT verification
│ ├── routes/ # auth, households, categories, expenses, settlements, stats
│ └── utils/ # balance calculation, mailer (nodemailer), household helpers
└── frontend/
├── Dockerfile # multi-stage: build (node) -> serve (nginx)
├── nginx.conf # proxies /api -> backend:3000
├── vite.config.js # PWA config (manifest, service worker)
└── src/
├── i18n/ # I18nContext, per-namespace locale JSON files
├── pages/ # Dashboard, AddExpense, History, Stats, Settings, Login, Register, ...
├── components/ # BottomNav, charts, forms, Icon, Switch, LanguageSwitcher, ...
├── api/ # fetch client + React Query hooks
├── auth/ # auth context (JWT in localStorage)
├── household/ # active household context (list + switching)
├── theme/ # light/dark/system theme context
└── offline/ # IndexedDB queue + sync on reconnect
Running it
Docker (with the Compose plugin) is the only requirement.
cp .env.example .env
# fill in .env (see below) — no need to edit docker-compose.yaml
sudo docker compose up -d --build
The app will be available at http://localhost:8856 (and at your Traefik domain, if configured — see below).
Configuration (.env)
All deployment configuration lives in .env — docker-compose.yaml doesn't need editing.
| Variable | Description | Default |
|---|---|---|
JWT_SECRET |
Secret used to sign login tokens. Generate with: openssl rand -hex 32 |
— (required) |
FRONTEND_URL |
Public URL of the app, used in email links (e.g. password reset) | https://example.com |
DOMAIN |
Domain Traefik should expose the app on | example.com |
TRAEFIK_NETWORK |
Name of the existing external Docker network Traefik is attached to | traefik_public |
SMTP_HOST |
SMTP server address. Empty = email sending disabled (console log only) | — (optional) |
SMTP_PORT |
SMTP port | 587 |
SMTP_SECURE |
true for immediate SSL/TLS (port 465), otherwise false (STARTTLS) |
false |
SMTP_USER / SMTP_PASS |
SMTP login credentials | — |
SMTP_FROM |
Sender address on outgoing emails | SMTP_USER |
Without SMTP configured the app works normally — "password reset" and "email notifications" simply don't send real emails (the backend logs to the console that sending was skipped).
Data / persistence
The SQLite database lives at ./sqlite/app.db on the host (a bind mount, not a named Docker volume) — easy to copy, back up, or inspect with the sqlite3 CLI without entering the container.
Deploying behind Traefik
The frontend service is attached to the external traefik_public network (name configurable via TRAEFIK_NETWORK) and carries Traefik labels (domain routing from .env, TLS via tls-resolver). Prerequisite: the traefik_public network must already exist on the host (normally created by the Traefik stack itself):
docker network create traefik_public # only if it doesn't exist yet
The frontend's port 8856 is also published directly on the host — useful for local testing alongside access through Traefik.
Data model (SQLite)
users— accounts (email, password hash, email notification preference, UI language)households— households (name, currency)household_members— household membership (any number of people; a user can be in several households at once)invites— household invite codes (valid 7 days, reusable — they don't expire after a single join)password_resets— one-time password reset tokens (valid 1h)categories— expense categories (name, Material Symbols icon, color)expenses— expenses (amount, payer, category, date, split type)expense_shares— the final split of an expense across household members (always sums to the expense amount regardless of split type)settlements— settlement history ("Settle up")
Per-person balance is computed as: (amount they paid) − (sum of their expense shares) − (net settlements). For the "who owes whom" display, balances are simplified with a greedy algorithm (backend/src/utils/balance.js: simplifyDebts) that produces the minimum number of transfers to settle everyone (instead of a separate debt between every pair).
New rows in users get a language column ('pl' or 'en', default 'pl'); on an existing database this is added automatically on startup via ALTER TABLE ... ADD COLUMN — no manual migration, and no existing rows are touched or deleted.
Multiple households — how it works
A user can belong to multiple households. Since every request for household-scoped resources (expenses, categories, balance, stats, settlements) needs to know which household it's about, the frontend sends an X-Household-Id: <active household id> header on every such request (set automatically by frontend/src/household/HouseholdContext.jsx whenever the active household is switched in Settings). The backend's requireHousehold middleware verifies the logged-in user is actually a member of the given household.
Removing the last member from a household automatically deletes the household itself (along with its entire expense history — cascading deletes via foreign keys). Deleting a user account that has shared financial history with others (expenses/shares/settlements) doesn't physically remove it from the database (that would break the history visible to the rest of the household) — instead the account is anonymized (name → "Deleted account", email replaced with a unique non-existent address, password invalidated). A fresh account with no history is deleted outright.
API (overview)
Every endpoint except /auth/register, /auth/login, /auth/forgot-password, /auth/reset-password and /health requires an Authorization: Bearer <token> header. Household/category/expense/settlement/stats endpoints additionally require X-Household-Id: <id>.
Error responses are { "error": "<code>" }, where <code> is a stable snake_case identifier (e.g. invalid_credentials, household_not_found) meant to be translated client-side — see frontend/src/i18n/locales/en/errors.json for the full list.
| Group | Endpoints |
|---|---|
| Auth | POST /auth/register, /login, /change-password, /forgot-password, /reset-password, GET /auth/me, PUT /auth/me, PUT /auth/me/notifications, PUT /auth/me/language, DELETE /auth/me |
| Households | GET /households (yours), GET/PUT/DELETE /households/:id, POST /households, POST /households/:id/invite, POST /households/join, DELETE /households/:id/members/:userId |
| Categories | GET/POST/PUT/DELETE /categories[/:id] |
| Expenses | GET/POST/PUT/DELETE /expenses[/:id] (filters: month, categoryId, payerId) |
| Settlements | GET/POST /settlements (POST immediately settles all simplified transfers) |
| Stats | GET /stats/balance, /summary, /monthly, /export.csv |
Offline mode (PWA)
A service worker (Workbox, via vite-plugin-pwa) caches the app shell and recently fetched GET data from the API (NetworkFirst strategy), so the app opens and shows data even without a connection. A new expense added offline is queued in IndexedDB (frontend/src/offline/) and sent automatically once the connection comes back (online event) — a banner then shows the number of pending entries.
Known limitations
- Copying the invite code via
navigator.clipboardrequires a secure context (HTTPS orlocalhost) — over plain HTTP on a local network the browser may block it; the code is therefore always also available as a selectable text field (manual copy always works). schema.sqlusesCREATE TABLE IF NOT EXISTS— adding a new column to an existing table on an already-running database requires either a manual migration (ALTER TABLE) or startup logic like the one already in place forusers.language(seebackend/src/db/db.js); a fresh database gets the current schema immediately.- A household invite code doesn't expire after first use (intentionally — it lets you invite any number of people with the same code), only after time (7 days) or a manual regeneration in Settings.
Local development (without Docker)
Requires Node.js 20+.
cd backend && npm install && JWT_SECRET=dev DATABASE_PATH=./data/app.db npm start
cd frontend && npm install && npm run dev # dev server on :5173, proxies /api -> :3000