Die Liste sagt ausdruecklich, dass sie nichts entscheidet: Drei Aufrundungen ueber 1.00 am selben Tag sind echt, drei gleiche Einkaeufe koennen es auch sein – das weiss nur der Nutzer. Dann darf sie aber auch nicht zu einer Entscheidung draengen, und genau das tat sie: Der einzige Knopf war der Papierkorb. Wer die Meldung loswerden wollte, musste eine richtige Buchung wegwerfen. Jetzt steht neben jeder Gruppe "Alle behalten". An den Buchungen aendert sich dabei nichts; es ist ein Haken an der Frage. Der Haken gilt dem, was man gesehen hat – nicht allem, was noch kommt: Eine Gruppe faellt erst weg, wenn ALLE ihre Zeilen ihn tragen. Kommt spaeter eine dritte gleich aussehende Buchung dazu, steht die Frage wieder da, und zwar fuer alle drei. Ein Test haelt das fest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
257 lines
11 KiB
Python
257 lines
11 KiB
Python
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"
|
||
|
||
|
||
# --- „Sieht gleich aus, ist aber richtig so." --------------------------------
|
||
|
||
def test_dubletten_lassen_sich_behalten_statt_loeschen(admin):
|
||
"""Ohne diesen Weg blieb nur Loeschen, um die Meldung loszuwerden.
|
||
|
||
Die Liste will ausdruecklich nichts entscheiden – dann darf sie auch nicht
|
||
zu einer Entscheidung draengen.
|
||
"""
|
||
konto = admin.post("/api/accounts", json={
|
||
"name": "Behalten-Konto", "currency": "CHF"}).json()
|
||
tag = "2026-09-12"
|
||
|
||
def buche(text):
|
||
return admin.post("/api/transactions", json={
|
||
"type": "expense", "date": tag, "account_id": konto["id"],
|
||
"amount_cents": 1043, "payee": text}).json()["id"]
|
||
|
||
ids = [buche("Migrolino"), buche("Migrolino")]
|
||
|
||
def meine():
|
||
return [g for g in admin.get("/api/transactions/dubletten").json()["gruppen"]
|
||
if g["account_id"] == konto["id"]]
|
||
|
||
assert len(meine()) == 1
|
||
|
||
r = admin.post("/api/transactions/dubletten/behalten", json={"ids": ids})
|
||
assert r.status_code == 204, r.text
|
||
assert meine() == [], "beantwortet – und damit weg"
|
||
|
||
# Und beide Buchungen stehen noch.
|
||
assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == -2086
|
||
|
||
|
||
def test_eine_dritte_gleiche_buchung_stellt_die_frage_neu(admin):
|
||
"""Der Haken gilt dem, was man gesehen hat – nicht allem, was noch kommt.
|
||
|
||
Deshalb faellt eine Gruppe erst weg, wenn ALLE ihre Zeilen ihn tragen.
|
||
"""
|
||
konto = admin.post("/api/accounts", json={
|
||
"name": "Dritte-Konto", "currency": "CHF"}).json()
|
||
tag = "2026-09-13"
|
||
|
||
def buche():
|
||
return admin.post("/api/transactions", json={
|
||
"type": "expense", "date": tag, "account_id": konto["id"],
|
||
"amount_cents": 1077, "payee": "Migrolino"}).json()["id"]
|
||
|
||
def meine():
|
||
return [g for g in admin.get("/api/transactions/dubletten").json()["gruppen"]
|
||
if g["account_id"] == konto["id"]]
|
||
|
||
ids = [buche(), buche()]
|
||
admin.post("/api/transactions/dubletten/behalten", json={"ids": ids})
|
||
assert meine() == []
|
||
|
||
buche()
|
||
gruppen = meine()
|
||
assert len(gruppen) == 1
|
||
assert len(gruppen[0]["zeilen"]) == 3, "die Frage gilt wieder fuer alle drei"
|
||
|
||
|
||
def test_fremde_buchung_laesst_sich_nicht_behalten(admin, client):
|
||
r = admin.post("/api/transactions/dubletten/behalten", json={"ids": [999999]})
|
||
assert r.status_code == 404, r.text
|
||
assert client
|