ClubLedger/docs/developer-guide.md
Claude 034c882425
Add documentation: user, admin, developer, and deployment guides
docs/user-guide.md    – sign-in, Members/Cashier/Bar tabs, receipts, FAQ
docs/admin-guide.md   – everything in user guide plus Admin tab, settings
                        reference, correcting transactions, DB backup/queries
docs/developer-guide.md – stack, project layout, DB schema, settings system,
                        auth system, full API reference, extension guide
docs/deployment.md    – internal network setup for Linux/Mac/Windows:
                        prerequisites, running the server, finding the IP,
                        connecting other devices, auto-start (systemd /
                        launchd / Task Scheduler), static IP, port change,
                        security notes

https://claude.ai/code/session_01JuRTR5Xjx8emQsyerBgGU7
2026-05-30 12:10:49 +00:00

270 lines
9.2 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.

# ClubLedger Developer Guide
## Technology Stack
| Layer | Technology |
|---|---|
| Backend | Python 3.11+ · FastAPI · SQLite (via stdlib `sqlite3`) |
| Auth | bcrypt password hashing · in-memory session tokens · httpOnly cookies |
| Frontend | Vanilla HTML/CSS/JS — no build step, no framework |
| Dependencies | `fastapi`, `uvicorn[standard]`, `bcrypt` |
---
## Project Structure
```
ClubLedger/
├── main.py # Entire backend — one file
├── requirements.txt # pip dependencies
├── run.sh # Start script (creates venv, installs deps, runs server)
├── clubledger.db # SQLite database (created on first run, git-ignored)
├── staff.json # Legacy staff name list (created on first use)
├── docs/
│ ├── user-guide.md
│ ├── admin-guide.md
│ ├── developer-guide.md
│ └── deployment.md
└── static/
├── index.html # Main SPA (Members / Cashier / Bar / Admin tabs)
├── style.css # All styles
├── app.js # Main SPA logic
├── common.js # Shared helpers (also used by standalone pages)
├── cashier.html # Standalone cashier page (/cashier)
├── cashier.js
├── bar.html # Standalone bar page (/bar)
└── bar.js
```
`main.py` is intentionally a single file. It stays under ~450 lines because the domain is simple. Split it only if it grows substantially.
---
## Running Locally
```bash
git clone <repo>
cd ClubLedger
./run.sh # creates .venv, installs deps, starts server on :8000
```
With auto-reload during development:
```bash
source .venv/bin/activate
uvicorn main:app --reload --port 8000
```
The default admin account (`admin` / `admin`) is printed to the console on first run.
---
## Database Schema
### `members`
| Column | Type | Notes |
|---|---|---|
| id | INTEGER PK | |
| member_number | TEXT UNIQUE | Human-readable ID |
| name | TEXT | |
| pin_hash | TEXT | bcrypt hash |
| created_at | TEXT | `datetime('now')` UTC |
### `ledger_entries`
| Column | Type | Notes |
|---|---|---|
| id | INTEGER PK | |
| member_id | INTEGER FK | → members.id |
| amount | INTEGER | Minor currency units (e.g. pence) — always positive |
| type | TEXT | `topup` or `charge` |
| venue | TEXT | `cashier` or `bar` |
| note | TEXT | Optional free text |
| staff_name | TEXT | Name of logged-in staff at time of transaction |
| created_at | TEXT | UTC datetime |
Balance is computed on-the-fly: `SUM(topups) - SUM(charges)`. There is no stored balance column — this avoids drift and makes the audit trail self-consistent.
### `staff_accounts`
| Column | Type | Notes |
|---|---|---|
| id | INTEGER PK | |
| name | TEXT | Display name, used as `staff_name` on transactions |
| username | TEXT UNIQUE | Login credential |
| password_hash | TEXT | bcrypt hash |
| role | TEXT | `staff` or `admin` |
| active | INTEGER | 0 or 1 |
| created_at | TEXT | |
### `products`
| Column | Type | Notes |
|---|---|---|
| id | INTEGER PK | |
| name | TEXT | |
| brand | TEXT | Optional |
| price | INTEGER | Minor units |
| member_price | INTEGER | Optional discounted price |
| search_tags | TEXT | Space/comma separated search terms |
| active | INTEGER | 0 or 1 |
Products are searchable via `GET /products?q=<term>` and managed via `POST /products`. No UI exists yet — use the API directly or SQLite directly to seed products.
### `app_settings`
| Column | Type |
|---|---|
| key | TEXT PK |
| value | TEXT (JSON) |
Settings are loaded at startup into the module-level `_settings` dict and re-read after every admin save. The `CONFIG` dict in `main.py` provides fallback defaults.
---
## Settings System
```
CONFIG dict ← hard defaults in main.py
+
app_settings table ← admin overrides stored as JSON strings
_settings dict ← merged at startup and after each /admin/settings POST
format_amount() ← reads _settings at call time
/config endpoint ← returns _settings to the frontend on every page load
```
To add a new configurable value:
1. Add a default to `CONFIG`
2. Add the field to the `AppSettingsUpdate` Pydantic model
3. Add the input to the Admin settings form in `index.html`
4. Load and save it in `loadAdminSettings()` / `saveSettings()` in `app.js`
---
## Auth System
- `POST /auth/login` validates credentials against `staff_accounts`, creates a `secrets.token_hex(32)` token, stores it in the module-level `_sessions` dict, and sets it as an `httpOnly` cookie.
- All protected endpoints use `Depends(current_user)` which reads the cookie and looks up the session.
- Admin-only endpoints use `Depends(admin_user)` which calls `current_user` then checks `role == "admin"`.
- Sessions expire after 8 hours (configurable via `SESSION_TTL` in `main.py`).
- Sessions are lost on server restart (in-memory). This is intentional for simplicity; upgrade to a DB-backed session store if persistence is needed.
- Print views (`/receipt/`, `/members/*/statement`) deliberately have **no auth** — they are opened as pop-up tabs from an authenticated page.
---
## API Reference
All endpoints except `/config`, `/auth/login`, and the print views require a valid session cookie.
### Auth
| Method | Path | Body | Returns |
|---|---|---|---|
| POST | `/auth/login` | `{username, password}` | `{name, role}` + sets cookie |
| POST | `/auth/logout` | — | `{ok}` + clears cookie |
| GET | `/auth/me` | — | `{name, role}` |
### Members
| Method | Path | Notes |
|---|---|---|
| GET | `/members?q=` | List/search. Returns balance per member. |
| POST | `/members` | `{member_number, name, pin}` |
| PUT | `/members/{id}` | `{member_number?, name?, pin?}` — all optional |
| DELETE | `/members/{id}` | Blocked if balance ≠ 0 |
| GET | `/members/{id}/transactions` | `?limit=50&offset=0` |
| GET | `/members/{id}/statement` | Returns printable HTML |
### Transactions
| Method | Path | Body |
|---|---|---|
| POST | `/topup` | `{member_id, amount, note?}` — amount in minor units |
| POST | `/charge` | `{member_id, amount, pin, note?}` |
Both return `{ok, entry_id, new_balance, new_balance_display}`.
### Receipts
| Method | Path | Notes |
|---|---|---|
| GET | `/receipt/{entry_id}` | Returns printable HTML. No auth. |
### Products
| Method | Path | Notes |
|---|---|---|
| GET | `/products?q=` | Search by name/brand/tags |
| POST | `/products` | `{name, brand?, price, member_price?, search_tags?}` |
### Admin
| Method | Path | Notes |
|---|---|---|
| GET | `/admin/settings` | Admin only |
| POST | `/admin/settings` | Admin only. Partial update — only sent fields are changed. |
| GET | `/admin/staff-accounts` | Admin only |
| POST | `/admin/staff-accounts` | `{name, username, password, role}` |
| PUT | `/admin/staff-accounts/{id}` | All fields optional |
| DELETE | `/admin/staff-accounts/{id}` | Cannot delete self or last admin |
### Config (public)
| Method | Path | Notes |
|---|---|---|
| GET | `/config` | Returns live `_settings`. Called by the frontend on every page load. |
---
## Frontend Architecture
`index.html` loads `common.js` then `app.js`.
- **`common.js`** — shared utilities: `loadConfig()`, `apiFetch()`, `esc()`, `fmtAmount()`, `balanceClass()`, `setMsg()`, and the staff name dropdown helpers (used only by the standalone cashier/bar pages).
- **`app.js`** — all SPA logic: auth flow, tab switching, Members/Cashier/Bar/Admin views, modal management.
The boot sequence in `app.js`:
1. `loadConfig()` — fetches `/config` so the login screen shows the club name.
2. `GET /auth/me` — if 401, show login overlay and stop.
3. On successful login or existing session: `startApp()` which wires up all event listeners and loads the members list.
Amount conversion: `toMinor(inputId)` reads a decimal input and multiplies by `cfg.currency_divisor` before posting. All amounts sent to the API are in minor units.
---
## Extending the App
### Adding a new setting
See the Settings System section above.
### Adding a new transaction venue
1. Add the new venue value to the `CHECK` constraint in the `ledger_entries` schema — requires a migration or database recreation.
2. Add a new endpoint (or extend `/topup`/`/charge` with a `venue` parameter).
3. Add a new tab or form in `index.html` / `app.js`.
### Adding product management UI
The `/products` API exists but only the GET endpoint is exposed in the main UI (products were removed from the bar tab). A products management panel in the Admin tab would follow the same pattern as Staff Accounts.
### Persistent sessions
Replace the `_sessions` dict with a `sessions` table in SQLite:
```sql
CREATE TABLE sessions (
token TEXT PRIMARY KEY,
user_id INTEGER REFERENCES staff_accounts(id),
expires TEXT
);
```
Adjust `current_user()` to query the table instead of the dict.
---
## Environment Notes
- The database file `clubledger.db` is created automatically in the working directory on first run. Add it to `.gitignore`.
- `staff.json` is also created in the working directory. Add to `.gitignore`.
- No environment variables are required. All configuration is in `CONFIG` (code) or `app_settings` (database).
- The app binds to `0.0.0.0:8000` by default — accessible from any device on the network. Pass `--host 127.0.0.1` to restrict to localhost only.