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. */} + {uebertrag && ( + setUebertrag(null)} + footer={ + <> + + + + } + > +

{t("inbox.transferIntro")}

+
+
+
{uebertrag.item.payee || t("inbox.noText")}
+
+ {formatDate(uebertrag.item.date)} ·{" "} + {accountById[uebertrag.item.account_id]?.name || "—"} +
+
+ +
+ +
+ )} + {rows.length === 0 ? (
{t("inbox.empty")}
) : ( @@ -141,9 +219,17 @@ export default function Eingang() { onChange={(id) => zuordnen(item, id)} /> {item.status !== "booked" && item.account_id && ( - + <> + + {/* Eine Umbuchung ist keine Ausgabe. Ohne diesen Weg + bliebe nur, sie als solche zu kategorisieren. */} + + )}