Files
certain/backend/app/routers/budgets.py
T
ScarriffleandClaude Opus 5 ff621984dc feat: Eine Ausgabe, die kein Verlust ist
Sparen zaehlt im Budget wie eine Ausgabe – zu Recht, das Geld ist diesen Monat
gebunden. Es stand danach aber in Rot da, und wer tausend zuruecklegte statt
achthundert, bekam eine Warnung fuer etwas Gutes.

Ein Haken an der Kategorie stellt das ab. Zwei Folgen:

Der Betrag steht neutral. NICHT gruen: Gruen heisst in Certain ueberall "Geld
kommt rein", und CHF 1'010 in Gruen liest sich als Einnahme. Grau sagt genau
das Richtige – kein Verlust, kein Gewinn.

Und mehr als geplant ist keine Ueberschreitung mehr. Das gilt auch eine Ebene
hoeher: Eine Gruppe misst sich an dem, was WIRKLICH zu viel war. Sonst faerbt
sich "Finanzen" rot, weil jemand gespart hat, und die echte Ueberschreitung
daneben faellt nicht mehr auf. Angezeigt werden weiter die vollen Summen; nur
das Urteil laesst das Sparen aus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:28:36 +02:00

291 lines
10 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 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)