Jedes Paar steht in der Kandidatenliste zweimal, einmal je Richtung. Was verknuepft wird, faellt ueber "verbraucht" heraus - ein blosser Vorschlag aber nicht. Also bot /uebertraege/vorschlaege dieselbe Zahlung zweimal zum Verbinden an, und wer das zweite anklickte, bekam "bereits verknuepft" fuer etwas, das er gerade selbst verknuepft hatte. Betrifft auch die Weboberflaeche: Uebertragsvorschlaege.jsx zeigt die Liste unveraendert an und hatte denselben Doppeleintrag. Gefunden beim Bauen der Arbeitsliste fuer die iOS-App - die Pruefdatei brachte den Vorschlag doppelt zurueck. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
384 lines
16 KiB
Python
384 lines
16 KiB
Python
"""Überträge zwischen eigenen Konten als Verknüpfung erkennen.
|
||
|
||
Der Kern dieses Entwurfs ist, dass NICHTS verändert wird: Beide Buchungen
|
||
bleiben stehen, mit Betrag, Konto, Kategorie und Import-Schlüssel. Die Tests
|
||
prüfen deshalb vor allem, was alles gleich bleiben MUSS.
|
||
"""
|
||
|
||
import io
|
||
import json
|
||
import secrets
|
||
|
||
|
||
def _konto(admin, name, currency="CHF", opening=0, identifiers=None):
|
||
body = {"name": name, "type": "girokonto", "currency": currency,
|
||
"opening_balance_cents": opening}
|
||
if identifiers:
|
||
body["identifiers"] = identifiers
|
||
r = admin.post("/api/accounts", json=body)
|
||
assert r.status_code == 201, r.text
|
||
return r.json()
|
||
|
||
|
||
def _buchung(admin, konto, typ, betrag, datum, payee="", category_id=None):
|
||
body = {"type": typ, "date": datum, "account_id": konto["id"],
|
||
"amount_cents": betrag, "payee": payee}
|
||
if category_id:
|
||
body["category_id"] = category_id
|
||
r = admin.post("/api/transactions", json=body)
|
||
assert r.status_code == 201, r.text
|
||
return r.json()
|
||
|
||
|
||
def _saldo(admin, konto):
|
||
return admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"]
|
||
|
||
|
||
def _suchen(admin):
|
||
r = admin.post("/api/uebertraege/suchen")
|
||
assert r.status_code == 200, r.text
|
||
return r.json()
|
||
|
||
|
||
def _links_von(admin, *konten):
|
||
"""Verknüpfungen, die zu den Konten DIESES Tests gehören.
|
||
|
||
Die Testdatenbank lebt über den ganzen Lauf, und die Suche läuft über alle
|
||
Buchungen des Nutzers. Absolut zu zählen hiesse, die Nachbartests
|
||
mitzuzählen."""
|
||
ids = {k["id"] for k in konten}
|
||
buchungen = {t["id"]: t for t in admin.get("/api/transactions").json()}
|
||
treffer = []
|
||
for l in admin.get("/api/uebertraege").json():
|
||
seiten = [buchungen.get(l["from_txn_id"]), buchungen.get(l["to_txn_id"])]
|
||
if all(s and s["account_id"] in ids for s in seiten):
|
||
treffer.append(l)
|
||
return treffer
|
||
|
||
|
||
def _kategorie(admin, name="Lebensmittel"):
|
||
for g in admin.get("/api/categories").json():
|
||
for c in g["categories"]:
|
||
if c["name"] == name:
|
||
return c["id"]
|
||
raise AssertionError(f"{name} fehlt")
|
||
|
||
|
||
# --- Was gleich bleiben muss -------------------------------------------------
|
||
|
||
|
||
def test_salden_bleiben_unveraendert(admin):
|
||
"""Der Kern des Entwurfs: Verknüpfen bewegt kein Geld."""
|
||
a = _konto(admin, "Von " + secrets.token_hex(3), opening=100000)
|
||
b = _konto(admin, "Nach " + secrets.token_hex(3))
|
||
_buchung(admin, a, "expense", 25000, "2026-04-01", "Übertrag auf Sparkonto")
|
||
_buchung(admin, b, "income", 25000, "2026-04-01", "Übertrag")
|
||
|
||
vorher = (_saldo(admin, a), _saldo(admin, b))
|
||
_suchen(admin)
|
||
assert len(_links_von(admin, a, b)) == 1
|
||
assert (_saldo(admin, a), _saldo(admin, b)) == vorher
|
||
|
||
# Und nach dem Lösen ebenfalls.
|
||
link = admin.get("/api/uebertraege").json()[0]
|
||
assert admin.delete(f"/api/uebertraege/{link['id']}").status_code == 204
|
||
assert (_saldo(admin, a), _saldo(admin, b)) == vorher
|
||
|
||
|
||
def test_buchungen_werden_nicht_angefasst(admin):
|
||
"""Kategorie, Betrag, Konto und Text bleiben, wie sie waren."""
|
||
leb = _kategorie(admin)
|
||
a = _konto(admin, "Quelle " + secrets.token_hex(3), opening=50000)
|
||
b = _konto(admin, "Ziel " + secrets.token_hex(3))
|
||
aus = _buchung(admin, a, "expense", 7000, "2026-04-02", "Umbuchung", category_id=leb)
|
||
ein = _buchung(admin, b, "income", 7000, "2026-04-02", "Umbuchung")
|
||
|
||
admin.post("/api/uebertraege/verbinden",
|
||
json={"a_id": aus["id"], "b_id": ein["id"]})
|
||
|
||
danach = {t["id"]: t for t in admin.get("/api/transactions").json()}
|
||
assert danach[aus["id"]]["category_id"] == leb
|
||
assert danach[aus["id"]]["amount_cents"] == 7000
|
||
assert danach[aus["id"]]["account_id"] == a["id"]
|
||
assert danach[aus["id"]]["type"] == "expense"
|
||
assert danach[ein["id"]]["type"] == "income"
|
||
|
||
|
||
def test_kategorie_kommt_beim_loesen_zurueck(admin):
|
||
"""Verknüpfte Buchungen werden übersprungen, nicht umkategorisiert."""
|
||
from datetime import date
|
||
|
||
heute = date.today().isoformat() # muss im laufenden Budget-Monat liegen
|
||
leb = _kategorie(admin)
|
||
a = _konto(admin, "BudgetQuelle " + secrets.token_hex(3), opening=90000)
|
||
b = _konto(admin, "BudgetZiel " + secrets.token_hex(3))
|
||
aus = _buchung(admin, a, "expense", 3300, heute, "Übertrag", category_id=leb)
|
||
ein = _buchung(admin, b, "income", 3300, heute, "Übertrag")
|
||
|
||
def budget_summe():
|
||
r = admin.get("/api/dashboard/income-expense").json()
|
||
return r["expense"]
|
||
|
||
vorher = budget_summe()
|
||
link = admin.post("/api/uebertraege/verbinden",
|
||
json={"a_id": aus["id"], "b_id": ein["id"]}).json()
|
||
assert budget_summe() < vorher, "verknüpft: zählt nicht mehr als Ausgabe"
|
||
|
||
admin.delete(f"/api/uebertraege/{link['id']}")
|
||
assert budget_summe() == vorher, "gelöst: zählt wieder, mit derselben Kategorie"
|
||
|
||
|
||
def test_import_ruecknahme_bleibt_unberuehrt(admin):
|
||
"""Die Gegenseite gehört einem anderen Stapel – sie darf nicht mitgehen."""
|
||
a = _konto(admin, "StapelA " + secrets.token_hex(3), opening=100000)
|
||
b = _konto(admin, "StapelB " + secrets.token_hex(3))
|
||
spalten = json.dumps({"date": 0, "amount": 1, "payee": 2})
|
||
|
||
def importieren(konto, text, name):
|
||
v = admin.post("/api/imports/preview",
|
||
files={"file": (name, io.BytesIO(text.encode()), "text/csv")},
|
||
data={"account_id": str(konto["id"]), "delimiter": ";",
|
||
"decimal_sep": ",", "date_format": "%d.%m.%Y",
|
||
"encoding": "utf-8", "has_header": "true", "skip_rows": "0",
|
||
"amount_mode": "signed", "column_map": spalten}).json()
|
||
return admin.post("/api/imports/commit", json={
|
||
"account_id": konto["id"], "filename": name,
|
||
"rows": [r for r in v["rows"] if r["include"]]}).json()
|
||
|
||
stapel_a = importieren(a, "Datum;Betrag;Text\n05.04.2026;-80,00;Übertrag\n", "a.csv")
|
||
importieren(b, "Datum;Betrag;Text\n05.04.2026;80,00;Übertrag\n", "b.csv")
|
||
_suchen(admin)
|
||
assert len(_links_von(admin, a, b)) == 1
|
||
assert _saldo(admin, b) == 8000
|
||
|
||
# Stapel A zurücknehmen: nur dessen Zeile verschwindet.
|
||
assert admin.delete(f"/api/imports/batches/{stapel_a['id']}").status_code == 200
|
||
assert _saldo(admin, a) == 100000, "eigene Zeile weg"
|
||
assert _saldo(admin, b) == 8000, "Gegenseite unberührt"
|
||
assert _links_von(admin, a, b) == [], "Verknüpfung verfällt mit der Buchung"
|
||
|
||
|
||
# --- Wann automatisch verknüpft wird ----------------------------------------
|
||
|
||
|
||
def test_ohne_hinweis_nur_vorschlag(admin):
|
||
"""50.00 im Laden bezahlt, am selben Tag 50.00 eingegangen: Zufall."""
|
||
a = _konto(admin, "Laden " + secrets.token_hex(3), opening=100000)
|
||
b = _konto(admin, "Sonst " + secrets.token_hex(3))
|
||
_buchung(admin, a, "expense", 5000, "2026-05-01", "Bäckerei Meier")
|
||
_buchung(admin, b, "income", 5000, "2026-05-01", "Rückerstattung Verein")
|
||
|
||
r = _suchen(admin)
|
||
assert _links_von(admin, a, b) == []
|
||
assert any(v["a"]["payee"] == "Bäckerei Meier" for v in r["vorschlaege"])
|
||
|
||
|
||
def test_mit_hinweis_automatisch(admin):
|
||
a = _konto(admin, "HinweisA " + secrets.token_hex(3), opening=100000)
|
||
b = _konto(admin, "HinweisB " + secrets.token_hex(3))
|
||
_buchung(admin, a, "expense", 60000, "2026-05-02", "Einzahlung von *6543")
|
||
_buchung(admin, b, "income", 60000, "2026-05-02", "Gutschrift")
|
||
_suchen(admin)
|
||
assert len(_links_von(admin, a, b)) == 1
|
||
|
||
|
||
def test_kontoname_im_text_zaehlt_als_hinweis(admin):
|
||
marke = secrets.token_hex(3)
|
||
a = _konto(admin, f"Lohnkonto {marke}", opening=100000)
|
||
b = _konto(admin, f"Sparkonto {marke}")
|
||
_buchung(admin, a, "expense", 12000, "2026-05-03", f"Sparkonto {marke}")
|
||
_buchung(admin, b, "income", 12000, "2026-05-03", "Eingang")
|
||
_suchen(admin)
|
||
assert len(_links_von(admin, a, b)) == 1
|
||
|
||
|
||
def test_kategorie_schuetzt_vor_automatik(admin):
|
||
"""Eine zugeordnete Ausgabe soll nicht ungefragt aus dem Budget fallen."""
|
||
leb = _kategorie(admin)
|
||
a = _konto(admin, "GeschuetztA " + secrets.token_hex(3), opening=100000)
|
||
b = _konto(admin, "GeschuetztB " + secrets.token_hex(3))
|
||
_buchung(admin, a, "expense", 4400, "2026-05-04", "Übertrag", category_id=leb)
|
||
_buchung(admin, b, "income", 4400, "2026-05-04", "Übertrag")
|
||
|
||
r = _suchen(admin)
|
||
assert _links_von(admin, a, b) == []
|
||
assert r["vorschlaege"]
|
||
|
||
|
||
def test_mehrdeutig_bleibt_vorschlag(admin):
|
||
"""Derselbe Betrag am selben Tag über drei Konten – nichts ist zwingend."""
|
||
marke = secrets.token_hex(3)
|
||
a = _konto(admin, f"M1 {marke}", opening=100000)
|
||
b = _konto(admin, f"M2 {marke}")
|
||
c = _konto(admin, f"M3 {marke}")
|
||
_buchung(admin, a, "expense", 9900, "2026-06-01", "Übertrag")
|
||
_buchung(admin, b, "income", 9900, "2026-06-01", "Übertrag")
|
||
_buchung(admin, c, "income", 9900, "2026-06-01", "Übertrag")
|
||
_suchen(admin)
|
||
assert _links_von(admin, a, b, c) == []
|
||
|
||
|
||
def test_austauschbare_kandidaten_werden_verbunden(admin):
|
||
"""Revolut-Aufrundungen: viele gleiche Beträge am selben Tag zwischen
|
||
denselben zwei Konten. Jede Zuordnung ist dieselbe."""
|
||
a = _konto(admin, "Giro " + secrets.token_hex(3), opening=100000)
|
||
b = _konto(admin, "Pocket " + secrets.token_hex(3))
|
||
for _ in range(6):
|
||
_buchung(admin, a, "expense", 60, "2026-06-02", "Übertrag Aufrundung")
|
||
_buchung(admin, b, "income", 60, "2026-06-02", "Übertrag Aufrundung")
|
||
|
||
vorher = (_saldo(admin, a), _saldo(admin, b))
|
||
_suchen(admin)
|
||
assert len(_links_von(admin, a, b)) == 6
|
||
assert (_saldo(admin, a), _saldo(admin, b)) == vorher
|
||
|
||
|
||
# --- Was nie ein Paar bilden darf -------------------------------------------
|
||
|
||
|
||
def test_kein_selbstbezug(admin):
|
||
a = _konto(admin, "Solo " + secrets.token_hex(3), opening=100000)
|
||
_buchung(admin, a, "expense", 2500, "2026-06-03", "Übertrag")
|
||
_buchung(admin, a, "income", 2500, "2026-06-03", "Übertrag")
|
||
_suchen(admin)
|
||
assert _links_von(admin, a) == []
|
||
|
||
|
||
def test_gleicher_betrag_andere_waehrung_ist_kein_paar(admin):
|
||
"""100 CHF und 100 EUR sind nicht derselbe Betrag – nur dieselbe Zahl."""
|
||
a = _konto(admin, "CHF " + secrets.token_hex(3), currency="CHF", opening=100000)
|
||
b = _konto(admin, "EUR " + secrets.token_hex(3), currency="EUR")
|
||
_buchung(admin, a, "expense", 10000, "2026-06-04", "Übertrag")
|
||
_buchung(admin, b, "income", 10000, "2026-06-04", "Übertrag")
|
||
_suchen(admin)
|
||
assert _links_von(admin, a, b) == []
|
||
|
||
|
||
def test_verschiedene_tage_bilden_kein_paar(admin):
|
||
a = _konto(admin, "TagA " + secrets.token_hex(3), opening=100000)
|
||
b = _konto(admin, "TagB " + secrets.token_hex(3))
|
||
_buchung(admin, a, "expense", 7700, "2026-06-05", "Übertrag")
|
||
_buchung(admin, b, "income", 7700, "2026-06-08", "Übertrag")
|
||
_suchen(admin)
|
||
assert _links_von(admin, a, b) == []
|
||
|
||
|
||
def test_keine_doppelverknuepfung(admin):
|
||
a = _konto(admin, "DoppA " + secrets.token_hex(3), opening=100000)
|
||
b = _konto(admin, "DoppB " + secrets.token_hex(3))
|
||
aus = _buchung(admin, a, "expense", 3100, "2026-06-06", "Übertrag")
|
||
ein = _buchung(admin, b, "income", 3100, "2026-06-06", "Übertrag")
|
||
admin.post("/api/uebertraege/verbinden", json={"a_id": aus["id"], "b_id": ein["id"]})
|
||
r = admin.post("/api/uebertraege/verbinden", json={"a_id": aus["id"], "b_id": ein["id"]})
|
||
assert r.status_code == 400
|
||
|
||
|
||
def test_ignorierte_paare_kehren_nicht_zurueck(admin):
|
||
a = _konto(admin, "IgnA " + secrets.token_hex(3), opening=100000)
|
||
b = _konto(admin, "IgnB " + secrets.token_hex(3))
|
||
aus = _buchung(admin, a, "expense", 1700, "2026-06-07", "Kiosk")
|
||
ein = _buchung(admin, b, "income", 1700, "2026-06-07", "Rückzahlung")
|
||
|
||
assert any(v["a"]["id"] in (aus["id"], ein["id"]) for v in _suchen(admin)["vorschlaege"])
|
||
admin.post("/api/uebertraege/ignorieren", json={"a_id": aus["id"], "b_id": ein["id"]})
|
||
danach = _suchen(admin)["vorschlaege"]
|
||
assert not any({v["a"]["id"], v["b"]["id"]} == {aus["id"], ein["id"]} for v in danach)
|
||
|
||
|
||
def test_vorschlaege_veraendern_nichts(admin):
|
||
"""Eine Ansicht, die bloss fragt „ist das ein Uebertrag?", darf beim
|
||
Hinsehen nichts verknuepfen – sonst entscheidet das Anschauen."""
|
||
import secrets
|
||
|
||
a = _konto(admin, name=f"V-A {secrets.token_hex(3)}")
|
||
b = _konto(admin, name=f"V-B {secrets.token_hex(3)}")
|
||
betrag = 30000 + secrets.randbelow(900)
|
||
# Mit Hinweis im Text – das wuerde /suchen automatisch verknuepfen.
|
||
raus = admin.post("/api/transactions", json={
|
||
"type": "expense", "date": "2026-08-20", "account_id": a["id"],
|
||
"amount_cents": betrag, "payee": f"Uebertrag auf {b['name']}"}).json()
|
||
rein = admin.post("/api/transactions", json={
|
||
"type": "income", "date": "2026-08-20", "account_id": b["id"],
|
||
"amount_cents": betrag, "payee": "Gutschrift"}).json()
|
||
|
||
r = admin.get("/api/uebertraege/vorschlaege").json()
|
||
assert r["verknuepft"] == 0, "beim Fragen darf nichts verknuepft werden"
|
||
paare = [(v["a"]["id"], v["b"]["id"]) for v in r["vorschlaege"]]
|
||
assert any({raus["id"], rein["id"]} == set(p) for p in paare)
|
||
|
||
# Und es ist wirklich keine Verknuepfung entstanden.
|
||
links = admin.get("/api/transactions/links").json()
|
||
assert str(raus["id"]) not in links and str(rein["id"]) not in links
|
||
|
||
|
||
def _konto_chf(admin, name):
|
||
import secrets
|
||
return admin.post("/api/accounts", json={
|
||
"name": f"{name} {secrets.token_hex(3)}", "type": "girokonto",
|
||
"currency": "CHF", "opening_balance_cents": 0}).json()
|
||
|
||
|
||
def test_vorschlag_auch_wenn_die_gegenseite_tage_spaeter_steht(admin):
|
||
"""Die Bank bucht die Gegenseite ein bis drei Tage spaeter. Die
|
||
Uebertrags-Pruefung sucht sie deshalb ueber ein Fenster und schickt den
|
||
Nutzer zum Verbinden – die Vorschlagsliste kannte aber nur den gleichen
|
||
Tag und bot das Paar nie an. Dann steht er vor einer Meldung, der er nicht
|
||
folgen kann."""
|
||
import secrets
|
||
|
||
quelle = _konto_chf(admin, "Revolut")
|
||
pocket = _konto_chf(admin, "Sparen Pocket")
|
||
betrag = 62 + secrets.randbelow(900)
|
||
text = f"Sparen {secrets.token_hex(4)}"
|
||
|
||
raus = admin.post("/api/transactions", json={
|
||
"type": "expense", "date": "2026-05-27", "account_id": quelle["id"],
|
||
"amount_cents": betrag, "payee": text}).json()
|
||
rein = admin.post("/api/transactions", json={
|
||
"type": "income", "date": "2026-05-29", "account_id": pocket["id"],
|
||
"amount_cents": betrag, "payee": text}).json()
|
||
|
||
paare = admin.get("/api/uebertraege/vorschlaege").json()["vorschlaege"]
|
||
meins = [p for p in paare
|
||
if {p["a"]["id"], p["b"]["id"]} == {raus["id"], rein["id"]}]
|
||
assert meins, "zwei Tage Abstand muessen als Vorschlag erscheinen"
|
||
|
||
# Von SELBST verknuepft wird weiter nur am gleichen Tag: Ein Vorschlag
|
||
# fragt, das automatische Verknuepfen entscheidet – und das bleibt eng.
|
||
admin.post("/api/uebertraege/suchen")
|
||
links = admin.get("/api/uebertraege").json()
|
||
assert not [l for l in links
|
||
if {l["from_txn_id"], l["to_txn_id"]} == {raus["id"], rein["id"]}], \
|
||
"ueber Tage hinweg darf nur vorgeschlagen, nicht selbst verknuepft werden"
|
||
|
||
# Bestaetigen kann der Nutzer es trotzdem.
|
||
assert admin.post("/api/uebertraege/verbinden",
|
||
json={"a_id": raus["id"], "b_id": rein["id"]}).status_code == 200
|
||
|
||
|
||
def test_ein_paar_wird_nur_einmal_vorgeschlagen(admin):
|
||
"""Jedes Paar steht intern zweimal, einmal je Richtung.
|
||
|
||
Ohne Entdopplung saehe der Nutzer dieselbe Zahlung zweimal zum Verbinden
|
||
angeboten - und wer das zweite anklickt, bekaeme den Fehler "bereits
|
||
verknuepft" fuer etwas, das er gerade selbst verknuepft hat.
|
||
"""
|
||
import secrets
|
||
|
||
betrag = 100000 + secrets.randbelow(800000)
|
||
a = admin.post("/api/accounts", json={
|
||
"name": f"A {secrets.token_hex(3)}", "currency": "EUR"}).json()
|
||
b = admin.post("/api/accounts", json={
|
||
"name": f"B {secrets.token_hex(3)}", "currency": "EUR"}).json()
|
||
raus = admin.post("/api/transactions", json={
|
||
"type": "expense", "date": "2026-07-14", "account_id": a["id"],
|
||
"amount_cents": betrag, "payee": "Umbuchung"}).json()
|
||
rein = admin.post("/api/transactions", json={
|
||
"type": "income", "date": "2026-07-14", "account_id": b["id"],
|
||
"amount_cents": betrag, "payee": "Umbuchung"}).json()
|
||
|
||
paare = admin.get("/api/uebertraege/vorschlaege").json()["vorschlaege"]
|
||
meins = [p for p in paare
|
||
if {p["a"]["id"], p["b"]["id"]} == {raus["id"], rein["id"]}]
|
||
assert len(meins) == 1, f"einmal vorschlagen, nicht {len(meins)}-mal"
|