mirror of
https://github.com/kbenestad/ClubLedger.git
synced 2026-06-18 09:44:33 +00:00
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:
parent
acd8ff3fd0
commit
7fa74963b0
3 changed files with 78 additions and 18 deletions
34
main.py
34
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:
|
||||
|
|
@ -301,6 +313,7 @@ class ChargeRequest(BaseModel):
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -308,6 +308,25 @@ 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() {
|
||||
|
|
@ -316,6 +335,8 @@ 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', '', '');
|
||||
}
|
||||
|
||||
|
|
@ -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');
|
||||
|
|
@ -357,7 +379,7 @@ async function loadAdminSettings() {
|
|||
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-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'}`;
|
||||
|
|
@ -377,7 +399,7 @@ async function saveSettings() {
|
|||
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,
|
||||
overdraft_policy: document.getElementById('s-overdraft-policy').value,
|
||||
};
|
||||
try {
|
||||
await apiFetch('/admin/settings', {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue