Files
certain/backend/app/routers/budgets.py
T
GuidoandClaude Opus 5 82edc29dfe fix: Die Budget-Vorlage nennt wieder Betriebsart und Zuschlag
GET /api/budget/template gab je Zeile nur planned_cents heraus, obwohl das
Modell modus und zuschlag_cents fuehrt und PUT sie zurueckgibt. Wer die
Vorlage so liest, haelt eine berechnete Zeile fuer eine feste - und schreibt
sie beim naechsten Speichern auch als feste zurueck.

Die Weboberflaeche traf es nicht, weil sie die Betriebsart aus der
Monatsansicht nimmt. Die iOS-App bietet beide Richtungen an und haette den
Fehler sichtbar gemacht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 20:10:21 +02:00

346 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 abos, 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 {
# Betriebsart und Zuschlag gehören dazu: PUT gibt sie zurück und die
# Monatsansicht liefert sie ohnehin. Ohne sie zeigt ein Client eine
# berechnete Zeile als feste an und schreibt sie beim nächsten
# Speichern auch als feste zurück.
"lines": [
{
"category_id": ln.category_id,
"planned_cents": ln.planned_cents,
"modus": ln.modus,
"zuschlag_cents": ln.zuschlag_cents,
}
for ln in lines
],
"income": [PlannedIncomeOut.model_validate(i).model_dump() for i in income],
}
MODI = ("fest", "berechnet")
def _pruefe_modus(modus: str | None) -> None:
if modus is not None and modus not in MODI:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unbekannte Betriebsart '{modus}'. Moeglich: {', '.join(MODI)}.",
)
@router.put("/template/category/{category_id}")
def set_template_category(
category_id: int,
planned_cents: int | None = None,
modus: str | None = None,
zuschlag_cents: int | None = None,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict:
_require_expense_category(db, user, category_id)
_pruefe_modus(modus)
line = (
db.query(BudgetTemplateLine)
.filter(BudgetTemplateLine.owner_id == user.id, BudgetTemplateLine.category_id == category_id)
.first()
)
ziel_modus = modus or (line.modus if line is not None else "fest")
# Eine Zeile ohne Inhalt wird geloescht – sie ist dasselbe wie "kein
# Budget". Bei "berechnet" gilt das NICHT: Dort ist die Zeile selbst die
# Aussage ("rechne aus den Vertraegen"), auch wenn der Zuschlag null ist.
leer = ziel_modus == "fest" and (planned_cents is not None and planned_cents <= 0)
if leer:
if line is not None:
db.delete(line)
db.commit()
return {"category_id": category_id, "planned_cents": 0, "modus": "fest",
"zuschlag_cents": 0}
if line is None:
line = BudgetTemplateLine(owner_id=user.id, category_id=category_id, planned_cents=0)
db.add(line)
budget.uebernimm(line, planned_cents, modus, zuschlag_cents)
db.commit()
return {"category_id": category_id, "planned_cents": line.planned_cents,
"modus": line.modus, "zuschlag_cents": line.zuschlag_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)
# Was aus Vertraegen kommt – auch fuer Kategorien auf "fest". Dort ist es
# kein Rechenwert, sondern ein Hinweis: "hier sind 43.89 gebunden".
vertraege = abos.uebersicht(db, user, base)
zeilen = {ln.category_id: ln for ln in budget._zeilen(db, user, label)}
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
zeile = zeilen.get(c.id)
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,
# Woraus sich der Soll-Betrag ergibt. "aus_vertraegen_cents"
# steht auch bei "fest" – dann als Hinweis, nicht als Summand.
"modus": zeile.modus if zeile is not None else "fest",
"zuschlag_cents": zeile.zuschlag_cents if zeile is not None else 0,
"aus_vertraegen_cents": vertraege["je_kategorie"].get(c.id, 0),
"vertraege_anzahl": vertraege["anzahl"].get(c.id, 0),
# "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),
"aus_vertraegen_cents": sum(c["aus_vertraegen_cents"] 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,
# Was sich NICHT zuordnen liess. Ohne diese zwei Angaben fehlt das Geld
# im Budget, ohne dass irgendwo steht, dass es fehlt.
"vertraege": {
"ohne_kategorie_cents": vertraege["ohne_kategorie_cents"],
"ohne_kategorie_anzahl": vertraege["ohne_kategorie_anzahl"],
"ohne_kurs": vertraege["ohne_kurs"],
},
"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 | None = None,
modus: str | None = None,
zuschlag_cents: int | None = None,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict:
budget.parse_label(label)
_require_expense_category(db, user, category_id)
_pruefe_modus(modus)
budget.set_month_category(db, user, label, category_id, planned_cents, modus,
zuschlag_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)