From 0ea2ab1322705dd9a1cf27bda6f0130e9087341b Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 15 Oct 2026 09:59:55 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Buchungen=20lassen=20sich=20von=20Hand?= =?UTF-8?q?=20einem=20Abo=20zuordnen=20=E2=80=93=20und=20das=20merkt=20sic?= =?UTF-8?q?h?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Erkennung lief seit Stufe 3, aber nur automatisch: Was sie nicht sicher traf, blieb liegen, und sehen konnte man davon nichts. Die Endpunkte gab es, die Oberflaeche kannte keinen davon. Jetzt steht in der Buchungsliste eine Spalte "Abo" – dieselbe Form wie die Kategorie daneben: Die Auswahl zeigt, zu welchem Vertrag die Zahlung gehoert, aendert es, und leer loest die Zuordnung. Uebertraege bleiben aussen vor; sie verschieben nur Geld. Und sie lernt. Musste man von Hand ran, hat der Erkennungstext nicht gepasst – also wird der Empfaenger dieser Buchung der neue. Beim naechsten Mal faellt dieselbe Zahlung von selbst zu, statt dass man jeden Monat dasselbe klickt. Der Vertragsname bleibt als zweite Chance: _passt_text prueft beide, nicht entweder/oder, sonst verlaere man beim Lernen, was vorher schon getroffen hat. Co-Authored-By: Claude Opus 5 --- backend/app/routers/subscriptions.py | 11 ++++++ backend/app/services/abo_erkennung.py | 19 ++++++---- backend/tests/test_abo_erkennung.py | 53 +++++++++++++++++++++++++++ web/src/api.js | 6 +++ web/src/locales/de.json | 2 + web/src/locales/en.json | 2 + web/src/pages/Transactions.jsx | 38 ++++++++++++++++++- 7 files changed, 122 insertions(+), 9 deletions(-) diff --git a/backend/app/routers/subscriptions.py b/backend/app/routers/subscriptions.py index 4b31bf1..d36e7db 100644 --- a/backend/app/routers/subscriptions.py +++ b/backend/app/routers/subscriptions.py @@ -34,6 +34,7 @@ from ..schemas import ( SubscriptionUpdate, ) from ..services import abo_erkennung, currency, logo as logo_service +from ..services.csv_import import normalize_text from .settings import BASE_CURRENCY_KEY, get_user_setting router = APIRouter(prefix="/subscriptions", tags=["subscriptions"]) @@ -186,6 +187,16 @@ def zuordnen_von_hand(sub_id: int, txn_id: int, db: Session = Depends(get_db), txn.subscription_id = s.id if txn.date is not None: abo_erkennung._ruecke_faelligkeit(s, txn.date) + # Und daraus lernen: Musste der Nutzer von Hand zuordnen, hat der + # Erkennungstext nicht gepasst. Der Empfaenger dieser Buchung wird der + # neue – beim naechsten Mal faellt dieselbe Zahlung von selbst zu. Der + # Name bleibt als zweite Chance (siehe _passt_text). + # + # shortcut: ersetzt einen frueher gelernten Text, statt mehrere zu fuehren. + # Taucht ein Vertrag unter mehreren Empfaengernamen auf, braucht es eine + # Liste wie bei den Konto-Kennungen. + if txn.payee and not abo_erkennung._passt_text(s, normalize_text(txn.payee)): + s.payee_pattern = txn.payee[:255] db.commit() db.refresh(s) return to_out(db, s, _base(db, user), date.today()) diff --git a/backend/app/services/abo_erkennung.py b/backend/app/services/abo_erkennung.py index 9d4661c..839123c 100644 --- a/backend/app/services/abo_erkennung.py +++ b/backend/app/services/abo_erkennung.py @@ -77,13 +77,18 @@ def _passt_text(vertrag: Subscription, text: str) -> bool: am Stück vor. Kurze Wörter bleiben draussen; „SA" oder „AG" trifft sonst die halbe Kontoauszug. """ - muster = normalize_text(vertrag.payee_pattern or vertrag.name) - if not muster: - return False - if muster in text: - return True - woerter = [w for w in muster.split() if len(w) >= 4] - return bool(woerter) and any(w in text for w in woerter) + # Beides pruefen, nicht entweder/oder: Der gelernte Text (payee_pattern) + # kommt beim Zuordnen von Hand dazu und soll den Namen nicht verdraengen. + for roh in (vertrag.payee_pattern, vertrag.name): + muster = normalize_text(roh) + if not muster: + continue + if muster in text: + return True + woerter = [w for w in muster.split() if len(w) >= 4] + if woerter and any(w in text for w in woerter): + return True + return False def _erwartet_in(db: Session, vertrag: Subscription, konto: Account, diff --git a/backend/tests/test_abo_erkennung.py b/backend/tests/test_abo_erkennung.py index 24efcab..4aeb0e1 100644 --- a/backend/tests/test_abo_erkennung.py +++ b/backend/tests/test_abo_erkennung.py @@ -329,3 +329,56 @@ def test_zuordnung_ueberlebt_sichern_und_einspielen(admin): assert verlauf["summe_cents"] == 1990 finally: pass + + +# --- Zuordnen von Hand lehrt den Vertrag ------------------------------------ + + +def test_haendische_zuordnung_lernt_den_empfaenger(admin): + """Musste man von Hand ran, hat der Erkennungstext nicht gepasst. + + Beim naechsten Mal soll dieselbe Zahlung von selbst fallen – sonst klickt + man jeden Monat dasselbe. + """ + konto = _konto(admin) + abo = _abo(admin, name="Fitness " + secrets.token_hex(3), amount_cents=4990, + account_id=konto["id"]) + # Der Auszug nennt den Anbieter ganz anders als der Vertrag heisst. + t1 = _buchung(admin, konto, 4990, "2026-06-05", payee="ACTIV GYM ZUERICH 8004") + t2 = _buchung(admin, konto, 4990, "2026-07-05", payee="ACTIV GYM ZUERICH 8004") + try: + r = admin.post(f"/api/subscriptions/{abo['id']}/buchungen/{t1['id']}") + assert r.status_code == 200, r.text + assert r.json()["payee_pattern"] == "ACTIV GYM ZUERICH 8004" + + _zuordnen(admin) + assert _txn(admin, t2["id"])["subscription_id"] == abo["id"] + finally: + _aufraeumen(admin, abo, (t1["id"], t2["id"])) + + +def test_lernen_verdraengt_den_namen_nicht(admin): + """Der Name bleibt als zweite Chance – sonst verliert man beim Lernen, + was vorher schon getroffen hat.""" + konto = _konto(admin) + abo = _abo(admin, name="Spotify", amount_cents=1295, account_id=konto["id"]) + fremd = _buchung(admin, konto, 1295, "2026-06-07", payee="DIGITAL RIVER GMBH") + klassisch = _buchung(admin, konto, 1295, "2026-07-07", payee="SPOTIFY AB") + try: + admin.post(f"/api/subscriptions/{abo['id']}/buchungen/{fremd['id']}") + _zuordnen(admin) + assert _txn(admin, klassisch["id"])["subscription_id"] == abo["id"] + finally: + _aufraeumen(admin, abo, (fremd["id"], klassisch["id"])) + + +def test_passende_zuordnung_aendert_den_erkennungstext_nicht(admin): + """Hat der Text schon gepasst, gibt es nichts zu lernen.""" + konto = _konto(admin) + abo = _abo(admin, name="Netflix", amount_cents=1799, account_id=konto["id"]) + t = _buchung(admin, konto, 1799, "2026-06-09", payee="NETFLIX.COM 4711") + try: + r = admin.post(f"/api/subscriptions/{abo['id']}/buchungen/{t['id']}") + assert r.json()["payee_pattern"] == "Netflix" + finally: + _aufraeumen(admin, abo, (t["id"],)) diff --git a/web/src/api.js b/web/src/api.js index ef2f86e..2ca6d1e 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -286,6 +286,12 @@ export const api = { createSubscription: (body) => request("/subscriptions", { method: "POST", body }), updateSubscription: (id, body) => request(`/subscriptions/${id}`, { method: "PATCH", body }), deleteSubscription: (id) => request(`/subscriptions/${id}`, { method: "DELETE" }), + // Zuordnung Buchung -> Vertrag. Beim Zuordnen von Hand lernt der Vertrag + // den Empfaengertext der Buchung. + assignSubscription: (subId, txnId) => + request(`/subscriptions/${subId}/buchungen/${txnId}`, { method: "POST" }), + unassignSubscription: (txnId) => + request(`/subscriptions/buchungen/${txnId}`, { method: "DELETE" }), // Das Logo geht als Datei raus, nicht als JSON – deshalb FormData statt body. uploadSubscriptionLogo: (id, datei) => { const fd = new FormData(); diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 815d9ae..aa63b7c 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -357,6 +357,8 @@ }, "abos": { "title": "Abos & Verträge", + "noneAssigned": "— kein Abo —", + "column": "Abo", "new": "Neuer Vertrag", "edit": "Vertrag bearbeiten", "delete": "Löschen", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index e7c1216..c720640 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -357,6 +357,8 @@ }, "abos": { "title": "Subscriptions & contracts", + "noneAssigned": "— no subscription —", + "column": "Subscription", "new": "New contract", "edit": "Edit contract", "delete": "Delete", diff --git a/web/src/pages/Transactions.jsx b/web/src/pages/Transactions.jsx index 0949e52..7f5ca9c 100644 --- a/web/src/pages/Transactions.jsx +++ b/web/src/pages/Transactions.jsx @@ -50,6 +50,7 @@ export default function Transactions() { const [links, setLinks] = useState({}); // Und je Buchungs-ID die Gegenseite, die es sein KÖNNTE – noch unbestätigt. const [vorschlag, setVorschlag] = useState({}); + const [abos, setAbos] = useState([]); // Buchung, die gerade zum Übertrag gemacht wird. const [uebertrag, setUebertrag] = useState(null); const [accounts, setAccounts] = useState([]); @@ -79,7 +80,7 @@ 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, vorschlaege] = await Promise.all([ + const [liste, verkn, vorschlaege, aboListe] = 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 @@ -90,9 +91,11 @@ export default function Transactions() { toast(t("transfers.suggestionsFailed", { fehler: e.message }), "error"); return { vorschlaege: [] }; }), + api.listSubscriptions().catch(() => ({ subscriptions: [] })), ]); setTxns(liste); setLinks(verkn); + setAbos(aboListe.subscriptions || []); const paare = {}; for (const v of vorschlaege.vorschlaege || []) { paare[v.a.id] = v.b; @@ -139,6 +142,19 @@ export default function Transactions() { } } + // Zuordnen heisst zugleich: lernen. Musste man von Hand ran, hat der + // Erkennungstext des Vertrags nicht gepasst; der Server uebernimmt den + // Empfaenger dieser Buchung, damit es beim naechsten Mal von selbst faellt. + async function aboSetzen(tx, subId) { + try { + if (subId) await api.assignSubscription(subId, tx.id); + else await api.unassignSubscription(tx.id); + ladenBuchungen(); + } catch (err) { + toast(err.message, "error"); + } + } + async function loesen(tx) { const link = links[String(tx.id)]; if (!link) return; @@ -455,6 +471,24 @@ export default function Transactions() { ) ), }, + { + // Welcher Vertrag diese Zahlung ist. Dieselbe Form wie die Kategorie + // daneben: Die Auswahl zeigt den Stand und aendert ihn, leer loest. + key: "abo", header: t("abos.column"), width: 160, + filterText: (tx) => abos.find((a) => a.id === tx.subscription_id)?.name || "", + render: (tx) => ( + // Ein Uebertrag ist keine Abo-Zahlung – er verschiebt nur Geld. + tx.type === "transfer" || links[String(tx.id)] ? ( + — + ) : ( + + ) + ), + }, { key: "account", header: t("transactions.account"), width: 130, filterText: (tx) => accountById[tx.account_id]?.name || "", @@ -532,7 +566,7 @@ export default function Transactions() { }, // eslint-disable-next-line react-hooks/exhaustive-deps ], [accountById, categoryById, baseCurrency, expenseGroups, incomeGroups, - formatDate, links, paarZu, vorschlag, t]); + formatDate, links, paarZu, vorschlag, abos, t]); return (