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 hashlib
import io import io
import json import json
import re
from collections import Counter from collections import Counter
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
@@ -15,13 +14,11 @@ from sqlalchemy.orm import Session
from ..models import ( from ..models import (
Account, Account,
AccountIdentifier,
AmountMode, AmountMode,
BudgetTreatment, BudgetTreatment,
CategorizationRule, CategorizationRule,
Category, Category,
CategoryKind, CategoryKind,
IdentifierKind,
ImportBatch, ImportBatch,
ImportStatus, ImportStatus,
ImportTemplate, ImportTemplate,
@@ -252,33 +249,6 @@ def _match_rule(
return None 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 # Status-Werte, die eine Buchung NICHT wirksam werden lassen. Bewusst als
# Sperrliste (nicht Positivliste): Bank-Exporte sind lokalisiert – „COMPLETED" # Sperrliste (nicht Positivliste): Bank-Exporte sind lokalisiert – „COMPLETED"
# heißt auf Deutsch „ABGESCHLOSSEN". Unbekannte Werte werden gebucht, damit # 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 # Fenster, in dem eine Buchung als Gegenseite eines bestehenden Transfers gilt
# (Buchungs-/Valutadatum können ein paar Tage auseinanderliegen). # (Buchungs-/Valutadatum können ein paar Tage auseinanderliegen).
COUNTERPART_DAYS = 5
# Wie weit der tatsächlich gutgeschriebene Betrag vom geschätzten abweichen darf, # 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 # 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 # mit dem EZB-Kurs, die Bank nutzt ihren eigenen – ein paar Prozent Unterschied
# sind normal, eine andere Buchung liegt praktisch nie so nah. # 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]: 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]: def detect_rows(db: Session, user: User, account: Account, candidates: list[dict]) -> list[dict]:
"""Reichert Kandidaten an: Typ, Auto-Kategorie, interner Transfer, Duplikat. """Reichert Kandidaten an: Typ, Auto-Kategorie, 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
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 = db.query(CategorizationRule).filter(CategorizationRule.owner_id == user.id).all()
rules.sort(key=_rule_sort_key, reverse=True) # längstes/​manuell zuerst rules.sort(key=_rule_sort_key, reverse=True) # längstes/​manuell zuerst
# None = neutral: zählt für Ausgabe UND Einnahme (siehe _match_rule). # 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) Transaction.owner_id == user.id, Transaction.import_hash.isnot(None)
) )
) )
# Bestehende Transfers je Konto-Paar: erkennt die Gegenbuchung, wenn beide # Neutrale Kategorie („Kontoübertrag"): Buchungen, die nur Geld zwischen
# Auszüge eines Übertrags importiert werden (sonst zählt die Bewegung doppelt). # eigenen Konten verschieben, werden damit kategorisiert statt zu einem
# Je Konto: welcher Betrag dort aus einem bestehenden Transfer erwartet wird. # Übertrag verbunden. Sie verändern die Salden korrekt und bleiben aus der
side_index: dict[int, list[tuple]] = {} # Statistik heraus.
for t_id, t_acc, t_to, t_date, t_amt, t_to_amt in ( neutrale_kategorie = next(
db.query(Transaction.id, Transaction.account_id, Transaction.to_account_id, (cid for cid, k in cat_kind.items() if k is None), None
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)
) )
seen: Counter = Counter() 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 d is not None and amount is not None and amount != 0
and state_ok and currency_ok 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" ttype = "expense" if (amount or 0) < 0 else "income"
category_id: int | None = None category_id: int | None = None
matched_rule_id: int | None = None matched_rule_id: int | None = None
to_account_id: int | None = None to_account_id: int | None = None
other = idents.get(iban) if iban else None kind = CategoryKind.expense if ttype == "expense" else CategoryKind.income
if other is None: rule = _match_rule(rules, payee or "", purpose or "", amount, kind, cat_kind)
# Symmetrisch: enthält der Text den Erkennungs-Text eines ANDEREN if rule is not None:
# Kontos, ist es ein Transfer von/zu diesem Konto (auch Eingänge). matched_rule_id = rule.id
other = _guess_target_account(text_idents, payee or "", purpose or "", account.id) # Frühere Übertrags-Schlagwörter kennzeichnen jetzt die neutrale
if other is not None and other != account.id: # Kategorie, statt eine Verbindung zwischen Konten herzustellen.
ttype = "transfer" category_id = rule.category_id or (neutrale_kategorie if rule.is_transfer else None)
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
h = import_hash(account.id, d, amount, payee) h = import_hash(account.id, d, amount, payee)
is_dup = seen[h] < existing[h] # nur so viele Duplikate wie schon in der DB is_dup = seen[h] < existing[h] # nur so viele Duplikate wie schon in der DB
seen[h] += 1 seen[h] += 1
# Gegenbuchung eines bereits gebuchten Transfers? Dann nicht erneut buchen – is_counterpart = False
# der tatsächliche Betrag dieser Zeile korrigiert aber die Schätzung der braucht_ziel = False
# 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
out.append({ out.append({
"index": i, "index": i,
@@ -561,7 +442,7 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
"is_duplicate": is_dup, "is_duplicate": is_dup,
"is_counterpart": is_counterpart, "is_counterpart": is_counterpart,
# Damit commit_import die geschätzte Gegenseite auf den echten Wert setzt. # 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, "include": valid and not is_dup and not is_counterpart and not braucht_ziel,
"needs_target": braucht_ziel, "needs_target": braucht_ziel,
}) })
+58 -58
View File
@@ -63,9 +63,11 @@ def test_preview_parsing_and_detection(admin):
# Zeile 2: gleich aussehend wie Zeile 0, aber im selben Auszug KEIN Duplikat. # Zeile 2: gleich aussehend wie Zeile 0, aber im selben Auszug KEIN Duplikat.
assert rows[2]["is_duplicate"] is False assert rows[2]["is_duplicate"] is False
assert rows[2]["include"] is True assert rows[2]["include"] is True
# Zeile 3: interner Transfer (IBAN passt zu 'spar') # Zeile 3: die IBAN gehört einem eigenen Konto – der Import verbindet
assert rows[3]["type"] == "transfer" # trotzdem nichts mehr, es bleibt eine gewöhnliche Ausgabe.
assert rows[3]["to_account_id"] == spar["id"] assert rows[3]["type"] == "expense"
assert rows[3]["to_account_id"] is None
assert spar
def test_commit_and_undo_and_learning(admin): def test_commit_and_undo_and_learning(admin):
@@ -317,62 +319,60 @@ EUR_UMTAUSCH = (
) )
def test_currency_exchange_pairs_across_statements(admin): def test_umtausch_wird_als_ausgabe_und_einnahme_gebucht(admin):
"""Beide Seiten eines Umtauschs zählen einzeln – der Import verbindet nichts.
Früher wurde daraus ein Übertrag mit geratenem Gegenkonto. Das ging schief,
sobald beide Auszüge eingelesen wurden oder das Ziel falsch geraten war.
Jetzt ist jede Zeile das, was im Auszug steht: Abgang beim einen Konto,
Zugang beim anderen. Beide Salden stimmen damit ohne Zutun.
"""
chf = _account(admin, name="Rev CHF X", currency="CHF", opening_balance_cents=50000) chf = _account(admin, name="Rev CHF X", currency="CHF", opening_balance_cents=50000)
eur = _account(admin, name="Rev EUR X", currency="EUR", opening_balance_cents=0) eur = _account(admin, name="Rev EUR X", currency="EUR", opening_balance_cents=0)
# EINE Regel genügt: „Umgetauscht in EUR" -> Gegenkonto ist das EUR-Konto.
admin.post("/api/categories/transfer-keywords", admin.post("/api/categories/transfer-keywords",
json={"word": "umgetauscht in eur", "target_account_id": eur["id"]}) json={"word": "umgetauscht in eur", "target_account_id": eur["id"]})
# 1) CHF-Auszug: Umtausch geht als Transfer CHF -> EUR raus.
rows = _rev_preview(admin, chf["id"], CHF_UMTAUSCH) rows = _rev_preview(admin, chf["id"], CHF_UMTAUSCH)
assert rows[0]["type"] == "transfer" assert rows[0]["type"] == "expense"
assert rows[0]["to_account_id"] == eur["id"] assert rows[0]["to_account_id"] is None
assert rows[0]["is_counterpart"] is False
admin.post("/api/imports/commit", json={"account_id": chf["id"], "filename": "chf.csv", "rows": rows}) admin.post("/api/imports/commit", json={"account_id": chf["id"], "filename": "chf.csv", "rows": rows})
assert admin.get(f"/api/accounts/{chf['id']}").json()["balance_cents"] == 40000 assert admin.get(f"/api/accounts/{chf['id']}").json()["balance_cents"] == 40000
# 2) EUR-Auszug: OHNE weitere Regel – die Gegenseite wird über den bereits # Der EUR-Auszug bringt den Zugang – als Einnahme, nicht als Gegenbuchung.
# gebuchten Transfer erkannt, obwohl der Betrag in EUR ein anderer ist.
rows2 = _rev_preview(admin, eur["id"], EUR_UMTAUSCH) rows2 = _rev_preview(admin, eur["id"], EUR_UMTAUSCH)
assert rows2[0]["type"] == "transfer" assert rows2[0]["type"] == "income"
assert rows2[0]["is_counterpart"] is True, "Umtausch-Gegenseite muss erkannt werden" assert rows2[0]["is_counterpart"] is False
assert rows2[0]["include"] is False, "darf nicht doppelt zählen" assert rows2[0]["include"] is True
r = admin.post("/api/imports/commit", json={"account_id": eur["id"], "filename": "eur.csv", "rows": rows2}) r = admin.post("/api/imports/commit", json={"account_id": eur["id"], "filename": "eur.csv", "rows": rows2})
assert r.json()["imported_count"] == 0 assert r.json()["imported_count"] == 1
# Der echte Eingang korrigiert die EZB-Schätzung: EUR-Konto steht auf 106.42.
assert admin.get(f"/api/accounts/{eur['id']}").json()["balance_cents"] == 10642 assert admin.get(f"/api/accounts/{eur['id']}").json()["balance_cents"] == 10642
# CHF-Seite bleibt unverändert.
assert admin.get(f"/api/accounts/{chf['id']}").json()["balance_cents"] == 40000 assert admin.get(f"/api/accounts/{chf['id']}").json()["balance_cents"] == 40000
def test_exchange_arrival_without_source_asks_instead_of_booking(admin): def test_uebertrags_schlagwort_setzt_neutrale_kategorie(admin):
"""Wird der Empfangs-Auszug zuerst eingelesen, ist die Quelle noch unbekannt. """Ein früheres Übertrags-Schlagwort kennzeichnet die Zeile jetzt nur noch.
Die Zeile darf dann NICHT still als Einnahme durchgehen, sondern fragt das
Konto ab – sonst zählt die Bewegung später doppelt.""" Es verbindet keine Konten mehr, sondern hängt die neutrale Kategorie an –
chf = _account(admin, name="Rev CHF Y", currency="CHF", opening_balance_cents=50000) die Buchung verändert den Saldo, bleibt aber aus der Statistik heraus.
eur = _account(admin, name="Rev EUR Y", currency="EUR") """
grp = admin.post("/api/categories/groups",
json={"name": "Neutral X", "kind": "expense"}).json()
neutral = admin.post("/api/categories", json={
"group_id": grp["id"], "name": "Kontoübertrag X", "excluded_from_stats": True}).json()
eur = _account(admin, name="Rev EUR Z", currency="EUR")
chf = _account(admin, name="Rev CHF Z", currency="CHF", opening_balance_cents=50000)
admin.post("/api/categories/transfer-keywords", admin.post("/api/categories/transfer-keywords",
json={"word": "umgetauscht in eur", "target_account_id": eur["id"]}) json={"word": "umgetauscht in eur", "target_account_id": eur["id"]})
rows = _rev_preview(admin, eur["id"], EUR_UMTAUSCH) rows = _rev_preview(admin, chf["id"], CHF_UMTAUSCH)
assert rows[0]["type"] == "transfer" assert rows[0]["type"] == "expense"
assert rows[0]["to_account_id"] is None assert rows[0]["category_id"] == neutral["id"]
assert rows[0]["needs_target"] is True assert rows[0]["needs_target"] is False
assert rows[0]["include"] is False, "ohne Gegenkonto nicht automatisch buchen"
r = admin.post("/api/imports/commit", json={"account_id": eur["id"], "filename": "e.csv", "rows": rows})
assert r.json()["imported_count"] == 0
assert admin.get(f"/api/accounts/{eur['id']}").json()["balance_cents"] == 0
assert chf # Quellkonto bleibt unberührt
def test_expense_is_not_swallowed_by_incoming_transfer(admin): def test_expense_is_not_swallowed_by_incoming_transfer(admin):
"""Eine echte Ausgabe über denselben Betrag wie ein erwarteter Transfer-Eingang """Eine echte Ausgabe bleibt eine Ausgabe – auch wenn anderswo ein gleich
darf NICHT als Gegenbuchung gelten – sonst fehlt sie und der Saldo läuft zu hoch.""" hoher Übertrag steht. Früher wurde sie als dessen Gegenbuchung geschluckt."""
quelle = _account(admin, name="Quelle Z", currency="EUR", opening_balance_cents=100000) quelle = _account(admin, name="Quelle Z", currency="EUR", opening_balance_cents=100000)
ziel = _account(admin, name="Ziel Z", currency="EUR", opening_balance_cents=10000) ziel = _account(admin, name="Ziel Z", currency="EUR", opening_balance_cents=10000)
@@ -398,14 +398,11 @@ def test_expense_is_not_swallowed_by_incoming_transfer(admin):
}, },
).json()["rows"] ).json()["rows"]
assert rows[0]["is_counterpart"] is False, "Ausgabe ist keine Gegenbuchung" # Keine Zeile gilt mehr als Gegenbuchung – beide werden gebucht.
assert rows[0]["include"] is True assert all(r["is_counterpart"] is False for r in rows)
assert rows[1]["is_counterpart"] is True, "der Eingang schon" assert all(r["include"] is True for r in rows)
assert rows[1]["include"] is False assert rows[0]["type"] == "expense"
assert rows[1]["type"] == "income"
admin.post("/api/imports/commit", json={"account_id": ziel["id"], "filename": "z.csv", "rows": rows})
# 100.00 + 50.00 (Transfer) - 50.00 (Einkauf) = 100.00
assert admin.get(f"/api/accounts/{ziel['id']}").json()["balance_cents"] == 10000
# --- Zeilenfilter: mehrere Pockets in einer Datei --------------------------- # --- Zeilenfilter: mehrere Pockets in einer Datei ---------------------------
@@ -487,12 +484,16 @@ def test_fee_row_direction_from_balance(admin):
assert rows["reconciliation"]["ok"] is True assert rows["reconciliation"]["ok"] is True
def test_reconciliation_flags_rows_waiting_for_target(admin): def test_keine_zeile_bleibt_wegen_fehlendem_gegenkonto_liegen(admin):
"""Eine Transfer-Regel ohne Gegenkonto lässt die Zeile ungebucht. Der """Jede Zeile des Auszugs wird gebucht – der Saldo-Abgleich geht auf.
Saldo-Abgleich muss das melden – sonst fehlt das Geld unbemerkt."""
Früher liess eine Übertrags-Regel ohne erkennbares Gegenkonto die Zeile
ungebucht; das Geld fehlte unbemerkt und der Abgleich schlug fehl. Ohne
automatische Kontoverknüpfung gibt es diesen Zustand nicht mehr.
"""
giro = _account(admin, name="Ziel offen", currency="CHF") giro = _account(admin, name="Ziel offen", currency="CHF")
admin.post("/api/categories/transfer-keywords", admin.post("/api/categories/transfer-keywords",
json={"word": "uebertrag", "target_account_id": giro["id"]}) # zeigt auf sich selbst json={"word": "uebertrag", "target_account_id": giro["id"]})
csv_text = ( csv_text = (
"Datum;Betrag;Empfaenger;Zweck;Saldo\n" "Datum;Betrag;Empfaenger;Zweck;Saldo\n"
@@ -511,14 +512,13 @@ def test_reconciliation_flags_rows_waiting_for_target(admin):
}, },
).json() ).json()
wartend = [r for r in res["rows"] if r.get("needs_target")] assert not [r for r in res["rows"] if r.get("needs_target")]
assert len(wartend) == 1 and wartend[0]["amount_cents"] == 9200 uebertrag = res["rows"][1]
assert uebertrag["type"] == "income" and uebertrag["include"] is True
rec = res["reconciliation"] rec = res["reconciliation"]
assert rec["ok"] is False, "die wartende Zeile muss auffallen" assert rec["ok"] is True, "der Auszug muss ohne Rest aufgehen"
assert rec["difference_cents"] == 9200 assert rec["difference_cents"] == 0
assert rec["first_mismatch"]["expected_cents"] == 9200
assert rec["first_mismatch"]["actual_cents"] == 0
def test_remove_undone_batch_from_history(admin): def test_remove_undone_batch_from_history(admin):
@@ -574,5 +574,5 @@ def test_kurzer_erkennungstext_trifft_nicht_mitten_im_wort(admin):
assert typ["PayPal Europe S.a.r.l."] == "expense" assert typ["PayPal Europe S.a.r.l."] == "expense"
assert typ["Europa-Park"] == "expense" assert typ["Europa-Park"] == "expense"
assert typ["TWINT EUROBUS AG"] == "expense" assert typ["TWINT EUROBUS AG"] == "expense"
# Als eigenständiges Wort greift der Erkennungs-Text weiterhin. # Auch als eigenständiges Wort verbindet der Erkennungs-Text keine Konten mehr.
assert typ["Umbuchung"] == "transfer" assert typ["Umbuchung"] == "expense"
+37 -51
View File
@@ -228,75 +228,61 @@ def test_amount_condition_rule(admin):
assert rows[1]["category_id"] is None assert rows[1]["category_id"] is None
def test_transfer_rule_and_target_guess(admin): def test_import_verbindet_keine_konten_mehr(admin):
"""Der Import erzeugt keine Überträge – weder über Schlagwort noch über
Erkennungs-Text, weder ausgehend noch eingehend.
Das Raten des Gegenkontos hat mehr kaputt gemacht als geholfen: falsch
geratene Ziele, verdrehte Richtungen und doppelt gebuchte Bewegungen,
sobald beide Auszüge eingelesen wurden. Jede Zeile ist jetzt schlicht das,
was im Auszug steht.
"""
giro = _account(admin, name="Giro TR") giro = _account(admin, name="Giro TR")
spar = _account(admin, name="Sparkonto TR", type="sparkonto", spar = _account(admin, name="Sparkonto TR", type="sparkonto",
identifiers=[{"kind": "text", "value": "Sparen"}]) identifiers=[{"kind": "text", "value": "Sparen"}])
assert any(i["kind"] == "text" for i in spar["identifiers"]) admin.post("/api/categories/transfer-keywords", json={"word": "uebertrag"})
admin.post("/api/categories/transfer-keywords",
r = admin.post("/api/categories/transfer-keywords", json={"word": "uebertrag"}) json={"word": "revolut", "target_account_id": spar["id"]})
assert r.status_code == 201, r.text
csv = ( csv = (
"Datum;Betrag;Empfaenger;Zweck\n" "Datum;Betrag;Empfaenger;Zweck\n"
"01.03.2026;-100,00;Max;Uebertrag Sparen\n" # -> Transfer, Ziel = Sparkonto "01.03.2026;-100,00;Max;Uebertrag Sparen\n" # Schlagwort + Erkennungs-Text
"02.03.2026;-50,00;Max;Uebertrag woandershin\n" # -> Transfer, kein Ziel erkannt "02.03.2026;-25,00;Einkauf REVOLUT;Karte\n" # Schlagwort mit festem Ziel
"03.03.2026;120,00;Gutschrift;von Sparen\n" # Eingang mit Erkennungs-Text
) )
rows = _preview_csv(admin, giro["id"], csv, COLMAP) rows = _preview_csv(admin, giro["id"], csv, COLMAP)
assert rows[0]["type"] == "transfer" assert [r["type"] for r in rows] == ["expense", "expense", "income"]
assert rows[0]["to_account_id"] == spar["id"] assert all(r["to_account_id"] is None for r in rows)
assert rows[1]["type"] == "transfer" assert all(r["needs_target"] is False for r in rows)
assert rows[1]["to_account_id"] is None
def test_transfer_keyword_fixed_target(admin): def test_beide_auszuege_werden_beide_gebucht(admin):
giro = _account(admin, name="Giro FT") """Lies man beide Seiten einer Bewegung ein, zählt jede auf ihrem Konto.
ziel = _account(admin, name="Revolut Konto")
# Transfer-Schlagwort „revolut" MIT festem Zielkonto (ohne Konto-Texte).
r = admin.post("/api/categories/transfer-keywords",
json={"word": "revolut", "target_account_id": ziel["id"]})
assert r.status_code == 201, r.text
assert r.json()["target_account_id"] == ziel["id"]
csv = "Datum;Betrag;Empfaenger;Zweck\n01.03.2026;-25,00;Einkauf REVOLUT;Karte\n" Früher galt die zweite Zeile als Gegenbuchung und blieb ungebucht – was
rows = _preview_csv(admin, giro["id"], csv, COLMAP) schiefging, sobald die Erkennung danebenlag. Jetzt bucht jedes Konto das,
assert rows[0]["type"] == "transfer" was in seinem eigenen Auszug steht, und beide Salden stimmen.
assert rows[0]["to_account_id"] == ziel["id"] """
a = _account(admin, name="Konto A CP", opening_balance_cents=100000,
# Erneutes Anlegen desselben Worts aktualisiert das Zielkonto (kein Konflikt). identifiers=[{"kind": "text", "value": "KontoA"}])
r2 = admin.post("/api/categories/transfer-keywords",
json={"word": "revolut", "target_account_id": giro["id"]})
assert r2.status_code == 201, r2.text
assert r2.json()["target_account_id"] == giro["id"]
def test_incoming_transfer_via_account_tag(admin):
# Erkennungs-Text am Quellkonto (Raiffeisen); Eingang auf Revolut wird zum
# Transfer VON Raiffeisen (symmetrische Erkennung, ohne Schlagwort).
revolut = _account(admin, name="Revolut Sym", identifiers=[{"kind": "text", "value": "Revolut"}])
raiff = _account(admin, name="Raiffeisen Sym", identifiers=[{"kind": "text", "value": "Raiffeisen"}])
csv = "Datum;Betrag;Empfaenger;Zweck\n01.03.2026;120,00;Gutschrift;Uebertrag Raiffeisen\n"
rows = _preview_csv(admin, revolut["id"], csv, COLMAP)
assert rows[0]["type"] == "transfer"
assert rows[0]["to_account_id"] == raiff["id"]
def test_transfer_counterpart_skipped(admin):
a = _account(admin, name="Konto A CP", identifiers=[{"kind": "text", "value": "KontoA"}])
b = _account(admin, name="Konto B CP", identifiers=[{"kind": "text", "value": "KontoB"}]) b = _account(admin, name="Konto B CP", identifiers=[{"kind": "text", "value": "KontoB"}])
# A-Auszug: Ausgang -100 mit „KontoB" -> Transfer nach B; buchen.
rowsA = _preview_csv(admin, a["id"], rowsA = _preview_csv(admin, a["id"],
"Datum;Betrag;Empfaenger;Zweck\n05.03.2026;-100,00;Uebertrag;an KontoB\n", COLMAP) "Datum;Betrag;Empfaenger;Zweck\n05.03.2026;-100,00;Uebertrag;an KontoB\n", COLMAP)
assert rowsA[0]["type"] == "transfer" and rowsA[0]["to_account_id"] == b["id"] assert rowsA[0]["type"] == "expense"
r = admin.post("/api/imports/commit", json={"account_id": a["id"], "filename": "a.csv", "rows": rowsA}) r = admin.post("/api/imports/commit", json={"account_id": a["id"], "filename": "a.csv", "rows": rowsA})
assert r.status_code == 200 and r.json()["imported_count"] == 1 assert r.json()["imported_count"] == 1
# B-Auszug: Eingang +100 (2 Tage später) mit „KontoA" -> Gegenbuchung, nicht gebucht.
rowsB = _preview_csv(admin, b["id"], rowsB = _preview_csv(admin, b["id"],
"Datum;Betrag;Empfaenger;Zweck\n07.03.2026;100,00;Gutschrift;von KontoA\n", COLMAP) "Datum;Betrag;Empfaenger;Zweck\n07.03.2026;100,00;Gutschrift;von KontoA\n", COLMAP)
assert rowsB[0]["type"] == "transfer" assert rowsB[0]["type"] == "income"
assert rowsB[0]["is_counterpart"] is True assert rowsB[0]["is_counterpart"] is False
assert rowsB[0]["include"] is False assert rowsB[0]["include"] is True
r = admin.post("/api/imports/commit", json={"account_id": b["id"], "filename": "b.csv", "rows": rowsB})
assert r.json()["imported_count"] == 1
assert admin.get(f"/api/accounts/{a['id']}").json()["balance_cents"] == 90000
assert admin.get(f"/api/accounts/{b['id']}").json()["balance_cents"] == 10000
def test_same_word_across_kinds_allowed(admin): def test_same_word_across_kinds_allowed(admin):