Die Vertraege standen bisher nur auf ihrer eigenen Seite. Im Budget sah man
nichts davon – man musste selbst nachrechnen, was fest gebunden ist, und beim
naechsten Preisaufschlag noch einmal.
Jetzt hat jede Kategorie zwei Betriebsarten, umschaltbar ueber ein Sigma neben
dem Eingabefeld:
fest eine Zahl, die man hinschreibt. Wie bisher.
berechnet Summe der Vertraege dieser Kategorie plus einem Zuschlag.
Der Zuschlag ist der Punkt. Wer fuer Streaming 62 plant, weil 42 gebunden sind
und 20 Luft sein sollen, muss beim naechsten Preisaufschlag nicht nachrechnen
– und verliert seine 20 dabei auch nicht. Unter der Zeile steht die Rechnung
offen, sonst stuende im Feld eine Zahl, die nicht der Soll-Betrag ist.
Das gilt fuer JEDE Kategorie, nicht nur fuer eine Abo-Kategorie: Ein
Handyvertrag liegt in "Telefon & Internet", und die rechnet sich genauso.
Auch auf "fest" steht der Hinweis da ("davon 16.84 fest gebunden") – sonst
sieht man nie, was einen schon bindet.
Zwei Dinge, die sonst still danebengingen, melden sich jetzt oben auf der
Seite: Vertraege OHNE Kategorie (ihr Geld fehlt im Budget, ohne dass irgendwo
steht, dass es fehlt) und Waehrungen ohne Kurs.
Gerechnet wird in effective_planned – der einen Stelle, aus der Budget-Seite
UND Dashboard ihr Soll beziehen. Damit koennen die beiden gar nicht erst
auseinanderlaufen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
334 lines
13 KiB
Python
334 lines
13 KiB
Python
"""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 {
|
||
"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],
|
||
}
|
||
|
||
|
||
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)
|