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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user