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