Bug-Jagd: Wenn die YNAB API nicht das liefert, was man erwartet

Einleitung

Heute habe ich einen der klassischsten Bugs in der API-Integration gefixt: Annahmen über die API-Struktur, die nicht mit der Realität übereinstimmen. Der User berichtete, dass er in der Rules-Seite keine YNAB-Kategorien auswählen konnte. Was wie ein simples UI-Problem aussah, entpuppte sich als tiefgreifendes Decoder-Problem.

Das Interessante an diesem Bug: Er war unsichtbar bei kleinen Test-Budgets, aber brach bei echten Produktionsdaten. Der User hatte drei Budgets in YNAB, aber nur zwei erschienen in der Settings-Dropdown. Das fehlende Budget? “My Budget” – mit 159 Kategorien das größte und wichtigste.

Dieser Blogpost beschreibt meine Debugging-Reise von “Kategorien laden nicht” bis zum Fix eines JSON-Decoder-Problems, das tief in der YNAB-API-Struktur verwurzelt war.

Ausgangslage

BudgetBuddy ist eine F#-Anwendung, die Transaktionen von Comdirect nach YNAB synchronisiert. Die YNAB-Integration war bereits implementiert und funktionierte – zumindest dachten wir das. Der YnabClient.fs enthielt Decoder für alle YNAB-Datentypen:

let budgetDetailDecoder : Decoder<YnabBudgetWithAccounts> =
    Decode.object (fun get -> {
        Budget = { ... }
        Accounts = get.Required.Field "accounts" (Decode.list accountDecoder)
        Categories = get.Required.Field "categories" (Decode.list categoryDecoder)
    })

Das Problem? Dieser Decoder basierte auf Annahmen, nicht auf der tatsächlichen API-Dokumentation.


Herausforderung 1: Das Symptom verstehen

Das Problem

Der User meldete: “Ich kann in den Rules keine YNAB Budgets auswählen.” Das klang nach einem Frontend-Problem – vielleicht wurde die Kategorie-Dropdown nicht richtig befüllt?

Der Debugging-Prozess

Mein erster Instinkt war, im Browser zu schauen. Mit dem Chrome DevTools MCP konnte ich direkt in die laufende Anwendung schauen:

  1. Navigierte zur Rules-Seite
  2. Öffnete das Edit-Modal für eine Regel
  3. Sah: “No categories loaded. Please configure YNAB first.”

Aber halt – YNAB war konfiguriert! In den Settings war eine Verbindung aktiv. Also lag das Problem tiefer.

Die erste falsche Fährte

Ich untersuchte zunächst eine Race Condition im Frontend. Die Theorie: Wenn man schnell von Settings zu Rules navigiert, sind die Kategorien vielleicht noch nicht geladen. Ich fixte diese Race Condition auch (ein legitimer Bug), aber das löste nicht das Hauptproblem.

Lesson Learned: Manchmal gibt es mehrere Bugs gleichzeitig. Der offensichtliche Bug ist nicht immer der wichtigste.


Herausforderung 2: Das richtige Budget finden

Das Problem

Bei genauerer Untersuchung stellte ich fest: Der User hatte das Budget “Haus” ausgewählt, das 0 Kategorien hatte. Also schlug ich vor, zu “My Budget” zu wechseln, das 159 Kategorien haben sollte.

Aber: “My Budget” erschien gar nicht in der Dropdown!

Der User hatte 3 Budgets in YNAB:

  1. “Haus” – erschien ✓
  2. “Testbudget” – erschien ✓
  3. “My Budget” – fehlte

Die Diagnose

Ich fügte Debug-Logging zum Server hinzu:

| Ok budgets ->
    printfn "Found %d budgets from getBudgets" budgets.Length
    for budget in budgets do
        printfn "Fetching details for budget: %s (%A)" budget.Name budget.Id
        match! YnabClient.getBudgetWithAccounts token budget.Id with
        | Ok details ->
            printfn "  SUCCESS: Got %d accounts, %d categories"
                details.Accounts.Length details.Categories.Length
        | Error err ->
            printfn "  FAILED: %s" (ynabErrorToString err)

Die Server-Logs zeigten:

Found 3 budgets from getBudgets
Fetching details for budget: My Budget (...)
  FAILED: Invalid YNAB response: Failed to parse budget details:
    Error at: `$.data.budget.category_groups.[0]`
    Expecting an object with a field named `categories` but instead got:
    {
        "id": "300eefb2-f934-4a8a-99c3-3dad585b5da4",
        "name": "Internal Master Category",
        "hidden": false,
        "deleted": false
    }

Eureka! Der Decoder erwartete ein categories-Feld, aber manche Category Groups haben keines!


Herausforderung 3: Die YNAB API-Struktur verstehen

Das Problem

Mein ursprünglicher Decoder ging davon aus, dass die YNAB API Kategorien als flache Liste liefert:

{
  "data": {
    "budget": {
      "categories": [
        { "id": "...", "name": "Groceries", "category_group_name": "Essential" }
      ]
    }
  }
}

