fix: Zweiter Uebertrag fuer dieselbe Bewegung wurde nicht erkannt

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 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-30 12:58:37 +02:00
co-authored by Claude Opus 5
parent c1561acdce
commit 7b3ed3e63c
2 changed files with 77 additions and 0 deletions
+39
View File
@@ -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, 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
# 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: if trotzdem:
return return
schon = _gebuchte_gegenseite(db, user, konto, ziel, eigener_typ, erwartet, schon = _gebuchte_gegenseite(db, user, konto, ziel, eigener_typ, erwartet,
+38
View File
@@ -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] 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" 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