From 187a1119a29b887fdcd4a1ae1ccfd16b1c598ab9 Mon Sep 17 00:00:00 2001 From: Guido Date: Fri, 21 Aug 2026 08:31:18 +0200 Subject: [PATCH] fix: Gegenbuchungs-Erkennung beachtet die Richtung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Suche prüfte nur Betrag und Zeitfenster. Ein erwarteter Transfer-EINGANG passte damit auch auf eine gleich hohe AUSGABE: Die echte Ausgabe galt als Gegenbuchung, wurde nicht gebucht und der Saldo lief um ihren Betrag zu hoch. Jetzt muss das Vorzeichen zur Seite des Transfers passen (wo Geld ankam, kann keine Ausgabe die Gegenseite sein). Test deckt genau diesen Fall ab; 87 grün. Co-Authored-By: Claude Opus 4.8 --- backend/app/services/csv_import.py | 11 ++++++--- backend/tests/test_import.py | 38 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/backend/app/services/csv_import.py b/backend/app/services/csv_import.py index b040557..5e3d78b 100644 --- a/backend/app/services/csv_import.py +++ b/backend/app/services/csv_import.py @@ -278,9 +278,14 @@ def _find_counterpart( absamt = abs(amount) unscharf = None for eintrag in side_index.get(account.id, []): - txn_id, t_date, erwartet, other_id = eintrag + txn_id, t_date, erwartet, other_id, richtung = eintrag if txn_id in verbraucht or abs((t_date - d).days) > COUNTERPART_DAYS: continue + # Die Richtung muss stimmen: Wo Geld ankam, kann keine Ausgabe die + # Gegenseite sein. Ohne diese Prüfung verschluckt eine Ausgabe über + # denselben Betrag den erwarteten Eingang – der Saldo liefe zu hoch. + if (amount < 0) != (richtung < 0): + continue # Hat eine Regel ein Gegenkonto gesetzt, muss es dazu passen. if nur_konto is not None and other_id != nur_konto: continue @@ -380,8 +385,8 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict Transaction.to_account_id.isnot(None)) ): gutschrift = t_to_amt if t_to_amt is not None else t_amt - side_index.setdefault(t_acc, []).append((t_id, t_date, t_amt, t_to)) - side_index.setdefault(t_to, []).append((t_id, t_date, gutschrift, t_acc)) + side_index.setdefault(t_acc, []).append((t_id, t_date, t_amt, t_to, -1)) + side_index.setdefault(t_to, []).append((t_id, t_date, gutschrift, t_acc, +1)) verbraucht: set[int] = set() waehrung_je_konto: dict[int, str] = dict( db.query(Account.id, Account.currency).filter(Account.owner_id == user.id) diff --git a/backend/tests/test_import.py b/backend/tests/test_import.py index 20a9e30..3702d0c 100644 --- a/backend/tests/test_import.py +++ b/backend/tests/test_import.py @@ -368,3 +368,41 @@ def test_exchange_arrival_without_source_asks_instead_of_booking(admin): assert r.json()["imported_count"] == 0 assert admin.get(f"/api/accounts/{eur['id']}").json()["balance_cents"] == 0 assert chf # Quellkonto bleibt unberührt + + +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.""" + quelle = _account(admin, name="Quelle Z", currency="EUR", opening_balance_cents=100000) + ziel = _account(admin, name="Ziel Z", currency="EUR", opening_balance_cents=10000) + + # Transfer 50,00 von Quelle -> Ziel buchen. + r = admin.post("/api/transactions", json={ + "type": "transfer", "date": "2026-03-10", + "account_id": quelle["id"], "to_account_id": ziel["id"], "amount_cents": 5000, + }) + assert r.status_code == 201, r.text + + csv_text = ( + "Datum;Betrag;Empfaenger;Zweck;GegenIBAN\n" + "11.03.2026;-50,00;Supermarkt;Einkauf;\n" # echte Ausgabe, gleicher Betrag + "12.03.2026;50,00;Von Quelle;Uebertrag;\n" # das ist die echte Gegenbuchung + ) + rows = admin.post( + "/api/imports/preview", + files={"file": ("z.csv", io.BytesIO(csv_text.encode("utf-8")), "text/csv")}, + data={ + "account_id": str(ziel["id"]), "delimiter": ";", "decimal_sep": ",", + "date_format": "%d.%m.%Y", "encoding": "utf-8", "has_header": "true", + "skip_rows": "0", "amount_mode": "signed", "column_map": COLMAP, + }, + ).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