diff --git a/backend/app/routers/transactions.py b/backend/app/routers/transactions.py index 88d6793..3499a78 100644 --- a/backend/app/routers/transactions.py +++ b/backend/app/routers/transactions.py @@ -159,6 +159,54 @@ def list_transactions( return [to_out(db, t, base, ccy, woher) for t in rows] +@router.get("/dubletten") +def duplicates(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: + """Buchungen, die gleich aussehen: gleiches Konto, gleicher Tag, gleicher + Betrag, gleicher Text. + + Entschieden wird hier NICHTS. Drei Aufrundungen über 1.00 am selben Tag + sind echt, drei gleiche Einkäufe können es auch sein – das weiss nur der + Nutzer. Deshalb steht zu jeder Zeile die Kennung der Bank dabei: Tragen + zwei Zeilen dieselbe, ist es sicher dieselbe Zahlung; tragen sie + verschiedene, hat die Bank sie als verschieden gemeldet. + """ + from ..services.csv_import import normalize_text + + gruppen: dict[tuple, list] = {} + 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) + + verdaechtig = {k: v for k, v in gruppen.items() if len(v) > 1} + 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] + 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)) + } + return {"gruppen": [ + { + "account_id": k[0], "datum": k[2].isoformat(), "amount_cents": k[3], + "typ": k[1], + "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)} + for t in zeilen + ], + } + for k, zeilen in sorted(verdaechtig.items(), key=lambda p: p[0][2], reverse=True) + ][:100]} + + @router.post("", response_model=TransactionOut, status_code=status.HTTP_201_CREATED) def create_transaction( payload: TransactionCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user) diff --git a/backend/tests/test_transactions_offen.py b/backend/tests/test_transactions_offen.py index 68b69f8..e3b3ce5 100644 --- a/backend/tests/test_transactions_offen.py +++ b/backend/tests/test_transactions_offen.py @@ -78,3 +78,46 @@ def test_verknuepfte_buchungen_sind_nicht_mehr_offen(admin): 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 diff --git a/web/src/api.js b/web/src/api.js index 6437e83..6484465 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -228,6 +228,8 @@ export const api = { // ändert nichts – erst zeigen, dann tun. applyRules: (vorschau = true) => request(`/categories/keywords/anwenden?vorschau=${vorschau}`, { method: "POST" }), + // Buchungen, die gleich aussehen – gemeldet, nicht entschieden. + duplicateTransactions: () => request("/transactions/dubletten"), transactionLinks: () => request("/transactions/links"), // Nur fragen, nichts tun – fuer Ansichten, die bloss anzeigen wollen, // dass hier ein Uebertrag stecken koennte. diff --git a/web/src/components/Dubletten.jsx b/web/src/components/Dubletten.jsx new file mode 100644 index 0000000..b0d9e0a --- /dev/null +++ b/web/src/components/Dubletten.jsx @@ -0,0 +1,92 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { api } from "../api"; +import Icon from "./Icon"; +import { useToast } from "../toast"; +import { useConfirm } from "../confirm"; +import { formatDate, formatMoney } from "../format"; + +// Buchungen, die gleich aussehen: gleiches Konto, gleicher Tag, gleicher +// Betrag, gleicher Text. +// +// Entschieden wird hier NICHTS. Drei Aufrundungen über 1.00 am selben Tag sind +// echt, drei gleiche Einkäufe können es auch sein – das weiss nur der Nutzer. +// Deshalb steht zu jeder Zeile die Kennung der Bank: Dieselbe Kennung heisst +// sicher dieselbe Zahlung, verschiedene heissen, die Bank hat sie als +// verschieden gemeldet. +export default function Dubletten({ accountById, onChange }) { + const { t } = useTranslation(); + const toast = useToast(); + const confirm = useConfirm(); + const [gruppen, setGruppen] = useState(null); + + async function laden() { + try { + setGruppen((await api.duplicateTransactions()).gruppen || []); + } catch { + setGruppen([]); // still ignorieren – nicht kritisch + } + } + useEffect(() => { laden(); }, []); + + async function loeschen(zeile) { + if (!(await confirm({ + title: t("dupes.delete"), + message: t("dupes.deleteConfirm"), + danger: true, + confirmLabel: t("dupes.delete"), + }))) return; + try { + await api.deleteTransaction(zeile.id); + await laden(); + onChange?.(); + } catch (e) { + toast(e.message, "error"); + } + } + + if (!gruppen?.length) return null; + + return ( +
+
+ +

{t("dupes.title", { n: gruppen.length })}

+
+

{t("dupes.intro")}

+ +
+ {gruppen.map((g) => ( +
+
+ {formatDate(g.datum)} · {accountById[g.account_id]?.name || "—"} ·{" "} + + {formatMoney(g.typ === "income" ? g.amount_cents : -g.amount_cents, + accountById[g.account_id]?.currency)} + + {" · "}{t("dupes.count", { n: g.zeilen.length })} +
+ {g.zeilen.map((z) => ( +
+ + {z.payee || z.description || t("inbox.noText")} + + {t(`todo.from.${z.herkunft}`, { + defaultValue: z.herkunft })} + {/* Die Kennung entscheidet die Frage – deshalb steht sie da, + auch wenn sie hässlich ist. */} + + {z.kennung || t("dupes.noRef")} + + +
+ ))} +
+ ))} +
+
+ ); +} diff --git a/web/src/finance.css b/web/src/finance.css index f277e1c..f5572d9 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -1457,3 +1457,26 @@ tr.row-dup { fill: var(--danger); color: var(--danger); } + +/* Gleich aussehende Buchungen: die Gruppe als Block, die Zeilen darin + eingerückt – so sieht man auf einen Blick, wie viele es sind. */ +.dubletten-gruppe { + padding: var(--sp-3) 0; + border-bottom: 1px solid var(--border); +} +.dubletten-gruppe:last-child { + border-bottom: none; +} +.dubletten-zeile { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: 3px 0 3px var(--sp-3); + border-left: 2px solid var(--border-strong); + margin-top: 4px; + min-width: 0; +} +.dubletten-zeile > .ellipsis { + flex: 1 1 auto; + min-width: 0; +} diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 1fdbb5f..26c46a6 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -712,5 +712,13 @@ "csv": "Datei-Import", "manuell": "von Hand" } + }, + "dupes": { + "title": "Gleich aussehende Buchungen ({{n}})", + "intro": "Diese Zeilen sehen aus wie dieselbe Zahlung – gleiches Konto, gleicher Tag, gleicher Betrag, gleicher Text. Entscheiden musst du: Steht überall dieselbe Kennung der Bank, ist es sicher dieselbe Zahlung. Stehen verschiedene, hat die Bank sie als verschieden gemeldet – dann können es auch wirklich mehrere sein.", + "count": "{{n}} mal", + "delete": "Buchung löschen", + "deleteConfirm": "Diese Buchung wird gelöscht. Kam sie von der Bank, holt kein späterer Abruf sie zurück – der Umsatz gilt dann als erledigt.", + "noRef": "keine Kennung" } } diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 552c9b8..5e81500 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -712,5 +712,13 @@ "csv": "File import", "manuell": "manual" } + }, + "dupes": { + "title": "Transactions that look alike ({{n}})", + "intro": "These rows look like the same payment – same account, same day, same amount, same text. You decide: if they all carry the same bank reference it is certainly the same payment. Different references mean the bank reported them as different.", + "count": "{{n}} times", + "delete": "Delete transaction", + "deleteConfirm": "This transaction will be deleted. If it came from the bank, no later fetch will bring it back – it counts as handled.", + "noRef": "no reference" } } diff --git a/web/src/pages/Eingang.jsx b/web/src/pages/Eingang.jsx index 4605b62..128dd59 100644 --- a/web/src/pages/Eingang.jsx +++ b/web/src/pages/Eingang.jsx @@ -6,6 +6,7 @@ import CategorySelect from "../components/CategorySelect"; import Icon from "../components/Icon"; import IconChip from "../components/IconChip"; import Money from "../components/Money"; +import Dubletten from "../components/Dubletten"; import UebertragDialog from "../components/UebertragDialog"; import Uebertragsvorschlaege from "../components/Uebertragsvorschlaege"; import { useToast } from "../toast"; @@ -208,6 +209,10 @@ export default function Eingang() { deshalb steht der Vorschlag hier und nicht in der Buchungsliste. */} + {/* Gleich aussehende Buchungen gehören hierher: Sie sind Arbeit, die + liegen bleibt, und man findet sie in der Buchungsliste nur zufällig. */} + + {aufgaben.length === 0 ? (