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:

  1. Keine eindeutige ID zwischen Systemen: Die Comdirect-Bank hat eine Referenznummer, YNAB hat eine andere ID. Es gibt keine natürliche Verbindung.

  2. Daten können sich unterscheiden: Die Bank sagt “AMAZON EU S.A R.L.”, der Benutzer hat in YNAB vielleicht “Amazon” eingetragen.

  3. Zeitversatz: Manchmal bucht die Bank eine Transaktion an einem Tag, YNAB zeigt sie aber mit einem anderen Datum.

  4. 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.fs mit 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:

  1. Kein Duplikat: Transaktion ist neu
  2. Mögliches Duplikat: Ähnliche Transaktion gefunden, aber nicht sicher
  3. 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:

  • PossibleDuplicate trägt einen reason-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”)
  • ConfirmedDuplicate trägt die reference, 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?

  1. memo könnte None sein
  2. memo könnte ein leerer String sein
  3. Das Regex könnte keinen Match finden
  4. 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:

  1. Reference Match: Die Bank-Referenz steht im YNAB-Memo
  2. Import-ID Match: Die von BudgetBuddy generierte import_id ist in YNAB
  3. 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?

  1. Reference Match zuerst: Die Bank-Referenz ist eindeutig. Wenn sie existiert, ist es definitiv ein Duplikat.

  2. Import-ID zweiter: Die import_id ist von BudgetBuddy generiert (BUDGETBUDDY:txId:ticks). Wenn sie existiert, haben wir die Transaktion selbst importiert.

  3. 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. 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.

  2. 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.

  3. Fuzzy Payee-Matching mit Contains:

    • Bank sagt: “AMAZON EU S.A.R.L.”
    • YNAB sagt: “Amazon”
    • Contains fä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:

  1. Beim Import: Kurz vor dem Senden an YNAB
  2. Nach dem Fetchen: Direkt nach dem Laden der Bank-Transaktionen
  3. 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:

  • None fü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?

  1. 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.

  2. Die Config von Anfang an konfigurierbar machen: Aktuell ist DateToleranceDays = 1 fest. In Zukunft sollte das in den Settings sein.

  3. 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?

  1. Discriminated Union für Status: Die klare Typisierung hat Bugs verhindert und macht den Code selbstdokumentierend.

  2. Defensive Programmierung bei extractReference: Jeder mögliche None-Fall wird behandelt.

  3. 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

  1. Discriminated Unions sind dein Freund: Anstatt bool + string option zu verwenden, mache unmögliche Zustände unmöglich durch richtige Typisierung.

  2. Defensive Programmierung bei externen Daten: Wenn du Daten aus einer Legacy-Quelle parsst, behandle JEDEN möglichen Fehlerfall explizit. None, leere Strings, Whitespace – alles.

  3. 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.