"""Budget: Standard-Vorlage, geplante Einnahmen und die Monats-Soll/Ist-Ansicht.""" from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from ..database import get_db from ..deps import get_current_user, get_owned from ..models import ( Account, BudgetTemplateLine, Category, CategoryGroup, CategoryKind, PlannedIncomeLine, User, ) from ..schemas import PlannedIncomeCreate, PlannedIncomeOut, PlannedIncomeUpdate from ..services import budget from .accounts import base_currency from .settings import BUDGET_START_DAY_KEY, get_user_setting router = APIRouter(prefix="/budget", tags=["budget"]) def _start_day(db: Session, user: User) -> int: return budget.start_tag(db, user) def _require_expense_category(db: Session, user: User, category_id: int) -> Category: cat = get_owned(db, Category, category_id, user) if cat.type != CategoryKind.expense: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Nur Ausgabe-Kategorien sind budgetierbar") return cat # --- Standard-Vorlage ------------------------------------------------------ @router.get("/template") def get_template(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: lines = db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id).all() income = ( db.query(PlannedIncomeLine) .filter(PlannedIncomeLine.owner_id == user.id) .order_by(PlannedIncomeLine.position, PlannedIncomeLine.id) .all() ) return { "lines": [{"category_id": ln.category_id, "planned_cents": ln.planned_cents} for ln in lines], "income": [PlannedIncomeOut.model_validate(i).model_dump() for i in income], } @router.put("/template/category/{category_id}") def set_template_category( category_id: int, planned_cents: int, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> dict: _require_expense_category(db, user, category_id) line = ( db.query(BudgetTemplateLine) .filter(BudgetTemplateLine.owner_id == user.id, BudgetTemplateLine.category_id == category_id) .first() ) if planned_cents <= 0: if line is not None: db.delete(line) db.commit() return {"category_id": category_id, "planned_cents": 0} if line is None: line = BudgetTemplateLine(owner_id=user.id, category_id=category_id, planned_cents=planned_cents) db.add(line) else: line.planned_cents = planned_cents db.commit() return {"category_id": category_id, "planned_cents": planned_cents} # --- Geplante Einnahmen ---------------------------------------------------- @router.post("/income", response_model=PlannedIncomeOut, status_code=status.HTTP_201_CREATED) def create_income( payload: PlannedIncomeCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user) ): if payload.category_id is not None: get_owned(db, Category, payload.category_id, user) if payload.target_account_id is not None: get_owned(db, Account, payload.target_account_id, user) max_pos = ( db.query(PlannedIncomeLine.position) .filter(PlannedIncomeLine.owner_id == user.id) .order_by(PlannedIncomeLine.position.desc()) .first() ) line = PlannedIncomeLine( owner_id=user.id, label=payload.label.strip(), category_id=payload.category_id, target_account_id=payload.target_account_id, planned_cents=payload.planned_cents, position=(max_pos[0] + 1) if max_pos else 0, ) db.add(line) db.commit() db.refresh(line) return line @router.patch("/income/{income_id}", response_model=PlannedIncomeOut) def update_income( income_id: int, payload: PlannedIncomeUpdate, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): line = get_owned(db, PlannedIncomeLine, income_id, user) data = payload.model_dump(exclude_unset=True) if "category_id" in data and data["category_id"] is not None: get_owned(db, Category, data["category_id"], user) if "target_account_id" in data and data["target_account_id"] is not None: get_owned(db, Account, data["target_account_id"], user) for key, value in data.items(): setattr(line, key, value) db.commit() db.refresh(line) return line @router.delete("/income/{income_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_income( income_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user) ) -> None: line = get_owned(db, PlannedIncomeLine, income_id, user) db.delete(line) db.commit() # --- Soll/Ist-Ansicht ------------------------------------------------------ def _build_view(db: Session, user: User, label: str) -> dict: base = base_currency(db, user) start_day = _start_day(db, user) start, end = budget.period_bounds(label, start_day) planned = budget.effective_planned(db, user, label) actual = budget.actual_expense_by_category(db, user, label, start, end, base) income_actual = budget.income_by_category(db, user, label, start, end, base) groups_out = [] total_planned = total_actual = 0 groups = ( db.query(CategoryGroup) .filter(CategoryGroup.owner_id == user.id, CategoryGroup.kind == CategoryKind.expense) .order_by(CategoryGroup.position, CategoryGroup.name) .all() ) for g in groups: cats = [] g_planned = g_actual = 0 for c in sorted(g.categories, key=lambda x: (x.position, x.name.lower())): if c.is_archived: continue p = planned.get(c.id, 0) a = actual.get(c.id, 0) g_planned += p g_actual += a cats.append({ "category_id": c.id, "name": c.name, "icon": c.icon, "color": c.color, "planned_cents": p, "actual_cents": a, "delta_cents": p - a, "over": a > p, # "over" bleibt die nackte Tatsache; ob sie eine Warnung IST, # entscheidet die Kategorie. Wer mehr zuruecklegt als geplant, # hat nichts ueberzogen. "good": c.spending_is_good, }) total_planned += g_planned total_actual += g_actual # Ueberschritten ist eine Gruppe an dem, was WIRKLICH zu viel war. Das # Sparen bleibt draussen: Sonst faerbt sich "Finanzen" rot, weil jemand # mehr zurueckgelegt hat als geplant – eine Warnung fuer etwas Gutes, # und daneben stuende die echte Ueberschreitung ununterscheidbar. # Angezeigt werden weiter die vollen Summen; nur das Urteil laesst das # Sparen aus. echt_geplant = sum(c["planned_cents"] for c in cats if not c["good"]) echt_ist = sum(c["actual_cents"] for c in cats if not c["good"]) groups_out.append({ "id": g.id, "name": g.name, "icon": g.icon, "color": g.color, "planned_cents": g_planned, "actual_cents": g_actual, "over": echt_ist > echt_geplant, "good": bool(cats) and all(c["good"] for c in cats), "categories": cats, }) income_lines = ( db.query(PlannedIncomeLine) .filter(PlannedIncomeLine.owner_id == user.id) .order_by(PlannedIncomeLine.position, PlannedIncomeLine.id) .all() ) income_out = [] total_planned_income = total_received_income = 0 for ln in income_lines: received = income_actual.get(ln.category_id, 0) if ln.category_id else 0 total_planned_income += ln.planned_cents total_received_income += received income_out.append({ "id": ln.id, "label": ln.label, "category_id": ln.category_id, "target_account_id": ln.target_account_id, "planned_cents": ln.planned_cents, "received_cents": received, }) return { "label": label, "start": start.isoformat(), "end": end.isoformat(), "customized": budget.is_customized(db, user, label), "base_currency": base, "prev": budget.shift_label(label, -1), "next": budget.shift_label(label, 1), "groups": groups_out, "income": income_out, "totals": { "planned_expense": total_planned, "actual_expense": total_actual, "planned_income": total_planned_income, "received_income": total_received_income, }, } @router.get("/current") def current(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: return _build_view(db, user, budget.current_label(_start_day(db, user))) @router.get("/{label}") def view(label: str, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: try: budget.parse_label(label) except (ValueError, IndexError): raise HTTPException(status.HTTP_400_BAD_REQUEST, "Ungültiges Monats-Label (YYYY-MM)") return _build_view(db, user, label) @router.put("/{label}/category/{category_id}") def set_month_category( label: str, category_id: int, planned_cents: int, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> dict: budget.parse_label(label) _require_expense_category(db, user, category_id) budget.set_month_category(db, user, label, category_id, max(0, planned_cents)) return _build_view(db, user, label) @router.delete("/{label}") def reset_month( label: str, db: Session = Depends(get_db), user: User = Depends(get_current_user) ) -> dict: budget.parse_label(label) budget.reset_month(db, user, label) return _build_view(db, user, label) # --- Kopieren Monat <-> Vorlage -------------------------------------------- @router.post("/template/from-month/{label}") def template_from_month( label: str, db: Session = Depends(get_db), user: User = Depends(get_current_user) ) -> dict: """„Diesen Monat als Standard festlegen": Monat -> Vorlage.""" budget.parse_label(label) budget.copy_month_to_template(db, user, label) return get_template(db, user) @router.post("/{label}/from-template") def month_from_template( label: str, db: Session = Depends(get_db), user: User = Depends(get_current_user) ) -> dict: """„Standard in diesen Monat übernehmen": Vorlage -> Monat (überschreibt).""" budget.parse_label(label) budget.copy_template_to_month(db, user, label) return _build_view(db, user, label)