Der versteckte Bug: Wie ein Format-Mismatch zu Duplikaten führte

Ein klassischer Bug, der zeigt, warum “Single Source of Truth” keine optionale Best Practice ist, sondern eine Notwendigkeit. In diesem Post beschreibe ich, wie ein simpler Format-Unterschied zwischen zwei Dateien zu einem kritischen Bug führte – und was wir daraus lernen können.

Die Ausgangslage

BudgetBuddy importiert Banktransaktionen in YNAB (You Need A Budget). Um Duplikate zu vermeiden, verwendet YNAB ein import_id Feld: wenn zwei Transaktionen dieselbe Import-ID haben, wird die zweite als Duplikat erkannt und abgelehnt.

Das System bestand aus zwei Komponenten:

  1. YnabClient.fs – Generiert Import-IDs beim Erstellen von Transaktionen
  2. DuplicateDetection.fs – Prüft vor dem Import, ob Transaktionen bereits existieren

Soweit die Theorie. In der Praxis hatte ich einen Bug, der lange unentdeckt blieb.

Das Problem: “Force Import” erstellt Duplikate

Ich stellte fest: “Wenn ich Force Import verwende, erscheinen meine Transaktionen doppelt in YNAB.”

Force Import ist eine Funktion, die Transaktionen erneut importiert, selbst wenn sie als Duplikate erkannt wurden. Nützlich, wenn man eine Transaktion in YNAB gelöscht hat und sie wieder haben möchte.

Meine erste Reaktion: “Das kann nicht sein, Force Import generiert neue UUIDs als Import-IDs, also sollten sie als neue Transaktionen erkannt werden.”

Aber der Bug war real.

Herausforderung 1: Das Format-Mismatch finden

Die Symptome verstehen

Ich habe mir die Logs angeschaut und etwas Seltsames bemerkt: bei Force Import wurden ALLE Transaktionen als “Duplikate” markiert, nicht nur einzelne. Das war merkwürdig – wie konnten alle Transaktionen Duplikate sein?

Die Spurensuche

Ich habe die beiden relevanten Dateien verglichen:

YnabClient.fs (wie Import-IDs generiert werden):

let importId =
    let txIdNoDashes = txId.ToString().Replace("-", "")
    $"BB:{txIdNoDashes}"

DuplicateDetection.fs (wie Import-IDs gesucht werden):

let matchesByImportId (bankTx: BankTransaction) (ynabTx: YnabTransaction) : bool =
    match ynabTx.ImportId with
    | Some importId ->
        let (TransactionId txId) = bankTx.Id
        importId.StartsWith($"BUDGETBUDDY:{txId}:")

Da war es. Offensichtlich. Peinlich offensichtlich.

  • YnabClient generierte: BB:tx123456
  • DuplicateDetection suchte nach: BUDGETBUDDY:tx-123-456:

Unterschiedliches Prefix (BB: vs BUDGETBUDDY:), unterschiedliche Behandlung der Bindestriche (entfernt vs beibehalten), und ein zusätzlicher Doppelpunkt am Ende.

Warum war das so schlimm?

Die matchesByImportId Funktion fand niemals einen Match. Das bedeutete:

  • Der Import-ID-basierte Duplikat-Check funktionierte nicht
  • Das System fiel auf andere Heuristiken zurück (Datum, Betrag, Payee)
  • Diese Heuristiken waren unzuverlässig

Herausforderung 2: Die gefährliche Fallback-Logik

Der zweite Bug im Bug

Während ich den Code analysierte, fand ich noch etwas Erschreckendes in Api.fs:

// ALTE VERSION - GEFÄHRLICH
if mapped.IsEmpty && not result.DuplicateImportIds.IsEmpty then
    toImport |> List.map (fun tx -> tx.Transaction.Id)
else
    mapped

Diese Logik sagte: “Wenn wir die Duplikat-IDs nicht den ursprünglichen Transaktionen zuordnen können, markiere ALLE Transaktionen als Duplikate.”

