Files
certain/backend/app/routers/budgets.py
T
GuidoandClaude Opus 5 0b19e07ce0 feat: Zu welchem Budget-Monat eine Buchung gehoert
Bisher entschied allein das Datum. Bei einem Budget-Monat, der nicht am
Ersten beginnt, faellt das auf: Die Zahlung geht am 24. ab und gehoert
doch zum naechsten Monat, die Rechnung wird verspaetet beglichen und
gehoert zum letzten.

Neu traegt jede Buchung einen Budget-Monat. Leer heisst weiterhin "aus dem
Datum" - der Normalfall, und er bleibt es. Wird einer von Hand gesetzt,
zaehlt die Buchung nur noch dort und in ihrem eigenen Monat NICHT mehr,
sonst stuende sie zweimal da.

Das Aendern liegt bewusst hinter einem Klick und hinter Pfeilen statt
hinter einer Auswahlliste: Es ist der Ausnahmefall, nicht der Normalfall,
und soll sich auch so anfuehlen. Waehlt man genau den Monat, der ohnehin
gilt, bleibt die Buchung automatisch - verschiebt sich das Datum spaeter,
wandert sie mit. In der Liste zeigt eine Marke den Monat, aber nur, wenn
jemand eingegriffen hat.

Der Server nimmt drei Monate in jede Richtung an. Ein Vertipper wie
2019-03 wird abgelehnt: Er liesse die Buchung aus jeder Auswertung
verschwinden, ohne dass irgendwo etwas fehlt.

Geprueft im Browser gegen eine Wegwerf-Datenbank mit Stichtag 25: Die
Karte summiert 25.08.-24.09., eine verschobene Buchung verlaesst diesen
Zeitraum und kehrt beim Zuruecknehmen zurueck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:57:16 +02:00

277 lines
9.5 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,
})
total_planned += g_planned
total_actual += g_actual
groups_out.append({
"id": g.id, "name": g.name, "icon": g.icon, "color": g.color,
"planned_cents": g_planned, "actual_cents": g_actual, "over": g_actual > g_planned,
"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)