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,
name TEXT NOT NULL,
pin_hash TEXT NOT NULL,
overdraft_override INTEGER DEFAULT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS ledger_entries (
@ -117,6 +118,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:
# --- 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 ---
schema = conn.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='staff_accounts'"
@ -302,6 +308,7 @@ class MemberUpdate(BaseModel):
member_number: Optional[str] = None
name: Optional[str] = None
pin: Optional[str] = None
overdraft_override: Optional[int] = None # NULL=default, 1=allow, 0=block
class TopupRequest(BaseModel):
member_id: int
@ -313,7 +320,6 @@ class ChargeRequest(BaseModel):
amount: int
pin: str
note: Optional[str] = None
overdraft_override: bool = False
class WithdrawalRequest(BaseModel):
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 len(body.pin) < 4: raise HTTPException(400, "PIN must be at least 4 characters")
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:
conn.execute(
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"])
result.append({
"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"],
})
return result
@ -523,15 +532,16 @@ def charge(body: ChargeRequest, user: dict = Depends(pos_user)):
raise HTTPException(403, "Incorrect PIN")
bal = member_balance(conn, body.member_id)
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
elif policy == "staff_override":
overdraft_ok = body.overdraft_override
elif policy == "admin_override":
overdraft_ok = body.overdraft_override and user["role"] == "admin"
elif policy in ("staff_override", "admin_override"):
overdraft_ok = (member_ov == 1)
elif policy == "staff_block":
overdraft_ok = not body.overdraft_override
else: # "never" or unknown
overdraft_ok = (member_ov != 0) # None or 1 = allowed; 0 = explicitly blocked
else:
overdraft_ok = False
if not overdraft_ok and bal < body.amount:
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 class="row-actions">
<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
? `<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
function openEditModal(id, name, number) {
function openEditModal(id, name, number, overdraftOverride) {
editMemberId = id;
document.getElementById('edit-number').value = number;
document.getElementById('edit-name').value = name;
document.getElementById('edit-pin').value = '';
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('edit-name').focus();
}
@ -192,6 +210,17 @@ async function saveEdit() {
};
const pin = document.getElementById('edit-pin').value;
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 {
await apiFetch(`/members/${editMemberId}`, {
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>`;
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() {
@ -335,8 +345,6 @@ function clearBarSelection() {
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', '', '');
}
@ -347,11 +355,10 @@ 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, overdraft_override })
body: JSON.stringify({ member_id: barMember.id, amount, pin, note: note || null })
});
window.open(`/receipt/${r.entry_id}`, '_blank');
setMsg('barMsg', `Charge complete. New balance: ${r.new_balance_display}`, 'ok');

View file

@ -153,10 +153,6 @@
<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>
@ -236,6 +232,10 @@
<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">
</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">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn" onclick="closeEditModal()">Cancel</button>