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>
317 lines
12 KiB
Python
317 lines
12 KiB
Python
"""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)
|