diff --git a/backend/app/routers/inbox.py b/backend/app/routers/inbox.py index 5c26183..44ca69f 100644 --- a/backend/app/routers/inbox.py +++ b/backend/app/routers/inbox.py @@ -5,6 +5,8 @@ Einträge bleiben nach dem Buchen bestehen: Der spätere CSV-Import gleicht gegen sie ab, damit dieselbe Zahlung nicht ein zweites Mal verbucht wird. """ +import logging + from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session @@ -23,6 +25,8 @@ from ..schemas import ApiTokenCreate, ApiTokenCreated, ApiTokenOut, InboxItemOut from ..security import generate_session_token, hash_session_token from ..services import currency +log = logging.getLogger(__name__) + router = APIRouter(prefix="/inbox", tags=["inbox"]) @@ -115,6 +119,26 @@ def _buche(db: Session, user: User, eintrag: InboxItem) -> Transaction | None: return txn +def _suche_uebertraege(db: Session, user: User) -> int: + """Nach dem Buchen nach Uebertraegen sehen – so wie nach einem Import. + + Seit die Bankanbindung ihre Umsaetze hierher legt, entsteht die Gegenseite + eines Uebertrags erst beim Buchen. Ohne diesen Aufruf wuerde sie nie + gefunden, und die beiden Seiten zaehlten dauerhaft in der Auswertung mit. + + Ein Fehler hier darf das Buchen nicht kippen: Die Buchung ist schon + festgeschrieben, die Verknuepfung ist nur eine Schicht darueber. + """ + from ..services import uebertraege + + try: + return uebertraege.finde(db, user).get("verknuepft", 0) + except Exception: # pragma: no cover - Sicherheitsnetz + log.exception("Uebertragssuche nach dem Buchen fehlgeschlagen") + db.rollback() + return 0 + + @router.post("/{item_id}/book", response_model=InboxItemOut) def book_item(item_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): eintrag = get_owned(db, InboxItem, item_id, user) @@ -125,6 +149,7 @@ def book_item(item_id: int, db: Session = Depends(get_db), user: User = Depends( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Lässt sich nicht buchen") db.commit() + _suche_uebertraege(db, user) db.refresh(eintrag) return eintrag @@ -145,7 +170,7 @@ def book_all(db: Session = Depends(get_db), user: User = Depends(get_current_use ) gebucht = sum(1 for e in offen if _buche(db, user, e) is not None) db.commit() - return {"gebucht": gebucht} + return {"gebucht": gebucht, "uebertraege": _suche_uebertraege(db, user)} @router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/services/uebertraege.py b/backend/app/services/uebertraege.py index 92b6640..70c4079 100644 --- a/backend/app/services/uebertraege.py +++ b/backend/app/services/uebertraege.py @@ -297,13 +297,31 @@ def verbinde(db: Session, user: User, a: Transaction, b: Transaction, def linkliste(db: Session, user: User) -> dict[int, dict]: - """Je Buchungs-ID die Gegenseite – für die Anzeige in der Buchungsliste.""" + """Je Buchungs-ID die Gegenseite – für die Anzeige in der Buchungsliste. + + Das Gegenkonto kommt mit: Die andere Buchung steht oft gar nicht in der + gerade gefilterten Liste, und „Übertrag" ohne Angabe wohin sagt nichts. + """ + links = db.query(TransferLink).filter(TransferLink.owner_id == user.id).all() + beteiligt = {i for l in links for i in (l.from_txn_id, l.to_txn_id)} + # Einmal nachschlagen statt je Verknüpfung – die Liste kann lang werden. + konto_von = dict( + db.query(Transaction.id, Transaction.account_id) + .filter(Transaction.id.in_(beteiligt or [0])) + .all() + ) + ergebnis: dict[int, dict] = {} - for link in db.query(TransferLink).filter(TransferLink.owner_id == user.id): - ergebnis[link.from_txn_id] = {"link_id": link.id, "other_id": link.to_txn_id, - "richtung": "raus", "automatisch": link.automatisch} - ergebnis[link.to_txn_id] = {"link_id": link.id, "other_id": link.from_txn_id, - "richtung": "rein", "automatisch": link.automatisch} + for link in links: + for eigen, gegen, richtung in ( + (link.from_txn_id, link.to_txn_id, "raus"), + (link.to_txn_id, link.from_txn_id, "rein"), + ): + ergebnis[eigen] = { + "link_id": link.id, "other_id": gegen, "richtung": richtung, + "other_account_id": konto_von.get(gegen), + "automatisch": link.automatisch, + } return ergebnis diff --git a/web/src/api.js b/web/src/api.js index 6054b81..d663492 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -204,6 +204,18 @@ export const api = { bankCallback: (state, code) => request(`/bank/callback?state=${encodeURIComponent(state)}&code=${encodeURIComponent(code)}`, { method: "POST" }), + // --- Überträge zwischen eigenen Konten ----------------------------------- + // Ändern ausschliesslich die Verknüpfungs-Schicht. Keine Buchung wird + // angefasst – deshalb ist auch eine falsche Verknüpfung folgenlos. + transactionLinks: () => request("/transactions/links"), + searchTransfers: () => request("/uebertraege/suchen", { method: "POST" }), + connectTransfer: (a_id, b_id) => + request("/uebertraege/verbinden", { method: "POST", body: { a_id, b_id } }), + releaseTransfer: (linkId) => + request(`/uebertraege/${linkId}`, { method: "DELETE" }), + ignoreTransfer: (a_id, b_id) => + request("/uebertraege/ignorieren", { method: "POST", body: { a_id, b_id } }), + listBankConnections: () => request("/bank/connections"), syncBankConnection: (id) => request(`/bank/connections/${id}/sync`, { method: "POST" }), refreshBankBalances: (id) => diff --git a/web/src/components/Uebertragsvorschlaege.jsx b/web/src/components/Uebertragsvorschlaege.jsx new file mode 100644 index 0000000..567ce9f --- /dev/null +++ b/web/src/components/Uebertragsvorschlaege.jsx @@ -0,0 +1,123 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { api } from "../api"; +import Icon from "../components/Icon"; +import Money from "../components/Money"; +import { useToast } from "../toast"; +import { formatDate } from "../format"; + +// Paare, die zusammengehören könnten: ein Abgang auf dem einen Konto, ein +// Zugang auf dem anderen. Verbinden ändert KEINE Buchung – es setzt nur einen +// Vermerk, dass die beiden dasselbe Geld sind. Beide bleiben mit Betrag, +// Kategorie und Import-Schlüssel stehen; sie fallen nur aus der Auswertung. +// Deshalb ist auch eine falsche Verknüpfung folgenlos: Lösen stellt alles her. +export default function Uebertragsvorschlaege({ baseCurrency, onChange }) { + const { t } = useTranslation(); + const { toast } = useToast(); + + const [stand, setStand] = useState(null); // { verknuepft, vorschlaege } + const [busy, setBusy] = useState(false); + + async function suchen(melden = false) { + setBusy(true); + try { + const r = await api.searchTransfers(); + setStand(r); + if (melden) { + toast(t("transfers.searched", { + verknuepft: r.verknuepft, offen: r.vorschlaege.length, + })); + } + if (r.verknuepft > 0) onChange?.(); + } catch (e) { + toast(e.message, "error"); + } finally { + setBusy(false); + } + } + + useEffect(() => { suchen(false); }, []); + + async function verbinden(paar) { + try { + await api.connectTransfer(paar.a.id, paar.b.id); + onChange?.(); + suchen(false); + } catch (e) { + toast(e.message, "error"); + } + } + + async function ignorieren(paar) { + try { + await api.ignoreTransfer(paar.a.id, paar.b.id); + suchen(false); + } catch (e) { + toast(e.message, "error"); + } + } + + // Vor dem ersten Ergebnis keine leere Karte zeigen. + if (stand === null) return null; + if (stand.vorschlaege.length === 0) return null; + + return ( +
+
+ +

