From ab5410ca6f6c87ac292fa454acef1028dad8e4f5 Mon Sep 17 00:00:00 2001 From: Guido Date: Fri, 21 Aug 2026 09:17:22 +0200 Subject: [PATCH] fix: Saldo-Abgleich meldet Zeilen, die auf ein Gegenkonto warten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eine Zeile mit „Gegenkonto wählen" wird nicht gebucht, der Abgleich zählte ihren Betrag aber trotzdem mit und meldete „stimmt überein" – das Geld fehlte im Konto, ohne dass es auffiel. Duplikate und Gegenbuchungen zählen weiterhin mit (ihr Betrag steckt bereits in einer früher gebuchten Buchung); wartende Zeilen zählen jetzt als 0 und tauchen als erste Abweichung mit Datum und Empfänger auf. Nachgestellt am echten Raiffeisen-Auszug: Zeigt die Transfer-Regel „Übertrag" auf das importierte Konto selbst, bleibt die Gutschrift über 92.00 ungebucht; der Abgleich nennt jetzt genau diese Zeile. Co-Authored-By: Claude Opus 4.8 --- backend/app/services/csv_import.py | 13 ++++++++---- backend/tests/test_import.py | 34 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/backend/app/services/csv_import.py b/backend/app/services/csv_import.py index 5ea0e19..8138c73 100644 --- a/backend/app/services/csv_import.py +++ b/backend/app/services/csv_import.py @@ -551,10 +551,15 @@ def reconcile(rows: list[dict]) -> dict: return {"available": False} def gebucht(r: dict) -> int: - """Betrag, der tatsächlich ins Konto fließt – 0 für nicht gebuchte Zeilen - (storniert, ungültig). Nur so fällt auf, wenn die Bank etwas verbucht hat, - wir aber nichts (z. B. nicht zugeordnete Gebührenspalte).""" - return (r.get("amount_cents") or 0) if r.get("valid") else 0 + """Betrag, der im Konto ankommt – 0, wenn die Zeile nirgends landet. + + Duplikate und Gegenbuchungen zählen mit: deren Betrag steckt bereits in + einer früher gebuchten Buchung. Eine Zeile, die auf ein Gegenkonto wartet, + zählt dagegen NICHT – sie ist unterwegs verloren, und genau das soll der + Abgleich melden.""" + if not r.get("valid") or r.get("needs_target"): + return 0 + return r.get("amount_cents") or 0 # Manche Banken exportieren neueste Buchung zuerst – für den Abgleich drehen. dated = [r["date"] for r in seq if r.get("date")] diff --git a/backend/tests/test_import.py b/backend/tests/test_import.py index 23f1886..1021051 100644 --- a/backend/tests/test_import.py +++ b/backend/tests/test_import.py @@ -485,3 +485,37 @@ def test_fee_row_direction_from_balance(admin): assert rows["rows"][1]["amount_cents"] == -1099, "Gebühr belastet" assert rows["rows"][2]["amount_cents"] == 1099, "Gebühr erstattet" 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.""" + 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 + + csv_text = ( + "Datum;Betrag;Empfaenger;Zweck;Saldo\n" + "01.03.2026;100,00;Lohn;Gehalt;100,00\n" + "02.03.2026;92,00;Uebertrag von Sparkonto;;192,00\n" + "03.03.2026;-10,00;Kiosk;;182,00\n" + ) + res = admin.post( + "/api/imports/preview", + files={"file": ("t.csv", io.BytesIO(csv_text.encode("utf-8")), "text/csv")}, + data={ + "account_id": str(giro["id"]), "delimiter": ";", "decimal_sep": ",", + "date_format": "%d.%m.%Y", "encoding": "utf-8", "has_header": "true", + "skip_rows": "0", "amount_mode": "signed", + "column_map": json.dumps({"date": 0, "amount": 1, "payee": 2, "purpose": 3, "balance": 4}), + }, + ).json() + + wartend = [r for r in res["rows"] if r.get("needs_target")] + assert len(wartend) == 1 and wartend[0]["amount_cents"] == 9200 + + 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