mirror of
https://github.com/kbenestad/ClubLedger.git
synced 2026-06-18 09:44:33 +00:00
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
343 lines
13 KiB
Markdown
343 lines
13 KiB
Markdown
# 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.
|