mirror of
https://github.com/kbenestad/ClubLedger.git
synced 2026-06-18 09:44:33 +00:00
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:
parent
7fa74963b0
commit
21b6791f4c
3 changed files with 65 additions and 48 deletions
42
main.py
42
main.py
|
|
@ -71,10 +71,11 @@ def init_db():
|
|||
with db_conn() as conn:
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
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'"
|
||||
|
|
@ -299,9 +305,10 @@ class MemberCreate(BaseModel):
|
|||
return v
|
||||
|
||||
class MemberUpdate(BaseModel):
|
||||
member_number: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
pin: Optional[str] = None
|
||||
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
|
||||
|
|
@ -309,11 +316,10 @@ class TopupRequest(BaseModel):
|
|||
note: Optional[str] = None
|
||||
|
||||
class ChargeRequest(BaseModel):
|
||||
member_id: int
|
||||
amount: int
|
||||
pin: str
|
||||
note: Optional[str] = None
|
||||
overdraft_override: bool = False
|
||||
member_id: int
|
||||
amount: int
|
||||
pin: str
|
||||
note: Optional[str] = None
|
||||
|
||||
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)})")
|
||||
|
|
|
|||
|
|
@ -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,35 +337,14 @@ function selectBarMember(id, name, number, balance, balanceDisplay) {
|
|||
`<strong>${esc(name)}</strong> #${esc(number)} 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('barOverrideCheck').checked = false;
|
||||
document.getElementById('barOverrideRow').classList.add('hidden');
|
||||
document.getElementById('barAmount').value = '';
|
||||
document.getElementById('barPin').value = '';
|
||||
document.getElementById('barNote').value = '';
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue