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