feat: Buchungen lassen sich von Hand einem Abo zuordnen – und das merkt sich
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
493a98ab6b
commit
0ea2ab1322
@@ -34,6 +34,7 @@ from ..schemas import (
|
|||||||
SubscriptionUpdate,
|
SubscriptionUpdate,
|
||||||
)
|
)
|
||||||
from ..services import abo_erkennung, currency, logo as logo_service
|
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
|
from .settings import BASE_CURRENCY_KEY, get_user_setting
|
||||||
|
|
||||||
router = APIRouter(prefix="/subscriptions", tags=["subscriptions"])
|
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
|
txn.subscription_id = s.id
|
||||||
if txn.date is not None:
|
if txn.date is not None:
|
||||||
abo_erkennung._ruecke_faelligkeit(s, txn.date)
|
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.commit()
|
||||||
db.refresh(s)
|
db.refresh(s)
|
||||||
return to_out(db, s, _base(db, user), date.today())
|
return to_out(db, s, _base(db, user), date.today())
|
||||||
|
|||||||
@@ -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
|
am Stück vor. Kurze Wörter bleiben draussen; „SA" oder „AG" trifft sonst
|
||||||
die halbe Kontoauszug.
|
die halbe Kontoauszug.
|
||||||
"""
|
"""
|
||||||
muster = normalize_text(vertrag.payee_pattern or vertrag.name)
|
# Beides pruefen, nicht entweder/oder: Der gelernte Text (payee_pattern)
|
||||||
if not muster:
|
# kommt beim Zuordnen von Hand dazu und soll den Namen nicht verdraengen.
|
||||||
return False
|
for roh in (vertrag.payee_pattern, vertrag.name):
|
||||||
if muster in text:
|
muster = normalize_text(roh)
|
||||||
return True
|
if not muster:
|
||||||
woerter = [w for w in muster.split() if len(w) >= 4]
|
continue
|
||||||
return bool(woerter) and any(w in text for w in woerter)
|
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,
|
def _erwartet_in(db: Session, vertrag: Subscription, konto: Account,
|
||||||
|
|||||||
@@ -329,3 +329,56 @@ def test_zuordnung_ueberlebt_sichern_und_einspielen(admin):
|
|||||||
assert verlauf["summe_cents"] == 1990
|
assert verlauf["summe_cents"] == 1990
|
||||||
finally:
|
finally:
|
||||||
pass
|
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"],))
|
||||||
|
|||||||
@@ -286,6 +286,12 @@ export const api = {
|
|||||||
createSubscription: (body) => request("/subscriptions", { method: "POST", body }),
|
createSubscription: (body) => request("/subscriptions", { method: "POST", body }),
|
||||||
updateSubscription: (id, body) => request(`/subscriptions/${id}`, { method: "PATCH", body }),
|
updateSubscription: (id, body) => request(`/subscriptions/${id}`, { method: "PATCH", body }),
|
||||||
deleteSubscription: (id) => request(`/subscriptions/${id}`, { method: "DELETE" }),
|
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.
|
// Das Logo geht als Datei raus, nicht als JSON – deshalb FormData statt body.
|
||||||
uploadSubscriptionLogo: (id, datei) => {
|
uploadSubscriptionLogo: (id, datei) => {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
|
|||||||
@@ -357,6 +357,8 @@
|
|||||||
},
|
},
|
||||||
"abos": {
|
"abos": {
|
||||||
"title": "Abos & Verträge",
|
"title": "Abos & Verträge",
|
||||||
|
"noneAssigned": "— kein Abo —",
|
||||||
|
"column": "Abo",
|
||||||
"new": "Neuer Vertrag",
|
"new": "Neuer Vertrag",
|
||||||
"edit": "Vertrag bearbeiten",
|
"edit": "Vertrag bearbeiten",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
|
|||||||
@@ -357,6 +357,8 @@
|
|||||||
},
|
},
|
||||||
"abos": {
|
"abos": {
|
||||||
"title": "Subscriptions & contracts",
|
"title": "Subscriptions & contracts",
|
||||||
|
"noneAssigned": "— no subscription —",
|
||||||
|
"column": "Subscription",
|
||||||
"new": "New contract",
|
"new": "New contract",
|
||||||
"edit": "Edit contract",
|
"edit": "Edit contract",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export default function Transactions() {
|
|||||||
const [links, setLinks] = useState({});
|
const [links, setLinks] = useState({});
|
||||||
// Und je Buchungs-ID die Gegenseite, die es sein KÖNNTE – noch unbestätigt.
|
// Und je Buchungs-ID die Gegenseite, die es sein KÖNNTE – noch unbestätigt.
|
||||||
const [vorschlag, setVorschlag] = useState({});
|
const [vorschlag, setVorschlag] = useState({});
|
||||||
|
const [abos, setAbos] = useState([]);
|
||||||
// Buchung, die gerade zum Übertrag gemacht wird.
|
// Buchung, die gerade zum Übertrag gemacht wird.
|
||||||
const [uebertrag, setUebertrag] = useState(null);
|
const [uebertrag, setUebertrag] = useState(null);
|
||||||
const [accounts, setAccounts] = useState([]);
|
const [accounts, setAccounts] = useState([]);
|
||||||
@@ -79,7 +80,7 @@ export default function Transactions() {
|
|||||||
try {
|
try {
|
||||||
// Verknüpfungen getrennt: Sie sind eine Schicht über den Buchungen,
|
// Verknüpfungen getrennt: Sie sind eine Schicht über den Buchungen,
|
||||||
// kein Feld an ihnen – deshalb auch ein eigener Abruf.
|
// 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.listTransactions({ account_id: filter.account_id, type: filter.type }),
|
||||||
api.transactionLinks().catch(() => ({})),
|
api.transactionLinks().catch(() => ({})),
|
||||||
// Nur fragen, nichts tun: Beim Anschauen der Liste darf nichts
|
// 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");
|
toast(t("transfers.suggestionsFailed", { fehler: e.message }), "error");
|
||||||
return { vorschlaege: [] };
|
return { vorschlaege: [] };
|
||||||
}),
|
}),
|
||||||
|
api.listSubscriptions().catch(() => ({ subscriptions: [] })),
|
||||||
]);
|
]);
|
||||||
setTxns(liste);
|
setTxns(liste);
|
||||||
setLinks(verkn);
|
setLinks(verkn);
|
||||||
|
setAbos(aboListe.subscriptions || []);
|
||||||
const paare = {};
|
const paare = {};
|
||||||
for (const v of vorschlaege.vorschlaege || []) {
|
for (const v of vorschlaege.vorschlaege || []) {
|
||||||
paare[v.a.id] = v.b;
|
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) {
|
async function loesen(tx) {
|
||||||
const link = links[String(tx.id)];
|
const link = links[String(tx.id)];
|
||||||
if (!link) return;
|
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)] ? (
|
||||||
|
<span className="muted">—</span>
|
||||||
|
) : (
|
||||||
|
<select value={tx.subscription_id || ""}
|
||||||
|
onChange={(e) => aboSetzen(tx, e.target.value ? Number(e.target.value) : null)}>
|
||||||
|
<option value="">{t("abos.noneAssigned")}</option>
|
||||||
|
{abos.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "account", header: t("transactions.account"), width: 130,
|
key: "account", header: t("transactions.account"), width: 130,
|
||||||
filterText: (tx) => accountById[tx.account_id]?.name || "",
|
filterText: (tx) => accountById[tx.account_id]?.name || "",
|
||||||
@@ -532,7 +566,7 @@ export default function Transactions() {
|
|||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
], [accountById, categoryById, baseCurrency, expenseGroups, incomeGroups,
|
], [accountById, categoryById, baseCurrency, expenseGroups, incomeGroups,
|
||||||
formatDate, links, paarZu, vorschlag, t]);
|
formatDate, links, paarZu, vorschlag, abos, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user