diff --git a/bincio/serve/routers/budget.py b/bincio/serve/routers/budget.py index 577f768..994dcfe 100644 --- a/bincio/serve/routers/budget.py +++ b/bincio/serve/routers/budget.py @@ -3,6 +3,7 @@ from __future__ import annotations import json import uuid +from datetime import date from pathlib import Path from fastapi import APIRouter, Cookie, Depends, HTTPException, Request @@ -36,9 +37,44 @@ def _save(data: dict) -> None: ) +def _materialise_recurring(data: dict) -> bool: + """Auto-add a copy of each recurring entry for the current month if absent. + + Returns True if data was modified (caller should save). + Copies reference the template via recurring_from so they're not re-generated. + """ + current = date.today().strftime("%Y-%m") + templates = [e for e in data.get("entries", []) if e.get("recurring")] + if not templates: + return False + modified = False + for t in templates: + if t["month"] == current: + continue # template is already this month + already = any( + e.get("recurring_from") == t["id"] and e["month"] == current + for e in data["entries"] + ) + if not already: + data["entries"].append({ + "id": str(uuid.uuid4())[:8], + "type": t["type"], + "label": t["label"], + "amount_eur": t["amount_eur"], + "month": current, + "note": t.get("note", ""), + "recurring_from": t["id"], + }) + modified = True + return modified + + @router.get("/api/budget") async def get_budget() -> JSONResponse: - return JSONResponse(_load()) + data = _load() + if _materialise_recurring(data): + _save(data) + return JSONResponse(data) @router.post("/api/budget/settings") @@ -76,7 +112,7 @@ async def add_entry( raise HTTPException(400, "month must be YYYY-MM") note = str(body.get("note", "")).strip() - entry = { + entry: dict = { "id": str(uuid.uuid4())[:8], "type": entry_type, "label": label, @@ -84,6 +120,8 @@ async def add_entry( "month": month, "note": note, } + if body.get("recurring"): + entry["recurring"] = True data = _load() data.setdefault("entries", []).append(entry) _save(data) @@ -113,6 +151,11 @@ async def update_entry( entry["month"] = str(body["month"]).strip() if "note" in body: entry["note"] = str(body["note"]).strip() + if "recurring" in body: + if body["recurring"]: + entry["recurring"] = True + else: + entry.pop("recurring", None) _save(data) return JSONResponse(entry) diff --git a/site/src/pages/support/index.astro b/site/src/pages/support/index.astro index e17fb16..42eec91 100644 --- a/site/src/pages/support/index.astro +++ b/site/src/pages/support/index.astro @@ -23,12 +23,17 @@ const baseUrl = import.meta.env.BASE_URL ?? '/'; style="background:#FF5E5B; color:#fff;"> ☕ Support on Ko-fi - - Satispay @brutsalvadi - +
+ + Satispay @brutsalvadi + + + preferred + +
Satispay QR code — @brutsalvadi @@ -84,6 +89,10 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
+
@@ -410,9 +419,14 @@ function renderBudget() { html += `
`; for (const e of items) { - const icon = e.type === 'donation' ? '💚' : '🔴'; - const sign = e.type === 'donation' ? '+' : '−'; - const color = e.type === 'donation' ? 'text-green-400' : 'text-red-400'; + const icon = e.type === 'donation' ? '💚' : '🔴'; + const sign = e.type === 'donation' ? '+' : '−'; + const color = e.type === 'donation' ? 'text-green-400' : 'text-red-400'; + const recurBadge = e.recurring + ? `` + : e.recurring_from + ? `` + : ''; const adminBtns = isAdmin ? ` ` @@ -422,6 +436,7 @@ function renderBudget() { ${e.label}${e.note ? `${e.note}` : ''} + ${recurBadge} ${sign}${fmtEur(e.amount_eur)} ${adminBtns}
`; @@ -464,6 +479,9 @@ function renderBudget() { class="px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none [color-scheme:dark]" /> + `; @@ -471,7 +489,7 @@ function renderBudget() { row.querySelector('.inline-edit-form')!.addEventListener('submit', async ev => { ev.preventDefault(); const fd = new FormData(ev.target as HTMLFormElement); - const body = { type: fd.get('type'), label: fd.get('label'), amount_eur: parseFloat(fd.get('amount_eur') as string), month: fd.get('month'), note: fd.get('note') }; + const body = { type: fd.get('type'), label: fd.get('label'), amount_eur: parseFloat(fd.get('amount_eur') as string), month: fd.get('month'), note: fd.get('note'), recurring: fd.get('recurring') === 'on' }; const r = await fetch(`/api/budget/entries/${id}`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (r.ok) { const updated = await r.json(); const idx = budgetData!.entries.findIndex(e => e.id === id); if (idx !== -1) budgetData!.entries[idx] = updated; renderBudget(); } }); @@ -533,11 +551,13 @@ addForm.addEventListener('submit', async e => { const note = (document.getElementById('entry-note') as HTMLInputElement).value.trim(); if (!label) { addErr.textContent = 'Label is required.'; addErr.classList.remove('hidden'); return; } if (isNaN(amount)) { addErr.textContent = 'Amount is required.'; addErr.classList.remove('hidden'); return; } - const r = await fetch('/api/budget/entries', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: addEntryType, label, amount_eur: amount, month, note }) }); + const recurring = (document.getElementById('entry-recurring') as HTMLInputElement).checked; + const r = await fetch('/api/budget/entries', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: addEntryType, label, amount_eur: amount, month, note, recurring: recurring || undefined }) }); if (r.ok) { budgetData!.entries.push(await r.json()); addForm.classList.add('hidden'); addForm.reset(); (document.getElementById('entry-month') as HTMLInputElement).value = defaultMonth; + (document.getElementById('entry-recurring') as HTMLInputElement).checked = false; (addForm.querySelector('.entry-type-btn[data-type="donation"]') as HTMLButtonElement).click(); renderBudget(); } else {