From 54ff6d6896b4feb4c3da922cc4ec7ea3258df735 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 15 Oct 2026 10:45:06 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Zwei=20Uebertraege=20ueber=20denselben?= =?UTF-8?q?=20Betrag=20sind=20erlaubt=20=E2=80=93=20es=20wird=20nur=20gefr?= =?UTF-8?q?agt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwischen zwei Konten liess sich kein zweiter Uebertrag ueber denselben Betrag buchen. Die Begruendung stimmte fuer den haeufigen Fall: Beide Seiten kommen oft getrennt herein, einmal von der Bank, einmal aus der Datei – wer beide einzeln verknuepft, verschiebt das Geld zweimal. Nur ist sie nicht immer richtig. Revolut-Aufrundungen sind reihenweise CHF 1.00 zwischen demselben Paar in denselben Tagen, und die sind alle echt. Als einziger Ausweg blieb, den vorhandenen Uebertrag zu loeschen. Jetzt wird gefragt statt verboten: dieselbe Warnung, aber ueber "trotzdem" zu uebergehen – der Weg, den alle anderen Pruefungen hier schon nehmen. Entscheiden muss das der Nutzer; die Regel kann es nicht wissen. Dabei fiel auf, dass die Rueckfrage HINTER dem Fenster lag, das sie aufgeworfen hat: Modal rendert per Portal an den Koerper und liegt damit spaeter im Baum, bei gleicher Ebene gewinnt der spaetere. Eine Rueckfrage gehoert ueber das, wonach sie fragt. Co-Authored-By: Claude Opus 5 --- backend/app/services/uebertrag_buchen.py | 25 ++++++++----- backend/tests/test_inbox.py | 45 +++++++++++++++++++++--- web/src/confirm.jsx | 2 +- web/src/styles.css | 6 ++++ 4 files changed, 63 insertions(+), 15 deletions(-) diff --git a/backend/app/services/uebertrag_buchen.py b/backend/app/services/uebertrag_buchen.py index c08dcda..96b6b52 100644 --- a/backend/app/services/uebertrag_buchen.py +++ b/backend/app/services/uebertrag_buchen.py @@ -147,21 +147,28 @@ def _schon_als_uebertrag(db: Session, user: User, konto: Account, ziel: Account, 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. + if trotzdem: + return + + # Zwischen denselben beiden Konten steht schon ein Uebertrag ueber + # denselben Betrag. Meist ist das dieselbe Bewegung, zweimal hereingekommen + # – einmal von der Bank, einmal aus der Datei –, und ein zweiter Uebertrag + # verschoebe das Geld noch einmal. + # + # Aber eben nur meist: Revolut-Aufrundungen sind reihenweise CHF 1.00 + # zwischen demselben Paar in denselben Tagen, und die sind alle echt. + # Deshalb fragen statt verbieten – wer es weiss, bucht trotzdem. 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." + f"denselben Betrag. Ist das dieselbe Bewegung, wäre sie damit " + f"doppelt erfasst – das Quellkonto zweimal belastet, das Ziel " + f"zweimal gutgeschrieben. Waren es wirklich zwei Zahlungen, buch " + f"trotzdem.", + nachfragen=True, ) - if trotzdem: - return schon = _gebuchte_gegenseite(db, user, konto, ziel, eigener_typ, erwartet, datum, exakt) if schon is None: diff --git a/backend/tests/test_inbox.py b/backend/tests/test_inbox.py index d5e2f04..da9005e 100644 --- a/backend/tests/test_inbox.py +++ b/backend/tests/test_inbox.py @@ -512,14 +512,49 @@ def test_zweiter_uebertrag_fuer_dieselbe_bewegung_wird_verhindert(admin): "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 r.status_code == 409, r.text assert "schon ein Übertrag" in r.json()["detail"] - # Auch mit "trotzdem" nicht – doppelt verschieben ist keine Ansichtssache. + # Nachgefragt, nicht verboten: Wer weiss, dass es wirklich zwei Zahlungen + # waren, bucht trotzdem. Siehe test_mehrere_gleiche_uebertraege_sind_erlaubt. r = admin.post(f"/api/transactions/{belastung['id']}/transfer", json={"to_account_id": revolut["id"], "trotzdem": True}) - assert r.status_code == 400 + assert r.status_code == 200, r.text - # Und die Salden sind unangetastet (bis auf die neue Belastung selbst). - assert admin.get(f"/api/accounts/{revolut['id']}").json()["balance_cents"] == betrag + # Bestaetigt heisst bestaetigt: Das Geld ist jetzt zweimal verschoben. + # Genau davor warnt die Rueckfrage – entschieden hat der Nutzer. + assert admin.get(f"/api/accounts/{revolut['id']}").json()["balance_cents"] == 2 * betrag assert admin.get(f"/api/accounts/{lohn['id']}").json()["balance_cents"] == -2 * betrag + + +def test_mehrere_gleiche_uebertraege_sind_erlaubt(admin): + """Revolut-Aufrundungen sind reihenweise CHF 1.00 zwischen demselben Paar + in denselben Tagen – und die sind alle echt. + + Gefragt wird trotzdem: Meist IST es dieselbe Bewegung, zweimal + hereingekommen. Entscheiden muss das aber der Nutzer, nicht die Regel. + """ + giro = _konto(admin, name=f"Giro {secrets.token_hex(3)}", currency="CHF") + pocket = _konto(admin, name=f"Pocket {secrets.token_hex(3)}", currency="CHF") + + def belastung(tag): + return admin.post("/api/transactions", json={ + "type": "expense", "date": tag, "account_id": giro["id"], + "amount_cents": 100, "payee": "Sparen Aufrundung"}).json() + + def zum_uebertrag(txn, trotzdem=False): + return admin.post(f"/api/transactions/{txn['id']}/transfer", + json={"to_account_id": pocket["id"], "trotzdem": trotzdem}) + + assert zum_uebertrag(belastung("2026-08-24")).status_code == 200 + + # Die zweite wird hinterfragt … + zweite = belastung("2026-08-25") + assert zum_uebertrag(zweite).status_code == 409 + + # … und laesst sich mit derselben Buchung bestaetigen. + assert zum_uebertrag(zweite, trotzdem=True).status_code == 200 + + # Beide Bewegungen stehen – das Pocket hat zweimal einen Franken bekommen. + assert admin.get(f"/api/accounts/{pocket['id']}").json()["balance_cents"] == 200 + assert admin.get(f"/api/accounts/{giro['id']}").json()["balance_cents"] == -200 diff --git a/web/src/confirm.jsx b/web/src/confirm.jsx index 3669cd3..2579ab7 100644 --- a/web/src/confirm.jsx +++ b/web/src/confirm.jsx @@ -63,7 +63,7 @@ export function ConfirmProvider({ children }) { {children} {frage && ( -
e.target === e.currentTarget && schliessen(frage.prompt ? null : false)}>
diff --git a/web/src/styles.css b/web/src/styles.css index e7a802e..065b996 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -688,6 +688,12 @@ td select { width: auto; min-width: 0; max-width: 100%; } padding: var(--sp-4); background: rgba(9, 12, 18, 0.55); } +/* Eine Rueckfrage gehoert ueber das, wonach sie fragt. + Modal rendert per Portal an den Koerper und liegt damit spaeter im Baum als + der Rueckfrage-Dialog, der im Provider steckt. Bei gleicher Ebene gewinnt + der spaetere – die Rueckfrage verschwand hinter dem Fenster, das sie + aufgeworfen hat. */ +.modal-backdrop.rueckfrage { z-index: 60; } .modal { width: 100%; max-width: 440px; background: var(--surface);