diff --git a/main.py b/main.py
index da9e685..835087e 100644
--- a/main.py
+++ b/main.py
@@ -1,40 +1,48 @@
"""
-ClubLedger - Store Credit Web App
-Admin configuration: edit the CONFIG dict below.
+ClubLedger – Store Credit Web App
+Hard defaults live in CONFIG below; everything is overridable via the Admin UI.
"""
import sqlite3
import json
import os
+import secrets
from contextlib import contextmanager, asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
-
-import bcrypt
-from fastapi import FastAPI, HTTPException
-from fastapi.responses import HTMLResponse, RedirectResponse
-from fastapi.staticfiles import StaticFiles
-from pydantic import BaseModel, field_validator
from typing import Optional
+import bcrypt
+from fastapi import FastAPI, HTTPException, Cookie, Depends, Response
+from fastapi.responses import HTMLResponse
+from fastapi.staticfiles import StaticFiles
+from pydantic import BaseModel, field_validator
+
# ---------------------------------------------------------------------------
-# Admin configuration
+# Hard defaults (overridden by app_settings table via Admin area)
# ---------------------------------------------------------------------------
CONFIG = {
- "club_name": "ClubLedger",
- "currency_symbol": "£",
- "currency_unit": "pence", # smallest unit stored as integer
- "currency_divisor": 100, # divide stored int by this for display
- "db_path": "clubledger.db",
- "allow_negative_balance": False, # set True to allow overdraft at bar
- "min_topup_amount": 100, # minimum top-up in pence (£1.00)
- "max_topup_amount": 100_000, # maximum top-up in pence (£1000.00)
- "max_charge_amount": 50_000, # maximum single charge in pence
+ "club_name": "ClubLedger",
+ "currency_symbol": "£",
+ "currency_major": "pounds", # label for major unit (what users enter)
+ "currency_minor": "pence", # label for stored minor unit
+ "currency_divisor": 100, # minor units per major unit
+ "allow_negative_balance": False,
+ "min_topup": 100, # minor units
+ "max_topup": 100_000,
+ "max_charge": 50_000,
+ "receipt_footer": "",
}
-DB_PATH = CONFIG["db_path"]
-static_dir = Path(__file__).parent / "static"
+DB_PATH = "clubledger.db"
STAFF_FILE = Path(__file__).parent / "staff.json"
+static_dir = Path(__file__).parent / "static"
+
+# In-memory sessions: token → {user_id, name, role, expires}
+_sessions: dict = {}
+
+# Cached settings merged from CONFIG + DB app_settings
+_settings: dict = {}
# ---------------------------------------------------------------------------
# Database
@@ -63,38 +71,69 @@ def init_db():
with db_conn() as conn:
conn.executescript("""
CREATE TABLE IF NOT EXISTS members (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
member_number TEXT UNIQUE NOT NULL,
- name TEXT NOT NULL,
- pin_hash TEXT NOT NULL,
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ name TEXT NOT NULL,
+ pin_hash TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-
CREATE TABLE IF NOT EXISTS ledger_entries (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- member_id INTEGER NOT NULL REFERENCES members(id),
- amount INTEGER NOT NULL,
- type TEXT NOT NULL CHECK(type IN ('topup','charge')),
- venue TEXT NOT NULL CHECK(venue IN ('cashier','bar')),
- note TEXT,
- staff_name TEXT NOT NULL,
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ member_id INTEGER NOT NULL REFERENCES members(id),
+ amount INTEGER NOT NULL,
+ type TEXT NOT NULL CHECK(type IN ('topup','charge')),
+ venue TEXT NOT NULL CHECK(venue IN ('cashier','bar')),
+ note TEXT,
+ staff_name TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-
CREATE TABLE IF NOT EXISTS products (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- name TEXT NOT NULL,
- brand TEXT,
- price INTEGER NOT NULL,
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ brand TEXT,
+ price INTEGER NOT NULL,
member_price INTEGER,
- search_tags TEXT,
- active INTEGER NOT NULL DEFAULT 1
+ search_tags TEXT,
+ active INTEGER NOT NULL DEFAULT 1
+ );
+ CREATE TABLE IF NOT EXISTS staff_accounts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ username TEXT UNIQUE NOT NULL,
+ password_hash TEXT NOT NULL,
+ role TEXT NOT NULL DEFAULT 'staff'
+ CHECK(role IN ('staff','admin')),
+ active INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+ CREATE TABLE IF NOT EXISTS app_settings (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
);
-
CREATE INDEX IF NOT EXISTS idx_ledger_member
ON ledger_entries(member_id);
""")
+def seed_admin():
+ with db_conn() as conn:
+ if conn.execute("SELECT COUNT(*) FROM staff_accounts WHERE role='admin'").fetchone()[0] == 0:
+ pw = bcrypt.hashpw(b"admin", bcrypt.gensalt()).decode()
+ conn.execute(
+ "INSERT INTO staff_accounts (name, username, password_hash, role) VALUES (?,?,?,?)",
+ ("Administrator", "admin", pw, "admin")
+ )
+ print("=" * 60)
+ print(" Default admin created → username: admin password: admin")
+ print(" Change this immediately in the Admin → Staff Accounts area.")
+ print("=" * 60)
+
+def refresh_settings():
+ global _settings
+ with db_conn() as conn:
+ rows = conn.execute("SELECT key, value FROM app_settings").fetchall()
+ overrides = {r["key"]: json.loads(r["value"]) for r in rows}
+ _settings = {**CONFIG, **overrides}
+
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -107,16 +146,14 @@ def verify_pin(pin: str, hashed: str) -> bool:
def member_balance(conn, member_id: int) -> int:
row = conn.execute("""
- SELECT COALESCE(
- SUM(CASE WHEN type='topup' THEN amount ELSE -amount END), 0
- ) AS balance
+ SELECT COALESCE(SUM(CASE WHEN type='topup' THEN amount ELSE -amount END),0) AS b
FROM ledger_entries WHERE member_id=?
""", (member_id,)).fetchone()
- return row["balance"] if row else 0
+ return row["b"] if row else 0
def format_amount(pence: int) -> str:
- sym = CONFIG["currency_symbol"]
- div = CONFIG["currency_divisor"]
+ sym = _settings.get("currency_symbol") or CONFIG["currency_symbol"]
+ div = _settings.get("currency_divisor") or CONFIG["currency_divisor"]
return f"{sym}{pence / div:.2f}"
def load_staff() -> list:
@@ -128,16 +165,37 @@ def save_staff(names: list):
STAFF_FILE.write_text(json.dumps({"staff": sorted(set(names))}, indent=2))
# ---------------------------------------------------------------------------
-# FastAPI app
+# Auth helpers
+# ---------------------------------------------------------------------------
+
+SESSION_TTL = 8 * 3600 # seconds
+
+def current_user(session: Optional[str] = Cookie(default=None)):
+ if not session or session not in _sessions:
+ raise HTTPException(401, "Not authenticated")
+ s = _sessions[session]
+ if datetime.now(timezone.utc).timestamp() > s["expires"]:
+ del _sessions[session]
+ raise HTTPException(401, "Session expired")
+ return s
+
+def admin_user(user: dict = Depends(current_user)):
+ if user["role"] != "admin":
+ raise HTTPException(403, "Admin access required")
+ return user
+
+# ---------------------------------------------------------------------------
+# App
# ---------------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app):
init_db()
+ seed_admin()
+ refresh_settings()
yield
-app = FastAPI(title=CONFIG["club_name"], lifespan=lifespan)
-
+app = FastAPI(title="ClubLedger", lifespan=lifespan)
static_dir.mkdir(exist_ok=True)
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
@@ -165,33 +223,60 @@ class MemberCreate(BaseModel):
raise ValueError("member_number cannot be empty")
return v
+class MemberUpdate(BaseModel):
+ member_number: Optional[str] = None
+ name: Optional[str] = None
+ pin: Optional[str] = None
+
class TopupRequest(BaseModel):
member_id: int
- amount: int
- staff_name: str
- note: Optional[str] = None
+ amount: int # minor units
+ note: Optional[str] = None
class ChargeRequest(BaseModel):
member_id: int
- amount: int
- pin: str
- staff_name: str
- note: Optional[str] = None
+ amount: int
+ pin: str
+ note: Optional[str] = None
class ProductCreate(BaseModel):
- name: str
- brand: Optional[str] = None
- price: int
+ name: str
+ brand: Optional[str] = None
+ price: int
member_price: Optional[int] = None
search_tags: Optional[str] = None
class StaffAdd(BaseModel):
name: str
-class MemberUpdate(BaseModel):
- member_number: Optional[str] = None
- name: Optional[str] = None
- pin: Optional[str] = None
+class LoginRequest(BaseModel):
+ username: str
+ password: str
+
+class StaffAccountCreate(BaseModel):
+ name: str
+ username: str
+ password: str
+ role: str = "staff"
+
+class StaffAccountUpdate(BaseModel):
+ name: Optional[str] = None
+ username: Optional[str] = None
+ password: Optional[str] = None
+ role: Optional[str] = None
+ active: Optional[bool] = None
+
+class AppSettingsUpdate(BaseModel):
+ club_name: Optional[str] = None
+ currency_symbol: Optional[str] = None
+ currency_major: Optional[str] = None
+ currency_minor: Optional[str] = None
+ currency_divisor: Optional[int] = None
+ allow_negative_balance: Optional[bool] = None
+ min_topup: Optional[int] = None
+ max_topup: Optional[int] = None
+ max_charge: Optional[int] = None
+ receipt_footer: Optional[str] = None
# ---------------------------------------------------------------------------
# Page routes
@@ -210,439 +295,468 @@ async def bar_page():
return (static_dir / "bar.html").read_text()
# ---------------------------------------------------------------------------
-# API endpoints – members
+# Auth endpoints
+# ---------------------------------------------------------------------------
+
+@app.post("/auth/login")
+def login(body: LoginRequest, response: Response):
+ with db_conn() as conn:
+ row = conn.execute(
+ "SELECT * FROM staff_accounts WHERE username=? AND active=1",
+ (body.username.strip(),)
+ ).fetchone()
+ if not row or not bcrypt.checkpw(body.password.encode(), row["password_hash"].encode()):
+ raise HTTPException(401, "Invalid username or password")
+ token = secrets.token_hex(32)
+ _sessions[token] = {
+ "user_id": row["id"],
+ "name": row["name"],
+ "role": row["role"],
+ "expires": datetime.now(timezone.utc).timestamp() + SESSION_TTL,
+ }
+ response.set_cookie("session", token, httponly=True, max_age=SESSION_TTL, samesite="strict")
+ return {"name": row["name"], "role": row["role"]}
+
+@app.post("/auth/logout")
+def logout(response: Response, session: Optional[str] = Cookie(default=None)):
+ if session and session in _sessions:
+ del _sessions[session]
+ response.delete_cookie("session")
+ return {"ok": True}
+
+@app.get("/auth/me")
+def auth_me(user: dict = Depends(current_user)):
+ return {"name": user["name"], "role": user["role"]}
+
+# ---------------------------------------------------------------------------
+# Member endpoints
# ---------------------------------------------------------------------------
@app.post("/members")
-def create_member(body: MemberCreate):
+def create_member(body: MemberCreate, user: dict = Depends(current_user)):
with db_conn() as conn:
- existing = conn.execute(
- "SELECT id FROM members WHERE member_number=?",
- (body.member_number.strip(),)
- ).fetchone()
- if existing:
+ if conn.execute("SELECT id FROM members WHERE member_number=?",
+ (body.member_number.strip(),)).fetchone():
raise HTTPException(400, "Member number already exists")
- pin_hash = hash_pin(body.pin)
cur = conn.execute(
"INSERT INTO members (member_number, name, pin_hash) VALUES (?,?,?)",
- (body.member_number.strip(), body.name.strip(), pin_hash)
+ (body.member_number.strip(), body.name.strip(), hash_pin(body.pin))
)
- member_id = cur.lastrowid
+ mid = cur.lastrowid
with db_conn() as conn:
- row = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone()
- return {
- "id": row["id"],
- "member_number": row["member_number"],
- "name": row["name"],
- "created_at": row["created_at"],
- }
+ r = conn.execute("SELECT * FROM members WHERE id=?", (mid,)).fetchone()
+ return {"id": r["id"], "member_number": r["member_number"],
+ "name": r["name"], "created_at": r["created_at"]}
@app.put("/members/{member_id}")
-def update_member(member_id: int, body: MemberUpdate):
+def update_member(member_id: int, body: MemberUpdate, user: dict = Depends(current_user)):
with db_conn() as conn:
- member = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone()
- if not member:
+ if not conn.execute("SELECT id FROM members WHERE id=?", (member_id,)).fetchone():
raise HTTPException(404, "Member not found")
updates = {}
if body.name is not None:
- name = body.name.strip()
- if not name:
- raise HTTPException(400, "Name cannot be empty")
- updates["name"] = name
+ n = body.name.strip()
+ if not n: raise HTTPException(400, "Name cannot be empty")
+ updates["name"] = n
if body.member_number is not None:
mn = body.member_number.strip()
- if not mn:
- raise HTTPException(400, "Member number cannot be empty")
- clash = conn.execute(
- "SELECT id FROM members WHERE member_number=? AND id!=?", (mn, member_id)
- ).fetchone()
- if clash:
+ if not mn: raise HTTPException(400, "Member number cannot be empty")
+ if conn.execute("SELECT id FROM members WHERE member_number=? AND id!=?",
+ (mn, member_id)).fetchone():
raise HTTPException(400, "Member number already in use")
updates["member_number"] = mn
if body.pin is not None:
- if len(body.pin) < 4:
- raise HTTPException(400, "PIN must be at least 4 characters")
+ if len(body.pin) < 4: raise HTTPException(400, "PIN must be at least 4 characters")
updates["pin_hash"] = hash_pin(body.pin)
if updates:
- set_clause = ", ".join(f"{k}=?" for k in updates)
conn.execute(
- f"UPDATE members SET {set_clause} WHERE id=?",
+ f"UPDATE members SET {', '.join(f'{k}=?' for k in updates)} WHERE id=?",
list(updates.values()) + [member_id]
)
- row = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone()
- return {"id": row["id"], "member_number": row["member_number"], "name": row["name"]}
+ r = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone()
+ return {"id": r["id"], "member_number": r["member_number"], "name": r["name"]}
@app.delete("/members/{member_id}")
-def delete_member(member_id: int):
+def delete_member(member_id: int, user: dict = Depends(current_user)):
with db_conn() as conn:
- member = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone()
- if not member:
+ if not conn.execute("SELECT id FROM members WHERE id=?", (member_id,)).fetchone():
raise HTTPException(404, "Member not found")
- balance = member_balance(conn, member_id)
- if balance != 0:
- raise HTTPException(400, f"Cannot delete: balance is {format_amount(balance)}")
+ bal = member_balance(conn, member_id)
+ if bal != 0:
+ raise HTTPException(400, f"Cannot delete: balance is {format_amount(bal)}")
conn.execute("DELETE FROM ledger_entries WHERE member_id=?", (member_id,))
conn.execute("DELETE FROM members WHERE id=?", (member_id,))
return {"ok": True}
@app.get("/members")
-def list_members(q: Optional[str] = None):
+def list_members(q: Optional[str] = None, user: dict = Depends(current_user)):
with db_conn() as conn:
if q:
- pattern = f"%{q}%"
+ pat = f"%{q}%"
rows = conn.execute(
"SELECT * FROM members WHERE name LIKE ? OR member_number LIKE ? ORDER BY name",
- (pattern, pattern)
+ (pat, pat)
).fetchall()
else:
rows = conn.execute("SELECT * FROM members ORDER BY name").fetchall()
result = []
for r in rows:
- balance = member_balance(conn, r["id"])
+ bal = member_balance(conn, r["id"])
result.append({
- "id": r["id"],
- "member_number": r["member_number"],
- "name": r["name"],
- "balance": balance,
- "balance_display": format_amount(balance),
- "created_at": r["created_at"],
+ "id": r["id"], "member_number": r["member_number"], "name": r["name"],
+ "balance": bal, "balance_display": format_amount(bal), "created_at": r["created_at"],
})
return result
@app.post("/topup")
-def topup(body: TopupRequest):
- if body.amount < CONFIG["min_topup_amount"]:
- raise HTTPException(400, f"Minimum top-up is {format_amount(CONFIG['min_topup_amount'])}")
- if body.amount > CONFIG["max_topup_amount"]:
- raise HTTPException(400, f"Maximum top-up is {format_amount(CONFIG['max_topup_amount'])}")
+def topup(body: TopupRequest, user: dict = Depends(current_user)):
+ s = _settings
+ if body.amount < s["min_topup"]:
+ raise HTTPException(400, f"Minimum top-up is {format_amount(s['min_topup'])}")
+ if body.amount > s["max_topup"]:
+ raise HTTPException(400, f"Maximum top-up is {format_amount(s['max_topup'])}")
with db_conn() as conn:
- member = conn.execute("SELECT * FROM members WHERE id=?", (body.member_id,)).fetchone()
- if not member:
+ if not conn.execute("SELECT id FROM members WHERE id=?", (body.member_id,)).fetchone():
raise HTTPException(404, "Member not found")
cur = conn.execute(
- "INSERT INTO ledger_entries (member_id, amount, type, venue, note, staff_name) VALUES (?,?,?,?,?,?)",
- (body.member_id, body.amount, "topup", "cashier", body.note, body.staff_name)
+ "INSERT INTO ledger_entries (member_id,amount,type,venue,note,staff_name) VALUES (?,?,?,?,?,?)",
+ (body.member_id, body.amount, "topup", "cashier", body.note, user["name"])
)
- entry_id = cur.lastrowid
- balance = member_balance(conn, body.member_id)
- return {
- "ok": True,
- "entry_id": entry_id,
- "new_balance": balance,
- "new_balance_display": format_amount(balance),
- }
+ eid = cur.lastrowid
+ bal = member_balance(conn, body.member_id)
+ return {"ok": True, "entry_id": eid, "new_balance": bal, "new_balance_display": format_amount(bal)}
@app.post("/charge")
-def charge(body: ChargeRequest):
+def charge(body: ChargeRequest, user: dict = Depends(current_user)):
+ s = _settings
if body.amount <= 0:
raise HTTPException(400, "Amount must be positive")
- if body.amount > CONFIG["max_charge_amount"]:
- raise HTTPException(400, f"Maximum single charge is {format_amount(CONFIG['max_charge_amount'])}")
+ if body.amount > s["max_charge"]:
+ raise HTTPException(400, f"Maximum single charge is {format_amount(s['max_charge'])}")
with db_conn() as conn:
member = conn.execute("SELECT * FROM members WHERE id=?", (body.member_id,)).fetchone()
if not member:
raise HTTPException(404, "Member not found")
if not verify_pin(body.pin, member["pin_hash"]):
raise HTTPException(403, "Incorrect PIN")
- balance = member_balance(conn, body.member_id)
- if not CONFIG["allow_negative_balance"] and balance < body.amount:
- raise HTTPException(400, f"Insufficient balance ({format_amount(balance)})")
+ bal = member_balance(conn, body.member_id)
+ if not s["allow_negative_balance"] and bal < body.amount:
+ raise HTTPException(400, f"Insufficient balance ({format_amount(bal)})")
cur = conn.execute(
- "INSERT INTO ledger_entries (member_id, amount, type, venue, note, staff_name) VALUES (?,?,?,?,?,?)",
- (body.member_id, body.amount, "charge", "bar", body.note, body.staff_name)
+ "INSERT INTO ledger_entries (member_id,amount,type,venue,note,staff_name) VALUES (?,?,?,?,?,?)",
+ (body.member_id, body.amount, "charge", "bar", body.note, user["name"])
)
- entry_id = cur.lastrowid
- new_balance = member_balance(conn, body.member_id)
- return {
- "ok": True,
- "entry_id": entry_id,
- "new_balance": new_balance,
- "new_balance_display": format_amount(new_balance),
- }
+ eid = cur.lastrowid
+ new_bal = member_balance(conn, body.member_id)
+ return {"ok": True, "entry_id": eid, "new_balance": new_bal, "new_balance_display": format_amount(new_bal)}
@app.get("/members/{member_id}/transactions")
-def transactions(member_id: int, limit: int = 50, offset: int = 0):
- with db_conn() as conn:
- member = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone()
- if not member:
- raise HTTPException(404, "Member not found")
- rows = conn.execute("""
- SELECT * FROM ledger_entries
- WHERE member_id=?
- ORDER BY created_at DESC
- LIMIT ? OFFSET ?
- """, (member_id, limit, offset)).fetchall()
- balance = member_balance(conn, member_id)
- return {
- "member": {
- "id": member["id"],
- "member_number": member["member_number"],
- "name": member["name"],
- },
- "balance": balance,
- "balance_display": format_amount(balance),
- "transactions": [
- {
- "id": r["id"],
- "amount": r["amount"],
- "amount_display": format_amount(r["amount"]),
- "type": r["type"],
- "venue": r["venue"],
- "note": r["note"],
- "staff_name": r["staff_name"],
- "created_at": r["created_at"],
- }
- for r in rows
- ],
- }
-
-# ---------------------------------------------------------------------------
-# Print views
-# ---------------------------------------------------------------------------
-
-def _print_size_script() -> str:
- return """
-"""
-
-def _print_controls(extra_class: str = "") -> str:
- return f"""
"""
-
-PRINT_CSS = """
- body { font-family: Arial, sans-serif; font-size: 11px; color: #111; margin: 24px; }
- h1 { font-size: 18px; margin-bottom: 2px; }
- h2 { font-size: 13px; font-weight: normal; color: #555; margin-top: 0; margin-bottom: 16px; }
- .controls { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
- .size-label { font-size: 12px; color: #555; }
- .controls label { font-size: 12px; cursor: pointer; }
- .print-btn { padding: 7px 18px; font-size: 13px; cursor: pointer; margin-left: auto; }
- @media print { .no-print { display: none; } }
-"""
-
-@app.get("/members/{member_id}/statement", response_class=HTMLResponse)
-def statement(member_id: int):
+def transactions(member_id: int, limit: int = 50, offset: int = 0,
+ user: dict = Depends(current_user)):
with db_conn() as conn:
member = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone()
if not member:
raise HTTPException(404, "Member not found")
rows = conn.execute("""
SELECT * FROM ledger_entries WHERE member_id=?
- ORDER BY created_at ASC
- """, (member_id,)).fetchall()
- balance = member_balance(conn, member_id)
+ ORDER BY created_at DESC LIMIT ? OFFSET ?
+ """, (member_id, limit, offset)).fetchall()
+ bal = member_balance(conn, member_id)
+ return {
+ "member": {"id": member["id"], "member_number": member["member_number"], "name": member["name"]},
+ "balance": bal, "balance_display": format_amount(bal),
+ "transactions": [
+ {"id": r["id"], "amount": r["amount"], "amount_display": format_amount(r["amount"]),
+ "type": r["type"], "venue": r["venue"], "note": r["note"],
+ "staff_name": r["staff_name"], "created_at": r["created_at"]}
+ for r in rows
+ ],
+ }
- sym = CONFIG["currency_symbol"]
- div = CONFIG["currency_divisor"]
- club = CONFIG["club_name"]
+# ---------------------------------------------------------------------------
+# Print views (no auth – opened as new-tab popups)
+# ---------------------------------------------------------------------------
- def fmt(p):
- return f"{sym}{p/div:.2f}"
+def _print_size_script():
+ return """"""
- rows_html = ""
- running = 0
+def _print_controls():
+ return """
+ Paper:
+
+
+
"""
+
+PRINT_CSS = """
+ body{font-family:Arial,sans-serif;font-size:11px;color:#111;margin:24px;}
+ h1{font-size:18px;margin-bottom:2px;} h2{font-size:13px;font-weight:normal;color:#555;margin:0 0 16px;}
+ .controls{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
+ .size-label{font-size:12px;color:#555;} .controls label{font-size:12px;cursor:pointer;}
+ .print-btn{padding:7px 18px;font-size:13px;cursor:pointer;margin-left:auto;}
+ .footer{margin-top:16px;font-size:10px;color:#888;text-align:center;white-space:pre-wrap;}
+ @media print{.no-print{display:none;}}
+"""
+
+@app.get("/members/{member_id}/statement", response_class=HTMLResponse)
+def statement(member_id: int):
+ s = _settings
+ with db_conn() as conn:
+ member = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone()
+ if not member: raise HTTPException(404, "Member not found")
+ rows = conn.execute(
+ "SELECT * FROM ledger_entries WHERE member_id=? ORDER BY created_at ASC",
+ (member_id,)
+ ).fetchall()
+ bal = member_balance(conn, member_id)
+
+ sym, div = s.get("currency_symbol","£"), s.get("currency_divisor",100)
+ club, footer = s.get("club_name","ClubLedger"), s.get("receipt_footer","")
+
+ def fmt(p): return f"{sym}{p/div:.2f}"
+
+ rows_html, running = "", 0
for r in rows:
if r["type"] == "topup":
- running += r["amount"]
- dr, cr = "", fmt(r["amount"])
+ running += r["amount"]; dr, cr = "", fmt(r["amount"])
else:
- running -= r["amount"]
- dr, cr = fmt(r["amount"]), ""
- rows_html += f"""
-
- | {r['created_at'][:16]} |
- {r['type']} |
- {r['venue']} |
- {r['note'] or ''} |
- {r['staff_name']} |
- {dr} |
- {cr} |
- {fmt(running)} |
-
"""
+ running -= r["amount"]; dr, cr = fmt(r["amount"]), ""
+ rows_html += (f"| {r['created_at'][:16]} | {r['type']} | "
+ f"{r['venue']} | {r['note'] or ''} | "
+ f"{r['staff_name']} | {dr} | "
+ f"{cr} | {fmt(running)} |
")
- return f"""
-
-
-
-Statement – {member['name']}
-
-
-
+ table{{width:100%;border-collapse:collapse;margin-top:16px;}}
+ th{{background:#222;color:#fff;padding:5px 8px;text-align:left;}}
+ td{{padding:4px 8px;border-bottom:1px solid #e0e0e0;}}
+ .num{{text-align:right;font-variant-numeric:tabular-nums;}}
+ .red{{color:#c00;}} .grn{{color:#080;}} .cap{{text-transform:capitalize;}}
+ .balance-box{{margin-top:12px;text-align:right;font-size:14px;}}
+ .balance-box span{{font-weight:bold;font-size:18px;}}
+
{_print_controls()}
{club} – Account Statement
-Member: {member['name']} | #{member['member_number']} | Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')} UTC
-
-
-
- | Date/Time | Type | Venue | Note |
- Staff | Charge | Top-up | Balance |
-
-
- {rows_html}
-
-Current Balance: {fmt(balance)}
-{_print_size_script()}
-
-"""
+Member: {member['name']} | #{member['member_number']} |
+Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')} UTC
+
+ | Date/Time | Type | Venue | Note |
+ Staff | Charge | Top-up | Balance |
+
{rows_html}
+Current Balance: {fmt(bal)}
+{('') if footer else ''}
+{_print_size_script()}