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
This commit is contained in:
Claude 2026-05-30 14:31:16 +00:00
parent 68a35e5bff
commit acd8ff3fd0
No known key found for this signature in database
4 changed files with 133 additions and 17 deletions

55
main.py
View file

@ -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"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">

View file

@ -231,6 +231,8 @@ function selectCashierMember(id, name, number, balance, balanceDisplay) {
document.getElementById('cashierSelected').innerHTML =
`<strong>${esc(name)}</strong> &nbsp; #${esc(number)} &nbsp; Balance: <span class="${balanceClass(balance)}">${esc(balanceDisplay)}</span>`;
document.getElementById('cashierForm').classList.remove('hidden');
setMsg('cashierTopupMsg', '', '');
setMsg('cashierWithdrawalMsg', '', '');
setMsg('cashierMsg', '', '');
}
@ -239,6 +241,11 @@ function clearCashierSelection() {
document.getElementById('cashierForm').classList.add('hidden');
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'); }
}
// ---------------------------------------------------------------------------

View file

@ -90,6 +90,9 @@
<div id="cashierForm" class="hidden">
<div class="selected-member-box" id="cashierSelected"></div>
<div class="cashier-action-panel">
<h3>Top Up</h3>
<div class="form-row">
<label>Amount (<span class="currency-unit"></span>)</label>
<input type="number" id="cashierAmount" placeholder="e.g. 10.00" min="0.01" step="0.01">
@ -99,7 +102,28 @@
<input type="text" id="cashierNote" placeholder="">
</div>
<button class="btn btn-primary" onclick="doTopup()">Top Up</button>
<button class="btn" onclick="clearCashierSelection()">Cancel</button>
<div id="cashierTopupMsg" class="msg"></div>
</div>
<div class="cashier-action-panel">
<h3>Withdrawal</h3>
<div class="form-row">
<label>Amount (<span class="currency-unit"></span>)</label>
<input type="number" id="withdrawalAmount" placeholder="e.g. 10.00" min="0.01" step="0.01">
</div>
<div class="form-row">
<label>Member PIN</label>
<input type="password" id="withdrawalPin" placeholder="" autocomplete="off">
</div>
<div class="form-row">
<label>Note (optional)</label>
<input type="text" id="withdrawalNote" placeholder="">
</div>
<button class="btn btn-danger" onclick="doWithdrawal()">Withdraw</button>
<div id="cashierWithdrawalMsg" class="msg"></div>
</div>
<button class="btn" onclick="clearCashierSelection()" style="margin-top:8px">Cancel</button>
</div>
<div id="cashierMsg" class="msg"></div>
</div>

View file

@ -137,6 +137,21 @@ nav {
}
.selected-member-box strong { font-size: 1.05rem; }
/* ---- Cashier dual-action panels ---- */
.cashier-action-panel {
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
}
.cashier-action-panel h3 {
margin: 0 0 12px;
font-size: 1rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .04em;
}
/* ---- Product results ---- */
.product-results { margin-bottom: 14px; }
.product-item {