feat: Import-API mit Vorschau, Saldo-Pruefung und Duplikat-Schutz

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 18:21:28 +02:00
parent 0576b396f1
commit 19d870cf20
4 changed files with 292 additions and 1 deletions

View File

@@ -5,13 +5,14 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from app import auth
from app.config import get_settings
from app.routers import accounts, categories, transactions
from app.routers import accounts, categories, imports, transactions
app = FastAPI(title="Finanzberatungs-Tool")
app.include_router(accounts.router)
app.include_router(transactions.router)
app.include_router(categories.router)
app.include_router(imports.router)
@app.get("/login", response_class=HTMLResponse)

View File

@@ -0,0 +1,143 @@
from datetime import date
from decimal import Decimal
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)):
settings = get_settings()
inbox_dir = settings.inbox_dir
inbox_dir.mkdir(parents=True, exist_ok=True)
dest = inbox_dir / file.filename
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")
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:
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")
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()

View File

@@ -0,0 +1,99 @@
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models.tables import Account, Statement, Transaction
from app.parsers.base import ParserError
from app.parsers.registry import parse_pdf
from app.parsers.validate import balance_difference, dedup_hash
from app.services.categorize import apply_rules
def _find_or_create_account(session: Session, bank: str, iban: str | None, filename: str) -> Account:
if iban:
acc = session.execute(select(Account).where(Account.iban == iban)).scalar()
if acc is not None:
return acc
else:
iban = f"UNBEKANNT-{filename}"
acc = Account(bank=bank, iban=iban, name=iban, type="giro")
session.add(acc)
session.flush()
return acc
def process_pdf(session: Session, path: Path) -> Statement:
settings = get_settings()
filename = path.name
try:
parsed = parse_pdf(path)
except ParserError as exc:
stmt = Statement(filename=filename, bank="unbekannt", account_id=None,
status="error", error_message=str(exc))
session.add(stmt)
session.commit()
session.refresh(stmt)
return stmt
account = _find_or_create_account(session, parsed.bank, parsed.iban, filename)
stmt = Statement(
filename=filename,
bank=parsed.bank,
account_id=account.id,
period_start=parsed.period_start,
period_end=parsed.period_end,
opening_balance=parsed.opening_balance,
closing_balance=parsed.closing_balance,
status="draft",
)
session.add(stmt)
session.flush()
diff = balance_difference(parsed)
if diff != 0:
stmt.status = "error"
stmt.error_message = f"Saldo-Differenz {diff} EUR"
session.commit()
session.refresh(stmt)
return stmt
drafts = []
for t in parsed.transactions:
h = dedup_hash(account.id, t.booking_date, t.amount, t.purpose)
is_duplicate = session.execute(
select(Transaction).where(
Transaction.dedup_hash == h,
Transaction.status == "confirmed",
)
).scalar() is not None
tx = Transaction(
account_id=account.id,
statement_id=stmt.id,
booking_date=t.booking_date,
value_date=t.value_date,
amount=t.amount,
purpose=t.purpose,
counterparty=t.counterparty,
status="draft",
dedup_hash=h,
is_duplicate=is_duplicate,
)
session.add(tx)
drafts.append(tx)
session.flush()
apply_rules(session, drafts)
stmt.status = "draft"
session.commit()
session.refresh(stmt)
uploads_dir = settings.uploads_dir
uploads_dir.mkdir(parents=True, exist_ok=True)
path.replace(uploads_dir / filename)
return stmt

View File

@@ -0,0 +1,48 @@
from datetime import date
from decimal import Decimal
from pathlib import Path
import pytest
from app.parsers.base import ParsedStatement, ParsedTransaction
H = {"Authorization": "Bearer test-key"}
FAKE = ParsedStatement(
bank="dkb", iban="DE02120300000000202051",
period_start=date(2026, 6, 1), period_end=date(2026, 6, 30),
opening_balance=Decimal("100.00"), closing_balance=Decimal("40.00"),
transactions=[ParsedTransaction(date(2026, 6, 3), date(2026, 6, 3),
Decimal("-60.00"), "Miete Juni", "Vermieter")])
@pytest.fixture
def fake_parse(monkeypatch):
monkeypatch.setattr("app.services.importer.parse_pdf", lambda p: FAKE)
def _upload(client, name="auszug.pdf"):
return client.post("/api/imports/upload", headers=H,
files={"file": (name, b"%PDF-fake", "application/pdf")})
def test_upload_preview_confirm(client, fake_parse, tmp_path, monkeypatch):
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
from app.config import get_settings
get_settings.cache_clear()
r = _upload(client)
assert r.status_code == 201
sid = r.json()["id"]
prev = client.get(f"/api/imports/{sid}/preview", headers=H).json()
assert prev["balance_ok"] is True
assert len(prev["transactions"]) == 1
assert client.post(f"/api/imports/{sid}/confirm", headers=H).status_code == 200
txs = client.get("/api/transactions", headers=H).json()
assert len(txs) == 1 and txs[0]["status"] == "confirmed"
# Zweiter Import desselben Auszugs: alles Duplikate
sid2 = _upload(client, "auszug2.pdf").json()["id"]
prev2 = client.get(f"/api/imports/{sid2}/preview", headers=H).json()
assert prev2["duplicates"] == 1
client.post(f"/api/imports/{sid2}/confirm", headers=H)
assert len(client.get("/api/transactions", headers=H).json()) == 1