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
13 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, 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
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.
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:
- 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 a
Depends()guard appropriate to the required role:Depends(pos_user)— allowspos-staffandadmin. Used by bar endpoints.Depends(cashier_user)— allowscashierandadmin. Used by cashier endpoints.Depends(admin_user)—adminonly.
- 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) 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:
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 existing transaction endpoints with a
venueparameter). - 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.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) 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.