diff --git a/main.py b/main.py
index 183facb..bf7e710 100644
--- a/main.py
+++ b/main.py
@@ -24,14 +24,49 @@ from pydantic import BaseModel, field_validator
CONFIG = {
"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
- "overdraft_policy": "never", # never|always|staff_override|admin_override|staff_block
- "min_topup": 100, # minor units
+ "currency_major": "pounds",
+ "currency_minor": "pence",
+ "currency_divisor": 100,
+ "overdraft_policy": "never",
+ "min_topup": 100,
"max_topup": 100_000,
"max_charge": 50_000,
+ # Business contact
+ "biz_address1": "",
+ "biz_address2": "",
+ "biz_address3": "",
+ "biz_address4": "",
+ "biz_country": "",
+ "biz_phone": "",
+ "biz_email": "",
+ "biz_website": "",
+ # Branding
+ "logo_url": "",
+ "logo_align": "left",
+ "bar_name": "Bar",
+ "cashier_name": "Cashier",
+ # Transactions
+ "txn_ref_prefix": "TXN",
+ "transfer_types": "Bank Transfer,Cash,QR",
+ # Receipt labels (localizable)
+ "lbl_receipt": "RECEIPT",
+ "lbl_topup_receipt": "TOP-UP RECEIPT",
+ "lbl_withdrawal_receipt": "WITHDRAWAL RECEIPT",
+ "lbl_staff": "STAFF",
+ "lbl_transaction": "TRANSACTION",
+ "lbl_charge_venue": "CHARGE",
+ "lbl_txn_time": "TRANSACTION TIME",
+ "lbl_amount_charged": "AMOUNT CHARGED",
+ "lbl_remaining_balance": "REMAINING BALANCE",
+ "lbl_balance_transfer": "BALANCE TRANSFER",
+ "lbl_amount_topup": "AMOUNT TOPPED-UP",
+ "lbl_amount_withdrawal": "AMOUNT WITHDRAWN",
+ "lbl_transfer_type": "TRANSFER TYPE",
+ "lbl_transfer_ref": "TRANSFER REFERENCE",
+ # Receipt footers
"receipt_footer": "",
+ "receipt_footer_charge": "",
+ "receipt_footer_cashier": "",
}
DB_PATH = "clubledger.db"
@@ -79,14 +114,16 @@ def init_db():
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','withdrawal')),
- 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','withdrawal')),
+ venue TEXT NOT NULL CHECK(venue IN ('cashier','bar')),
+ note TEXT,
+ staff_name TEXT NOT NULL,
+ transfer_type TEXT,
+ transfer_ref TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -184,6 +221,13 @@ def migrate_db():
)
conn.execute("DELETE FROM app_settings WHERE key='allow_negative_balance'")
+ # --- ledger_entries: add transfer_type and transfer_ref columns ---
+ le_cols = [r[1] for r in conn.execute("PRAGMA table_info(ledger_entries)").fetchall()]
+ if "transfer_type" not in le_cols:
+ conn.execute("ALTER TABLE ledger_entries ADD COLUMN transfer_type TEXT")
+ if "transfer_ref" not in le_cols:
+ conn.execute("ALTER TABLE ledger_entries ADD COLUMN transfer_ref TEXT")
+
def seed_admin():
with db_conn() as conn:
if conn.execute("SELECT COUNT(*) FROM staff_accounts WHERE role='admin'").fetchone()[0] == 0:
@@ -262,7 +306,6 @@ def pos_user(user: dict = Depends(current_user)):
if user["role"] not in ("pos-staff", "admin"):
raise HTTPException(403, "POS staff access required")
return user
- return user
# ---------------------------------------------------------------------------
# App
@@ -311,9 +354,11 @@ class MemberUpdate(BaseModel):
overdraft_override: Optional[int] = None # NULL=default, 1=allow, 0=block
class TopupRequest(BaseModel):
- member_id: int
- amount: int # minor units
- note: Optional[str] = None
+ member_id: int
+ amount: int
+ note: Optional[str] = None
+ transfer_type: Optional[str] = None
+ transfer_ref: Optional[str] = None
class ChargeRequest(BaseModel):
member_id: int
@@ -322,10 +367,12 @@ class ChargeRequest(BaseModel):
note: Optional[str] = None
class WithdrawalRequest(BaseModel):
- member_id: int
- amount: int # minor units
- pin: str
- note: Optional[str] = None
+ member_id: int
+ amount: int
+ pin: str
+ note: Optional[str] = None
+ transfer_type: Optional[str] = None
+ transfer_ref: Optional[str] = None
class ProductCreate(BaseModel):
name: str
@@ -364,7 +411,42 @@ class AppSettingsUpdate(BaseModel):
min_topup: Optional[int] = None
max_topup: Optional[int] = None
max_charge: Optional[int] = None
+ # Business contact
+ biz_address1: Optional[str] = None
+ biz_address2: Optional[str] = None
+ biz_address3: Optional[str] = None
+ biz_address4: Optional[str] = None
+ biz_country: Optional[str] = None
+ biz_phone: Optional[str] = None
+ biz_email: Optional[str] = None
+ biz_website: Optional[str] = None
+ # Branding
+ logo_url: Optional[str] = None
+ logo_align: Optional[str] = None
+ bar_name: Optional[str] = None
+ cashier_name: Optional[str] = None
+ # Transactions
+ txn_ref_prefix: Optional[str] = None
+ transfer_types: Optional[str] = None
+ # Receipt labels
+ lbl_receipt: Optional[str] = None
+ lbl_topup_receipt: Optional[str] = None
+ lbl_withdrawal_receipt: Optional[str] = None
+ lbl_staff: Optional[str] = None
+ lbl_transaction: Optional[str] = None
+ lbl_charge_venue: Optional[str] = None
+ lbl_txn_time: Optional[str] = None
+ lbl_amount_charged: Optional[str] = None
+ lbl_remaining_balance: Optional[str] = None
+ lbl_balance_transfer: Optional[str] = None
+ lbl_amount_topup: Optional[str] = None
+ lbl_amount_withdrawal: Optional[str] = None
+ lbl_transfer_type: Optional[str] = None
+ lbl_transfer_ref: Optional[str] = None
+ # Receipt footers
receipt_footer: Optional[str] = None
+ receipt_footer_charge: Optional[str] = None
+ receipt_footer_cashier: Optional[str] = None
# ---------------------------------------------------------------------------
# Page routes
@@ -510,8 +592,9 @@ def topup(body: TopupRequest, user: dict = Depends(cashier_user)):
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, user["name"])
+ "INSERT INTO ledger_entries (member_id,amount,type,venue,note,staff_name,transfer_type,transfer_ref) VALUES (?,?,?,?,?,?,?,?)",
+ (body.member_id, body.amount, "topup", "cashier", body.note, user["name"],
+ body.transfer_type, body.transfer_ref)
)
eid = cur.lastrowid
bal = member_balance(conn, body.member_id)
@@ -567,8 +650,9 @@ def withdrawal(body: WithdrawalRequest, user: dict = Depends(cashier_user)):
if 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, "withdrawal", "cashier", body.note, user["name"])
+ "INSERT INTO ledger_entries (member_id,amount,type,venue,note,staff_name,transfer_type,transfer_ref) VALUES (?,?,?,?,?,?,?,?)",
+ (body.member_id, body.amount, "withdrawal", "cashier", body.note, user["name"],
+ body.transfer_type, body.transfer_ref)
)
eid = cur.lastrowid
new_bal = member_balance(conn, body.member_id)
@@ -617,14 +701,65 @@ def _print_controls():
"""
-PRINT_CSS = """
+def _txn_ref(entry_id: int, s: dict) -> str:
+ prefix = (s.get("txn_ref_prefix") or "TXN").strip()
+ return f"{prefix}{entry_id:07d}"
+
+def _logo_html(s: dict) -> str:
+ url = (s.get("logo_url") or "").strip()
+ if not url:
+ return ""
+ align = s.get("logo_align", "left")
+ css_cls = f"biz-logo align-{align}" if align in ("left", "center", "right") else "biz-logo align-left"
+ return f''
+
+def _biz_header_html(s: dict) -> str:
+ parts = [_logo_html(s)]
+ parts.append(f'
{s.get("club_name") or "ClubLedger"}
')
+ addr = [s.get(f"biz_address{i}", "") for i in range(1, 5)] + [s.get("biz_country", "")]
+ addr = [l.strip() for l in addr if l and l.strip()]
+ if addr:
+ parts.append('
' + " ".join(addr) + "
")
+ contact = [x for x in [s.get("biz_phone",""), s.get("biz_email",""), s.get("biz_website","")] if x and x.strip()]
+ if contact:
+ parts.append('