Add business branding, transaction references, and redesigned receipts

- CONFIG: add business address/contact, logo URL+alignment, venue names,
  transaction ref prefix, transfer types list, and 14 localizable receipt
  labels plus per-receipt-type footer fields
- DB: add transfer_type and transfer_ref columns to ledger_entries
  (init_db + migrate_db); fix duplicate return in pos_user
- Pydantic: extend AppSettingsUpdate with all new settings;
  add transfer_type/transfer_ref to TopupRequest and WithdrawalRequest
- /topup and /withdrawal: persist transfer_type and transfer_ref
- /config: return transfer_types as parsed array for frontend dropdowns
- New helpers: _txn_ref(), _logo_html(), _biz_header_html()
- New RECEIPT_CSS with 2-column grid layout; receipt() fully redesigned
  with business header, auto-generated TXN reference, separate charge vs
  top-up/withdrawal layouts; statement() adds Reference column and
  transfer-detail sub-rows
- index.html: Transfer Type + Transfer Reference fields on cashier/
  withdrawal panels; admin settings expanded into organized sections
  (General, Business Address, Branding, Transactions, Receipt Labels,
  Receipt Footers)
- app.js: populateTransferTypes() called on startup and after settings
  save; doTopup/doWithdrawal send transfer fields; clearCashierSelection
  clears new fields; loadAdminSettings/saveSettings handle all new fields

https://claude.ai/code/session_01JuRTR5Xjx8emQsyerBgGU7
This commit is contained in:
Claude 2026-05-30 15:44:27 +00:00
parent 21b6791f4c
commit 8616ff1d49
No known key found for this signature in database
3 changed files with 489 additions and 115 deletions

314
main.py
View file

