175 lines
5.2 KiB
Python
175 lines
5.2 KiB
Python
"""Budget transparency endpoints (/api/budget)."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import uuid
|
|
from datetime import date
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from bincio.serve import deps
|
|
from bincio.serve.db import User
|
|
|
|
router = APIRouter()
|
|
|
|
_BUDGET_FILE = "budget.json"
|
|
|
|
|
|
def _budget_path() -> Path:
|
|
return deps._get_data_dir() / _BUDGET_FILE
|
|
|
|
|
|
def _load() -> dict:
|
|
p = _budget_path()
|
|
if not p.exists():
|
|
return {"monthly_target_eur": None, "entries": []}
|
|
try:
|
|
return json.loads(p.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, OSError):
|
|
return {"monthly_target_eur": None, "entries": []}
|
|
|
|
|
|
def _save(data: dict) -> None:
|
|
_budget_path().write_text(
|
|
json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
)
|
|
|
|
|
|
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:
|
|
data = _load()
|
|
if _materialise_recurring(data):
|
|
_save(data)
|
|
return JSONResponse(data)
|
|
|
|
|
|
@router.post("/api/budget/settings")
|
|
async def update_settings(
|
|
request: Request,
|
|
_: User = Depends(deps._require_admin),
|
|
) -> JSONResponse:
|
|
body = await request.json()
|
|
data = _load()
|
|
if "monthly_target_eur" in body:
|
|
v = body["monthly_target_eur"]
|
|
data["monthly_target_eur"] = round(float(v), 2) if v is not None else None
|
|
_save(data)
|
|
return JSONResponse({"ok": True})
|
|
|
|
|
|
@router.post("/api/budget/entries")
|
|
async def add_entry(
|
|
request: Request,
|
|
_: User = Depends(deps._require_admin),
|
|
) -> JSONResponse:
|
|
body = await request.json()
|
|
entry_type = body.get("type")
|
|
if entry_type not in ("donation", "expense"):
|
|
raise HTTPException(400, "type must be 'donation' or 'expense'")
|
|
label = str(body.get("label", "")).strip()
|
|
if not label:
|
|
raise HTTPException(400, "label is required")
|
|
try:
|
|
amount = round(float(body["amount_eur"]), 2)
|
|
except (KeyError, TypeError, ValueError):
|
|
raise HTTPException(400, "amount_eur must be a number")
|
|
month = str(body.get("month", "")).strip()
|
|
if len(month) != 7 or month[4] != "-":
|
|
raise HTTPException(400, "month must be YYYY-MM")
|
|
note = str(body.get("note", "")).strip()
|
|
|
|
entry: dict = {
|
|
"id": str(uuid.uuid4())[:8],
|
|
"type": entry_type,
|
|
"label": label,
|
|
"amount_eur": amount,
|
|
"month": month,
|
|
"note": note,
|
|
}
|
|
if body.get("recurring"):
|
|
entry["recurring"] = True
|
|
data = _load()
|
|
data.setdefault("entries", []).append(entry)
|
|
_save(data)
|
|
return JSONResponse(entry, status_code=201)
|
|
|
|
|
|
@router.patch("/api/budget/entries/{entry_id}")
|
|
async def update_entry(
|
|
entry_id: str,
|
|
request: Request,
|
|
_: User = Depends(deps._require_admin),
|
|
) -> JSONResponse:
|
|
body = await request.json()
|
|
data = _load()
|
|
entry = next((e for e in data.get("entries", []) if e["id"] == entry_id), None)
|
|
if not entry:
|
|
raise HTTPException(404, "Entry not found")
|
|
if "label" in body:
|
|
entry["label"] = str(body["label"]).strip()
|
|
if "type" in body:
|
|
if body["type"] not in ("donation", "expense"):
|
|
raise HTTPException(400, "type must be 'donation' or 'expense'")
|
|
entry["type"] = body["type"]
|
|
if "amount_eur" in body:
|
|
entry["amount_eur"] = round(float(body["amount_eur"]), 2)
|
|
if "month" in body:
|
|
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)
|
|
|
|
|
|
@router.delete("/api/budget/entries/{entry_id}")
|
|
async def delete_entry(
|
|
entry_id: str,
|
|
_: User = Depends(deps._require_admin),
|
|
) -> JSONResponse:
|
|
data = _load()
|
|
before = len(data.get("entries", []))
|
|
data["entries"] = [e for e in data.get("entries", []) if e["id"] != entry_id]
|
|
if len(data["entries"]) == before:
|
|
raise HTTPException(404, "Entry not found")
|
|
_save(data)
|
|
return JSONResponse({"ok": True})
|