"""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-])$") # Folgeseiten-Kopfblock (Bankanschrift bis Spaltenüberschrift) als Sequenz # überspringen: fängt auch die Kontoinhaber-Kopfzeile ab, ohne gleichnamige # Gegenparteien in Buchungen zu zerstören. RE_HEADER_START = re.compile(r"^Beraterpark am Olgaplatz") RE_HEADER_END = re.compile(r"^Bu-Tag\s+Wert\s+Vorgang") # Blattwechsel-/Formular-Reste, die sonst in den Verwendungszweck rutschen. RE_NOISE = re.compile( r"(?:Übertrag" # "Übertrag auf/von Blatt N" r"|^\d{3,4}$" # Formularcodes (z. B. 0007, 000) r"|^K\d{6}" # Formularcode (K + Ziffernblock) r"|Bitte beachten Sie die Hinweise" # Fußzeilen-Hinweis r"|^[─—–\-]{3,}$" # Trennlinien r")" ) def _is_noise(line: str) -> bool: return bool(RE_NOISE.search(line)) def _join_purpose(parts: list[str]) -> str: """Verwendungszweck zusammensetzen und harte Zeilenumbrüche der VR-PDF (~54 Zeichen, teils mitten im Wort) rekonstruieren. Ohne Leerzeichen wird nur dann verbunden, wenn die Vorzeile mindestens 53 Zeichen lang ist UND (Vorzeile endet auf Ziffer und Folgezeile beginnt mit Ziffer) ODER (beide an der Bruchstelle Kleinbuchstaben) – sonst mit Leerzeichen. So bleibt z. B. "... 75" + "Euro" mit Leerzeichen getrennt. """ if not parts: return "" out = parts[0] for prev, cur in zip(parts, parts[1:]): glue = "" if len(prev) >= 53 and prev and cur: if (prev[-1].isdigit() and cur[0].isdigit()) or \ (prev[-1].islower() and cur[0].islower()): glue = "" else: glue = " " else: glue = " " out += glue + cur return out.strip() 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 in_header = False for line in text.splitlines(): line = line.strip() if not line: continue # Folgeseiten-Kopfblock komplett überspringen (inkl. Kontoinhaber-Zeile). if in_header: if RE_HEADER_END.match(line): in_header = False continue if RE_HEADER_START.match(line): in_header = True continue # Schlusssaldo erreicht: laufende Buchung abschließen und Sammlung beenden. if RE_CLOSING.search(line): break m = RE_TX.match(line) 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 not _is_noise(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_purpose([p["head"], *p["extra"][1:]]) return ParsedTransaction(booking_date=p["booking"], value_date=p["value"], amount=p["amount"], purpose=purpose, counterparty=counterparty)