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