Files
bin/finance/app/routers/imports.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

165 lines
5.6 KiB
Python

from datetime import date
from decimal import Decimal
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile
from pydantic import BaseModel, ConfigDict
from sqlalchemy import 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 Statement, Transaction
from app.services.importer import process_pdf
router = APIRouter(prefix="/api/imports", tags=["imports"],
dependencies=[Depends(require_auth)])
class StatementOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
filename: str
bank: str
account_id: int | None = None
period_start: date | None = None
period_end: date | None = None
opening_balance: Decimal | None = None
closing_balance: Decimal | None = None
status: str
error_message: str | None = None
class TransactionOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
account_id: int
statement_id: int | None = None
booking_date: date
value_date: date | None = None
amount: Decimal
purpose: str
counterparty: str
category_id: int | None = None
status: str
dedup_hash: str
is_duplicate: bool
class PreviewOut(BaseModel):
statement: StatementOut
transactions: list[TransactionOut]
balance_ok: bool
duplicates: int
@router.post("/upload", response_model=StatementOut, status_code=201)
def upload(file: UploadFile, session: Session = Depends(get_session)):
safe_name = Path(file.filename or "upload.pdf").name
if not safe_name or not safe_name.lower().endswith(".pdf"):
raise HTTPException(400, "Nur PDF-Dateien")
settings = get_settings()
inbox_dir = settings.inbox_dir
inbox_dir.mkdir(parents=True, exist_ok=True)
dest = inbox_dir / safe_name
with dest.open("wb") as f:
f.write(file.file.read())
stmt = process_pdf(session, dest)
return StatementOut.model_validate(stmt)
@router.post("/scan-inbox", response_model=list[StatementOut])
def scan_inbox(session: Session = Depends(get_session)):
settings = get_settings()
inbox_dir = settings.inbox_dir
inbox_dir.mkdir(parents=True, exist_ok=True)
results = []
for path in sorted(inbox_dir.glob("*.pdf")):
stmt = process_pdf(session, path)
results.append(StatementOut.model_validate(stmt))
return results
@router.get("", response_model=list[StatementOut])
def list_imports(session: Session = Depends(get_session)):
stmts = session.execute(select(Statement).order_by(Statement.id)).scalars()
return [StatementOut.model_validate(s) for s in stmts]
@router.get("/{statement_id}/preview", response_model=PreviewOut)
def preview(statement_id: int, session: Session = Depends(get_session)):
stmt = session.get(Statement, statement_id)
if stmt is None:
raise HTTPException(404, "Import nicht gefunden")
txs = session.execute(
select(Transaction).where(Transaction.statement_id == statement_id)
.order_by(Transaction.booking_date, Transaction.id)
).scalars().all()
balance_ok = (
stmt.opening_balance is not None and stmt.closing_balance is not None
and (stmt.opening_balance + sum((t.amount for t in txs), Decimal("0"))
- stmt.closing_balance) == 0
)
duplicates = sum(1 for t in txs if t.is_duplicate)
return PreviewOut(
statement=StatementOut.model_validate(stmt),
transactions=[TransactionOut.model_validate(t) for t in txs],
balance_ok=balance_ok,
duplicates=duplicates,
)
@router.post("/{statement_id}/confirm", response_model=StatementOut)
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,
Transaction.status == "draft",
Transaction.is_duplicate == False, # noqa: E712
)
).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()
session.refresh(stmt)
return StatementOut.model_validate(stmt)
@router.delete("/{statement_id}", status_code=204)
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,
Transaction.status == "draft",
)
).scalars().all()
for tx in drafts:
session.delete(tx)
session.delete(stmt)
session.commit()