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
+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.
assert rows[2]["is_duplicate"] is False
assert rows[2]["include"] is True
# Zeile 3: interner Transfer (IBAN passt zu 'spar')
assert rows[3]["type"] == "transfer"
assert rows[3]["to_account_id"] == spar["id"]
# Zeile 3: die IBAN gehört einem eigenen Konto – der Import verbindet
# trotzdem nichts mehr, es bleibt eine gewöhnliche Ausgabe.
assert rows[3]["type"] == "expense"
assert rows[3]["to_account_id"] is None
assert spar
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)
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",
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)
assert rows[0]["type"] == "transfer"
assert rows[0]["to_account_id"] == eur["id"]
assert rows[0]["is_counterpart"] is False
assert rows[0]["type"] == "expense"
assert rows[0]["to_account_id"] is None
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
# 2) EUR-Auszug: OHNE weitere Regel – die Gegenseite wird über den bereits
# gebuchten Transfer erkannt, obwohl der Betrag in EUR ein anderer ist.
# Der EUR-Auszug bringt den Zugang – als Einnahme, nicht als Gegenbuchung.
rows2 = _rev_preview(admin, eur["id"], EUR_UMTAUSCH)
assert rows2[0]["type"] == "transfer"
assert rows2[0]["is_counterpart"] is True, "Umtausch-Gegenseite muss erkannt werden"
assert rows2[0]["include"] is False, "darf nicht doppelt zählen"
assert rows2[0]["type"] == "income"
assert rows2[0]["is_counterpart"] is False
assert rows2[0]["include"] is True
r = admin.post("/api/imports/commit", json={"account_id": eur["id"], "filename": "eur.csv", "rows": rows2})
assert r.json()["imported_count"] == 0
# Der echte Eingang korrigiert die EZB-Schätzung: EUR-Konto steht auf 106.42.
assert r.json()["imported_count"] == 1
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
def test_exchange_arrival_without_source_asks_instead_of_booking(admin):
"""Wird der Empfangs-Auszug zuerst eingelesen, ist die Quelle noch unbekannt.
Die Zeile darf dann NICHT still als Einnahme durchgehen, sondern fragt das
Konto ab – sonst zählt die Bewegung später doppelt."""
chf = _account(admin, name="Rev CHF Y", currency="CHF", opening_balance_cents=50000)
eur = _account(admin, name="Rev EUR Y", currency="EUR")
def test_uebertrags_schlagwort_setzt_neutrale_kategorie(admin):
"""Ein früheres Übertrags-Schlagwort kennzeichnet die Zeile jetzt nur noch.
Es verbindet keine Konten mehr, sondern hängt die neutrale Kategorie an –
die Buchung verändert den Saldo, bleibt aber aus der Statistik heraus.
"""
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",
json={"word": "umgetauscht in eur", "target_account_id": eur["id"]})
rows = _rev_preview(admin, eur["id"], EUR_UMTAUSCH)
assert rows[0]["type"] == "transfer"
assert rows[0]["to_account_id"] is None
assert rows[0]["needs_target"] is True
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
rows = _rev_preview(admin, chf["id"], CHF_UMTAUSCH)
assert rows[0]["type"] == "expense"
assert rows[0]["category_id"] == neutral["id"]
assert rows[0]["needs_target"] is False
def test_expense_is_not_swallowed_by_incoming_transfer(admin):
"""Eine echte Ausgabe über denselben Betrag wie ein erwarteter Transfer-Eingang
darf NICHT als Gegenbuchung gelten – sonst fehlt sie und der Saldo läuft zu hoch."""
"""Eine echte Ausgabe bleibt eine Ausgabe – auch wenn anderswo ein gleich
hoher Übertrag steht. Früher wurde sie als dessen Gegenbuchung geschluckt."""
quelle = _account(admin, name="Quelle Z", currency="EUR", opening_balance_cents=100000)
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"]
assert rows[0]["is_counterpart"] is False, "Ausgabe ist keine Gegenbuchung"
assert rows[0]["include"] is True
assert rows[1]["is_counterpart"] is True, "der Eingang schon"
assert rows[1]["include"] is False
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
# Keine Zeile gilt mehr als Gegenbuchung – beide werden gebucht.
assert all(r["is_counterpart"] is False for r in rows)
assert all(r["include"] is True for r in rows)
assert rows[0]["type"] == "expense"
assert rows[1]["type"] == "income"
# --- Zeilenfilter: mehrere Pockets in einer Datei ---------------------------
@@ -487,12 +484,16 @@ def test_fee_row_direction_from_balance(admin):
assert rows["reconciliation"]["ok"] is True
def test_reconciliation_flags_rows_waiting_for_target(admin):
"""Eine Transfer-Regel ohne Gegenkonto lässt die Zeile ungebucht. Der
Saldo-Abgleich muss das melden – sonst fehlt das Geld unbemerkt."""
def test_keine_zeile_bleibt_wegen_fehlendem_gegenkonto_liegen(admin):
"""Jede Zeile des Auszugs wird gebucht – der Saldo-Abgleich geht auf.
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")
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 = (
"Datum;Betrag;Empfaenger;Zweck;Saldo\n"
@@ -511,14 +512,13 @@ def test_reconciliation_flags_rows_waiting_for_target(admin):
},
).json()
wartend = [r for r in res["rows"] if r.get("needs_target")]
assert len(wartend) == 1 and wartend[0]["amount_cents"] == 9200
assert not [r for r in res["rows"] if r.get("needs_target")]
uebertrag = res["rows"][1]
assert uebertrag["type"] == "income" and uebertrag["include"] is True
rec = res["reconciliation"]
assert rec["ok"] is False, "die wartende Zeile muss auffallen"
assert rec["difference_cents"] == 9200
assert rec["first_mismatch"]["expected_cents"] == 9200
assert rec["first_mismatch"]["actual_cents"] == 0
assert rec["ok"] is True, "der Auszug muss ohne Rest aufgehen"
assert rec["difference_cents"] == 0
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["Europa-Park"] == "expense"
assert typ["TWINT EUROBUS AG"] == "expense"
# Als eigenständiges Wort greift der Erkennungs-Text weiterhin.
assert typ["Umbuchung"] == "transfer"
# Auch als eigenständiges Wort verbindet der Erkennungs-Text keine Konten mehr.
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
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")
spar = _account(admin, name="Sparkonto TR", type="sparkonto",
identifiers=[{"kind": "text", "value": "Sparen"}])
assert any(i["kind"] == "text" for i in spar["identifiers"])
r = admin.post("/api/categories/transfer-keywords", json={"word": "uebertrag"})
assert r.status_code == 201, r.text
admin.post("/api/categories/transfer-keywords", json={"word": "uebertrag"})
admin.post("/api/categories/transfer-keywords",
json={"word": "revolut", "target_account_id": spar["id"]})
csv = (
"Datum;Betrag;Empfaenger;Zweck\n"
"01.03.2026;-100,00;Max;Uebertrag Sparen\n" # -> Transfer, Ziel = Sparkonto
"02.03.2026;-50,00;Max;Uebertrag woandershin\n" # -> Transfer, kein Ziel erkannt
"01.03.2026;-100,00;Max;Uebertrag Sparen\n" # Schlagwort + Erkennungs-Text
"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)
assert rows[0]["type"] == "transfer"
assert rows[0]["to_account_id"] == spar["id"]
assert rows[1]["type"] == "transfer"
assert rows[1]["to_account_id"] is None
assert [r["type"] for r in rows] == ["expense", "expense", "income"]
assert all(r["to_account_id"] is None for r in rows)
assert all(r["needs_target"] is False for r in rows)
def test_transfer_keyword_fixed_target(admin):
giro = _account(admin, name="Giro FT")
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"]
def test_beide_auszuege_werden_beide_gebucht(admin):
"""Lies man beide Seiten einer Bewegung ein, zählt jede auf ihrem Konto.
csv = "Datum;Betrag;Empfaenger;Zweck\n01.03.2026;-25,00;Einkauf REVOLUT;Karte\n"
rows = _preview_csv(admin, giro["id"], csv, COLMAP)
assert rows[0]["type"] == "transfer"
assert rows[0]["to_account_id"] == ziel["id"]
# Erneutes Anlegen desselben Worts aktualisiert das Zielkonto (kein Konflikt).
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"}])
Früher galt die zweite Zeile als Gegenbuchung und blieb ungebucht – was
schiefging, sobald die Erkennung danebenlag. Jetzt bucht jedes Konto das,
was in seinem eigenen Auszug steht, und beide Salden stimmen.
"""
a = _account(admin, name="Konto A CP", opening_balance_cents=100000,
identifiers=[{"kind": "text", "value": "KontoA"}])
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"],
"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})
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"],
"Datum;Betrag;Empfaenger;Zweck\n07.03.2026;100,00;Gutschrift;von KontoA\n", COLMAP)
assert rowsB[0]["type"] == "transfer"
assert rowsB[0]["is_counterpart"] is True
assert rowsB[0]["include"] is False
assert rowsB[0]["type"] == "income"
assert rowsB[0]["is_counterpart"] 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):