From bd79b83339699a2fd3d171a7cff81a5584255db1 Mon Sep 17 00:00:00 2001 From: Guido Date: Sun, 30 Aug 2026 14:39:08 +0200 Subject: [PATCH] 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 --- backend/app/routers/dashboard.py | 5 ++ backend/app/routers/transactions.py | 43 ++++++++++++--- backend/tests/test_transactions_offen.py | 66 ++++++++++++++++++++++++ web/src/dashboard/cards.jsx | 13 ++++- web/src/finance.css | 7 +++ 5 files changed, 127 insertions(+), 7 deletions(-) diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 7706d61..00aada2 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -186,6 +186,11 @@ def expense_share( "total": gesamt, "total_planned": gesamt_geplant, "total_over": gesamt_geplant > 0 and gesamt > gesamt_geplant, + # Der Zeitraum gehoert dazu: Bei einem Budget-Monat, der nicht am + # Ersten beginnt, ist sonst nicht nachvollziehbar, welche Buchungen + # ueberhaupt gezaehlt wurden. + "von": start.isoformat(), + "bis": end.isoformat(), "currency": base, "label": label, "group_by": "category" if by_category else "group", diff --git a/backend/app/routers/transactions.py b/backend/app/routers/transactions.py index 3499a78..9e07a15 100644 --- a/backend/app/routers/transactions.py +++ b/backend/app/routers/transactions.py @@ -173,25 +173,56 @@ def duplicates(db: Session = Depends(get_db), user: User = Depends(get_current_u from ..services.csv_import import normalize_text gruppen: dict[tuple, list] = {} + texte: dict[int, str] = {} for txn in db.query(Transaction).filter( Transaction.owner_id == user.id, Transaction.type != TxnType.transfer, ).order_by(Transaction.date.desc(), Transaction.id): - schluessel = (txn.account_id, txn.type.value, txn.date, - txn.amount_cents, normalize_text(txn.payee or txn.description or "")) - gruppen.setdefault(schluessel, []).append(txn) + # Konto, Tag und Betrag muessen immer stimmen. Der Text NICHT: Ein + # krummer Betrag wie 10.43 kommt an einem Tag kaum zweimal vor, auch + # wenn zwei Quellen ihn verschieden benennen – und genau daran + # scheitert ein Vergleich ueber den Text. + gruppen.setdefault((txn.account_id, txn.type.value, txn.date, + txn.amount_cents), []).append(txn) + texte[txn.id] = normalize_text(txn.payee or txn.description or "") - verdaechtig = {k: v for k, v in gruppen.items() if len(v) > 1} + # Bei einem runden Betrag ist zweimal am selben Tag durchaus moeglich – + # dort muss auch der Text uebereinstimmen, sonst waere es blosser Zufall. + def rund(cents: int) -> bool: + return cents % 50 == 0 + + verdaechtig = {} + for schluessel, zeilen in gruppen.items(): + if len(zeilen) < 2: + continue + if rund(schluessel[3]) and len({texte[t.id] for t in zeilen}) > 1: + continue + verdaechtig[schluessel] = zeilen if not verdaechtig: return {"gruppen": []} alle_ids = [t.id for zeilen in verdaechtig.values() for t in zeilen] herkunft = _herkunft(db, user, [t for zeilen in verdaechtig.values() for t in zeilen]) kennungen = { - txn_id: (wert or "").split(":", 1)[-1] + txn_id: (wert or "") for txn_id, wert in db.query(InboxItem.transaction_id, InboxItem.external_id) .filter(InboxItem.owner_id == user.id, InboxItem.transaction_id.in_(alle_ids)) } + # Melden nur, wenn es etwas zu entscheiden gibt: Stammen alle Zeilen aus + # DEMSELBEN Bankkonto und tragen verschiedene Kennungen, hat die Bank sie + # ausdruecklich als verschieden gemeldet – so sind Revolut-Aufrundungen + # ueber denselben Betrag, und die sind echt. + def entschieden(zeilen) -> bool: + werte = [kennungen.get(t.id) for t in zeilen] + if any(w is None for w in werte): + return False + konten = {w.split(":", 1)[0] for w in werte} + return len(konten) == 1 and len(set(werte)) == len(werte) + + verdaechtig = {k: v for k, v in verdaechtig.items() if not entschieden(v)} + if not verdaechtig: + return {"gruppen": []} + return {"gruppen": [ { "account_id": k[0], "datum": k[2].isoformat(), "amount_cents": k[3], @@ -199,7 +230,7 @@ def duplicates(db: Session = Depends(get_db), user: User = Depends(get_current_u "zeilen": [ {"id": t.id, "payee": t.payee, "description": t.description, "category_id": t.category_id, "herkunft": herkunft.get(t.id, "manuell"), - "kennung": kennungen.get(t.id)} + "kennung": (kennungen.get(t.id) or "").split(":", 1)[-1] or None} for t in zeilen ], } diff --git a/backend/tests/test_transactions_offen.py b/backend/tests/test_transactions_offen.py index e3b3ce5..cdd3dd3 100644 --- a/backend/tests/test_transactions_offen.py +++ b/backend/tests/test_transactions_offen.py @@ -121,3 +121,69 @@ def test_gleich_aussehende_buchungen_werden_gemeldet_nicht_entschieden(admin): # 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" diff --git a/web/src/dashboard/cards.jsx b/web/src/dashboard/cards.jsx index fd9da69..70bb88d 100644 --- a/web/src/dashboard/cards.jsx +++ b/web/src/dashboard/cards.jsx @@ -4,7 +4,7 @@ import { api } from "../api"; import { useSettings } from "../settings"; import IconChip from "../components/IconChip"; import Money from "../components/Money"; -import { formatMoney, formatAmount } from "../format"; +import { formatDate, formatMoney, formatAmount } from "../format"; import { tokenFarbe } from "./palette"; import Donut from "./charts/Donut"; import Line from "./charts/Line"; @@ -156,9 +156,18 @@ function ExpenseShare({ props }) { const data = useData(() => api.cardExpenseShare(groupBy)); if (!data) return ; if (!data.rows?.length) return
{t("cards.noExpenses")}
; + // Der Zeitraum gehoert dazu: Bei einem Budget-Monat, der nicht am Ersten + // beginnt, ist sonst nicht nachvollziehbar, was ueberhaupt gezaehlt wurde. + const zeitraum = data.von && data.bis + ?
+ {formatDate(data.von)} – {formatDate(data.bis)} +
+ : null; // Kreis: der Donut beschriftet seine Legende selbst mit Betrag und Anteil. if ((props?.darstellung || "liste") === "kreis") { return ( + <> + {zeitraum} ({ name: r.name, value: r.amount, color: r.color, @@ -169,10 +178,12 @@ function ExpenseShare({ props }) { gesamtBudget={data.total_planned > 0 ? (data.total_over ? "over" : "ok") : null} fmt={(v) => formatMoney(v, data.currency)} /> + ); } return (
+ {zeitraum} {data.rows.map((r) => { // Anteil aus den Rohwerten – gerundete Prozente ergäben in der Summe // sonst nicht 100 %. diff --git a/web/src/finance.css b/web/src/finance.css index f5572d9..5f8f8ea 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -1480,3 +1480,10 @@ tr.row-dup { flex: 1 1 auto; min-width: 0; } + +/* Zeitraum einer Dashboard-Karte: leise, aber da – ohne ihn ist bei einem + Budget-Monat, der nicht am Ersten beginnt, nicht nachvollziehbar, was + gezählt wurde. */ +.dash-zeitraum { + margin-bottom: var(--sp-2); +}