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.intro")}
+ +