feat: Gleich aussehende Buchungen stehen in der Arbeitsliste
Bisher fand man Dubletten nur zufaellig beim Durchscrollen der Buchungsliste – oder ich habe ein Skript dafuer geschrieben, und der Nutzer musste sich auf einen Server einloggen, um seine eigenen Zahlen zu reparieren. Beides ist falsch. Die Arbeitsliste zeigt sie jetzt: gleiches Konto, gleicher Tag, gleicher Betrag, gleicher Text. Je Zeile steht die Kennung der Bank dabei, und die entscheidet die Frage – dieselbe Kennung heisst sicher dieselbe Zahlung, verschiedene heissen, die Bank hat sie als verschieden gemeldet. Entschieden wird bewusst NICHTS von selbst. Drei Aufrundungen ueber 1.00 am selben Tag sind echt, drei gleiche Einkaeufe koennen es auch sein; das weiss nur der Nutzer. Certain meldet und laesst loeschen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -159,6 +159,54 @@ def list_transactions(
|
|||||||
return [to_out(db, t, base, ccy, woher) for t in rows]
|
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)
|
@router.post("", response_model=TransactionOut, status_code=status.HTTP_201_CREATED)
|
||||||
def create_transaction(
|
def create_transaction(
|
||||||
payload: TransactionCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
payload: TransactionCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
|
|||||||
@@ -78,3 +78,46 @@ def test_verknuepfte_buchungen_sind_nicht_mehr_offen(admin):
|
|||||||
params={"uncategorized": True}).json()}
|
params={"uncategorized": True}).json()}
|
||||||
assert raus["id"] not in danach and rein["id"] not in danach, \
|
assert raus["id"] not in danach and rein["id"] not in danach, \
|
||||||
"verknuepfte Buchungen brauchen keine Kategorie mehr"
|
"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
|
||||||
|
|||||||
@@ -228,6 +228,8 @@ export const api = {
|
|||||||
// ändert nichts – erst zeigen, dann tun.
|
// ändert nichts – erst zeigen, dann tun.
|
||||||
applyRules: (vorschau = true) =>
|
applyRules: (vorschau = true) =>
|
||||||
request(`/categories/keywords/anwenden?vorschau=${vorschau}`, { method: "POST" }),
|
request(`/categories/keywords/anwenden?vorschau=${vorschau}`, { method: "POST" }),
|
||||||
|
// Buchungen, die gleich aussehen – gemeldet, nicht entschieden.
|
||||||
|
duplicateTransactions: () => request("/transactions/dubletten"),
|
||||||
transactionLinks: () => request("/transactions/links"),
|
transactionLinks: () => request("/transactions/links"),
|
||||||
// Nur fragen, nichts tun – fuer Ansichten, die bloss anzeigen wollen,
|
// Nur fragen, nichts tun – fuer Ansichten, die bloss anzeigen wollen,
|
||||||
// dass hier ein Uebertrag stecken koennte.
|
// dass hier ein Uebertrag stecken koennte.
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-head">
|
||||||
|
<Icon name="alert" size={18} />
|
||||||
|
<h2>{t("dupes.title", { n: gruppen.length })}</h2>
|
||||||
|
</div>
|
||||||
|
<p className="muted small">{t("dupes.intro")}</p>
|
||||||
|
|
||||||
|
<div className="account-list">
|
||||||
|
{gruppen.map((g) => (
|
||||||
|
<div className="dubletten-gruppe" key={`${g.account_id}-${g.datum}-${g.amount_cents}`}>
|
||||||
|
<div className="named-sub">
|
||||||
|
{formatDate(g.datum)} · {accountById[g.account_id]?.name || "—"} ·{" "}
|
||||||
|
<strong>
|
||||||
|
{formatMoney(g.typ === "income" ? g.amount_cents : -g.amount_cents,
|
||||||
|
accountById[g.account_id]?.currency)}
|
||||||
|
</strong>
|
||||||
|
{" · "}{t("dupes.count", { n: g.zeilen.length })}
|
||||||
|
</div>
|
||||||
|
{g.zeilen.map((z) => (
|
||||||
|
<div className="dubletten-zeile" key={z.id}>
|
||||||
|
<span className="ellipsis selectable">
|
||||||
|
{z.payee || z.description || t("inbox.noText")}
|
||||||
|
</span>
|
||||||
|
<span className="badge">{t(`todo.from.${z.herkunft}`, {
|
||||||
|
defaultValue: z.herkunft })}</span>
|
||||||
|
{/* Die Kennung entscheidet die Frage – deshalb steht sie da,
|
||||||
|
auch wenn sie hässlich ist. */}
|
||||||
|
<span className="muted small selectable nowrap">
|
||||||
|
{z.kennung || t("dupes.noRef")}
|
||||||
|
</span>
|
||||||
|
<button className="btn sm danger" onClick={() => loeschen(z)}>
|
||||||
|
<Icon name="trash" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1457,3 +1457,26 @@ tr.row-dup {
|
|||||||
fill: var(--danger);
|
fill: var(--danger);
|
||||||
color: 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -712,5 +712,13 @@
|
|||||||
"csv": "Datei-Import",
|
"csv": "Datei-Import",
|
||||||
"manuell": "von Hand"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -712,5 +712,13 @@
|
|||||||
"csv": "File import",
|
"csv": "File import",
|
||||||
"manuell": "manual"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import CategorySelect from "../components/CategorySelect";
|
|||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import IconChip from "../components/IconChip";
|
import IconChip from "../components/IconChip";
|
||||||
import Money from "../components/Money";
|
import Money from "../components/Money";
|
||||||
|
import Dubletten from "../components/Dubletten";
|
||||||
import UebertragDialog from "../components/UebertragDialog";
|
import UebertragDialog from "../components/UebertragDialog";
|
||||||
import Uebertragsvorschlaege from "../components/Uebertragsvorschlaege";
|
import Uebertragsvorschlaege from "../components/Uebertragsvorschlaege";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
@@ -208,6 +209,10 @@ export default function Eingang() {
|
|||||||
deshalb steht der Vorschlag hier und nicht in der Buchungsliste. */}
|
deshalb steht der Vorschlag hier und nicht in der Buchungsliste. */}
|
||||||
<Uebertragsvorschlaege baseCurrency={baseCurrency} onChange={laden} />
|
<Uebertragsvorschlaege baseCurrency={baseCurrency} onChange={laden} />
|
||||||
|
|
||||||
|
{/* Gleich aussehende Buchungen gehören hierher: Sie sind Arbeit, die
|
||||||
|
liegen bleibt, und man findet sie in der Buchungsliste nur zufällig. */}
|
||||||
|
<Dubletten accountById={accountById} onChange={laden} />
|
||||||
|
|
||||||
{aufgaben.length === 0 ? (
|
{aufgaben.length === 0 ? (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
|
|||||||
Reference in New Issue
Block a user