Debugging-Marathon: YNAB-Integration, Race Conditions und die Tücken von JSON-Serialisierung
Debugging-Marathon: YNAB-Integration, Race Conditions und die Tücken von JSON-Serialisierung
Einleitung
Manchmal verbringt man einen ganzen Tag damit, Bugs zu finden und zu fixen, die sich gegenseitig verstecken. Diesen Samstag war so ein Tag. Was als einfache Benutzeroberflächen-Verbesserung begann (“der Button sollte einen Loading-State haben”), entwickelte sich zu einer Reise durch die Tiefen der JSON-Serialisierung, Race Conditions, und der Frage, warum YNAB meine Transaktionen einfach nicht annehmen wollte.
BudgetBuddy ist eine Self-Hosted F#-App, die Transaktionen von der Comdirect-Bank holt und nach YNAB (You Need A Budget) importiert. Die Architektur: Fable/Elmish-Frontend, Giraffe-Backend, und viel asynchrone Kommunikation dazwischen.
In diesem Post dokumentiere ich die sieben Bugs, die ich an einem Tag gefunden und gefixt habe - und was ich dabei gelernt habe.
Ausgangslage
Die App funktionierte… größtenteils. Transaktionen wurden von der Comdirect geholt, die Benutzer konnten Kategorien zuweisen, und dann auf “Import to YNAB” klicken. Das Problem: Die Erfolgsmeldung stimmte nicht. “Imported 5 transactions” stand da, aber in YNAB tauchten 0 auf.
Herausforderung 1: Der Double-Click Bug
Das Problem
Ein Benutzer klickte auf “I’ve Confirmed” (der TAN-Bestätigungs-Button), sah keinen Loading-Indikator, klickte nochmal - und bekam einen kryptischen Fehler:
Invalid session state. Expected: AwaitingTan, Actual: FetchingTransactions
Was war passiert? Die Session-State-Machine hatte ihren Zustand bereits geändert, aber das Frontend wusste nichts davon.
Optionen, die ich betrachtet habe
- Backend: Idempotente Bestätigung - Der Server könnte mehrfache Bestätigungen ignorieren
- Pro: Keine Frontend-Änderung nötig
- Contra: Versteckt das eigentliche UX-Problem
- Frontend: Loading-State mit Flag (gewählt)
- Pro: Sofortiges visuelles Feedback
- Contra: Mehr State im Frontend
- Button komplett deaktivieren nach Klick
- Pro: Einfachste Lösung
- Contra: Schlechte UX wenn der Request fehlschlägt
Die Lösung
Ich habe ein IsTanConfirming: bool Flag zum Model hinzugefügt:
// src/Client/Components/SyncFlow/Types.fs
type Model = {
CurrentSession: RemoteData<SyncSession option>
SyncTransactions: RemoteData<SyncTransaction list>
// ... andere Felder
IsTanConfirming: bool // NEU
}
Im Update-Handler wird das Flag gesetzt und weitere Klicks ignoriert:
| ConfirmTan ->
// Prevent double-clicks: ignore if already confirming
if model.IsTanConfirming then
model, Cmd.none, NoOp
else
// ... normale Verarbeitung
{ model with IsTanConfirming = true }, cmd, NoOp
Rationale: Das Elmish/MVU-Pattern macht solche UI-States einfach. Ein Boolean-Flag, eine Prüfung am Anfang - fertig. Der Button zeigt jetzt “Importing…” mit einem Spinner.
Herausforderung 2: Die “False Success” Lüge
Das Problem
“Imported 5 transactions” - aber 0 waren tatsächlich in YNAB. Die Erfolgsmeldung log.
Die Analyse
Der Code in YnabClient.fs tat folgendes:
// ALT - der Bug
let createTransactions (token: string) (budgetId: YnabBudgetId) (transactions: ...) =
async {
// ... HTTP Request senden
return transactions.Length // <-- HIER: Wir zählen was wir GESENDET haben
}
Das Problem: YNAB antwortet mit einer JSON-Response, die enthält:
transaction_ids: Die IDs der tatsächlich erstellten Transaktionenduplicate_import_ids: Import-IDs von Transaktionen, die als Duplikate abgelehnt wurden
Wir haben die Response komplett ignoriert und einfach angenommen, dass alle Transaktionen erfolgreich waren.
Die Lösung
Neuer Return-Type mit korrekten Informationen:
type TransactionCreateResult = {
CreatedCount: int
DuplicateImportIds: string list
}
let createTransactions (token: string) (...) : Async<YnabResult<TransactionCreateResult>> =
async {
// ... HTTP Request
// Parse die tatsächliche Response
let createdIds =
Decode.field "data" (
Decode.field "transaction_ids" (Decode.list Decode.string)
)
let duplicateIds =
Decode.field "data" (
Decode.optionalField "duplicate_import_ids" (Decode.list Decode.string)
)
|> Option.defaultValue []
return Ok {
CreatedCount = createdIds.Length
DuplicateImportIds = duplicateIds
}
}
Lessons Learned: Immer die API-Response parsen. Nie annehmen, dass ein HTTP 200 bedeutet, dass alles funktioniert hat.
Herausforderung 3: Der JSON-Serialisierungs-Alptraum
Das Problem
Selbst nachdem ich die Response korrekt parsete, wurden immer noch 0 Transaktionen erstellt. Die API antwortete mit "transaction_ids": [] - aber keine Fehlermeldung.
Zeit für Debug-Logging:
printfn "YNAB Request Body: %s" requestBody
Und da war es:
{
"transactions": [
{
"amount": "-50250", // <-- FALSCH: String!
"date": "2025-12-07",
// ...
}
]
}
"-50250" als String statt -50250 als Number.
Warum passierte das?
Der Betrag war als int64 definiert:
type YnabTransactionRequest = {
Amount: int64 // Milliunits (z.B. -50250 für -50.25 EUR)
// ...
}
Und ich verwendete Encode.int64:
Encode.object [
"amount", Encode.int64 tx.Amount
// ...
]
Das Problem: Thoth.Json.Net’s Encode.int64 serialisiert 64-Bit-Integers als Strings. Warum? Weil JavaScript keine 64-Bit-Integers nativ unterstützt (alles ist ein 64-Bit Float). Um Präzision zu erhalten, werden große Zahlen als Strings serialisiert.
YNAB erwartet aber eine JSON-Number. Kein String. Und anstatt mit einem Fehler zu antworten, ignoriert YNAB die Transaktion einfach still.
Die Lösung
YNAB-Milliunits passen problemlos in einen 32-Bit-Integer (max. ~2.1 Milliarden = ~2.1 Millionen EUR):
type YnabTransactionRequest = {
Amount: int // Geändert von int64 zu int
// ...
}
// Und beim Encodieren:
Encode.object [
"amount", Encode.int tx.Amount // Geändert von Encode.int64
// ...
]
Jetzt: "amount": -50250 - eine echte JSON-Number.
Regression-Tests hinzugefügt:
testCase "amount is serialized as JSON number, not string" <| fun () ->
// This test prevents regression of the bug where Encode.int64 serialized
// amounts as strings (e.g., "-50250" instead of -50250), causing YNAB
// to silently reject transactions.
let tx = { ... Amount = -50250 ... }
let json = encodeTransaction tx |> Encode.toString 0
Expect.isTrue
(json.Contains("\"amount\":-50250"))
"Amount should be a JSON number"
Expect.isFalse
(json.Contains("\"-50250\""))
"Amount should NOT be a string"
Key Takeaway: Immer das tatsächliche HTTP-Request-Body loggen und prüfen. Und: Regression-Tests für jeden Bug.
Herausforderung 4: Der Stale Reference Bug
Das Problem
Nach all den Fixes funktionierten die Importe endlich! Aber: Die Erfolgsseite zeigte “0 Imported, 0 Skipped” - obwohl die Transaktionen tatsächlich in YNAB waren.
Die Analyse
Im SyncSessionManager.fs:
let completeSession () : SyncSession option =
match currentSession.Value with
| Some state ->
// Zähle die Status
updateSessionCounts() // <-- Aktualisiert currentSession.Value
// Erstelle die completed Session
let completed = {
state.Session with // <-- HIER: Verwendet alte 'state'-Referenz!
Status = Completed
CompletedAt = Some DateTime.UtcNow
}
// ...
Das Pattern-Match Some state bindet state an den alten Wert. Dann rufen wir updateSessionCounts() auf, das currentSession.Value mutiert. Aber state zeigt immer noch auf die alte Kopie - mit ImportedCount = 0.
Die Lösung
Nach der Mutation den Wert neu lesen:
let completeSession () : SyncSession option =
match currentSession.Value with
| Some state ->
updateSessionCounts()
// RE-READ nach der Mutation!
match currentSession.Value with
| Some updatedState ->
let completed = {
updatedState.Session with // <-- Jetzt mit aktuellen Counts!
Status = Completed
CompletedAt = Some DateTime.UtcNow
}
// ...
| None -> None
| None -> None
Lessons Learned: Mutable State und F#’s Pattern Matching sind eine gefährliche Kombination. Nach jeder Mutation müssen Bindings neu gelesen werden.
Herausforderung 5: Duplikate sind keine Fehler
Das Problem
YNAB hat ein cleveres Feature: import_id. Wenn du eine Transaktion mit der gleichen Import-ID nochmal sendest, wird sie als Duplikat erkannt und nicht nochmal erstellt. Das ist gut - es verhindert versehentliche Doppel-Importe.
Aber: Wenn der Benutzer eine Transaktion in YNAB löscht und sie erneut importieren will, geht das nicht. YNAB erinnert sich an die Import-ID und lehnt ab. Der Benutzer sieht: “0 transactions imported” und ist verwirrt.
Die Lösung: Force Re-Import
Neuer API-Endpunkt:
// src/Shared/Api.fs
type ImportResult = {
CreatedCount: int
DuplicateTransactionIds: TransactionId list
}
type ISyncApi = {
importToYnab: unit -> Async<SyncResult<ImportResult>>
forceImportDuplicates: TransactionId list -> Async<SyncResult<int>>
}
Beim Force-Import generieren wir neue UUIDs statt der deterministischen Import-IDs:
// src/Server/YnabClient.fs
let createTransactions
(token: string)
(budgetId: YnabBudgetId)
(accountId: YnabAccountId)
(transactions: ...)
(forceNewImportId: bool) // NEU
: Async<YnabResult<TransactionCreateResult>> =
let importId =
if forceNewImportId then
$"YNAB:{Guid.NewGuid()}" // Neue UUID
else
$"YNAB:{tx.TransactionId}" // Deterministische ID
Das Frontend zeigt jetzt:
Imported 3 transaction(s). 2 already exist in YNAB.
[Re-import 2 Duplicate(s)] <-- Neuer Button
Rationale: Der Benutzer hat die Kontrolle. Normale Importe sind sicher (Duplikatschutz), aber wenn nötig kann man Duplikate forcieren.
Herausforderung 6: Das UI-Flackern
Das Problem
Jede kleine Änderung (Kategorie auswählen, Skip-Button klicken) lud die komplette Transaktionsliste vom Server neu. Das verursachte sichtbares Flackern - die Liste wurde kurz leer (Loading-State), dann wieder gefüllt.
Warum war das so?
Der Update-Handler:
| TransactionCategorized (Ok updatedTx) ->
model, Cmd.ofMsg LoadTransactions, ShowToast (...)
// ^^^^^^^^^^^^^^^^^^^^^^^^
// Lädt ALLES neu vom Server
Das war bequem zu implementieren - aber furchtbare UX.
Die Lösung: Lokale State-Updates
Die API gibt das aktualisierte Objekt zurück. Nutzen wir das:
| TransactionCategorized (Ok updatedTx) ->
let updatedTransactions =
model.SyncTransactions
|> RemoteData.map (fun txs ->
txs |> List.map (fun tx ->
if tx.Transaction.Id = updatedTx.Transaction.Id then updatedTx
else tx
)
)
{ model with SyncTransactions = updatedTransactions }, Cmd.none, ShowToast (...)
Dasselbe Pattern für alle Mutationen: Skip, Bulk-Categorize, Splits speichern.
Gleichzeitig: Refresh-Buttons hinzugefügt für manuelles Neuladen wenn gewünscht.
Rationale: Das MVU-Pattern mit Virtual DOM sollte nur geänderte Elemente re-rendern. Aber wenn wir die komplette Liste austauschen, muss React alles neu rendern. Lokale Updates = minimale DOM-Änderungen = keine Flicker.
Herausforderung 7: Tests schreiben in die Produktionsdatenbank
Das Problem
Ich schrieb Persistence-Tests, die Rules in die Datenbank schrieben. Tests liefen grün. Alles gut.
Dann öffnete ich die App im Browser: 236 “Test Rule” Einträge.
Jeder Testlauf hatte 6 Test-Rules in die Produktions-SQLite geschrieben.
Warum passierte das?
F#-Module werden beim ersten Zugriff initialisiert, nicht wenn sie importiert werden. Der Connection-String wurde beim Modul-Start festgelegt:
// src/Server/Persistence.fs
module Persistence
let private connectionString =
// Wird SOFORT beim Modul-Load ausgeführt
Environment.GetEnvironmentVariable("DATABASE_URL")
|> Option.ofObj
|> Option.defaultValue "Data Source=budgetbuddy.db"
Meine Tests setzten die Environment-Variable für In-Memory-SQLite - aber zu spät. Das Persistence-Modul war bereits initialisiert mit dem Produktions-Connection-String.
Die Lösung: Lazy Loading
// Lazy - wird erst beim ERSTEN ZUGRIFF evaluiert
let private dbConfig = lazy (
let useMemoryDb =
Environment.GetEnvironmentVariable("USE_MEMORY_DB")
|> Option.ofObj
|> Option.map (fun s -> s.ToLower() = "true")
|> Option.defaultValue false
if useMemoryDb then
// In-Memory SQLite für Tests
let connString = "Data Source=:memory:;Mode=Memory;Cache=Shared"
let sharedConnection = new SqliteConnection(connString)
sharedConnection.Open() // Muss offen bleiben!
{ ConnectionString = connString; SharedConnection = Some sharedConnection }
else
{ ConnectionString = "Data Source=budgetbuddy.db"; SharedConnection = None }
)
Und in den Tests:
// WICHTIG: Environment Variable setzen BEVOR das Modul lädt
do Environment.SetEnvironmentVariable("USE_MEMORY_DB", "true")
// Erst DANN das Modul öffnen
open Persistence
Der do vor open Trick: In F# werden Top-Level do-Expressions in der Reihenfolge ausgeführt, in der sie im File stehen - noch bevor open-Statements ihre Module initialisieren.
Lessons Learned:
- Tests müssen isoliert sein - immer
- F# Module Initialization Order ist subtil
lazyist dein Freund für konfigurierbare Singletons
Fazit
Ein Tag, sieben Bugs. Jeder einzelne wäre alleine managebar gewesen. Zusammen haben sie sich gegenseitig versteckt - der JSON-Bug sah aus wie ein Duplikat-Problem, der Stale-Reference-Bug sah aus wie falsches Zählen.
Was ich implementiert habe:
- Double-Click-Schutz mit Loading-State
- Korrekte YNAB-Response-Parsing
- JSON-Number statt JSON-String für Beträge
- Stale-Reference-Fix beim Session-Completion
- Force-Re-Import für YNAB-Duplikate
- Lokale UI-Updates statt Server-Roundtrips
- Test-Isolation mit In-Memory-SQLite
Statistiken:
- 7 Bug-Fixes
- 4 neue API-Endpunkte/Typen
- ~15 Regression-Tests hinzugefügt
- 0 mehr flackernde UIs
Was ich zur Dokumentation hinzugefügt habe:
- Neuer “Bug Fix Protocol” Abschnitt in CLAUDE.md
- Aktualisierte Persistence-Skill-Dokumentation
- Aktualisierte Testing-Dokumentation
Key Takeaways für Neulinge
-
Immer die HTTP-Response parsen - Ein 200 OK bedeutet nicht, dass alles funktioniert hat. APIs können partial success returnen. Logge den Request-Body wenn etwas nicht funktioniert.
-
Mutable State und Pattern Matching sind gefährlich - In F# binden Pattern Matches Werte zum Zeitpunkt des Matchings. Nach einer Mutation zeigen sie auf veraltete Daten. Entweder immutable bleiben oder explizit neu lesen.
-
Tests schützen vor Regressionen - aber nur wenn sie existieren - Jeder dieser Bugs hätte mit den richtigen Tests verhindert werden können. Nach dem Fix: Regression-Test schreiben. Nicht optional.
Geschrieben während einer Debugging-Session, die länger dauerte als erwartet. Der Code ist jetzt besser. Die Tests sind jetzt da. Bis zum nächsten Bug.