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:
@@ -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
|
||||||
@@ -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"]
|
||||||
Reference in New Issue
Block a user