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>
This commit is contained in:
2026-07-17 20:11:31 +02:00
parent b3b40a5982
commit cf9282c716
10 changed files with 265 additions and 31 deletions

View File

@@ -115,6 +115,8 @@ def confirm(statement_id: int, session: Session = Depends(get_session)):
stmt = session.get(Statement, statement_id)
if stmt is None:
raise HTTPException(404, "Import nicht gefunden")
if stmt.status != "draft":
raise HTTPException(409, "Import ist nicht im Entwurfsstatus")
txs = session.execute(
select(Transaction).where(
Transaction.statement_id == statement_id,
@@ -123,6 +125,19 @@ def confirm(statement_id: int, session: Session = Depends(get_session)):
)
).scalars().all()
for tx in txs:
# Re-check against currently confirmed transactions: another import
# confirmed between preview and now may have introduced the same
# booking. Mark late duplicates and skip promoting them.
clash = session.execute(
select(Transaction).where(
Transaction.dedup_hash == tx.dedup_hash,
Transaction.status == "confirmed",
Transaction.id != tx.id,
)
).scalar()
if clash is not None:
tx.is_duplicate = True
continue
tx.status = "confirmed"
stmt.status = "confirmed"
session.commit()
@@ -135,6 +150,8 @@ def delete_import(statement_id: int, session: Session = Depends(get_session)):
stmt = session.get(Statement, statement_id)
if stmt is None:
raise HTTPException(404, "Import nicht gefunden")
if stmt.status == "confirmed":
raise HTTPException(409, "Bestätigter Import kann nicht gelöscht werden")
drafts = session.execute(
select(Transaction).where(
Transaction.statement_id == statement_id,

View File

@@ -1,15 +1,17 @@
from datetime import date
from decimal import Decimal
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
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
from app.models.tables import (Category, Loan, PlannedItem, RecurringItem,
ScenarioLoan)
from app.services.suggestions import suggest_recurring
router = APIRouter(prefix="/api", tags=["planning"],
@@ -20,8 +22,8 @@ router = APIRouter(prefix="/api", tags=["planning"],
class RecurringIn(BaseModel):
name: str
amount: Decimal
rhythm: str
due_day: int
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
@@ -35,8 +37,8 @@ class RecurringOut(RecurringIn):
class RecurringPatch(BaseModel):
name: str | None = None
amount: Decimal | None = None
rhythm: str | None = None
due_day: int | 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
@@ -170,7 +172,7 @@ class LoanIn(BaseModel):
annual_rate_pct: Decimal
term_months: int
payout_date: date
repayment_type: str = "annuity"
repayment_type: Literal["annuity", "bullet"] = "annuity"
class LoanOut(LoanIn):
@@ -184,7 +186,7 @@ class LoanPatch(BaseModel):
annual_rate_pct: Decimal | None = None
term_months: int | None = None
payout_date: date | None = None
repayment_type: str | None = None
repayment_type: Literal["annuity", "bullet"] | None = None
class InstallmentOut(BaseModel):
@@ -227,6 +229,10 @@ 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()

View File

@@ -1,16 +1,17 @@
from datetime import date
from decimal import Decimal
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.auth import require_auth
from app.config import get_settings
from app.db import get_session
from app.models.tables import (Loan, ProjectionPoint, Scenario, ScenarioLoan,
ScenarioModifier)
from app.models.tables import (Loan, ProjectionPoint, ProjectionResult,
Scenario, ScenarioLoan, ScenarioModifier)
from app.services.projection_service import run_projection
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"],
@@ -37,9 +38,9 @@ class ScenarioPatch(BaseModel):
class ModifierIn(BaseModel):
target_type: str
target_type: Literal["category", "recurring"]
target_id: int
kind: str
kind: Literal["percent", "absolute", "remove"]
value: Decimal = Decimal("0")
@@ -90,7 +91,13 @@ def create_scenario(data: ScenarioIn, session: Session = Depends(get_session)):
def patch_scenario(scenario_id: int, data: ScenarioPatch,
session: Session = Depends(get_session)):
scenario = _get_scenario(session, scenario_id)
for key, value in data.model_dump(exclude_unset=True).items():
fields = data.model_dump(exclude_unset=True)
if "name" in fields and fields["name"] != scenario.name:
clash = session.execute(
select(Scenario).where(Scenario.name == fields["name"])).scalar()
if clash is not None:
raise HTTPException(409, "Szenario existiert bereits")
for key, value in fields.items():
setattr(scenario, key, value)
session.commit()
session.refresh(scenario)
@@ -100,6 +107,16 @@ def patch_scenario(scenario_id: int, data: ScenarioPatch,
@router.delete("/{scenario_id}", status_code=204)
def delete_scenario(scenario_id: int, session: Session = Depends(get_session)):
scenario = _get_scenario(session, scenario_id)
# No DB-level cascades exist, so remove dependent rows explicitly to avoid
# foreign-key violations (would surface as a 500 on Postgres).
session.execute(delete(ProjectionPoint).where(
ProjectionPoint.scenario_id == scenario_id))
session.execute(delete(ProjectionResult).where(
ProjectionResult.scenario_id == scenario_id))
session.execute(delete(ScenarioLoan).where(
ScenarioLoan.scenario_id == scenario_id))
session.execute(delete(ScenarioModifier).where(
ScenarioModifier.scenario_id == scenario_id))
session.delete(scenario)
session.commit()

View File

@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from app.auth import require_auth
from app.db import get_session
from app.models.tables import Category, Transaction
from app.models.tables import Account, Category, Transaction
from app.parsers.validate import dedup_hash
from app.services.categorize import apply_rules
@@ -79,6 +79,8 @@ def list_transactions(
@router.post("", response_model=TransactionOut, status_code=201)
def create_transaction(data: TransactionIn, session: Session = Depends(get_session)):
if session.get(Account, data.account_id) is None:
raise HTTPException(404, "Konto nicht gefunden")
h = dedup_hash(data.account_id, data.booking_date, data.amount, data.purpose)
existing = session.execute(
select(Transaction).where(Transaction.dedup_hash == h)).scalar()