From 42d3d95e4822f0995760d0c44b23ef6ff281f77e73599d9624145d32155a9d67 Mon Sep 17 00:00:00 2001 From: wlfb Date: Tue, 29 Sep 2026 07:27:30 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Erkennung=20m=C3=B6glicher=20Doppelz?= =?UTF-8?q?=C3=A4hlung=20Kredit/Fixposten=20(A12-T2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- finance/app/services/double_count.py | 127 +++++++++++++++++++++++++++ finance/tests/test_double_count.py | 99 +++++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 finance/app/services/double_count.py create mode 100644 finance/tests/test_double_count.py diff --git a/finance/app/services/double_count.py b/finance/app/services/double_count.py new file mode 100644 index 0000000..7922221 --- /dev/null +++ b/finance/app/services/double_count.py @@ -0,0 +1,127 @@ +"""Doppelzählungs-Erkennung Kredit ↔ wiederkehrender Posten (Ausbaustufe 12). + +Ein monatlicher Ausgabe-Posten gilt als vermutlich doppelt zu einem Kredit, +wenn sein Betrag innerhalb der Toleranz der regulären Monatsrate liegt und +sich die Zeiträume überschneiden. Bewusst kein Namensvergleich +(„Autokredit" vs. „Rate VW Bank" ist nicht zuverlässig zuordenbar). +Reine Logik auf Wertobjekten, damit auch noch nicht gespeicherte bzw. per +PATCH veränderte Zustände geprüft werden können. Alle Beträge Decimal. +""" +from dataclasses import dataclass +from datetime import date +from decimal import ROUND_HALF_UP, Decimal + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.engine.loans import add_months, annuity_payment +from app.models.tables import Loan, RecurringItem + +CODE = "doppelzaehlung_kredit" +TOLERANZ_PROZENT = Decimal("3") +TOLERANZ_MIN = Decimal("1.00") +CENT = Decimal("0.01") + + +@dataclass(frozen=True) +class PostenDaten: + id: int | None + name: str + amount: Decimal + rhythm: str + start_date: date | None + end_date: date | None + + +@dataclass(frozen=True) +class KreditDaten: + id: int | None + name: str + principal: Decimal + annual_rate_pct: Decimal + term_months: int + payout_date: date + repayment_type: str + + +@dataclass(frozen=True) +class Konflikt: + posten_id: int | None + posten_name: str + posten_betrag: Decimal + kredit_id: int | None + kredit_name: str + kredit_rate: Decimal + + @property + def paar(self) -> tuple[int | None, int | None]: + return (self.posten_id, self.kredit_id) + + +def posten_aus(item: RecurringItem) -> PostenDaten: + return PostenDaten(item.id, item.name, Decimal(item.amount), item.rhythm, + item.start_date, item.end_date) + + +def kredit_aus(loan: Loan) -> KreditDaten: + return KreditDaten(loan.id, loan.name, Decimal(loan.principal), + Decimal(loan.annual_rate_pct), loan.term_months, + loan.payout_date, loan.repayment_type) + + +def regular_rate(k: KreditDaten) -> Decimal | None: + """Reguläre Monatsrate (positiv): Annuität bzw. bei endfälligen Krediten + die monatlichen Zinsen. Ohne reguläre Rate (0 % endfällig, Laufzeit <= 0) + -> None, dann nie Konflikt. Die Schlusszahlung wird nicht betrachtet.""" + if k.term_months <= 0: + return None + if k.repayment_type == "bullet": + if k.annual_rate_pct == 0: + return None + return (k.principal * k.annual_rate_pct / Decimal(100) / Decimal(12) + ).quantize(CENT, ROUND_HALF_UP) + return annuity_payment(k.principal, k.annual_rate_pct, k.term_months) + + +def rate_period(k: KreditDaten) -> tuple[date, date]: + """Erste und letzte Rate, identisch zu engine/loans.loan_schedule.""" + return add_months(k.payout_date, 1), add_months(k.payout_date, k.term_months) + + +def find_conflict(p: PostenDaten, k: KreditDaten) -> Konflikt | None: + if p.rhythm != "monthly" or p.amount >= 0: + return None + rate = regular_rate(k) + if rate is None or rate <= 0: + return None + toleranz = max(TOLERANZ_MIN, rate * TOLERANZ_PROZENT / Decimal(100)) + if abs(-p.amount - rate) > toleranz: + return None + erste, letzte = rate_period(k) + if p.start_date is not None and p.start_date > letzte: + return None + if p.end_date is not None and p.end_date < erste: + return None + return Konflikt(p.id, p.name, p.amount, k.id, k.name, rate) + + +def conflicts_for_recurring(session: Session, p: PostenDaten) -> list[Konflikt]: + loans = session.execute(select(Loan).order_by(Loan.name, Loan.id)).scalars() + return [c for c in (find_conflict(p, kredit_aus(l)) for l in loans) if c] + + +def conflicts_for_loan(session: Session, k: KreditDaten) -> list[Konflikt]: + items = session.execute(select(RecurringItem) + .order_by(RecurringItem.name, RecurringItem.id)).scalars() + return [c for c in (find_conflict(posten_aus(r), k) for r in items) if c] + + +def all_conflicts(session: Session) -> list[Konflikt]: + kredite = [kredit_aus(l) for l in session.execute( + select(Loan).order_by(Loan.name, Loan.id)).scalars()] + out: list[Konflikt] = [] + for r in session.execute(select(RecurringItem) + .order_by(RecurringItem.name, RecurringItem.id)).scalars(): + p = posten_aus(r) + out.extend(c for c in (find_conflict(p, k) for k in kredite) if c) + return out diff --git a/finance/tests/test_double_count.py b/finance/tests/test_double_count.py new file mode 100644 index 0000000..1a3aef2 --- /dev/null +++ b/finance/tests/test_double_count.py @@ -0,0 +1,99 @@ +from datetime import date +from decimal import Decimal + +import pytest + +from app.models.tables import Loan, RecurringItem +from app.services.double_count import (KreditDaten, PostenDaten, all_conflicts, + conflicts_for_loan, conflicts_for_recurring, + find_conflict, rate_period, regular_rate) + +D = Decimal +# 1.200 € zu 0 % auf 12 Monate, Auszahlung 15.01.2026 -> Rate 100,00; +# erste Rate 15.02.2026, letzte 15.01.2027. +K100 = KreditDaten(1, "Auto", D("1200.00"), D("0"), 12, date(2026, 1, 15), "annuity") + + +def P(amount, rhythm="monthly", start=None, end=None, name="Rate"): + return PostenDaten(7, name, D(amount), rhythm, start, end) + + +def test_regular_rate_und_zeitraum(): + assert regular_rate(K100) == D("100.00") + assert rate_period(K100) == (date(2026, 2, 15), date(2027, 1, 15)) + annu = KreditDaten(2, "A", D("5000.00"), D("6.0"), 48, date(2026, 1, 1), "annuity") + assert regular_rate(annu) == D("117.43") + bullet = KreditDaten(3, "B", D("12000.00"), D("6.0"), 24, date(2026, 1, 1), "bullet") + assert regular_rate(bullet) == D("60.00") + bullet0 = KreditDaten(4, "B0", D("12000.00"), D("0"), 24, date(2026, 1, 1), "bullet") + assert regular_rate(bullet0) is None + + +@pytest.mark.parametrize("amount,hit", [ + ("-100.00", True), ("-103.00", True), ("-103.01", False), + ("-97.00", True), ("-96.99", False), +]) +def test_toleranz_drei_prozent(amount, hit): + assert (find_conflict(P(amount), K100) is not None) is hit + + +@pytest.mark.parametrize("amount,hit", [("-21.00", True), ("-21.01", False), ("-19.00", True)]) +def test_mindesttoleranz_ein_euro(amount, hit): + k20 = KreditDaten(5, "Klein", D("240.00"), D("0"), 12, date(2026, 1, 15), "annuity") + assert (find_conflict(P(amount), k20) is not None) is hit + + +def test_endfaellig_zinsrate_und_nullzins(): + bullet = KreditDaten(3, "B", D("12000.00"), D("6.0"), 24, date(2026, 1, 1), "bullet") + assert find_conflict(P("-60.00"), bullet) is not None + bullet0 = KreditDaten(4, "B0", D("12000.00"), D("0"), 24, date(2026, 1, 1), "bullet") + assert find_conflict(P("-60.00"), bullet0) is None + assert find_conflict(P("-0.50"), bullet0) is None + + +def test_nur_monatliche_ausgaben(): + assert find_conflict(P("-100.00", rhythm="quarterly"), K100) is None + assert find_conflict(P("100.00"), K100) is None + + +@pytest.mark.parametrize("start,end,hit", [ + (None, date(2026, 2, 14), False), (None, date(2026, 2, 15), True), + (date(2027, 1, 16), None, False), (date(2027, 1, 15), None, True), + (None, None, True), (date(2025, 1, 1), date(2030, 1, 1), True), +]) +def test_zeitraeume(start, end, hit): + assert (find_conflict(P("-100.00", start=start, end=end), K100) is not None) is hit + + +def test_konflikt_inhalt(): + k = find_conflict(P("-101.50", name="Rate Auto"), K100) + assert (k.posten_id, k.posten_name, k.posten_betrag) == (7, "Rate Auto", D("-101.50")) + assert (k.kredit_id, k.kredit_name, k.kredit_rate) == (1, "Auto", D("100.00")) + assert k.paar == (7, 1) + + +def _seed(db): + db.add_all([ + Loan(name="Auto", principal=D("1200.00"), annual_rate_pct=D("0"), term_months=12, + payout_date=date(2026, 1, 15), repayment_type="annuity"), + Loan(name="Möbel", principal=D("240.00"), annual_rate_pct=D("0"), term_months=12, + payout_date=date(2026, 1, 15), repayment_type="annuity"), + RecurringItem(name="Rate Auto", amount=D("-100.00"), rhythm="monthly", due_day=15), + RecurringItem(name="Rate Möbel", amount=D("-20.00"), rhythm="monthly", due_day=15), + RecurringItem(name="Miete", amount=D("-600.00"), rhythm="monthly", due_day=1), + ]) + db.commit() + + +def test_all_conflicts_findet_paare_sortiert(db): + _seed(db) + assert [(k.posten_name, k.kredit_name) for k in all_conflicts(db)] == [ + ("Rate Auto", "Auto"), ("Rate Möbel", "Möbel")] + + +def test_conflicts_for_recurring_und_loan(db): + _seed(db) + neu = PostenDaten(None, "Neu", D("-99.00"), "monthly", None, None) + assert [k.kredit_name for k in conflicts_for_recurring(db, neu)] == ["Auto"] + kredit = KreditDaten(None, "Neu", D("7200.00"), D("0"), 12, date(2026, 1, 1), "annuity") + assert [k.posten_name for k in conflicts_for_loan(db, kredit)] == ["Miete"]