diff --git a/finance/app/parsers/detect.py b/finance/app/parsers/detect.py index a6e7009..cddfa7c 100644 --- a/finance/app/parsers/detect.py +++ b/finance/app/parsers/detect.py @@ -1,6 +1,6 @@ MARKERS = [ ("vr", ["Volksbank", "Raiffeisenbank", "VR-Bank", "VR Bank"]), - ("hvb", ["HypoVereinsbank", "UniCredit"]), + ("hvb", ["HypoVereinsbank", "UniCredit", "Unicredit", "HYVEDEMM"]), ("dkb", ["Deutsche Kreditbank", "DKB"]), ] diff --git a/finance/app/parsers/dkb.py b/finance/app/parsers/dkb.py new file mode 100644 index 0000000..c3fa905 --- /dev/null +++ b/finance/app/parsers/dkb.py @@ -0,0 +1,79 @@ +"""Parser für DKB (Deutsche Kreditbank)-Kontoauszüge. + +Realer Fixture-Abgleich (Task 7, Schritt 4): Der eigentliche Auszugszeitraum +steht nicht als "vom X bis Y"-Satz im Text (dieser Wortlaut kommt nur in einem +separaten quartalsweisen Zinsabschluss-Absatz mit abweichendem Zeitraum vor); +er wird daher aus den Datumsangaben der beiden Kontostand-Zeilen abgeleitet. +Buchungszeilen haben nur eine Datumsspalte, die ohne Leerzeichen direkt am +Buchungstext klebt, und der Betrag trägt ein optionales führendes Minus statt +eines nachgestellten S/H-Kürzels. Der Anfangssaldo trägt den Zusatz +", Auszug Nr. N", der Endsaldo den Zusatz "um HH:MM Uhr" – beides +unterscheidet die echten Saldo-Zeilen von einer abweichend formulierten +Zwischensumme in einem quartalsweisen Rechnungsabschluss-Anhang. +""" +import re +from datetime import date +from pathlib import Path + +import pdfplumber + +from app.parsers.base import (ParsedStatement, ParsedTransaction, ParserError, + parse_german_amount, parse_german_date) + +BANK = "dkb" +RE_IBAN = re.compile(r"\b([A-Z]{2}\d{2}(?:\s?\d{4}){4,5}(?:\s?\d{1,2})?)\b") +# "Kontostand am 01.06.2020, Auszug Nr. 1 1.234,56" +RE_OPENING = re.compile(r"Kontostand\s+am\s+(\d{2}\.\d{2}\.\d{4}),\s*Auszug\s+Nr\.\s*\d+\s+(-?[\d.,]+)", re.I) +# "Kontostand am 30.06.2020 um 12:00 Uhr -2.345,67" +RE_CLOSING = re.compile(r"Kontostand\s+am\s+(\d{2}\.\d{2}\.\d{4})\s+um\s+\d{2}:\d{2}\s*Uhr\s+(-?[\d.,]+)", re.I) +# Buchungszeile: "01.06.2020Überweisung -123,45" (Datum direkt am Text, kein Leerzeichen) +RE_TX_HEAD = re.compile(r"^(\d{2}\.\d{2}\.\d{4})(\S.*?)\s+(-?[\d.,]+)$") + + +def parse(path: Path) -> ParsedStatement: + with pdfplumber.open(path) as pdf: + text = "\n".join((page.extract_text() or "") for page in pdf.pages) + opening = RE_OPENING.search(text) + closing = RE_CLOSING.search(text) + if not opening or not closing: + raise ParserError("DKB: Anfangs-/Endsaldo nicht gefunden") + period_start = parse_german_date(opening.group(1)) + period_end = parse_german_date(closing.group(1)) + iban_m = RE_IBAN.search(text) + txs: list[ParsedTransaction] = [] + pending: dict | None = None + for line in text.splitlines(): + line = line.strip() + if not line: + continue + m = RE_TX_HEAD.match(line) + if m: + if pending: + txs.append(_finish(pending)) + booking = parse_german_date(m.group(1)) + pending = { + "booking": booking, + "value": booking, + "head": m.group(2).strip(), + "amount": parse_german_amount(m.group(3)), + "extra": [], + } + continue + if pending and not RE_OPENING.search(line) and not RE_CLOSING.search(line): + pending["extra"].append(line) + if pending: + txs.append(_finish(pending)) + return ParsedStatement(bank=BANK, + iban=iban_m.group(1).replace(" ", "") if iban_m else None, + period_start=period_start, period_end=period_end, + opening_balance=parse_german_amount(opening.group(2)), + closing_balance=parse_german_amount(closing.group(2)), + transactions=txs) + + +def _finish(p: dict) -> ParsedTransaction: + counterparty = p["extra"][0] if p["extra"] else "" + purpose = " ".join([p["head"], *p["extra"][1:]]).strip() + return ParsedTransaction(booking_date=p["booking"], value_date=p["value"], + amount=p["amount"], purpose=purpose, + counterparty=counterparty) diff --git a/finance/app/parsers/hvb.py b/finance/app/parsers/hvb.py new file mode 100644 index 0000000..3ffe4ce --- /dev/null +++ b/finance/app/parsers/hvb.py @@ -0,0 +1,118 @@ +"""Parser für HypoVereinsbank (UniCredit)-Kontoauszüge. + +Realer Fixture-Abgleich (Task 7, Schritt 4): Das Layout weicht strukturell vom +generischen Platzhalter ab, nicht nur in der Wortwahl: +- Eine Buchung besteht aus zwei Zeilen: Zeile 1 = Buchungsdatum + Vorgangsart + + Saldo/Umsatz-Betrag mit direkt angehängtem Vorzeichen (kein Leerzeichen vor + "+"/"-"); Zeile 2 = Wertstellungsdatum + Buchungsinformation (Text kann + leer sein, dann folgt nur ein Datum ohne weiteren Text). Fehlt Zeile 2 ganz + (z. B. Seitenumbruch mitten in der Buchung), gilt das als Parserfehler statt + eine unvollständige Buchung zu erzeugen. +- Mehrseitige Auszüge wiederholen Kopfzeilen und Zwischensummen + ("Übertrag: EUR ...", "Saldo per DATUM: EUR ..."), die nicht mit dem + tatsächlichen Anfangs-/Endsaldo verwechselt werden dürfen und aus dem + Verwendungszweck herausgefiltert werden. +- Der Auszugszeitraum steht nur in der wiederholten Kopfzeile als + " - ". +""" +import re +from datetime import date +from pathlib import Path + +import pdfplumber + +from app.parsers.base import (ParsedStatement, ParsedTransaction, ParserError, + parse_german_amount, parse_german_date) + +BANK = "hvb" +RE_IBAN = re.compile(r"\b([A-Z]{2}\d{2}(?:\s?\d{4}){4,5}(?:\s?\d{1,2})?)\b") +# Kopfzeile pro Seite: "... - " +RE_PERIOD = re.compile(r"(\d{2}\.\d{2}\.\d{4})\s*-\s*(\d{2}\.\d{2}\.\d{4})\s+\d+\s+\d+\s*$", re.M) +# "Ihr alter Kontostand per 01.06.2020: EUR 1.234,56+" +RE_OPENING = re.compile(r"Ihr\s+alter\s+Kontostand\s+per\s+\d{2}\.\d{2}\.\d{4}:\s*EUR\s+([\d.,]+[+-]?)", re.I) +# "Ihr neuer Kontostand: EUR 2.345,67+" (Zwischensummen heißen "Saldo per ...:" und matchen hier nicht) +RE_CLOSING = re.compile(r"Ihr\s+neuer\s+Kontostand:\s*EUR\s+([\d.,]+[+-]?)", re.I) +# Buchungszeile Teil 1: "01.06. LASTSCHRIFT 123,45-" (Buchungsdatum, Vorgang, Betrag+Vorzeichen ohne Leerzeichen) +RE_TX_HEAD = re.compile(r"^(\d{2}\.\d{2}\.)\s+(.+?)\s+([\d.,]+[+-])$") +# Buchungszeile Teil 2: "01.06. Muster GmbH" (Wertdatum, Buchungsinformation; Text darf leer sein) +RE_TX_DETAIL = re.compile(r"^(\d{2}\.\d{2}\.)\s*(.*)$") +# Zwischensummen/Übertrag zwischen Seiten, dürfen nicht in den Verwendungszweck rutschen +RE_CARRYOVER = re.compile(r"^(?:Übertrag|Saldo\s+per)\b", re.I) +# Wiederkehrende Seiten-Kopf-/Fußzeilen (Titel, Spaltenköpfe, Fußnoten), ebenfalls kein Buchungstext +RE_PAGE_NOISE = re.compile( + r"^(?:K\s?O\s?N\s?T\s?O\s?A\s?U\s?S\s?Z\s?U\s?G" + r"|Kontonummer\s+Bankleitzahl" + r"|IBAN\s+BIC" + r"|Kontoinhaber\s+Ansprechpartner" + r"|Buchung/Wert\s+Buchungsinformation" + r"|Bitte\s+R(?:ü|ue)ckseite\s+beachten" + r"|Seite\s+\d+\s+von\s+\d+)", + re.I, +) + + +def _is_noise(line: str) -> bool: + return bool(RE_OPENING.search(line) or RE_CLOSING.search(line) + or RE_CARRYOVER.match(line) or RE_PAGE_NOISE.match(line)) + + +def parse(path: Path) -> ParsedStatement: + with pdfplumber.open(path) as pdf: + text = "\n".join((page.extract_text() or "") for page in pdf.pages) + period = RE_PERIOD.search(text) + if not period: + raise ParserError("HVB: Abrechnungszeitraum nicht gefunden") + period_start = parse_german_date(period.group(1)) + period_end = parse_german_date(period.group(2)) + opening = RE_OPENING.search(text) + closing = RE_CLOSING.search(text) + if not opening or not closing: + raise ParserError("HVB: Anfangs-/Endsaldo nicht gefunden") + iban_m = RE_IBAN.search(text) + txs: list[ParsedTransaction] = [] + pending: dict | None = None + for line in text.splitlines(): + line = line.strip() + if not line: + continue + m = RE_TX_HEAD.match(line) + if m: + if pending: + if pending["value"] is None: + raise ParserError("HVB: Buchungszeile ohne Detailzeile") + txs.append(_finish(pending)) + pending = { + "booking": parse_german_date(m.group(1), period_end.year), + "value": None, + "head": m.group(2).strip(), + "amount": parse_german_amount(m.group(3)), + "extra": [], + } + continue + if pending and pending["value"] is None: + d = RE_TX_DETAIL.match(line) + if d: + pending["value"] = parse_german_date(d.group(1), period_end.year) + if d.group(2).strip(): + pending["extra"].append(d.group(2).strip()) + continue + if pending and not _is_noise(line): + pending["extra"].append(line) + if pending: + if pending["value"] is None: + raise ParserError("HVB: Buchungszeile ohne Detailzeile") + txs.append(_finish(pending)) + return ParsedStatement(bank=BANK, + iban=iban_m.group(1).replace(" ", "") if iban_m else None, + period_start=period_start, period_end=period_end, + opening_balance=parse_german_amount(opening.group(1)), + closing_balance=parse_german_amount(closing.group(1)), + transactions=txs) + + +def _finish(p: dict) -> ParsedTransaction: + counterparty = p["extra"][0] if p["extra"] else "" + purpose = " ".join([p["head"], *p["extra"][1:]]).strip() + return ParsedTransaction(booking_date=p["booking"], value_date=p["value"], + amount=p["amount"], purpose=purpose, + counterparty=counterparty) diff --git a/finance/app/parsers/registry.py b/finance/app/parsers/registry.py new file mode 100644 index 0000000..3e5219c --- /dev/null +++ b/finance/app/parsers/registry.py @@ -0,0 +1,21 @@ +from pathlib import Path +from typing import Callable + +import pdfplumber + +from app.parsers import dkb, hvb, vr +from app.parsers.base import ParsedStatement, ParserError +from app.parsers.detect import detect_bank + +PARSERS: dict[str, Callable[[Path], ParsedStatement]] = { + "vr": vr.parse, "hvb": hvb.parse, "dkb": dkb.parse, +} + + +def parse_pdf(path: Path) -> ParsedStatement: + with pdfplumber.open(path) as pdf: + first = pdf.pages[0].extract_text() or "" + bank = detect_bank(first) + if bank is None: + raise ParserError("Bank nicht erkannt") + return PARSERS[bank](path) diff --git a/finance/app/parsers/vr.py b/finance/app/parsers/vr.py new file mode 100644 index 0000000..f97a2d2 --- /dev/null +++ b/finance/app/parsers/vr.py @@ -0,0 +1,80 @@ +"""Parser für Volksbank/Raiffeisenbank-Kontoauszüge. + +Realer Fixture-Abgleich (Task 7, Schritt 4): Der eigentliche Auszugszeitraum +steht nicht als "vom X bis Y"-Satz im Text (dieser Wortlaut kommt nur in einem +separaten quartalsweisen Zinsabschluss-Absatz mit abweichendem Zeitraum vor). +Anfangs-/Endsaldo tragen stattdessen je ein eigenes Datum ("alter/neuer +Kontostand vom DD.MM.YYYY ..."), aus dem der Auszugszeitraum abgeleitet wird; +RE_PERIOD bleibt als Fallback erhalten, falls ein Auszug diese Daten nicht +enthält. +""" +import re +from datetime import date +from pathlib import Path + +import pdfplumber + +from app.parsers.base import (ParsedStatement, ParsedTransaction, ParserError, + parse_german_amount, parse_german_date) + +BANK = "vr" +RE_IBAN = re.compile(r"\b([A-Z]{2}\d{2}(?:\s?\d{4}){4,5}(?:\s?\d{1,2})?)\b") +# Fallback, falls kein Datum an den Kontostand-Zeilen hängt: "vom 01.06.2020 bis 30.06.2020" +RE_PERIOD = re.compile(r"vom\s+(\d{2}\.\d{2}\.\d{4})\s+bis\s+(\d{2}\.\d{2}\.\d{4})") +# "alter Kontostand vom 01.06.2020 1.234,56 H" (Datum optional, je nach Formatvariante) +RE_OPENING = re.compile(r"alter\s+Kontostand\s+(?:vom\s+(\d{2}\.\d{2}\.\d{4})\s+)?([\d.,]+\s*[SH-]?)", re.I) +# "neuer Kontostand vom 30.06.2020 2.345,67 H" +RE_CLOSING = re.compile(r"neuer\s+Kontostand\s+(?:vom\s+(\d{2}\.\d{2}\.\d{4})\s+)?([\d.,]+\s*[SH-]?)", re.I) +# Buchungszeile: "01.06. 01.06. Muster GmbH 1.234,56 S" +RE_TX = re.compile(r"^(\d{2}\.\d{2}\.)\s+(\d{2}\.\d{2}\.)\s+(.*?)\s+([\d.,]+\s*[SH-])$") + + +def parse(path: Path) -> ParsedStatement: + with pdfplumber.open(path) as pdf: + text = "\n".join((page.extract_text() or "") for page in pdf.pages) + opening = RE_OPENING.search(text) + closing = RE_CLOSING.search(text) + if not opening or not closing: + raise ParserError("VR: Anfangs-/Endsaldo nicht gefunden") + if opening.group(1) and closing.group(1): + period_start = parse_german_date(opening.group(1)) + period_end = parse_german_date(closing.group(1)) + else: + period = RE_PERIOD.search(text) + if not period: + raise ParserError("VR: Abrechnungszeitraum nicht gefunden") + period_start = parse_german_date(period.group(1)) + period_end = parse_german_date(period.group(2)) + iban_m = RE_IBAN.search(text) + txs: list[ParsedTransaction] = [] + pending: dict | None = None + for line in text.splitlines(): + m = RE_TX.match(line.strip()) + if m: + if pending: + txs.append(_finish(pending)) + pending = { + "booking": parse_german_date(m.group(1), period_end.year), + "value": parse_german_date(m.group(2), period_end.year), + "head": m.group(3).strip(), + "amount": parse_german_amount(m.group(4)), + "extra": [], + } + elif pending and line.strip() and not RE_CLOSING.search(line): + pending["extra"].append(line.strip()) + if pending: + txs.append(_finish(pending)) + return ParsedStatement(bank=BANK, + iban=iban_m.group(1).replace(" ", "") if iban_m else None, + period_start=period_start, period_end=period_end, + opening_balance=parse_german_amount(opening.group(2)), + closing_balance=parse_german_amount(closing.group(2)), + transactions=txs) + + +def _finish(p: dict) -> ParsedTransaction: + counterparty = p["extra"][0] if p["extra"] else "" + purpose = " ".join([p["head"], *p["extra"][1:]]).strip() + return ParsedTransaction(booking_date=p["booking"], value_date=p["value"], + amount=p["amount"], purpose=purpose, + counterparty=counterparty) diff --git a/finance/scripts/dump_pdf_text.py b/finance/scripts/dump_pdf_text.py new file mode 100644 index 0000000..02b2bf8 --- /dev/null +++ b/finance/scripts/dump_pdf_text.py @@ -0,0 +1,9 @@ +"""PDF-Text anzeigen, um Parser-Regexe abzugleichen: python scripts/dump_pdf_text.py """ +import sys + +import pdfplumber + +with pdfplumber.open(sys.argv[1]) as pdf: + for i, page in enumerate(pdf.pages, 1): + print(f"--- Seite {i} ---") + print(page.extract_text() or "(kein Text)") diff --git a/finance/tests/fixtures/README.md b/finance/tests/fixtures/README.md new file mode 100644 index 0000000..51c476a --- /dev/null +++ b/finance/tests/fixtures/README.md @@ -0,0 +1,9 @@ +# Beispiel-Auszüge (nicht committen!) + +Hier pro Bank mindestens einen echten PDF-Kontoauszug ablegen: +- `vr_beispiel.pdf` (Volksbank/Raiffeisenbank) +- `hvb_beispiel.pdf` (HypoVereinsbank) +- `dkb_beispiel.pdf` (DKB) + +Beträge dürfen verfremdet sein, das Layout muss echt sein. +PDFs sind via .gitignore vom Repo ausgeschlossen. diff --git a/finance/tests/test_bank_parsers.py b/finance/tests/test_bank_parsers.py new file mode 100644 index 0000000..90e4a1a --- /dev/null +++ b/finance/tests/test_bank_parsers.py @@ -0,0 +1,24 @@ +from decimal import Decimal +from pathlib import Path + +import pytest + +from app.parsers.registry import parse_pdf +from app.parsers.validate import balance_difference + +FIXTURES = Path(__file__).parent / "fixtures" +CASES = [("vr_beispiel.pdf", "vr"), ("hvb_beispiel.pdf", "hvb"), + ("dkb_beispiel.pdf", "dkb")] + + +@pytest.mark.parametrize("filename,bank", CASES) +def test_parse_fixture(filename, bank): + path = FIXTURES / filename + if not path.exists(): + pytest.skip(f"Fixture {filename} fehlt") + stmt = parse_pdf(path) + assert stmt.bank == bank + assert stmt.opening_balance is not None + assert stmt.closing_balance is not None + assert len(stmt.transactions) > 0 + assert balance_difference(stmt) == Decimal("0")