Das war die eigentliche Ursache für den Bug! Weil das Format-Mismatch dafür sorgte, dass mapped immer leer war, wurden bei jedem Import ALLE Transaktionen als Duplikate markiert.

Die “Sicherheitslogik” die alles kaputt machte

Diese Fallback-Logik war vermutlich als Sicherheitsnetz gedacht: “Lieber zu viele Duplikate erkennen als zu wenige.” Aber in Kombination mit dem Format-Bug wurde sie zum Problem.

Lesson Learned: Fallback-Logik, die “sicherheitshalber” den schlimmsten Fall annimmt, kann genau das Gegenteil bewirken. Lieber ehrlich scheitern (mit Logging) als still falsche Annahmen treffen.

Herausforderung 3: Tautologische Tests

Tests die nichts testen

Jetzt kam die unangenehme Frage: Warum haben die Tests das nicht gefangen?

Die Antwort: Die Tests waren tautologisch. Sie testeten das falsche Format gegen das falsche Format:

// ALTE VERSION - TAUTOLOGISCH
testCase "generates consistent import IDs for same transaction" <| fun () ->
    let transactionId = TransactionId "tx-123"
    let bookingDate = DateTime(2025, 11, 29)

    let (TransactionId id) = transactionId
    let importId1 = $"BUDGETBUDDY:{id}:{bookingDate.Ticks}"
    let importId2 = $"BUDGETBUDDY:{id}:{bookingDate.Ticks}"

    Expect.equal importId1 importId2 "Same transaction should generate same import ID"

Dieser Test prüft, ob X == X. Natürlich ist das wahr. Aber er testet nicht, ob der Code das richtige Format verwendet!

Das gleiche Problem in den DuplicateDetection-Tests:

// ALTE VERSION - TAUTOLOGISCH
test "matchesByImportId returns true when import IDs match" {
    let txId = "TX123"
    let ynabTx = createYnabTransaction ... (Some $"BUDGETBUDDY:{txId}:12345")

    let result = matchesByImportId bankTx' ynabTx
    Expect.isTrue result "Should match by import ID"
}

Der Test erstellt das Format manuell (BUDGETBUDDY:...) und prüft dann, ob der Code dieses Format findet. Das funktioniert, weil der Test und der Code zufällig das gleiche (falsche) Format verwenden.

Das fundamentale Problem

Tautologische Tests sind gefährlich, weil sie:

  1. Grün sind – sie geben falsches Vertrauen
  2. Bugs nicht finden – sie testen nicht das echte Verhalten
  3. Refactoring verhindern – sie brechen bei Änderungen, auch wenn das echte Verhalten korrekt bleibt

Die Lösung: Single Source of Truth

Schritt 1: Zentralisieren des Formats

Ich habe das Import-ID-Format in Domain.fs zentralisiert:

/// Import ID prefix used for YNAB transactions to prevent duplicates.
/// MUST be used in both YnabClient (generation) and DuplicateDetection (matching).
[<Literal>]
let ImportIdPrefix = "BB"

/// Generates an import ID from a transaction ID.
/// Format: "BB:{transactionId}" (max 36 chars for YNAB)
let generateImportId (TransactionId txId) : string =
    let txIdNoDashes = txId.Replace("-", "")
    $"{ImportIdPrefix}:{txIdNoDashes}"

/// Checks if an import ID matches a transaction ID.
/// Used in duplicate detection to identify transactions we previously imported.
let matchesImportId (TransactionId txId) (importId: string) : bool =
    let txIdNoDashes = txId.Replace("-", "")
    importId.StartsWith($"{ImportIdPrefix}:{txIdNoDashes}")

Warum diese Entscheidungen:

  1. [<Literal>] für den Prefix – Compile-time Konstante, kann nicht versehentlich geändert werden
  2. Beide Funktionen nebeneinander – Macht die Beziehung zwischen Generierung und Matching offensichtlich
  3. In Domain.fs – Das Shared-Modul, das von Server und Tests importiert wird

Schritt 2: YnabClient anpassen

