ClubLedger/docs/developer-guide.md
Claude 6aa4c45616
docs: update all four guides to reflect new features
Covers timezone settings, business address/branding/logo upload,
transfer types, transaction reference prefix, receipt label
localisation, separate charge/cashier footers, three-role system
(POS Staff / Cashier / Admin), five-option overdraft policy,
per-member overdraft override, withdrawal transaction type,
and manage.py CLI (reset-admin, reset-db).

https://claude.ai/code/session_01JuRTR5Xjx8emQsyerBgGU7
2026-05-30 17:12:09 +00:00

343 lines
13 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`, `python-multipart` |
`python-multipart` is required for file upload support via FastAPI's `UploadFile`.
---
## Project Structure
```
ClubLedger/
├── main.py # Entire backend — one file
├── manage.py # CLI for database/admin management (reset-admin, reset-db)
├── 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
└── logo.* # Uploaded logo file (created when admin uploads a logo; git-ignored)
```
`main.py` is kept as a single file for simplicity. 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.
---
## manage.py
The `manage.py` script provides CLI commands for server-side administration. Run it from the project root with the virtual environment active.
### `python manage.py reset-admin`
Interactively resets an admin account password from the terminal. Safe to run while the app is running (SQLite WAL mode). Uses `getpass` so the password is not echoed.
### `python manage.py reset-db`
Wipes `clubledger.db`, `clubledger.db-wal`, and `clubledger.db-shm`. Requires typing `RESET` to confirm. The app must be stopped before running this command.
---
## Database Schema
### `members`
| Column | Type | Notes |
|---|---|---|
| id | INTEGER PK | |
| member_number | TEXT UNIQUE | Human-readable ID |
| name | TEXT | |
| pin_hash | TEXT | bcrypt hash |
| overdraft_override | INTEGER | NULL = use global policy; 1 = override allowed; 0 = override blocked |
| 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 — always positive |
| type | TEXT | `topup`, `charge`, or `withdrawal` |
| venue | TEXT | `cashier` or `bar` |
| note | TEXT | Optional free text |
| staff_name | TEXT | Name of logged-in staff at time of transaction |
| transfer_type | TEXT | Payment method for top-ups/withdrawals (e.g. "Cash") |
| transfer_ref | TEXT | Optional payment reference for top-ups/withdrawals |
| created_at | TEXT | UTC datetime |
Balance is computed on-the-fly: `SUM(topups) - SUM(charges) - SUM(withdrawals)`. 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 | `pos-staff`, `cashier`, or `admin` |
| active | INTEGER | 0 or 1 |
| created_at | TEXT | |
On startup, any existing rows with `role = 'staff'` are automatically migrated to `role = 'pos-staff'`.
### `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
```
### CONFIG keys
| Key | Default / Notes |
|---|---|
| `club_name` | Club display name |
| `currency_symbol` | e.g. `£` |
| `currency_major` | e.g. `GBP` |
| `currency_minor` | e.g. `pence` |
| `currency_divisor` | e.g. `100` |
| `overdraft_policy` | `"never"` / `"always"` / `"staff-override"` / `"admin-override"` / `"staff-block"` |
| `min_topup` | Minimum top-up amount (minor units) |
| `max_topup` | Maximum top-up amount (minor units) |
| `max_charge` | Maximum single charge amount (minor units) |
| `biz_address1` `biz_address4` | Business address lines |
| `biz_country` | |
| `biz_phone` | |
| `biz_email` | |
| `biz_website` | |
| `logo_url` | URL path to uploaded logo (set automatically on upload) |
| `logo_align` | |
| `logo_max_width` | Default `200` |
| `logo_max_height` | Default `80` |
| `bar_name` | Default `"Bar"` |
| `cashier_name` | Default `"Cashier"` |
| `txn_ref_prefix` | Default `"TXN"` |
| `transfer_types` | Comma-separated string (e.g. `"Bank Transfer,Cash,QR"`); returned as an array by `/config` |
| `lbl_receipt` | Receipt label keys (14 total — see source for full list) |
| `lbl_topup_receipt` | |
| `lbl_withdrawal_receipt` | |
| `lbl_staff` | |
| `lbl_transaction` | |
| `lbl_charge_venue` | |
| `lbl_txn_time` | |
| `lbl_amount_charged` | |
| `lbl_remaining_balance` | |
| `lbl_balance_transfer` | |
| `lbl_amount_topup` | |
| `lbl_amount_withdrawal` | |
| `lbl_transfer_type` | |
| `lbl_transfer_ref` | |
| `receipt_footer` | Footer text for all receipts |
| `receipt_footer_charge` | Override footer for charge receipts |
| `receipt_footer_cashier` | Override footer for cashier receipts |
| `timezone` | IANA timezone name; defaults to server local timezone via `_server_timezone()` |
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 a `Depends()` guard appropriate to the required role:
- `Depends(pos_user)` — allows `pos-staff` and `admin`. Used by bar endpoints.
- `Depends(cashier_user)` — allows `cashier` and `admin`. Used by cashier endpoints.
- `Depends(admin_user)``admin` only.
- 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`) have no auth — they are opened as pop-up tabs from an authenticated session.
---
## 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?, overdraft_override?}` — all optional |
| DELETE | `/members/{id}` | Blocked if balance ≠ 0 |
| GET | `/members/{id}/transactions` | `?limit=50&offset=0` |
| GET | `/members/{id}/statement` | Returns printable HTML. No auth. |
### Transactions
| Method | Path | Body |
|---|---|---|
| POST | `/topup` | `{member_id, amount, transfer_type?, transfer_ref?, note?}` — amount in minor units |
| POST | `/charge` | `{member_id, amount, pin, note?}` |
| POST | `/withdrawal` | `{member_id, amount, pin, transfer_type?, transfer_ref?, note?}` |
All 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. |
| POST | `/admin/logo` | Admin only. Multipart file upload. Saves image to `static/logo.<ext>`, updates `logo_url` setting, returns `{url}`. Accepts PNG, JPG, GIF, WebP, SVG. |
| 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. `transfer_types` is returned as an array. |
---
## 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 existing transaction endpoints 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`.
- `static/logo.*` is created when an admin uploads a logo via the Admin panel. 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.