Transparente Duplicate Detection: Warum zeigt mir BudgetBuddy Duplikate an?
Eine der frustrierendsten Erfahrungen mit Software ist, wenn sie Entscheidungen trifft, die man nicht nachvollziehen kann. “Diese Transaktion ist ein Duplikat” - aber warum? In dieser Session habe ich BudgetBuddys Duplicate Detection komplett transparent gemacht. Jeder User kann jetzt genau sehen, warum eine Transaktion als Duplikat erkannt wurde - oder warum YNAB sie trotzdem abgelehnt hat.
Ausgangslage: Zwei Systeme, eine Verwirrung
BudgetBuddy hat zwei separate Duplicate-Detection-Systeme, die unabhängig voneinander arbeiten:
-
BudgetBuddys Pre-Import Detection: Bevor Transaktionen an YNAB gesendet werden, prüft BudgetBuddy gegen existierende YNAB-Transaktionen (Reference-Matching, ImportId-Matching, Fuzzy-Matching).
-
YNABs eigene Rejection: YNAB hat ein eigenes Duplikat-System basierend auf
import_id. Wenn BudgetBuddy eine Transaktion sendet, kann YNAB sie trotzdem ablehnen.
Das Problem: Beide Systeme wurden in der UI nicht unterschieden. Ein User sah nur “X Duplikate” und einen “Re-import?”-Button - ohne zu verstehen, welches System die Entscheidung getroffen hat.
Herausforderung 1: Domain Modeling für zwei Detection-Systeme
Das Problem
Wie modelliert man zwei unabhängige Systeme, die beide “Duplikat” sagen können, aber aus unterschiedlichen Gründen?
Die ursprüngliche Modellierung war zu simpel:
// VORHER: Keine Unterscheidung woher das Duplikat kam
type DuplicateStatus =
| NotDuplicate
| PossibleDuplicate of reason: string
| ConfirmedDuplicate of reference: string
Optionen, die ich betrachtet habe
- Boolean Flags erweitern (nicht gewählt)
- Pro: Einfach
- Contra: Explodiert bei mehr Fällen, keine strukturierte Information
- Separate Types für Pre-Import und Post-Import (gewählt)
- Pro: Klare Trennung, jedes System hat eigene Semantik
- Contra: Mehr Typen, mehr Komplexität
- Ein großer Union Type für alles (nicht gewählt)
- Pro: Alles an einem Ort
- Contra: Vermischt zwei konzeptuell unterschiedliche Dinge
Die Lösung: Zwei separate Types
Ich habe zwei klar getrennte Konzepte modelliert:
1. BudgetBuddys Pre-Import Analysis:
/// Details about why BudgetBuddy detected (or didn't detect) this as a duplicate.
/// Purpose: Provides transparency into the duplicate detection algorithm for debugging.
type DuplicateDetectionDetails = {
/// The bank transaction's Reference field from Comdirect
TransactionReference: string
/// Did we find this Reference in any YNAB transaction memo ("Ref: X")?
ReferenceFoundInYnab: bool
/// Did we find an ImportId starting with "BUDGETBUDDY:{txId}" in YNAB?
ImportIdFoundInYnab: bool
/// If fuzzy matched: matched YNAB transaction date
FuzzyMatchDate: DateTime option
/// If fuzzy matched: matched YNAB transaction amount
FuzzyMatchAmount: decimal option
/// If fuzzy matched: matched YNAB transaction payee
FuzzyMatchPayee: string option
}
type DuplicateStatus =
| NotDuplicate of details: DuplicateDetectionDetails
| PossibleDuplicate of reason: string * details: DuplicateDetectionDetails
| ConfirmedDuplicate of reference: string * details: DuplicateDetectionDetails
2. YNABs Post-Import Response:
/// Why YNAB rejected a transaction during import
type YnabRejectionReason =
| DuplicateImportId of importId: string
| UnknownRejection of rawResponse: string option
/// Status of YNAB's import attempt for a transaction
type YnabImportStatus =
| NotAttempted // Import not yet tried
| YnabImported // Successfully imported to YNAB
| RejectedByYnab of YnabRejectionReason
Architekturentscheidung: Warum Details in allen DuplicateStatus-Varianten?
Beachte, dass DuplicateDetectionDetails in jeder Variante von DuplicateStatus enthalten ist - auch in NotDuplicate. Das ist bewusst:
- Debugging: Auch wenn keine Duplikate erkannt wurden, will der User sehen, welche Checks durchgeführt wurden.
- Transparenz: “Wir haben geprüft: Reference nicht in YNAB, ImportId nicht in YNAB, kein Fuzzy-Match” ist wertvoller als nur “Kein Duplikat”.
- Konsistenz: Ein Helper wie
getDuplicateDetailsfunktioniert für alle Fälle.
/// Helper to extract details from any DuplicateStatus
let getDuplicateDetails (status: DuplicateStatus) : DuplicateDetectionDetails =
match status with
| NotDuplicate details -> details
| PossibleDuplicate (_, details) -> details
| ConfirmedDuplicate (_, details) -> details
Herausforderung 2: Die Detection-Logik transparent machen
Das Problem
Die ursprüngliche detectDuplicate-Funktion gab nur das Ergebnis zurück, nicht den Weg dorthin:
// VORHER: Black Box - nur das Ergebnis
let detectDuplicate ynabTransactions bankTx : DuplicateStatus =
// ... interne Logik ...
ConfirmedDuplicate bankTx.Reference // Keine Details!
Die Lösung: Alle Checks durchführen und dokumentieren
Die neue Implementierung führt alle drei Checks durch und speichert die Ergebnisse:
let detectDuplicate
(config: DuplicateMatchConfig)
(ynabTransactions: YnabTransaction list)
(bankTx: BankTransaction)
: DuplicateStatus =
// Check 1: Exact reference match (confirmed duplicate)
let referenceMatch =
ynabTransactions
|> List.tryFind (matchesByReference bankTx)
// Check 2: Import_id match (confirmed duplicate)
let importIdMatch =
ynabTransactions
|> List.tryFind (matchesByImportId bankTx)
// Check 3: Fuzzy match by date/amount/payee (possible duplicate)
let fuzzyMatch =
ynabTransactions
|> List.tryFind (matchesByDateAmountPayee config bankTx)
// Build diagnostic details about ALL checks
let details: DuplicateDetectionDetails = {
TransactionReference = bankTx.Reference
ReferenceFoundInYnab = referenceMatch.IsSome
ImportIdFoundInYnab = importIdMatch.IsSome
FuzzyMatchDate = fuzzyMatch |> Option.map (fun tx -> tx.Date)
FuzzyMatchAmount = fuzzyMatch |> Option.map (fun tx -> tx.Amount.Amount)
FuzzyMatchPayee = fuzzyMatch |> Option.bind (fun tx -> tx.Payee)
}
// Determine status with priority: Reference > ImportId > Fuzzy > None
match referenceMatch with
| Some _ -> ConfirmedDuplicate (bankTx.Reference, details)
| None ->
match importIdMatch with
| Some _ -> ConfirmedDuplicate (bankTx.Reference, details)
| None ->
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, details)
| None ->
NotDuplicate details
Rationale für die Priorität Reference > ImportId > Fuzzy:
- Reference-Match ist der zuverlässigste Check. Die Comdirect-Reference ist eindeutig.
- ImportId-Match bedeutet, BudgetBuddy hat diese Transaktion schon einmal importiert.
- Fuzzy-Match ist nur eine Vermutung basierend auf Datum/Betrag/Payee.
Herausforderung 3: Das Debug-Info-Panel in der UI
Das Problem
Wie zeigt man technische Debugging-Informationen so an, dass sie:
- Für Neulinge verständlich sind
- Für Power-User nützlich sind
- Die UI nicht überladen
Die Lösung: Expandierbares Debug-Panel
Das Panel erscheint, wenn eine Transaktion expandiert wird, und zeigt alle relevanten Informationen:
/// Debug info panel showing duplicate detection diagnostics
let private duplicateDebugInfo (tx: SyncTransaction) =
let details = getDuplicateDetails tx.DuplicateStatus
Html.div [
prop.className "mt-3 px-3 py-2.5 rounded-lg bg-base-200/50 text-xs font-mono space-y-2 border border-white/5"
prop.children [
// Section header
Html.div [
prop.className "flex items-center gap-2 text-neon-teal/80 font-medium"
prop.children [
Icons.search Icons.XS Icons.NeonTeal
Html.span [ prop.text "BudgetBuddy Duplicate Detection" ]
]
]
// Reference info
Html.div [
prop.className "flex items-center gap-2 flex-wrap"
prop.children [
Html.span [ prop.text "Reference:" ]
Html.code [ prop.text details.TransactionReference ]
if details.ReferenceFoundInYnab then
Html.span [
prop.className "bg-neon-green/20 text-neon-green"
prop.text "Found in YNAB"
]
else
Html.span [
prop.className "bg-base-content/10 text-base-content/50"
prop.text "Not in YNAB"
]
]
]
// Import ID info
Html.div [
prop.children [
Html.span [ prop.text "Import ID:" ]
if details.ImportIdFoundInYnab then
Html.span [ prop.className "text-neon-green"; prop.text "Exists in YNAB" ]
else
Html.span [ prop.text "New" ]
]
]
// Fuzzy match info (only if applicable)
match details.FuzzyMatchDate, details.FuzzyMatchAmount, details.FuzzyMatchPayee with
| Some date, Some amount, payee ->
Html.div [
prop.className "text-neon-orange"
prop.children [
Icons.warning Icons.XS Icons.NeonOrange
Html.span [
prop.text $"Fuzzy match: {payee |> Option.defaultValue "?"} on {date:yyyy-MM-dd} for {amount:F2}"
]
]
]
| _ -> Html.none
// YNAB Import Status (only if attempted)
match tx.YnabImportStatus with
| NotAttempted -> Html.none
| YnabImported ->
Html.div [
prop.className "text-neon-green"
prop.children [ Html.span [ prop.text "YNAB: Successfully imported" ] ]
]
| RejectedByYnab reason ->
let reasonText =
match reason with
| DuplicateImportId id -> $"YNAB rejected: duplicate import_id ({id})"
| UnknownRejection msg -> $"YNAB rejected: {msg |> Option.defaultValue "unknown"}"
Html.div [
prop.className "text-neon-red"
prop.children [
Html.span [ prop.text reasonText ]
// Highlight discrepancy
match tx.DuplicateStatus with
| NotDuplicate _ ->
Html.span [
prop.className "text-neon-orange font-medium"
prop.text "(BudgetBuddy missed this!)"
]
| _ -> Html.none
]
]
]
]
Design-Entscheidungen:
- Monospace-Font: Technische Daten wie References lesen sich besser in Monospace.
- Farbcodierung: Grün = gefunden/OK, Orange = Warnung/Fuzzy, Rot = Problem/Abgelehnt.
- “BudgetBuddy missed this!”: Wenn YNAB eine Transaktion ablehnt, die BudgetBuddy nicht erkannt hat, ist das ein wichtiger Hinweis für den User (und für mich als Entwickler).
Herausforderung 4: Separate Banner für Pre-Import vs. Post-Import
Das Problem
Ein einziges “X Duplikate”-Banner war verwirrend:
- Sind das Duplikate, die BudgetBuddy erkannt hat?
- Oder Transaktionen, die YNAB abgelehnt hat?
- Oder beides zusammen?
Die Lösung: Zwei getrennte Banner
Banner 1: BudgetBuddy’s Pre-Import Detection (Teal)
// Banner for confirmed duplicates (BudgetBuddy's pre-import detection)
if confirmedDuplicates > 0 then
Html.div [
prop.className "bg-neon-teal/10 border border-neon-teal/30"
prop.children [
Html.p [ prop.text (sprintf "%d pre-detected duplicates (BudgetBuddy)" confirmedDuplicates) ]
Html.span [
prop.className "bg-neon-teal/20 text-neon-teal"
prop.text "Pre-Import"
]
Html.p [ prop.text "Diese Transaktionen wurden automatisch übersprungen." ]
]
]
Banner 2: YNAB’s Post-Import Rejection (Rot)
// Banner for YNAB-rejected transactions (red - these were rejected during import)
if ynabRejected > 0 then
Html.div [
prop.className "bg-neon-red/10 border border-neon-red/30"
prop.children [
Html.p [ prop.text (sprintf "%d rejected by YNAB" ynabRejected) ]
Html.span [
prop.className "bg-neon-red/20 text-neon-red"
prop.text "Post-Import"
]
Html.p [ prop.text "YNAB hat diese während des Imports abgelehnt." ]
Button.view {
Button.defaultProps with
Text = sprintf "Force Re-import %d" ynabRejected
OnClick = fun () -> dispatch ForceImportDuplicates
}
]
]
Rationale für die Farben:
- Teal (BudgetBuddy): Informativ, nicht alarmierend. “Wir haben das für dich erkannt.”
- Rot (YNAB rejected): Achtung! “YNAB hat etwas abgelehnt, das wir nicht erkannt haben.”
Herausforderung 5: Der Force-Re-import-Button-Bug
Das Problem
Ein subtiler Bug: Der “Re-import X Duplicate(s)”-Button erschien bevor überhaupt ein Import versucht wurde.
Die fehlerhafte Logik zählte alle Transaktionen, die:
- Nicht Imported
- Nicht Skipped
- Eine Kategorie haben
Das waren alle “import-bereiten” Transaktionen - nicht die von YNAB abgelehnten!
Die Lösung
Der Button erscheint jetzt nur für Transaktionen mit YnabImportStatus = RejectedByYnab:
// Show force import button ONLY if YNAB rejected transactions during import
let ynabRejectedCount =
match model.SyncTransactions with
| Success transactions ->
transactions
|> List.filter (fun tx ->
match tx.YnabImportStatus with
| RejectedByYnab _ -> true
| _ -> false)
|> List.length
| _ -> 0
if ynabRejectedCount > 0 then
Button.view {
Button.defaultProps with
Text = $"Re-import {ynabRejectedCount} Rejected"
OnClick = fun () -> dispatch ForceImportDuplicates
}
Rationale:
Vor dem Import ist YnabImportStatus = NotAttempted für alle Transaktionen. Der Count ist 0, der Button ist versteckt. Erst nach einem Import-Versuch kann RejectedByYnab auftreten.
Herausforderung 6: API-Update für YnabImportStatus
Das Problem
Die API musste die YnabImportStatus für jede Transaktion setzen - basierend auf YNABs Antwort.
Die Lösung
Im importToYnab-Handler wird nach dem YNAB-Response jede Transaktion aktualisiert:
// Nach YNAB-Import: Status für jede Transaktion setzen
let updatedTransactions =
transactions |> List.map (fun tx ->
if ynabSuccessIds.Contains tx.Transaction.Id then
{ tx with YnabImportStatus = YnabImported }
elif ynabRejectedIds.Contains tx.Transaction.Id then
{ tx with YnabImportStatus = RejectedByYnab (DuplicateImportId importId) }
else
tx)
Lessons Learned
1. Transparenz schlägt Magie
Users vertrauen Software mehr, wenn sie verstehen, was sie tut. Ein Debug-Panel, das zeigt “Wir haben X, Y, Z geprüft” ist wertvoller als ein mysteriöses “Duplikat erkannt”.
2. Zwei Systeme = Zwei Types
Als ich realisierte, dass BudgetBuddy und YNAB unabhängige Duplicate-Detection haben, wurde die Lösung klar: Zwei separate Typen (DuplicateStatus und YnabImportStatus), nicht ein vermischter.
3. Details in allen Varianten
Der Counter-intuitive Ansatz, DuplicateDetectionDetails auch in NotDuplicate zu speichern, hat sich als goldrichtig erwiesen. “Wir haben geprüft und nichts gefunden” ist eine Information.
4. UI-State sorgfältig modellieren
Der Force-Re-import-Button-Bug kam daher, dass ich nicht sauber zwischen “bereit zum Import” und “von YNAB abgelehnt” unterschieden habe. Sauberes Domain Modeling verhindert solche Bugs.
Fazit
Was als “User sind verwirrt über Duplikate” begann, führte zu einer umfassenden Überarbeitung:
Neue Types:
DuplicateDetectionDetails- Transparente Diagnose-DatenYnabRejectionReason- Warum YNAB abgelehnt hatYnabImportStatus- Was beim Import passiert ist
Neue UI-Elemente:
- Debug-Info-Panel mit allen Detection-Details
- Zwei separate Banner (Pre-Import vs. Post-Import)
- “BudgetBuddy missed this!” Warnung
Geänderte Dateien:
src/Shared/Domain.fs- Neue Typessrc/Server/DuplicateDetection.fs- Diagnostics-Erfassungsrc/Server/Api.fs- YnabImportStatus setzensrc/Client/Components/SyncFlow/View.fs- Debug-Panel, Banner
Statistiken:
- Build: Erfolgreich
- Tests: 279/285 bestanden (6 Integration-Tests übersprungen)
Key Takeaways für Neulinge
-
Transparenz ist UX: Wenn deine Software Entscheidungen trifft, zeig dem User warum. Ein “Debug-Panel” muss nicht nur für Entwickler sein.
-
Separate Konzepte = Separate Types: Wenn zwei Systeme unabhängig voneinander “Duplikat” sagen können, modelliere sie separat. Nicht alles in einen Type quetschen.
-
Auch “nichts gefunden” ist Information: Speichere Diagnose-Details auch für negative Ergebnisse. “Wir haben geprüft” ist wertvoller als Stille.