feat: Zeitraum an der Karte, und eine schaerfere Suche nach Dubletten

Zwei Dinge, die zusammen die Frage beantworten "zaehlt das wirklich ab
meinem Stichtag, und sind das wirklich zwei Zahlungen?".

Die Karte Ausgaben-Anteile nennt jetzt ihren Zeitraum. Gerechnet hat sie
schon immer ueber den eingestellten Budget-Monat (25.08.-24.09. bei
Starttag 25); nachvollziehen liess sich das nur nicht, weil die Karte
nirgends sagte, worueber sie summiert.

Die Dublettensuche vergleicht nicht mehr stur ueber den Text. Ein krummer
Betrag wie 10.43 kommt an einem Tag kaum zweimal vor - auch wenn zwei
Quellen ihn verschieden benennen, und genau daran scheiterte der
Textvergleich. Bei einem runden Betrag (Vielfaches von 50 Rappen) muss der
Text weiter passen, sonst waeren zwei Zwanziger am selben Tag ein Fund.

Und was die Bank selbst als verschieden gemeldet hat, gilt als geklaert:
Stammen alle Zeilen aus demselben Bankkonto und tragen verschiedene
Kennungen, verschweigt die Karte sie. Das sind die Revolut-Aufrundungen,
die echt sind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-30 14:39:08 +02:00
co-authored by Claude Opus 5
parent 335a7d55c8
commit bd79b83339
5 changed files with 127 additions and 7 deletions
+5
View File
@@ -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",
+37 -6
View File
@@ -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
],
}