From acd8ff3fd022dabaa3d52f1195abd8eba01550a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 14:31:16 +0000 Subject: [PATCH] Add withdrawal feature to cashier tab Cashiers can process credit withdrawals (cash-back) for members: - Requires member PIN to authorize - Always checks sufficient balance (overdraft not allowed for withdrawals) - Appears as 'withdrawal' type in ledger, statement, and on receipt - Receipt opens automatically, same as top-up/charge Backend: - migrate_db() now also recreates ledger_entries with type constraint extended to include 'withdrawal' (existing rows preserved) - POST /withdrawal endpoint (cashier_user required) - Receipt label map updated to include Withdrawal Frontend: - Cashier tab now shows two panels side-by-side: Top Up and Withdrawal - Each panel has its own message area so they don't overwrite each other - Withdrawal panel includes PIN field; top-up panel unchanged https://claude.ai/code/session_01JuRTR5Xjx8emQsyerBgGU7 --- main.py | 55 ++++++++++++++++++++++++++++++++++++++++++++--- static/app.js | 40 ++++++++++++++++++++++++++++------ static/index.html | 40 +++++++++++++++++++++++++++------- static/style.css | 15 +++++++++++++ 4 files changed, 133 insertions(+), 17 deletions(-) diff --git a/main.py b/main.py index 6daa978..85e445f 100644 --- a/main.py +++ b/main.py @@ -81,7 +81,7 @@ def init_db(): 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')), + 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, @@ -117,11 +117,11 @@ def init_db(): def migrate_db(): """Run schema migrations that can't be expressed as CREATE TABLE IF NOT EXISTS.""" with db_conn() as conn: + # --- staff_accounts: add cashier/pos-staff roles --- schema = conn.execute( "SELECT sql FROM sqlite_master WHERE type='table' AND name='staff_accounts'" ).fetchone() if schema and "'pos-staff'" not in schema["sql"]: - # Recreate staff_accounts with new role set; convert 'staff' → 'pos-staff' conn.execute("ALTER TABLE staff_accounts RENAME TO _staff_accounts_old") conn.execute(""" CREATE TABLE staff_accounts ( @@ -144,6 +144,28 @@ def migrate_db(): """) conn.execute("DROP TABLE _staff_accounts_old") + # --- ledger_entries: add withdrawal type --- + le_schema = conn.execute( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='ledger_entries'" + ).fetchone() + if le_schema and "'withdrawal'" not in le_schema["sql"]: + conn.execute("ALTER TABLE ledger_entries RENAME TO _ledger_entries_old") + conn.execute(""" + CREATE TABLE 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')) + ) + """) + conn.execute("INSERT INTO ledger_entries SELECT * FROM _ledger_entries_old") + conn.execute("DROP TABLE _ledger_entries_old") + conn.execute("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: @@ -280,6 +302,12 @@ class ChargeRequest(BaseModel): pin: str note: Optional[str] = None +class WithdrawalRequest(BaseModel): + member_id: int + amount: int # minor units + pin: str + note: Optional[str] = None + class ProductCreate(BaseModel): name: str brand: Optional[str] = None @@ -491,6 +519,27 @@ def charge(body: ChargeRequest, user: dict = Depends(pos_user)): 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.post("/withdrawal") +def withdrawal(body: WithdrawalRequest, user: dict = Depends(cashier_user)): + if body.amount <= 0: + raise HTTPException(400, "Amount must be positive") + 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") + bal = member_balance(conn, body.member_id) + 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"]) + ) + 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, user: dict = Depends(current_user)): @@ -615,7 +664,7 @@ def receipt(entry_id: int): def fmt(p): return f"{sym}{p/div:.2f}" - type_label = "Top-up" if entry["type"] == "topup" else "Charge" + type_label = {"topup": "Top-up", "charge": "Charge", "withdrawal": "Withdrawal"}.get(entry["type"], entry["type"].capitalize()) colour = "#080" if entry["type"] == "topup" else "#c00" return f""" diff --git a/static/app.js b/static/app.js index ecb6280..58941b8 100644 --- a/static/app.js +++ b/static/app.js @@ -231,14 +231,21 @@ function selectCashierMember(id, name, number, balance, balanceDisplay) { document.getElementById('cashierSelected').innerHTML = `${esc(name)}   #${esc(number)}   Balance: ${esc(balanceDisplay)}`; document.getElementById('cashierForm').classList.remove('hidden'); + setMsg('cashierTopupMsg', '', ''); + setMsg('cashierWithdrawalMsg', '', ''); setMsg('cashierMsg', '', ''); } function clearCashierSelection() { cashierMember = null; document.getElementById('cashierForm').classList.add('hidden'); - document.getElementById('cashierAmount').value = ''; - document.getElementById('cashierNote').value = ''; + document.getElementById('cashierAmount').value = ''; + document.getElementById('cashierNote').value = ''; + document.getElementById('withdrawalAmount').value = ''; + document.getElementById('withdrawalPin').value = ''; + document.getElementById('withdrawalNote').value = ''; + setMsg('cashierTopupMsg', '', ''); + setMsg('cashierWithdrawalMsg', '', ''); setMsg('cashierMsg', '', ''); } @@ -246,16 +253,37 @@ async function doTopup() { if (!cashierMember) return; const amount = toMinor('cashierAmount'); const note = document.getElementById('cashierNote').value.trim(); - if (!amount) { setMsg('cashierMsg', 'Enter a valid amount.', 'err'); return; } + if (!amount) { setMsg('cashierTopupMsg', 'Enter a valid amount.', 'err'); return; } try { const r = await apiFetch('/topup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ member_id: cashierMember.id, amount, note: note || null }) }); window.open(`/receipt/${r.entry_id}`, '_blank'); - setMsg('cashierMsg', `Top-up complete. New balance: ${r.new_balance_display}`, 'ok'); - clearCashierSelection(); - } catch (err) { setMsg('cashierMsg', err.message, 'err'); } + document.getElementById('cashierAmount').value = ''; + document.getElementById('cashierNote').value = ''; + setMsg('cashierTopupMsg', `Top-up complete. New balance: ${r.new_balance_display}`, 'ok'); + } catch (err) { setMsg('cashierTopupMsg', err.message, 'err'); } +} + +async function doWithdrawal() { + if (!cashierMember) return; + const amount = toMinor('withdrawalAmount'); + const pin = document.getElementById('withdrawalPin').value; + const note = document.getElementById('withdrawalNote').value.trim(); + if (!amount) { setMsg('cashierWithdrawalMsg', 'Enter a valid amount.', 'err'); return; } + if (!pin) { setMsg('cashierWithdrawalMsg', 'PIN is required.', 'err'); return; } + try { + const r = await apiFetch('/withdrawal', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ member_id: cashierMember.id, amount, pin, note: note || null }) + }); + window.open(`/receipt/${r.entry_id}`, '_blank'); + document.getElementById('withdrawalAmount').value = ''; + document.getElementById('withdrawalPin').value = ''; + document.getElementById('withdrawalNote').value = ''; + setMsg('cashierWithdrawalMsg', `Withdrawal complete. New balance: ${r.new_balance_display}`, 'ok'); + } catch (err) { setMsg('cashierWithdrawalMsg', err.message, 'err'); } } // --------------------------------------------------------------------------- diff --git a/static/index.html b/static/index.html index 8a94c1e..0f247c0 100644 --- a/static/index.html +++ b/static/index.html @@ -90,16 +90,40 @@