{t("transfers.title")}

+ +
+

{t("transfers.intro")}

+ +
+ {stand.vorschlaege.map((paar) => { + const mitKategorie = paar.a.category_id != null || paar.b.category_id != null; + return ( +
+
+ + + +
+ {/* Eine zugeordnete Buchung faellt durchs Verbinden aus dem + Budget. Das soll niemanden ueberraschen. */} + {mitKategorie && ( +

+ {t("transfers.hasCategory")} +

+ )} +
+ + +
+
+ ); + })} +
+
+ ); +} + +function Seite({ seite, baseCurrency }) { + return ( +
+
+ {formatDate(seite.date)} + {" · "} + {seite.account_name} +
+
{seite.payee || seite.description || "—"}
+ +
+ ); +} diff --git a/web/src/finance.css b/web/src/finance.css index 9e3b0a5..2cf7930 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -1153,3 +1153,44 @@ tr.row-dup { fehlende oder doppelte Buchung – deshalb warnend, nicht rot. */ .saldo-ok { color: var(--ok); } .saldo-abweichung { color: var(--warn); font-weight: 560; } + +/* ---------- Übertrags-Vorschläge ---------- */ +.uebertrag-vorschlag { + padding: var(--sp-3) 0; + border-bottom: 1px solid var(--border); +} +.uebertrag-vorschlag:last-child { + border-bottom: none; +} +/* Die beiden Seiten nebeneinander, damit man sie vergleicht statt sie zu + lesen. Auf schmalen Schirmen untereinander – gestapelt bleibt der Vergleich + möglich, gequetscht nicht. */ +.uebertrag-seiten { + display: flex; + align-items: center; + gap: var(--sp-3); +} +.uebertrag-seite { + flex: 1 1 0; + min-width: 0; +} +.uebertrag-pfeil { + flex: 0 0 auto; + color: var(--muted); +} +@media (max-width: 820px) { + .uebertrag-seiten { + flex-direction: column; + align-items: stretch; + gap: var(--sp-2); + } +} + +/* Übertrags-Marke in der Buchungsliste: gedämpft, nicht warnend – die Buchung + ist in Ordnung, sie zählt nur nicht als Ausgabe oder Einnahme. */ +.uebertrag-marke { + display: flex; + align-items: center; + gap: 4px; + color: var(--accent); +} diff --git a/web/src/locales/de.json b/web/src/locales/de.json index e8e556c..7d1271f 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -633,5 +633,17 @@ "noAccounts": "Verbunden, aber die Bank hat kein Konto übermittelt", "noAccountsHint": "Im eingeschränkten Betrieb gibt die Bank nur Konten heraus, die du bei Enable Banking selbst verknüpft hast. Öffne dort deine Anwendung, drück „Activate by linking accounts“ und wähle diese Bank – danach hier noch einmal verbinden.", "noAccountsDropped": "Die Bank hat {{n}} Einträge geschickt, aber ohne Kennung – damit lässt sich nichts abrufen. Das ist ein Fall für den Support von Enable Banking; schick mir die Meldung, dann formuliere ich die Anfrage." + }, + "transfers": { + "title": "Mögliche Überträge", + "intro": "Ein Abgang hier, ein Zugang dort – vermutlich dasselbe Geld. Verbinden ändert keine Buchung: Beide bleiben stehen und zählen nur nicht mehr als Ausgabe und Einnahme. Lösen stellt alles wieder her.", + "search": "Neu suchen", + "searched": "{{verknuepft}} verbunden, {{offen}} zum Ansehen.", + "connect": "Verbinden", + "ignore": "Ignorieren", + "hasCategory": "Eine der beiden hat eine Kategorie – sie fällt durchs Verbinden aus der Auswertung.", + "linked": "Übertrag", + "release": "Verknüpfung lösen", + "releaseConfirm": "Die beiden Buchungen bleiben unverändert stehen und zählen danach wieder als Ausgabe und Einnahme." } } diff --git a/web/src/locales/en.json b/web/src/locales/en.json index c508441..fdd4d83 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -633,5 +633,17 @@ "noAccounts": "Connected, but the bank returned no account", "noAccountsHint": "In restricted mode the bank only releases accounts you linked at Enable Banking yourself. Open your application there, press “Activate by linking accounts” and pick this bank – then connect again here.", "noAccountsDropped": "The bank sent {{n}} entries but without an identifier, so nothing can be fetched. That is one for Enable Banking support; send me the message and I will draft the question." + }, + "transfers": { + "title": "Possible transfers", + "intro": "Money out here, money in there – probably the same money. Connecting changes no transaction: both stay put and simply stop counting as expense and income. Releasing restores everything.", + "search": "Search again", + "searched": "{{verknuepft}} connected, {{offen}} to review.", + "connect": "Connect", + "ignore": "Ignore", + "hasCategory": "One of them has a category – connecting takes it out of the evaluation.", + "linked": "Transfer", + "release": "Release link", + "releaseConfirm": "Both transactions stay exactly as they are and count as expense and income again afterwards." } } diff --git a/web/src/pages/Eingang.jsx b/web/src/pages/Eingang.jsx index 00a3388..98792e9 100644 --- a/web/src/pages/Eingang.jsx +++ b/web/src/pages/Eingang.jsx @@ -6,6 +6,7 @@ import CategorySelect from "../components/CategorySelect"; import Icon from "../components/Icon"; import IconChip from "../components/IconChip"; import Money from "../components/Money"; +import Uebertragsvorschlaege from "../components/Uebertragsvorschlaege"; import { useToast } from "../toast"; import { useConfirm } from "../confirm"; import { useSettings } from "../settings"; @@ -102,6 +103,10 @@ export default function Eingang() { )} + {/* Nach dem Buchen entsteht die Gegenseite eines Übertrags erst – + deshalb steht der Vorschlag hier und nicht in der Buchungsliste. */} + + {rows.length === 0 ? (
{t("inbox.empty")}
) : ( diff --git a/web/src/pages/Transactions.jsx b/web/src/pages/Transactions.jsx index b1d4570..a36e3e4 100644 --- a/web/src/pages/Transactions.jsx +++ b/web/src/pages/Transactions.jsx @@ -41,6 +41,8 @@ export default function Transactions() { const toast = useToast(); const [txns, setTxns] = useState([]); + // Je Buchungs-ID die Gegenseite, falls sie Teil eines Übertrags ist. + const [links, setLinks] = useState({}); const [accounts, setAccounts] = useState([]); const [groups, setGroups] = useState([]); const [filter, setFilter] = useState({ account_id: "", type: "" }); @@ -66,7 +68,30 @@ export default function Transactions() { } async function ladenBuchungen() { try { - setTxns(await api.listTransactions({ account_id: filter.account_id, type: filter.type })); + // Verknüpfungen getrennt: Sie sind eine Schicht über den Buchungen, + // kein Feld an ihnen – deshalb auch ein eigener Abruf. + const [liste, verkn] = await Promise.all([ + api.listTransactions({ account_id: filter.account_id, type: filter.type }), + api.transactionLinks().catch(() => ({})), + ]); + setTxns(liste); + setLinks(verkn); + } catch (err) { + toast(err.message, "error"); + } + } + + async function loesen(tx) { + const link = links[String(tx.id)]; + if (!link) return; + if (!(await confirm({ + title: t("transfers.release"), + message: t("transfers.releaseConfirm"), + confirmLabel: t("transfers.release"), + }))) return; + try { + await api.releaseTransfer(link.link_id); + ladenBuchungen(); } catch (err) { toast(err.message, "error"); } @@ -218,6 +243,15 @@ export default function Transactions() {
{l.title}
{l.sub &&
{l.sub}
} + {/* Verknüpft heisst: zählt nicht als Ausgabe oder Einnahme. + Ohne das Gegenkonto sagt „Übertrag" nichts. */} + {links[String(tx.id)] && ( +
+ {" "} + {accountById[links[String(tx.id)].other_account_id]?.name + || t("transfers.linked")} +
+ )}
); @@ -247,6 +281,12 @@ export default function Transactions() { + {links[String(tx.id)] && ( + + )} @@ -254,7 +294,7 @@ export default function Transactions() { ), }, // eslint-disable-next-line react-hooks/exhaustive-deps - ], [accountById, categoryById, baseCurrency, formatDate, t]); + ], [accountById, categoryById, baseCurrency, formatDate, links, t]); return (