diff --git a/backend/app/routers/inbox.py b/backend/app/routers/inbox.py index be08e71..758be0d 100644 --- a/backend/app/routers/inbox.py +++ b/backend/app/routers/inbox.py @@ -72,19 +72,55 @@ def delete_token(token_id: int, db: Session = Depends(get_db), user: User = Depe # --- Eingang ----------------------------------------------------------------- +def _gegenkonten(db: Session, user: User, eintraege: list[InboxItem]) -> dict[int, int]: + """Je Eintrag das Gegenkonto, falls seine Buchung ein Übertrag ist. + + Als Sammelabfrage und nicht je Zeile: Die Liste geht bis 200 Einträge. + """ + txn_ids = {e.transaction_id for e in eintraege if e.transaction_id is not None} + if not txn_ids: + return {} + buchungen = { + b.id: b + for b in db.query(Transaction).filter( + Transaction.owner_id == user.id, + Transaction.id.in_(txn_ids), + Transaction.type == TxnType.transfer, + ) + } + gegen: dict[int, int] = {} + for e in eintraege: + buchung = buchungen.get(e.transaction_id) + if buchung is None: + continue + # Das jeweils ANDERE Konto, aus Sicht dieses Eintrags. + gegen[e.id] = (buchung.to_account_id if buchung.account_id == e.account_id + else buchung.account_id) + return gegen + + +def _out(db: Session, user: User, eintraege: list[InboxItem]) -> list[InboxItemOut]: + gegen = _gegenkonten(db, user, eintraege) + return [ + InboxItemOut.model_validate(e).model_copy( + update={"transfer_account_id": gegen.get(e.id)}) + for e in eintraege + ] + + @router.get("", response_model=list[InboxItemOut]) def list_inbox( limit: int = 200, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): - return ( + return _out(db, user, ( db.query(InboxItem) .filter(InboxItem.owner_id == user.id, InboxItem.status != InboxStatus.discarded) .order_by(InboxItem.date.desc(), InboxItem.id.desc()) .limit(min(limit, 1000)) .all() - ) + )) @router.post("/{item_id}/book", response_model=InboxItemOut) @@ -99,7 +135,7 @@ def book_item(item_id: int, db: Session = Depends(get_db), user: User = Depends( db.commit() eingang.suche_uebertraege(db, user) db.refresh(eintrag) - return eintrag + return _out(db, user, [eintrag])[0] class InboxTransferIn(BaseModel): @@ -142,7 +178,7 @@ def book_as_transfer(item_id: int, payload: InboxTransferIn, detail="Lässt sich nicht buchen") db.commit() db.refresh(eintrag) - return eintrag + return _out(db, user, [eintrag])[0] @router.post("/book-all") @@ -194,11 +230,19 @@ def set_category( eintrag = get_owned(db, InboxItem, item_id, user) kategorie = get_owned(db, Category, category_id, user) + txn = (db.get(Transaction, eintrag.transaction_id) + if eintrag.transaction_id is not None else None) + if txn is not None and txn.owner_id == user.id: + # Ein Übertrag ist keine Ausgabe. Ohne diese Sperre landete über den + # Eingang eine Kategorie auf einer Übertrags-Buchung – genau das, was + # das Buchungsformular beim Anlegen verhindert. + if txn.type == TxnType.transfer: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ein Kontoübertrag bekommt keine Kategorie.", + ) + txn.category_id = kategorie.id eintrag.category_id = kategorie.id - 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: - txn.category_id = kategorie.id db.commit() db.refresh(eintrag) - return eintrag + return _out(db, user, [eintrag])[0] diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 7991eeb..b162630 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -533,6 +533,10 @@ class InboxItemOut(BaseModel): note: str | None category_id: int | None transaction_id: int | None + # Das jeweils ANDERE Konto, wenn die Buchung ein Kontoübertrag ist. Ohne + # das kann die Oberfläche einen Übertrag nicht von einer Ausgabe + # unterscheiden und fragt weiter nach einer Kategorie. + transfer_account_id: int | None = None created_at: datetime | None model_config = ConfigDict(from_attributes=True) diff --git a/backend/tests/test_inbox.py b/backend/tests/test_inbox.py index 4caf4c2..4480ebb 100644 --- a/backend/tests/test_inbox.py +++ b/backend/tests/test_inbox.py @@ -16,6 +16,14 @@ def _konto(admin, **kw): return r.json() +def _kategorie(admin): + """Irgendeine Ausgabe-Kategorie aus den Vorgaben.""" + for gruppe in admin.get("/api/categories").json(): + if gruppe["kind"] == "expense" and gruppe["categories"]: + return gruppe["categories"][0]["id"] + raise AssertionError("keine Ausgabe-Kategorie vorhanden") + + def _eingang(admin, konto, betrag_cents, datum="2026-08-20", payee="Umbuchung"): """Ein OFFENER Eintrag – so wie ihn Mail und Bankabruf hinterlassen. @@ -142,3 +150,44 @@ def test_angebundenes_zielkonto_wird_abgefangen(admin): 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 + + +def test_uebertrag_nennt_das_gegenkonto_und_nimmt_keine_kategorie(admin): + """Ohne das Gegenkonto kann die Oberfläche einen Übertrag nicht von einer + Ausgabe unterscheiden – und fragt weiter nach einer Kategorie.""" + von = _konto(admin, name=f"Nenn-Von {secrets.token_hex(3)}", currency="CHF") + nach = _konto(admin, name=f"Nenn-Nach {secrets.token_hex(3)}", currency="CHF") + eintrag = _eingang(admin, von, -8000) + + r = admin.post(f"/api/inbox/{eintrag['id']}/transfer", json={"to_account_id": nach["id"]}) + assert r.status_code == 200, r.text + assert r.json()["transfer_account_id"] == nach["id"] + + # Auch in der Liste, und aus Sicht des Eintrags das jeweils ANDERE Konto. + aus_liste = [e for e in admin.get("/api/inbox").json() if e["id"] == eintrag["id"]][0] + assert aus_liste["transfer_account_id"] == nach["id"] + + r = admin.post(f"/api/inbox/{eintrag['id']}/category/{_kategorie(admin)}") + assert r.status_code == 400, r.text + assert "Kategorie" in r.json()["detail"] + + # Und die Buchung hat weiterhin keine. + txn = admin.get(f"/api/transactions/{aus_liste['transaction_id']}").json() + assert txn["category_id"] is None + + +def test_normale_buchung_nimmt_weiterhin_eine_kategorie(admin): + """Die Sperre darf nur Überträge treffen.""" + konto = _konto(admin, name=f"Normal {secrets.token_hex(3)}", currency="CHF") + # Eigener Betrag: Die Testdatenbank lebt ueber den ganzen Lauf, und ein + # runder Betrag kollidiert mit dem, wonach ein anderer Test sucht. + eintrag = _eingang(admin, konto, -(700000 + secrets.randbelow(90000))) + admin.post(f"/api/inbox/{eintrag['id']}/book") + + kat = _kategorie(admin) + r = admin.post(f"/api/inbox/{eintrag['id']}/category/{kat}") + assert r.status_code == 200, r.text + assert r.json()["category_id"] == kat + 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" diff --git a/web/src/pages/Eingang.jsx b/web/src/pages/Eingang.jsx index d19fd04..f482e57 100644 --- a/web/src/pages/Eingang.jsx +++ b/web/src/pages/Eingang.jsx @@ -213,11 +213,23 @@ export default function Eingang() { - zuordnen(item, id)} - /> + {/* Ein Übertrag ist keine Ausgabe – er bekommt keine + Kategorie, also auch keine Auswahl dafür. */} + {item.transfer_account_id != null ? ( +
+ + + {accountById[item.transfer_account_id]?.name + || t("transfers.linked")} + +
+ ) : ( + zuordnen(item, id)} + /> + )} {item.status !== "booked" && item.account_id && ( <>