fix: Zwei Uebertraege ueber denselben Betrag sind erlaubt – es wird nur gefragt

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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-15 10:45:06 +02:00
co-authored by Claude Opus 5
parent b867b272ba
commit 54ff6d6896
4 changed files with 63 additions and 15 deletions
+16 -9
View File
@@ -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, def _pruefe_gegenseite(db: Session, user: User, konto: Account, ziel: Account,
eigener_typ: TxnType, erwartet: int | None, datum, eigener_typ: TxnType, erwartet: int | None, datum,
exakt: bool, trotzdem: bool) -> None: exakt: bool, trotzdem: bool) -> None:
# Zuerst der Fall, der sich NICHT uebergehen laesst: Zwischen denselben if trotzdem:
# beiden Konten bewegt schon ein Uebertrag dasselbe Geld. Ein zweiter return
# verschoebe es ein zweites Mal – das ist keine Ansichtssache.
# 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) doppelt = _schon_als_uebertrag(db, user, konto, ziel, erwartet, datum, exakt)
if doppelt is not None: if doppelt is not None:
raise UebertragFehler( raise UebertragFehler(
f"Zwischen '{konto.name}' und '{ziel.name}' steht für den " f"Zwischen '{konto.name}' und '{ziel.name}' steht für den "
f"{doppelt.date.strftime('%d.%m.%Y')} schon ein Übertrag über " f"{doppelt.date.strftime('%d.%m.%Y')} schon ein Übertrag über "
f"denselben Betrag. Diese Bewegung ist damit bereits erfasst – ein " f"denselben Betrag. Ist das dieselbe Bewegung, wäre sie damit "
f"zweiter Übertrag verschöbe das Geld noch einmal. Wenn diese " f"doppelt erfasst – das Quellkonto zweimal belastet, das Ziel "
f"Buchung dieselbe Zahlung ist, lösch sie; wenn es wirklich zwei " f"zweimal gutgeschrieben. Waren es wirklich zwei Zahlungen, buch "
f"Zahlungen waren, lösch zuerst den vorhandenen Übertrag." f"trotzdem.",
nachfragen=True,
) )
if trotzdem:
return
schon = _gebuchte_gegenseite(db, user, konto, ziel, eigener_typ, erwartet, schon = _gebuchte_gegenseite(db, user, konto, ziel, eigener_typ, erwartet,
datum, exakt) datum, exakt)
if schon is None: if schon is None:
+40 -5
View File
@@ -512,14 +512,49 @@ def test_zweiter_uebertrag_fuer_dieselbe_bewegung_wird_verhindert(admin):
"amount_cents": betrag, "payee": "Auftrag Revolut"}).json() "amount_cents": betrag, "payee": "Auftrag Revolut"}).json()
r = admin.post(f"/api/transactions/{belastung['id']}/transfer", r = admin.post(f"/api/transactions/{belastung['id']}/transfer",
json={"to_account_id": revolut["id"]}) 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"] 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", r = admin.post(f"/api/transactions/{belastung['id']}/transfer",
json={"to_account_id": revolut["id"], "trotzdem": True}) 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). # Bestaetigt heisst bestaetigt: Das Geld ist jetzt zweimal verschoben.
assert admin.get(f"/api/accounts/{revolut['id']}").json()["balance_cents"] == betrag # 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 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
+1 -1
View File
@@ -63,7 +63,7 @@ export function ConfirmProvider({ children }) {
<ConfirmContext.Provider value={confirm}> <ConfirmContext.Provider value={confirm}>
{children} {children}
{frage && ( {frage && (
<div className="modal-backdrop" <div className="modal-backdrop rueckfrage"
onClick={(e) => e.target === e.currentTarget && schliessen(frage.prompt ? null : false)}> onClick={(e) => e.target === e.currentTarget && schliessen(frage.prompt ? null : false)}>
<div className="modal" role="dialog" aria-modal="true"> <div className="modal" role="dialog" aria-modal="true">
<div className="modal-head"> <div className="modal-head">
+6
View File
@@ -688,6 +688,12 @@ td select { width: auto; min-width: 0; max-width: 100%; }
padding: var(--sp-4); padding: var(--sp-4);
background: rgba(9, 12, 18, 0.55); 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 { .modal {
width: 100%; max-width: 440px; width: 100%; max-width: 440px;
background: var(--surface); background: var(--surface);