Files
whowhat/README.md
Kacper 350ccdbaf7
All checks were successful
Build and Push Docker Images / build-and-push (push) Successful in 1m33s
Rename docker-compose.yaml to compose.yaml and pull prebuilt images
Use the images published by the new Gitea Actions workflow instead
of building locally, so deploys just pull the latest tag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 20:39:48 +02:00

167 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/
├── 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.
```bash
cp .env.example .env
# fill in .env (see below) — no need to edit compose.yaml
sudo docker compose pull
sudo docker compose up -d
```
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``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):
```bash
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`](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+.
```bash
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](LICENSE)