feat: Parser-Basis, Bank-Erkennung, Saldo-/Duplikat-Pruefung

- ParsedTransaction und ParsedStatement Datenklassen
- parse_german_amount(): German format number parsing (1.234,56 -> Decimal)
- parse_german_date(): DD.MM.YYYY date parsing with optional year
- detect_bank(): Recognize VR/HVB/DKB from text markers
- balance_difference(): Validate statement balance consistency
- dedup_hash(): SHA256-based transaction deduplication

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 17:30:10 +02:00
parent 877cba4964
commit d91bb2e331
5 changed files with 121 additions and 0 deletions

View File

View File

@@ -0,0 +1,46 @@
import re
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
class ParserError(Exception):
pass
@dataclass(frozen=True)
class ParsedTransaction:
booking_date: date
value_date: date | None
amount: Decimal
purpose: str
counterparty: str
@dataclass
class ParsedStatement:
bank: str
iban: str | None
period_start: date | None
period_end: date | None
opening_balance: Decimal | None
closing_balance: Decimal | None
transactions: list[ParsedTransaction] = field(default_factory=list)
def parse_german_amount(s: str) -> Decimal:
s = s.strip().replace(" ", "").replace(" ", "")
neg = s.endswith("-") or s.startswith("-") or s.endswith("S")
s = s.strip("+-SH")
value = Decimal(s.replace(".", "").replace(",", "."))
return -value if neg else value
def parse_german_date(s: str, default_year: int | None = None) -> date:
m = re.fullmatch(r"(\d{2})\.(\d{2})\.(\d{4})?", s.strip())
if not m:
raise ParserError(f"Kein Datum: {s!r}")
year = int(m.group(3)) if m.group(3) else default_year
if year is None:
raise ParserError(f"Jahr fehlt: {s!r}")
return date(year, int(m.group(2)), int(m.group(1)))

View File

@@ -0,0 +1,12 @@
MARKERS = [
("vr", ["Volksbank", "Raiffeisenbank", "VR-Bank", "VR Bank"]),
("hvb", ["HypoVereinsbank", "UniCredit"]),
("dkb", ["Deutsche Kreditbank", "DKB"]),
]
def detect_bank(text: str) -> str | None:
for bank, needles in MARKERS:
if any(n in text for n in needles):
return bank
return None

View File

@@ -0,0 +1,15 @@
import hashlib
from datetime import date
from decimal import Decimal
from app.parsers.base import ParsedStatement
def balance_difference(p: ParsedStatement) -> Decimal:
total = sum((t.amount for t in p.transactions), Decimal("0"))
return (p.opening_balance + total - p.closing_balance).copy_abs()
def dedup_hash(account_id: int, booking_date: date, amount: Decimal, purpose: str) -> str:
raw = f"{account_id}|{booking_date.isoformat()}|{amount:.2f}|{' '.join(purpose.split())}"
return hashlib.sha256(raw.encode()).hexdigest()