Kacper 1a8c679014
Some checks failed
Build and Push Docker Images / build-and-push (push) Failing after 14s
Add Gitea Actions workflow to build and push Docker images
Builds backend and frontend images and pushes them to the Gitea
Container Registry on every push to main.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 20:12:32 +02:00
2026-07-11 20:03:15 +02:00
2026-07-11 20:03:15 +02:00
2026-07-11 20:03:15 +02:00
2026-07-10 22:57:16 +02:00
2026-07-11 20:03:15 +02:00
2026-07-11 20:03:15 +02:00
2026-07-11 20:03:15 +02:00

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 .envdocker-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.clipboard requires a secure context (HTTPS or localhost) — 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.sql uses CREATE 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 for users.language (see backend/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

License

MIT

Description
Shared expenses for groups, couples and roommates
Readme MIT 224 KiB
Languages
JavaScript 89.9%
CSS 9.4%
HTML 0.4%
Dockerfile 0.3%