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 (
{t("transfers.ignoredHint")}
+ {abgelehnt.map((paar) => ( +