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 ? (
+