diff --git a/backend/app/routers/uebertraege.py b/backend/app/routers/uebertraege.py index be37aec..8d535c8 100644 --- a/backend/app/routers/uebertraege.py +++ b/backend/app/routers/uebertraege.py @@ -85,6 +85,66 @@ def verbinden( "automatisch": False} +@router.get("/ignoriert") +def ignorierte(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict]: + """Welche Vorschlaege wurden abgelehnt? + + Ohne diese Liste war „Ignorieren" eine Einbahnstrasse: Das Paar blieb bei + jedem weiteren Suchlauf stumm, und nichts verriet, dass es je einen + Vorschlag gab. Wer einmal falsch klickte, suchte danach einen Fehler in + der Erkennung, den es nicht gab. + """ + eintraege = ( + db.query(LinkIgnored) + .filter(LinkIgnored.owner_id == user.id) + .order_by(LinkIgnored.id.desc()) + .all() + ) + ids = {i for e in eintraege for i in (e.a_txn_id, e.b_txn_id)} + buchungen = { + t.id: t for t in db.query(Transaction).filter(Transaction.id.in_(ids or [0])) + } + namen = dict(db.query(Account.id, Account.name).filter(Account.owner_id == user.id)) + + def kurz(txn_id: int) -> dict | None: + t = buchungen.get(txn_id) + if t is None: + return None + return { + "id": t.id, "date": t.date.isoformat(), "amount_cents": t.amount_cents, + "payee": t.payee or t.description, + "account_name": namen.get(t.account_id, "?"), + } + + ergebnis = [] + for e in eintraege: + a, b = kurz(e.a_txn_id), kurz(e.b_txn_id) + # Ist eine der beiden Buchungen geloescht, gibt es nichts mehr + # zurueckzunehmen – der Eintrag waere nur noch Rauschen. + if a and b: + ergebnis.append({"a": a, "b": b}) + return ergebnis + + +@router.delete("/ignoriert", status_code=status.HTTP_204_NO_CONTENT) +def ignorieren_aufheben( + payload: PaarIn, db: Session = Depends(get_db), user: User = Depends(get_current_user) +) -> None: + """Die Ablehnung zuruecknehmen – das Paar wird wieder vorgeschlagen.""" + a, b = _paar(db, user, payload) + klein, gross = sorted((a.id, b.id)) + ( + db.query(LinkIgnored) + .filter(LinkIgnored.owner_id == user.id, + LinkIgnored.a_txn_id == klein, LinkIgnored.b_txn_id == gross) + .delete() + ) + db.commit() + + +# Vor der Route mit dem Platzhalter, sonst verschluckt "/{link_id}" den +# wortwoertlichen Pfad: FastAPI nimmt die erste passende Route, und +# "ignoriert" laesst sich nicht als Zahl lesen (422 statt 204). @router.delete("/{link_id}", status_code=status.HTTP_204_NO_CONTENT) def loesen(link_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): """Verknüpfung entfernen. Die beiden Buchungen bleiben, wie sie sind – diff --git a/backend/tests/test_notiz_und_ignoriert.py b/backend/tests/test_notiz_und_ignoriert.py new file mode 100644 index 0000000..b549343 --- /dev/null +++ b/backend/tests/test_notiz_und_ignoriert.py @@ -0,0 +1,137 @@ +"""Zwei Dinge, die es brauchte, damit man einer Buchung ansieht, was sie ist. + +Die Notiz: Die Kategorie sagt, in welche Schublade es gehoert – nicht, WAS es +war. „Amazon" kann Einrichtung sein, Werkzeug oder ein Geschenk. + +Der Rueckweg aus „Ignorieren": Bisher war ein Klick endgueltig. Das Paar blieb +bei jedem weiteren Suchlauf stumm, und nichts verriet, dass es je einen +Vorschlag gab. +""" + +from datetime import date + + +def _konto(admin, name, **kw): + body = {"name": name, "type": "girokonto", "currency": "EUR"} + body.update(kw) + r = admin.post("/api/accounts", json=body) + assert r.status_code == 201, r.text + return r.json() + + +def _buchung(admin, konto, art, betrag, wer, tag="2026-05-04", **kw): + body = {"type": art, "date": tag, "account_id": konto["id"], + "amount_cents": betrag, "payee": wer} + body.update(kw) + r = admin.post("/api/transactions", json=body) + assert r.status_code in (200, 201), r.text + return r.json() + + +# --- Notiz ------------------------------------------------------------------ + +def test_notiz_laesst_sich_setzen_aendern_und_loeschen(admin): + konto = _konto(admin, "Notiz-Konto") + txn = _buchung(admin, konto, "expense", 4990, "Amazon", + notes="Schraubenset, kein Moebelstueck") + assert txn["notes"] == "Schraubenset, kein Moebelstueck" + + r = admin.patch(f"/api/transactions/{txn['id']}", json={"notes": "Geschenk fuer Tim"}) + assert r.json()["notes"] == "Geschenk fuer Tim" + + r = admin.patch(f"/api/transactions/{txn['id']}", json={"notes": None}) + assert r.json()["notes"] is None + + +def test_notiz_ueberlebt_eine_aenderung_am_betrag(admin): + """Sie haengt an der Buchung, nicht an einer Nebensache.""" + konto = _konto(admin, "Notiz-Konto 2") + txn = _buchung(admin, konto, "expense", 1000, "Amazon", notes="beruflich") + r = admin.patch(f"/api/transactions/{txn['id']}", json={"amount_cents": 1200}) + assert r.json()["notes"] == "beruflich" + + +def test_notiz_steht_in_der_liste(admin): + """Nach dem eigenen Konto gefragt – sonst haengt der Test an der Anzahl + Buchungen, die andere Tests vorher angelegt haben.""" + konto = _konto(admin, "Notiz-Konto 3") + _buchung(admin, konto, "expense", 2000, "Amazon", notes="Werkzeug") + zeilen = admin.get(f"/api/transactions?account_id={konto['id']}").json() + assert [t for t in zeilen if t.get("notes") == "Werkzeug"], "ohne das Feld in der Liste nuetzt die Notiz beim Suchen nichts" + + +# --- Abgelehnte Vorschlaege ------------------------------------------------- + +def _paar(admin, name): + a = _konto(admin, f"{name} A") + b = _konto(admin, f"{name} B") + # Mit Kategorie, damit nicht automatisch verknuepft wird: Dann bleibt es + # ein Vorschlag, und genau den kann man ablehnen. + kat = None + for g in admin.get("/api/categories").json(): + for c in g["categories"]: + if c["type"] == "expense": + kat = c["id"] + break + if kat: + break + raus = _buchung(admin, a, "expense", 777, "Umbuchung X", category_id=kat) + rein = _buchung(admin, b, "income", 777, "Umbuchung X") + return raus, rein + + +def test_abgelehntes_paar_laesst_sich_zurueckholen(admin): + raus, rein = _paar(admin, "Ignoriert") + + vor = admin.get("/api/uebertraege/vorschlaege").json()["vorschlaege"] + assert any({p["a"]["id"], p["b"]["id"]} == {raus["id"], rein["id"]} for p in vor) + + admin.post("/api/uebertraege/ignorieren", + json={"a_id": raus["id"], "b_id": rein["id"]}) + + nach = admin.get("/api/uebertraege/vorschlaege").json()["vorschlaege"] + assert not any({p["a"]["id"], p["b"]["id"]} == {raus["id"], rein["id"]} for p in nach) + + # Und es ist auffindbar, statt spurlos zu verschwinden. + liste = admin.get("/api/uebertraege/ignoriert").json() + assert any({p["a"]["id"], p["b"]["id"]} == {raus["id"], rein["id"]} for p in liste) + + r = admin.request("DELETE", "/api/uebertraege/ignoriert", + json={"a_id": raus["id"], "b_id": rein["id"]}) + assert r.status_code == 204, r.text + + wieder = admin.get("/api/uebertraege/vorschlaege").json()["vorschlaege"] + assert any({p["a"]["id"], p["b"]["id"]} == {raus["id"], rein["id"]} for p in wieder) + + +def test_ignoriert_zeigt_genug_zum_wiedererkennen(admin): + raus, rein = _paar(admin, "Lesbar") + admin.post("/api/uebertraege/ignorieren", + json={"a_id": raus["id"], "b_id": rein["id"]}) + + eintrag = [p for p in admin.get("/api/uebertraege/ignoriert").json() + if {p["a"]["id"], p["b"]["id"]} == {raus["id"], rein["id"]}][0] + for seite in (eintrag["a"], eintrag["b"]): + assert seite["date"] == "2026-05-04" + assert seite["amount_cents"] == 777 + assert seite["payee"] == "Umbuchung X" + assert seite["account_name"].startswith("Lesbar") + + +def test_geloeschte_buchung_verschwindet_aus_der_liste(admin): + """Ein Eintrag, zu dem es nichts mehr zurueckzunehmen gibt, ist Rauschen.""" + raus, rein = _paar(admin, "Geloescht") + admin.post("/api/uebertraege/ignorieren", + json={"a_id": raus["id"], "b_id": rein["id"]}) + admin.delete(f"/api/transactions/{rein['id']}") + + liste = admin.get("/api/uebertraege/ignoriert").json() + assert not any(p["a"]["id"] == raus["id"] or p["b"]["id"] == raus["id"] + for p in liste) + + +def test_fremdes_paar_laesst_sich_nicht_zurueckholen(admin): + assert date.today() + r = admin.request("DELETE", "/api/uebertraege/ignoriert", + json={"a_id": 999999, "b_id": 999998}) + assert r.status_code == 404, r.text diff --git a/web/src/api.js b/web/src/api.js index 13a3371..ef2f86e 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -250,6 +250,11 @@ export const api = { request(`/uebertraege/${linkId}`, { method: "DELETE" }), ignoreTransfer: (a_id, b_id) => request("/uebertraege/ignorieren", { method: "POST", body: { a_id, b_id } }), + // Abgelehnte Vorschlaege ansehen und zuruecknehmen. Ohne das waere + // „Ignorieren" eine Einbahnstrasse. + ignoredTransfers: () => request("/uebertraege/ignoriert"), + unignoreTransfer: (a_id, b_id) => + request("/uebertraege/ignoriert", { method: "DELETE", body: { a_id, b_id } }), listBankConnections: () => request("/bank/connections"), syncBankConnection: (id) => request(`/bank/connections/${id}/sync`, { method: "POST" }), diff --git a/web/src/components/Uebertragsvorschlaege.jsx b/web/src/components/Uebertragsvorschlaege.jsx index a25c97c..9e09387 100644 --- a/web/src/components/Uebertragsvorschlaege.jsx +++ b/web/src/components/Uebertragsvorschlaege.jsx @@ -18,6 +18,11 @@ export default function Uebertragsvorschlaege({ baseCurrency, onChange }) { const [stand, setStand] = useState(null); // { verknuepft, vorschlaege } const [busy, setBusy] = useState(false); + // Abgelehnte Vorschlaege. Ohne diese Liste war „Ignorieren" endgueltig: + // Das Paar blieb bei jedem weiteren Lauf stumm, und nichts verriet, dass es + // je einen Vorschlag gab – wer einmal falsch klickte, suchte danach einen + // Fehler in der Erkennung, den es nicht gab. + const [abgelehnt, setAbgelehnt] = useState([]); async function suchen(melden = false) { setBusy(true); @@ -37,7 +42,21 @@ export default function Uebertragsvorschlaege({ baseCurrency, onChange }) { } } - useEffect(() => { suchen(false); }, []); + async function abgelehnteLaden() { + try { setAbgelehnt(await api.ignoredTransfers()); } catch { /* still */ } + } + + useEffect(() => { suchen(false); abgelehnteLaden(); }, []); + + async function zurueckholen(paar) { + try { + await api.unignoreTransfer(paar.a.id, paar.b.id); + await abgelehnteLaden(); + suchen(false); + } catch (e) { + toast(e.message, "error"); + } + } async function verbinden(paar) { try { @@ -53,6 +72,7 @@ export default function Uebertragsvorschlaege({ baseCurrency, onChange }) { try { await api.ignoreTransfer(paar.a.id, paar.b.id); suchen(false); + abgelehnteLaden(); } catch (e) { toast(e.message, "error"); } @@ -60,7 +80,7 @@ export default function Uebertragsvorschlaege({ baseCurrency, onChange }) { // Vor dem ersten Ergebnis keine leere Karte zeigen. if (stand === null) return null; - if (stand.vorschlaege.length === 0) return null; + if (stand.vorschlaege.length === 0 && abgelehnt.length === 0) return null; return (
@@ -102,6 +122,30 @@ export default function Uebertragsvorschlaege({ baseCurrency, onChange }) { ); })}
+ + {/* Der Rueckweg. Eingeklappt, weil man ihn selten braucht – aber da, + weil ein Klick sonst fuer immer gilt. */} + {abgelehnt.length > 0 && ( +
+ + {t("transfers.ignoredCount", { count: abgelehnt.length })} + +

{t("transfers.ignoredHint")}

+ {abgelehnt.map((paar) => ( +
+
+ + + +
+ +
+ ))} +
+ )} ); } diff --git a/web/src/finance.css b/web/src/finance.css index e75384a..74aa48a 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -1208,6 +1208,24 @@ tr.row-dup { font-size: 0.9em; } +/* Eigene Notiz an einer Buchung – von Hand geschrieben und deshalb + aussagekraeftiger als jeder Banktext. Abgesetzt, damit sie nicht mit dem + Verwendungszweck verwechselt wird. */ +.buchungsnotiz { + color: var(--muted); + font-style: italic; + opacity: 0.95; +} +.buchungsnotiz svg { vertical-align: -1px; } + +/* Der Rueckweg aus „Ignorieren" – selten gebraucht, aber vorhanden. */ +.abgelehnte { + margin-top: var(--sp-3); + border-top: 1px solid var(--border); + padding-top: var(--sp-2); +} +.abgelehnte summary { cursor: pointer; padding: 4px 0; } + /* ---------- Übertrags-Vorschläge ---------- */ .uebertrag-vorschlag { padding: var(--sp-3) 0; diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 2e3b8e7..815d9ae 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -295,6 +295,8 @@ "toAmount": "Gutschrift", "payee": "Empfänger", "description": "Verwendungszweck", + "notes": "Notiz", + "notesPlaceholder": "Wofür war das? z. B. „Geschenk“, „beruflich“, „Werkzeug“", "uncategorized": "Ohne Kategorie", "empty": "Noch keine Buchungen.", "needAccount": "Lege zuerst ein Konto an, um Buchungen zu erfassen.", @@ -807,6 +809,12 @@ "ignore": "Ignorieren", "hasCategory": "Eine der beiden hat eine Kategorie – sie fällt durchs Verbinden aus der Auswertung.", "linked": "Übertrag", + "linkedHint": "Übertrag zwischen eigenen Konten – zählt weder als Ausgabe noch als Einnahme.", + "suggestionsFailed": "Mögliche Überträge konnten nicht geladen werden: {{fehler}}", + "ignoredCount_one": "{{count}} abgelehnter Vorschlag", + "ignoredCount_other": "{{count}} abgelehnte Vorschläge", + "ignoredHint": "Abgelehnte Paare bleiben bei jedem weiteren Suchlauf stumm. Zurückholen heisst: Sie werden wieder vorgeschlagen.", + "unignore": "Wieder vorschlagen", "release": "Verknüpfung lösen", "releaseConfirm": "Die beiden Buchungen bleiben unverändert stehen und zählen danach wieder als Ausgabe und Einnahme.", "question": "Übertrag von/nach {{konto}}?" diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 3e6660c..e7c1216 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -295,6 +295,8 @@ "toAmount": "Credited", "payee": "Payee", "description": "Description", + "notes": "Note", + "notesPlaceholder": "What was it for? e.g. “gift”, “work”, “tools”", "uncategorized": "Uncategorized", "empty": "No transactions yet.", "needAccount": "Create an account first to record transactions.", @@ -807,6 +809,12 @@ "ignore": "Ignore", "hasCategory": "One of them has a category – connecting takes it out of the evaluation.", "linked": "Transfer", + "linkedHint": "Transfer between your own accounts — counts neither as an expense nor as income.", + "suggestionsFailed": "Possible transfers could not be loaded: {{fehler}}", + "ignoredCount_one": "{{count}} dismissed suggestion", + "ignoredCount_other": "{{count}} dismissed suggestions", + "ignoredHint": "Dismissed pairs stay silent in every later search. Bringing one back means it will be suggested again.", + "unignore": "Suggest again", "release": "Release link", "releaseConfirm": "Both transactions stay exactly as they are and count as expense and income again afterwards.", "question": "Transfer from/to {{konto}}?" diff --git a/web/src/pages/Transactions.jsx b/web/src/pages/Transactions.jsx index 1d1cadf..0949e52 100644 --- a/web/src/pages/Transactions.jsx +++ b/web/src/pages/Transactions.jsx @@ -31,6 +31,7 @@ function emptyForm() { to_amount: "", payee: "", description: "", + notes: "", budget_as_expense: true, budget_category_id: "", // Leer heisst: aus dem Datum. Der Normalfall, und er bleibt es. @@ -83,7 +84,12 @@ export default function Transactions() { api.transactionLinks().catch(() => ({})), // Nur fragen, nichts tun: Beim Anschauen der Liste darf nichts // verknüpft werden. - api.transferSuggestions().catch(() => ({ vorschlaege: [] })), + api.transferSuggestions().catch((e) => { + // Stillschweigen waere hier das Schlimmste: Die Liste saehe aus, als + // gaebe es nichts zu verbinden. + toast(t("transfers.suggestionsFailed", { fehler: e.message }), "error"); + return { vorschlaege: [] }; + }), ]); setTxns(liste); setLinks(verkn); @@ -211,6 +217,7 @@ export default function Transactions() { to_amount: tx.to_amount_cents ? centsToInput(tx.to_amount_cents) : "", payee: tx.payee || "", description: tx.description || "", + notes: tx.notes || "", budget_as_expense: tx.budget_treatment !== "neutral", budget_category_id: tx.budget_category_id ? String(tx.budget_category_id) : "", // Nur der von Hand gesetzte Monat gehoert ins Formular. Leer heisst: @@ -238,6 +245,7 @@ export default function Transactions() { amount_cents: cents, payee: form.payee.trim() || null, description: form.description.trim() || null, + notes: form.notes.trim() || null, budget_period: form.budget_period || null, }; if (form.type === "transfer") { @@ -295,11 +303,31 @@ export default function Transactions() { const von = accountById[tx.account_id]?.name || "?"; const nach = accountById[gegen.account_id]?.name || "?"; return { - icon: "arrow-left-right", color: "#6b7480", + icon: "arrow-left-right", color: "#6b7480", uebertrag: true, title: `${von} → ${nach}`, sub: tx.payee || tx.description || t("transactions.transfer"), }; } + // Verknuepft, aber die Gegenseite ist nicht in der Liste (gefiltert oder + // auf einer anderen Seite). Dieselbe Darstellung wie beim Paar: Oben steht, + // zwischen WELCHEN Konten das Geld lief, und nicht ein Empfaengertext, den + // die Bank vergeben hat. + // + // Vorher stand hier der Rohtext als Titel und die Verknuepfung nur klein + // darunter – „To CHF MB:8f33c0aa-9c92-456b-a6fe-b64a858a019c" sah aus wie + // eine Zahlung an jemanden. + const link = links[String(tx.id)]; + if (link) { + const eigen = accountById[tx.account_id]?.name || "?"; + const anderes = accountById[link.other_account_id]?.name + || t("transfers.linked"); + return { + icon: "arrow-left-right", color: "#6b7480", uebertrag: true, + title: link.richtung === "raus" + ? `${eigen} → ${anderes}` : `${anderes} → ${eigen}`, + sub: tx.payee || tx.description || "", + }; + } if (tx.type === "transfer") { const from = accountById[tx.account_id]?.name || "?"; const to = accountById[tx.to_account_id]?.name || "?"; @@ -330,7 +358,9 @@ export default function Transactions() { { // Wofür die Zahlung war – danach sucht man in der Liste. key: "text", header: t("transactions.subject"), grow: true, min: 190, - filterText: (tx) => rowLabel(tx).title, + // Auch die Notiz durchsuchbar: Wer sie schreibt, will spaeter danach + // suchen koennen („Geschenk", „beruflich"). + filterText: (tx) => [rowLabel(tx).title, tx.notes].filter(Boolean).join(" "), sortValue: (tx) => rowLabel(tx).title, render: (tx) => { const l = rowLabel(tx); @@ -343,6 +373,15 @@ export default function Transactions() { stapeln macht die Zeilen hoch und den Blick eng. */}
{l.title} + {/* Ein Uebertrag ist keine Ausgabe und keine Einnahme. Das + gehoert in die erste Zeile, nicht in eine Fussnote. */} + {l.uebertrag && ( + + + {t("transactions.transfer")} + + )} {t(`todo.from.${tx.herkunft}`, { defaultValue: tx.herkunft, })} @@ -358,17 +397,16 @@ export default function Transactions() { )}
{l.sub &&
{l.sub}
} - {/* Verknüpft heisst: zählt nicht als Ausgabe oder Einnahme. - Ohne das Gegenkonto sagt „Übertrag" nichts. */} - {/* Bei einer Paar-Zeile steht das Gegenkonto schon im Titel – - die Marke waere dort nur eine Wiederholung. */} - {links[String(tx.id)] && !paarZu[tx.id] && ( -
- {" "} - {accountById[links[String(tx.id)].other_account_id]?.name - || t("transfers.linked")} + {/* Von Hand geschrieben und deshalb aussagekraeftiger als jeder + Banktext: „Amazon" kann Einrichtung sein, Werkzeug oder ein + Geschenk. */} + {tx.notes && ( +
+ {tx.notes}
)} + {/* Die fruehere Marke mit dem Gegenkonto ist entfallen: Das + Gegenkonto steht jetzt im Titel, wo man es sucht. */} {/* 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. */} @@ -657,6 +695,16 @@ export default function Transactions() { {t("transactions.description")} setForm({ ...form, description: e.target.value })} /> + {/* Die Kategorie sagt, in welche Schublade es gehoert. Die Notiz + sagt, WAS es war – „Amazon" kann Einrichtung sein, Werkzeug + oder ein Geschenk, und in drei Monaten weiss das niemand mehr. + Freier Text, weil sich das nicht in Schubladen fassen laesst. */} +