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

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)