// NEUE VERSION
let importId =
    if forceNewImportId then
        let newGuid = Guid.NewGuid().ToString("N")
        $"{Shared.Domain.ImportIdPrefix}:{newGuid}"
    else
        Shared.Domain.generateImportId tx.Transaction.Id

Jetzt verwendet YnabClient die zentrale Funktion. Kein Risiko mehr, dass das Format an einer Stelle geändert wird und an der anderen nicht.

Schritt 3: DuplicateDetection anpassen

// NEUE VERSION
let matchesByImportId (bankTx: BankTransaction) (ynabTx: YnabTransaction) : bool =
    match ynabTx.ImportId with
    | None -> false
    | Some importId ->
        Shared.Domain.matchesImportId bankTx.Id importId

Keine duplizierte Logik mehr. Die Matching-Logik ist jetzt exakt das Gegenstück zur Generierungs-Logik.

Schritt 4: Gefährliche Fallback-Logik entfernen

// NEUE VERSION
if mapped.IsEmpty && not result.DuplicateImportIds.IsEmpty then
    printfn "[WARNING] Could not map %d duplicate import IDs to transaction IDs: %A"
        result.DuplicateImportIds.Length result.DuplicateImportIds
// Only return actually mapped duplicates, never all transactions
mapped

Statt “alle als Duplikate markieren” loggen wir jetzt eine Warnung. Das System verhält sich ehrlich: wenn etwas nicht funktioniert, tut es so, als hätte es keine Duplikate gefunden – was sicherer ist als das Gegenteil.

Schritt 5: Echte Tests schreiben

// NEUE VERSION - ECHTER TEST
testCase "generateImportId produces correct format with BB prefix" <| fun () ->
    let txId = TransactionId "TX-123-456"
    let importId = generateImportId txId

    Expect.stringStarts importId $"{ImportIdPrefix}:" "Import ID should start with BB:"
    Expect.stringContains importId "TX123456" "Should contain transaction ID without dashes"

testCase "matchesImportId works with generateImportId output" <| fun () ->
    let txId = TransactionId "abc-def-ghi"
    let importId = generateImportId txId

    Expect.isTrue (matchesImportId txId importId) "Generated ID should match its source"

test "matchesByImportId returns true when import IDs match" {
    let txId = TransactionId "TX123"
    let bankTx' = { bankTx with Id = txId }
    // Use Domain.generateImportId to ensure test uses same format as production code
    let importId = generateImportId txId
    let ynabTx = createYnabTransaction ... (Some importId)

    let result = matchesByImportId bankTx' ynabTx
    Expect.isTrue result "Should match by import ID"
}

Der Unterschied: Die Tests verwenden jetzt generateImportId statt ein hardcodiertes Format. Wenn das Format geändert wird, ändern sich Tests und Produktionscode gemeinsam.

Lessons Learned

1. “Single Source of Truth” ist nicht optional

Wenn zwei Code-Teile dasselbe Format oder dieselbe Logik verwenden, müssen sie eine gemeinsame Quelle haben. Alles andere ist ein Bug, der nur darauf wartet zu passieren.

In diesem Fall:

  • Vorher: Format in YnabClient.fs UND DuplicateDetection.fs (unabhängig)
  • Nachher: Format in Domain.fs, verwendet von beiden

2. Tautologische Tests sind gefährlicher als keine Tests

Ein Test, der X == X prüft, gibt falsches Vertrauen. Er ist grün, also denkt man, alles funktioniert. Aber er testet nichts Nützliches.

Erkennungsmerkmale tautologischer Tests:

  • Der Test erstellt Testdaten mit demselben Code/Format, den er dann prüft
  • Der Test verwendet hardcodierte Werte, die zufällig mit dem aktuellen Code übereinstimmen
  • Wenn man den Produktionscode ändert, muss man auch die Test-Assertions ändern

Lösung: Tests sollten eine andere “Quelle der Wahrheit” haben als der Code selbst. In diesem Fall: Domain.generateImportId.

3. Defensive Fallbacks können mehr schaden als nutzen

