Move overdraft override to per-member setting on member record

Instead of a per-transaction checkbox at point of sale, the overdraft
override is now a persistent flag on each member, set via the Edit Member
modal by the appropriate role.

Schema:
- members.overdraft_override INTEGER DEFAULT NULL
  (NULL = follow global policy, 1 = explicitly allowed, 0 = explicitly blocked)
- migrate_db(): ALTER TABLE members ADD COLUMN ... for existing databases

Charge logic (combining global policy + member flag):
- never:          always block (member flag ignored)
- always:         always allow (member flag ignored)
- staff_override: allow only if member flag = 1
- admin_override: allow only if member flag = 1 (only admin can set it via UI)
- staff_block:    allow unless member flag = 0 (explicitly blocked)

Edit Member modal:
- Shows "Allow overdraft for this member" for staff_override / admin_override
  (admin_override hidden from non-admins)
- Shows "Block overdraft for this member" for staff_block
- Hidden for never / always policies
- Checkbox state pre-populated from current member.overdraft_override value

Removed the per-transaction barOverrideRow that was added in the previous
commit — it has been superseded by this per-member approach.

https://claude.ai/code/session_01JuRTR5Xjx8emQsyerBgGU7
This commit is contained in:
Claude 2026-05-30 15:08:19 +00:00
parent 7fa74963b0
commit 21b6791f4c
No known key found for this signature in database
3 changed files with 65 additions and 48 deletions

26
main.py
View file

