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
9.2 KiB
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
git clone <repo>
cd ClubLedger
./run.sh # creates .venv, installs deps, starts server on :8000
With auto-reload during development:
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:
- Add a default to
CONFIG - Add the field to the
AppSettingsUpdatePydantic model - Add the input to the Admin settings form in
index.html - Load and save it in
loadAdminSettings()/saveSettings()inapp.js
Auth System
POST /auth/loginvalidates credentials againststaff_accounts, creates asecrets.token_hex(32)token, stores it in the module-level_sessionsdict, and sets it as anhttpOnlycookie.- All protected endpoints use
Depends(current_user)which reads the cookie and looks up the session. - Admin-only endpoints use
Depends(admin_user)which callscurrent_userthen checksrole == "admin". - Sessions expire after 8 hours (configurable via
SESSION_TTLinmain.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:
loadConfig()— fetches/configso the login screen shows the club name.GET /auth/me— if 401, show login overlay and stop.- 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
- Add the new venue value to the
CHECKconstraint in theledger_entriesschema — requires a migration or database recreation. - Add a new endpoint (or extend
/topup//chargewith avenueparameter). - 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:
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.dbis created automatically in the working directory on first run. Add it to.gitignore. staff.jsonis also created in the working directory. Add to.gitignore.- No environment variables are required. All configuration is in
CONFIG(code) orapp_settings(database). - The app binds to
0.0.0.0:8000by default — accessible from any device on the network. Pass--host 127.0.0.1to restrict to localhost only.