Die Realität sah anders aus. Der /budgets/{id} Endpoint liefert:

{
  "data": {
    "budget": {
      "category_groups": [
        {
          "id": "...",
          "name": "Internal Master Category",
          "hidden": false,
          "deleted": false
          // KEIN "categories" Feld!
        },
        {
          "id": "...",
          "name": "Essential Expenses",
          "categories": [
            { "id": "...", "name": "Groceries" }
          ]
        }
      ]
    }
  }
}

Die Erkenntnisse

  1. Kategorien sind in category_groups verschachtelt, nicht in einer flachen categories-Liste
  2. Manche Category Groups haben kein categories-Feld (z.B. “Internal Master Category”)
  3. Der Group-Name kommt vom Parent-Objekt, nicht aus der Kategorie selbst

Warum der Bug bei kleinen Budgets nicht auftrat

“Haus” und “Testbudget” hatten entweder:

  • Keine Category Groups ohne categories-Feld, oder
  • Der Decoder schlug fehl, aber die Fehler wurden still ignoriert

Bei “My Budget” war “Internal Master Category” das erste Element im Array, was den gesamten Decoder zum Absturz brachte.


Herausforderung 4: Den Decoder reparieren

Die Lösung

Ich musste drei neue Decoder schreiben:

/// Decoder für Kategorien innerhalb von category_groups
let categoryInGroupDecoder (groupName: string) : Decoder<YnabCategory> =
    Decode.object (fun get -> {
        Id = get.Required.Field "id" Decode.guid |> YnabCategoryId
        Name = get.Required.Field "name" Decode.string
        GroupName = groupName  // Vom Parent übergeben!
    })

/// Decoder für die category_groups-Struktur
let categoryGroupsDecoder : Decoder<YnabCategory list> =
    Decode.list (
        Decode.object (fun get ->
            let groupName = get.Required.Field "name" Decode.string
            // Optional! Manche Groups haben keine categories
            let categories =
                get.Optional.Field "categories"
                    (Decode.list (categoryInGroupDecoder groupName))
            categories |> Option.defaultValue []
        )
    )
    |> Decode.map List.concat  // Flatten der verschachtelten Listen

let budgetDetailDecoder : Decoder<YnabBudgetWithAccounts> =
    Decode.object (fun get -> {
        Budget = { ... }
        Accounts = get.Required.Field "accounts" (Decode.list accountDecoder)
        // NEU: category_groups statt categories, und Optional!
        Categories =
            get.Optional.Field "category_groups" categoryGroupsDecoder
            |> Option.defaultValue []
    })

Architekturentscheidung: Warum Optional.Field?

Ich hätte auch Required.Field verwenden und leere Arrays als Default behandeln können. Aber:

  1. Robustheit: Wenn YNAB irgendwann category_groups umbenennt oder weglässt, bricht nicht alles zusammen
  2. Defensive Programmierung: Lieber “keine Kategorien” als “API kaputt”
  3. Konsistenz: Manche Budgets haben vielleicht wirklich keine Category Groups

Warum ein separater categoryInGroupDecoder?

Der bestehende categoryDecoder erwartete category_group_name als eigenes Feld:

let categoryDecoder : Decoder<YnabCategory> =
    Decode.object (fun get -> {
        Id = get.Required.Field "id" Decode.guid |> YnabCategoryId
        Name = get.Required.Field "name" Decode.string
        GroupName = get.Required.Field "category_group_name" Decode.string
    })

Dieses Feld existiert aber nur beim /categories Endpoint, nicht bei /budgets/{id}. Statt den bestehenden Decoder zu modifizieren, erstellte ich einen neuen, der den Group-Name als Parameter akzeptiert. Das erhält die Kompatibilität mit beiden API-Endpoints.


Herausforderung 5: Die Tests anpassen

Das Problem

Nach dem Fix schlugen die Unit-Tests fehl:

Should have 3 categories. Expected list to have length 3, but length was 0

Die Tests verwendeten noch das alte JSON-Format:

let budgetDetailJson = """
{
    "data": {
        "budget": {
            "id": "budget-123",
            "name": "My Budget",
            "accounts": [...],
            "categories": [...]  // FALSCH!
        }
    }
}
"""

Die Lösung

Ich aktualisierte die Test-Daten auf das echte API-Format:

let budgetDetailJson = """
{
    "data": {
        "budget": {
            "id": "budget-123",
            "name": "My Budget",
            "accounts": [...],
            "category_groups": [
                {
                    "id": "group-internal",
                    "name": "Internal Master Category",
                    "hidden": false,
                    "deleted": false
                },
                {
                    "id": "group-1",
                    "name": "Essential Expenses",
                    "categories": [
                        { "id": "cat-1", "name": "Groceries" },
                        { "id": "cat-2", "name": "Rent" }
                    ]
                },
                {
                    "id": "group-2",
                    "name": "Fun Money",
                    "categories": [
                        { "id": "cat-3", "name": "Entertainment" }
                    ]
                }
            ]
        }
    }
}
"""

