From bb748cc0923766d2d6d6088df54bc93d4bf6e816 Mon Sep 17 00:00:00 2001 From: Guido Date: Sun, 30 Aug 2026 13:03:25 +0200 Subject: [PATCH] feat: Certain fragt in der Buchungsliste nach, ob es ein Uebertrag ist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Erkennung gab es laengst – aber nur auf der Seite "Zu erledigen", in einer Karte, die man erst finden muss. Wer in der Buchungsliste steht und die zweite Haelfte eines Uebertrags sieht, greift stattdessen zum ↔-Knopf und legt einen zweiten Uebertrag an. Genau so ist heute eine Zahlung doppelt verschoben worden. Jetzt steht die Frage in der Zeile: "Uebertrag von/nach Revolut CHF?" mit Verbinden und Ignorieren. Ignorieren wird gemerkt, sonst fragte Certain bei jedem Laden erneut. Dafuer gibt es die Suche jetzt auch als reines Nachschlagen: ``finde`` mit ``nur_vorschlaege`` verknuepft nichts, und dahinter liegt GET /uebertraege/vorschlaege. Eine Ansicht, die bloss fragt, darf beim Hinsehen nichts veraendern – sonst entschiede das Anschauen. Co-Authored-By: Claude Opus 5 --- backend/app/routers/uebertraege.py | 10 +++++ backend/app/services/uebertraege.py | 15 ++++++-- backend/tests/test_uebertraege.py | 26 +++++++++++++ web/src/api.js | 3 ++ web/src/finance.css | 16 ++++++++ web/src/locales/de.json | 3 +- web/src/locales/en.json | 3 +- web/src/pages/Transactions.jsx | 59 ++++++++++++++++++++++++++++- 8 files changed, 128 insertions(+), 7 deletions(-) diff --git a/backend/app/routers/uebertraege.py b/backend/app/routers/uebertraege.py index 80011aa..be37aec 100644 --- a/backend/app/routers/uebertraege.py +++ b/backend/app/routers/uebertraege.py @@ -41,6 +41,16 @@ def list_links(db: Session = Depends(get_db), user: User = Depends(get_current_u ] +@router.get("/vorschlaege") +def vorschlaege(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: + """Nur fragen, nichts tun. + + Getrennt von ``/suchen``: Eine Ansicht, die bloss „ist das ein Übertrag?" + anzeigt, darf beim Hinsehen nichts verknüpfen. + """ + return uebertraege.finde(db, user, nur_vorschlaege=True) + + @router.post("/suchen") def suchen(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: """Paare suchen. Eindeutige mit Übertrags-Hinweis werden verknüpft, der diff --git a/backend/app/services/uebertraege.py b/backend/app/services/uebertraege.py index 70c4079..821c0cc 100644 --- a/backend/app/services/uebertraege.py +++ b/backend/app/services/uebertraege.py @@ -226,8 +226,13 @@ def _eindeutig(a: Transaction, moegliche: list[Transaction], return moegliche[0] if len(konten) == 1 else None -def finde(db: Session, user: User) -> dict: - """Paare suchen. Eindeutige verknüpfen, den Rest als Vorschlag melden.""" +def finde(db: Session, user: User, nur_vorschlaege: bool = False) -> dict: + """Paare suchen. Eindeutige verknüpfen, den Rest als Vorschlag melden. + + Mit ``nur_vorschlaege`` wird NICHTS verknüpft – dann kommt alles als + Vorschlag zurück. Das braucht jede Ansicht, die bloss fragen will („ist das + ein Übertrag?"), ohne beim Hinsehen schon etwas zu verändern. + """ kandidaten = _kandidaten(db, user) bezeichner = _kontobezeichner(db, user) nach_id = {} @@ -260,11 +265,15 @@ def finde(db: Session, user: User) -> dict: if not _hat_hinweis(a, gegen, bezeichner): vorschlaege.append((a, gegen)) continue + if nur_vorschlaege: + vorschlaege.append((a, gegen)) + continue verbinde(db, user, a, gegen, automatisch=True) verbraucht.update((a.id, gegen.id)) verknuepft += 1 - db.commit() + if not nur_vorschlaege: + db.commit() return { "verknuepft": verknuepft, "vorschlaege": [ diff --git a/backend/tests/test_uebertraege.py b/backend/tests/test_uebertraege.py index 36954fa..adb92eb 100644 --- a/backend/tests/test_uebertraege.py +++ b/backend/tests/test_uebertraege.py @@ -283,3 +283,29 @@ def test_ignorierte_paare_kehren_nicht_zurueck(admin): admin.post("/api/uebertraege/ignorieren", json={"a_id": aus["id"], "b_id": ein["id"]}) danach = _suchen(admin)["vorschlaege"] assert not any({v["a"]["id"], v["b"]["id"]} == {aus["id"], ein["id"]} for v in danach) + + +def test_vorschlaege_veraendern_nichts(admin): + """Eine Ansicht, die bloss fragt „ist das ein Uebertrag?", darf beim + Hinsehen nichts verknuepfen – sonst entscheidet das Anschauen.""" + import secrets + + a = _konto(admin, name=f"V-A {secrets.token_hex(3)}") + b = _konto(admin, name=f"V-B {secrets.token_hex(3)}") + betrag = 30000 + secrets.randbelow(900) + # Mit Hinweis im Text – das wuerde /suchen automatisch verknuepfen. + raus = admin.post("/api/transactions", json={ + "type": "expense", "date": "2026-08-20", "account_id": a["id"], + "amount_cents": betrag, "payee": f"Uebertrag auf {b['name']}"}).json() + rein = admin.post("/api/transactions", json={ + "type": "income", "date": "2026-08-20", "account_id": b["id"], + "amount_cents": betrag, "payee": "Gutschrift"}).json() + + r = admin.get("/api/uebertraege/vorschlaege").json() + assert r["verknuepft"] == 0, "beim Fragen darf nichts verknuepft werden" + paare = [(v["a"]["id"], v["b"]["id"]) for v in r["vorschlaege"]] + assert any({raus["id"], rein["id"]} == set(p) for p in paare) + + # Und es ist wirklich keine Verknuepfung entstanden. + links = admin.get("/api/transactions/links").json() + assert str(raus["id"]) not in links and str(rein["id"]) not in links diff --git a/web/src/api.js b/web/src/api.js index ba683a3..58ec9d8 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -229,6 +229,9 @@ export const api = { applyRules: (vorschau = true) => request(`/categories/keywords/anwenden?vorschau=${vorschau}`, { method: "POST" }), transactionLinks: () => request("/transactions/links"), + // Nur fragen, nichts tun – fuer Ansichten, die bloss anzeigen wollen, + // dass hier ein Uebertrag stecken koennte. + transferSuggestions: () => request("/uebertraege/vorschlaege"), searchTransfers: () => request("/uebertraege/suchen", { method: "POST" }), connectTransfer: (a_id, b_id) => request("/uebertraege/verbinden", { method: "POST", body: { a_id, b_id } }), diff --git a/web/src/finance.css b/web/src/finance.css index 76a8992..fdb41fd 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -1391,3 +1391,19 @@ tr.row-dup { .bank-verbindung .input-with-button > .muted { white-space: nowrap; } + +/* Rückfrage in der Buchungszeile: „Das könnte ein Übertrag sein." Bewusst + auffällig genug, um gesehen zu werden – sie verlangt eine Antwort, anders + als die Marke einer schon bestätigten Verknüpfung. */ +.uebertrag-frage { + display: flex; + align-items: center; + gap: var(--sp-2); + margin-top: 3px; + font-size: 0.8rem; + color: var(--warn); + min-width: 0; +} +.uebertrag-frage .btn { + flex: 0 0 auto; +} diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 79c7c4b..5e8b8aa 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -683,7 +683,8 @@ "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." + "releaseConfirm": "Die beiden Buchungen bleiben unverändert stehen und zählen danach wieder als Ausgabe und Einnahme.", + "question": "Übertrag von/nach {{konto}}?" }, "todo": { "title": "Zu erledigen", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 2020bcf..de0b613 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -683,7 +683,8 @@ "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." + "releaseConfirm": "Both transactions stay exactly as they are and count as expense and income again afterwards.", + "question": "Transfer from/to {{konto}}?" }, "todo": { "title": "To do", diff --git a/web/src/pages/Transactions.jsx b/web/src/pages/Transactions.jsx index bb8d980..16e885b 100644 --- a/web/src/pages/Transactions.jsx +++ b/web/src/pages/Transactions.jsx @@ -44,6 +44,8 @@ export default function Transactions() { const [txns, setTxns] = useState([]); // Je Buchungs-ID die Gegenseite, falls sie Teil eines Übertrags ist. const [links, setLinks] = useState({}); + // Und je Buchungs-ID die Gegenseite, die es sein KÖNNTE – noch unbestätigt. + const [vorschlag, setVorschlag] = useState({}); // Buchung, die gerade zum Übertrag gemacht wird. const [uebertrag, setUebertrag] = useState(null); const [accounts, setAccounts] = useState([]); @@ -73,12 +75,21 @@ export default function Transactions() { try { // 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([ + const [liste, verkn, vorschlaege] = await Promise.all([ api.listTransactions({ account_id: filter.account_id, type: filter.type }), api.transactionLinks().catch(() => ({})), + // Nur fragen, nichts tun: Beim Anschauen der Liste darf nichts + // verknüpft werden. + api.transferSuggestions().catch(() => ({ vorschlaege: [] })), ]); setTxns(liste); setLinks(verkn); + const paare = {}; + for (const v of vorschlaege.vorschlaege || []) { + paare[v.a.id] = v.b; + paare[v.b.id] = v.a; + } + setVorschlag(paare); } catch (err) { toast(err.message, "error"); } @@ -96,6 +107,29 @@ export default function Transactions() { } } + async function verbinden(tx) { + const gegen = vorschlag[tx.id]; + if (!gegen) return; + try { + await api.connectTransfer(tx.id, gegen.id); + ladenBuchungen(); + } catch (err) { + toast(err.message, "error"); + } + } + + async function nichtVerbinden(tx) { + const gegen = vorschlag[tx.id]; + if (!gegen) return; + try { + // Ablehnen wird gemerkt, sonst fragt Certain bei jedem Laden erneut. + await api.ignoreTransfer(tx.id, gegen.id); + ladenBuchungen(); + } catch (err) { + toast(err.message, "error"); + } + } + async function loesen(tx) { const link = links[String(tx.id)]; if (!link) return; @@ -280,6 +314,27 @@ export default function Transactions() { || t("transfers.linked")} )} + {/* Nachträglich erkannt: Auf einem anderen Konto steht der + gleiche Betrag am selben Tag in die andere Richtung. Fragen + statt tun – und die Antwort merken. */} + {!links[String(tx.id)] && vorschlag[tx.id] && ( +
+ + + {t("transfers.question", { + konto: accountById[vorschlag[tx.id].account_id]?.name || "?", + })} + + + +
+ )} ); @@ -356,7 +411,7 @@ export default function Transactions() { }, // eslint-disable-next-line react-hooks/exhaustive-deps ], [accountById, categoryById, baseCurrency, expenseGroups, incomeGroups, - formatDate, links, t]); + formatDate, links, vorschlag, t]); return (