Die Erkennung gab es laengst – aber nur auf der Seite "Zu erledigen", in einer Karte, die man erst finden muss. Wer in der Buchungsliste steht und die zweite Haelfte eines Uebertrags sieht, greift stattdessen zum ↔-Knopf und legt einen zweiten Uebertrag an. Genau so ist heute eine Zahlung doppelt verschoben worden. Jetzt steht die Frage in der Zeile: "Uebertrag von/nach Revolut CHF?" mit Verbinden und Ignorieren. Ignorieren wird gemerkt, sonst fragte Certain bei jedem Laden erneut. Dafuer gibt es die Suche jetzt auch als reines Nachschlagen: ``finde`` mit ``nur_vorschlaege`` verknuepft nichts, und dahinter liegt GET /uebertraege/vorschlaege. Eine Ansicht, die bloss fragt, darf beim Hinsehen nichts veraendern – sonst entschiede das Anschauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
350 lines
14 KiB
Python
350 lines
14 KiB
Python
"""Gleichwährige Überträge zwischen eigenen Konten erkennen.
|
||
|
||
Ein Übertrag steht in zwei Auszügen: als Abgang beim einen Konto, als Zugang
|
||
beim anderen. Beide werden gebucht – das ist richtig, denn beide Salden bewegen
|
||
sich. Nur zählen sie ohne Zusammenhang als Ausgabe UND als Einnahme in der
|
||
Auswertung, obwohl kein Geld ausgegeben wurde.
|
||
|
||
Dieses Modul findet solche Paare und legt eine ``TransferLink`` an. An den
|
||
Buchungen selbst wird nichts geändert; die Verknüpfung ist eine Schicht
|
||
obendrauf. Eine falsche Verknüpfung ist damit ein Etikett, das man abzieht –
|
||
kein Eingriff, der rückgängig gemacht werden müsste.
|
||
|
||
Die Regeln sind bewusst eng. Der Vorgänger hat Zielkonten aus Texten geraten
|
||
und Beträge über Wechselkurse geschätzt; beides hat Salden verfälscht. Hier
|
||
gilt: exakt gleicher Betrag bei gleicher Währung, gleicher Tag.
|
||
|
||
Bei VERSCHIEDENEN Währungen wird der EZB-Kurs des Tages herangezogen – aber nur
|
||
als Prüfung, ob zwei Beträge zueinander passen können. Gespeichert wird nichts
|
||
davon; beide Buchungen behalten den Betrag, der in ihrem Auszug steht. Genau
|
||
das war der Unterschied zum alten Ansatz: Der hat den Gegenbetrag geschätzt und
|
||
in die Buchung geschrieben, und damit Salden verfälscht. Eine Verknüpfung kann
|
||
das nicht – sie ist nur ein Etikett.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from collections import defaultdict
|
||
|
||
from sqlalchemy.orm import Session
|
||
|
||
from ..models import (
|
||
Account,
|
||
AccountIdentifier,
|
||
IdentifierKind,
|
||
LinkIgnored,
|
||
Transaction,
|
||
TransferLink,
|
||
TxnType,
|
||
User,
|
||
)
|
||
from . import currency
|
||
from .csv_import import normalize_text
|
||
|
||
# Wörter, die eine Bewegung zwischen eigenen Konten ankündigen. Ein Treffer
|
||
# allein verknüpft noch nichts – er erlaubt nur, dass es ohne Rückfrage
|
||
# geschieht. Ohne Treffer wird vorgeschlagen.
|
||
HINWEISE = (
|
||
"übertrag", "uebertrag", "umbuchung", "einzahlung von", "auszahlung",
|
||
"transfer", "gutschrift von", "von konto", "auf konto", "eigenübertrag",
|
||
"umgetauscht", "umtausch", "exchange", "wechsel",
|
||
)
|
||
|
||
# Wie weit ein Betrag bei Währungswechsel vom EZB-Kurs abweichen darf. Banken
|
||
# nehmen einen Aufschlag; Revolut liegt werktags nahe am Referenzkurs, eine
|
||
# klassische Überweisung deutlich darüber. Enger als 2,5 % würde echte Wechsel
|
||
# verwerfen, weiter würde beliebige Beträge zusammenwürfeln.
|
||
FX_TOLERANZ = 0.025
|
||
|
||
|
||
def _trifft_als_wort(nadel: str, heu: str) -> bool:
|
||
"""Nur an Wortgrenzen suchen, nicht mitten im Wort.
|
||
|
||
Ein kurzer Kontoname als blosser Teilstring richtet Schaden an: „Euro"
|
||
steckt auch in „PayPal Europe" und „Europa-Park". Genau daran ist die alte
|
||
Transfer-Erkennung gescheitert.
|
||
"""
|
||
if not nadel:
|
||
return False
|
||
return re.search(rf"(?<![0-9a-zäöüß]){re.escape(nadel)}(?![0-9a-zäöüß])", heu) is not None
|
||
|
||
|
||
def _text(txn: Transaction) -> str:
|
||
return normalize_text(f"{txn.payee or ''} {txn.description or ''}")
|
||
|
||
|
||
def _kontobezeichner(db: Session, user: User) -> dict[int, list[str]]:
|
||
"""Je Konto die Wörter, an denen es im Buchungstext erkennbar ist:
|
||
sein Name und seine Erkennungs-Texte."""
|
||
je_konto: dict[int, list[str]] = defaultdict(list)
|
||
for konto in db.query(Account).filter(Account.owner_id == user.id):
|
||
name = normalize_text(konto.name)
|
||
if len(name) >= 4:
|
||
je_konto[konto.id].append(name)
|
||
for acc_id, kind, wert in (
|
||
db.query(AccountIdentifier.account_id, AccountIdentifier.kind, AccountIdentifier.value_norm)
|
||
.join(Account, Account.id == AccountIdentifier.account_id)
|
||
.filter(Account.owner_id == user.id)
|
||
):
|
||
if kind == IdentifierKind.text and wert and len(wert) >= 3:
|
||
je_konto[acc_id].append(normalize_text(wert))
|
||
return je_konto
|
||
|
||
|
||
def _hat_hinweis(a: Transaction, b: Transaction, bezeichner: dict[int, list[str]]) -> bool:
|
||
"""Deutet mindestens eine der beiden Zeilen auf einen Übertrag hin?
|
||
|
||
Ohne diese Hürde würde ein zufälliger Treffer – 50.00 im Laden bezahlt, am
|
||
selben Tag 50.00 auf einem anderen Konto eingegangen – als „eindeutig"
|
||
gelten und eine echte Ausgabe samt echter Einnahme aus der Statistik
|
||
nehmen. Der Saldo bliebe richtig, die Auswertung nicht.
|
||
"""
|
||
for txn, gegen in ((a, b), (b, a)):
|
||
text = _text(txn)
|
||
if any(h in text for h in HINWEISE):
|
||
return True
|
||
# Der Name oder Erkennungs-Text des ANDEREN Kontos im Text – an
|
||
# Wortgrenzen geprüft, damit sich „Euro" nicht wieder „Europe" einfängt.
|
||
for wort in bezeichner.get(gegen.account_id, ()):
|
||
if _trifft_als_wort(wort, text):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _kandidaten(db: Session, user: User) -> dict[int, list[Transaction]]:
|
||
"""Zu jeder unverknüpften Buchung die möglichen Gegenstücke."""
|
||
verknuepft = set()
|
||
for a, b in db.query(TransferLink.from_txn_id, TransferLink.to_txn_id).filter(
|
||
TransferLink.owner_id == user.id
|
||
):
|
||
verknuepft.update((a, b))
|
||
ignoriert = {
|
||
tuple(sorted(p))
|
||
for p in db.query(LinkIgnored.a_txn_id, LinkIgnored.b_txn_id).filter(
|
||
LinkIgnored.owner_id == user.id
|
||
)
|
||
}
|
||
waehrung = dict(db.query(Account.id, Account.currency).filter(Account.owner_id == user.id))
|
||
|
||
buchungen = [
|
||
t for t in db.query(Transaction).filter(
|
||
Transaction.owner_id == user.id,
|
||
Transaction.type.in_((TxnType.expense, TxnType.income)),
|
||
)
|
||
if t.id not in verknuepft
|
||
]
|
||
|
||
# Nach Tag und Betrag bündeln: Nur wer beides teilt, kommt überhaupt infrage.
|
||
nach_schluessel: dict[tuple, list[Transaction]] = defaultdict(list)
|
||
for t in buchungen:
|
||
nach_schluessel[(t.date, t.amount_cents)].append(t)
|
||
|
||
kandidaten: dict[int, list[Transaction]] = defaultdict(list)
|
||
for gruppe in nach_schluessel.values():
|
||
if len(gruppe) < 2:
|
||
continue
|
||
for a in gruppe:
|
||
for b in gruppe:
|
||
if a.id == b.id or a.account_id == b.account_id:
|
||
continue
|
||
if a.type == b.type:
|
||
continue # eine Ausgabe, eine Einnahme
|
||
if waehrung.get(a.account_id) != waehrung.get(b.account_id):
|
||
continue # 100 CHF und 100 EUR sind nicht derselbe
|
||
# Betrag, nur dieselbe Zahl
|
||
if tuple(sorted((a.id, b.id))) in ignoriert:
|
||
continue
|
||
kandidaten[a.id].append(b)
|
||
|
||
# Währungswechsel: gleicher Tag, aber unterschiedliche Beträge. Hier
|
||
# entscheidet der EZB-Kurs, ob die beiden zueinander passen können.
|
||
kandidaten = _ergaenze_waehrungswechsel(db, user, buchungen, waehrung,
|
||
ignoriert, kandidaten)
|
||
return kandidaten
|
||
|
||
|
||
def _passt_ueber_kurs(db: Session, a: Transaction, b: Transaction,
|
||
von: str, nach: str) -> bool:
|
||
"""Passt ``b`` zum umgerechneten ``a``?
|
||
|
||
Fehlt der Kurs für den Tag, wird NICHT geraten – dann gibt es kein Paar.
|
||
"""
|
||
erwartet = currency.convert(db, a.amount_cents, von, nach, a.date)
|
||
if erwartet is None or erwartet <= 0:
|
||
return False
|
||
return abs(erwartet - b.amount_cents) <= erwartet * FX_TOLERANZ
|
||
|
||
|
||
def _ergaenze_waehrungswechsel(db, user, buchungen, waehrung, ignoriert, kandidaten):
|
||
nach_tag: dict = defaultdict(list)
|
||
for t in buchungen:
|
||
nach_tag[t.date].append(t)
|
||
for gruppe in nach_tag.values():
|
||
for a in gruppe:
|
||
for b in gruppe:
|
||
if a.id == b.id or a.account_id == b.account_id or a.type == b.type:
|
||
continue
|
||
va, vb = waehrung.get(a.account_id), waehrung.get(b.account_id)
|
||
if not va or not vb or va == vb:
|
||
continue # gleiche Währung ist oben schon erledigt
|
||
if a.amount_cents == b.amount_cents:
|
||
continue # zufällig gleiche Zahl, andere Währung
|
||
if tuple(sorted((a.id, b.id))) in ignoriert:
|
||
continue
|
||
if _passt_ueber_kurs(db, a, b, va, vb):
|
||
kandidaten[a.id].append(b)
|
||
return kandidaten
|
||
|
||
|
||
def _eindeutig(a: Transaction, moegliche: list[Transaction],
|
||
kandidaten: dict[int, list[Transaction]],
|
||
verbraucht: set[int]) -> Transaction | None:
|
||
"""Gibt es genau eine sinnvolle Zuordnung?
|
||
|
||
Zwei Fälle gelten als zwingend:
|
||
|
||
1. Genau ein Gegenstück, und dieses hat seinerseits nur diese eine
|
||
Möglichkeit.
|
||
2. Alle Gegenstücke sind untereinander austauschbar – gleiches Konto,
|
||
gleicher Betrag, gleicher Tag. Dann ist jede Zuordnung dieselbe, und
|
||
eine beliebige ist richtig. Das ist der Alltag: Revolut-Aufrundungen
|
||
sind hunderte gleiche Beträge am selben Tag zwischen denselben Konten.
|
||
"""
|
||
if not moegliche:
|
||
return None
|
||
if len(moegliche) == 1:
|
||
gegen = moegliche[0]
|
||
# Nur die noch OFFENEN Gegenstücke zählen. Zählte man alle, bliebe beim
|
||
# letzten Paar einer Reihe gleicher Beträge immer eines übrig: Sein
|
||
# Gegenstück hätte rechnerisch viele Kandidaten, obwohl alle anderen
|
||
# längst vergeben sind.
|
||
offen_dort = [x for x in kandidaten.get(gegen.id, ())
|
||
if x.id not in verbraucht and x.id != a.id]
|
||
return gegen if not offen_dort else None
|
||
konten = {b.account_id for b in moegliche}
|
||
return moegliche[0] if len(konten) == 1 else None
|
||
|
||
|
||
def finde(db: Session, user: User, nur_vorschlaege: bool = False) -> dict:
|
||
"""Paare suchen. Eindeutige verknüpfen, den Rest als Vorschlag melden.
|
||
|
||
Mit ``nur_vorschlaege`` wird NICHTS verknüpft – dann kommt alles als
|
||
Vorschlag zurück. Das braucht jede Ansicht, die bloss fragen will („ist das
|
||
ein Übertrag?"), ohne beim Hinsehen schon etwas zu verändern.
|
||
"""
|
||
kandidaten = _kandidaten(db, user)
|
||
bezeichner = _kontobezeichner(db, user)
|
||
nach_id = {}
|
||
for a_id, liste in kandidaten.items():
|
||
for b in liste:
|
||
nach_id[b.id] = b
|
||
for t in db.query(Transaction).filter(Transaction.id.in_(list(kandidaten.keys()) or [0])):
|
||
nach_id[t.id] = t
|
||
|
||
verbraucht: set[int] = set()
|
||
verknuepft = 0
|
||
vorschlaege: list[tuple[Transaction, Transaction]] = []
|
||
|
||
for a_id in sorted(kandidaten):
|
||
if a_id in verbraucht:
|
||
continue
|
||
a = nach_id.get(a_id)
|
||
if a is None:
|
||
continue
|
||
offen = [b for b in kandidaten[a_id] if b.id not in verbraucht]
|
||
gegen = _eindeutig(a, offen, kandidaten, verbraucht)
|
||
if gegen is None:
|
||
if offen:
|
||
vorschlaege.append((a, offen[0]))
|
||
continue
|
||
# Automatisch nur ohne Kategorie und mit Hinweis – siehe _hat_hinweis.
|
||
if a.category_id is not None or gegen.category_id is not None:
|
||
vorschlaege.append((a, gegen))
|
||
continue
|
||
if not _hat_hinweis(a, gegen, bezeichner):
|
||
vorschlaege.append((a, gegen))
|
||
continue
|
||
if nur_vorschlaege:
|
||
vorschlaege.append((a, gegen))
|
||
continue
|
||
verbinde(db, user, a, gegen, automatisch=True)
|
||
verbraucht.update((a.id, gegen.id))
|
||
verknuepft += 1
|
||
|
||
if not nur_vorschlaege:
|
||
db.commit()
|
||
return {
|
||
"verknuepft": verknuepft,
|
||
"vorschlaege": [
|
||
{"a": _kurz(db, x), "b": _kurz(db, y)}
|
||
for x, y in vorschlaege
|
||
if x.id not in verbraucht and y.id not in verbraucht
|
||
],
|
||
}
|
||
|
||
|
||
def _kurz(db: Session, t: Transaction) -> dict:
|
||
konto = db.get(Account, t.account_id)
|
||
return {
|
||
"id": t.id, "date": t.date.isoformat(), "type": t.type.value,
|
||
"amount_cents": t.amount_cents, "currency": konto.currency if konto else "",
|
||
"account_id": t.account_id, "account_name": konto.name if konto else "",
|
||
"payee": t.payee, "description": t.description, "category_id": t.category_id,
|
||
}
|
||
|
||
|
||
def verbinde(db: Session, user: User, a: Transaction, b: Transaction,
|
||
automatisch: bool = False) -> TransferLink:
|
||
"""Verknüpfung anlegen. Abgang zuerst, damit die Richtung stimmt."""
|
||
von, nach = (a, b) if a.type == TxnType.expense else (b, a)
|
||
link = TransferLink(owner_id=user.id, from_txn_id=von.id, to_txn_id=nach.id,
|
||
automatisch=automatisch)
|
||
db.add(link)
|
||
db.flush()
|
||
return link
|
||
|
||
|
||
def linkliste(db: Session, user: User) -> dict[int, dict]:
|
||
"""Je Buchungs-ID die Gegenseite – für die Anzeige in der Buchungsliste.
|
||
|
||
Das Gegenkonto kommt mit: Die andere Buchung steht oft gar nicht in der
|
||
gerade gefilterten Liste, und „Übertrag" ohne Angabe wohin sagt nichts.
|
||
"""
|
||
links = db.query(TransferLink).filter(TransferLink.owner_id == user.id).all()
|
||
beteiligt = {i for l in links for i in (l.from_txn_id, l.to_txn_id)}
|
||
# Einmal nachschlagen statt je Verknüpfung – die Liste kann lang werden.
|
||
konto_von = dict(
|
||
db.query(Transaction.id, Transaction.account_id)
|
||
.filter(Transaction.id.in_(beteiligt or [0]))
|
||
.all()
|
||
)
|
||
|
||
ergebnis: dict[int, dict] = {}
|
||
for link in links:
|
||
for eigen, gegen, richtung in (
|
||
(link.from_txn_id, link.to_txn_id, "raus"),
|
||
(link.to_txn_id, link.from_txn_id, "rein"),
|
||
):
|
||
ergebnis[eigen] = {
|
||
"link_id": link.id, "other_id": gegen, "richtung": richtung,
|
||
"other_account_id": konto_von.get(gegen),
|
||
"automatisch": link.automatisch,
|
||
}
|
||
return ergebnis
|
||
|
||
|
||
def verknuepfte_ids(db: Session, user: User) -> set[int]:
|
||
"""Alle Buchungen, die Teil einer Verknüpfung sind.
|
||
|
||
Die Auswertung überspringt sie – so wie sie heute schon neutrale
|
||
Kategorien überspringt. Umkategorisiert wird nichts: Löst man die
|
||
Verknüpfung, zählt die Buchung mit ihrer alten Kategorie wieder mit.
|
||
"""
|
||
ids: set[int] = set()
|
||
for a, b in db.query(TransferLink.from_txn_id, TransferLink.to_txn_id).filter(
|
||
TransferLink.owner_id == user.id
|
||
):
|
||
ids.update((a, b))
|
||
return ids
|