Wichtig: Ich fügte explizit “Internal Master Category” ohne categories-Feld hinzu, um den Edge Case zu testen.


Herausforderung 6: Debug-Code aufräumen

Das Problem

Ich hatte Debug-Logging zum Server hinzugefügt, um das Problem zu diagnostizieren:

printfn "Found %d budgets from getBudgets" budgets.Length
printfn "Fetching details for budget: %s (%A)" budget.Name budget.Id
printfn "  SUCCESS: Got %d accounts, %d categories" ...
printfn "  FAILED: %s" (ynabErrorToString err)

Diese Zeilen müssen vor dem Commit entfernt werden.

Die Lösung

Einfach die printfn-Aufrufe entfernen:

| Ok budgets ->
    let! budgetsWithDetails = async {
        let mutable results = []
        for budget in budgets do
            match! YnabClient.getBudgetWithAccounts token budget.Id with
            | Ok details ->
                results <- details :: results
            | Error _ ->
                () // Skip budgets that fail to load
        return results |> List.rev
    }
    return Ok budgetsWithDetails

Architekturentscheidung: Ich habe printfn "FAILED" auch entfernt, obwohl man argumentieren könnte, dass Fehler geloggt werden sollten. Der Grund: Diese Funktion ist Teil einer User-facing API. Wenn ein Budget nicht geladen werden kann, überspringen wir es still. Der User sieht einfach weniger Budgets – was immer noch besser ist als ein kompletter Fehler. Für Production-Logging sollte ein richtiges Logging-Framework verwendet werden.


Lessons Learned

1. API-Dokumentation lesen, nicht raten

Ich hatte angenommen, dass die YNAB API Kategorien als flache Liste liefert, weil das “logisch” erschien. Ein Blick in die offizielle Dokumentation hätte den Bug von Anfang an verhindert.

2. Tests mit echten API-Daten

Die ursprünglichen Tests verwendeten vereinfachte JSON-Strukturen. Das führte dazu, dass der Decoder in Tests funktionierte, aber in Production scheiterte. Wenn möglich, immer echte API-Responses als Test-Daten verwenden.

3. Optional-Felder defensiv behandeln

In Thoth.Json.Net gibt es Required.Field und Optional.Field. Bei externen APIs ist Optional.Field oft sicherer, weil sich APIs ändern können. Lieber mit fehlenden Daten umgehen als abstürzen.

4. Edge Cases in Tests abdecken

Der Bug trat nur auf, weil “Internal Master Category” keine nested categories hatte. Mein Fix-Test deckt diesen Edge Case jetzt explizit ab. Jeder Bug ist eine Gelegenheit, einen neuen Test-Case hinzuzufügen.

5. Debug-Code systematisch entfernen

Ich habe einen Task in meiner Todo-Liste erstellt: “Remove debug logging”. Das verhindert, dass Debug-Code versehentlich committed wird.


Fazit

Was wurde erreicht?

  • Bug gefixt: Alle 3 YNAB-Budgets erscheinen jetzt in der Dropdown
  • 159 Kategorien laden: “My Budget” funktioniert vollständig
  • Tests aktualisiert: Echtes API-Format in Test-Daten
  • Code aufgeräumt: Debug-Logging entfernt

Dateien geändert

Datei Änderung
src/Server/YnabClient.fs Neue Decoder für category_groups-Struktur
src/Server/Api.fs Debug-Logging hinzugefügt und wieder entfernt
src/Tests/YnabClientTests.fs Test-Daten auf echtes API-Format aktualisiert
diary/development.md Diary-Eintrag hinzugefügt

Test-Ergebnisse

  • 115 Tests passed
  • 6 Tests skipped (Integration-Tests ohne Credentials)
  • 0 Fehler

Key Takeaways für Neulinge

1. Thoth.Json.Net Decoder sind mächtig, aber erfordern Präzision

Die Decoder-Syntax in Thoth.Json.Net ist elegant, aber jedes Feld muss exakt zur API-Struktur passen. Nutze Optional.Field für Felder, die fehlen könnten, und erstelle separate Decoder für verschiedene API-Endpoints, wenn die Strukturen unterschiedlich sind.

2. Debugging-Workflow mit Debug-Logging

Wenn API-Calls fehlschlagen, füge temporäres Logging hinzu:

match result with
| Ok data -> printfn "SUCCESS: %A" data
| Error err -> printfn "FAILED: %s" err

Aber erstelle einen Task, um das Logging später zu entfernen!

3. Nested Structures erfordern nested Decoders

Bei verschachtelten JSON-Strukturen wie category_groups.[].categories.[] brauchst du:

  1. Einen Decoder für das innerste Element
  2. Einen Decoder, der die Liste dekodiert
  3. Einen Decoder, der die Parent-Struktur verarbeitet
  4. Decode.map List.concat um verschachtelte Listen zu flatten

Dieser Blogpost dokumentiert echte Debugging-Arbeit an BudgetBuddy, einer F#-Anwendung zur Synchronisation von Banktransaktionen mit YNAB.