mirror of
https://github.com/kbenestad/ClubLedger.git
synced 2026-06-18 09:44:33 +00:00
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:
parent
68a35e5bff
commit
acd8ff3fd0
4 changed files with 133 additions and 17 deletions
55
main.py
55
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"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
|
||||
|
|
|
|||
|
|
@ -231,14 +231,21 @@ function selectCashierMember(id, name, number, balance, balanceDisplay) {
|
|||
document.getElementById('cashierSelected').innerHTML =
|
||||
`<strong>${esc(name)}</strong> #${esc(number)} Balance: <span class="${balanceClass(balance)}">${esc(balanceDisplay)}</span>`;
|
||||
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'); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -90,16 +90,40 @@
|
|||
|
||||
<div id="cashierForm" class="hidden">
|
||||
<div class="selected-member-box" id="cashierSelected"></div>
|
||||
<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">
|
||||
|
||||
<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">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Note (optional)</label>
|
||||
<input type="text" id="cashierNote" placeholder="">
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="doTopup()">Top Up</button>
|
||||
<div id="cashierTopupMsg" class="msg"></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Note (optional)</label>
|
||||
<input type="text" id="cashierNote" placeholder="">
|
||||
|
||||
<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 btn-primary" onclick="doTopup()">Top Up</button>
|
||||
<button class="btn" onclick="clearCashierSelection()">Cancel</button>
|
||||
|
||||
<button class="btn" onclick="clearCashierSelection()" style="margin-top:8px">Cancel</button>
|
||||
</div>
|
||||
<div id="cashierMsg" class="msg"></div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue