Files
certain/backend/tests/test_neutral_category.py
GuidoandClaude Opus 5 f2c1ab7dec feat: Regeln nachtraeglich auf Buchungen ohne Kategorie anwenden
Regeln greifen beim Import. Alles, was vorher hereinkam – oder hereinkam, bevor
es die Regel gab –, bleibt liegen. Bei einer Bankanbindung passiert genau das
dauernd: Die Umsaetze kommen von selbst, die Regel entsteht spaeter. Die
Revolut-Aufrundungen aufs Sparen-Pocket sind der Fall, an dem es aufgefallen
ist.

Der Knopf steht bei der Transfer-Erkennung und zeigt erst, was passieren
wuerde. Uebertrags-Regeln laufen durch denselben Dienst wie jeder andere
Uebertrag – er lehnt ab, wo die Gegenseite schon gebucht ist, und solche
Buchungen bleiben unangetastet und werden mit Grund gemeldet.

Ohne festes Zielkonto wird es aus dem Buchungstext erraten, aber nur bei GENAU
einem Treffer und nur an Wortgrenzen: "Euro" darf nicht "PayPal Europe"
treffen. Zwei Moeglichkeiten heisst raten, und dann passiert nichts.

Angefasst werden nur Buchungen OHNE Kategorie – sonst ueberschriebe der Knopf
stillschweigend, was von Hand zugeordnet wurde. Ein Uebertrag ist kein Kandidat
mehr, ein zweiter Klick verschiebt das Geld also nicht erneut.

Zwei Dinge fielen beim Testen auf und sind mit behoben:

- Ein fehlgeschlagener Uebertrag verwarf per rollback() auch alles, was im
  selben Durchlauf schon gelungen war. Jetzt haelt ein Rettungspunkt je
  Uebertrag den Schaden bei dem einen.
- test_neutral_category nahm VORHANDENE Kategorien und stellte eine davon auf
  "nicht einrechnen". Benutzten andere Buchungen sie, fielen die mit heraus und
  die Rechnung ging um deren Summe daneben – ohne Bezug zum Gepruefften. Der
  Test legt sich seine Kategorien jetzt selbst an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:02:04 +02:00

