diff --git a/finance/app/main.py b/finance/app/main.py index f68f4c6..796bbb8 100644 --- a/finance/app/main.py +++ b/finance/app/main.py @@ -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) diff --git a/finance/app/routers/imports.py b/finance/app/routers/imports.py new file mode 100644 index 0000000..4de61db --- /dev/null +++ b/finance/app/routers/imports.py @@ -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() diff --git a/finance/app/services/importer.py b/finance/app/services/importer.py new file mode 100644 index 0000000..48d937f --- /dev/null +++ b/finance/app/services/importer.py @@ -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 diff --git a/finance/tests/test_import_api.py b/finance/tests/test_import_api.py new file mode 100644 index 0000000..0ba960a --- /dev/null +++ b/finance/tests/test_import_api.py @@ -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