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