feat: Erkennung möglicher Doppelzählung Kredit/Fixposten (A12-T2)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 07:27:30 +02:00
co-authored by Claude Opus 5.5
parent 0a5fa90ae8
commit 42d3d95e48
2 changed files with 226 additions and 0 deletions
+127
View File
@@ -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
+99
View File
@@ -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"]