Milestone 13: Duplicate Detection – Wie ich doppelte Bank-Transaktionen in YNAB verhindere
Milestone 13: Duplicate Detection
Heute habe ich eines der kritischsten Features für BudgetBuddy implementiert: die Erkennung von Duplikaten. Ohne dieses Feature würde jeder Sync-Vorgang potenziell dieselben Transaktionen mehrfach zu YNAB importieren – ein Alptraum für jeden, der sein Budget sauber halten möchte.
Das Problem: Warum Duplicate Detection nicht trivial ist
Auf den ersten Blick klingt Duplicate Detection einfach: “Prüfe, ob die Transaktion schon existiert.” Aber in der Praxis gibt es mehrere Herausforderungen:
-
Keine eindeutige ID zwischen Systemen: Die Comdirect-Bank hat eine Referenznummer, YNAB hat eine andere ID. Es gibt keine natürliche Verbindung.
-
Daten können sich unterscheiden: Die Bank sagt “AMAZON EU S.A R.L.”, der Benutzer hat in YNAB vielleicht “Amazon” eingetragen.
-
Zeitversatz: Manchmal bucht die Bank eine Transaktion an einem Tag, YNAB zeigt sie aber mit einem anderen Datum.
-
Legacy-Kompatibilität: Das alte Comdirect2YNAB-Projekt speicherte die Referenz im Memo-Feld als “…, Ref: ABC123”. Ich musste dieses Format verstehen und unterstützen.
Ausgangslage: Was war bereits vorhanden?
BudgetBuddy hatte bereits:
- Einen funktionierenden Sync-Flow (Comdirect → Rules Engine → YNAB)
- Die
SyncTransaction-Struktur mit Status-Tracking - Eine
YnabClient.fsmit Funktionen zum Erstellen von Transaktionen
Was fehlte:
- Das Abrufen existierender YNAB-Transaktionen
- Jegliche Form von Duplikat-Erkennung
- UI-Feedback für potenzielle Duplikate
Herausforderung 1: Die richtige Datenstruktur für Duplikat-Status
Das Problem
Wie repräsentiere ich den Duplikat-Status einer Transaktion? Die naive Lösung wäre ein Boolean (isDuplicate), aber das ist zu grob. Ich wollte drei Zustände unterscheiden:
- Kein Duplikat: Transaktion ist neu
- Mögliches Duplikat: Ähnliche Transaktion gefunden, aber nicht sicher
- Bestätigtes Duplikat: Definitiv schon importiert (Referenz-Match)
Optionen, die ich betrachtet habe
Option 1: Boolean mit optionalem Grund
type SyncTransaction = {
// ...
IsDuplicate: bool
DuplicateReason: string option
}
- Pro: Einfach
- Contra: Keine Unterscheidung zwischen “sicher” und “möglich”
Option 2: Enum mit separatem Grund-Feld
type DuplicateLevel = None | Possible | Confirmed
type SyncTransaction = {
// ...
DuplicateLevel: DuplicateLevel
DuplicateReason: string option
}
- Pro: Klare Abstufung
- Contra: Zwei Felder, die zusammengehören aber getrennt sind
Option 3: Discriminated Union (gewählt)
type DuplicateStatus =
| NotDuplicate
| PossibleDuplicate of reason: string
| ConfirmedDuplicate of reference: string
- Pro: Selbstdokumentierend, unmöglich inkonsistente Zustände zu haben
- Contra: Keiner – das ist der F#-Weg
Die Lösung
Ich habe mich für die Discriminated Union entschieden. Der entscheidende Vorteil: Man kann keinen Fehler machen. Bei Option 1 könnte isDuplicate = true mit DuplicateReason = None existieren. Bei Option 3 ist das strukturell unmöglich.
// Aus src/Shared/Domain.fs
type DuplicateStatus =
| NotDuplicate // Kein Duplikat erkannt
| PossibleDuplicate of reason: string // Vielleicht ein Duplikat (Datum/Betrag/Payee-Match)
| ConfirmedDuplicate of reference: string // Definitiv Duplikat (Referenz-Match)
Rationale für die Argumente:
PossibleDuplicateträgt einenreason-String, weil ich dem Benutzer erklären möchte, warum ich denke, dass es ein Duplikat sein könnte (“Similar transaction found: AMAZON EU on 2025-12-03 for -50.00”)ConfirmedDuplicateträgt diereference, weil das der Beweis ist – die eindeutige Referenz-ID, die in beiden Systemen existiert
Herausforderung 2: Legacy-Format parsen – Reference Extraction
Das Problem
Das alte Comdirect2YNAB-Projekt speicherte die Bank-Referenz im YNAB-Memo-Feld in diesem Format:
AMAZON EU S.A.R.L., Online-Einkauf, Ref: 2024123456789
Ich musste diese Information extrahieren, um bestätigte Duplikate zu erkennen.
Die Lösung: Regex mit Pattern Matching
// Aus src/Server/DuplicateDetection.fs
let private referenceRegex = new Regex(@"Ref:\s*(.+)$", RegexOptions.Compiled)
let extractReference (memo: string option) : string option =
match memo with
| None -> None
| Some m when String.IsNullOrWhiteSpace m -> None
| Some m ->
let result = referenceRegex.Match(m)
if result.Success then
let refValue = result.Groups[1].Value.Trim()
if String.IsNullOrWhiteSpace refValue then None
else Some refValue
else
None
Warum so defensiv?
memokönnteNoneseinmemokönnte ein leerer String sein- Das Regex könnte keinen Match finden
- Der extrahierte Wert könnte nur Whitespace sein
Jeder dieser Fälle gibt None zurück. Das ist der F#-Ansatz: Explizit sein über alle Möglichkeiten.
Warum RegexOptions.Compiled?
Das Regex wird bei jedem YNAB-Transaktions-Check verwendet. Bei 100+ Transaktionen summiert sich das. Compiled bedeutet, dass das Regex zu IL-Code kompiliert wird – etwa 10x schneller als interpretierte Regex.
Herausforderung 3: Die drei Erkennungsmethoden und ihre Priorität
Das Problem
Ich habe drei Wege, um Duplikate zu erkennen:
- Reference Match: Die Bank-Referenz steht im YNAB-Memo
- Import-ID Match: Die von BudgetBuddy generierte
import_idist in YNAB - Fuzzy Match: Datum, Betrag und Payee stimmen überein
Aber welche Methode hat Vorrang? Was passiert, wenn Methode 1 “kein Duplikat” sagt, aber Methode 3 “mögliches Duplikat”?
Die Lösung: Klare Prioritätsreihenfolge
let detectDuplicate config ynabTransactions bankTx : DuplicateStatus =
// Zuerst: Exakter Reference-Match (höchste Sicherheit)
let referenceMatch =
ynabTransactions
|> List.tryFind (matchesByReference bankTx)
match referenceMatch with
| Some _ -> ConfirmedDuplicate bankTx.Reference
| None ->
// Zweitens: Import-ID Match (auch sehr sicher)
let importIdMatch =
ynabTransactions
|> List.tryFind (matchesByImportId bankTx)
match importIdMatch with
| Some _ -> ConfirmedDuplicate bankTx.Reference
| None ->
// Drittens: Fuzzy Match (nur "möglich", nicht "bestätigt")
let fuzzyMatch =
ynabTransactions
|> List.tryFind (matchesByDateAmountPayee config bankTx)
match fuzzyMatch with
| Some ynabTx ->
let reason = sprintf "Similar transaction found: %s on %s for %.2f"
(ynabTx.Payee |> Option.defaultValue "Unknown")
(ynabTx.Date.ToString("yyyy-MM-dd"))
ynabTx.Amount.Amount
PossibleDuplicate reason
| None ->
NotDuplicate
Architekturentscheidung: Warum diese Reihenfolge?
-
Reference Match zuerst: Die Bank-Referenz ist eindeutig. Wenn sie existiert, ist es definitiv ein Duplikat.
-
Import-ID zweiter: Die
import_idist von BudgetBuddy generiert (BUDGETBUDDY:txId:ticks). Wenn sie existiert, haben wir die Transaktion selbst importiert. -
Fuzzy Match zuletzt: Datum + Betrag + Payee kann zufällig übereinstimmen (zwei Amazon-Käufe am selben Tag). Daher nur “möglich”, nicht “bestätigt”.
Warum schlägt ein Reference-Match einen Fuzzy-Match?
Stell dir vor: Eine Amazon-Transaktion hat Referenz “REF123”. Das alte System hat sie importiert. Dann kaufst du NOCHMAL bei Amazon, am selben Tag, für denselben Betrag.
- Fuzzy würde BEIDE als “Duplikat” markieren
- Reference prüft korrekt: Nur REF123 ist ein Duplikat, REF456 (die neue) ist es nicht
Herausforderung 4: Fuzzy Matching richtig implementieren
Das Problem
Für den Fuzzy-Match musste ich entscheiden:
- Wie viele Tage Toleranz beim Datum?
- Exakter oder prozentualer Betrags-Vergleich?
- Wie vergleiche ich Payee-Namen, die unterschiedlich formatiert sind?
Die Lösung: Konfigurierbare Toleranzen + Fuzzy String-Matching
type DuplicateMatchConfig = {
DateToleranceDays: int
AmountTolerancePercent: decimal
}
let defaultConfig = {
DateToleranceDays = 1 // 1 Tag Toleranz
AmountTolerancePercent = 0.01m // 1% (aktuell nicht verwendet)
}
let matchesByDateAmountPayee config bankTx ynabTx : bool =
// Datum-Check mit Toleranz
let dateDiff = abs (bankTx.BookingDate.Date - ynabTx.Date.Date).Days
let dateMatches = dateDiff <= config.DateToleranceDays
// Betrags-Check (exakt - sollte identisch sein)
let amountMatches = bankTx.Amount.Amount = ynabTx.Amount.Amount
// Payee-Check (fuzzy - einer enthält den anderen)
let payeeMatches =
match bankTx.Payee, ynabTx.Payee with
| Some bankPayee, Some ynabPayee ->
let bankNormalized = bankPayee.ToUpperInvariant().Trim()
let ynabNormalized = ynabPayee.ToUpperInvariant().Trim()
bankNormalized.Contains(ynabNormalized) ||
ynabNormalized.Contains(bankNormalized) ||
bankNormalized = ynabNormalized
| _, _ -> false // Kein Match wenn Payee fehlt
dateMatches && amountMatches && payeeMatches
Rationale für die Entscheidungen:
-
1 Tag Datums-Toleranz: Manche Transaktionen werden an einem Tag initiiert, aber erst am nächsten gebucht. 1 Tag fängt das ab, ohne zu viele False Positives.
-
Exakter Betrags-Vergleich: Im Gegensatz zum Datum gibt es beim Betrag keinen “natürlichen” Unterschied. -50.00€ ist -50.00€. Prozentuale Toleranz würde nur Verwirrung stiften.
-
Fuzzy Payee-Matching mit Contains:
- Bank sagt: “AMAZON EU S.A.R.L.”
- YNAB sagt: “Amazon”
Containsfängt das ab
Warum kein Levenshtein-Distance?
Ich habe überlegt, “echtes” Fuzzy-Matching mit Edit-Distance zu verwenden. Aber:
- Overhead für 100+ Transaktionen
- Die
Contains-Logik deckt 95% der Fälle ab - Zu viele False Positives bei kurzen Payee-Namen (“DM” würde “ADMIN” matchen)
KISS – Keep It Simple, Stupid.
Herausforderung 5: Integration in den Sync-Flow
Das Problem
Wo genau im Flow prüfe ich auf Duplikate? Die Optionen:
- Beim Import: Kurz vor dem Senden an YNAB
- Nach dem Fetchen: Direkt nach dem Laden der Bank-Transaktionen
- On-Demand: Erst wenn der User eine Transaktion anklickt
Die Lösung: Nach dem Fetchen, vor dem Review
Ich habe mich für Option 2 entschieden. Im confirmTan-Handler passiert folgendes:
// Aus src/Server/Api.fs
| Ok bankTransactions ->
// 1. Rules Engine anwenden
let! allRules = Persistence.Rules.getAllRules()
match classifyTransactions allRules bankTransactions with
| Ok syncTransactions ->
// 2. Duplikate erkennen
let! syncTransactionsWithDuplicates = async {
let! tokenOpt = Persistence.Settings.getSetting "ynab_token"
let! budgetIdOpt = Persistence.Settings.getSetting "ynab_default_budget_id"
let! accountIdOpt = Persistence.Settings.getSetting "ynab_default_account_id"
match tokenOpt, budgetIdOpt, accountIdOpt with
| Some token, Some budgetId, Some accountIdStr ->
match Guid.TryParse(accountIdStr) with
| true, accountIdGuid ->
// YNAB-Transaktionen laden (syncDays + 7 Tage extra)
match! YnabClient.getAccountTransactions
token
(YnabBudgetId budgetId)
(YnabAccountId accountIdGuid)
(settings.Sync.DaysToFetch + 7) with
| Ok ynabTransactions ->
return DuplicateDetection.markDuplicates ynabTransactions syncTransactions
| Error _ ->
return syncTransactions // Fallback ohne Detection
// ...
| _ ->
return syncTransactions
}
// 3. Zur Session hinzufügen
SyncSessionManager.addTransactions syncTransactionsWithDuplicates
Warum syncDays + 7?
Wenn der User 30 Tage Transaktionen abruft, lade ich 37 Tage aus YNAB. Grund: Datums-Toleranz. Eine Transaktion von vor 31 Tagen in der Bank könnte vor 30 Tagen in YNAB gebucht sein.
Warum Fallback ohne Detection?
Wenn YNAB nicht erreichbar ist, ist das kein Grund, den Sync abzubrechen. Der User sieht dann halt keine Duplikat-Warnungen – besser als gar kein Sync.
Herausforderung 6: Frontend-Visualisierung
Das Problem
Wie zeige ich Duplikate im UI an, ohne den User zu überfordern?
Die Lösung: Farbcodierte Karten + Warnung-Banner
Ich habe drei visuelle Elemente implementiert:
1. Border-Farbe der Transaktions-Karte
let borderClass =
if isDuplicate then
"border-l-4 border-l-neon-red bg-neon-red/5"
elif isPossibleDuplicate then
"border-l-4 border-l-neon-orange bg-neon-orange/5"
else
match tx.Status with
// ... normale Status-Farben
2. Warnung-Banner auf der Karte selbst
match tx.DuplicateStatus with
| NotDuplicate -> () // Nichts anzeigen
| PossibleDuplicate reason ->
Html.div [
prop.className "... bg-neon-orange/10 border border-neon-orange/30"
prop.children [
Icons.warning Icons.SM Icons.NeonOrange
Html.span [ prop.text reason ]
Html.span [ prop.text "You can still import if it's not a duplicate." ]
]
]
| ConfirmedDuplicate reference ->
Html.div [
prop.className "... bg-neon-red/10 border border-neon-red/30"
prop.children [
Icons.xCircle Icons.SM Icons.Error
Html.span [ prop.text $"Already imported (Ref: {reference})" ]
Html.span [ prop.text "Consider skipping this transaction." ]
]
]
3. Zusammenfassungs-Banner für die gesamte Liste
let duplicates = transactions |> List.filter (fun tx ->
match tx.DuplicateStatus with
| ConfirmedDuplicate _ | PossibleDuplicate _ -> true
| _ -> false
) |> List.length
if duplicates > 0 then
Html.div [
prop.className "... bg-neon-orange/10"
prop.children [
Html.p [ prop.text $"{duplicates} potential duplicate(s) detected" ]
Html.p [ prop.text "Review transactions marked in orange or red before importing." ]
]
]
Rationale: Warum “Can still import”?
Manchmal sind Fuzzy-Matches falsch. Zwei echte Amazon-Käufe am selben Tag für denselben Betrag sind KEIN Duplikat. Der User muss das überstimmen können. Daher die Formulierung: “You can still import if it’s not a duplicate.”
Herausforderung 7: Umfassende Tests schreiben
Das Problem
Duplicate Detection hat viele Edge Cases:
Nonefür Memo/Payee- Leere Listen
- Prioritäts-Reihenfolge der Matching-Methoden
- Datum-Grenzen
Wie stelle ich sicher, dass alles funktioniert?
Die Lösung: 28 gezielte Unit-Tests
Ich habe die Tests nach Funktion gruppiert:
// Reference Extraction: 7 Tests
"extractReference returns reference from standard format"
"extractReference handles reference with spaces"
"extractReference handles Ref with extra space"
"extractReference returns None for None memo"
"extractReference returns None for empty string"
"extractReference returns None for whitespace"
"extractReference returns None for memo without Ref"
// Match By Reference: 4 Tests
"matchesByReference returns true when references match"
"matchesByReference returns false when references differ"
"matchesByReference returns false when YNAB memo has no reference"
"matchesByReference returns false when YNAB memo is None"
// Match By Import ID: 3 Tests
// Match By Date/Amount/Payee: 8 Tests
// Detect Duplicate: 5 Tests
// Mark Duplicates: 1 Test
// Additional Edge Cases: 4 Tests
// Count Duplicates: 1 Test
Beispiel: Prioritäts-Test
test "detectDuplicate prioritizes reference match over fuzzy match" {
let today = DateTime.Today
let bankTx = createBankTransaction "REF123" (Some "AMAZON EU") "Memo" -50m today
// Diese YNAB-Transaktion würde BEIDE Methoden matchen
let ynabTransactions = [
createYnabTransaction "id1" today -50m (Some "AMAZON EU")
(Some "Desc, Ref: REF123") None
]
let result = detectDuplicate defaultConfig ynabTransactions bankTx
// Muss ConfirmedDuplicate sein, nicht PossibleDuplicate
match result with
| ConfirmedDuplicate _ -> ()
| _ -> failwith "Expected ConfirmedDuplicate when reference matches"
}
Dieser Test stellt sicher, dass selbst wenn Fuzzy AUCH matchen würde, die Reference-Methode gewinnt.
Lessons Learned
Was würde ich anders machen?
-
Früher mit dem Legacy-Format beschäftigen: Ich habe erst beim Implementieren gemerkt, dass das alte Format “Ref: ABC” ist. Das hätte ich vorab im Legacy-Code recherchieren sollen.
-
Die Config von Anfang an konfigurierbar machen: Aktuell ist
DateToleranceDays = 1fest. In Zukunft sollte das in den Settings sein. -
Persistierung des Duplikat-Status: Aktuell wird der Status nicht in der Datenbank gespeichert. Wenn der Server neustartet während des Reviews, geht die Duplikat-Info verloren.
Was lief gut?
-
Discriminated Union für Status: Die klare Typisierung hat Bugs verhindert und macht den Code selbstdokumentierend.
-
Defensive Programmierung bei
extractReference: Jeder möglicheNone-Fall wird behandelt. -
Test-First für Edge Cases: Durch das Schreiben der Tests WÄHREND der Implementierung habe ich Bugs gefunden, bevor sie in Production gelandet wären.
Fazit
Was wurde erreicht?
- 2 neue Dateien:
src/Server/DuplicateDetection.fs(159 Zeilen)src/Tests/DuplicateDetectionTests.fs(511 Zeilen)
-
6 modifizierte Dateien: Domain.fs, YnabClient.fs, RulesEngine.fs, Api.fs, View.fs, Tests
-
148 Tests passieren (28 neu für Duplicate Detection)
-
3 Erkennungsmethoden: Reference, Import-ID, Fuzzy (Datum/Betrag/Payee)
- 2 UI-Warnstufen: Orange für “möglich”, Rot für “bestätigt”
Die wichtigste Erkenntnis
Duplicate Detection ist ein Paradebeispiel für “einfach klingendes Problem, komplexe Lösung”. Die naive Lösung (“prüfe ob gleich”) hätte nicht funktioniert. Die Kombination aus drei Methoden mit klarer Priorität ist robust und benutzerfreundlich.
Key Takeaways für Neulinge
-
Discriminated Unions sind dein Freund: Anstatt
bool+string optionzu verwenden, mache unmögliche Zustände unmöglich durch richtige Typisierung. -
Defensive Programmierung bei externen Daten: Wenn du Daten aus einer Legacy-Quelle parsst, behandle JEDEN möglichen Fehlerfall explizit.
None, leere Strings, Whitespace – alles. -
Prioritäten explizit machen: Wenn du mehrere Methoden hast, die das gleiche Ergebnis liefern können, dokumentiere die Reihenfolge im Code. Nicht “hoffen dass es passt”, sondern testen dass die richtige Methode gewinnt.