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
-
+

@@ -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 {