diff --git a/main.py b/main.py index 85e445f..58e4a01 100644 --- a/main.py +++ b/main.py @@ -27,7 +27,7 @@ CONFIG = { "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 - "allow_negative_balance": False, + "overdraft_policy": "never", # never|always|staff_override|admin_override|staff_block "min_topup": 100, # minor units "max_topup": 100_000, "max_charge": 50_000, @@ -166,6 +166,18 @@ def migrate_db(): conn.execute("DROP TABLE _ledger_entries_old") conn.execute("CREATE INDEX IF NOT EXISTS idx_ledger_member ON ledger_entries(member_id)") + # --- app_settings: rename allow_negative_balance → overdraft_policy --- + row = conn.execute( + "SELECT value FROM app_settings WHERE key='allow_negative_balance'" + ).fetchone() + if row is not None: + old_val = json.loads(row[0]) + conn.execute( + "INSERT OR IGNORE INTO app_settings (key,value) VALUES (?,?)", + ("overdraft_policy", json.dumps("always" if old_val else "never")) + ) + conn.execute("DELETE FROM app_settings WHERE key='allow_negative_balance'") + def seed_admin(): with db_conn() as conn: if conn.execute("SELECT COUNT(*) FROM staff_accounts WHERE role='admin'").fetchone()[0] == 0: @@ -297,10 +309,11 @@ class TopupRequest(BaseModel): note: Optional[str] = None class ChargeRequest(BaseModel): - member_id: int - amount: int - pin: str - note: Optional[str] = None + member_id: int + amount: int + pin: str + note: Optional[str] = None + overdraft_override: bool = False class WithdrawalRequest(BaseModel): member_id: int @@ -341,7 +354,7 @@ class AppSettingsUpdate(BaseModel): currency_major: Optional[str] = None currency_minor: Optional[str] = None currency_divisor: Optional[int] = None - allow_negative_balance: Optional[bool] = None + overdraft_policy: Optional[str] = None min_topup: Optional[int] = None max_topup: Optional[int] = None max_charge: Optional[int] = None @@ -509,7 +522,18 @@ def charge(body: ChargeRequest, user: dict = Depends(pos_user)): if not verify_pin(body.pin, member["pin_hash"]): raise HTTPException(403, "Incorrect PIN") bal = member_balance(conn, body.member_id) - if not s["allow_negative_balance"] and bal < body.amount: + policy = s.get("overdraft_policy", "never") + if policy == "always": + overdraft_ok = True + elif policy == "staff_override": + overdraft_ok = body.overdraft_override + elif policy == "admin_override": + overdraft_ok = body.overdraft_override and user["role"] == "admin" + elif policy == "staff_block": + overdraft_ok = not body.overdraft_override + else: # "never" or unknown + overdraft_ok = False + if not overdraft_ok and 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 (?,?,?,?,?,?)", @@ -824,8 +848,12 @@ def delete_staff_account(account_id: int, user: dict = Depends(admin_user)): def get_admin_settings(user: dict = Depends(admin_user)): return _settings +_OVERDRAFT_POLICIES = ("never", "always", "staff_override", "admin_override", "staff_block") + @app.post("/admin/settings") def update_admin_settings(body: AppSettingsUpdate, user: dict = Depends(admin_user)): + if body.overdraft_policy is not None and body.overdraft_policy not in _OVERDRAFT_POLICIES: + raise HTTPException(400, "Invalid overdraft policy") with db_conn() as conn: for field in body.model_fields_set: val = getattr(body, field) diff --git a/static/app.js b/static/app.js index 58941b8..f938099 100644 --- a/static/app.js +++ b/static/app.js @@ -308,14 +308,35 @@ function selectBarMember(id, name, number, balance, balanceDisplay) { `${esc(name)}   #${esc(number)}   Balance: ${esc(balanceDisplay)}`; document.getElementById('barForm').classList.remove('hidden'); setMsg('barMsg', '', ''); + + const policy = cfg.overdraft_policy || 'never'; + const overrideRow = document.getElementById('barOverrideRow'); + const overrideCheck = document.getElementById('barOverrideCheck'); + const overrideLabel = document.getElementById('barOverrideLabel'); + overrideCheck.checked = false; + + if (policy === 'staff_override') { + overrideLabel.textContent = 'Allow overdraft for this transaction'; + overrideRow.classList.remove('hidden'); + } else if (policy === 'admin_override' && currentUser.role === 'admin') { + overrideLabel.textContent = 'Allow overdraft for this transaction'; + overrideRow.classList.remove('hidden'); + } else if (policy === 'staff_block') { + overrideLabel.textContent = 'Block if insufficient balance'; + overrideRow.classList.remove('hidden'); + } else { + overrideRow.classList.add('hidden'); + } } function clearBarSelection() { barMember = null; document.getElementById('barForm').classList.add('hidden'); - document.getElementById('barAmount').value = ''; - document.getElementById('barPin').value = ''; - document.getElementById('barNote').value = ''; + document.getElementById('barAmount').value = ''; + document.getElementById('barPin').value = ''; + document.getElementById('barNote').value = ''; + document.getElementById('barOverrideCheck').checked = false; + document.getElementById('barOverrideRow').classList.add('hidden'); setMsg('barMsg', '', ''); } @@ -326,10 +347,11 @@ async function doCharge() { const note = document.getElementById('barNote').value.trim(); if (!amount) { setMsg('barMsg', 'Enter a valid amount.', 'err'); return; } if (!pin) { setMsg('barMsg', 'PIN required.', 'err'); return; } + const overdraft_override = document.getElementById('barOverrideCheck').checked; try { const r = await apiFetch('/charge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ member_id: barMember.id, amount, pin, note: note || null }) + body: JSON.stringify({ member_id: barMember.id, amount, pin, note: note || null, overdraft_override }) }); window.open(`/receipt/${r.entry_id}`, '_blank'); setMsg('barMsg', `Charge complete. New balance: ${r.new_balance_display}`, 'ok'); @@ -356,8 +378,8 @@ async function loadAdminSettings() { 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-charge').value = ((s.max_charge || 0) / div).toFixed(2); - document.getElementById('s-receipt-footer').value = s.receipt_footer || ''; - document.getElementById('s-allow-negative').checked = !!s.allow_negative_balance; + document.getElementById('s-receipt-footer').value = s.receipt_footer || ''; + document.getElementById('s-overdraft-policy').value = s.overdraft_policy || 'never'; const sym = s.currency_symbol || ''; document.getElementById('s-min-hint').textContent = `in ${s.currency_major || 'major units'}`; document.getElementById('s-max-hint').textContent = `in ${s.currency_major || 'major units'}`; @@ -376,8 +398,8 @@ async function saveSettings() { min_topup: Math.round(parseFloat(document.getElementById('s-min-topup').value) * div), max_topup: Math.round(parseFloat(document.getElementById('s-max-topup').value) * div), max_charge: Math.round(parseFloat(document.getElementById('s-max-charge').value) * div), - receipt_footer: document.getElementById('s-receipt-footer').value, - allow_negative_balance: document.getElementById('s-allow-negative').checked, + receipt_footer: document.getElementById('s-receipt-footer').value, + overdraft_policy: document.getElementById('s-overdraft-policy').value, }; try { await apiFetch('/admin/settings', { diff --git a/static/index.html b/static/index.html index 0f247c0..e6223eb 100644 --- a/static/index.html +++ b/static/index.html @@ -153,6 +153,10 @@ + @@ -184,9 +188,15 @@
-
- - +
+ +