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); +}