Files
bin/finance/app/routers/planning.py
wlfb cf9282c716 fix: Final-Review-Findings (GUI-Login, FK-Kaskaden, Validierung, Duplikat-Recheck, Views)
F1: GUI-Login-Hash gegen $-Korruption gehaertet. .env-Werte einfach-gequotet
    geschrieben + Sanity-Check nach Sourcing. Zusaetzlich systemd-Ebene:
    podman escapt $->$$ in Environment=, systemd 252 kollabiert das nicht ->
    sed-Post-Processing der Unit-Dateien. Live-.env: GUI-Passwort rotiert.
F2: Explizite Dependent-Deletes gegen FK-500 (delete_scenario/loan), 409 bei
    bestaetigtem Import-Delete. Test-Engine erzwingt PRAGMA foreign_keys=ON.
F3: Enum/Range-Validierung (Literal + Field ge/le) fuer Recurring, Loan,
    Modifier inkl. PATCH-Pfade.
F4: Confirm nur fuer draft-Statements (sonst 409) + Dedup-Re-Check gegen
    bestaetigte Buchungen vor dem Promoten.
F5: v_balance_history/v_balance_total addieren pro Konto den opening_balance
    des fruehesten bestaetigten Statements (approved deviation vom Plan-SQL).
F6: patch_scenario Namenskollision -> 409; create_transaction unbekanntes
    Konto -> 404 (statt 500).
F7: Generierte systemd-Unit-Dateien chmod 600 (enthalten Env-Secrets).
F8: Globaler htmx:responseError-Handler in base.html.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:11:31 +02:00

250 lines
8.2 KiB
Python

