diff --git a/backend/app/routers/inbox.py b/backend/app/routers/inbox.py
index 6d8be02..be08e71 100644
--- a/backend/app/routers/inbox.py
+++ b/backend/app/routers/inbox.py
@@ -6,6 +6,7 @@ gegen sie ab, damit dieselbe Zahlung nicht ein zweites Mal verbucht wird.
"""
from fastapi import APIRouter, Depends, HTTPException, status
+from pydantic import BaseModel
from sqlalchemy.orm import Session
from ..database import get_db
@@ -101,6 +102,49 @@ def book_item(item_id: int, db: Session = Depends(get_db), user: User = Depends(
return eintrag
+class InboxTransferIn(BaseModel):
+ to_account_id: int
+ # Nur setzen, wenn man es besser weiss als Certain – siehe unten.
+ trotzdem: bool = False
+
+
+@router.post("/{item_id}/transfer", response_model=InboxItemOut)
+def book_as_transfer(item_id: int, payload: InboxTransferIn,
+ db: Session = Depends(get_db), user: User = Depends(get_current_user)):
+ """Einen Vorschlag als Kontoübertrag buchen statt als Ausgabe oder Einnahme.
+
+ Es entsteht EINE Buchung, die das Geld vom einen Konto ins andere bewegt.
+ Sie zählt auf beiden Seiten im Saldo und in keiner Auswertung – ein
+ Ü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")
+ db.commit()
+ db.refresh(eintrag)
+ return eintrag
+
+
@router.post("/book-all")
def book_all(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
"""Alle offenen Vorschläge buchen, die ein Konto haben.
diff --git a/backend/app/routers/transactions.py b/backend/app/routers/transactions.py
index cfe6b48..7d7d7cf 100644
--- a/backend/app/routers/transactions.py
+++ b/backend/app/routers/transactions.py
@@ -14,34 +14,12 @@ 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 currency
+from ..services import buchung, currency
from .accounts import base_currency
router = APIRouter(prefix="/transactions", tags=["transactions"])
-def _fx(txn: Transaction) -> float | None:
- return float(txn.fx_rate) if txn.fx_rate is not None else None
-
-
-def _recompute(db: Session, txn: Transaction, from_acc: Account, to_acc: Account | None) -> None:
- """Setzt amount_eur_cents und (bei Transfers) to_amount_cents neu."""
- txn.amount_eur_cents = currency.compute_amount_eur_cents(
- db, txn.amount_cents, from_acc.currency, txn.date, _fx(txn)
- )
- if txn.type == TxnType.transfer and to_acc is not None:
- if txn.to_amount_cents is None:
- if to_acc.currency == from_acc.currency:
- txn.to_amount_cents = txn.amount_cents
- else:
- conv = currency.convert(
- db, txn.amount_cents, from_acc.currency, to_acc.currency, txn.date, _fx(txn)
- )
- txn.to_amount_cents = conv if conv is not None else txn.amount_cents
- else:
- txn.to_amount_cents = None
-
-
def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, str]) -> TransactionOut:
acc_ccy = ccy_by_account.get(txn.account_id, "EUR")
if base == "EUR":
@@ -59,7 +37,7 @@ def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, s
account_currency=acc_ccy,
amount_cents=txn.amount_cents,
to_amount_cents=txn.to_amount_cents,
- fx_rate=_fx(txn),
+ fx_rate=buchung.fx(txn),
amount_eur_cents=txn.amount_eur_cents,
amount_base_cents=amount_base,
category_id=txn.category_id,
@@ -168,7 +146,7 @@ def create_transaction(
description=(payload.description or None),
notes=(payload.notes or None),
)
- _recompute(db, txn, from_acc, to_acc)
+ buchung.setze_betraege(db, txn, from_acc, to_acc)
db.add(txn)
db.commit()
db.refresh(txn)
@@ -222,7 +200,7 @@ def update_transaction(
txn.category_id = None
else:
txn.to_account_id = None
- _recompute(db, txn, from_acc, to_acc)
+ buchung.setze_betraege(db, txn, from_acc, to_acc)
db.commit()
db.refresh(txn)
diff --git a/backend/app/services/buchung.py b/backend/app/services/buchung.py
new file mode 100644
index 0000000..6c83133
--- /dev/null
+++ b/backend/app/services/buchung.py
@@ -0,0 +1,36 @@
+"""Betraege einer Buchung berechnen.
+
+Eigener Dienst, weil zwei Wege Buchungen anlegen: das Formular und der
+Eingang, der einen Vorschlag als Uebertrag bucht. Zwei Kopien derselben
+Umrechnung waeren genau die Stelle, an der Salden auseinanderlaufen.
+"""
+
+from __future__ import annotations
+
+from sqlalchemy.orm import Session
+
+from ..models import Account, Transaction, TxnType
+from . import currency
+
+
+def fx(txn: Transaction) -> float | None:
+ return float(txn.fx_rate) if txn.fx_rate is not None else None
+
+
+def setze_betraege(db: Session, txn: Transaction, von: Account,
+ nach: Account | None) -> None:
+ """Setzt amount_eur_cents und (bei Uebertraegen) to_amount_cents neu."""
+ txn.amount_eur_cents = currency.compute_amount_eur_cents(
+ db, txn.amount_cents, von.currency, txn.date, fx(txn)
+ )
+ if txn.type == TxnType.transfer and nach is not None:
+ if txn.to_amount_cents is None:
+ if nach.currency == von.currency:
+ txn.to_amount_cents = txn.amount_cents
+ else:
+ conv = currency.convert(
+ db, txn.amount_cents, von.currency, nach.currency, txn.date, fx(txn)
+ )
+ txn.to_amount_cents = conv if conv is not None else txn.amount_cents
+ else:
+ txn.to_amount_cents = None
diff --git a/backend/app/services/eingang.py b/backend/app/services/eingang.py
index 75adfc2..e80c4b2 100644
--- a/backend/app/services/eingang.py
+++ b/backend/app/services/eingang.py
@@ -12,8 +12,16 @@ import logging
from sqlalchemy.orm import Session
-from ..models import Account, InboxItem, InboxStatus, Transaction, TxnType, User
-from . import currency
+from ..models import (
+ Account,
+ BankAccountLink,
+ InboxItem,
+ InboxStatus,
+ Transaction,
+ TxnType,
+ User,
+)
+from . import buchung, currency
log = logging.getLogger(__name__)
@@ -47,6 +55,72 @@ 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/tests/test_inbox.py b/backend/tests/test_inbox.py
new file mode 100644
index 0000000..4caf4c2
--- /dev/null
+++ b/backend/tests/test_inbox.py
@@ -0,0 +1,144 @@
+"""Eingang: einen Vorschlag als Kontoübertrag buchen.
+
+Ohne diesen Weg bleibt nur, eine Umbuchung als Ausgabe zu kategorisieren – sie
+zählt dann als Ausgabe, obwohl kein Geld weg ist.
+"""
+
+import secrets
+
+
+def _konto(admin, **kw):
+ body = {"name": "Girokonto", "type": "girokonto", "currency": "CHF",
+ "opening_balance_cents": 0}
+ body.update(kw)
+ r = admin.post("/api/accounts", json=body)
+ assert r.status_code == 201, r.text
+ return r.json()
+
+
+def _eingang(admin, konto, betrag_cents, datum="2026-08-20", payee="Umbuchung"):
+ """Ein OFFENER Eintrag – so wie ihn Mail und Bankabruf hinterlassen.
+
+ Nicht ueber die Schnellerfassung: Die bucht sofort, und dann gaebe es
+ nichts mehr als Uebertrag zu buchen.
+ """
+ from datetime import date
+
+ from app.database import SessionLocal
+ from app.models import InboxItem, InboxSource, InboxStatus, User
+
+ db = SessionLocal()
+ try:
+ besitzer = db.query(User).order_by(User.id).first()
+ eintrag = InboxItem(
+ owner_id=besitzer.id,
+ source=InboxSource.mail,
+ status=InboxStatus.pending,
+ account_id=konto["id"],
+ date=date.fromisoformat(datum),
+ amount_cents=betrag_cents,
+ currency=konto["currency"],
+ payee=payee,
+ )
+ db.add(eintrag)
+ db.commit()
+ return {"id": eintrag.id}
+ finally:
+ db.close()
+
+
+def test_eingang_laesst_sich_als_uebertrag_buchen(admin):
+ von = _konto(admin, name=f"Von {secrets.token_hex(3)}", currency="CHF")
+ nach = _konto(admin, name=f"Nach {secrets.token_hex(3)}", currency="CHF")
+ eintrag = _eingang(admin, von, -50000)
+
+ 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()["status"] == "booked"
+ assert r.json()["category_id"] is None
+
+ txn = admin.get(f"/api/transactions/{r.json()['transaction_id']}").json()
+ assert txn["type"] == "transfer"
+ assert txn["account_id"] == von["id"]
+ assert txn["to_account_id"] == nach["id"]
+ assert txn["amount_cents"] == 50000
+
+ # Das Geld ist verschoben, nicht ausgegeben.
+ assert admin.get(f"/api/accounts/{von['id']}").json()["balance_cents"] == -50000
+ assert admin.get(f"/api/accounts/{nach['id']}").json()["balance_cents"] == 50000
+
+
+def test_zugang_dreht_die_richtung_um(admin):
+ """Ein positiver Betrag heisst: Auf DIESEM Konto kam das Geld an. Die
+ Buchung muss vom Gegenkonto hierher gehen, nicht umgekehrt."""
+ hier = _konto(admin, name=f"Hier {secrets.token_hex(3)}", currency="CHF")
+ dort = _konto(admin, name=f"Dort {secrets.token_hex(3)}", currency="CHF")
+ eintrag = _eingang(admin, hier, 25000)
+
+ r = admin.post(f"/api/inbox/{eintrag['id']}/transfer", json={"to_account_id": dort["id"]})
+ assert r.status_code == 200, r.text
+ txn = admin.get(f"/api/transactions/{r.json()['transaction_id']}").json()
+ assert txn["account_id"] == dort["id"], "der Abgang gehört zum Gegenkonto"
+ assert txn["to_account_id"] == hier["id"]
+ assert admin.get(f"/api/accounts/{hier['id']}").json()["balance_cents"] == 25000
+ assert admin.get(f"/api/accounts/{dort['id']}").json()["balance_cents"] == -25000
+
+
+def test_uebertrag_zaehlt_in_keiner_auswertung(admin):
+ """Der eigentliche Zweck: Ein Übertrag ist keine Ausgabe."""
+ von = _konto(admin, name=f"Budget-Von {secrets.token_hex(3)}", currency="CHF")
+ nach = _konto(admin, name=f"Budget-Nach {secrets.token_hex(3)}", currency="CHF")
+ eintrag = _eingang(admin, von, -30000, datum="2026-08-05")
+ admin.post(f"/api/inbox/{eintrag['id']}/transfer", json={"to_account_id": nach["id"]})
+
+ v = admin.get("/api/budget/2026-08").json()
+ gesamt = sum(z.get("actual_cents") or 0 for z in v.get("rows", []))
+ assert gesamt == 0, "ein Übertrag darf im Budget nicht auftauchen"
+
+
+def _binde_an(konto):
+ """Konto so markieren, als haenge es an einer Bank."""
+ from app.database import SessionLocal
+ from app.models import BankAccountLink, BankApp, BankConnection, User
+
+ db = SessionLocal()
+ try:
+ besitzer = db.query(User).order_by(User.id).first()
+ app = BankApp(owner_id=besitzer.id, application_id="app-x",
+ private_key_enc="x", base_url="https://x.example")
+ db.add(app)
+ verbindung = BankConnection(owner_id=besitzer.id, aspsp_name="Zielbank",
+ aspsp_country="CH")
+ db.add(verbindung)
+ db.flush()
+ db.add(BankAccountLink(
+ owner_id=besitzer.id, connection_id=verbindung.id,
+ remote_uid=f"acc-{secrets.token_hex(4)}", currency=konto["currency"],
+ account_id=konto["id"]))
+ db.commit()
+ finally:
+ 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."""
+ 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 == 409, r.text
+ assert "angebunden" in r.json()["detail"]
+ # Nichts gebucht, nichts verschoben.
+ 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
diff --git a/web/src/api.js b/web/src/api.js
index d663492..ec11645 100644
--- a/web/src/api.js
+++ b/web/src/api.js
@@ -204,6 +204,14 @@ export const api = {
bankCallback: (state, code) =>
request(`/bank/callback?state=${encodeURIComponent(state)}&code=${encodeURIComponent(code)}`,
{ method: "POST" }),
+ // Einen Eingangs-Vorschlag als Kontoübertrag buchen statt als Ausgabe.
+ // `trotzdem` nur setzen, wenn der Server mit 409 gewarnt hat und der Nutzer
+ // die Warnung gelesen und bestätigt hat.
+ bookInboxAsTransfer: (id, toAccountId, trotzdem = false) =>
+ request(`/inbox/${id}/transfer`, {
+ method: "POST", body: { to_account_id: toAccountId, trotzdem },
+ }),
+
// --- Überträge zwischen eigenen Konten -----------------------------------
// Ändern ausschliesslich die Verknüpfungs-Schicht. Keine Buchung wird
// angefasst – deshalb ist auch eine falsche Verknüpfung folgenlos.
diff --git a/web/src/locales/de.json b/web/src/locales/de.json
index 54f047e..c4d91db 100644
--- a/web/src/locales/de.json
+++ b/web/src/locales/de.json
@@ -519,7 +519,13 @@
"bookedN": "{{n}} gebucht.",
"booked": "gebucht",
"unclear": "unklar – bitte ansehen",
- "viaMail": "aus einer E-Mail"
+ "viaMail": "aus einer E-Mail",
+ "asTransfer": "Als Übertrag buchen",
+ "transferTitle": "Kontoübertrag",
+ "transferIntro": "Kein Geld ausgegeben, nur verschoben. Es entsteht eine Buchung, die beide Salden bewegt und in keiner Auswertung zählt.",
+ "transferTargetOut": "Wohin ging das Geld?",
+ "transferTargetIn": "Woher kam das Geld?",
+ "transferBooked": "Als Übertrag gebucht."
},
"mailbox": {
"title": "Postfach für Zahlungs-Mails",
diff --git a/web/src/locales/en.json b/web/src/locales/en.json
index 5cf0f74..af05749 100644
--- a/web/src/locales/en.json
+++ b/web/src/locales/en.json
@@ -519,7 +519,13 @@
"bookedN": "{{n}} booked.",
"booked": "booked",
"unclear": "unclear – please review",
- "viaMail": "from an email"
+ "viaMail": "from an email",
+ "asTransfer": "Book as transfer",
+ "transferTitle": "Account transfer",
+ "transferIntro": "No money spent, only moved. This creates one transaction that changes both balances and counts in no evaluation.",
+ "transferTargetOut": "Where did the money go?",
+ "transferTargetIn": "Where did the money come from?",
+ "transferBooked": "Booked as a transfer."
},
"mailbox": {
"title": "Mailbox for payment emails",
diff --git a/web/src/pages/Eingang.jsx b/web/src/pages/Eingang.jsx
index 98792e9..d19fd04 100644
--- a/web/src/pages/Eingang.jsx
+++ b/web/src/pages/Eingang.jsx
@@ -5,6 +5,8 @@ import { api } from "../api";
import CategorySelect from "../components/CategorySelect";
import Icon from "../components/Icon";
import IconChip from "../components/IconChip";
+import AccountSelect from "../components/AccountSelect";
+import Modal from "../components/Modal";
import Money from "../components/Money";
import Uebertragsvorschlaege from "../components/Uebertragsvorschlaege";
import { useToast } from "../toast";
@@ -25,6 +27,8 @@ export default function Eingang() {
const [rows, setRows] = useState(null);
const [accounts, setAccounts] = useState([]);
const [groups, setGroups] = useState([]);
+ // Vorschlag, der gerade als Übertrag gebucht wird, samt Zielkonto.
+ const [uebertrag, setUebertrag] = useState(null);
const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]);
const incomeGroups = useMemo(() => gruppenFuer(groups, "income"), [groups]);
@@ -74,6 +78,33 @@ export default function Eingang() {
}
}
+ async function uebertragBuchen() {
+ const { item, ziel } = uebertrag;
+ if (!ziel) return;
+ try {
+ await api.bookInboxAsTransfer(item.id, Number(ziel));
+ } catch (e) {
+ // 409: Das Zielkonto haengt selbst an einer Bank und meldet den Zugang
+ // von allein. Die Begruendung kommt vom Server – dort steht das Wissen,
+ // ob ein Konto angebunden ist.
+ if (e.status !== 409) { toast(e.message, "error"); return; }
+ if (!(await confirm({
+ title: t("inbox.transferTitle"),
+ message: e.message,
+ confirmLabel: t("inbox.asTransfer"),
+ }))) return;
+ try {
+ await api.bookInboxAsTransfer(item.id, Number(ziel), true);
+ } catch (zweiter) {
+ toast(zweiter.message, "error");
+ return;
+ }
+ }
+ setUebertrag(null);
+ toast(t("inbox.transferBooked"));
+ laden();
+ }
+
async function verwerfen(item) {
if (!(await confirm({
title: t("inbox.discard"),
@@ -107,6 +138,53 @@ export default function Eingang() {
deshalb steht der Vorschlag hier und nicht in der Buchungsliste. */}
{t("inbox.transferIntro")}