feat: Import verbindet keine Konten mehr automatisch

Das Raten des Gegenkontos war die Quelle fast aller Saldo-Fehler: falsch
geratene Ziele, verdrehte Richtungen und doppelt gebuchte Bewegungen,
sobald beide Ausz-uege eingelesen wurden. Zeilen ohne erkennbares Ziel
blieben ausserdem ungebucht, wodurch Geld unbemerkt fehlte.

Jede Zeile ist jetzt schlicht das, was im Auszug steht: Ausgabe oder
Einnahme. Fruehere Uebertrags-Schlagwoerter haengen stattdessen die
neutrale Kategorie an - der Saldo stimmt, die Statistik bleibt sauber.
Ein Uebertrag laesst sich weiterhin von Hand buchen.

Damit entfallen Gegenbuchungs-Suche, Zielkonto-Raten und der Zustand
"wartet auf Zielkonto".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-21 17:19:34 +02:00
co-authored by Claude Opus 5
parent b4cd5b7ca3
commit a4ea0e46db
3 changed files with 121 additions and 254 deletions
+26 -145
View File
@@ -6,7 +6,6 @@ import csv
import hashlib
import io
import json
import re
from collections import Counter
from datetime import date, datetime
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
@@ -15,13 +14,11 @@ from sqlalchemy.orm import Session
from ..models import (
Account,
AccountIdentifier,
AmountMode,
BudgetTreatment,
CategorizationRule,
Category,
CategoryKind,
IdentifierKind,
ImportBatch,
ImportStatus,
ImportTemplate,
@@ -252,33 +249,6 @@ def _match_rule(
return None
def _trifft_als_wort(nadel: str, heu: str) -> bool:
"""Erkennungs-Text nur an Wortgrenzen suchen, nicht mitten im Wort.
Ein kurzer Text richtet als reiner Teilstring Schaden an: Der Erkennungs-Text
„Euro" eines EUR-Kontos steckt auch in „PayPal Europe", „Europa-Park" und
„EUROBUS" – jede solche Zahlung wurde dadurch zum Übertrag auf dieses Konto
und das Geld doppelt gezählt. Genauso darf die Kartenendung „6543" nicht
mitten in einer längeren Kontonummer anschlagen.
"""
return re.search(rf"(?<![0-9a-zäöüß]){re.escape(nadel)}(?![0-9a-zäöüß])", heu) is not None
def _guess_target_account(text_idents: list[tuple[str, int]], payee: str, purpose: str, source_id: int) -> int | None:
"""Zielkonto eines Transfers aus dem Buchungstext raten: längster passender
Erkennungs-Text je Konto gewinnt."""
hay = f"{normalize_text(payee)} {normalize_text(purpose)}"
best: tuple[int, int] | None = None # (Länge, Konto-ID)
for val, acc_id in text_idents:
if not val or acc_id == source_id:
continue
if not _trifft_als_wort(val, hay):
continue
if best is None or len(val) > best[0]:
best = (len(val), acc_id)
return best[1] if best else None
# Status-Werte, die eine Buchung NICHT wirksam werden lassen. Bewusst als
# Sperrliste (nicht Positivliste): Bank-Exporte sind lokalisiert – „COMPLETED"
# heißt auf Deutsch „ABGESCHLOSSEN". Unbekannte Werte werden gebucht, damit
@@ -301,57 +271,12 @@ def state_is_bookable(state: str | None) -> bool:
# Fenster, in dem eine Buchung als Gegenseite eines bestehenden Transfers gilt
# (Buchungs-/Valutadatum können ein paar Tage auseinanderliegen).
COUNTERPART_DAYS = 5
# Wie weit der tatsächlich gutgeschriebene Betrag vom geschätzten abweichen darf,
# damit er noch als dieselbe Bewegung gilt. Bei einem Währungstausch rechnen wir
# mit dem EZB-Kurs, die Bank nutzt ihren eigenen – ein paar Prozent Unterschied
# sind normal, eine andere Buchung liegt praktisch nie so nah.
COUNTERPART_TOLERANCE = 0.20
def _find_counterpart(
side_index: dict, verbraucht: set, account: Account, d: date, amount: int,
waehrung: dict, nur_konto: int | None,
):
"""Sucht einen bereits gebuchten Transfer, dessen Gegenseite diese Zeile ist.
Gesucht wird über die Seite des importierten Kontos – die Zeile braucht also
selbst keine Transfer-Regel. Genau das ist der Empfangs-Auszug: Im CHF-Auszug
entsteht der Transfer, im EUR-Auszug taucht dieselbe Bewegung als Eingang auf.
Bei einem Umtausch stehen zwei Währungen und damit zwei Beträge gegenüber;
dann zählt ein plausibler Betrag statt eines exakten."""
absamt = abs(amount)
bester = None
for eintrag in side_index.get(account.id, []):
txn_id, t_date, erwartet, other_id, richtung = eintrag
tage = abs((t_date - d).days)
if txn_id in verbraucht or tage > COUNTERPART_DAYS:
continue
# Die Richtung muss stimmen: Wo Geld ankam, kann keine Ausgabe die
# Gegenseite sein. Ohne diese Prüfung verschluckt eine Ausgabe über
# denselben Betrag den erwarteten Eingang – der Saldo liefe zu hoch.
if (amount < 0) != (richtung < 0):
continue
# Hat eine Regel ein Gegenkonto gesetzt, muss es dazu passen.
if nur_konto is not None and other_id != nur_konto:
continue
if absamt == erwartet:
return eintrag
cross = waehrung.get(other_id, account.currency) != account.currency
if not cross or not erwartet:
continue
abweichung = abs(absamt - erwartet) / erwartet
if abweichung > COUNTERPART_TOLERANCE:
continue
# Den BESTEN Treffer nehmen, nicht den ersten: Liegen mehrere Umtausche
# im Zeitfenster, schnappte sonst der frühere den passenden Transfer weg
# und die eigentlich gemeinte Zeile blieb ohne Gegenstück.
rang = (tage, abweichung)
if bester is None or rang < bester[0]:
bester = (rang, eintrag)
return bester[1] if bester else None
def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict]:
@@ -413,21 +338,12 @@ def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict
def detect_rows(db: Session, user: User, account: Account, candidates: list[dict]) -> list[dict]:
"""Reichert Kandidaten an: Typ, Auto-Kategorie, interner Transfer, Duplikat.
Wird von CSV- UND Beleg-Import genutzt (gleiche Prüf-/Buchungslogik)."""
idents: dict[str, int] = {} # IBAN/Nummer -> Konto (für Transfer via IBAN)
text_idents: list[tuple[str, int]] = [] # freie Erkennungs-Texte -> Konto
for value_norm, acc_id, kind in (
db.query(AccountIdentifier.value_norm, AccountIdentifier.account_id, AccountIdentifier.kind)
.join(Account, Account.id == AccountIdentifier.account_id)
.filter(Account.owner_id == user.id)
):
if kind == IdentifierKind.text:
text_idents.append((value_norm, acc_id))
else:
idents[value_norm] = acc_id
"""Reichert Kandidaten an: Typ, Auto-Kategorie, Duplikat.
Wird von CSV- UND Beleg-Import genutzt (gleiche Prüf-/Buchungslogik).
Konten werden hier NICHT mehr miteinander verbunden – jede Zeile ist
Ausgabe oder Einnahme. Überträge bucht man von Hand.
"""
rules = db.query(CategorizationRule).filter(CategorizationRule.owner_id == user.id).all()
rules.sort(key=_rule_sort_key, reverse=True) # längstes/​manuell zuerst
# None = neutral: zählt für Ausgabe UND Einnahme (siehe _match_rule).
@@ -447,22 +363,12 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
Transaction.owner_id == user.id, Transaction.import_hash.isnot(None)
)
)
# Bestehende Transfers je Konto-Paar: erkennt die Gegenbuchung, wenn beide
# Auszüge eines Übertrags importiert werden (sonst zählt die Bewegung doppelt).
# Je Konto: welcher Betrag dort aus einem bestehenden Transfer erwartet wird.
side_index: dict[int, list[tuple]] = {}
for t_id, t_acc, t_to, t_date, t_amt, t_to_amt in (
db.query(Transaction.id, Transaction.account_id, Transaction.to_account_id,
Transaction.date, Transaction.amount_cents, Transaction.to_amount_cents)
.filter(Transaction.owner_id == user.id, Transaction.type == TxnType.transfer,
Transaction.to_account_id.isnot(None))
):
gutschrift = t_to_amt if t_to_amt is not None else t_amt
side_index.setdefault(t_acc, []).append((t_id, t_date, t_amt, t_to, -1))
side_index.setdefault(t_to, []).append((t_id, t_date, gutschrift, t_acc, +1))
verbraucht: set[int] = set()
waehrung_je_konto: dict[int, str] = dict(
db.query(Account.id, Account.currency).filter(Account.owner_id == user.id)
# Neutrale Kategorie („Kontoübertrag"): Buchungen, die nur Geld zwischen
# eigenen Konten verschieben, werden damit kategorisiert statt zu einem
# Übertrag verbunden. Sie verändern die Salden korrekt und bleiben aus der
# Statistik heraus.
neutrale_kategorie = next(
(cid for cid, k in cat_kind.items() if k is None), None
)
seen: Counter = Counter()
@@ -488,55 +394,30 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
d is not None and amount is not None and amount != 0
and state_ok and currency_ok
)
# Jede Zeile ist Ausgabe oder Einnahme – der Import verbindet keine
# Konten mehr selbst. Das Raten des Gegenkontos hat mehr Schaden
# angerichtet als Nutzen gebracht: geratene Ziele, verdrehte Richtungen
# und doppelt gebuchte Bewegungen, wenn beide Auszüge eingelesen wurden.
# Wer zwei Buchungen bewusst verbinden will, bucht den Übertrag von Hand.
ttype = "expense" if (amount or 0) < 0 else "income"
category_id: int | None = None
matched_rule_id: int | None = None
to_account_id: int | None = None
other = idents.get(iban) if iban else None
if other is None:
# Symmetrisch: enthält der Text den Erkennungs-Text eines ANDEREN
# Kontos, ist es ein Transfer von/zu diesem Konto (auch Eingänge).
other = _guess_target_account(text_idents, payee or "", purpose or "", account.id)
if other is not None and other != account.id:
ttype = "transfer"
to_account_id = other
else:
kind = CategoryKind.expense if ttype == "expense" else CategoryKind.income
rule = _match_rule(rules, payee or "", purpose or "", amount, kind, cat_kind)
if rule is not None:
matched_rule_id = rule.id
if rule.is_transfer:
ttype = "transfer"
# Festes Zielkonto der Regel bevorzugen, sonst aus dem Text raten.
if rule.target_account_id and rule.target_account_id != account.id:
to_account_id = rule.target_account_id
else:
to_account_id = _guess_target_account(text_idents, payee or "", purpose or "", account.id)
else:
category_id = rule.category_id
kind = CategoryKind.expense if ttype == "expense" else CategoryKind.income
rule = _match_rule(rules, payee or "", purpose or "", amount, kind, cat_kind)
if rule is not None:
matched_rule_id = rule.id
# Frühere Übertrags-Schlagwörter kennzeichnen jetzt die neutrale
# Kategorie, statt eine Verbindung zwischen Konten herzustellen.
category_id = rule.category_id or (neutrale_kategorie if rule.is_transfer else None)
h = import_hash(account.id, d, amount, payee)
is_dup = seen[h] < existing[h] # nur so viele Duplikate wie schon in der DB
seen[h] += 1
# Gegenbuchung eines bereits gebuchten Transfers? Dann nicht erneut buchen –
# der tatsächliche Betrag dieser Zeile korrigiert aber die Schätzung der
# anderen Seite (beim Umtausch rechneten wir dort mit dem EZB-Kurs).
gegenbuchung = None
if d is not None and amount is not None and not is_dup:
gegenbuchung = _find_counterpart(
side_index, verbraucht, account, d, amount, waehrung_je_konto, to_account_id
)
if gegenbuchung is not None:
verbraucht.add(gegenbuchung[0])
ttype = "transfer" # in der Prüfansicht als Transfer zeigen
to_account_id = to_account_id or gegenbuchung[3]
is_counterpart = gegenbuchung is not None
# Transfer-Regel ohne erkennbares Gegenkonto: NICHT still als Einnahme/
# Ausgabe buchen, sondern in der Prüfansicht das Konto abfragen.
braucht_ziel = ttype == "transfer" and to_account_id is None
is_counterpart = False
braucht_ziel = False
out.append({
"index": i,
@@ -561,7 +442,7 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
"is_duplicate": is_dup,
"is_counterpart": is_counterpart,
# Damit commit_import die geschätzte Gegenseite auf den echten Wert setzt.
"counterpart_txn_id": gegenbuchung[0] if gegenbuchung else None,
"counterpart_txn_id": None,
"include": valid and not is_dup and not is_counterpart and not braucht_ziel,
"needs_target": braucht_ziel,
})