Files
certain/backend/tests/test_transactions_offen.py
T
GuidoandClaude Opus 5 bd79b83339 feat: Zeitraum an der Karte, und eine schaerfere Suche nach Dubletten
Zwei Dinge, die zusammen die Frage beantworten "zaehlt das wirklich ab
meinem Stichtag, und sind das wirklich zwei Zahlungen?".

Die Karte Ausgaben-Anteile nennt jetzt ihren Zeitraum. Gerechnet hat sie
schon immer ueber den eingestellten Budget-Monat (25.08.-24.09. bei
Starttag 25); nachvollziehen liess sich das nur nicht, weil die Karte
nirgends sagte, worueber sie summiert.

Die Dublettensuche vergleicht nicht mehr stur ueber den Text. Ein krummer
Betrag wie 10.43 kommt an einem Tag kaum zweimal vor - auch wenn zwei
Quellen ihn verschieden benennen, und genau daran scheiterte der
Textvergleich. Bei einem runden Betrag (Vielfaches von 50 Rappen) muss der
Text weiter passen, sonst waeren zwei Zwanziger am selben Tag ein Fund.

Und was die Bank selbst als verschieden gemeldet hat, gilt als geklaert:
Stammen alle Zeilen aus demselben Bankkonto und tragen verschiedene
Kennungen, verschweigt die Karte sie. Das sind die Revolut-Aufrundungen,
die echt sind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:39:08 +02:00