155 lines
6.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Kategorien, die nicht in Auswertungen zählen (z. B. „Kontoübertrag").
Eine Umbuchung zwischen eigenen Konten ist keine Ausgabe – das Geld liegt nur
woanders. Der Kontostand muss sie trotzdem abbilden.
Gemessen wird als Differenz: Die Testdatenbank lebt über den ganzen Lauf, es
liegen also schon Buchungen anderer Tests im laufenden Monat."""
import datetime
def _expense_cats(admin, n=2):
"""EIGENE Kategorien anlegen, keine vorhandenen nehmen.
Der Test stellt eine davon spaeter auf "nicht einrechnen". Nimmt er eine
Kategorie, die andere Buchungen schon benutzen, fallen die mit heraus – und
die Rechnung geht um deren Summe daneben, ohne dass es mit dem zu tun hat,
was hier geprueft wird.
"""
import secrets
gruppe = next(g for g in admin.get("/api/categories").json()
if g["kind"] == "expense")
out = []
for i in range(n):
r = admin.post("/api/categories", json={
"group_id": gruppe["id"], "name": f"Neutral-Test {i} {secrets.token_hex(3)}"})
assert r.status_code == 201, r.text
out.append(r.json())
return out
def _ausgaben(admin):
return admin.get("/api/dashboard/income-expense").json()["expense"]
def _anteile(admin):
d = admin.get("/api/dashboard/expense-share?group_by=category").json()
return {r["name"]: r["amount"] for r in d["rows"]}
def test_neutral_category_out_of_stats_but_in_balance(admin):
acc = admin.post("/api/accounts", json={
"name": "Giro Neutral", "type": "girokonto", "currency": "EUR",
"opening_balance_cents": 100000,
}).json()
normal, uebertrag = _expense_cats(admin, 2)
for c in (normal, uebertrag): # definierter Ausgangszustand
admin.patch(f"/api/categories/{c['id']}", json={"excluded_from_stats": False})
vorher_ausgaben = _ausgaben(admin)
vorher_anteile = _anteile(admin)
heute = datetime.date.today().isoformat()
for cat, cents in ((normal, 5000), (uebertrag, 30000)):
r = admin.post("/api/transactions", json={
"type": "expense", "date": heute, "account_id": acc["id"],
"amount_cents": cents, "category_id": cat["id"],
})
assert r.status_code == 201, r.text
# Beide Buchungen wirken auf den Saldo: 1000.00 - 50.00 - 300.00 = 650.00
assert admin.get(f"/api/accounts/{acc['id']}").json()["balance_cents"] == 65000
assert _ausgaben(admin) == vorher_ausgaben + 35000
# Jetzt „Kontoübertrag" aus den Auswertungen nehmen.
r = admin.patch(f"/api/categories/{uebertrag['id']}", json={"excluded_from_stats": True})
assert r.status_code == 200, r.text
assert r.json()["excluded_from_stats"] is True
# Nur noch die echte Ausgabe zählt – der Saldo bleibt unverändert.
assert _ausgaben(admin) == vorher_ausgaben + 5000
assert admin.get(f"/api/accounts/{acc['id']}").json()["balance_cents"] == 65000
jetzt = _anteile(admin)
assert jetzt.get(normal["name"], 0) == vorher_anteile.get(normal["name"], 0) + 5000
assert jetzt.get(uebertrag["name"], 0) == 0, "neutrale Kategorie darf nicht auftauchen"
# Häkchen entfernen -> zählt sofort wieder mit.
admin.patch(f"/api/categories/{uebertrag['id']}", json={"excluded_from_stats": False})
assert _ausgaben(admin) == vorher_ausgaben + 35000
def test_neutral_keyword_matches_both_directions(admin):
"""Ein Kontoübertrag geht auf dem einen Konto raus und kommt auf dem anderen
rein. Die neutrale Kategorie muss deshalb für BEIDE Richtungen greifen –
obwohl sie in einer Ausgabe-Gruppe liegt."""
import io, json
acc = admin.post("/api/accounts", json={
"name": "Giro Beide", "type": "girokonto", "currency": "EUR",
}).json()
uebertrag = _expense_cats(admin, 2)[1]
admin.patch(f"/api/categories/{uebertrag['id']}", json={"excluded_from_stats": True})
admin.post(f"/api/categories/{uebertrag['id']}/keywords", json={"word": "kontouebertrag"})
csv_text = (
"Datum;Betrag;Empfaenger;Zweck\n"
"05.03.2026;-250,00;Kontouebertrag raus;;\n"
"06.03.2026;250,00;Kontouebertrag rein;;\n"
)
rows = admin.post(
"/api/imports/preview",
files={"file": ("k.csv", io.BytesIO(csv_text.encode("utf-8")), "text/csv")},
data={
"account_id": str(acc["id"]), "delimiter": ";", "decimal_sep": ",",
"date_format": "%d.%m.%Y", "encoding": "utf-8", "has_header": "true",
"skip_rows": "0", "amount_mode": "signed",
"column_map": json.dumps({"date": 0, "amount": 1, "payee": 2, "purpose": 3}),
},
).json()["rows"]
assert rows[0]["category_id"] == uebertrag["id"], "Ausgabe-Richtung"
assert rows[1]["category_id"] == uebertrag["id"], "Einnahme-Richtung ebenso"
def _einnahmen(admin):
return admin.get("/api/dashboard/income-expense").json()["income"]
def test_neutrale_kategorie_gilt_auch_fuer_einnahmen(admin):
"""Die Gegenseite eines Kontoübertrags ist eine Einnahme – und darf ebenso
wenig in die Statistik einfliessen wie die Ausgabe.
Seit der Import keine Konten mehr verbindet, wird aus jedem Übertrag ein
Paar: Ausgabe beim einen Konto, Einnahme beim anderen. Zählte nur die
Ausgabe nicht, stünde das Geld als Einnahme in der Auswertung und die
Bilanz des Monats wäre um den vollen Betrag zu gut.
"""
acc = admin.post("/api/accounts", json={
"name": "Giro Neutral Einnahme", "type": "girokonto", "currency": "EUR",
"opening_balance_cents": 0,
}).json()
# Neutrale Kategorie liegt in einer AUSGABEN-Gruppe – sie muss trotzdem
# für eine Einnahme verwendbar sein.
uebertrag, = _expense_cats(admin, 1)
admin.patch(f"/api/categories/{uebertrag['id']}", json={"excluded_from_stats": True})
vorher = _einnahmen(admin)
heute = str(datetime.date.today())
r = admin.post("/api/transactions", json={
"type": "income", "date": heute, "account_id": acc["id"],
"amount_cents": 25000, "category_id": uebertrag["id"],
})
assert r.status_code == 201, r.text
# Der Kontostand steigt ...
assert admin.get(f"/api/accounts/{acc['id']}").json()["balance_cents"] == 25000
# ... die Einnahmen der Auswertung nicht.
assert _einnahmen(admin) == vorher
# Ohne die Markierung zählt dieselbe Buchung wieder mit.
admin.patch(f"/api/categories/{uebertrag['id']}", json={"excluded_from_stats": False})
assert _einnahmen(admin) == vorher + 25000
admin.patch(f"/api/categories/{uebertrag['id']}", json={"excluded_from_stats": True})