Files
certain/backend/app/services/abo_erkennung.py
T
ScarriffleandClaude Opus 5 0ea2ab1322 feat: Buchungen lassen sich von Hand einem Abo zuordnen – und das merkt sich
Die Erkennung lief seit Stufe 3, aber nur automatisch: Was sie nicht
sicher traf, blieb liegen, und sehen konnte man davon nichts. Die
Endpunkte gab es, die Oberflaeche kannte keinen davon.

Jetzt steht in der Buchungsliste eine Spalte "Abo" – dieselbe Form wie
die Kategorie daneben: Die Auswahl zeigt, zu welchem Vertrag die Zahlung
gehoert, aendert es, und leer loest die Zuordnung. Uebertraege bleiben
aussen vor; sie verschieben nur Geld.

Und sie lernt. Musste man von Hand ran, hat der Erkennungstext nicht
gepasst – also wird der Empfaenger dieser Buchung der neue. Beim
naechsten Mal faellt dieselbe Zahlung von selbst zu, statt dass man
jeden Monat dasselbe klickt. Der Vertragsname bleibt als zweite Chance:
_passt_text prueft beide, nicht entweder/oder, sonst verlaere man beim
Lernen, was vorher schon getroffen hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 09:59:55 +02:00

317 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Welche Buchung gehört zu welchem Vertrag.
Eine Schicht OBENDRAUF, wie die Übertrags-Verknüpfung: An der Buchung ändert
sich nichts. Hier steht nur, dass sie die Abbuchung eines Vertrags ist. Fällt
die Zuordnung falsch aus, zieht man ein Etikett ab – keine Buchung ist verbogen.
Ein Vertrag weiß vier Dinge über seine Abbuchung: Konto, Betrag, ungefähres
Datum und einen Text. Daraus ergeben sich drei Stufen:
**Sicher** Betrag stimmt auf den Cent, und der Text passt. Wird
zugeordnet, ohne zu fragen.
**Vorschlag** Betrag stimmt, Text nicht – oder der Text passt, aber der
Betrag ist ein anderer. Das zweite ist der interessante Fall:
So sieht eine Preiserhöhung aus.
**Nichts** Alles andere. Lieber nichts sagen als daneben raten.
Was hier NICHT passiert: den Preis am Vertrag stillschweigend nachziehen. Eine
Preiserhöhung ist eine Nachricht an den Nutzer, kein Wartungsvorgang.
"""
from __future__ import annotations
import logging
from collections import defaultdict
from dataclasses import dataclass
from datetime import date, timedelta
from dateutil.relativedelta import relativedelta
from sqlalchemy.orm import Session
from ..models import Account, Subscription, Transaction, TxnType, User
from . import currency
from .csv_import import normalize_text
log = logging.getLogger(__name__)
# Wie weit das Buchungsdatum von der erwarteten Fälligkeit abweichen darf und
# trotzdem als dieselbe Abbuchung gilt. Grosszügig: Banken buchen über
# Wochenenden und Feiertage verschoben, und wer den Vertrag von Hand angelegt
# hat, kennt den genauen Tag oft nicht.
FENSTER_TAGE = 12
# Ohne eingetragene Fälligkeit muss ein weiteres Fenster herhalten – dann
# entscheidet praktisch allein der Betrag samt Text.
FENSTER_OHNE_FAELLIGKEIT = 45
# Wie weit der Betrag abweichen darf und noch als PREISÄNDERUNG desselben
# Vertrags gilt (statt als fremde Zahlung). Halb so viel bis doppelt so viel:
# Ein Abo, das sich verdreifacht, ist kein Abo mehr, sondern etwas anderes.
PREIS_UNTEN = 0.5
PREIS_OBEN = 2.0
@dataclass
class Treffer:
"""Eine Buchung und der Vertrag, zu dem sie gehören könnte."""
txn: Transaction
vertrag: Subscription
sicher: bool
# Der Betrag des Vertrags in der Währung der Buchung – zum Vergleichen.
erwartet_cents: int | None
@property
def preis_geaendert(self) -> bool:
return self.erwartet_cents is not None and self.txn.amount_cents != self.erwartet_cents
def _texte(t: Transaction) -> str:
return normalize_text(f"{t.payee or ''} {t.description or ''}")
def _passt_text(vertrag: Subscription, text: str) -> bool:
"""Steht der Erkennungstext des Vertrags in der Buchung?
Wortweise und nicht als Ganzes: Auf dem Auszug steht „NETFLIX.COM 1234"
oder „Salt Mobile SA Rechnung" – der eingetragene Name kommt dort selten
am Stück vor. Kurze Wörter bleiben draussen; „SA" oder „AG" trifft sonst
die halbe Kontoauszug.
"""
# Beides pruefen, nicht entweder/oder: Der gelernte Text (payee_pattern)
# kommt beim Zuordnen von Hand dazu und soll den Namen nicht verdraengen.
for roh in (vertrag.payee_pattern, vertrag.name):
muster = normalize_text(roh)
if not muster:
continue
if muster in text:
return True
woerter = [w for w in muster.split() if len(w) >= 4]
if woerter and any(w in text for w in woerter):
return True
return False
def _erwartet_in(db: Session, vertrag: Subscription, konto: Account,
am: date) -> int | None:
"""Was der Vertrag in der Währung DES KONTOS kosten sollte.
Ein Vertrag in Euro, bezahlt von einem Franken-Konto, kommt jeden Monat mit
einem anderen Frankenbetrag an. Der exakte Vergleich wäre dort immer
falsch – also wird umgerechnet, und der Betrag darf danach etwas
abweichen (siehe ``_betrag_passt``).
"""
if vertrag.currency == konto.currency:
return vertrag.amount_cents
return currency.convert(db, vertrag.amount_cents, vertrag.currency,
konto.currency, am)
def _betrag_passt(ist: int, erwartet: int, gleiche_waehrung: bool) -> bool:
"""Ist das derselbe Betrag?
Bei gleicher Währung auf den Cent – ein Abo kostet, was es kostet. Bei
umgerechneter Währung mit Spielraum: Der Kurs von heute ist nicht der vom
Buchungstag, und die Bank rechnet mit ihrem eigenen.
"""
if gleiche_waehrung:
return ist == erwartet
if erwartet <= 0:
return False
return abs(ist - erwartet) <= max(50, round(erwartet * 0.05))
def _im_fenster(tag: date, faellig: date | None, intervall: int) -> bool:
"""Liegt der Tag nahe genug an einer erwarteten Fälligkeit?
Gerechnet wird über MEHRERE Intervalle rückwärts und vorwärts: Wer seine
Verträge nachträgt, hat Buchungen aus Monaten, die längst vorbei sind, und
``next_due`` steht in der Zukunft.
"""
if faellig is None:
return True
for n in range(-24, 25):
erwartet = faellig + relativedelta(months=intervall * n)
if abs((tag - erwartet).days) <= FENSTER_TAGE:
return True
if erwartet > tag + timedelta(days=FENSTER_TAGE * 2) and n > 0:
break
return False
def _kandidaten(db: Session, user: User) -> list[Transaction]:
"""Buchungen, die noch keinem Vertrag zugeordnet sind.
Überträge bleiben draussen: Ein Abo wird bezahlt, nicht zwischen eigenen
Konten verschoben. Verknüpfte Buchungen ebenfalls – die sind schon als
etwas anderes erkannt.
"""
from .uebertraege import verknuepfte_ids
verknuepft = verknuepfte_ids(db, user)
return [
t for t in db.query(Transaction).filter(
Transaction.owner_id == user.id,
Transaction.type == TxnType.expense,
Transaction.subscription_id.is_(None),
)
if t.id not in verknuepft
]
def _pruefe(db: Session, t: Transaction, vertrag: Subscription,
konto: Account) -> Treffer | None:
"""Passt diese eine Buchung zu diesem einen Vertrag?"""
if vertrag.account_id is not None and vertrag.account_id != t.account_id:
return None
erwartet = _erwartet_in(db, vertrag, konto, t.date)
if erwartet is None:
return None
gleiche = vertrag.currency == konto.currency
betrag_ok = _betrag_passt(t.amount_cents, erwartet, gleiche)
text_ok = _passt_text(vertrag, _texte(t))
if betrag_ok and text_ok and _im_fenster(t.date, vertrag.next_due,
vertrag.interval_months):
return Treffer(t, vertrag, True, erwartet)
# Betrag stimmt, Text nicht: Auf dem Auszug steht oft der Abwickler
# ("STRIPE") und nicht die Marke. Fragen statt raten.
if betrag_ok and _im_fenster(t.date, vertrag.next_due, vertrag.interval_months):
return Treffer(t, vertrag, False, erwartet)
# Text stimmt, Betrag nicht – SO SIEHT EINE PREISERHOEHUNG AUS. Der
# wertvollste Fall, und einer, den man nie automatisch verbuchen darf.
if text_ok and erwartet > 0:
verhaeltnis = t.amount_cents / erwartet
if PREIS_UNTEN <= verhaeltnis <= PREIS_OBEN:
fenster = (vertrag.next_due is not None
and _im_fenster(t.date, vertrag.next_due, vertrag.interval_months))
ohne = vertrag.next_due is None
if fenster or ohne:
return Treffer(t, vertrag, False, erwartet)
return None
def finde(db: Session, user: User, nur_vorschlaege: bool = False) -> dict:
"""Buchungen ihren Verträgen zuordnen.
Eindeutig Sicheres wird zugeordnet, der Rest kommt als Vorschlag zurück.
Mit ``nur_vorschlaege`` wird NICHTS zugeordnet – das braucht jede Ansicht,
die bloss fragen will, ohne beim Hinsehen schon etwas zu verändern.
"""
vertraege = (
db.query(Subscription)
.filter(Subscription.owner_id == user.id, Subscription.is_active.is_(True))
.all()
)
if not vertraege:
return {"zugeordnet": 0, "vorschlaege": [], "preisaenderungen": []}
konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
zugeordnet = 0
vorschlaege: list[Treffer] = []
for t in _kandidaten(db, user):
konto = konten.get(t.account_id)
if konto is None:
continue
treffer = [x for x in (_pruefe(db, t, v, konto) for v in vertraege) if x]
if not treffer:
continue
sichere = [x for x in treffer if x.sicher]
# Nur wenn GENAU EINER sicher passt. Zwei Abos zum selben Preis am
# selben Tag gibt es, und dann soll der Nutzer entscheiden.
if len(sichere) == 1 and not nur_vorschlaege:
t.subscription_id = sichere[0].vertrag.id
_ruecke_faelligkeit(sichere[0].vertrag, t.date)
zugeordnet += 1
continue
if len(sichere) == 1:
vorschlaege.append(sichere[0])
continue
# Mehrdeutig oder unsicher: der beste Vorschlag je Buchung genuegt.
vorschlaege.append(sichere[0] if sichere else treffer[0])
if zugeordnet:
db.commit()
return {
"zugeordnet": zugeordnet,
"vorschlaege": [_kurz(db, x) for x in vorschlaege],
"preisaenderungen": [_kurz(db, x) for x in vorschlaege if x.preis_geaendert],
}
def _ruecke_faelligkeit(vertrag: Subscription, gebucht_am: date) -> None:
"""Die nächste Fälligkeit hinter die gerade erkannte Abbuchung schieben.
Nur vorwärts: Wer alte Buchungen nachträgt, soll die Fälligkeit nicht in
die Vergangenheit ziehen.
"""
naechste = gebucht_am + relativedelta(months=vertrag.interval_months)
if vertrag.next_due is None or naechste > vertrag.next_due:
vertrag.next_due = naechste
def _kurz(db: Session, x: Treffer) -> dict:
konto = db.get(Account, x.txn.account_id)
return {
"txn_id": x.txn.id,
"date": x.txn.date.isoformat(),
"amount_cents": x.txn.amount_cents,
"currency": konto.currency if konto else "",
"payee": x.txn.payee,
"description": x.txn.description,
"account_name": konto.name if konto else "",
"subscription_id": x.vertrag.id,
"subscription_name": x.vertrag.name,
"erwartet_cents": x.erwartet_cents,
"preis_geaendert": x.preis_geaendert,
}
def verlauf(db: Session, user: User, vertrag: Subscription) -> list[dict]:
"""Die erkannten Abbuchungen eines Vertrags, neueste zuerst.
Damit beantwortet sich „was habe ich dafür insgesamt bezahlt" – und man
sieht, wann der Preis gestiegen ist.
"""
konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
zeilen = []
for t in (
db.query(Transaction)
.filter(Transaction.owner_id == user.id,
Transaction.subscription_id == vertrag.id)
.order_by(Transaction.date.desc(), Transaction.id.desc())
):
konto = konten.get(t.account_id)
zeilen.append({
"txn_id": t.id,
"date": t.date.isoformat(),
"amount_cents": t.amount_cents,
"currency": konto.currency if konto else "",
"payee": t.payee,
"account_name": konto.name if konto else "",
})
return zeilen
def summe_je_vertrag(db: Session, user: User) -> dict[int, dict]:
"""Je Vertrag: wie viele Abbuchungen erkannt sind und was sie zusammen
gekostet haben. Fuer die Liste, ohne je Zeile einzeln nachzufragen."""
ergebnis: dict[int, dict] = defaultdict(lambda: {"anzahl": 0, "summe_cents": 0,
"letzte": None})
for t in db.query(Transaction).filter(
Transaction.owner_id == user.id,
Transaction.subscription_id.isnot(None),
):
eintrag = ergebnis[t.subscription_id]
eintrag["anzahl"] += 1
eintrag["summe_cents"] += t.amount_cents
iso = t.date.isoformat()
if eintrag["letzte"] is None or iso > eintrag["letzte"]:
eintrag["letzte"] = iso
return dict(ergebnis)