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>
385 lines
15 KiB
Python
385 lines
15 KiB
Python
"""Welche Buchung gehoert zu welchem Vertrag.
|
||
|
||
Die Zuordnung ist ein ETIKETT: An der Buchung aendert sich nichts. Die Tests
|
||
pruefen deshalb vor allem, was gleich bleiben MUSS – und wo das Programm
|
||
lieber fragt, statt zu raten.
|
||
"""
|
||
|
||
import secrets
|
||
from datetime import date, timedelta
|
||
|
||
|
||
def _konto(admin, name=None, currency="EUR"):
|
||
r = admin.post("/api/accounts", json={
|
||
"name": name or ("Konto " + secrets.token_hex(3)), "type": "girokonto",
|
||
"currency": currency, "opening_balance_cents": 0})
|
||
assert r.status_code == 201, r.text
|
||
return r.json()
|
||
|
||
|
||
def _abo(admin, **kw):
|
||
body = {"name": "Netflix " + secrets.token_hex(3), "amount_cents": 1990,
|
||
"currency": "EUR", "interval_months": 1}
|
||
body.update(kw)
|
||
r = admin.post("/api/subscriptions", json=body)
|
||
assert r.status_code == 201, r.text
|
||
return r.json()
|
||
|
||
|
||
def _buchung(admin, konto, betrag, datum, payee="", beschreibung=""):
|
||
r = admin.post("/api/transactions", json={
|
||
"type": "expense", "date": datum, "account_id": konto["id"],
|
||
"amount_cents": betrag, "payee": payee, "description": beschreibung})
|
||
assert r.status_code == 201, r.text
|
||
return r.json()
|
||
|
||
|
||
def _zuordnen(admin):
|
||
r = admin.post("/api/subscriptions/zuordnen")
|
||
assert r.status_code == 200, r.text
|
||
return r.json()
|
||
|
||
|
||
def _txn(admin, txn_id):
|
||
for t in admin.get("/api/transactions").json():
|
||
if t["id"] == txn_id:
|
||
return t
|
||
raise AssertionError("Buchung verschwunden")
|
||
|
||
|
||
def _aufraeumen(admin, abo=None, txns=()):
|
||
for t in txns:
|
||
admin.delete(f"/api/transactions/{t}")
|
||
if abo:
|
||
admin.delete(f"/api/subscriptions/{abo['id']}")
|
||
|
||
|
||
# --- Der Normalfall ----------------------------------------------------------
|
||
|
||
|
||
def test_abbuchung_findet_ihren_vertrag(admin):
|
||
konto = _konto(admin)
|
||
abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"],
|
||
next_due=str(date.today()))
|
||
b = _buchung(admin, konto, 1990, str(date.today()), payee="NETFLIX.COM 4711")
|
||
try:
|
||
assert _zuordnen(admin)["zugeordnet"] >= 1
|
||
|
||
verlauf = admin.get(f"/api/subscriptions/{abo['id']}/verlauf").json()
|
||
assert verlauf["anzahl"] == 1
|
||
assert verlauf["summe_cents"] == 1990
|
||
assert verlauf["buchungen"][0]["txn_id"] == b["id"]
|
||
finally:
|
||
_aufraeumen(admin, abo, [b["id"]])
|
||
|
||
|
||
def test_die_buchung_bleibt_unveraendert(admin):
|
||
"""Es ist ein Etikett. Betrag, Konto und Kategorie bleiben, wie sie sind."""
|
||
konto = _konto(admin)
|
||
abo = _abo(admin, name="Spotify", amount_cents=1299, account_id=konto["id"],
|
||
next_due=str(date.today()))
|
||
b = _buchung(admin, konto, 1299, str(date.today()), payee="SPOTIFY AB")
|
||
try:
|
||
_zuordnen(admin)
|
||
danach = _txn(admin, b["id"])
|
||
assert danach["amount_cents"] == 1299
|
||
assert danach["account_id"] == konto["id"]
|
||
assert danach["date"] == b["date"]
|
||
assert danach["type"] == "expense"
|
||
finally:
|
||
_aufraeumen(admin, abo, [b["id"]])
|
||
|
||
|
||
def test_faelligkeit_rueckt_nach_der_erkannten_abbuchung_weiter(admin):
|
||
konto = _konto(admin)
|
||
heute = date.today()
|
||
abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"],
|
||
next_due=str(heute), interval_months=1)
|
||
b = _buchung(admin, konto, 1990, str(heute), payee="NETFLIX")
|
||
try:
|
||
_zuordnen(admin)
|
||
danach = admin.get(f"/api/subscriptions/{abo['id']}").json()
|
||
assert danach["next_due"] is not None
|
||
assert danach["next_due"] > str(heute), "Faelligkeit blieb stehen"
|
||
finally:
|
||
_aufraeumen(admin, abo, [b["id"]])
|
||
|
||
|
||
def test_jede_abbuchung_wird_einzeln_erkannt(admin):
|
||
"""Drei Monate nachgetragen ergeben drei Zeilen im Verlauf, nicht eine."""
|
||
konto = _konto(admin)
|
||
heute = date.today()
|
||
abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"],
|
||
next_due=str(heute), interval_months=1)
|
||
tage = [heute - timedelta(days=62), heute - timedelta(days=31), heute]
|
||
ids = [_buchung(admin, konto, 1990, str(t), payee="NETFLIX")["id"] for t in tage]
|
||
try:
|
||
_zuordnen(admin)
|
||
verlauf = admin.get(f"/api/subscriptions/{abo['id']}/verlauf").json()
|
||
assert verlauf["anzahl"] == 3, verlauf
|
||
assert verlauf["summe_cents"] == 3 * 1990
|
||
finally:
|
||
_aufraeumen(admin, abo, ids)
|
||
|
||
|
||
# --- Wo das Programm lieber fragt --------------------------------------------
|
||
|
||
|
||
def test_fremder_betrag_wird_nicht_zugeordnet(admin):
|
||
"""Lieber nichts sagen als daneben raten."""
|
||
konto = _konto(admin)
|
||
abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"],
|
||
next_due=str(date.today()))
|
||
b = _buchung(admin, konto, 7350, str(date.today()), payee="BAUMARKT")
|
||
try:
|
||
_zuordnen(admin)
|
||
assert _txn(admin, b["id"]).get("subscription_id") in (None, 0)
|
||
assert admin.get(f"/api/subscriptions/{abo['id']}/verlauf").json()["anzahl"] == 0
|
||
finally:
|
||
_aufraeumen(admin, abo, [b["id"]])
|
||
|
||
|
||
def test_richtiger_betrag_fremder_text_kommt_als_vorschlag(admin):
|
||
"""Auf dem Auszug steht oft der Abwickler, nicht die Marke. Fragen statt
|
||
raten – aber die Frage muss gestellt werden."""
|
||
konto = _konto(admin)
|
||
abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"],
|
||
next_due=str(date.today()))
|
||
b = _buchung(admin, konto, 1990, str(date.today()), payee="STRIPE PAYMENTS")
|
||
try:
|
||
antwort = _zuordnen(admin)
|
||
assert _txn(admin, b["id"]).get("subscription_id") in (None, 0), \
|
||
"ungefragt zugeordnet"
|
||
passend = [v for v in antwort["vorschlaege"] if v["txn_id"] == b["id"]]
|
||
assert passend, antwort["vorschlaege"]
|
||
assert passend[0]["subscription_id"] == abo["id"]
|
||
finally:
|
||
_aufraeumen(admin, abo, [b["id"]])
|
||
|
||
|
||
def test_buchung_auf_fremdem_konto_zaehlt_nicht(admin):
|
||
"""Der Vertrag sagt, ueber welches Konto er laeuft – das gilt."""
|
||
konto = _konto(admin)
|
||
anderes = _konto(admin)
|
||
abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"],
|
||
next_due=str(date.today()))
|
||
b = _buchung(admin, anderes, 1990, str(date.today()), payee="NETFLIX")
|
||
try:
|
||
_zuordnen(admin)
|
||
assert _txn(admin, b["id"]).get("subscription_id") in (None, 0)
|
||
finally:
|
||
_aufraeumen(admin, abo, [b["id"]])
|
||
|
||
|
||
def test_gekuendigte_vertraege_ziehen_nichts_mehr_an(admin):
|
||
konto = _konto(admin)
|
||
abo = _abo(admin, name="Altes Abo", amount_cents=1990, account_id=konto["id"],
|
||
next_due=str(date.today()), is_active=False)
|
||
b = _buchung(admin, konto, 1990, str(date.today()), payee="ALTES ABO")
|
||
try:
|
||
_zuordnen(admin)
|
||
assert _txn(admin, b["id"]).get("subscription_id") in (None, 0)
|
||
finally:
|
||
_aufraeumen(admin, abo, [b["id"]])
|
||
|
||
|
||
# --- Die Preiserhoehung ------------------------------------------------------
|
||
|
||
|
||
def test_preiserhoehung_wird_gemeldet_statt_verbucht(admin):
|
||
"""Der wertvollste Fall – und einer, den man nie automatisch nachziehen
|
||
darf. Eine Preiserhoehung ist eine Nachricht an den Nutzer."""
|
||
konto = _konto(admin)
|
||
abo = _abo(admin, name="Netflix", amount_cents=1790, account_id=konto["id"],
|
||
next_due=str(date.today()))
|
||
b = _buchung(admin, konto, 1990, str(date.today()), payee="NETFLIX.COM")
|
||
try:
|
||
antwort = _zuordnen(admin)
|
||
meldungen = [p for p in antwort["preisaenderungen"] if p["txn_id"] == b["id"]]
|
||
assert meldungen, antwort
|
||
assert meldungen[0]["erwartet_cents"] == 1790
|
||
assert meldungen[0]["amount_cents"] == 1990
|
||
|
||
# Ungefragt geaendert hat sich nichts.
|
||
assert admin.get(f"/api/subscriptions/{abo['id']}").json()["amount_cents"] == 1790
|
||
assert _txn(admin, b["id"]).get("subscription_id") in (None, 0)
|
||
finally:
|
||
_aufraeumen(admin, abo, [b["id"]])
|
||
|
||
|
||
def test_neuen_preis_uebernehmen_und_zuordnen(admin):
|
||
"""Der Weg, den der Nutzer danach geht."""
|
||
konto = _konto(admin)
|
||
abo = _abo(admin, name="Netflix", amount_cents=1790, account_id=konto["id"],
|
||
next_due=str(date.today()))
|
||
b = _buchung(admin, konto, 1990, str(date.today()), payee="NETFLIX.COM")
|
||
try:
|
||
r = admin.post(f"/api/subscriptions/{abo['id']}/preis",
|
||
params={"amount_cents": 1990})
|
||
assert r.status_code == 200, r.text
|
||
assert r.json()["amount_cents"] == 1990
|
||
|
||
r = admin.post(f"/api/subscriptions/{abo['id']}/buchungen/{b['id']}")
|
||
assert r.status_code == 200, r.text
|
||
assert admin.get(f"/api/subscriptions/{abo['id']}/verlauf").json()["anzahl"] == 1
|
||
finally:
|
||
_aufraeumen(admin, abo, [b["id"]])
|
||
|
||
|
||
def test_ein_vielfaches_gilt_nicht_als_preisaenderung(admin):
|
||
"""Ein Abo, das sich verdreifacht, ist kein Abo mehr, sondern etwas
|
||
anderes – sonst zieht jeder groessere Einkauf beim selben Haendler an."""
|
||
konto = _konto(admin)
|
||
abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"],
|
||
next_due=str(date.today()))
|
||
b = _buchung(admin, konto, 19900, str(date.today()), payee="NETFLIX.COM")
|
||
try:
|
||
antwort = _zuordnen(admin)
|
||
assert not [p for p in antwort["preisaenderungen"] if p["txn_id"] == b["id"]]
|
||
assert not [v for v in antwort["vorschlaege"] if v["txn_id"] == b["id"]]
|
||
finally:
|
||
_aufraeumen(admin, abo, [b["id"]])
|
||
|
||
|
||
# --- Das Etikett wieder abziehen ---------------------------------------------
|
||
|
||
|
||
def test_zuordnung_laesst_sich_loesen_und_die_buchung_bleibt(admin):
|
||
konto = _konto(admin)
|
||
abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"],
|
||
next_due=str(date.today()))
|
||
b = _buchung(admin, konto, 1990, str(date.today()), payee="NETFLIX")
|
||
try:
|
||
_zuordnen(admin)
|
||
assert admin.get(f"/api/subscriptions/{abo['id']}/verlauf").json()["anzahl"] == 1
|
||
|
||
r = admin.delete(f"/api/subscriptions/buchungen/{b['id']}")
|
||
assert r.status_code == 204, r.text
|
||
assert admin.get(f"/api/subscriptions/{abo['id']}/verlauf").json()["anzahl"] == 0
|
||
# Die Buchung selbst steht unveraendert da.
|
||
assert _txn(admin, b["id"])["amount_cents"] == 1990
|
||
finally:
|
||
_aufraeumen(admin, abo, [b["id"]])
|
||
|
||
|
||
def test_geloeschter_vertrag_nimmt_die_buchung_nicht_mit(admin):
|
||
"""Wer ein Abo kuendigt und loescht, will nicht seine Zahlungshistorie
|
||
mitloeschen."""
|
||
konto = _konto(admin)
|
||
abo = _abo(admin, name="Kurzlebig", amount_cents=1990, account_id=konto["id"],
|
||
next_due=str(date.today()))
|
||
b = _buchung(admin, konto, 1990, str(date.today()), payee="KURZLEBIG")
|
||
try:
|
||
_zuordnen(admin)
|
||
assert admin.delete(f"/api/subscriptions/{abo['id']}").status_code == 204
|
||
danach = _txn(admin, b["id"])
|
||
assert danach["amount_cents"] == 1990, "Buchung mitgeloescht"
|
||
finally:
|
||
admin.delete(f"/api/transactions/{b['id']}")
|
||
|
||
|
||
def test_vorschlaege_veraendern_nichts(admin):
|
||
"""Eine Ansicht, die bloss hinsieht, darf nichts zuordnen."""
|
||
konto = _konto(admin)
|
||
abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"],
|
||
next_due=str(date.today()))
|
||
b = _buchung(admin, konto, 1990, str(date.today()), payee="NETFLIX")
|
||
try:
|
||
r = admin.get("/api/subscriptions/vorschlaege")
|
||
assert r.status_code == 200, r.text
|
||
assert r.json()["zugeordnet"] == 0
|
||
assert _txn(admin, b["id"]).get("subscription_id") in (None, 0)
|
||
finally:
|
||
_aufraeumen(admin, abo, [b["id"]])
|
||
|
||
|
||
def test_zuordnung_ueberlebt_sichern_und_einspielen(admin):
|
||
"""Ohne den Verweis in der Sicherung zeigte das Etikett danach auf einen
|
||
FREMDEN Vertrag – die Nummern werden beim Einspielen neu vergeben."""
|
||
import json
|
||
|
||
konto = _konto(admin)
|
||
name = "Backup-Abo " + secrets.token_hex(3)
|
||
abo = _abo(admin, name=name, amount_cents=1990, account_id=konto["id"],
|
||
next_due=str(date.today()))
|
||
b = _buchung(admin, konto, 1990, str(date.today()), payee=name)
|
||
try:
|
||
_zuordnen(admin)
|
||
assert _txn(admin, b["id"])["subscription_id"] == abo["id"]
|
||
|
||
roh = admin.get("/api/export/backup.json").content
|
||
haufen = json.loads(roh)
|
||
haufen = haufen.get("data", haufen)
|
||
# Der Verweis muss ueberhaupt in der Sicherung stehen.
|
||
gesichert = [t for t in haufen["transactions"]
|
||
if t.get("subscription_id") is not None]
|
||
assert gesichert, "kein einziges Etikett gesichert"
|
||
|
||
r = admin.post("/api/backup/restore",
|
||
files={"file": ("backup.json", roh, "application/json")},
|
||
data={"mode": "replace"})
|
||
assert r.status_code == 200, r.text
|
||
|
||
# Nach dem Einspielen sind die Nummern andere – die Zuordnung muss
|
||
# trotzdem auf DENSELBEN Vertrag zeigen.
|
||
abos = admin.get("/api/subscriptions").json()["subscriptions"]
|
||
wieder = next(s for s in abos if s["name"] == name)
|
||
verlauf = admin.get(f"/api/subscriptions/{wieder['id']}/verlauf").json()
|
||
assert verlauf["anzahl"] == 1, "Zuordnung nach dem Einspielen verloren"
|
||
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"],))
|