190 lines
8.8 KiB
Python
Raw 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.
import io
import json
def _konto(admin, **kw):
body = {"name": "Giro", "type": "girokonto", "currency": "EUR", "opening_balance_cents": 0}
body.update(kw)
return admin.post("/api/accounts", json=body).json()
def _kategorie(admin):
for g in admin.get("/api/categories").json():
for c in g["categories"]:
if c["name"] == "Lebensmittel":
return c["id"]
raise AssertionError("Lebensmittel fehlt")
def test_offene_buchungen_listet_nur_unkategorisierte(admin):
"""Zum Abarbeiten braucht man die Buchungen ohne Kategorie – und zwar nur die."""
konto = _konto(admin, name="Abarbeiten")
kat = _kategorie(admin)
admin.post("/api/transactions", json={"type": "expense", "date": "2026-04-01",
"account_id": konto["id"], "amount_cents": 1000,
"payee": "Ohne Kategorie"})
admin.post("/api/transactions", json={"type": "expense", "date": "2026-04-02",
"account_id": konto["id"], "amount_cents": 2000,
"payee": "Mit Kategorie", "category_id": kat})
admin.post("/api/transactions", json={"type": "income", "date": "2026-04-03",
"account_id": konto["id"], "amount_cents": 3000,
"payee": "Einnahme ohne Kategorie"})
offen = admin.get("/api/transactions", params={"uncategorized": "true",
"account_id": konto["id"]}).json()
namen = sorted(t["payee"] for t in offen)
assert namen == ["Einnahme ohne Kategorie", "Ohne Kategorie"]
def test_offene_buchungen_lassen_uebertraege_aus(admin):
"""Ein Übertrag ordnet kein Geld ein, er verschiebt es – er gehört nicht auf die Liste."""
a = _konto(admin, name="Von")
b = _konto(admin, name="Nach")
admin.post("/api/transactions", json={"type": "transfer", "date": "2026-04-04",
"account_id": a["id"], "to_account_id": b["id"],
"amount_cents": 5000})
offen = admin.get("/api/transactions", params={"uncategorized": "true",
"account_id": a["id"]}).json()
assert offen == []
def test_verknuepfte_buchungen_sind_nicht_mehr_offen(admin):
"""Sie gehoeren zu einem Uebertrag und zaehlen in keiner Auswertung. Nach
einer Kategorie zu fragen, verlangt Arbeit, die nichts bewirkt."""
import secrets
a = admin.post("/api/accounts", json={
"name": f"Offen-A {secrets.token_hex(3)}", "type": "girokonto",
"currency": "CHF", "opening_balance_cents": 0}).json()
b = admin.post("/api/accounts", json={
"name": f"Offen-B {secrets.token_hex(3)}", "type": "girokonto",
"currency": "CHF", "opening_balance_cents": 0}).json()
betrag = 20000 + secrets.randbelow(900)
raus = admin.post("/api/transactions", json={
"type": "expense", "date": "2026-08-21", "account_id": a["id"],
"amount_cents": betrag, "payee": "Umbuchung"}).json()
rein = admin.post("/api/transactions", json={
"type": "income", "date": "2026-08-21", "account_id": b["id"],
"amount_cents": betrag, "payee": "Umbuchung"}).json()
offen = {t["id"] for t in admin.get("/api/transactions",
params={"uncategorized": True}).json()}
assert raus["id"] in offen and rein["id"] in offen, "vorher beide offen"
assert admin.post("/api/uebertraege/verbinden",
json={"a_id": raus["id"], "b_id": rein["id"]}).status_code == 200
danach = {t["id"] for t in admin.get("/api/transactions",
params={"uncategorized": True}).json()}
assert raus["id"] not in danach and rein["id"] not in danach, \
"verknuepfte Buchungen brauchen keine Kategorie mehr"
def test_gleich_aussehende_buchungen_werden_gemeldet_nicht_entschieden(admin):
"""Drei Aufrundungen ueber 1.00 am selben Tag sind echt, drei gleiche
Einkaeufe koennen es auch sein – das weiss nur der Nutzer. Deshalb wird
gemeldet, nicht entschieden, und die Kennung der Bank steht dabei."""
import secrets
from app.database import SessionLocal
from app.models import InboxItem, InboxSource, InboxStatus, User
konto = admin.post("/api/accounts", json={
"name": f"Dublette {secrets.token_hex(3)}", "type": "girokonto",
"currency": "CHF", "opening_balance_cents": 0}).json()
text = f"Migrolino {secrets.token_hex(3)}"
ids = [admin.post("/api/transactions", json={
"type": "expense", "date": "2026-08-25", "account_id": konto["id"],
"amount_cents": 390, "payee": text}).json()["id"] for _ in range(3)]
# Zwei davon kamen von der Bank, unter verschiedenen Kennungen.
db = SessionLocal()
try:
besitzer = db.query(User).order_by(User.id).first()
for i, txn_id in enumerate(ids[:2]):
db.add(InboxItem(owner_id=besitzer.id, source=InboxSource.bank,
status=InboxStatus.booked, account_id=konto["id"],
date=__import__("datetime").date(2026, 8, 25),
amount_cents=-390, currency="CHF", payee=text,
external_id=f"acc-x:ref-{i}", transaction_id=txn_id))
db.commit()
finally:
db.close()
gruppen = admin.get("/api/transactions/dubletten").json()["gruppen"]
meine = next(g for g in gruppen if g["account_id"] == konto["id"])
assert len(meine["zeilen"]) == 3
assert meine["amount_cents"] == 390
kennungen = {z["kennung"] for z in meine["zeilen"]}
assert kennungen == {"ref-0", "ref-1", None}, "die Kennung muss dabeistehen"
assert {z["herkunft"] for z in meine["zeilen"]} == {"bank", "manuell"}
# Und nichts wurde geloescht.
assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == -1170
def test_dubletten_regel_kennt_runde_und_krumme_betraege(admin):
"""Ein krummer Betrag wie 10.43 kommt an einem Tag kaum zweimal vor – auch
wenn zwei Quellen ihn verschieden benennen. Ein runder schon: Da muss auch
der Text passen, sonst waere es blosser Zufall."""
import secrets
konto = admin.post("/api/accounts", json={
"name": f"Regel {secrets.token_hex(3)}", "type": "girokonto",
"currency": "CHF", "opening_balance_cents": 0}).json()
tag = "2026-07-14"
def buche(cents, text):
return admin.post("/api/transactions", json={
"type": "expense", "date": tag, "account_id": konto["id"],
"amount_cents": cents, "payee": text}).json()["id"]
# Krumm, verschiedene Texte -> gemeldet.
buche(1043, "Migrolino Zuerich")
buche(1043, "MIGROLINO ZH PRIME")
# Rund, verschiedene Texte -> zwei echte Zahlungen, nicht gemeldet.
buche(2000, "Bargeldbezug")
buche(2000, "Spende")
gruppen = [g for g in admin.get("/api/transactions/dubletten").json()["gruppen"]
if g["account_id"] == konto["id"]]
betraege = {g["amount_cents"] for g in gruppen}
assert 1043 in betraege, "krummer Betrag zweimal am Tag – das ist verdaechtig"
assert 2000 not in betraege, "runder Betrag mit verschiedenen Texten ist normal"
def test_verschiedene_kennungen_desselben_kontos_gelten_als_geklaert(admin):
"""Revolut-Aufrundungen: mehrfach derselbe Betrag am selben Tag, mit
demselben Text – aber die Bank hat sie als verschiedene Umsaetze gemeldet.
Dann gibt es nichts zu entscheiden."""
import datetime
import secrets
from app.database import SessionLocal
from app.models import InboxItem, InboxSource, InboxStatus, User
konto = admin.post("/api/accounts", json={
"name": f"Aufrundung {secrets.token_hex(3)}", "type": "girokonto",
"currency": "CHF", "opening_balance_cents": 0}).json()
text = f"To CHF MB:{secrets.token_hex(4)}"
ids = [admin.post("/api/transactions", json={
"type": "expense", "date": "2026-07-15", "account_id": konto["id"],
"amount_cents": 43, "payee": text}).json()["id"] for _ in range(3)]
db = SessionLocal()
try:
besitzer = db.query(User).order_by(User.id).first()
for i, txn_id in enumerate(ids):
db.add(InboxItem(owner_id=besitzer.id, source=InboxSource.bank,
status=InboxStatus.booked, account_id=konto["id"],
date=datetime.date(2026, 7, 15), amount_cents=-43,
currency="CHF", payee=text,
external_id=f"acc-eins:ref-{i}", transaction_id=txn_id))
db.commit()
finally:
db.close()
gruppen = [g for g in admin.get("/api/transactions/dubletten").json()["gruppen"]
if g["account_id"] == konto["id"]]
assert not gruppen, "die Bank hat sie als verschieden gemeldet"