@ -24,14 +24,49 @@ from pydantic import BaseModel, field_validator
CONFIG = { CONFIG = {
"club_name": "ClubLedger", "club_name": "ClubLedger",
"currency_symbol": "£", "currency_symbol": "£",
"currency_major": "pounds", # label for major unit (what users enter) "currency_major": "pounds",
"currency_minor": "pence", # label for stored minor unit "currency_minor": "pence",
"currency_divisor": 100, # minor units per major unit "currency_divisor": 100,
"overdraft_policy": "never", # never|always|staff_override|admin_override|staff_block "overdraft_policy": "never",
"min_topup": 100, # minor units "min_topup": 100,
"max_topup": 100_000, "max_topup": 100_000,
"max_charge": 50_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": "",
"receipt_footer_charge": "",
"receipt_footer_cashier": "",
} }
DB_PATH = "clubledger.db" DB_PATH = "clubledger.db"
@ -86,6 +121,8 @@ def init_db():
venue TEXT NOT NULL CHECK(venue IN ('cashier','bar')), venue TEXT NOT NULL CHECK(venue IN ('cashier','bar')),
note TEXT, note TEXT,
staff_name TEXT NOT NULL, staff_name TEXT NOT NULL,
transfer_type TEXT,
transfer_ref TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
CREATE TABLE IF NOT EXISTS products ( CREATE TABLE IF NOT EXISTS products (
@ -184,6 +221,13 @@ def migrate_db():
) )
conn.execute("DELETE FROM app_settings WHERE key='allow_negative_balance'") 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(): def seed_admin():
with db_conn() as conn: with db_conn() as conn:
if conn.execute("SELECT COUNT(*) FROM staff_accounts WHERE role='admin'").fetchone()[0] == 0: 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"): if user["role"] not in ("pos-staff", "admin"):
raise HTTPException(403, "POS staff access required") raise HTTPException(403, "POS staff access required")
return user return user
return user
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# App # App
@ -312,8 +355,10 @@ class MemberUpdate(BaseModel):
class TopupRequest(BaseModel): class TopupRequest(BaseModel):
member_id: int member_id: int
amount: int # minor units amount: int
note: Optional[str] = None note: Optional[str] = None
transfer_type: Optional[str] = None
transfer_ref: Optional[str] = None
class ChargeRequest(BaseModel): class ChargeRequest(BaseModel):
member_id: int member_id: int
@ -323,9 +368,11 @@ class ChargeRequest(BaseModel):
class WithdrawalRequest(BaseModel): class WithdrawalRequest(BaseModel):
member_id: int member_id: int
amount: int # minor units amount: int
pin: str pin: str
note: Optional[str] = None note: Optional[str] = None
transfer_type: Optional[str] = None
transfer_ref: Optional[str] = None
class ProductCreate(BaseModel): class ProductCreate(BaseModel):
name: str name: str
@ -364,7 +411,42 @@ class AppSettingsUpdate(BaseModel):
min_topup: Optional[int] = None min_topup: Optional[int] = None
max_topup: Optional[int] = None max_topup: Optional[int] = None
max_charge: 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: Optional[str] = None
receipt_footer_charge: Optional[str] = None
receipt_footer_cashier: Optional[str] = None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Page routes # 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(): if not conn.execute("SELECT id FROM members WHERE id=?", (body.member_id,)).fetchone():
raise HTTPException(404, "Member not found") raise HTTPException(404, "Member not found")
cur = conn.execute( cur = conn.execute(
"INSERT INTO ledger_entries (member_id,amount,type,venue,note,staff_name) VALUES (?,?,?,?,?,?)", "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.member_id, body.amount, "topup", "cashier", body.note, user["name"],
body.transfer_type, body.transfer_ref)
) )
eid = cur.lastrowid eid = cur.lastrowid
bal = member_balance(conn, body.member_id) bal = member_balance(conn, body.member_id)
@ -567,8 +650,9 @@ def withdrawal(body: WithdrawalRequest, user: dict = Depends(cashier_user)):
if bal < body.amount: if bal < body.amount:
raise HTTPException(400, f"Insufficient balance ({format_amount(bal)})") raise HTTPException(400, f"Insufficient balance ({format_amount(bal)})")
cur = conn.execute( cur = conn.execute(
"INSERT INTO ledger_entries (member_id,amount,type,venue,note,staff_name) VALUES (?,?,?,?,?,?)", "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.member_id, body.amount, "withdrawal", "cashier", body.note, user["name"],
body.transfer_type, body.transfer_ref)
) )
eid = cur.lastrowid eid = cur.lastrowid
new_bal = member_balance(conn, body.member_id) new_bal = member_balance(conn, body.member_id)
@ -617,14 +701,65 @@ def _print_controls():
<label><input type="radio" name="ps" value="A5" onchange="setSize('A5')"> A5</label> <label><input type="radio" name="ps" value="A5" onchange="setSize('A5')"> A5</label>
</div>""" </div>"""
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'<img src="{url}" class="{css_cls}" alt="logo">'
def _biz_header_html(s: dict) -> str:
parts = [_logo_html(s)]
parts.append(f'<div class="biz-name">{s.get("club_name") or "ClubLedger"}</div>')
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('<div class="biz-address">' + "<br>".join(addr) + "</div>")
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('<div class="biz-contact">' + " &nbsp;|&nbsp; ".join(contact) + "</div>")
return '<div class="biz-header">' + "\n".join(p for p in parts if p) + "</div>"
RECEIPT_CSS = """
body{font-family:Arial,sans-serif;font-size:11px;color:#111;margin:24px;} 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;} h2{font-size:14px;font-weight:bold;margin:10px 0 4px;}
hr{border:none;border-top:1px solid #ccc;margin:10px 0;}
.controls{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;} .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;} .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;} .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;}} @media print{.no-print{display:none;}}
.biz-header{margin-bottom:4px;}
.biz-logo{max-height:60px;max-width:200px;display:block;margin-bottom:4px;}
.biz-logo.align-center{margin-left:auto;margin-right:auto;}
.biz-logo.align-right{margin-left:auto;}
.biz-name{font-size:15px;font-weight:bold;margin:2px 0;}
.biz-address{color:#555;line-height:1.5;}
.biz-contact{color:#555;margin-top:3px;}
.receipt-title{font-size:13px;font-weight:bold;text-transform:uppercase;letter-spacing:.04em;margin:10px 0 6px;}
.member-section{display:flex;justify-content:space-between;align-items:baseline;margin:5px 0;}
.member-name{font-size:13px;font-weight:bold;}
.member-num{color:#555;}
.receipt-grid{display:grid;grid-template-columns:auto 1fr;gap:3px 14px;margin:6px 0;}
.rlbl{font-weight:600;color:#555;white-space:nowrap;font-size:10px;text-transform:uppercase;letter-spacing:.03em;}
.rval{text-align:right;}
.section-head{grid-column:span 2;font-weight:bold;font-size:11px;text-transform:uppercase;letter-spacing:.04em;margin:6px 0 2px;}
.charge-val{font-size:20px;font-weight:bold;color:#c00;}
.credit-val{font-size:20px;font-weight:bold;color:#080;}
.balance-val{font-size:14px;font-weight:bold;}
table{width:100%;border-collapse:collapse;margin-top:10px;}
th{background:#222;color:#fff;padding:5px 8px;text-align:left;font-size:10px;}
td{padding:4px 8px;border-bottom:1px solid #e0e0e0;}
.num{text-align:right;font-variant-numeric:tabular-nums;}
.red{color:#c00;} .grn{color:#080;}
.balance-box{margin-top:12px;text-align:right;font-size:14px;}
.balance-box span{font-weight:bold;font-size:18px;}
.sub-row td{font-size:10px;color:#777;padding-top:0;border-bottom:none;padding-left:24px;}
.footer{margin-top:14px;font-size:10px;color:#888;text-align:center;white-space:pre-wrap;}
""" """
@app.get("/members/{member_id}/statement", response_class=HTMLResponse) @app.get("/members/{member_id}/statement", response_class=HTMLResponse)
@ -640,42 +775,53 @@ def statement(member_id: int):
bal = member_balance(conn, member_id) bal = member_balance(conn, member_id)
sym, div = s.get("currency_symbol","£"), s.get("currency_divisor",100) sym, div = s.get("currency_symbol","£"), s.get("currency_divisor",100)
club, footer = s.get("club_name","ClubLedger"), s.get("receipt_footer","") footer = s.get("receipt_footer","")
bar_name = s.get("bar_name","Bar")
cashier_name = s.get("cashier_name","Cashier")
def fmt(p): return f"{sym}{p/div:.2f}" def fmt(p): return f"{sym}{p/div:.2f}"
def venue_label(v): return bar_name if v == "bar" else cashier_name
rows_html, running = "", 0 rows_html, running = "", 0
for r in rows: for r in rows:
txn_ref = _txn_ref(r["id"], s)
if r["type"] == "topup": if r["type"] == "topup":
running += r["amount"]; dr, cr = "", fmt(r["amount"]) running += r["amount"]; dr, cr = "", fmt(r["amount"]); type_lbl = "Top-up"
elif r["type"] == "withdrawal":
running -= r["amount"]; dr, cr = fmt(r["amount"]), ""; type_lbl = "Withdrawal"
else: else:
running -= r["amount"]; dr, cr = fmt(r["amount"]), "" running -= r["amount"]; dr, cr = fmt(r["amount"]), ""; type_lbl = "Charge"
rows_html += (f"<tr><td>{r['created_at'][:16]}</td><td class='cap'>{r['type']}</td>" rows_html += (f"<tr><td>{r['created_at'][:16]}</td><td>{type_lbl}</td>"
f"<td class='cap'>{r['venue']}</td><td>{r['note'] or ''}</td>" f"<td>{venue_label(r['venue'])}</td><td>{txn_ref}</td>"
f"<td>{r['staff_name']}</td><td class='num red'>{dr}</td>" f"<td>{r['note'] or ''}</td><td>{r['staff_name']}</td>"
f"<td class='num grn'>{cr}</td><td class='num'>{fmt(running)}</td></tr>") f"<td class='num red'>{dr}</td><td class='num grn'>{cr}</td>"
f"<td class='num'>{fmt(running)}</td></tr>")
if r["type"] in ("topup", "withdrawal"):
tf_type = r["transfer_type"] or ""
tf_ref = r["transfer_ref"] or ""
if tf_type or tf_ref:
sub = " &nbsp;·&nbsp; ".join(filter(None, [
f"Type: {tf_type}" if tf_type else "",
f"Ref: {tf_ref}" if tf_ref else "",
]))
rows_html += f'<tr class="sub-row"><td colspan="9">{sub}</td></tr>'
return f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"> return f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
<title>Statement {member['name']}</title><style> <title>Statement {member['name']}</title><style>{RECEIPT_CSS}</style></head><body>
{PRINT_CSS}
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;}}
</style></head><body>
{_print_controls()} {_print_controls()}
<div class="no-print controls" style="margin-top:0"> <div class="no-print controls" style="margin-top:0">
<button class="print-btn" onclick="window.print()">Print Statement</button> <button class="print-btn" onclick="window.print()">Print Statement</button>
</div> </div>
<h1>{club} Account Statement</h1> {_biz_header_html(s)}
<h2>Member: {member['name']} &nbsp;|&nbsp; #{member['member_number']} &nbsp;|&nbsp; <hr>
Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')} UTC</h2> <h2>Account Statement</h2>
<div style="margin-bottom:10px;color:#555;font-size:11px;">
Member: <strong>{member['name']}</strong> &nbsp;|&nbsp; #{member['member_number']} &nbsp;|&nbsp;
Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')} UTC
</div>
<table><thead><tr> <table><thead><tr>
<th>Date/Time</th><th>Type</th><th>Venue</th><th>Note</th> <th>Date/Time</th><th>Type</th><th>Venue</th><th>Reference</th><th>Note</th>
<th>Staff</th><th class="num">Charge</th><th class="num">Top-up</th><th class="num">Balance</th> <th>Staff</th><th class="num">Charge</th><th class="num">Credit</th><th class="num">Balance</th>
</tr></thead><tbody>{rows_html}</tbody></table> </tr></thead><tbody>{rows_html}</tbody></table>
<div class="balance-box">Current Balance: <span>{fmt(bal)}</span></div> <div class="balance-box">Current Balance: <span>{fmt(bal)}</span></div>
{('<div class="footer">' + footer + '</div>') if footer else ''} {('<div class="footer">' + footer + '</div>') if footer else ''}
@ -694,39 +840,80 @@ def receipt(entry_id: int):
""", (entry["member_id"], entry_id)).fetchone()[0] """, (entry["member_id"], entry_id)).fetchone()[0]
sym, div = s.get("currency_symbol","£"), s.get("currency_divisor",100) 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}" def fmt(p): return f"{sym}{p/div:.2f}"
type_label = {"topup": "Top-up", "charge": "Charge", "withdrawal": "Withdrawal"}.get(entry["type"], entry["type"].capitalize()) txn_ref = _txn_ref(entry_id, s)
colour = "#080" if entry["type"] == "topup" else "#c00" etype = entry["type"]
venue_name = s.get("bar_name","Bar") if entry["venue"] == "bar" else s.get("cashier_name","Cashier")
lbl_staff = s.get("lbl_staff", "STAFF")
lbl_txn = s.get("lbl_transaction", "TRANSACTION")
lbl_txn_time = s.get("lbl_txn_time", "TRANSACTION TIME")
lbl_remaining = s.get("lbl_remaining_balance", "REMAINING BALANCE")
if etype == "topup":
title = s.get("lbl_topup_receipt", "TOP-UP RECEIPT")
footer = s.get("receipt_footer_cashier") or s.get("receipt_footer", "")
elif etype == "withdrawal":
title = s.get("lbl_withdrawal_receipt", "WITHDRAWAL RECEIPT")
footer = s.get("receipt_footer_cashier") or s.get("receipt_footer", "")
else:
title = s.get("lbl_receipt", "RECEIPT")
footer = s.get("receipt_footer_charge") or s.get("receipt_footer", "")
if etype == "charge":
lbl_charge = s.get("lbl_charge_venue", "CHARGE")
lbl_amount = s.get("lbl_amount_charged", "AMOUNT CHARGED")
grid_details = (
f'<div class="rlbl">{lbl_staff}</div><div class="rval">{entry["staff_name"]}</div>'
f'<div class="rlbl">{lbl_txn}</div><div class="rval">{txn_ref}</div>'
f'<div class="rlbl">{lbl_charge}</div><div class="rval">{venue_name}</div>'
f'<div class="rlbl">{lbl_txn_time}</div><div class="rval">{entry["created_at"][:16]} UTC</div>'
)
grid_amounts = (
f'<div class="rlbl">{lbl_amount}</div><div class="rval charge-val">{fmt(entry["amount"])}</div>'
f'<div class="rlbl">{lbl_remaining}</div><div class="rval balance-val">{fmt(bal_after)}</div>'
)
else:
lbl_tf_section = s.get("lbl_balance_transfer", "BALANCE TRANSFER")
lbl_tf_type = s.get("lbl_transfer_type", "TRANSFER TYPE")
lbl_tf_ref = s.get("lbl_transfer_ref", "TRANSFER REFERENCE")
lbl_amount = s.get("lbl_amount_topup", "AMOUNT TOPPED-UP") if etype == "topup" else s.get("lbl_amount_withdrawal", "AMOUNT WITHDRAWN")
tf_type = entry["transfer_type"] or ""
tf_ref = entry["transfer_ref"] or ""
grid_details = (
f'<div class="rlbl">{lbl_staff}</div><div class="rval">{entry["staff_name"]}</div>'
f'<div class="rlbl">{lbl_txn}</div><div class="rval">{txn_ref}</div>'
f'<div class="rlbl">{lbl_txn_time}</div><div class="rval">{entry["created_at"][:16]} UTC</div>'
)
tf_rows = ""
if tf_type: tf_rows += f'<div class="rlbl">{lbl_tf_type}</div><div class="rval">{tf_type}</div>'
if tf_ref: tf_rows += f'<div class="rlbl">{lbl_tf_ref}</div><div class="rval">{tf_ref}</div>'
grid_amounts = (
f'<div class="section-head">{lbl_tf_section}</div>'
f'{tf_rows}'
f'<div class="rlbl">{lbl_amount}</div><div class="rval credit-val">{fmt(entry["amount"])}</div>'
f'<div class="rlbl">{lbl_remaining}</div><div class="rval balance-val">{fmt(bal_after)}</div>'
)
return f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"> return f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
<title>Receipt {member['name']}</title><style> <title>Receipt {member['name']}</title><style>{RECEIPT_CSS}</style></head><body>
{PRINT_CSS}
.sub{{font-size:15px;color:#555;margin-bottom:20px;}}
table{{border-collapse:collapse;}}
td{{padding:5px 16px 5px 0;vertical-align:top;}}
td:first-child{{font-weight:600;color:#555;white-space:nowrap;min-width:110px;}}
.amount{{font-size:24px;font-weight:bold;color:{colour};}}
.bal{{font-size:20px;font-weight:bold;}}
hr{{border:none;border-top:1px solid #ccc;margin:16px 0;}}
</style></head><body>
{_print_controls()} {_print_controls()}
<div class="no-print controls" style="margin-top:0"> <div class="no-print controls" style="margin-top:0">
<button class="print-btn" onclick="window.print()">Print Receipt</button> <button class="print-btn" onclick="window.print()">Print Receipt</button>
</div> </div>
<h1>{club}</h1><div class="sub">{type_label} Receipt</div><hr> {_biz_header_html(s)}
<table> <hr>
<tr><td>Member</td><td><strong>{member['name']}</strong></td></tr> <div class="receipt-title">{title}</div>
<tr><td>Member #</td><td>{member['member_number']}</td></tr> <div class="member-section">
<tr><td>Type</td><td>{type_label}</td></tr> <div class="member-name">{member['name']}</div>
<tr><td>Amount</td><td class="amount">{fmt(entry['amount'])}</td></tr> <div class="member-num">#{member['member_number']}</div>
<tr><td>Balance after</td><td class="bal">{fmt(bal_after)}</td></tr> </div>
<tr><td>Staff</td><td>{entry['staff_name']}</td></tr> <hr>
<tr><td>Note</td><td>{entry['note'] or ''}</td></tr> <div class="receipt-grid">{grid_details}</div>
<tr><td>Date / Time</td><td>{entry['created_at']} UTC</td></tr> <hr>
</table> <div class="receipt-grid">{grid_amounts}</div>
<hr>
{('<div class="footer">' + footer + '</div>') if footer else ''} {('<div class="footer">' + footer + '</div>') if footer else ''}
{_print_size_script()}</body></html>""" {_print_size_script()}</body></html>"""
@ -882,8 +1069,9 @@ def update_admin_settings(body: AppSettingsUpdate, user: dict = Depends(admin_us
@app.get("/config") @app.get("/config")
def get_config(): def get_config():
s = dict(_settings) s = dict(_settings)
# expose currency_major as currency_unit so common.js .currency-unit spans still work
s["currency_unit"] = s.get("currency_major", "pounds") s["currency_unit"] = s.get("currency_major", "pounds")
raw_tt = s.get("transfer_types", "Bank Transfer,Cash,QR")
s["transfer_types"] = [t.strip() for t in raw_tt.split(",") if t.strip()]
return s return s
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -61,9 +61,22 @@ async function doLogout() {
showLogin(); showLogin();
} }
function populateTransferTypes() {
const types = Array.isArray(cfg.transfer_types) ? cfg.transfer_types : [];
['cashierTransferType', 'withdrawalTransferType'].forEach(id => {
const sel = document.getElementById(id);
if (!sel) return;
const prev = sel.value;
sel.innerHTML = '<option value="">— select —</option>' +
types.map(t => `<option value="${esc(t)}">${esc(t)}</option>`).join('');
if (prev && types.includes(prev)) sel.value = prev;
});
}
async function startApp() { async function startApp() {
document.getElementById('loginOverlay').classList.add('hidden'); document.getElementById('loginOverlay').classList.add('hidden');
await loadConfig(); await loadConfig();
populateTransferTypes();
const brand = document.getElementById('navBrand'); const brand = document.getElementById('navBrand');
if (brand) brand.textContent = cfg.club_name; if (brand) brand.textContent = cfg.club_name;
@ -269,9 +282,13 @@ function clearCashierSelection() {
cashierMember = null; cashierMember = null;
document.getElementById('cashierForm').classList.add('hidden'); document.getElementById('cashierForm').classList.add('hidden');
document.getElementById('cashierAmount').value = ''; document.getElementById('cashierAmount').value = '';
document.getElementById('cashierTransferType').value = '';
document.getElementById('cashierTransferRef').value = '';
document.getElementById('cashierNote').value = ''; document.getElementById('cashierNote').value = '';
document.getElementById('withdrawalAmount').value = ''; document.getElementById('withdrawalAmount').value = '';
document.getElementById('withdrawalPin').value = ''; document.getElementById('withdrawalPin').value = '';
document.getElementById('withdrawalTransferType').value = '';
document.getElementById('withdrawalTransferRef').value = '';
document.getElementById('withdrawalNote').value = ''; document.getElementById('withdrawalNote').value = '';
setMsg('cashierTopupMsg', '', ''); setMsg('cashierTopupMsg', '', '');
setMsg('cashierWithdrawalMsg', '', ''); setMsg('cashierWithdrawalMsg', '', '');
@ -281,15 +298,20 @@ function clearCashierSelection() {
async function doTopup() { async function doTopup() {
if (!cashierMember) return; if (!cashierMember) return;
const amount = toMinor('cashierAmount'); const amount = toMinor('cashierAmount');
const note = document.getElementById('cashierNote').value.trim(); const transferType = document.getElementById('cashierTransferType').value || null;
const transferRef = document.getElementById('cashierTransferRef').value.trim() || null;
const note = document.getElementById('cashierNote').value.trim() || null;
if (!amount) { setMsg('cashierTopupMsg', 'Enter a valid amount.', 'err'); return; } if (!amount) { setMsg('cashierTopupMsg', 'Enter a valid amount.', 'err'); return; }
try { try {
const r = await apiFetch('/topup', { const r = await apiFetch('/topup', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ member_id: cashierMember.id, amount, note: note || null }) body: JSON.stringify({ member_id: cashierMember.id, amount, note,
transfer_type: transferType, transfer_ref: transferRef })
}); });
window.open(`/receipt/${r.entry_id}`, '_blank'); window.open(`/receipt/${r.entry_id}`, '_blank');
document.getElementById('cashierAmount').value = ''; document.getElementById('cashierAmount').value = '';
document.getElementById('cashierTransferType').value = '';
document.getElementById('cashierTransferRef').value = '';
document.getElementById('cashierNote').value = ''; document.getElementById('cashierNote').value = '';
setMsg('cashierTopupMsg', `Top-up complete. New balance: ${r.new_balance_display}`, 'ok'); setMsg('cashierTopupMsg', `Top-up complete. New balance: ${r.new_balance_display}`, 'ok');
} catch (err) { setMsg('cashierTopupMsg', err.message, 'err'); } } catch (err) { setMsg('cashierTopupMsg', err.message, 'err'); }
@ -299,17 +321,22 @@ async function doWithdrawal() {
if (!cashierMember) return; if (!cashierMember) return;
const amount = toMinor('withdrawalAmount'); const amount = toMinor('withdrawalAmount');
const pin = document.getElementById('withdrawalPin').value; const pin = document.getElementById('withdrawalPin').value;
const note = document.getElementById('withdrawalNote').value.trim(); const transferType = document.getElementById('withdrawalTransferType').value || null;
const transferRef = document.getElementById('withdrawalTransferRef').value.trim() || null;
const note = document.getElementById('withdrawalNote').value.trim() || null;
if (!amount) { setMsg('cashierWithdrawalMsg', 'Enter a valid amount.', 'err'); return; } if (!amount) { setMsg('cashierWithdrawalMsg', 'Enter a valid amount.', 'err'); return; }
if (!pin) { setMsg('cashierWithdrawalMsg', 'PIN is required.', 'err'); return; } if (!pin) { setMsg('cashierWithdrawalMsg', 'PIN is required.', 'err'); return; }
try { try {
const r = await apiFetch('/withdrawal', { const r = await apiFetch('/withdrawal', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ member_id: cashierMember.id, amount, pin, note: note || null }) body: JSON.stringify({ member_id: cashierMember.id, amount, pin, note,
transfer_type: transferType, transfer_ref: transferRef })
}); });
window.open(`/receipt/${r.entry_id}`, '_blank'); window.open(`/receipt/${r.entry_id}`, '_blank');
document.getElementById('withdrawalAmount').value = ''; document.getElementById('withdrawalAmount').value = '';
document.getElementById('withdrawalPin').value = ''; document.getElementById('withdrawalPin').value = '';
document.getElementById('withdrawalTransferType').value = '';
document.getElementById('withdrawalTransferRef').value = '';
document.getElementById('withdrawalNote').value = ''; document.getElementById('withdrawalNote').value = '';
setMsg('cashierWithdrawalMsg', `Withdrawal complete. New balance: ${r.new_balance_display}`, 'ok'); setMsg('cashierWithdrawalMsg', `Withdrawal complete. New balance: ${r.new_balance_display}`, 'ok');
} catch (err) { setMsg('cashierWithdrawalMsg', err.message, 'err'); } } catch (err) { setMsg('cashierWithdrawalMsg', err.message, 'err'); }
@ -377,6 +404,8 @@ async function loadAdminSettings() {
try { try {
const s = await apiFetch('/admin/settings'); const s = await apiFetch('/admin/settings');
const div = s.currency_divisor || 100; const div = s.currency_divisor || 100;
const majorUnit = s.currency_major || 'major units';
// General
document.getElementById('s-club-name').value = s.club_name || ''; document.getElementById('s-club-name').value = s.club_name || '';
document.getElementById('s-currency-symbol').value = s.currency_symbol || ''; document.getElementById('s-currency-symbol').value = s.currency_symbol || '';
document.getElementById('s-currency-major').value = s.currency_major || ''; document.getElementById('s-currency-major').value = s.currency_major || '';
@ -385,28 +414,103 @@ async function loadAdminSettings() {
document.getElementById('s-min-topup').value = ((s.min_topup || 0) / div).toFixed(2); document.getElementById('s-min-topup').value = ((s.min_topup || 0) / div).toFixed(2);
document.getElementById('s-max-topup').value = ((s.max_topup || 0) / div).toFixed(2); document.getElementById('s-max-topup').value = ((s.max_topup || 0) / div).toFixed(2);
document.getElementById('s-max-charge').value = ((s.max_charge || 0) / div).toFixed(2); document.getElementById('s-max-charge').value = ((s.max_charge || 0) / div).toFixed(2);
document.getElementById('s-receipt-footer').value = s.receipt_footer || '';
document.getElementById('s-overdraft-policy').value = s.overdraft_policy || 'never'; document.getElementById('s-overdraft-policy').value = s.overdraft_policy || 'never';
const sym = s.currency_symbol || ''; document.getElementById('s-min-hint').textContent = `in ${majorUnit}`;
document.getElementById('s-min-hint').textContent = `in ${s.currency_major || 'major units'}`; document.getElementById('s-max-hint').textContent = `in ${majorUnit}`;
document.getElementById('s-max-hint').textContent = `in ${s.currency_major || 'major units'}`; document.getElementById('s-charge-hint').textContent= `in ${majorUnit}`;
document.getElementById('s-charge-hint').textContent= `in ${s.currency_major || 'major units'}`; // Business address
document.getElementById('s-biz-address1').value = s.biz_address1 || '';
document.getElementById('s-biz-address2').value = s.biz_address2 || '';
document.getElementById('s-biz-address3').value = s.biz_address3 || '';
document.getElementById('s-biz-address4').value = s.biz_address4 || '';
document.getElementById('s-biz-country').value = s.biz_country || '';
document.getElementById('s-biz-phone').value = s.biz_phone || '';
document.getElementById('s-biz-email').value = s.biz_email || '';
document.getElementById('s-biz-website').value = s.biz_website || '';
// Branding
document.getElementById('s-logo-url').value = s.logo_url || '';
document.getElementById('s-logo-align').value = s.logo_align || 'left';
document.getElementById('s-bar-name').value = s.bar_name || '';
document.getElementById('s-cashier-name').value = s.cashier_name || '';
// Transactions
document.getElementById('s-txn-ref-prefix').value = s.txn_ref_prefix || '';
// transfer_types from /admin/settings is the raw comma-separated string
const rawTT = Array.isArray(s.transfer_types) ? s.transfer_types.join(',') : (s.transfer_types || '');
document.getElementById('s-transfer-types').value = rawTT;
// Receipt labels
document.getElementById('s-lbl-receipt').value = s.lbl_receipt || '';
document.getElementById('s-lbl-topup-receipt').value = s.lbl_topup_receipt || '';
document.getElementById('s-lbl-withdrawal-receipt').value = s.lbl_withdrawal_receipt || '';
document.getElementById('s-lbl-staff').value = s.lbl_staff || '';
document.getElementById('s-lbl-transaction').value = s.lbl_transaction || '';
document.getElementById('s-lbl-charge').value = s.lbl_charge_venue || '';
document.getElementById('s-lbl-txn-time').value = s.lbl_txn_time || '';
document.getElementById('s-lbl-amount-charged').value = s.lbl_amount_charged || '';
document.getElementById('s-lbl-remaining-balance').value = s.lbl_remaining_balance || '';
document.getElementById('s-lbl-balance-transfer').value = s.lbl_balance_transfer || '';
document.getElementById('s-lbl-amount-topup').value = s.lbl_amount_topup || '';
document.getElementById('s-lbl-amount-withdrawal').value = s.lbl_amount_withdrawal || '';
document.getElementById('s-lbl-transfer-type').value = s.lbl_transfer_type || '';
document.getElementById('s-lbl-transfer-ref').value = s.lbl_transfer_ref || '';
// Footers
document.getElementById('s-receipt-footer').value = s.receipt_footer || '';
document.getElementById('s-receipt-footer-charge').value = s.receipt_footer_charge || '';
document.getElementById('s-receipt-footer-cashier').value = s.receipt_footer_cashier || '';
} catch (err) { setMsg('settingsMsg', err.message, 'err'); } } catch (err) { setMsg('settingsMsg', err.message, 'err'); }
} }
function _sv(id) { return document.getElementById(id).value; }
function _svt(id) { return _sv(id).trim(); }
async function saveSettings() { async function saveSettings() {
const div = parseInt(document.getElementById('s-currency-divisor').value, 10) || 100; const div = parseInt(_sv('s-currency-divisor'), 10) || 100;
const body = { const body = {
club_name: document.getElementById('s-club-name').value.trim(), // General
currency_symbol: document.getElementById('s-currency-symbol').value.trim(), club_name: _svt('s-club-name'),
currency_major: document.getElementById('s-currency-major').value.trim(), currency_symbol: _svt('s-currency-symbol'),
currency_minor: document.getElementById('s-currency-minor').value.trim(), currency_major: _svt('s-currency-major'),
currency_minor: _svt('s-currency-minor'),
currency_divisor: div, currency_divisor: div,
min_topup: Math.round(parseFloat(document.getElementById('s-min-topup').value) * div), min_topup: Math.round(parseFloat(_sv('s-min-topup')) * div),
max_topup: Math.round(parseFloat(document.getElementById('s-max-topup').value) * div), max_topup: Math.round(parseFloat(_sv('s-max-topup')) * div),
max_charge: Math.round(parseFloat(document.getElementById('s-max-charge').value) * div), max_charge: Math.round(parseFloat(_sv('s-max-charge')) * div),
receipt_footer: document.getElementById('s-receipt-footer').value, overdraft_policy: _sv('s-overdraft-policy'),
overdraft_policy: document.getElementById('s-overdraft-policy').value, // Business address
biz_address1: _svt('s-biz-address1'),
biz_address2: _svt('s-biz-address2'),
biz_address3: _svt('s-biz-address3'),
biz_address4: _svt('s-biz-address4'),
biz_country: _svt('s-biz-country'),
biz_phone: _svt('s-biz-phone'),
biz_email: _svt('s-biz-email'),
biz_website: _svt('s-biz-website'),
// Branding
logo_url: _svt('s-logo-url'),
logo_align: _sv('s-logo-align'),
bar_name: _svt('s-bar-name'),
cashier_name: _svt('s-cashier-name'),
// Transactions
txn_ref_prefix: _svt('s-txn-ref-prefix'),
transfer_types: _svt('s-transfer-types'),
// Receipt labels
lbl_receipt: _svt('s-lbl-receipt'),
lbl_topup_receipt: _svt('s-lbl-topup-receipt'),
lbl_withdrawal_receipt: _svt('s-lbl-withdrawal-receipt'),
lbl_staff: _svt('s-lbl-staff'),
lbl_transaction: _svt('s-lbl-transaction'),
lbl_charge_venue: _svt('s-lbl-charge'),
lbl_txn_time: _svt('s-lbl-txn-time'),
lbl_amount_charged: _svt('s-lbl-amount-charged'),
lbl_remaining_balance: _svt('s-lbl-remaining-balance'),
lbl_balance_transfer: _svt('s-lbl-balance-transfer'),
lbl_amount_topup: _svt('s-lbl-amount-topup'),
lbl_amount_withdrawal: _svt('s-lbl-amount-withdrawal'),
lbl_transfer_type: _svt('s-lbl-transfer-type'),
lbl_transfer_ref: _svt('s-lbl-transfer-ref'),
// Footers
receipt_footer: _sv('s-receipt-footer'),
receipt_footer_charge: _sv('s-receipt-footer-charge'),
receipt_footer_cashier: _sv('s-receipt-footer-cashier'),
}; };
try { try {
await apiFetch('/admin/settings', { await apiFetch('/admin/settings', {
@ -414,7 +518,8 @@ async function saveSettings() {
body: JSON.stringify(body) body: JSON.stringify(body)
}); });
setMsg('settingsMsg', 'Settings saved.', 'ok'); setMsg('settingsMsg', 'Settings saved.', 'ok');
await loadConfig(); // refresh frontend cfg await loadConfig();
populateTransferTypes();
document.querySelectorAll('.currency-unit').forEach(el => { el.textContent = cfg.currency_major || cfg.currency_unit; }); document.querySelectorAll('.currency-unit').forEach(el => { el.textContent = cfg.currency_major || cfg.currency_unit; });
if (document.getElementById('navBrand')) if (document.getElementById('navBrand'))
document.getElementById('navBrand').textContent = cfg.club_name; document.getElementById('navBrand').textContent = cfg.club_name;

View file

@ -98,7 +98,17 @@
<input type="number" id="cashierAmount" placeholder="e.g. 10.00" min="0.01" step="0.01"> <input type="number" id="cashierAmount" placeholder="e.g. 10.00" min="0.01" step="0.01">
</div> </div>
<div class="form-row"> <div class="form-row">
<label>Note (optional)</label> <label>Transfer Type</label>
<select id="cashierTransferType">
<option value="">— select —</option>
</select>
</div>
<div class="form-row">
<label>Transfer Reference <span class="label-hint">(optional)</span></label>
<input type="text" id="cashierTransferRef" placeholder="">
</div>
<div class="form-row">
<label>Note <span class="label-hint">(optional)</span></label>
<input type="text" id="cashierNote" placeholder=""> <input type="text" id="cashierNote" placeholder="">
</div> </div>
<button class="btn btn-primary" onclick="doTopup()">Top Up</button> <button class="btn btn-primary" onclick="doTopup()">Top Up</button>
@ -116,7 +126,17 @@
<input type="password" id="withdrawalPin" placeholder="" autocomplete="off"> <input type="password" id="withdrawalPin" placeholder="" autocomplete="off">
</div> </div>
<div class="form-row"> <div class="form-row">
<label>Note (optional)</label> <label>Transfer Type</label>
<select id="withdrawalTransferType">
<option value="">— select —</option>
</select>
</div>
<div class="form-row">
<label>Transfer Reference <span class="label-hint">(optional)</span></label>
<input type="text" id="withdrawalTransferRef" placeholder="">
</div>
<div class="form-row">
<label>Note <span class="label-hint">(optional)</span></label>
<input type="text" id="withdrawalNote" placeholder=""> <input type="text" id="withdrawalNote" placeholder="">
</div> </div>
<button class="btn btn-danger" onclick="doWithdrawal()">Withdraw</button> <button class="btn btn-danger" onclick="doWithdrawal()">Withdraw</button>
@ -166,6 +186,8 @@
<div class="panel"> <div class="panel">
<h2>App Settings</h2> <h2>App Settings</h2>
<form id="settingsForm"> <form id="settingsForm">
<h3 class="sub-heading">General</h3>
<div class="form-row"><label>Club Name</label> <div class="form-row"><label>Club Name</label>
<input type="text" id="s-club-name"></div> <input type="text" id="s-club-name"></div>
<div class="form-row"><label>Currency Symbol</label> <div class="form-row"><label>Currency Symbol</label>
@ -182,8 +204,6 @@
<input type="number" id="s-max-topup" step="0.01"></div> <input type="number" id="s-max-topup" step="0.01"></div>
<div class="form-row"><label>Maximum single charge <span class="label-hint" id="s-charge-hint"></span></label> <div class="form-row"><label>Maximum single charge <span class="label-hint" id="s-charge-hint"></span></label>
<input type="number" id="s-max-charge" step="0.01"></div> <input type="number" id="s-max-charge" step="0.01"></div>
<div class="form-row"><label>Receipt footer text <span class="label-hint">(optional)</span></label>
<textarea id="s-receipt-footer" rows="2" placeholder="Printed at the bottom of every receipt and statement"></textarea></div>
<div class="form-row"> <div class="form-row">
<label>Overdraft (bar charges)</label> <label>Overdraft (bar charges)</label>
<select id="s-overdraft-policy"> <select id="s-overdraft-policy">
@ -194,7 +214,68 @@
<option value="staff_block">Default allowed — staff may block per charge</option> <option value="staff_block">Default allowed — staff may block per charge</option>
</select> </select>
</div> </div>
<button type="submit" class="btn btn-primary">Save Settings</button>
<div class="panel-divider"></div>
<h3 class="sub-heading">Business Address</h3>
<div class="form-row"><label>Address line 1</label><input type="text" id="s-biz-address1"></div>
<div class="form-row"><label>Address line 2</label><input type="text" id="s-biz-address2"></div>
<div class="form-row"><label>Address line 3</label><input type="text" id="s-biz-address3"></div>
<div class="form-row"><label>Address line 4</label><input type="text" id="s-biz-address4"></div>
<div class="form-row"><label>Country</label><input type="text" id="s-biz-country"></div>
<div class="form-row"><label>Phone</label><input type="text" id="s-biz-phone"></div>
<div class="form-row"><label>Email</label><input type="text" id="s-biz-email"></div>
<div class="form-row"><label>Website</label><input type="text" id="s-biz-website"></div>
<div class="panel-divider"></div>
<h3 class="sub-heading">Branding</h3>
<div class="form-row"><label>Logo URL <span class="label-hint">(optional)</span></label>
<input type="text" id="s-logo-url" placeholder="https://..."></div>
<div class="form-row"><label>Logo alignment</label>
<select id="s-logo-align">
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
</select>
</div>
<div class="form-row"><label>Bar venue name</label>
<input type="text" id="s-bar-name" placeholder="Bar"></div>
<div class="form-row"><label>Cashier venue name</label>
<input type="text" id="s-cashier-name" placeholder="Cashier"></div>
<div class="panel-divider"></div>
<h3 class="sub-heading">Transactions</h3>
<div class="form-row"><label>Transaction reference prefix</label>
<input type="text" id="s-txn-ref-prefix" placeholder="TXN" style="max-width:120px"></div>
<div class="form-row"><label>Transfer types <span class="label-hint">(comma-separated)</span></label>
<input type="text" id="s-transfer-types" placeholder="Bank Transfer,Cash,QR"></div>
<div class="panel-divider"></div>
<h3 class="sub-heading">Receipt Labels <span class="label-hint">(for localisation)</span></h3>
<div class="form-row"><label>Receipt title (charge)</label><input type="text" id="s-lbl-receipt" placeholder="RECEIPT"></div>
<div class="form-row"><label>Receipt title (top-up)</label><input type="text" id="s-lbl-topup-receipt" placeholder="TOP-UP RECEIPT"></div>
<div class="form-row"><label>Receipt title (withdrawal)</label><input type="text" id="s-lbl-withdrawal-receipt" placeholder="WITHDRAWAL RECEIPT"></div>
<div class="form-row"><label>Staff label</label><input type="text" id="s-lbl-staff" placeholder="STAFF"></div>
<div class="form-row"><label>Transaction label</label><input type="text" id="s-lbl-transaction" placeholder="TRANSACTION"></div>
<div class="form-row"><label>Charge/venue label</label><input type="text" id="s-lbl-charge" placeholder="CHARGE"></div>
<div class="form-row"><label>Transaction time label</label><input type="text" id="s-lbl-txn-time" placeholder="TRANSACTION TIME"></div>
<div class="form-row"><label>Amount charged label</label><input type="text" id="s-lbl-amount-charged" placeholder="AMOUNT CHARGED"></div>
<div class="form-row"><label>Remaining balance label</label><input type="text" id="s-lbl-remaining-balance" placeholder="REMAINING BALANCE"></div>
<div class="form-row"><label>Balance transfer section header</label><input type="text" id="s-lbl-balance-transfer" placeholder="BALANCE TRANSFER"></div>
<div class="form-row"><label>Amount topped-up label</label><input type="text" id="s-lbl-amount-topup" placeholder="AMOUNT TOPPED-UP"></div>
<div class="form-row"><label>Amount withdrawn label</label><input type="text" id="s-lbl-amount-withdrawal" placeholder="AMOUNT WITHDRAWN"></div>
<div class="form-row"><label>Transfer type label</label><input type="text" id="s-lbl-transfer-type" placeholder="TRANSFER TYPE"></div>
<div class="form-row"><label>Transfer reference label</label><input type="text" id="s-lbl-transfer-ref" placeholder="TRANSFER REFERENCE"></div>
<div class="panel-divider"></div>
<h3 class="sub-heading">Receipt Footers</h3>
<div class="form-row"><label>Footer — all <span class="label-hint">(fallback for all receipts and statement)</span></label>
<textarea id="s-receipt-footer" rows="2" placeholder="Printed at the bottom of every receipt and statement"></textarea></div>
<div class="form-row"><label>Footer — charge receipts <span class="label-hint">(overrides all-footer for bar charges)</span></label>
<textarea id="s-receipt-footer-charge" rows="2"></textarea></div>
<div class="form-row"><label>Footer — cashier receipts <span class="label-hint">(overrides all-footer for top-ups and withdrawals)</span></label>
<textarea id="s-receipt-footer-cashier" rows="2"></textarea></div>
<button type="submit" class="btn btn-primary" style="margin-top:8px">Save Settings</button>
</form> </form>
<div id="settingsMsg" class="msg"></div> <div id="settingsMsg" class="msg"></div>
</div> </div>