diff --git a/finance/app/parsers/__init__.py b/finance/app/parsers/__init__.py new file mode 100644 index 0000000..473a0f4 diff --git a/finance/app/parsers/base.py b/finance/app/parsers/base.py new file mode 100644 index 0000000..fe68406 --- /dev/null +++ b/finance/app/parsers/base.py @@ -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))) diff --git a/finance/app/parsers/detect.py b/finance/app/parsers/detect.py new file mode 100644 index 0000000..a6e7009 --- /dev/null +++ b/finance/app/parsers/detect.py @@ -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 diff --git a/finance/app/parsers/validate.py b/finance/app/parsers/validate.py new file mode 100644 index 0000000..2dad83b --- /dev/null +++ b/finance/app/parsers/validate.py @@ -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() diff --git a/finance/tests/test_parser_base.py b/finance/tests/test_parser_base.py new file mode 100644 index 0000000..bafdef7 --- /dev/null +++ b/finance/tests/test_parser_base.py @@ -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