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()