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
+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):