@ -75,6 +75,7 @@ def init_db():
member_number TEXT UNIQUE NOT NULL, member_number TEXT UNIQUE NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
pin_hash TEXT NOT NULL, pin_hash TEXT NOT NULL,
overdraft_override INTEGER DEFAULT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
CREATE TABLE IF NOT EXISTS ledger_entries ( CREATE TABLE IF NOT EXISTS ledger_entries (
@ -117,6 +118,11 @@ def init_db():
def migrate_db(): def migrate_db():
"""Run schema migrations that can't be expressed as CREATE TABLE IF NOT EXISTS.""" """Run schema migrations that can't be expressed as CREATE TABLE IF NOT EXISTS."""
with db_conn() as conn: with db_conn() as conn:
# --- members: add overdraft_override column ---
cols = [r[1] for r in conn.execute("PRAGMA table_info(members)").fetchall()]
if "overdraft_override" not in cols:
conn.execute("ALTER TABLE members ADD COLUMN overdraft_override INTEGER DEFAULT NULL")
# --- staff_accounts: add cashier/pos-staff roles --- # --- staff_accounts: add cashier/pos-staff roles ---
schema = conn.execute( schema = conn.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='staff_accounts'" "SELECT sql FROM sqlite_master WHERE type='table' AND name='staff_accounts'"
@ -302,6 +308,7 @@ class MemberUpdate(BaseModel):
member_number: Optional[str] = None member_number: Optional[str] = None
name: Optional[str] = None name: Optional[str] = None
pin: Optional[str] = None pin: Optional[str] = None
overdraft_override: Optional[int] = None # NULL=default, 1=allow, 0=block
class TopupRequest(BaseModel): class TopupRequest(BaseModel):
member_id: int member_id: int
@ -313,7 +320,6 @@ class ChargeRequest(BaseModel):
amount: int amount: int
pin: str pin: str
note: Optional[str] = None note: Optional[str] = None
overdraft_override: bool = False
class WithdrawalRequest(BaseModel): class WithdrawalRequest(BaseModel):
member_id: int member_id: int
@ -450,6 +456,8 @@ def update_member(member_id: int, body: MemberUpdate, user: dict = Depends(curre
if body.pin is not None: if body.pin is not None:
if len(body.pin) < 4: raise HTTPException(400, "PIN must be at least 4 characters") if len(body.pin) < 4: raise HTTPException(400, "PIN must be at least 4 characters")
updates["pin_hash"] = hash_pin(body.pin) updates["pin_hash"] = hash_pin(body.pin)
if "overdraft_override" in body.model_fields_set:
updates["overdraft_override"] = body.overdraft_override # None, 0, or 1
if updates: if updates:
conn.execute( conn.execute(
f"UPDATE members SET {', '.join(f'{k}=?' for k in updates)} WHERE id=?", f"UPDATE members SET {', '.join(f'{k}=?' for k in updates)} WHERE id=?",
@ -486,6 +494,7 @@ def list_members(q: Optional[str] = None, user: dict = Depends(current_user)):
bal = member_balance(conn, r["id"]) bal = member_balance(conn, r["id"])
result.append({ result.append({
"id": r["id"], "member_number": r["member_number"], "name": r["name"], "id": r["id"], "member_number": r["member_number"], "name": r["name"],
"overdraft_override": r["overdraft_override"],
"balance": bal, "balance_display": format_amount(bal), "created_at": r["created_at"], "balance": bal, "balance_display": format_amount(bal), "created_at": r["created_at"],
}) })
return result return result
@ -523,15 +532,16 @@ def charge(body: ChargeRequest, user: dict = Depends(pos_user)):
raise HTTPException(403, "Incorrect PIN") raise HTTPException(403, "Incorrect PIN")
bal = member_balance(conn, body.member_id) bal = member_balance(conn, body.member_id)
policy = s.get("overdraft_policy", "never") policy = s.get("overdraft_policy", "never")
if policy == "always": member_ov = member["overdraft_override"] # None, 0, or 1
if policy == "never":
overdraft_ok = False
elif policy == "always":
overdraft_ok = True overdraft_ok = True
elif policy == "staff_override": elif policy in ("staff_override", "admin_override"):
overdraft_ok = body.overdraft_override overdraft_ok = (member_ov == 1)
elif policy == "admin_override":
overdraft_ok = body.overdraft_override and user["role"] == "admin"
elif policy == "staff_block": elif policy == "staff_block":
overdraft_ok = not body.overdraft_override overdraft_ok = (member_ov != 0) # None or 1 = allowed; 0 = explicitly blocked
else: # "never" or unknown else:
overdraft_ok = False overdraft_ok = False
if not overdraft_ok and bal < body.amount: if not overdraft_ok and bal < body.amount:
raise HTTPException(400, f"Insufficient balance ({format_amount(bal)})") raise HTTPException(400, f"Insufficient balance ({format_amount(bal)})")

View file

@ -160,7 +160,7 @@ function renderMemberTable(members) {
<td>${m.created_at ? m.created_at.slice(0, 10) : ''}</td> <td>${m.created_at ? m.created_at.slice(0, 10) : ''}</td>
<td class="row-actions"> <td class="row-actions">
<a href="/members/${m.id}/statement" target="_blank" class="btn row-btn">Statement</a> <a href="/members/${m.id}/statement" target="_blank" class="btn row-btn">Statement</a>
<button class="btn row-btn" onclick="openEditModal(${m.id},'${esc(m.name)}','${esc(m.member_number)}')">Edit</button> <button class="btn row-btn" onclick="openEditModal(${m.id},'${esc(m.name)}','${esc(m.member_number)}',${JSON.stringify(m.overdraft_override)})">Edit</button>
${m.balance === 0 ${m.balance === 0
? `<button class="btn btn-danger row-btn" onclick="deleteMember(${m.id},'${esc(m.name)}')">Delete</button>` ? `<button class="btn btn-danger row-btn" onclick="deleteMember(${m.id},'${esc(m.name)}')">Delete</button>`
: ''} : ''}
@ -169,12 +169,30 @@ function renderMemberTable(members) {
} }
// Edit member modal // Edit member modal
function openEditModal(id, name, number) { function openEditModal(id, name, number, overdraftOverride) {
editMemberId = id; editMemberId = id;
document.getElementById('edit-number').value = number; document.getElementById('edit-number').value = number;
document.getElementById('edit-name').value = name; document.getElementById('edit-name').value = name;
document.getElementById('edit-pin').value = ''; document.getElementById('edit-pin').value = '';
setMsg('editMsg', '', ''); setMsg('editMsg', '', '');
const policy = cfg.overdraft_policy || 'never';
const overrideRow = document.getElementById('editOverdraftRow');
const overrideCheck = document.getElementById('edit-overdraft');
const overrideLabel = document.getElementById('editOverdraftLabel');
if (policy === 'staff_override' || (policy === 'admin_override' && currentUser.role === 'admin')) {
overrideLabel.textContent = 'Allow overdraft for this member';
overrideCheck.checked = (overdraftOverride === 1);
overrideRow.classList.remove('hidden');
} else if (policy === 'staff_block') {
overrideLabel.textContent = 'Block overdraft for this member';
overrideCheck.checked = (overdraftOverride === 0);
overrideRow.classList.remove('hidden');
} else {
overrideRow.classList.add('hidden');
}
document.getElementById('editModal').classList.remove('hidden'); document.getElementById('editModal').classList.remove('hidden');
document.getElementById('edit-name').focus(); document.getElementById('edit-name').focus();
} }
@ -192,6 +210,17 @@ async function saveEdit() {
}; };
const pin = document.getElementById('edit-pin').value; const pin = document.getElementById('edit-pin').value;
if (pin) body.pin = pin; if (pin) body.pin = pin;
const policy = cfg.overdraft_policy || 'never';
const overrideRow = document.getElementById('editOverdraftRow');
if (!overrideRow.classList.contains('hidden')) {
const checked = document.getElementById('edit-overdraft').checked;
if (policy === 'staff_block') {
body.overdraft_override = checked ? 0 : null;
} else {
body.overdraft_override = checked ? 1 : null;
}
}
try { try {
await apiFetch(`/members/${editMemberId}`, { await apiFetch(`/members/${editMemberId}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, method: 'PUT', headers: { 'Content-Type': 'application/json' },
@ -308,25 +337,6 @@ function selectBarMember(id, name, number, balance, balanceDisplay) {
`<strong>${esc(name)}</strong> &nbsp; #${esc(number)} &nbsp; Balance: <span class="${balanceClass(balance)}">${esc(balanceDisplay)}</span>`; `<strong>${esc(name)}</strong> &nbsp; #${esc(number)} &nbsp; Balance: <span class="${balanceClass(balance)}">${esc(balanceDisplay)}</span>`;
document.getElementById('barForm').classList.remove('hidden'); document.getElementById('barForm').classList.remove('hidden');
setMsg('barMsg', '', ''); 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() { function clearBarSelection() {
@ -335,8 +345,6 @@ function clearBarSelection() {
document.getElementById('barAmount').value = ''; document.getElementById('barAmount').value = '';
document.getElementById('barPin').value = ''; document.getElementById('barPin').value = '';
document.getElementById('barNote').value = ''; document.getElementById('barNote').value = '';
document.getElementById('barOverrideCheck').checked = false;
document.getElementById('barOverrideRow').classList.add('hidden');
setMsg('barMsg', '', ''); setMsg('barMsg', '', '');
} }
@ -347,11 +355,10 @@ async function doCharge() {
const note = document.getElementById('barNote').value.trim(); const note = document.getElementById('barNote').value.trim();
if (!amount) { setMsg('barMsg', 'Enter a valid amount.', 'err'); return; } if (!amount) { setMsg('barMsg', 'Enter a valid amount.', 'err'); return; }
if (!pin) { setMsg('barMsg', 'PIN required.', 'err'); return; } if (!pin) { setMsg('barMsg', 'PIN required.', 'err'); return; }
const overdraft_override = document.getElementById('barOverrideCheck').checked;
try { try {
const r = await apiFetch('/charge', { const r = await apiFetch('/charge', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ member_id: barMember.id, amount, pin, note: note || null, overdraft_override }) body: JSON.stringify({ member_id: barMember.id, amount, pin, note: note || null })
}); });
window.open(`/receipt/${r.entry_id}`, '_blank'); window.open(`/receipt/${r.entry_id}`, '_blank');
setMsg('barMsg', `Charge complete. New balance: ${r.new_balance_display}`, 'ok'); setMsg('barMsg', `Charge complete. New balance: ${r.new_balance_display}`, 'ok');

View file

@ -153,10 +153,6 @@
<label>Note (optional)</label> <label>Note (optional)</label>
<input type="text" id="barNote" placeholder=""> <input type="text" id="barNote" placeholder="">
</div> </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 btn-danger" onclick="doCharge()">Charge</button>
<button class="btn" onclick="clearBarSelection()">Cancel</button> <button class="btn" onclick="clearBarSelection()">Cancel</button>
</div> </div>
@ -236,6 +232,10 @@
<label>New PIN <span class="label-hint">(leave blank to keep current)</span></label> <label>New PIN <span class="label-hint">(leave blank to keep current)</span></label>
<input type="password" id="edit-pin" placeholder="Leave blank to keep"> <input type="password" id="edit-pin" placeholder="Leave blank to keep">
</div> </div>
<div id="editOverdraftRow" class="form-row-check hidden">
<input type="checkbox" id="edit-overdraft">
<label for="edit-overdraft" id="editOverdraftLabel"></label>
</div>
<div class="modal-actions"> <div class="modal-actions">
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn" onclick="closeEditModal()">Cancel</button> <button type="button" class="btn" onclick="closeEditModal()">Cancel</button>