Ein Uebertrag steht in zwei Auszuegen und wird zweimal gebucht – richtig fuer die Salden, aber er zaehlt als Ausgabe UND als Einnahme in der Auswertung. Statt beide zu einer Uebertrags-Buchung zusammenzufuehren (der urspruengliche Plan) legt Certain nur eine Schicht darueber: transfer_links haelt fest, dass zwei Buchungen zusammengehoeren. An den Buchungen selbst aendert sich nichts – Betrag, Konto, Kategorie, Import-Schluessel und Stapel bleiben. Das haelt alles heraus, was am Zusammenfuehren haette brechen koennen: der Duplikatschutz verliert keinen Schluessel, undo_batch reisst keine Gegenseite mit, und eine falsche Paarung ist ein Etikett, das man abzieht. Die Auswertung ueberspringt verknuepfte Buchungen – nicht umkategorisiert: Loest man die Verknuepfung, zaehlt die Buchung mit ihrer alten Kategorie sofort wieder mit. Automatisch nur, wenn die Zuordnung zwingend ist UND keine Seite eine Kategorie traegt UND der Text auf einen Uebertrag hindeutet. Sonst Vorschlag. Waehrungswechsel werden ueber den EZB-Kurs des Tages geprueft (2,5 % Toleranz fuer den Bankaufschlag) – der Kurs dient nur als Pruefung, gespeichert wird nichts davon. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
286 lines
12 KiB
Python
286 lines
12 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)
|