diff --git a/backend/app/routers/transactions.py b/backend/app/routers/transactions.py index 7b48061..e357b9c 100644 --- a/backend/app/routers/transactions.py +++ b/backend/app/routers/transactions.py @@ -13,7 +13,15 @@ from sqlalchemy.orm import Session from ..database import get_db from ..deps import get_current_user, get_owned -from ..models import Account, Category, Transaction, TxnType, User +from ..models import ( + Account, + Category, + InboxItem, + InboxStatus, + Transaction, + TxnType, + User, +) from ..schemas import TransactionCreate, TransactionOut, TransactionUpdate from ..services import buchung, currency, uebertrag_buchen from ..services.uebertrag_buchen import UebertragFehler @@ -246,5 +254,13 @@ def delete_transaction( txn_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user) ): txn = get_owned(db, Transaction, txn_id, user) + # Eingangs-Eintraege, die auf diese Buchung zeigen, mit verwerfen. Der + # Fremdschluessel setzt sonst nur ihre Verweisspalte auf NULL, und sie + # blieben als "gebucht" ohne Buchung stehen – bei einem Uebertrag sind das + # zwei, die Quelle und die Gegenseite. + for eintrag in db.query(InboxItem).filter(InboxItem.owner_id == user.id, + InboxItem.transaction_id == txn.id): + eintrag.status = InboxStatus.discarded + eintrag.transaction_id = None db.delete(txn) db.commit() diff --git a/backend/app/services/uebertrag_buchen.py b/backend/app/services/uebertrag_buchen.py index a355783..052e585 100644 --- a/backend/app/services/uebertrag_buchen.py +++ b/backend/app/services/uebertrag_buchen.py @@ -27,6 +27,8 @@ anlegt, waehlt beide Konten bewusst und hat keine Gegenseite, die noch kommt. from __future__ import annotations +from datetime import timedelta + from sqlalchemy.orm import Session from ..models import ( @@ -36,10 +38,13 @@ from ..models import ( InboxSource, InboxStatus, Transaction, + TransferLink, TxnType, User, ) from . import buchung, csv_import, currency +from .csv_import import INBOX_TAGE +from .uebertraege import FX_TOLERANZ class UebertragFehler(RuntimeError): @@ -64,6 +69,74 @@ def ist_angebunden(db: Session, user: User, konto: Account) -> bool: ).first() is not None +def _eingang_zu(db: Session, user: User, txn: Transaction) -> InboxItem | None: + """Der Eingangs-Eintrag, aus dem diese Buchung entstand. + + Er traegt, was die Bank ueber die Umrechnung gesagt hat. Ohne ihn bliebe + nur der EZB-Kurs – auch dann, wenn die Bank ihren eigenen genannt hat. + """ + return db.query(InboxItem).filter( + InboxItem.owner_id == user.id, + InboxItem.transaction_id == txn.id, + InboxItem.account_id == txn.account_id, + ).first() + + +def _schon_verknuepft(db: Session, user: User, txn: Transaction) -> bool: + """Haengt diese Buchung schon als Paar an ihrer Gegenseite?""" + return db.query(TransferLink.id).filter( + TransferLink.owner_id == user.id, + (TransferLink.from_txn_id == txn.id) | (TransferLink.to_txn_id == txn.id), + ).first() is not None + + +def _gebuchte_gegenseite(db: Session, user: User, konto: Account, ziel: Account, + eigener_typ: TxnType, erwartet: int | None, datum, + exakt: bool) -> Transaction | None: + """Steht auf dem Zielkonto schon eine passende Buchung? + + Dann waere ein Uebertrag falsch: Er wuerde das Geld dort ein zweites Mal + bewegen. Richtig ist in dem Fall, die beiden zu VERKNUEPFEN – beide + Buchungen bleiben stehen und zaehlen nur nicht mehr in der Auswertung. + + Ueber Waehrungen hinweg wird mit Spielraum verglichen: Der erwartete Betrag + ist dann gerechnet, der echte kommt von der Bank. + """ + if not erwartet: + return None + gegen_typ = TxnType.income if eigener_typ == TxnType.expense else TxnType.expense + spielraum = 0 if exakt else max(1, round(erwartet * FX_TOLERANZ)) + return next( + (k for k in db.query(Transaction).filter( + Transaction.owner_id == user.id, + Transaction.account_id == ziel.id, + Transaction.type == gegen_typ, + Transaction.date >= datum - timedelta(days=INBOX_TAGE), + Transaction.date <= datum + timedelta(days=INBOX_TAGE), + ) if abs(k.amount_cents - erwartet) <= 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: + if trotzdem: + return + schon = _gebuchte_gegenseite(db, user, konto, ziel, eigener_typ, erwartet, + datum, exakt) + if schon is None: + return + raise UebertragFehler( + f"Auf '{ziel.name}' steht schon eine passende Buchung vom " + f"{schon.date.strftime('%d.%m.%Y')}. Beide Seiten sind also bereits " + f"gebucht – ein Übertrag würde das Geld dort ein zweites Mal bewegen. " + f"Richtig ist hier, die beiden zu verbinden: Sie bleiben stehen und " + f"zählen nur nicht mehr als Ausgabe und Einnahme.", + nachfragen=True, + ) + + def _gegenbetrag(db: Session, konto: Account, ziel: Account, betrag: int, datum, eintrag: InboxItem | None = None, ) -> tuple[int | None, float | None, bool]: @@ -203,6 +276,10 @@ def aus_eingang(db: Session, user: User, eintrag: InboxItem, ziel: Account, betrag = abs(eintrag.amount_cents) ziel_betrag, kurs, exakt = _gegenbetrag(db, konto, ziel, betrag, eintrag.date, eintrag) _pruefe(db, user, konto, ziel, exakt, trotzdem) + _pruefe_gegenseite( + db, user, konto, ziel, + TxnType.expense if eintrag.amount_cents < 0 else TxnType.income, + ziel_betrag, eintrag.date, exakt, trotzdem) txn = _lege_an(db, user, konto, ziel, eintrag.amount_cents, ziel_betrag, eintrag.date, eintrag.payee, eintrag.note, kurs) @@ -227,9 +304,28 @@ def aus_buchung(db: Session, user: User, txn: Transaction, ziel: Account, if konto is None or konto.owner_id != user.id: raise UebertragFehler("Buchung ohne Konto") + # Ist sie schon mit ihrer Gegenseite verknuepft, sind BEIDE gebucht. Ein + # Uebertrag wuerde das Geld auf dem Zielkonto ein zweites Mal bewegen – + # und das laesst sich nicht mit "trotzdem" wegdruecken, es ist einfach + # falsch. + if _schon_verknuepft(db, user, txn): + raise UebertragFehler( + "Diese Buchung ist bereits mit ihrer Gegenseite verbunden – beide " + "Seiten sind gebucht und zählen schon jetzt in keiner Auswertung. " + "Ein Übertrag würde das Geld auf dem Zielkonto ein zweites Mal " + "bewegen. Wenn du es wirklich anders willst, löse zuerst die " + "Verknüpfung." + ) + signiert = txn.amount_cents if txn.type == TxnType.income else -txn.amount_cents - ziel_betrag, _kurs, exakt = _gegenbetrag(db, konto, ziel, abs(signiert), txn.date) + # Der Eingangs-Eintrag traegt den Kurs der Bank – ohne ihn bliebe hier nur + # die EZB-Naeherung, auch wenn die Bank ihren eigenen genannt hat. + eintrag = _eingang_zu(db, user, txn) + ziel_betrag, kurs, exakt = _gegenbetrag(db, konto, ziel, abs(signiert), + txn.date, eintrag) _pruefe(db, user, konto, ziel, exakt, trotzdem) + _pruefe_gegenseite(db, user, konto, ziel, txn.type, ziel_betrag, txn.date, + exakt, trotzdem) # Die vorhandene Buchung umschreiben statt eine zweite anzulegen: Alles, was # auf sie zeigt – Eingangs-Eintrag, Verknuepfungen – bleibt damit gueltig. @@ -244,6 +340,8 @@ def aus_buchung(db: Session, user: User, txn: Transaction, ziel: Account, txn.account_id = von.id txn.to_account_id = nach.id txn.category_id = None + if kurs: + txn.fx_rate = kurs # der Kurs der Bank schlaegt die EZB buchung.setze_betraege(db, txn, von, nach) db.flush() diff --git a/backend/tests/test_inbox.py b/backend/tests/test_inbox.py index 145e8d6..d3219e2 100644 --- a/backend/tests/test_inbox.py +++ b/backend/tests/test_inbox.py @@ -351,3 +351,85 @@ def test_api_antworten_werden_nicht_zwischengespeichert(admin): assert r.headers.get("cache-control") == "no-store" r = admin.get("/api/accounts") assert r.headers.get("cache-control") == "no-store" + + +def test_verknuepfte_buchung_wird_nicht_zum_uebertrag(admin): + """Sind beide Seiten gebucht und verbunden, waere ein Uebertrag falsch: Er + bewegt das Geld auf dem Zielkonto ein zweites Mal.""" + a = _konto(admin, name=f"Verk-A {secrets.token_hex(3)}", currency="CHF") + b = _konto(admin, name=f"Verk-B {secrets.token_hex(3)}", currency="CHF") + betrag = 11000 + secrets.randbelow(900) + raus = admin.post("/api/transactions", json={ + "type": "expense", "date": "2026-08-15", "account_id": a["id"], + "amount_cents": betrag, "payee": "Umbuchung"}).json() + rein = admin.post("/api/transactions", json={ + "type": "income", "date": "2026-08-15", "account_id": b["id"], + "amount_cents": betrag, "payee": "Umbuchung"}).json() + assert admin.post("/api/uebertraege/verbinden", + json={"a_id": raus["id"], "b_id": rein["id"]}).status_code == 200 + + r = admin.post(f"/api/transactions/{raus['id']}/transfer", + json={"to_account_id": b["id"]}) + assert r.status_code == 400, r.text + assert "verbunden" in r.json()["detail"] + # Auch mit "trotzdem" nicht – es ist nachweislich falsch, keine Ansichtssache. + r = admin.post(f"/api/transactions/{raus['id']}/transfer", + json={"to_account_id": b["id"], "trotzdem": True}) + assert r.status_code == 400 + + +def test_schon_gebuchte_gegenseite_wird_gemeldet(admin): + """Ohne diese Warnung stuende das Geld auf dem Zielkonto zweimal.""" + a = _konto(admin, name=f"Doppel-A {secrets.token_hex(3)}", currency="CHF") + b = _konto(admin, name=f"Doppel-B {secrets.token_hex(3)}", currency="CHF") + betrag = 12000 + secrets.randbelow(900) + raus = admin.post("/api/transactions", json={ + "type": "expense", "date": "2026-08-16", "account_id": a["id"], + "amount_cents": betrag}).json() + # Die Gegenseite liegt schon gebucht auf B – nur eben unverbunden. + admin.post("/api/transactions", json={ + "type": "income", "date": "2026-08-17", "account_id": b["id"], + "amount_cents": betrag}) + + r = admin.post(f"/api/transactions/{raus['id']}/transfer", + json={"to_account_id": b["id"]}) + assert r.status_code == 409, r.text + assert "verbinden" in r.json()["detail"] + assert admin.get(f"/api/accounts/{b['id']}").json()["balance_cents"] == betrag + + # Der Treffer ist unscharf (Datum, Betrag) – wer es besser weiss, kommt durch. + r = admin.post(f"/api/transactions/{raus['id']}/transfer", + json={"to_account_id": b["id"], "trotzdem": True}) + assert r.status_code == 200, r.text + + +def test_uebertrag_nimmt_den_kurs_der_bank_auch_nachtraeglich(admin): + """Der Eingangs-Eintrag traegt den Kurs. Ohne ihn bliebe hier nur die + EZB-Naeherung – und die passt nicht auf den Rappen.""" + from app.database import SessionLocal + from app.models import InboxItem + + chf = _konto(admin, name=f"Kurs-CHF {secrets.token_hex(3)}", currency="CHF") + eur = _konto(admin, name=f"Kurs-EUR {secrets.token_hex(3)}", currency="EUR") + txn = admin.post("/api/transactions", json={ + "type": "expense", "date": "2026-08-18", "account_id": chf["id"], + "amount_cents": 40000, "payee": "Exchanged to EUR"}).json() + + # So sieht es aus, wenn die Bank ihren Kurs mitgeliefert hat. + db = SessionLocal() + try: + db.add(InboxItem( + owner_id=admin.get("/api/auth/me").json()["id"], + source="bank", status="booked", account_id=chf["id"], + date=__import__("datetime").date(2026, 8, 18), amount_cents=-40000, + currency="CHF", payee="Exchanged to EUR", transaction_id=txn["id"], + counter_amount_cents=42532, counter_currency="EUR")) + db.commit() + finally: + db.close() + + r = admin.post(f"/api/transactions/{txn['id']}/transfer", + json={"to_account_id": eur["id"]}) + assert r.status_code == 200, r.text + assert r.json()["to_amount_cents"] == 42532, "der Betrag der Bank, nicht der gerechnete" + assert admin.get(f"/api/accounts/{eur['id']}").json()["balance_cents"] == 42532