fix: Saldo-Abgleich meldet Zeilen, die auf ein Gegenkonto warten

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 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-21 09:17:22 +02:00
co-authored by Claude Opus 4.8
parent c438d1ec50
commit ab5410ca6f
2 changed files with 43 additions and 4 deletions
+9 -4
View File
@@ -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")]
+34
View File
@@ -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