Replace overdraft checkbox with 5-option policy dropdown

Policies:
- never            – not allowed (default)
- always           – allowed for all charges
- staff_override   – default no; staff sees checkbox to allow per charge
- admin_override   – default no; only admins see the allow-per-charge checkbox
- staff_block      – default yes; staff sees checkbox to block per charge

Backend:
- CONFIG: allow_negative_balance → overdraft_policy: "never"
- migrate_db(): converts old allow_negative_balance setting in app_settings
  to the equivalent overdraft_policy value on first startup after upgrade
- ChargeRequest: new optional overdraft_override: bool = False
- POST /charge: full policy logic; admin_override enforced server-side
  so a non-admin can't bypass it by sending override=true
- POST /admin/settings: validates policy value before saving

Frontend:
- Admin settings: checkbox replaced by <select> with five options
- Bar form: barOverrideRow (hidden by default); selectBarMember() shows
  it with the right label when policy is staff_override / admin_override
  (admin only) / staff_block; hidden for never and always
- clearBarSelection() resets the override checkbox and hides the row

https://claude.ai/code/session_01JuRTR5Xjx8emQsyerBgGU7
This commit is contained in:
Claude 2026-05-30 14:39:43 +00:00
parent acd8ff3fd0
commit 7fa74963b0
No known key found for this signature in database
3 changed files with 78 additions and 18 deletions

42
main.py
View file

@ -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)

View file

@ -308,14 +308,35 @@ function selectBarMember(id, name, number, balance, balanceDisplay) {
`<strong>${esc(name)}</strong> &nbsp; #${esc(number)} &nbsp; Balance: <span class="${balanceClass(balance)}">${esc(balanceDisplay)}</span>`;
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', {

View file

@ -153,6 +153,10 @@
<label>Note (optional)</label>
<input type="text" id="barNote" placeholder="">
</div>
<div id="barOverrideRow" class="form-row-check hidden">
<input type="checkbox" id="barOverrideCheck">
<label for="barOverrideCheck" id="barOverrideLabel"></label>
</div>
<button class="btn btn-danger" onclick="doCharge()">Charge</button>
<button class="btn" onclick="clearBarSelection()">Cancel</button>
</div>
@ -184,9 +188,15 @@
<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 form-row-check">
<input type="checkbox" id="s-allow-negative">
<label for="s-allow-negative">Allow negative balance (overdraft at bar)</label>
<div class="form-row">
<label>Overdraft (bar charges)</label>
<select id="s-overdraft-policy">
<option value="never">Not allowed</option>
<option value="always">Allowed for all</option>
<option value="staff_override">Default not allowed — staff may override per charge</option>
<option value="admin_override">Default not allowed — admin may override per charge</option>
<option value="staff_block">Default allowed — staff may block per charge</option>
</select>
</div>
<button type="submit" class="btn btn-primary">Save Settings</button>
</form>