feat: PDF-Parser VR/HVB/DKB mit Registry und Fixture-Tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 17:50:07 +02:00
parent 78d94a583b
commit 101ee01af1
8 changed files with 341 additions and 1 deletions

View File

@@ -1,6 +1,6 @@
MARKERS = [
("vr", ["Volksbank", "Raiffeisenbank", "VR-Bank", "VR Bank"]),
("hvb", ["HypoVereinsbank", "UniCredit"]),
("hvb", ["HypoVereinsbank", "UniCredit", "Unicredit", "HYVEDEMM"]),
("dkb", ["Deutsche Kreditbank", "DKB"]),
]

View File

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

118
finance/app/parsers/hvb.py Normal file
View File

@@ -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
"<Start> - <Ende> <Auszug-Nr.> <Seite>".
"""
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: "... <Zeitraum-Start> - <Zeitraum-Ende> <Auszug-Nr> <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)

View File

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

80
finance/app/parsers/vr.py Normal file
View File

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

View File

@@ -0,0 +1,9 @@
"""PDF-Text anzeigen, um Parser-Regexe abzugleichen: python scripts/dump_pdf_text.py <pdf>"""
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)")

9
finance/tests/fixtures/README.md vendored Normal file
View File

@@ -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.

View File

@@ -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")