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:
0
finance/app/parsers/__init__.py
Normal file
0
finance/app/parsers/__init__.py
Normal file
46
finance/app/parsers/base.py
Normal file
46
finance/app/parsers/base.py
Normal 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)))
|
||||
12
finance/app/parsers/detect.py
Normal file
12
finance/app/parsers/detect.py
Normal 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
|
||||
15
finance/app/parsers/validate.py
Normal file
15
finance/app/parsers/validate.py
Normal 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()
|
||||
48
finance/tests/test_parser_base.py
Normal file
48
finance/tests/test_parser_base.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from app.parsers.base import (ParsedStatement, ParsedTransaction,
|
||||
parse_german_amount, parse_german_date)
|
||||
from app.parsers.detect import detect_bank
|
||||
from app.parsers.validate import balance_difference, dedup_hash
|
||||
|
||||
|
||||
def test_parse_german_amount():
|
||||
assert parse_german_amount("1.234,56") == Decimal("1234.56")
|
||||
assert parse_german_amount("1.234,56-") == Decimal("-1234.56")
|
||||
assert parse_german_amount("12,00 S") == Decimal("-12.00")
|
||||
assert parse_german_amount("12,00 H") == Decimal("12.00")
|
||||
|
||||
|
||||
def test_parse_german_date():
|
||||
assert parse_german_date("03.06.2026") == date(2026, 6, 3)
|
||||
assert parse_german_date("03.06.", default_year=2026) == date(2026, 6, 3)
|
||||
|
||||
|
||||
def test_detect_bank():
|
||||
assert detect_bank("... Volksbank Musterstadt eG ...") == "vr"
|
||||
assert detect_bank("... Raiffeisenbank ...") == "vr"
|
||||
assert detect_bank("... HypoVereinsbank ...") == "hvb"
|
||||
assert detect_bank("... UniCredit Bank ...") == "hvb"
|
||||
assert detect_bank("... Deutsche Kreditbank AG ...") == "dkb"
|
||||
assert detect_bank("... DKB ...") == "dkb"
|
||||
assert detect_bank("irgendwas") is None
|
||||
|
||||
|
||||
def _stmt(txs, opening, closing):
|
||||
return ParsedStatement(bank="vr", iban=None, period_start=None,
|
||||
period_end=None, opening_balance=opening,
|
||||
closing_balance=closing, transactions=txs)
|
||||
|
||||
|
||||
def test_balance_difference():
|
||||
txs = [ParsedTransaction(date(2026, 6, 1), None, Decimal("-50"), "Miete", "V"),
|
||||
ParsedTransaction(date(2026, 6, 2), None, Decimal("20"), "Gutschrift", "K")]
|
||||
assert balance_difference(_stmt(txs, Decimal("100"), Decimal("70"))) == Decimal("0")
|
||||
assert balance_difference(_stmt(txs, Decimal("100"), Decimal("75"))) == Decimal("5")
|
||||
|
||||
|
||||
def test_dedup_hash_normalizes_whitespace():
|
||||
a = dedup_hash(1, date(2026, 6, 1), Decimal("-50.00"), "Miete Juni")
|
||||
b = dedup_hash(1, date(2026, 6, 1), Decimal("-50.00"), "Miete Juni")
|
||||
assert a == b and len(a) == 64
|
||||
Reference in New Issue
Block a user