Die Logik “wenn unser Check fehlschlägt, nimm den schlimmsten Fall an” klingt sicher. Aber sie kann genau das Gegenteil bewirken:

  • Sie versteckt den eigentlichen Bug (der Check schlägt fehl!)
  • Sie verursacht oft mehr Schaden als ein ehrliches Scheitern
  • Sie macht Debugging schwieriger

Besser: Loggen und ehrlich scheitern. Lieber eine fehlende Funktion als eine kaputte.

4. Bugs in “glue code” sind am schwierigsten zu finden

Der Bug war nicht in der Generierungs-Logik. Der Bug war nicht in der Matching-Logik. Beide Teile für sich waren korrekt. Der Bug war im Zusammenspiel – in der Annahme, dass beide dasselbe Format verwenden.

Solche Bugs sind schwer zu testen, weil Unit-Tests typischerweise einzelne Komponenten isoliert testen.

Lösung: Integration-Tests, die den gesamten Flow testen. Und: weniger “glue code” durch bessere Abstraktion (Single Source of Truth).

Fazit

Am Ende war es ein simpler Fix: ~50 Zeilen neuer Code in Domain.fs, einige Zeilen gelöscht in YnabClient.fs und DuplicateDetection.fs, und überarbeitete Tests.

Änderungen im Überblick:

  • src/Shared/Domain.fs – Neue Funktionen generateImportId, matchesImportId, Konstante ImportIdPrefix
  • src/Server/YnabClient.fs – Verwendet jetzt Domain.generateImportId
  • src/Server/DuplicateDetection.fs – Verwendet jetzt Domain.matchesImportId
  • src/Server/Api.fs – Gefährliche Fallback-Logik durch Logging ersetzt
  • src/Tests/*.fs – Tests verwenden jetzt die Domain-Funktionen statt hardcodierte Formate

Build: Erfolgreich Tests: 375/375 bestanden

Aber die Geschichte war noch nicht zu Ende…

Der Follow-up Bug: Comdirect IDs mit Slashes

Das Problem kehrt zurück

Nur wenige Stunden nach dem Fix meldete sich das gleiche Symptom zurück: Force Import funktionierte nicht, alle Transaktionen wurden als Duplikate markiert.

Die Docker-Logs zeigten eine alarmierende Meldung:

[WARNING] Could not map 41 duplicate import IDs to transaction IDs

Moment – diese Warnung hatte ich gerade erst eingebaut. Wenn sie erscheint, bedeutet das, dass das Mapping zwischen YNAB-Duplikat-IDs und lokalen Transaktions-IDs fehlschlägt. Aber wie konnte das sein, wenn ich gerade erst das Format vereinheitlicht hatte?

Die Spurensuche

Ich schaute mir die Api.fs genauer an. Dort fand ich diese Zeile:

// ALTE VERSION
let cleanId = txIdPart.Split('/') |> Array.head

Diese Zeile sollte das Prefix BB: von der Import-ID abtrennen. Aber warum wurde an / gesplittet?

Dann fiel es mir wie Schuppen von den Augen: Comdirect Transaktions-IDs enthalten Slashes!

Eine typische Comdirect-ID sieht so aus:

3I2C21XS1ZXDAP9P/33825

Der Slash ist Teil der ID, nicht ein Trennzeichen. Aber der Code Split('/') zerlegte diese ID in zwei Teile und behielt nur den ersten:

Input:  "BB:3I2C21XS1ZXDAP9P/33825"
Nach Split('/'):  ["BB:3I2C21XS1ZXDAP9P", "33825"]
Array.head:  "BB:3I2C21XS1ZXDAP9P"  // FALSCH - Suffix fehlt!

Die lokalen Transaktionen hatten aber die vollständige ID 3I2C21XS1ZXDAP9P/33825. Da YNAB die abgeschnittene Version zurückgab, fand das Mapping nie einen Match.

Warum dieser Code überhaupt existierte

Ich habe im Git-Log nachgeschaut. Der Split('/') Code war ein Überbleibsel aus einer früheren Version, als Import-IDs ein anderes Format hatten. Jemand (wahrscheinlich ich selbst) hatte angenommen, dass / ein Format-Trennzeichen war, das sicher entfernt werden konnte.

Lesson Learned: Wenn du Code kopierst oder anpasst, verstehe WARUM er so geschrieben wurde. Ein Split('/') sieht harmlos aus, kann aber katastrophale Auswirkungen haben, wenn deine IDs dieses Zeichen enthalten.

Der Fix

Die Lösung war simpel – das fehlerhafte Split entfernen:

// NEUE VERSION
// Don't split on '/' - Comdirect IDs contain slashes as part of the ID!
let cleanId = txIdPart

Und natürlich habe ich Regression-Tests hinzugefügt:

testCase "handles Comdirect IDs with slashes" <| fun () ->
    let txId = TransactionId "3I2C21XS1ZXDAP9P/33825"
    let importId = generateImportId txId

    Expect.isTrue (matchesImportId txId importId) "Should match Comdirect ID with slash"

testCase "Comdirect IDs with slashes round-trip correctly" <| fun () ->
    let txId = TransactionId "ABC123DEF/99999"
    let importId = generateImportId txId

    // Simulate what YNAB returns - should still match
    Expect.isTrue (matchesImportId txId importId) "Round-trip should work"

Änderungen:

  • src/Server/Api.fs – Fehlerhaften Split('/') entfernt
  • src/Tests/DuplicateDetectionTests.fs – 2 neue Regression-Tests für Comdirect IDs

Build: Erfolgreich Tests: 377/377 bestanden (+2 neue Regression-Tests)

Die Meta-Lesson

Dieser Follow-up Bug zeigt ein wichtiges Muster:

Ein Bug kommt selten allein.

Wenn du einen Bug findest, der sich lange versteckt hat, prüfe die umgebenden Code-Pfade. Oft gibt es verwandte Bugs, die aus derselben falschen Annahme entstanden sind.

In diesem Fall:

  1. Bug 1: Format-Mismatch zwischen BB: und BUDGETBUDDY:
  2. Bug 2: Slash-Parsing zerstört Comdirect IDs

Beide Bugs hatten dieselbe Root Cause: Code, der Annahmen über das ID-Format machte, ohne diese Annahmen explizit zu dokumentieren oder zentral zu definieren.


Der Bug existierte wahrscheinlich seit der ersten Implementation des Duplikat-Checks. Er wurde nie gefunden, weil:

  1. Der normale Import-Flow trotzdem funktionierte (andere Heuristiken)
  2. Die Tests “grün” waren
  3. Force Import relativ selten verwendet wurde

Das ist das Heimtückische an solchen Bugs: sie verstecken sich in Edge Cases und zeigen sich erst, wenn mehrere ungünstige Umstände zusammenkommen.

Key Takeaways für Neulinge

  1. Wenn zwei Code-Teile dasselbe Format verwenden, definiere es genau einmal. Nicht “die verwenden beide dasselbe Format” – sondern “die verwenden beide DIESE Konstante/Funktion”. Das ist ein fundamentaler Unterschied.

  2. Tautologische Tests erkennen: Wenn dein Test und dein Produktionscode beide ein Format/eine Logik hardcoden, ist der Test wertlos. Der Test muss eine unabhängige Quelle der Wahrheit haben.

  3. “Defensiv programmieren” heißt nicht “alle Fehler verstecken”. Ehrliches Scheitern mit gutem Logging ist fast immer besser als stilles Fehlverhalten.

  4. Ein Bug kommt selten allein. Wenn du einen lange versteckten Bug findest, prüfe verwandte Code-Pfade. Oft gibt es weitere Bugs mit derselben Root Cause.

  5. Verstehe den Code, bevor du ihn änderst. Ein harmlos aussehender Split('/') kann katastrophale Auswirkungen haben, wenn deine Daten dieses Zeichen enthalten. Frage dich immer: “Warum wurde das so geschrieben?”