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