From 7b3ed3e63cae12a71dce89cafff98d9e06aee94f Mon Sep 17 00:00:00 2001 From: Guido Date: Sun, 30 Aug 2026 12:58:37 +0200 Subject: [PATCH] fix: Zweiter Uebertrag fuer dieselbe Bewegung wurde nicht erkannt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Sperre sah auf dem Zielkonto nur nach Ausgaben und Einnahmen. Steht dort schon ein UEBERTRAG, der dasselbe Geld zwischen denselben beiden Konten bewegt, fand sie nichts – und der zweite ging durch. Genau das passiert im Alltag: Die eine Seite kommt von der Bank, die andere aus der CSV-Datei, und beide sehen einzeln nach einem Uebertrag aus. Wer beide umwidmet, hat das Quellkonto doppelt belastet und das Ziel doppelt gutgeschrieben – bei 1'575 macht das 3'150 Unterschied auf zwei Konten. Jetzt wird zuerst geprueft, ob zwischen denselben beiden Konten schon ein Uebertrag ueber denselben Betrag in derselben Woche steht. Die Richtung ist dabei egal, ein Uebertrag hat kein Vorzeichen. Dieser Fall laesst sich auch nicht mit "trotzdem" uebergehen: Dasselbe Geld zweimal zu verschieben ist keine Ansichtssache. Co-Authored-By: Claude Opus 5 --- backend/app/services/uebertrag_buchen.py | 39 ++++++++++++++++++++++++ backend/tests/test_inbox.py | 38 +++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/backend/app/services/uebertrag_buchen.py b/backend/app/services/uebertrag_buchen.py index 052e585..551f827 100644 --- a/backend/app/services/uebertrag_buchen.py +++ b/backend/app/services/uebertrag_buchen.py @@ -118,9 +118,48 @@ def _gebuchte_gegenseite(db: Session, user: User, konto: Account, ziel: Account, ) +def _schon_als_uebertrag(db: Session, user: User, konto: Account, ziel: Account, + betrag: int | None, datum, exakt: bool) -> Transaction | None: + """Bewegt schon ein Uebertrag dasselbe Geld zwischen diesen beiden Konten? + + Beide Seiten kommen oft getrennt herein – die eine von der Bank, die andere + aus der Datei. Wer beide einzeln zum Uebertrag macht, verschiebt das Geld + zweimal. Die Richtung ist dabei egal: Ein Uebertrag hat kein Vorzeichen, + nur ein Von und ein Nach. + """ + if not betrag: + return None + spielraum = 0 if exakt else max(1, round(betrag * FX_TOLERANZ)) + paar = {konto.id, ziel.id} + return next( + (t for t in db.query(Transaction).filter( + Transaction.owner_id == user.id, + Transaction.type == TxnType.transfer, + Transaction.date >= datum - timedelta(days=INBOX_TAGE), + Transaction.date <= datum + timedelta(days=INBOX_TAGE), + ) if {t.account_id, t.to_account_id} == paar + and min(abs(t.amount_cents - betrag), + abs((t.to_amount_cents or t.amount_cents) - betrag)) <= spielraum), + None, + ) + + def _pruefe_gegenseite(db: Session, user: User, konto: Account, ziel: Account, eigener_typ: TxnType, erwartet: int | None, datum, exakt: bool, trotzdem: bool) -> None: + # Zuerst der Fall, der sich NICHT uebergehen laesst: Zwischen denselben + # beiden Konten bewegt schon ein Uebertrag dasselbe Geld. Ein zweiter + # verschoebe es ein zweites Mal – das ist keine Ansichtssache. + doppelt = _schon_als_uebertrag(db, user, konto, ziel, erwartet, datum, exakt) + if doppelt is not None: + raise UebertragFehler( + f"Zwischen '{konto.name}' und '{ziel.name}' steht für den " + f"{doppelt.date.strftime('%d.%m.%Y')} schon ein Übertrag über " + f"denselben Betrag. Diese Bewegung ist damit bereits erfasst – ein " + f"zweiter Übertrag verschöbe das Geld noch einmal. Wenn diese " + f"Buchung dieselbe Zahlung ist, lösch sie; wenn es wirklich zwei " + f"Zahlungen waren, lösch zuerst den vorhandenen Übertrag." + ) if trotzdem: return schon = _gebuchte_gegenseite(db, user, konto, ziel, eigener_typ, erwartet, diff --git a/backend/tests/test_inbox.py b/backend/tests/test_inbox.py index 7b3d901..d5e2f04 100644 --- a/backend/tests/test_inbox.py +++ b/backend/tests/test_inbox.py @@ -485,3 +485,41 @@ def test_uebertrag_nennt_die_echte_quelle_nicht_die_gegenseite(admin): aus_liste = [t for t in admin.get("/api/transactions").json() if t["id"] == txn_id][0] assert aus_liste["herkunft"] == "mail", "nicht 'transfer' – das ist die Gegenseite" + + +def test_zweiter_uebertrag_fuer_dieselbe_bewegung_wird_verhindert(admin): + """Beide Seiten kommen oft getrennt herein – die eine von der Bank, die + andere aus der Datei. Wer beide einzeln zum Uebertrag macht, verschiebt das + Geld zweimal: Das Quellkonto ist doppelt belastet, das Ziel doppelt + gutgeschrieben.""" + lohn = _konto(admin, name=f"Lohn {secrets.token_hex(3)}", currency="CHF") + revolut = _konto(admin, name=f"Revolut {secrets.token_hex(3)}", currency="CHF") + betrag = 157500 + + # Die Gutschrift kam von der Bank und wurde zum Uebertrag gemacht. + gutschrift = admin.post("/api/transactions", json={ + "type": "income", "date": "2026-08-25", "account_id": revolut["id"], + "amount_cents": betrag, "payee": "Guido Schmit"}).json() + r = admin.post(f"/api/transactions/{gutschrift['id']}/transfer", + json={"to_account_id": lohn["id"]}) + assert r.status_code == 200, r.text + assert admin.get(f"/api/accounts/{revolut['id']}").json()["balance_cents"] == betrag + assert admin.get(f"/api/accounts/{lohn['id']}").json()["balance_cents"] == -betrag + + # Jetzt kommt die Belastung aus der CSV-Datei dazu. + belastung = admin.post("/api/transactions", json={ + "type": "expense", "date": "2026-08-25", "account_id": lohn["id"], + "amount_cents": betrag, "payee": "Auftrag Revolut"}).json() + r = admin.post(f"/api/transactions/{belastung['id']}/transfer", + json={"to_account_id": revolut["id"]}) + assert r.status_code == 400, r.text + assert "schon ein Übertrag" in r.json()["detail"] + + # Auch mit "trotzdem" nicht – doppelt verschieben ist keine Ansichtssache. + r = admin.post(f"/api/transactions/{belastung['id']}/transfer", + json={"to_account_id": revolut["id"], "trotzdem": True}) + assert r.status_code == 400 + + # Und die Salden sind unangetastet (bis auf die neue Belastung selbst). + assert admin.get(f"/api/accounts/{revolut['id']}").json()["balance_cents"] == betrag + assert admin.get(f"/api/accounts/{lohn['id']}").json()["balance_cents"] == -2 * betrag