diff --git a/finance/app/routers/imports.py b/finance/app/routers/imports.py index d8f9818..eac70b8 100644 --- a/finance/app/routers/imports.py +++ b/finance/app/routers/imports.py @@ -11,7 +11,7 @@ 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 +from app.services.importer import process_file router = APIRouter(prefix="/api/imports", tags=["imports"], dependencies=[Depends(require_auth)]) @@ -50,22 +50,22 @@ class TransactionOut(BaseModel): class PreviewOut(BaseModel): statement: StatementOut transactions: list[TransactionOut] - balance_ok: bool + balance_ok: bool | None 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") + if not safe_name or not safe_name.lower().endswith((".pdf", ".csv")): + raise HTTPException(400, "Nur PDF- oder CSV-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) + stmt = process_file(session, dest) return StatementOut.model_validate(stmt) @@ -74,9 +74,10 @@ def scan_inbox(session: Session = Depends(get_session)): settings = get_settings() inbox_dir = settings.inbox_dir inbox_dir.mkdir(parents=True, exist_ok=True) + paths = sorted(inbox_dir.glob("*.pdf")) + sorted(inbox_dir.glob("*.csv")) results = [] - for path in sorted(inbox_dir.glob("*.pdf")): - stmt = process_pdf(session, path) + for path in paths: + stmt = process_file(session, path) results.append(StatementOut.model_validate(stmt)) return results @@ -96,11 +97,17 @@ def preview(statement_id: int, session: Session = Depends(get_session)): 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 - ) + # Dreiwertig (Ausbaustufe 3 Task 3): manche CSV-Formate (z.B. HVB, DKB) + # liefern keine Saldodaten - None statt False, damit die Vorschau nicht + # faelschlich einen Saldo-Fehler anzeigt, wo schlicht keine Pruefung + # moeglich ist. + if stmt.opening_balance is None or stmt.closing_balance is None: + balance_ok = None + else: + balance_ok = ( + 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), diff --git a/finance/app/services/importer.py b/finance/app/services/importer.py index 8e8d96d..67174c9 100644 --- a/finance/app/services/importer.py +++ b/finance/app/services/importer.py @@ -1,3 +1,5 @@ +from datetime import date +from decimal import Decimal from pathlib import Path from sqlalchemy import select @@ -5,7 +7,8 @@ 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.base import ParsedStatement, ParserError +from app.parsers.csv_formats import parse_csv from app.parsers.registry import parse_pdf from app.parsers.validate import balance_difference, dedup_hash from app.services.categorize import apply_rules @@ -14,6 +17,17 @@ from app.services.categorize import apply_rules def _find_or_create_account(session: Session, bank: str, iban: str | None, filename: str) -> Account: if not iban: iban = f"UNBEKANNT-{filename}" + if bank == "hvb_csv": + # HVB-CSV traegt nur die Kontonummer (keine IBAN). Konto-Zuordnung + # ueber "endet auf Kontonummer" gegen alle bestehenden IBANs - + # dokumentierte Einschraenkung (siehe Formatreferenz Task 2/3). + for existing in session.execute(select(Account)).scalars(): + if existing.iban.endswith(iban): + return existing + acc = Account(bank=bank, iban=iban, name=iban, type="giro") + session.add(acc) + session.flush() + return acc acc = session.execute(select(Account).where(Account.iban == iban)).scalar() if acc is not None: return acc @@ -23,6 +37,20 @@ def _find_or_create_account(session: Session, bank: str, iban: str | None, filen return acc +def _apply_anchor_autofill(account: Account, anchor: tuple[date, Decimal] | None) -> None: + """CSV-Anker-Regel (Ausbaustufe 3 Task 3): der Datei-Kontostand wird nur + uebernommen, wenn er neuer ist als ein evtl. vorhandener Anker (oder noch + keiner existiert). Ein neuerer, manuell per PATCH gesetzter Anker bleibt + unangetastet - so entkommt kein aelterer CSV-Export einem bewusst + gesetzten aktuellen Stand.""" + if anchor is None: + return + anchor_date, anchor_balance = anchor + if account.anchor_date is None or anchor_date >= account.anchor_date: + account.anchor_date = anchor_date + account.anchor_balance = anchor_balance + + def _error_statement(session: Session, filename: str, message: str) -> Statement: stmt = Statement(filename=filename, bank="unbekannt", account_id=None, status="error", error_message=message) @@ -32,23 +60,18 @@ def _error_statement(session: Session, filename: str, message: str) -> Statement return stmt -def process_pdf(session: Session, path: Path) -> Statement: +def _finish_statement(session: Session, path: Path, parsed: ParsedStatement, + check_balance: bool, + anchor: tuple[date, Decimal] | None) -> Statement: + """Gemeinsamer Abschluss fuer PDF- und CSV-Importe, sobald ein + ParsedStatement vorliegt: Konto zuordnen, Statement+Transaktionen als + Entwurf anlegen, optional den Saldo pruefen, Datei aus dem Posteingang + verschieben. `check_balance=False` (CSV-Formate ohne Saldodaten, z.B. + HVB/DKB) laesst den Entwurf OHNE Fehler stehen statt einer + Saldo-Differenz-Pruefung, die auf None-Feldern crashen wuerde.""" settings = get_settings() filename = path.name - try: - parsed = parse_pdf(path) - except ParserError as exc: - return _error_statement(session, filename, str(exc)) - except Exception as exc: - # Fremdbibliotheken (pdfplumber/pdfminer) werfen bei strukturell - # kaputten PDFs eigene Exception-Typen statt ParserError (z.B. - # PdfminerException "No /Root object!"). Ohne diesen Fang wuerde ein - # kaputtes Upload zu einem unbehandelten 500 fuehren statt zu einem - # sauberen Fehler-Import wie bei "Bank nicht erkannt". Die Datei - # bleibt dabei unangetastet im Posteingang. - return _error_statement(session, filename, f"PDF nicht lesbar: {exc}") - account = _find_or_create_account(session, parsed.bank, parsed.iban, filename) stmt = Statement( @@ -64,13 +87,14 @@ def process_pdf(session: Session, path: Path) -> Statement: 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 + if check_balance: + 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: @@ -100,10 +124,11 @@ def process_pdf(session: Session, path: Path) -> Statement: apply_rules(session, drafts) stmt.status = "draft" + _apply_anchor_autofill(account, anchor) - # Invariant: a committed draft implies the PDF left the inbox. Move the - # file before committing so a failed move can never leave a committed - # draft with the PDF still sitting in the inbox. + # Invariant: a committed draft implies the source file left the inbox. + # Move the file before committing so a failed move can never leave a + # committed draft with the file still sitting in the inbox. uploads_dir = settings.uploads_dir uploads_dir.mkdir(parents=True, exist_ok=True) try: @@ -116,3 +141,46 @@ def process_pdf(session: Session, path: Path) -> Statement: session.refresh(stmt) return stmt + + +def process_pdf(session: Session, path: Path) -> Statement: + filename = path.name + try: + parsed = parse_pdf(path) + except ParserError as exc: + return _error_statement(session, filename, str(exc)) + except Exception as exc: + # Fremdbibliotheken (pdfplumber/pdfminer) werfen bei strukturell + # kaputten PDFs eigene Exception-Typen statt ParserError (z.B. + # PdfminerException "No /Root object!"). Ohne diesen Fang wuerde ein + # kaputtes Upload zu einem unbehandelten 500 fuehren statt zu einem + # sauberen Fehler-Import wie bei "Bank nicht erkannt". Die Datei + # bleibt dabei unangetastet im Posteingang. + return _error_statement(session, filename, f"PDF nicht lesbar: {exc}") + + return _finish_statement(session, path, parsed, check_balance=True, anchor=None) + + +def process_csv(session: Session, path: Path) -> Statement: + filename = path.name + try: + parsed_csv = parse_csv(path) + except ParserError as exc: + return _error_statement(session, filename, str(exc)) + except Exception as exc: + # Analog zu process_pdf: eine strukturell kaputte/unlesbare CSV darf + # nicht als unbehandelter 500 durchschlagen. + return _error_statement(session, filename, f"CSV nicht lesbar: {exc}") + + return _finish_statement(session, path, parsed_csv.statement, + check_balance=parsed_csv.balance_checkable, + anchor=parsed_csv.anchor) + + +def process_file(session: Session, path: Path) -> Statement: + """Dateiendungs-Weiche fuer die Import-Pipeline (Ausbaustufe 3 Task 3): + .csv laeuft ueber den CSV-Formatparser, alles andere weiterhin ueber den + PDF-Parser.""" + if path.suffix.lower() == ".csv": + return process_csv(session, path) + return process_pdf(session, path) diff --git a/finance/app/templates/_preview_table.html b/finance/app/templates/_preview_table.html index 74d699b..f0fb513 100644 --- a/finance/app/templates/_preview_table.html +++ b/finance/app/templates/_preview_table.html @@ -18,8 +18,10 @@
Saldo-Status: - {% if balance_ok %} - Saldo stimmt + {% if balance_ok is none %} + Saldo-Prüfung: nicht verfügbar (Format ohne Saldodaten) + {% elif balance_ok %} + Saldo plausibel {% else %} Saldo-Differenz – bitte prüfen {% endif %} diff --git a/finance/app/templates/hilfe.html b/finance/app/templates/hilfe.html index 885f3e5..f306f4e 100644 --- a/finance/app/templates/hilfe.html +++ b/finance/app/templates/hilfe.html @@ -16,15 +16,24 @@ Über die Übersicht umbenennbar (z. B. "DKB Giro").
~/.local/share/finance_pod/data/inbox/ legen und "Inbox
- scannen"). Jeder Import wird geprüft (Anfangssaldo + Buchungen = Endsaldo)
- und landet als Entwurf: erst nach Kontrolle der Vorschau "Übernehmen"
- klicken. Duplikate werden erkannt und nicht doppelt übernommen. Bestätigte
- Importe lassen sich als Ganzes zurückrollen (Auszug + alle seine
- Buchungen), z. B. um einen Auszug mit einem verbesserten Parser neu zu
- importieren; einzelne Buchungen bleiben unlöschbar.
+ scannen"). PDF-Kontoauszüge funktionieren weiterhin als Fallback, falls
+ kein CSV-Export vorliegt. Jeder Import landet als Entwurf: erst nach
+ Kontrolle der Vorschau "Übernehmen" klicken. Der Saldo-Status in der
+ Vorschau ist dreiwertig – "Saldo plausibel" (Anfangssaldo + Buchungen =
+ Endsaldo bzw. lückenlose Saldo-Kette), eine rote Saldo-Differenz, oder
+ "Saldo-Prüfung nicht verfügbar", wenn das Format (z. B. HVB-CSV) keine
+ Saldodaten liefert – dort lohnt ein Blick in die Zeilenzahl und
+ Stichproben vor dem Übernehmen. CSV-Importe mit Datei-Kontostand (DKB)
+ oder lückenloser Saldo-Kette (VR) aktualisieren beim Import automatisch
+ den Konto-Saldo-Anker, sofern er dadurch nicht älter würde als ein bereits
+ vorhandener. Duplikate werden erkannt und nicht doppelt übernommen.
+ Bestätigte Importe lassen sich als Ganzes zurückrollen (Auszug + alle
+ seine Buchungen), z. B. um einen Auszug mit einem verbesserten Parser neu
+ zu importieren; einzelne Buchungen bleiben unlöschbar.
Durchsucht ~/.local/share/finance_pod/data/inbox/ nach neuen PDF-Kontoauszügen.
Durchsucht ~/.local/share/finance_pod/data/inbox/ nach neuen CSV- oder PDF-Kontoauszügen.