from datetime import date
from decimal import Decimal
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.auth import require_auth
from app.db import get_session
from app.engine.loans import loan_schedule
from app.models.tables import (Category, Loan, PlannedItem, RecurringItem,
ScenarioLoan)
from app.services.suggestions import suggest_recurring
router = APIRouter(prefix="/api", tags=["planning"],
dependencies=[Depends(require_auth)])
# ---------------------------------------------------------------- Recurring
class RecurringIn(BaseModel):
name: str
amount: Decimal
rhythm: Literal["monthly", "quarterly", "yearly"]
due_day: int = Field(ge=1, le=31)
start_date: date | None = None
end_date: date | None = None
category_id: int | None = None
class RecurringOut(RecurringIn):
model_config = ConfigDict(from_attributes=True)
id: int
class RecurringPatch(BaseModel):
name: str | None = None
amount: Decimal | None = None
rhythm: Literal["monthly", "quarterly", "yearly"] | None = None
due_day: int | None = Field(default=None, ge=1, le=31)
start_date: date | None = None
end_date: date | None = None
category_id: int | None = None
class SuggestionOut(BaseModel):
name: str
amount: Decimal
rhythm: str
due_day: int
category_id: int | None = None
def _check_category(session: Session, category_id: int | None) -> None:
if category_id is not None and session.get(Category, category_id) is None:
raise HTTPException(404, "Kategorie nicht gefunden")
@router.get("/recurring", response_model=list[RecurringOut])
def list_recurring(session: Session = Depends(get_session)):
return [RecurringOut.model_validate(r)
for r in session.execute(select(RecurringItem)).scalars()]
@router.post("/recurring", response_model=RecurringOut, status_code=201)
def create_recurring(data: RecurringIn, session: Session = Depends(get_session)):
_check_category(session, data.category_id)
item = RecurringItem(**data.model_dump())
session.add(item)
session.commit()
session.refresh(item)
return RecurringOut.model_validate(item)
# Muss vor /recurring/{item_id} stehen, sonst faengt die {id}-Route den Pfad ab.
@router.get("/recurring/suggestions", response_model=list[SuggestionOut])
def recurring_suggestions(session: Session = Depends(get_session)):
return suggest_recurring(session)
@router.patch("/recurring/{item_id}", response_model=RecurringOut)
def patch_recurring(item_id: int, data: RecurringPatch,
session: Session = Depends(get_session)):
item = session.get(RecurringItem, item_id)
if item is None:
raise HTTPException(404, "Fixposten nicht gefunden")
fields = data.model_dump(exclude_unset=True)
if "category_id" in fields:
_check_category(session, fields["category_id"])
for key, value in fields.items():
setattr(item, key, value)
session.commit()
session.refresh(item)
return RecurringOut.model_validate(item)
@router.delete("/recurring/{item_id}", status_code=204)
def delete_recurring(item_id: int, session: Session = Depends(get_session)):
item = session.get(RecurringItem, item_id)
if item is None:
raise HTTPException(404, "Fixposten nicht gefunden")
session.delete(item)
session.commit()
# ------------------------------------------------------------------ Planned
class PlannedIn(BaseModel):
name: str
amount: Decimal
due: date
category_id: int | None = None
class PlannedOut(PlannedIn):
model_config = ConfigDict(from_attributes=True)
id: int
class PlannedPatch(BaseModel):
name: str | None = None
amount: Decimal | None = None
due: date | None = None
category_id: int | None = None
@router.get("/planned", response_model=list[PlannedOut])
def list_planned(session: Session = Depends(get_session)):
return [PlannedOut.model_validate(p)
for p in session.execute(select(PlannedItem)).scalars()]
@router.post("/planned", response_model=PlannedOut, status_code=201)
def create_planned(data: PlannedIn, session: Session = Depends(get_session)):
_check_category(session, data.category_id)
item = PlannedItem(**data.model_dump())
session.add(item)
session.commit()
session.refresh(item)
return PlannedOut.model_validate(item)
@router.patch("/planned/{item_id}", response_model=PlannedOut)
def patch_planned(item_id: int, data: PlannedPatch,
session: Session = Depends(get_session)):
item = session.get(PlannedItem, item_id)
if item is None:
raise HTTPException(404, "Einmalposten nicht gefunden")
fields = data.model_dump(exclude_unset=True)
if "category_id" in fields:
_check_category(session, fields["category_id"])
for key, value in fields.items():
setattr(item, key, value)
session.commit()
session.refresh(item)
return PlannedOut.model_validate(item)
@router.delete("/planned/{item_id}", status_code=204)
def delete_planned(item_id: int, session: Session = Depends(get_session)):
item = session.get(PlannedItem, item_id)
if item is None:
raise HTTPException(404, "Einmalposten nicht gefunden")
session.delete(item)
session.commit()
# --------------------------------------------------------------------- Loans
class LoanIn(BaseModel):
name: str
principal: Decimal
annual_rate_pct: Decimal
term_months: int
payout_date: date
repayment_type: Literal["annuity", "bullet"] = "annuity"
class LoanOut(LoanIn):
model_config = ConfigDict(from_attributes=True)
id: int
class LoanPatch(BaseModel):
name: str | None = None
principal: Decimal | None = None
annual_rate_pct: Decimal | None = None
term_months: int | None = None
payout_date: date | None = None
repayment_type: Literal["annuity", "bullet"] | None = None
class InstallmentOut(BaseModel):
due: date
payment: Decimal
interest: Decimal
principal: Decimal
remaining: Decimal
@router.get("/loans", response_model=list[LoanOut])
def list_loans(session: Session = Depends(get_session)):
return [LoanOut.model_validate(l)
for l in session.execute(select(Loan)).scalars()]
@router.post("/loans", response_model=LoanOut, status_code=201)
def create_loan(data: LoanIn, session: Session = Depends(get_session)):
loan = Loan(**data.model_dump())
session.add(loan)
session.commit()
session.refresh(loan)
return LoanOut.model_validate(loan)
@router.patch("/loans/{loan_id}", response_model=LoanOut)
def patch_loan(loan_id: int, data: LoanPatch, session: Session = Depends(get_session)):
loan = session.get(Loan, loan_id)
if loan is None:
raise HTTPException(404, "Kredit nicht gefunden")
for key, value in data.model_dump(exclude_unset=True).items():
setattr(loan, key, value)
session.commit()
session.refresh(loan)
return LoanOut.model_validate(loan)
@router.delete("/loans/{loan_id}", status_code=204)
def delete_loan(loan_id: int, session: Session = Depends(get_session)):
loan = session.get(Loan, loan_id)
if loan is None:
raise HTTPException(404, "Kredit nicht gefunden")
# Remove scenario assignments first: no DB-level cascade exists, so a
# dangling ScenarioLoan row would raise a foreign-key violation (500 on
# Postgres).
session.execute(delete(ScenarioLoan).where(ScenarioLoan.loan_id == loan_id))
session.delete(loan)
session.commit()
@router.get("/loans/{loan_id}/schedule", response_model=list[InstallmentOut])
def get_loan_schedule(loan_id: int, session: Session = Depends(get_session)):
loan = session.get(Loan, loan_id)
if loan is None:
raise HTTPException(404, "Kredit nicht gefunden")
plan = loan_schedule(Decimal(loan.principal), Decimal(loan.annual_rate_pct),
loan.term_months, loan.payout_date, loan.repayment_type)
return [InstallmentOut(due=i.due, payment=i.payment, interest=i.interest,
principal=i.principal, remaining=i.remaining)
for i in plan]