diff --git a/backend/app/models.py b/backend/app/models.py index ece8a4f..da5c26a 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -631,6 +631,7 @@ class InboxSource(str, enum.Enum): shortcut = "shortcut" # per Kurzbefehl vom Telefon erfasst mail = "mail" # aus einer Benachrichtigungs-Mail gelesen bank = "bank" # direkt von der Bank geholt (PSD2) + transfer = "transfer" # Gegenseite eines Kontouebertrags, von Certain angelegt class InboxStatus(str, enum.Enum): diff --git a/backend/app/routers/inbox.py b/backend/app/routers/inbox.py index 758be0d..ee7c452 100644 --- a/backend/app/routers/inbox.py +++ b/backend/app/routers/inbox.py @@ -22,7 +22,8 @@ from ..models import ( ) from ..schemas import ApiTokenCreate, ApiTokenCreated, ApiTokenOut, InboxItemOut from ..security import generate_session_token, hash_session_token -from ..services import eingang +from ..services import eingang, uebertrag_buchen +from ..services.uebertrag_buchen import UebertragFehler router = APIRouter(prefix="/inbox", tags=["inbox"]) @@ -72,6 +73,19 @@ def delete_token(token_id: int, db: Session = Depends(get_db), user: User = Depe # --- Eingang ----------------------------------------------------------------- +def _uebertrags_fehler(e: UebertragFehler) -> HTTPException: + """409, wenn sich der Fehler mit ``trotzdem`` übergehen lässt, sonst 400. + + Der Unterschied ist der Oberfläche wichtig: 409 heisst nachfragen und noch + einmal senden, 400 heisst, es geht so nicht. + """ + return HTTPException( + status_code=status.HTTP_409_CONFLICT if e.nachfragen + else status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + + def _gegenkonten(db: Session, user: User, eintraege: list[InboxItem]) -> dict[int, int]: """Je Eintrag das Gegenkonto, falls seine Buchung ein Übertrag ist. @@ -154,28 +168,12 @@ def book_as_transfer(item_id: int, payload: InboxTransferIn, Übertrag ist keine Ausgabe. """ eintrag = get_owned(db, InboxItem, item_id, user) - if eintrag.account_id is None: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, - detail="Kein Konto zugeordnet") ziel = get_owned(db, Account, payload.to_account_id, user) - if ziel.id == eintrag.account_id: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, - detail="Ein Übertrag braucht ein anderes Zielkonto") - - # Der teuerste Fehler an dieser Stelle: Das Zielkonto hängt selbst an einer - # Bank, liefert die Gegenbuchung also von allein. Wer hier trotzdem eine - # anlegt, hat das Geld zweimal auf dem Zielkonto. - if not payload.trotzdem and eingang.ist_angebunden(db, user, ziel): - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=(f"'{ziel.name}' ist selbst an eine Bank angebunden und meldet " - f"den Zugang von allein. Buche das hier als Ausgabe – beide " - f"Seiten werden danach automatisch als Übertrag verbunden."), - ) - - if eingang.buche_als_uebertrag(db, user, eintrag, ziel) is None: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, - detail="Lässt sich nicht buchen") + try: + uebertrag_buchen.aus_eingang(db, user, eintrag, ziel, payload.trotzdem) + except UebertragFehler as e: + db.rollback() + raise _uebertrags_fehler(e) from e db.commit() db.refresh(eintrag) return _out(db, user, [eintrag])[0] @@ -209,12 +207,23 @@ def discard_item(item_id: int, db: Session = Depends(get_db), user: User = Depen hat; so kann der Abgleich sie weiterhin erkennen. """ eintrag = get_owned(db, InboxItem, item_id, user) + # Bei einem Übertrag zeigen ZWEI Einträge auf dieselbe Buchung. Löscht man + # sie, bliebe die andere Seite als „gebucht" ohne Buchung stehen – der + # Fremdschlüssel setzt nur ihre Verweisspalte auf NULL. + geschwister = ( + db.query(InboxItem) + .filter(InboxItem.owner_id == user.id, + InboxItem.transaction_id == eintrag.transaction_id) + .all() + if eintrag.transaction_id is not None else [eintrag] + ) if eintrag.transaction_id is not None: txn = db.get(Transaction, eintrag.transaction_id) if txn is not None and txn.owner_id == user.id: db.delete(txn) - eintrag.status = InboxStatus.discarded - eintrag.transaction_id = None + for e in geschwister: + e.status = InboxStatus.discarded + e.transaction_id = None db.commit() diff --git a/backend/app/routers/transactions.py b/backend/app/routers/transactions.py index 7d7d7cf..68396ec 100644 --- a/backend/app/routers/transactions.py +++ b/backend/app/routers/transactions.py @@ -8,13 +8,15 @@ Kurs (``fx_rate``) hat Vorrang vor dem EZB-Kurs. from datetime import date from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel 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 ..schemas import TransactionCreate, TransactionOut, TransactionUpdate -from ..services import buchung, currency +from ..services import buchung, currency, uebertrag_buchen +from ..services.uebertrag_buchen import UebertragFehler from .accounts import base_currency router = APIRouter(prefix="/transactions", tags=["transactions"]) @@ -207,6 +209,37 @@ def update_transaction( return to_out(db, txn, base_currency(db, user), _currency_map(db, user)) +class TransactionTransferIn(BaseModel): + to_account_id: int + # Nur setzen, wenn man es besser weiss als Certain – siehe uebertrag_buchen. + trotzdem: bool = False + + +@router.post("/{txn_id}/transfer", response_model=TransactionOut) +def make_transfer(txn_id: int, payload: TransactionTransferIn, + db: Session = Depends(get_db), user: User = Depends(get_current_user)): + """Eine gebuchte Ausgabe oder Einnahme zum Kontoübertrag machen. + + Nicht über PATCH: Der dreht die Richtung bei einer Einnahme nicht um und + legt keine Gegenseite auf dem Zielkonto an. Beides gehört zusammen, deshalb + läuft es durch denselben Dienst wie der Weg aus dem Eingang. + """ + txn = get_owned(db, Transaction, txn_id, user) + ziel = get_owned(db, Account, payload.to_account_id, user) + try: + uebertrag_buchen.aus_buchung(db, user, txn, ziel, payload.trotzdem) + except UebertragFehler as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT if e.nachfragen + else status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e + db.commit() + db.refresh(txn) + return to_out(db, txn, base_currency(db, user), _currency_map(db, user)) + + @router.delete("/{txn_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_transaction( txn_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user) diff --git a/backend/app/services/csv_import.py b/backend/app/services/csv_import.py index ab579f9..1c585d7 100644 --- a/backend/app/services/csv_import.py +++ b/backend/app/services/csv_import.py @@ -641,7 +641,7 @@ def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict INBOX_TAGE = 3 # Bank bucht oft ein bis zwei Tage nach der Zahlung -def _find_inbox_match( +def finde_eingangs_treffer( eingang: list[tuple], verbraucht: set[int], d: date, amount: int ) -> int | None: """Passenden, noch nicht verbrauchten Eingangs-Eintrag suchen. @@ -760,7 +760,7 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict dup_grund = "hash" if is_dup else None if not is_dup and d is not None and amount: - treffer = _find_inbox_match(eingang, eingang_verbraucht, d, amount) + treffer = finde_eingangs_treffer(eingang, eingang_verbraucht, d, amount) if treffer is not None: eingang_verbraucht.add(treffer) is_dup = True diff --git a/backend/app/services/eingang.py b/backend/app/services/eingang.py index e80c4b2..2392ab8 100644 --- a/backend/app/services/eingang.py +++ b/backend/app/services/eingang.py @@ -14,14 +14,13 @@ from sqlalchemy.orm import Session from ..models import ( Account, - BankAccountLink, InboxItem, InboxStatus, Transaction, TxnType, User, ) -from . import buchung, currency +from . import currency log = logging.getLogger(__name__) @@ -55,72 +54,6 @@ def buche(db: Session, user: User, eintrag: InboxItem) -> Transaction | None: return txn -def ist_angebunden(db: Session, user: User, konto: Account) -> bool: - """Holt Certain die Umsaetze dieses Kontos selbst von der Bank? - - Entscheidend fuer einen Uebertrag: Bei einem angebundenen Gegenkonto - liefert die Bank die Gegenbuchung selbst. Wer sie hier trotzdem anlegt, - hat das Geld zweimal. - """ - return db.query(BankAccountLink.id).filter( - BankAccountLink.owner_id == user.id, - BankAccountLink.account_id == konto.id, - ).first() is not None - - -def buche_als_uebertrag(db: Session, user: User, eintrag: InboxItem, - ziel: Account) -> Transaction | None: - """Einen Vorschlag als Kontouebertrag buchen. - - Statt Ausgabe oder Einnahme entsteht EINE Buchung, die das Geld vom einen - Konto ins andere bewegt. Die Richtung kommt aus dem Vorzeichen: Ein - negativer Betrag ist ein Abgang von diesem Konto, ein positiver ein Zugang. - - Bei verschiedenen Waehrungen wird die Seite exakt gesetzt, die wir wirklich - kennen – die aus dem Vorschlag. Die andere wird umgerechnet. - """ - if eintrag.status == InboxStatus.booked or eintrag.transaction_id is not None: - return None - if eintrag.account_id is None or eintrag.account_id == ziel.id: - return None - konto = db.get(Account, eintrag.account_id) - if konto is None or konto.owner_id != user.id: - return None - - betrag = abs(eintrag.amount_cents) - if eintrag.amount_cents < 0: - von, nach = konto, ziel - ziel_betrag = None # wird umgerechnet - else: - von, nach = ziel, konto - # Bekannt ist der Zugang, gesucht der Abgang – also rueckwaerts. - ziel_betrag = betrag - if ziel.currency != konto.currency: - umgerechnet = currency.convert( - db, betrag, konto.currency, ziel.currency, eintrag.date) - betrag = umgerechnet if umgerechnet is not None else betrag - - txn = Transaction( - owner_id=user.id, - type=TxnType.transfer, - date=eintrag.date, - account_id=von.id, - to_account_id=nach.id, - amount_cents=betrag, - to_amount_cents=ziel_betrag, - payee=eintrag.payee, - description=eintrag.note, - ) - buchung.setze_betraege(db, txn, von, nach) - db.add(txn) - db.flush() - eintrag.transaction_id = txn.id - eintrag.status = InboxStatus.booked - # Ein Uebertrag traegt keine Kategorie – er ist keine Ausgabe. - eintrag.category_id = None - return txn - - def suche_uebertraege(db: Session, user: User) -> int: """Nach dem Buchen nach Uebertraegen sehen – so wie nach einem Import. diff --git a/backend/app/services/uebertrag_buchen.py b/backend/app/services/uebertrag_buchen.py new file mode 100644 index 0000000..ee57bf7 --- /dev/null +++ b/backend/app/services/uebertrag_buchen.py @@ -0,0 +1,236 @@ +"""Etwas zum Kontouebertrag machen – aus dem Eingang oder nachtraeglich. + +Zwei Wege fuehren hierher: ein offener Vorschlag im Eingang und eine schon +gebuchte Ausgabe oder Einnahme. Beide muessen dieselben vier Dinge einhalten, +deshalb liegen sie hier und nicht in den Routern: + + 1. Die Richtung kommt aus dem Vorzeichen. Ein Abgang geht von hier weg, ein + Zugang kommt vom Gegenkonto her. + 2. Der Betrag der Gegenseite ist bekannt oder geschaetzt – das entscheidet + ueber Punkt 3. + 3. Ist er geschaetzt und haengt das Zielkonto selbst an einer Bank, wird + nachgefragt: Die Bank meldet den Zugang von allein, und ein geschaetzter + Betrag laesst sich beim Abgleich nicht wiedererkennen. + 4. Auf dem Zielkonto entsteht ein Eingangs-Eintrag, der auf DIESELBE Buchung + zeigt. Er faengt die Gegenseite ab – die schon vorliegende, indem er sie + verbraucht, und die spaeter eintreffende, weil ``detect_rows`` gegen + gebuchte Eingangs-Eintraege prueft. + +Ohne Punkt 4 stuende das Geld zweimal auf dem Zielkonto: Der Duplikatschutz +vergleicht ``import_hash`` – den hat eine Uebertrags-Buchung nicht – und +Eingangs-Eintraege, und ohne diesen legte der Uebertrag auf dem Zielkonto +keinen an. + +Nicht betroffen ist das Buchungsformular: Wer dort von Hand einen Uebertrag +anlegt, waehlt beide Konten bewusst und hat keine Gegenseite, die noch kommt. +""" + +from __future__ import annotations + +from sqlalchemy.orm import Session + +from ..models import ( + Account, + BankAccountLink, + InboxItem, + InboxSource, + InboxStatus, + Transaction, + TxnType, + User, +) +from . import buchung, csv_import, currency + + +class UebertragFehler(RuntimeError): + """Fehler mit einem Text, den man dem Nutzer zeigen kann.""" + + def __init__(self, text: str, nachfragen: bool = False): + super().__init__(text) + # True heisst: Es ist kein Fehler, sondern eine Warnung, die sich mit + # ``trotzdem`` uebergehen laesst. + self.nachfragen = nachfragen + + +def ist_angebunden(db: Session, user: User, konto: Account) -> bool: + """Holt Certain die Umsaetze dieses Kontos selbst von der Bank? + + Entscheidend fuer einen Uebertrag: Bei einem angebundenen Gegenkonto + liefert die Bank die Gegenbuchung selbst. + """ + return db.query(BankAccountLink.id).filter( + BankAccountLink.owner_id == user.id, + BankAccountLink.account_id == konto.id, + ).first() is not None + + +def _gegenbetrag(db: Session, konto: Account, ziel: Account, betrag: int, + datum) -> tuple[int | None, bool]: + """Betrag auf der Zielseite und ob er BEKANNT oder geschaetzt ist. + + Gleiche Waehrung: derselbe Betrag, exakt. Sonst der EZB-Kurs – eine + Naeherung, denn die Bank rechnet mit ihrem eigenen Kurs. + """ + if ziel.currency == konto.currency: + return betrag, True + umgerechnet = currency.convert(db, betrag, konto.currency, ziel.currency, datum) + return umgerechnet, False + + +def _pruefe(db: Session, user: User, konto: Account, ziel: Account, + exakt: bool, trotzdem: bool) -> None: + if ziel.id == konto.id: + raise UebertragFehler("Ein Übertrag braucht ein anderes Zielkonto") + if exakt or trotzdem: + return + if not ist_angebunden(db, user, ziel): + return + raise UebertragFehler( + f"Der Betrag auf '{ziel.name}' ist eine Umrechnung über den EZB-Kurs, " + f"nicht der Betrag, den die Bank meldet. Da das Konto selbst angebunden " + f"ist, kommt der Zugang von dort noch einmal – und liesse sich mit einem " + f"geschätzten Betrag nicht als derselbe erkennen. Sicherer: hier als " + f"Ausgabe buchen und beide Seiten danach verbinden lassen.", + nachfragen=True, + ) + + +def _lege_an(db: Session, user: User, konto: Account, ziel: Account, + betrag_signiert: int, ziel_betrag: int | None, datum, + payee: str | None, beschreibung: str | None) -> Transaction: + """Die Uebertrags-Buchung, richtig herum. + + Bei einem Zugang kennen wir den Betrag auf DIESEM Konto exakt – der gehoert + dann auf die Zielseite der Buchung, und der Abgang wird umgerechnet. + """ + betrag = abs(betrag_signiert) + if betrag_signiert < 0: + von, nach = konto, ziel + von_betrag, nach_betrag = betrag, ziel_betrag + else: + von, nach = ziel, konto + von_betrag, nach_betrag = (ziel_betrag if ziel_betrag is not None else betrag), betrag + + txn = Transaction( + owner_id=user.id, + type=TxnType.transfer, + date=datum, + account_id=von.id, + to_account_id=nach.id, + amount_cents=von_betrag, + to_amount_cents=nach_betrag, + payee=payee, + description=beschreibung, + ) + buchung.setze_betraege(db, txn, von, nach) + db.add(txn) + db.flush() + return txn + + +def _gegenseite(db: Session, user: User, txn: Transaction, ziel: Account, + betrag_signiert: int, ziel_betrag: int, datum) -> InboxItem: + """Den Eingangs-Eintrag auf dem Zielkonto – verbrauchen oder anlegen. + + Verbraucht wird ein noch OFFENER Eintrag: Er beschreibt dieselbe Bewegung, + nur von der anderen Seite gesehen, und wuerde sonst ein zweites Mal gebucht. + """ + # Auf dem Zielkonto ist die Bewegung anders herum. + gesucht = -1 if betrag_signiert > 0 else 1 + gesucht *= ziel_betrag + + offen = [ + (eid, edatum, ebetrag) + for eid, edatum, ebetrag in db.query( + InboxItem.id, InboxItem.date, InboxItem.amount_cents + ).filter( + InboxItem.owner_id == user.id, + InboxItem.account_id == ziel.id, + InboxItem.status == InboxStatus.pending, + ) + ] + # Derselbe Abgleich wie beim Import – nicht eine zweite Vergleichslogik. + treffer = csv_import.finde_eingangs_treffer(offen, set(), datum, gesucht) + + eintrag = db.get(InboxItem, treffer) if treffer is not None else None + if eintrag is None: + eintrag = InboxItem( + owner_id=user.id, + source=InboxSource.transfer, + account_id=ziel.id, + date=datum, + amount_cents=gesucht, + currency=ziel.currency, + payee=txn.payee, + note=txn.description, + ) + db.add(eintrag) + eintrag.status = InboxStatus.booked + eintrag.transaction_id = txn.id + # Ein Uebertrag traegt keine Kategorie – auch nicht auf der Gegenseite. + eintrag.category_id = None + db.flush() + return eintrag + + +def aus_eingang(db: Session, user: User, eintrag: InboxItem, ziel: Account, + trotzdem: bool = False) -> Transaction: + """Einen offenen Vorschlag als Kontouebertrag buchen.""" + if eintrag.status == InboxStatus.booked or eintrag.transaction_id is not None: + raise UebertragFehler("Dieser Eintrag ist schon gebucht") + if eintrag.account_id is None: + raise UebertragFehler("Kein Konto zugeordnet") + konto = db.get(Account, eintrag.account_id) + if konto is None or konto.owner_id != user.id: + raise UebertragFehler("Kein Konto zugeordnet") + + betrag = abs(eintrag.amount_cents) + ziel_betrag, exakt = _gegenbetrag(db, konto, ziel, betrag, eintrag.date) + _pruefe(db, user, konto, ziel, exakt, trotzdem) + + txn = _lege_an(db, user, konto, ziel, eintrag.amount_cents, ziel_betrag, + eintrag.date, eintrag.payee, eintrag.note) + eintrag.transaction_id = txn.id + eintrag.status = InboxStatus.booked + eintrag.category_id = None + _gegenseite(db, user, txn, ziel, eintrag.amount_cents, + ziel_betrag if ziel_betrag is not None else betrag, eintrag.date) + return txn + + +def aus_buchung(db: Session, user: User, txn: Transaction, ziel: Account, + trotzdem: bool = False) -> Transaction: + """Eine schon gebuchte Ausgabe oder Einnahme zum Uebertrag machen. + + Nicht ueber den PATCH-Weg des Buchungs-Routers: Der dreht die Richtung bei + einer Einnahme nicht um und legt keine Gegenseite an. + """ + if txn.type == TxnType.transfer: + raise UebertragFehler("Diese Buchung ist bereits ein Übertrag") + konto = db.get(Account, txn.account_id) + if konto is None or konto.owner_id != user.id: + raise UebertragFehler("Buchung ohne Konto") + + signiert = txn.amount_cents if txn.type == TxnType.income else -txn.amount_cents + ziel_betrag, exakt = _gegenbetrag(db, konto, ziel, abs(signiert), txn.date) + _pruefe(db, user, konto, ziel, exakt, trotzdem) + + # Die vorhandene Buchung umschreiben statt eine zweite anzulegen: Alles, was + # auf sie zeigt – Eingangs-Eintrag, Verknuepfungen – bleibt damit gueltig. + if signiert < 0: + von, nach = konto, ziel + txn.amount_cents, txn.to_amount_cents = abs(signiert), ziel_betrag + else: + von, nach = ziel, konto + txn.amount_cents = ziel_betrag if ziel_betrag is not None else abs(signiert) + txn.to_amount_cents = abs(signiert) + txn.type = TxnType.transfer + txn.account_id = von.id + txn.to_account_id = nach.id + txn.category_id = None + buchung.setze_betraege(db, txn, von, nach) + db.flush() + + _gegenseite(db, user, txn, ziel, signiert, + ziel_betrag if ziel_betrag is not None else abs(signiert), txn.date) + return txn diff --git a/backend/tests/test_bank.py b/backend/tests/test_bank.py index 5ccdd28..0d73fdc 100644 --- a/backend/tests/test_bank.py +++ b/backend/tests/test_bank.py @@ -835,3 +835,45 @@ def test_takt_wird_geprueft(admin, fake_bank): params={"minutes": 0}).json()["poll_minutes"] == 0 assert admin.put(f"/api/bank/connections/{verbindung}/takt", params={"minutes": 240}).json()["poll_minutes"] == 240 + + +def test_spaeter_gelieferte_gegenseite_wird_nicht_noch_einmal_gebucht(admin, fake_bank): + """Der eigentliche Zweck der Gegenseite: Die Bank meldet den Zugang selbst. + Ohne den Eingangs-Eintrag auf dem Zielkonto stuende das Geld zweimal da.""" + _app(admin) + uid = f"acc-{secrets.token_hex(4)}" + heute = date.today() + betrag = 30000 + secrets.randbelow(9000) + fake_bank( + konten=[{"uid": uid, "name": "Ziel", "currency": "CHF"}], + umsaetze=[{ + "entry_reference": f"tx-{secrets.token_hex(4)}", + "booking_date": heute.isoformat(), + "transaction_amount": {"amount": f"{betrag / 100:.2f}", "currency": "CHF"}, + "credit_debit_indicator": "CRDT", + "debtor": {"name": "Eigenes Konto"}, + }], + ) + state = admin.post("/api/bank/connect", + json={"aspsp_name": f"Gegen-{secrets.token_hex(3)}", + "country": "CH"}).json()["state"] + admin.post(f"/api/bank/callback?state={state}&code=guter-code") + link = [v for v in admin.get("/api/bank/links").json() if v["remote_uid"] == uid][0] + + ziel = _konto(admin, name=f"Bank-Ziel {secrets.token_hex(3)}", currency="CHF") + admin.post(f"/api/bank/links/{link['id']}/account/{ziel['id']}") + quelle = _konto(admin, name=f"Quelle {secrets.token_hex(3)}", currency="CHF") + + # Den Uebertrag von Hand buchen – die Gegenseite entsteht dabei auf `ziel`. + from tests.test_inbox import _eingang + eintrag = _eingang(admin, quelle, -betrag, datum=heute.isoformat()) + r = admin.post(f"/api/inbox/{eintrag['id']}/transfer", json={"to_account_id": ziel["id"]}) + assert r.status_code == 200, r.text + vorher = admin.get(f"/api/accounts/{ziel['id']}").json()["balance_cents"] + assert vorher == betrag + + # Jetzt liefert die Bank denselben Zugang. + abruf = admin.post(f"/api/bank/links/{link['id']}/sync").json() + assert abruf["geholt"] == 1, "der Umsatz kam an" + assert abruf["neu"] == 0, "und wurde als Gegenseite erkannt" + assert admin.get(f"/api/accounts/{ziel['id']}").json()["balance_cents"] == vorher diff --git a/backend/tests/test_inbox.py b/backend/tests/test_inbox.py index 4480ebb..6f7d7d1 100644 --- a/backend/tests/test_inbox.py +++ b/backend/tests/test_inbox.py @@ -129,27 +129,38 @@ def _binde_an(konto): db.close() -def test_angebundenes_zielkonto_wird_abgefangen(admin): - """Der teuerste Fehler an dieser Stelle: Das Zielkonto haengt selbst an - einer Bank und meldet den Zugang von allein – eine Gegenbuchung hier - bedeutet das Geld zweimal.""" +def test_angebundenes_ziel_gleicher_waehrung_geht_durch(admin): + """Bei gleicher Waehrung ist der Betrag exakt – die Gegenseite wird beim + Bankabruf sicher als dieselbe erkannt. Dann muss man nicht fragen.""" ziel = _konto(admin, name=f"Angebunden {secrets.token_hex(3)}", currency="CHF") _binde_an(ziel) - quelle = _konto(admin, name=f"Quelle {secrets.token_hex(3)}", currency="CHF") eintrag = _eingang(admin, quelle, -10000) + r = admin.post(f"/api/inbox/{eintrag['id']}/transfer", json={"to_account_id": ziel["id"]}) + assert r.status_code == 200, r.text + assert admin.get(f"/api/accounts/{ziel['id']}").json()["balance_cents"] == 10000 + + +def test_angebundenes_ziel_anderer_waehrung_fragt_nach(admin): + """Ueber Waehrungen hinweg rechnet Certain mit dem EZB-Kurs, die Bank mit + ihrem eigenen. Der geschaetzte Betrag laesst sich beim Abruf nicht + wiedererkennen – dann kaeme der Zugang ein zweites Mal.""" + ziel = _konto(admin, name=f"Angebunden EUR {secrets.token_hex(3)}", currency="EUR") + _binde_an(ziel) + quelle = _konto(admin, name=f"Quelle CHF {secrets.token_hex(3)}", currency="CHF") + eintrag = _eingang(admin, quelle, -10000) + r = admin.post(f"/api/inbox/{eintrag['id']}/transfer", json={"to_account_id": ziel["id"]}) assert r.status_code == 409, r.text - assert "angebunden" in r.json()["detail"] - # Nichts gebucht, nichts verschoben. + assert "EZB" in r.json()["detail"] assert admin.get(f"/api/accounts/{ziel['id']}").json()["balance_cents"] == 0 # Wer es besser weiss, kommt trotzdem durch. r = admin.post(f"/api/inbox/{eintrag['id']}/transfer", json={"to_account_id": ziel["id"], "trotzdem": True}) assert r.status_code == 200, r.text - assert admin.get(f"/api/accounts/{ziel['id']}").json()["balance_cents"] == 10000 + assert admin.get(f"/api/accounts/{ziel['id']}").json()["balance_cents"] > 0 def test_uebertrag_nennt_das_gegenkonto_und_nimmt_keine_kategorie(admin): @@ -191,3 +202,103 @@ def test_normale_buchung_nimmt_weiterhin_eine_kategorie(admin): assert r.json()["transfer_account_id"] is None txn = admin.get(f"/api/transactions/{r.json()['transaction_id']}").json() assert txn["category_id"] == kat, "die Kategorie muss auch an der Buchung stehen" + + +def test_gegenseite_wird_verbraucht_statt_gespiegelt(admin): + """Liegt die Gegenseite schon im Eingang, wuerde sie sonst ein zweites Mal + gebucht – das Geld staende zweimal auf dem Zielkonto.""" + von = _konto(admin, name=f"Paar-Von {secrets.token_hex(3)}", currency="CHF") + nach = _konto(admin, name=f"Paar-Nach {secrets.token_hex(3)}", currency="CHF") + betrag = 40000 + secrets.randbelow(9000) + ab = _eingang(admin, von, -betrag, datum="2026-08-10") + an = _eingang(admin, nach, betrag, datum="2026-08-11") # einen Tag spaeter + + r = admin.post(f"/api/inbox/{ab['id']}/transfer", json={"to_account_id": nach["id"]}) + assert r.status_code == 200, r.text + txn_id = r.json()["transaction_id"] + + eintraege = {e["id"]: e for e in admin.get("/api/inbox").json()} + assert eintraege[an["id"]]["status"] == "booked", "die Gegenseite blieb offen" + assert eintraege[an["id"]]["transaction_id"] == txn_id, "muss dieselbe Buchung sein" + + # Genau eine Bewegung, kein doppeltes Geld. + assert admin.get(f"/api/accounts/{von['id']}").json()["balance_cents"] == -betrag + assert admin.get(f"/api/accounts/{nach['id']}").json()["balance_cents"] == betrag + + +def test_ohne_gegenseite_entsteht_genau_eine(admin): + von = _konto(admin, name=f"Spiegel-Von {secrets.token_hex(3)}", currency="CHF") + nach = _konto(admin, name=f"Spiegel-Nach {secrets.token_hex(3)}", currency="CHF") + betrag = 50000 + secrets.randbelow(9000) + eintrag = _eingang(admin, von, -betrag) + + r = admin.post(f"/api/inbox/{eintrag['id']}/transfer", json={"to_account_id": nach["id"]}) + txn_id = r.json()["transaction_id"] + + gegen = [e for e in admin.get("/api/inbox").json() + if e["transaction_id"] == txn_id and e["id"] != eintrag["id"]] + assert len(gegen) == 1 + assert gegen[0]["source"] == "transfer" + assert gegen[0]["account_id"] == nach["id"] + assert gegen[0]["amount_cents"] == betrag + assert gegen[0]["transfer_account_id"] == von["id"], "aus ihrer Sicht das andere Konto" + + +def test_verwerfen_raeumt_beide_seiten(admin): + """Sonst bliebe die andere Seite als „gebucht" ohne Buchung stehen.""" + von = _konto(admin, name=f"Weg-Von {secrets.token_hex(3)}", currency="CHF") + nach = _konto(admin, name=f"Weg-Nach {secrets.token_hex(3)}", currency="CHF") + betrag = 60000 + secrets.randbelow(9000) + eintrag = _eingang(admin, von, -betrag) + r = admin.post(f"/api/inbox/{eintrag['id']}/transfer", json={"to_account_id": nach["id"]}) + txn_id = r.json()["transaction_id"] + + assert admin.delete(f"/api/inbox/{eintrag['id']}").status_code == 204 + + offen = admin.get("/api/inbox").json() + assert not [e for e in offen if e["transaction_id"] == txn_id], \ + "ein Eintrag zeigt noch auf die geloeschte Buchung" + assert admin.get(f"/api/accounts/{von['id']}").json()["balance_cents"] == 0 + assert admin.get(f"/api/accounts/{nach['id']}").json()["balance_cents"] == 0 + + +def test_gebuchte_ausgabe_laesst_sich_nachtraeglich_zum_uebertrag_machen(admin): + """Ohne diesen Weg bliebe eine per CSV eingelesene Umbuchung fuer immer + eine Ausgabe.""" + von = _konto(admin, name=f"Nachtraeglich {secrets.token_hex(3)}", currency="CHF") + nach = _konto(admin, name=f"Nachziel {secrets.token_hex(3)}", currency="CHF") + betrag = 80000 + secrets.randbelow(9000) + txn = admin.post("/api/transactions", json={ + "type": "expense", "date": "2026-08-12", "account_id": von["id"], + "amount_cents": betrag, "payee": "Umbuchung"}).json() + + r = admin.post(f"/api/transactions/{txn['id']}/transfer", + json={"to_account_id": nach["id"]}) + assert r.status_code == 200, r.text + assert r.json()["type"] == "transfer" + assert r.json()["category_id"] is None + assert admin.get(f"/api/accounts/{von['id']}").json()["balance_cents"] == -betrag + assert admin.get(f"/api/accounts/{nach['id']}").json()["balance_cents"] == betrag + + # Und die Gegenseite ist da, damit die Bank sie nicht noch einmal liefert. + gegen = [e for e in admin.get("/api/inbox").json() if e["transaction_id"] == txn["id"]] + assert len(gegen) == 1 and gegen[0]["account_id"] == nach["id"] + + +def test_nachtraeglicher_uebertrag_dreht_eine_einnahme_um(admin): + """Der PATCH-Weg des Buchungs-Routers taete das nicht – die Buchung liefe + falsch herum und beide Salden waeren verkehrt.""" + hier = _konto(admin, name=f"Ein-Hier {secrets.token_hex(3)}", currency="CHF") + dort = _konto(admin, name=f"Ein-Dort {secrets.token_hex(3)}", currency="CHF") + betrag = 90000 + secrets.randbelow(9000) + txn = admin.post("/api/transactions", json={ + "type": "income", "date": "2026-08-13", "account_id": hier["id"], + "amount_cents": betrag}).json() + + r = admin.post(f"/api/transactions/{txn['id']}/transfer", + json={"to_account_id": dort["id"]}) + assert r.status_code == 200, r.text + assert r.json()["account_id"] == dort["id"], "der Abgang gehoert zum Gegenkonto" + assert r.json()["to_account_id"] == hier["id"] + assert admin.get(f"/api/accounts/{hier['id']}").json()["balance_cents"] == betrag + assert admin.get(f"/api/accounts/{dort['id']}").json()["balance_cents"] == -betrag