# payment_status fälschlich auf 'Stripe' gesetzt **Session ID:** ses_0d6e3d016ffe5bSA1vczYc4ZKf **Created:** 7/3/2026, 12:52:20 PM **Updated:** 7/3/2026, 4:18:40 PM --- ## User ## Aufgabe: Bug-Diagnose — falscher payment_status bei Stripe-Zahlungen ### Kontext Node.js/Express/PostgreSQL Invoice-System mit QuickBooks Online (QBO) Integration. Zahlungen können über mehrere Wege gebucht werden: 1. Manueller Payment-Dialog (record-payment Endpoint) 2. Ein Stripe-Auto-Poller, der periodisch Stripe-Zahlungsstatus abfragt 3. Ein sync-payments Endpoint, der den Bezahlstatus aus QBO zurücksynchronisiert ### Beobachteter Fehler Zwei Invoices wurden am selben Tag per Stripe bezahlt: - #110667: payment_status = 'Paid' (korrekt) - #110679: payment_status = 'Stripe' (FALSCH) Bei beiden ist paid_date korrekt gesetzt (2026-06-30) und ein payment_invoices- Eintrag existiert. Nur die Spalte invoices.payment_status enthält bei #110679 den Wert 'Stripe' statt 'Paid'. Die Invoice-Liste im Frontend erwartet in payment_status nur die Werte 'Paid', 'Deposited' oder 'Partial'. 'Stripe' ist ein ungültiger Wert und führt zu falscher Badge-Anzeige. ### Untersuchungsauftrag (NICHTS ändern, nur analysieren und berichten) 1. Finde ALLE Stellen im Code, die `payment_status` schreiben (SET payment_status, UPDATE ... payment_status, INSERT mit payment_status). Liste jede Stelle mit Dateiname, Zeilennummer und dem konkret geschriebenen Wert auf. 2. Identifiziere die Stripe-Auto-Poller-Funktion (loggt "auto-polled" bzw. "Checking Stripe payment status"). Zeige, welchen Wert sie in payment_status schreibt. Prüfe, ob sie versehentlich die Zahlungsmethode ('Stripe') in die Status-Spalte schreibt statt 'Paid'. 3. Kläre die Spalten-Semantik: Unterscheide sauber zwischen - payments.payment_method (Zahlungsart: 'Stripe Credit Card', 'Check', ...) - invoices.payment_status (Zustand: 'Paid', 'Deposited', 'Partial') Gibt es Stellen, wo diese beiden verwechselt werden? 4. Untersuche, warum sync-payments den falschen Wert bei #110667 korrigiert hat, bei #110679 aber NICHT. Prüfe insbesondere die Bedingung, unter der sync-payments eine Invoice überspringt (z.B. QBO-Balance-Check qboBalance === 0, oder ob die Invoice überhaupt in der QBO-Query-Antwort enthalten war). Erkläre den Unterschied zwischen den beiden Invoices. 5. Prüfe auf Race Condition: Kann der Stripe-Poller und sync-payments gleichzeitig oder in ungünstiger Reihenfolge laufen, sodass ein korrekter Status wieder mit 'Stripe' überschrieben wird? ### Output-Format - Abschnitt "Betroffene Codestellen": Liste aller payment_status-Schreibzugriffe - Abschnitt "Root Cause": Die konkrete Zeile, die 'Stripe' fälschlich setzt - Abschnitt "Warum #110679 nicht korrigiert wurde": Erklärung der sync-payments-Lücke - Abschnitt "Empfohlener Fix": Vorschlag OHNE ihn schon umzusetzen Führe zuerst die vollständige Analyse durch. Implementiere noch keine Änderungen. --- ## Assistant **Tool: todowrite** --- ## Assistant **Tool: task** **Tool: grep** **Tool: grep** --- ## Assistant **Tool: read** **Tool: read** **Tool: read** **Tool: read** --- ## Assistant Now let me check additional write sites and the accounting service: **Tool: grep** **Tool: read** **Tool: read** --- ## Assistant Let me also check the record-payment endpoint, the schema, and the frontend usage: **Tool: read** **Tool: grep** **Tool: read** --- ## Assistant Let me check the remaining pieces — the import-invoices-2025.js and the schema: **Tool: read** **Tool: grep** **Tool: read** --- ## Assistant Let me check what value the import script writes for payment_status: **Tool: read** --- ## Assistant **Tool: grep** --- ## Assistant **Tool: read** --- ## Assistant Let me check one final piece — how `isPaid` is computed in the frontend: **Tool: grep** --- ## Assistant **Tool: todowrite** --- ## Assistant --- # Bug-Diagnose: Falscher `payment_status` bei Stripe-Zahlungen ## Betroffene Codestellen — alle `payment_status`-Schreibzugriffe | # | Datei | Zeile | Geschriebener Wert | Quelle / Trigger | |---|-------|-------|--------------------|--------------------| | 1 | `src/services/stripe-poll-service.js` | 109-112 | **`'Stripe'`** : `'Partial'` | Stripe Auto-Poller (alle 4h) | | 2 | `src/routes/invoices.js` | 1221-1224 | **`'Stripe'`** : `'Partial'` | Manueller Check via `/:id/check-payment` | | 3 | `src/routes/qbo.js` | 524 | `'Paid'` : `'Deposited'` | `POST /sync-payments` (aus QBO) | | 4 | `src/routes/qbo.js` | 557 | `'Partial'` | `POST /sync-payments` (Partielle Zahlung) | | 5 | `import-invoices-2025.js` | 138-140 | `'Paid'` : `'Partial'` | Massenimport aus QBO | | 6 | `src/routes/qbo.js` | 386-392 | *(kein `payment_status` gesetzt!)* | `POST /record-payment` (manuelle Zahlung) | ### Anmerkung zu #6 Der `record-payment`-Endpoint (`qbo.js:293-410`) schreibt nur `paid_date`, aber **niemals** `payment_status`. Bei einer manuellen QBO-Zahlung bleibt `payment_status` auf dem alten Wert. Das ist eine eigenständige Inkonsistenz, aber nicht die Ursache des gemeldeten Bugs. --- ## Root Cause — die konkrete Zeile, die `'Stripe'` setzt **Zwei identische Bugs an zwei Stellen:** **Stelle 1:** `src/services/stripe-poll-service.js:112` ```js [fullyPaid ? 'Stripe' : 'Partial', invoice.id] // ← 'Stripe' ist falsch ``` **Stelle 2:** `src/routes/invoices.js:1224` ```js [fullyPaid ? 'Stripe' : 'Partial', id] // ← 'Stripe' ist falsch ``` An beiden Orten wird der ternäre Ausdruck `fullyPaid ? 'Stripe' : 'Partial'` verwendet. Es wurde versehentlich das Prefix des `payments.payment_method`-Werts (`'Stripe Credit Card'` / `'Stripe ACH'`) in die Status-Spalte geschrieben. Richtig wäre: ```js fullyPaid ? 'Paid' : 'Partial' ``` --- ## Spalten-Semantik: Verwechslung `payment_method` vs. `payment_status` | Spalte | Tabelle | Typ | Gültige Werte | |--------|---------|-----|---------------| | `payment_method` | `payments` | varchar(50) | `'Stripe Credit Card'`, `'Stripe ACH'`, `'Check'`, `'Synced from QBO'`, … | | `payment_status` | `invoices` | varchar(20) | `'Paid'`, `'Deposited'`, `'Partial'` | | `stripe_payment_status` | `invoices` | varchar(50) | `'paid'`, `'pending'`, `'processing'`, `'unpaid'`, … | **Es gibt keine Stelle im Code, wo `invoices.payment_status` mit `payments.payment_method` per Join oder Copy verwechselt wird.** Der Fehler liegt ausschließlich im hartkodierten String-Literal `'Stripe'` in den beiden ternären Ausdrücken oben. Der Entwickler hat vermutlich `'Stripe'` als Kurzform von `'Stripe Credit Card'` verwendet, ohne zu realisieren, dass dies die falsche Spalte ist. Das Frontend (`invoice-view.js:347-352`) behandelt `'Stripe'` zwar als Spezialfall und zeigt ein lila Badge — das maskiert den Fehler visuell, macht ihn aber nicht korrekt. --- ## Warum sync-payments #110667 korrigiert hat, #110679 aber NICHT Der `sync-payments`-Endpoint (`qbo.js:414-595`) funktioniert so: 1. **Zeile 417-422**: Lädt ALLE lokalen Invoices mit `qbo_id IS NOT NULL`. Wenn #110679 kein `qbo_id` hat, wird es gar nicht erst in die Liste aufgenommen → **übersprungen**. 2. **Zeile 438-448**: Fragt QBO in Batches à 50 ab (`Invoice WHERE Id IN (...)`). Wenn #110679 zwar `qbo_id` hat, aber QBO diese ID nicht zurückliefert (z.B. weil die Rechnung in QBO gelöscht wurde), ist `qboInv = undefined` → **übersprungen** (Zeile 502: `if (!qboInv) continue`). 3. **Zeile 508**: Nur Invoices mit `qboBalance === 0 && qboTotal > 0` werden korrigiert. Wenn #110679 nicht vollständig bezahlt ist (Balance > 0 in QBO), greift dieser Block nicht → `payment_status` bleibt unverändert. 4. **Zeile 551-558**: Der `else if`-Block (Teilzahlung) prüft nur, ob `payment_status !== 'Partial'`. Da #110679 bereits `'Partial'` sein müsste (wenn nicht fully paid), würde hier nichts passieren. 5. **Zeile 520**: Die Guard-Bedingung `if (!localInv.paid_date || localInv.payment_status !== status)` prüft, ob überhaupt ein Update nötig ist. Wenn `paid_date` bereits gesetzt ist und `payment_status === 'Stripe'`, dann ist `'Stripe' !== 'Paid'` → Update WÜRDE ausgeführt. ### Wahrscheinlichstes Szenario (zeitliche Reihenfolge): ``` 08:00 Poller-Lauf 1: verarbeitet #110667 → payment_status = 'Stripe' 10:00 sync-payments: #110667 gefunden, qboBalance=0 → korrigiert zu 'Paid' ✓ 16:00 Poller-Lauf 3: verarbeitet #110679 → payment_status = 'Stripe' sync-payments: HAT NOCH NICHT STATTGEFUNDEN (nächster Lauf: 22:00) → #110679 steht noch auf 'Stripe' ``` Alternativ: **#110679 hat kein `qbo_id`** und wird von sync-payments grundsätzlich ignoriert. --- ## Race Condition: Poller vs. sync-payments **Unwahrscheinlich, aber theoretisch möglich.** Der Poller schützt sich durch seine Abfrage vor Doppelverarbeitung: ```sql -- stripe-poll-service.js:33-35 WHERE i.stripe_payment_link_id IS NOT NULL AND i.stripe_payment_status NOT IN ('paid') AND i.paid_date IS NULL ``` Nachdem der Poller eine Invoice verarbeitet hat, setzt er: - `stripe_payment_status = 'paid'` - `paid_date = COALESCE(paid_date, CURRENT_DATE)` Damit ist die Invoice für zukünftige Poller-Läufe ausgeschlossen. Ein zweiter Poller-Lauf kann denselben Datensatz also nicht nochmal überschreiben. **Theoretische Race**: Wenn sync-payments und der Poller **exakt gleichzeitig** laufen und der Poller die Invoice in seiner initialen SELECT-Query noch als offen sieht (bevor sync-payments committed hat), könnte der Poller den korrigierten Status wieder mit `'Stripe'` überschreiben. Die Wahrscheinlichkeit dafür ist bei einem 4-Stunden-Poller-Intervall gering. **Reales Risiko**: Nicht die Race Condition, sondern dass der Poller bei jedem seiner 4-Stunden-Läufe **neue** Invoices mit `'Stripe'` kontaminiert, die sync-payments dann erst beim nächsten Durchlauf korrigiert — oder nie, falls sie kein `qbo_id` haben. --- ## Empfohlener Fix 1. **`stripe-poll-service.js:112` und `invoices.js:1224`**: `'Stripe'` → `'Paid'` 2. **(Optional) `qbo.js:386-392`**: `record-payment` sollte auch `payment_status` setzen (auf `'Paid'` falls `payAmt >= invTotal`, sonst `'Partial'`) 3. **(Optional) Datenbereinigung**: Ein einmaliges SQL-Update, das alle Zeilen mit `payment_status = 'Stripe'` auf `'Paid'` korrigiert, wo `paid_date IS NOT NULL` 4. **(Optional) DB-Constraint**: `CHECK`-Constraint auf `payment_status IN ('Paid', 'Deposited', 'Partial')` um zukünftige Falschwerte zu verhindern **Tool: todowrite** --- ## Assistant Analyse abgeschlossen. Der Bericht ist oben vollständig — keine Änderungen wurden vorgenommen. --- ## User ## Aufgabe: Semantik-Analyse des payment_status 'Stripe' — KEINE Änderungen ### Kontext Node.js/Express/PostgreSQL Invoice-System mit QuickBooks Online (QBO) und Stripe. Vorherige Analyse ergab: An zwei Stellen wird payment_status = 'Stripe' gesetzt (stripe-poll-service.js:112 und invoices.js:1224, jeweils via `fullyPaid ? 'Stripe' : 'Partial'`). WICHTIGE NEUE HYPOTHESE, die geprüft werden muss: 'Stripe' könnte KEIN Tippfehler sein, sondern ein bewusster Zwischenstatus: ein Marker, der signalisiert "Stripe-Zahlung erkannt, QBO-Paid-Buchung steht noch aus". Erst nach erfolgreicher QBO-Buchung sollte auf 'Paid' gewechselt werden. Konkreter Widerspruch, den es aufzuklären gilt: - Log zeigt für Invoice #110679: "QBO Payment created: ID 39456" am 30.06. - Aber in QBO ist #110679 weiterhin "Overdue" (NICHT bezahlt / Payment nicht angewendet) - Im lokalen System stand #110679 auf payment_status = 'Stripe' - Vergleichsfall #110667: in QBO korrekt "Paid", lokal wurde 'Stripe' später zu 'Paid' ### Untersuchungsauftrag (NUR analysieren und berichten, NICHTS ändern) FRAGE 1 — Reagiert irgendein Code auf payment_status = 'Stripe'? Durchsuche das GESAMTE Projekt (Backend-Services, Routes, Cronjobs, Poller, Frontend) nach jeder Stelle, die payment_status = 'Stripe' LIEST oder in einer WHERE-Bedingung / einem Vergleich verwendet (nicht schreibt). Gibt es einen Prozess, der 'Stripe'-Invoices aufgreift und eine QBO-Paid-Buchung anstößt oder retryt? Oder ist 'Stripe' ein Sackgassen-Status, auf den nichts reagiert? Liste jede Fundstelle mit Datei, Zeile und Zweck. FRAGE 2 — Ist 'Stripe' ein bewusster Zwischenstatus oder ein Bug? Basierend auf Frage 1: Belege mit Code, ob 'Stripe' Teil eines mehrstufigen State-Flows ist (Stripe erkannt → QBO gebucht → 'Paid') oder ob es schlicht ein falsches Literal ohne weitere Verarbeitung ist. Zeige den vollständigen Lebenszyklus einer Stripe-bezahlten Invoice: welche Spalten (stripe_payment_status, payment_status, paid_date, qbo_id) werden in welcher Reihenfolge von welcher Funktion gesetzt? FRAGE 3 — Wurde QBO Payment 39456 korrekt an die Invoice gebunden? Analysiere die Funktion, die "QBO Payment created" loggt (Stripe-Poller-Pfad, NICHT der manuelle record-payment Endpoint). Prüfe: - Wird beim Erstellen des QBO Payments ein LinkedTxn mit der Invoice-qbo_id gesetzt, sodass das Payment die Invoice tatsächlich ausgleicht? - Oder wird nur ein "unapplied" Payment ohne Verlinkung erstellt (das die Invoice in QBO offen lässt)? - Verifiziert der Code nach dem Erstellen, ob die Buchung erfolgreich ANGEWENDET wurde (Balance der Invoice = 0), oder loggt er nur "created" ohne Prüfung? - Hat der Stripe-Poller Zugriff auf die Invoice-qbo_id zum Zeitpunkt der Buchung, oder könnte diese fehlen/null sein? FRAGE 4 — Reihenfolge Poller vs. QBO-Export Prüfe die zeitliche Abhängigkeit: Muss eine Invoice erst per QBO-Export eine qbo_id bekommen, BEVOR der Stripe-Poller ein QBO-Payment dafür buchen kann? Was passiert im Poller, wenn qbo_id zum Buchungszeitpunkt noch fehlt? Wird trotzdem "QBO Payment created" geloggt, obwohl die Verlinkung ins Leere läuft? ### Output-Format - "State-Flow": Vollständiger Lebenszyklus einer Stripe-Invoice mit allen Spalten-Übergängen und der jeweils verantwortlichen Funktion/Zeile - "Reagiert Code auf 'Stripe'?": Ja/Nein mit Belegen - "Semantik-Urteil": Ist 'Stripe' bewusster Zwischenstatus oder Bug? Begründung. - "QBO-Payment-Verlinkung": Wird 39456-artige Payments korrekt an Invoices gebunden? - "Konsequenz für Datenbereinigung": Warum ein simples UPDATE ... SET 'Paid' gefährlich wäre, falls 'Stripe' ein Trigger-Status ist Führe die vollständige Analyse durch. Implementiere KEINE Änderungen und schlage noch keinen konkreten Fix-Code vor — es geht ausschließlich um das Verständnis des Ist-Zustands. --- ## Assistant **Tool: todowrite** --- ## Assistant **Tool: grep** **Tool: grep** **Tool: read** --- ## Assistant **Tool: read** **Tool: read** **Tool: read** **Tool: read** --- ## Assistant **Tool: grep** **Tool: grep** --- ## Assistant **Tool: read** **Tool: read** --- ## Assistant **Tool: grep** **Tool: read** --- ## Assistant **Tool: read** **Tool: grep** --- ## Assistant **Tool: read** **Tool: read** --- ## Assistant **Tool: read** --- ## Assistant **Tool: grep** **Tool: grep** --- ## Assistant **Tool: read** --- ## Assistant **Tool: todowrite** --- ## Assistant --- # Semantik-Analyse: `payment_status = 'Stripe'` — Bug oder Zwischenstatus? ## 1. State-Flow: Vollständiger Lebenszyklus einer Stripe-bezahlten Invoice ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ PHASE 1: Invoice-Erstellung │ ├─────────────────────────────────────────────────────────────────────────────┤ │ Aktion: Invoice wird erstellt (manuell oder recurring) │ │ invoices.js:212 (INSERT) — keine payment_status gesetzt → NULL │ │ invoice_number = nächste Nummer, qbo_id = NULL │ ├─────────────────────────────────────────────────────────────────────────────┤ │ PHASE 2: QBO-Export (manuell getriggert) │ ├─────────────────────────────────────────────────────────────────────────────┤ │ Aktion: qbo-service.js:exportInvoiceToQbo() (line 23) │ │ qbo-service.js:117-118: UPDATE invoices SET │ │ qbo_id = , │ │ qbo_sync_token = , │ │ qbo_doc_number = , │ │ invoice_number = │ │ → payment_status bleibt NULL (wird nicht gesetzt) │ ├─────────────────────────────────────────────────────────────────────────────┤ │ PHASE 3: Stripe Payment Link (manuell) │ ├─────────────────────────────────────────────────────────────────────────────┤ │ Aktion: invoices.js:1105-1112 (POST /:id/create-payment-link) │ │ UPDATE invoices SET │ │ stripe_payment_link_id = , │ │ stripe_payment_link_url = , │ │ stripe_payment_status = 'pending' │ │ → payment_status bleibt unverändert │ ├─────────────────────────────────────────────────────────────────────────────┤ │ PHASE 4: Zahlungseingang bei Stripe │ │ (erkannt durch Poller ODER manuellen check-payment) │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ PFAD A – Stripe Auto-Poller (alle 4h + 2min nach Start) │ │ ───────────────────────────────────────────────────── │ │ Trigger: stripe-poll-service.js:pollStripePayments() (line 22) │ │ Query: (line 28-36) │ │ WHERE stripe_payment_link_id IS NOT NULL │ │ AND stripe_payment_status NOT IN ('paid') │ │ AND paid_date IS NULL │ │ │ │ Bei Zahlungserkennung (line 69-132): │ │ │ │ 1) payments.payment_method = 'Stripe Credit Card' / 'Stripe ACH' │ │ stripe-poll-service.js:87 (INSERT INTO payments) │ │ │ │ 2) payment_invoices: Eintrag erstellt │ │ stripe-poll-service.js:95-97 │ │ │ │ 3) invoices: │ │ stripe-poll-service.js:105-112 │ │ stripe_payment_status = 'paid' │ │ paid_date = COALESCE(paid_date, CURRENT_DATE) │ │ payment_status = 'Stripe' ← ❌ BUG (sollte 'Paid' sein) │ │ │ │ 4) QBO Payment via recordStripePaymentInQbo() │ │ stripe-poll-service.js:119-125 — NUR wenn qbo_id + customer_qbo_id │ │ vorhanden sind │ │ │ │ PFAD B – Manueller Stripe-Status-Check (Frontend-Button 🔍 Check) │ │ ─────────────────────────────────────────────────────────── │ │ Trigger: invoices.js:1129 (POST /:id/check-payment) │ │ Bei Zahlungserkennung (line 1184-1248): │ │ Selbe Logik wie Poller: │ │ invoices.js:1219-1224 │ │ stripe_payment_status = 'paid' │ │ paid_date = COALESCE(paid_date, CURRENT_DATE) │ │ payment_status = 'Stripe' ← ❌ BUG (identisch zu Poller) │ │ │ │ → BEIDE Pfade schreiben denselben falschen Wert │ │ │ ├─────────────────────────────────────────────────────────────────────────────┤ │ PHASE 5: Korrektur durch sync-payments │ │ (NUR manuell getriggert! Kein Cron/Scheduler!) │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ Trigger: qbo.js:414 (POST /sync-payments) — Button im Frontend │ │ NICHT automatisiert! Kein setInterval/setTimeout in src/ │ │ │ │ Logik (line 500-531): │ │ qboBalance = QBO Invoice Balance │ │ IF qboBalance === 0 AND qboTotal > 0: │ │ status = 'Paid' (oder 'Deposited' je nach LinkedTxn) │ │ IF paid_date ist NULL ODER payment_status != status: │ │ qbo.js:522-527: UPDATE invoices SET payment_status = 'Paid' │ │ → HIER wird 'Stripe' zu 'Paid' korrigiert │ │ │ │ WICHTIG: sync-payments ersetzt payment_status BLIND durch den QBO-Wert. │ │ Es prüft NICHT, ob vorher 'Stripe' drinstand. Es reagiert NICHT spezifisch │ │ auf 'Stripe'. Es überschreibt JEDEN abweichenden Wert. │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` **Zusammenfassung der Spalten-Transitionen:** | Phase | `qbo_id` | `stripe_payment_status` | `payment_status` | `paid_date` | |-------|----------|------------------------|-------------------|-------------| | Nach Erstellung | NULL | NULL | NULL | NULL | | Nach QBO-Export | gesetzt | NULL | NULL | NULL | | Nach Stripe-Link | gesetzt | `'pending'` | NULL | NULL | | Nach Poller (Zahlung erkannt) | unverändert | `'paid'` | **`'Stripe'`** | CURRENT_DATE | | Nach sync-payments | unverändert | unverändert | `'Paid'` | unverändert | --- ## 2. Reagiert Code auf `payment_status = 'Stripe'`? ### Backend: **NEIN — kein einziger Leser/Leser** Kein Service, kein Route-Handler, kein Cronjob, kein Poller wertet `payment_status = 'Stripe'` aus oder startet daraufhin eine Aktion. | Datei | Zeile | Code | Reaktion auf 'Stripe'? | |-------|-------|------|------------------------| | `qbo.js` | 520 | `localInv.payment_status !== status` | Nein — vergleicht gegen QBO-Wert ('Paid'/'Deposited'), nicht spezifisch gegen 'Stripe' | | `qbo.js` | 555 | `localInv.payment_status !== 'Partial'` | Nein — prüft nur auf 'Partial' | | `stripe-poll-service.js` | 34 | `stripe_payment_status NOT IN ('paid')` | Nein — liest `stripe_payment_status`, nicht `payment_status` | ### Frontend: **Ja, aber nur kosmetisch** | Datei | Zeile | Code | Zweck | |-------|-------|------|-------| | `invoice-view.js` | 349 | `invoice.payment_status === 'Stripe'` | Lila Badge statt Grün — rein visuelle Anpassung | | `invoice-view.js` | 457 | `invoice.payment_status === 'Deposited'` | Blaue Zeilenfarbe (liest 'Deposited' aber nicht 'Stripe') | Der Frontend-Code behandelt `'Stripe'` bereits als Workaround — er hat den Fall implementiert, statt die Ursache zu beheben. ### Fazit: `'Stripe'` ist ein Sackgassen-Status. --- ## 3. Semantik-Urteil: Ist `'Stripe'` bewusster Zwischenstatus oder Bug? ### Es ist ein **Bug**, kein Zwischenstatus. Belege: **A) Kein Übergangsmechanismus existiert** - Es gibt **keinen einzigen Code-Pfad**, der `payment_status = 'Stripe'` liest und daraufhin 'Paid' setzt. - Die einzige Stelle, die 'Stripe' jemals korrigiert, ist `sync-payments` — und das tut es **nicht weil** 'Stripe' drin steht, sondern **weil der Wert vom QBO abweicht**. Es würde auch 'Banana' überschreiben, wenn QBO 'Paid' sagt. **B) Kein Automatismus für den Übergang** - `sync-payments` ist ein **manueller** `POST`-Endpoint (Zeile 414). Es gibt **keinen** `setInterval`, `setTimeout` oder Cron, der ihn automatisch aufruft. Der einzige Scheduled-Prozess im System ist: - `recurring-service.js` — rekursive Rechnungen (24h-Intervall) - `stripe-poll-service.js` — Stripe-Poller (4h-Intervall) - Keiner von beiden ruft `sync-payments` auf oder wertet `payment_status` aus. **C) Der Poller selbst pollt nicht nochmal** - Nach Poller-Verarbeitung steht `stripe_payment_status = 'paid'` und `paid_date = CURRENT_DATE`. - Die Poller-Query (Zeile 33-35) schließt solche Invoices **explizit aus** (`stripe_payment_status NOT IN ('paid') AND paid_date IS NULL`). - Der Poller würde denselben Datensatz nie wieder anfassen. **D) `record-payment` ignoriert payment_status** - Der manuelle QBO-Zahlungsdialog (`qbo.js:293-410`) setzt `paid_date`, aber **niemals** `payment_status`. Auch keine Reaktion auf 'Stripe'. **E) Der Wert `'Stripe'` stammt aus der Payment-Method-Spalte** - `payments.payment_method` bekommt Werte wie `'Stripe Credit Card'`, `'Stripe ACH'` (siehe qbo-service.js:87 bzw. invoices.js:1198). - Der Entwickler hat das Payment-Method-Prefix `'Stripe'` fälschlich in die Status-Spalte geschrieben. ### Die Hypothese "bewusster Zwischenstatus" ist widerlegt. --- ## 4. QBO-Payment-Verlinkung: Wird Payment 39456-artig korrekt gebunden? ### Analyse von `recordStripePaymentInQbo()` (`qbo-service.js:235-327`) **Ja, der Code erstellt ein korrekt verlinktes Payment:** ```js // qbo-service.js:241-256 const paymentPayload = { CustomerRef: { value: invoice.customer_qbo_id }, TotalAmt: amount, ... Line: [{ Amount: amount, LinkedTxn: [{ TxnId: invoice.qbo_id, // ← verlinkt auf die Invoice TxnType: 'Invoice' }] }], DepositToAccountRef: { value: '221' } // Undeposited Funds }; ``` Der Payment wird via `LinkedTxn` direkt an die QBO-Invoice gebunden. Das ist der korrekte QBO-API-Weg, um eine Rechnung als bezahlt zu markieren. ### ABER: Keine Verifikation nach Erstellung Die Funktion: 1. Sendet den Payment-Create-Request an QBO (line 260-265) 2. Prüft auf `Fault` (line 268-271) 3. Loggt `✅ QBO Payment created: ID ${paymentData.Payment?.Id}` (line 272) 4. **Prüft NICHT**: - Ob die Invoice danach Balance = 0 hat - Ob der Payment-Status `applied` ist - Ob die LinkedTxn korrekt verarbeitet wurden Wenn QBO den Payment zwar erstellt (ID 39456), ihn aber aus irgendeinem Grund nicht anwendet (z.B. Invoice bereits mit anderem Payment verrechnet, SyncToken-Konflikt, Race Condition), würde der Code trotzdem "created" loggen und nicht bemerken, dass die Invoice in QBO weiterhin überfällig ist. ### Mögliche Erklärung für #110679 (QBO: "Overdue", Payment 39456 existiert): 1. **Payment erstellt, aber nicht applied**: QBO hat Payment 39456 angelegt, aber es wurde nicht korrekt auf die Invoice angewendet. Das könnte passieren wenn: - Die Invoice in QBO bereits einen anderen offenen Payment-Link hatte - Ein QBO-interner Fehler die Applikation verhinderte (trotz HTTP 200) - Der Payment-Betrag nicht exakt dem Invoice-Betrag entsprach (Rundungsdifferenz) 2. **Payment wurde applied, dann reversed**: Ein anderer Prozess hat das Payment storniert. 3. **Die LinkedTxn-ID stimmt nicht**: Wenn `invoice.qbo_id` im lokalen System eine andere ID hat als die tatsächliche QBO-Invoice, würde der Payment zwar erstellt, aber an die falsche (oder keine) Invoice gebunden. Diese Frage kann ohne QBO-Audit-Logs nicht abschließend beantwortet werden, aber der Code enthält **keine Plausibilitätsprüfung** nach Payment-Erstellung. --- ## 5. Reihenfolge Poller vs. QBO-Export — Zeitabhängigkeit ### Muss eine Invoice erst eine `qbo_id` haben, bevor der Poller QBO buchen kann? **Ja, zwingend.** Der relevante Code in `stripe-poll-service.js:119-125`: ```js // 4. QBO booking if (invoice.qbo_id && invoice.customer_qbo_id) { // ← Guard try { await recordStripePaymentInQbo(...); } catch (qboErr) { console.error(`⚠️ QBO booking failed...`); } } ``` ### Was passiert, wenn `qbo_id` fehlt? 1. Der Poller **schreibt trotzdem** `payment_status = 'Stripe'` (Zeile 109-112) 2. Der Poller **schreibt trotzdem** `stripe_payment_status = 'paid'` und `paid_date` 3. Der Poller **überspringt** die QBO-Buchung — kein "QBO Payment created" im Log 4. Die Invoice ist lokal als bezahlt markiert, hat aber keine QBO-Entsprechung 5. `sync-payments` kann die Invoice nicht korrigieren, weil `qbo_id IS NOT NULL` Voraussetzung für den sync ist (line 421) 6. **Ergebnis: `payment_status = 'Stripe'` bleibt permanent — Sackgasse.** ### Sequenz-Diagramm: ``` ┌──────────┐ ┌──────────┐ ┌─────┐ ┌─────┐ │ Invoice │ │ QBO │ │ Str │ │Syncy│ │ Created │ │ Export │ │ Pol │ │ Paym│ │ qbo=NULL │──► │ qbo=set │──► │ ler │──► │ ents│ │ │ │ │ │ │ │ │ └──────────┘ └──────────┘ └─────┘ └─────┘ │ │ ┌─────────────┤ │ │ qbo_id │ │ │ vorhanden? │ │ ├─JA──► QBO-Payment │ │ + 'Stripe'───────►'Paid' │ ├─NEIN─► nur local │ 'Stripe'──────► KEIN Fix │ (Dauerschaden) ``` **Die zeitliche Reihenfolge ist also: QBO-Export MUSS vor dem Poller-Lauf stattfinden, sonst entsteht eine nicht-automatisch korrigierbare Inkonsistenz.** --- ## 6. Konsequenz für Datenbereinigung ### Warum ein simples `UPDATE invoices SET payment_status = 'Paid' WHERE payment_status = 'Stripe'` gefährlich wäre: 1. **Bei fehlendem QBO-Payment**: Eine Invoice könnte `payment_status = 'Stripe'` haben, weil der Poller sie als bezahlt erkannt hat, aber kein QBO-Payment gebucht wurde (weil `qbo_id` zum Zeitpunkt fehlte oder `recordStripePaymentInQbo` fehlschlug). Würde man diese auf 'Paid' setzen, gäbe es lokal den Anschein eines vollständig gebuchten Vorgangs, aber in QBO wäre keine Zahlung vorhanden — die Invoice bliebe offen. 2. **Bei fehlgeschlagener QBO-LinkedTxn**: Falls Payment 39456 zwar existiert, aber nicht applied wurde (siehe Analyse unter Punkt 4), würde ein lokales 'Paid' die Diskrepanz zwischen lokaler DB und QBO verschleiern, statt sie zu beheben. 3. **Empfehlung**: Vor einem UPDATE sollte man sicherstellen, dass: - Die Invoice eine `qbo_id` hat - In QBO tatsächlich Balance = 0 ist (via sync-payments prüfbar) - Oder alternativ: sync-payments laufen lassen (manueller Button), das korrigiert alle gültigen Fälle automatisch --- ## Zusammenfassung | Frage | Antwort | |-------|---------| | Reagiert Code auf `'Stripe'`? | **Nein** — kein Backend-Code liest oder handelt auf diesen Wert | | Bewusster Zwischenstatus? | **Nein** — es ist ein Bug (falsches String-Literal) | | Wird QBO-Payment korrekt verlinkt? | **Theoretisch ja** (LinkedTxn vorhanden), aber keine Verifikation nach Erstellung | | `qbo_id`-Abhängigkeit? | **Ja** — ohne `qbo_id` wird kein QBO-Payment gebucht, nur lokal gespeichert | **Tool: todowrite** --- ## Assistant Analyse abgeschlossen. Keine Änderungen vorgenommen. --- ## User ## Aufgabe: Root-Cause-Analyse "Phantom QBO Payment" — KEINE Änderungen ### Kontext Node.js/Express/PostgreSQL Invoice-System mit QuickBooks Online (QBO) + Stripe. Der Stripe-Auto-Poller (stripe-poll-service.js) bucht bei erkannter Stripe-Zahlung ein Payment in QBO via recordStripePaymentInQbo() (qbo-service.js:235-327). ### Bestätigter Fehler Am 30.06. um 16:28 liefen ZWEI Stripe-Zahlungen im selben Poller-Durchlauf, nur ~1 Sekunde auseinander: [16:28:38] ✅ QBO Payment created: ID 39455 → Invoice #110667 [16:28:39] ✅ QBO Payment created: ID 39456 → Invoice #110679 Verifikation in QBO: - Payment 39455 EXISTIERT in QBO, korrekt auf #110667 angewendet. - Payment 39456 EXISTIERT NICHT in QBO (txnId=39456 liefert kein Objekt). #110679 blieb offen ("Overdue"), Customer balance $135.31. Das heißt: Der Code hat "✅ QBO Payment created: ID 39456" geloggt, OBWOHL QBO kein Payment angelegt hat. Es wurde eine Phantom-ID protokolliert. ### Untersuchungsauftrag (NUR analysieren, NICHTS ändern) FRAGE 1 — Wie wird die QBO-Response verarbeitet? Zeige den vollständigen Response-Handling-Code in recordStripePaymentInQbo() (qbo-service.js:235-327). Beantworte konkret: - Woraus genau wird die geloggte ID gelesen (z.B. paymentData.Payment?.Id)? - Wird VOR dem "created"-Log geprüft, ob die Response ein Fault/Error-Objekt ist? - Was passiert, wenn QBO HTTP 200 mit einem Fault-Body zurückgibt, oder wenn Payment undefined ist? Kann dann trotzdem eine (alte/falsche/undefined) ID ins Log geraten? - Wird der HTTP-Statuscode ausgewertet, oder nur der Body? FRAGE 2 — Kann bei zwei fast gleichzeitigen Calls ein Token-Konflikt entstehen? Der QBO-OAuth-Client teilt sich Access-/Refresh-Token über qbo_token.json. Beim Log-Muster fällt auf, dass VOR fast jedem API-Call ein Token gespeichert wird ("💾 Speichere Token..."). Untersuche: - Wird bei jedem API-Call getOAuthClient() / makeQboApiCall() der Token neu geladen oder geschrieben? - Wenn zwei Payment-Buchungen ~1s auseinander laufen: Kann der zweite Call einen Token-Refresh des ersten Calls stören (überlappender refreshUsingToken)? - Gibt es einen QBO-Fehlercode (z.B. 3200 "message=...", oder ein ValidationFault/BusinessValidationError), der bei Token- oder SyncToken-Konflikt auftritt und im Code NICHT als Fehler erkannt wird? FRAGE 3 — Wird der Poller sequenziell oder parallel verarbeitet? Zeige die Schleife in pollStripePayments() (stripe-poll-service.js:22ff), die mehrere zahlungsfällige Invoices abarbeitet. Beantworte: - Werden die Invoices sequenziell (await in for-Schleife) oder parallel (Promise.all / map ohne await) verarbeitet? - Falls parallel: Können mehrere recordStripePaymentInQbo()-Calls gleichzeitig laufen und sich beim geteilten OAuth-Token/SyncToken in die Quere kommen? - Falls sequenziell: Warum liegen die beiden Logs dann nur 1 Sekunde auseinander — spricht das für parallele Verarbeitung? FRAGE 4 — Fehlt eine Verifikation nach dem Buchen? Prüft der Code nach dem Payment-Create, ob das Payment wirklich existiert bzw. die Invoice-Balance in QBO nun 0 ist? Falls nein: Bestätige, dass ein fehlgeschlagener Call still als Erfolg durchgeht. FRAGE 5 — Wie finde ich ALLE betroffenen Invoices? Formuliere eine Diagnose-SQL-Query (NUR SELECT, nicht ausführen), die alle Invoices findet, die potenziell ein Phantom-Payment haben könnten — z.B. Kandidaten mit payment_status = 'Stripe' und/oder stripe_payment_status = 'paid', die aber in QBO möglicherweise offen sind. Erkläre, welche Felder man mit QBO abgleichen müsste, um Phantom-Payments sicher zu identifizieren. ### Output-Format - "Response-Handling": Wie die ID gelesen wird + ob Fault sauber erkannt wird - "Concurrency-Modell": sequenziell vs. parallel, mit Codebeleg - "Token-Konflikt-Hypothese": Kann überlappender Token-Refresh die Ursache sein? - "Fehlende Verifikation": Ja/Nein mit Beleg - "Root-Cause-Kandidaten": Priorisierte Liste der wahrscheinlichsten Ursachen - "Diagnose-Query": SELECT zum Finden weiterer betroffener Invoices - "Fix-Optionen": Skizze möglicher Lösungen (NUR beschreiben, nicht implementieren) Führe die vollständige Analyse durch. Implementiere KEINE Änderungen. --- ## Assistant **Tool: todowrite** --- ## Assistant **Tool: read** **Tool: read** --- ## Assistant **Tool: read** --- ## Assistant Now let me re-read the poller's error handling and the critical error-suppression section: **Tool: read** **Tool: read** --- ## Assistant Let me also check the `import_qbo_payment.js` for any QBO payment creation patterns, and verify whether there's any `PaymentRefNum` uniqueness issue: **Tool: read** **Tool: grep** --- ## Assistant Let me verify one final piece — the Stripe session handling around paymentIntentId that is passed as `reference`: **Tool: read** --- ## Assistant Now I have all the data. Let me compile the comprehensive report. **Tool: todowrite** --- ## Assistant --- # Root-Cause-Analyse: "Phantom QBO Payment" ID 39456 ## 1. Response-Handling: Wie die ID gelesen wird ### Vollständiger Pfad (zwei Schichten): **Schicht 1: `makeQboApiCall()` — `qbo_helper.js:104-174`** ```js // qbo_helper.js:144-160 try { const response = await client.makeApiCall(requestOptions); const data = response.getJson ? response.getJson() : response.json; if (data.fault && data.fault.error) { // ← checkt LOWERCASE 'fault' const errorCode = data.fault.error[0].code; if (errorCode === '3200' || errorCode === '3202' || errorCode === '3100') { await doRefresh(); return await client.makeApiCall(requestOptions); // Refresh & Retry } throw new Error(`QBO API Error ${errorCode}: ${data.fault.error[0].message}`); } return response; // ← gibt Raw-Response zurück } ``` **Schicht 2: `recordStripePaymentInQbo()` — `qbo-service.js:260-272`** ```js // qbo-service.js:260-267 const paymentRes = await makeQboApiCall({...}); const paymentData = paymentRes.getJson ? paymentRes.getJson() : paymentRes.json; // ↑ erneutes getJson()! // qbo-service.js:268-271 if (paymentData.Fault) { // ← checkt UPPERCASE 'Fault'! throw new Error('QBO Payment failed: ' + errMsg); } // qbo-service.js:272 console.log(`✅ QBO Payment created: ID ${paymentData.Payment?.Id}`); // ↑ DAS ist die geloggte ID ``` ### Kritische Schwachstellen: | # | Schwachstelle | Auswirkung | |---|---------------|------------| | **A** | **Case-Mismatch:** `makeQboApiCall` prüft `data.fault` (lowercase), `recordStripePaymentInQbo` prüft `paymentData.Fault` (uppercase). QBO kann beide Varianten oder Mischformen liefern. **Es gibt keine einheitliche Error-Erkennung.** | Ein Fault im falschen Case kann durchrutschen. | | **B** | **Keine HTTP-Status-Prüfung:** `paymentRes.status` / `statusCode` wird nirgends ausgewertet. QBO kann HTTP 200 mit Fault-Body, 200 mit leeren Body, 400 mit Fault, etc. senden. | Ein HTTP 200 mit leerem Body führt zu `paymentData.Payment?.Id` → `undefined`. | | **C** | **Doppeltes `getJson()`:** `makeQboApiCall` ruft bereits `getJson()` auf (line 146), `recordStripePaymentInQbo` ruft es **erneut** auf (line 267). Falls `getJson()` den Response-Body konsumiert, könnte der zweite Call einen leeren/veränderten Wert liefern. | Nicht-deterministisches ID-Parsing. | | **D** | **Kein Guard für `paymentData.Payment`:** `paymentData.Payment?.Id` ist `undefined`, wenn `paymentData.Payment` null/undefined ist. Es gibt keine Prüfung, ob `paymentData.Payment` überhaupt existiert. | Die "✅ created" Log-Message kann mit `ID undefined` erscheinen. | | **E** | **Nur `Fault` geprüft, nicht `fault`:** Im `recordStripePaymentInQbo` wird nur `paymentData.Fault` (PascalCase) geprüft. QBO-Fehler wie `BusinessValidationError` verwenden manchmal die Struktur `{ fault: { error: [...] } }`. | Ein Fault mit lowercase-Struktur wird NICHT erkannt → Code läuft in die Erfolgs-Log-Zeile. | ### Erklärung, wie die ID 39456 ins Log kam: Die wahrscheinlichste Erklärung ist, dass QBO das Payment **tatsächlich mit ID 39456 angelegt hat** (die HttpResponse enthielt `{ "Payment": { "Id": "39456", ... } }`), aber der Payment-Create-Vorgang auf QBO-Seite **nach dem Response-Senden intern verworfen wurde** (z.B. durch eine asynchrone Business-Validation, die eine Stale-Invoice oder einen SyncToken-Konflikt erkannte). Die Response wurde bereits mit der zugewiesenen ID gesendet, aber der Payment wurde nie persistent committed. Alternative: Ein **Fault kam durch**, und `paymentData.Payment?.Id` ergab einen Wert, der zufällig wie eine ID aussah. Das ist weniger wahrscheinlich, weil `?.` bei `undefined` `undefined` zurückgibt, und `39456` ein sehr spezifischer, sequenzieller Wert ist. --- ## 2. Concurrency-Modell: Sequenziell oder parallel? ### Code-Beleg: **Sequenziell** ```js // stripe-poll-service.js:51-140 (gekürzt) for (const invoice of openInvoices) { // ← einfache for...of try { const status = await checkPaymentStatus(...); // ← await 1 // ... await dbClient.query('BEGIN'); await dbClient.query('INSERT INTO payments...'); // ← await 2 await dbClient.query('INSERT INTO payment_invoices...'); await dbClient.query('UPDATE invoices SET...'); // ← await 3 await deactivatePaymentLink(...); // ← await 4 await recordStripePaymentInQbo(...); // ← await 5 (QBO API call) await dbClient.query('COMMIT'); // ← await 6 } catch (err) { await dbClient.query('ROLLBACK'); } } ``` **Fazit: Streng sequenziell.** Jeder `await` in der `for...of`-Schleife muss abgeschlossen sein, bevor der nächste Schleifendurchlauf beginnt. Invoice #110679 wird erst verarbeitet, NACHDEM #110667 vollständig durchgelaufen ist (inkl. QBO-Buchung, COMMIT). ### Warum dann nur 1 Sekunde Abstand? Der Zeitunterschied von 1 Sekunde ist plausibel für sequenzielle Verarbeitung: - `checkPaymentStatus()` → Stripe-API-Call: ~200ms - `INSERT/UPDATE` DB-Operationen: ~50ms - `recordStripePaymentInQbo()` → QBO API POST: ~400-600ms - `COMMIT`: ~50ms - **Summe pro Invoice: ~800-1000ms → 1s Differenz ist realistisch** --- ## 3. Token-Konflikt-Hypothese ### Architektur: ``` qbo_helper.js (Singleton) ├─ oauthClient (global, one instance) ├─ refreshInFlight (global Promise|null) └─ qbo_token.json (one file, all state) ``` ### Token-Flow bei jedem API-Call (`makeQboApiCall`, line 104-173): 1. Holt `client.getToken()` (das in-memory Token des globalen `oauthClient`) 2. Sendet Request via `client.makeApiCall(requestOptions)` 3. Bei Fault-Code 3200/3202/3100 → `doRefresh()` → retry 4. Bei HTTP 401 → `doRefresh()` → retry 5. **Bei Erfolg: Kein `saveTokens()`** (wird nur bei Token-Change gespeichert) ### Refresh-Mechanik (`doRefresh`, line 113-142): ```js const doRefresh = async () => { if (refreshInFlight) { return refreshInFlight; // ← Single-Flight Lock: Warteschlange } refreshInFlight = (async () => { const authResponse = await client.refreshUsingToken(refreshTokenStr); saveTokens(); // ← Schreibt qbo_token.json return authResponse; })(); return refreshInFlight; }; ``` Der `refreshInFlight`-Lock verhindert, dass mehrere parallele Calls **parallel** refreshen. Aufeinanderfolgende Calls warten auf das laufende Refresh. **Das ist korrekt implementiert und kein Bug.** ### Kann überlappender Token-Refresh die Ursache sein? **Unwahrscheinlich, aber nicht auszuschließen:** - Der Poller verarbeitet **sequenziell** → Call 2 startet erst nach Call 1 - Ein Token-Refresh kann nur durch einen 3200-Fehler oder 401 ausgelöst werden - Wenn Call 1 (Payment 39455) einen 401 auslöst → Refresh → Retry (erfolgreich) - Token ist jetzt frisch - Call 2 (Payment 39456) startet mit frischem Token → kein Refresh nötig **Mögliche Race-Condition:** Wenn der Token des ersten Calls expired, während der zweite Call läuft, könnte ein Refresh den `oauthClient`-Zustand ändern, noch während die erste Response verarbeitet wird. Aber die `refreshInFlight`-Lock verhindert doppelte Refreshes. Ein Refresh innerhalb des gleichen `makeQboApiCall`-Aufrufs ist harmlos, weil es nur das eigene Request-Objekt betrifft. **Fazit: Token-Konflikt ist nicht die primäre Ursache, aber das Shared-Mutable-State-Design (`oauthClient` global) ist fragil.** --- ## 4. Fehlende Verifikation nach dem Buchen ### Ja — es gibt KEINE Verifikation. Nach `recordStripePaymentInQbo()` (qbo-service.js:260-272): ```js console.log(`✅ QBO Payment created: ID ${paymentData.Payment?.Id}`); // ⬆️ ENDE. Kein GET zur Verifikation. Kein Balance-Check der Invoice. ``` **Was fehlt:** 1. **Kein QBO Payment-GET nach dem Create**, um zu bestätigen, dass das Payment tatsächlich existiert und den Status "applied" hat 2. **Kein QBO Invoice-Balance-Check** nach der Payment-Buchung (z.B. Invoice-GET: `Balance === 0`?) 3. **Keine Prüfung auf `paymentData.Payment` Existenz** — das `?.` maskiert ein potentielles `undefined` 4. **Keine Prüfung auf QBO-Warnings** im Response-Body — QBO kann `Warnings` oder `Messages` im Response zurücksenden, ohne einen `Fault` zu werfen ### Ein fehlgeschlagener Call geht definitiv als Erfolg durch: ```js // qbo-service.js:268-272 if (paymentData.Fault) { // Nur wenn Fault existiert throw new Error('QBO Payment failed: ' + errMsg); // → wird in poller:126 gefangen } console.log(`✅ QBO Payment created: ID ${paymentData.Payment?.Id}`); // ← Ansonsten: "Erfolg" ``` Wenn `paymentData.Payment` `undefined` ist, wird geloggt: `✅ QBO Payment created: ID undefined`. **Das ist der Beweis, dass keine Verifikation stattfindet.** ### Zusätzlich: QBO-Buchung ist vom DB-COMMIT entkoppelt ```js // stripe-poll-service.js:79-131 await dbClient.query('BEGIN'); // DB Transaktion startet // ... lokale DB-Operationen ... if (invoice.qbo_id && invoice.customer_qbo_id) { try { await recordStripePaymentInQbo(...); // QBO-Call INNERHALB der DB-Tx } catch (qboErr) { // QBO-Fehler wird GESCHLUCKT → kein ROLLBACK der DB-Tx! // Lokale DB wird committet, QBO ist inkonsistent } } await dbClient.query('COMMIT'); // COMMIT unabhängig vom QBO-Erfolg ``` **Der QBO-Buchungsfehler wird nicht an die DB-Transaktion weitergereicht.** Wenn QBO fehlschlägt, wird lokal alles committet (`payment_status = 'Stripe'`, `paid_date` gesetzt, `payment_invoices` erstellt), aber QBO hat keinen Zahlungseingang. --- ## 5. Root-Cause-Kandidaten (priorisiert) | Rang | Ursache | Wahrscheinlichkeit | Begründung | |------|---------|--------------------|-------------| | **1** | **QBO erstellte Payment 39456, gab es in der Response zurück, verwarf es aber danach intern** (z.B. business validation nach Response-Commit) | **Hoch** | Erklärt, warum ID 39456 (sequenziell zu 39455) geloggt wurde, aber in QBO nicht existiert. QBO kann Payment-IDs vergeben und in der Response zurückgeben, bevor die Business-Logik vollständig validiert. | | **2** | **Fault-Case-Mismatch:** QBO retournierte einen Fault (z.B. `BusinessValidationError`), der von keiner der beiden Error-Checks (`data.fault` lowercase und `paymentData.Fault` uppercase) erfasst wurde | **Mittel** | QBO-Faults haben keine konsistente Case-Struktur. Bei Nicht-Erkennung läuft der Code in den Erfolgs-Log, aber `paymentData.Payment?.Id` wäre dann `undefined`, nicht `39456`. Daher weniger wahrscheinlich für den konkreten Fall, aber ein reales Risiko. | | **3** | **`PaymentRefNum`-Duplikat:** Beide Stripe-Zahlungen hatten denselben `paymentIntentId` als Reference, QBO lehnte das zweite Payment als Duplikat ab | **Niedrig** | Zwei Stripe-Sessions haben unterschiedliche `paymentIntentId`s. Die Reference-Map zeigt, dass 39455 und 39456 unterschiedliche Payment-Creates sind. | | **4** | **Token-Conflict / Shared-State:** Der globale `oauthClient` wurde während des zweiten Calls durch einen Token-Refresh des ersten Calls korrumpiert | **Niedrig** | Die `refreshInFlight`-Lock verhindert konkurrierende Refreshes. Sequenzielle Verarbeitung macht parallele Konflikte unmöglich. | | **5** | **DB-Client im Poller shared:** Der `dbClient` wird vor der Schleife geholt und für alle Invoices wiederverwendet (line 25: `const dbClient = await pool.connect()`). Nach einem ROLLBACK (catch) wird er ohne `BEGIN` weitergenutzt | **Niedrig** | Betrifft nur das lokale DB-Handling, nicht den QBO-Call. | ### Primärer Root Cause: Keine QBO-Response-Verifikation + keine Post-Create-Validierung Die Kombination aus: 1. Keiner Prüfung auf `paymentData.Payment`-Existenz vor dem Log 2. Keiner GET-Verifikation nach dem POST 3. Keiner Reaktion auf QBO-Warnings im Response 4. QBO-Buchungsfehler wird geschluckt (`catch` in poller:126), DB trotzdem committet ...ermöglicht, dass QBO-Payment 39456 erfolgreich "erstellt" geloggt wurde, obwohl er in QBO nie persistent wurde. --- ## 6. Diagnose-Query: Weitere betroffene Invoices finden ```sql -- Kandidaten für Phantom-Payments: -- Invoices, die via Stripe bezahlt wurden, deren payment_status aber -- nie von 'Stripe' zu 'Paid' korrigiert wurde (d.h. sync-payments -- hat sie nie erwischt, oder ein QBO-Payment wurde nie erstellt) SELECT i.id, i.invoice_number, i.payment_status, -- sollte 'Paid' sein, nicht 'Stripe' i.stripe_payment_status, -- sollte 'paid' sein i.paid_date, i.qbo_id, -- NULL = kein QBO, sync-payments kann nicht helfen i.total, COALESCE( (SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0 ) AS amount_paid, EXISTS( SELECT 1 FROM payments p JOIN payment_invoices pi ON pi.payment_id = p.id WHERE pi.invoice_id = i.id AND p.payment_method LIKE 'Stripe%' ) AS has_stripe_local_payment, -- Hat die Invoice überhaupt eine QBO-Repräsentation? i.qbo_id IS NOT NULL AS in_qbo FROM invoices i WHERE i.payment_status = 'Stripe' -- ← der inkonsistente Status OR ( i.stripe_payment_status = 'paid' -- ← Stripe sagt "bezahlt" AND i.payment_status = 'Paid' -- ← aber QBO-sync sagt auch "Paid" AND i.qbo_id IS NOT NULL ) ORDER BY i.paid_date DESC NULLS LAST, i.id; ``` ### Manuelle Abgleich-Schritte für jede gefundene Invoice: 1. **Für Invoices mit `qbo_id`:** QBO Invoice-GET: `SELECT * FROM Invoice WHERE Id = ''`. Prüfen: `Balance === 0`? `LinkedTxn` mit Payment vorhanden? 2. **Payment in QBO finden:** Über `LinkedTxn` der QBO-Invoice (Feld `TxnId`) → Payment-GET mit dieser ID. Existiert das Payment? 3. **Payment über Ref-Nummer:** Aus der lokalen DB die `reference_number` des Stripe-Payments holen → in QBO mit `SELECT * FROM Payment WHERE PaymentRefNum = ''` suchen. 4. **Differenz feststellen:** Wenn lokales System bezahlt sagt, QBO aber offene Balance zeigt → Phantom-Payment bestätigt. --- ## 7. Fix-Optionen (nur Beschreibung, keine Implementierung) ### A) Response-Verifikation nach QBO Payment-Create (minimale Änderung) In `recordStripePaymentInQbo()` NACH dem Create: - `paymentRes.status` prüfen (muss im 2xx-Bereich sein) - `paymentData.Payment` auf Existenz prüfen (nicht nur `?.Id`) - `paymentData.Payment.Id` auf non-null, non-empty prüfen - **Optional:** QBO Payment-GET mit der zurückgegebenen ID, um Existenz zu bestätigen - **Optional:** QBO Invoice-GET, um `Balance === 0` zu verifizieren ### B) Fault-Erkennung vereinheitlichen - `makeQboApiCall` und `recordStripePaymentInQbo` auf dieselbe Error-Check-Logik umstellen (case-insensitive) - Helper-Funktion `isQboFault(data)` extrahieren, die sowohl `fault` als auch `Fault` und alternative QBO-Error-Strukturen erkennt ### C) QBO-Buchungsfehler an DB-Transaktion weitergeben In `stripe-poll-service.js`: - `recordStripePaymentInQbo` NICHT in eigenem try-catch fangen - Bei QBO-Fehler: `ROLLBACK` der kompletten DB-Transaktion - Oder: QBO-Buchung AUSSERHALB der DB-Transaktion, nach COMMIT (dann lokal konsistent, QBO nachbuchbar) ### D) Automatischen Retry-Mechanismus - Bei QBO-Fehler: Fehlgeschlagene Buchungen in eine `qbo_payment_queue` schreiben - Separater Cron/Poller versucht die Queue abzuarbeiten (mit Exponential Backoff) - Nach erfolgreicher Buchung: `payment_status` auf 'Paid' setzen ### E) `payment_status` auf 'Paid' korrigieren (Bugfix aus vorheriger Analyse) - `stripe-poll-service.js:112`: `'Stripe'` → `'Paid'` - `invoices.js:1224`: `'Stripe'` → `'Paid'` - Dies behebt den falschen Wert, löst aber nicht das Phantom-Payment-Problem **Tool: todowrite** --- ## Assistant Analyse abgeschlossen. Keine Änderungen vorgenommen. **Kernfazit:** Der wahrscheinlichste Root Cause ist, dass QBO Payment 39456 in der HTTP-Response mit einer ID zurückgab, aber **nachträglich intern verworfen** wurde. Der Code enthält **keine Post-Create-Verifikation** (kein GET zur Bestätigung, kein Invoice-Balance-Check), und der QBO-Buchungsfehler-Handler in `stripe-poll-service.js:126` schluckt Fehler, während die lokale DB trotzdem committed (`payment_status = 'Stripe'`, `paid_date` gesetzt). Der Case-Mismatch in der Fault-Erkennung (`data.fault` vs. `paymentData.Fault`) erhöht zusätzlich das Risiko, dass Fehler unbemerkt durchrutschen. --- ## User ## Aufgabe: Robustheits-Fixes für Stripe→QBO Payment-Buchung — IMPLEMENTIEREN ### Kontext Node.js/Express/PostgreSQL Invoice-System mit QBO + Stripe. Vorherige Analyse ergab vier zusammenhängende Schwachstellen im Stripe-Poller-Pfad. Ein QBO-Payment wurde als "created" geloggt, obwohl es in QBO nicht existierte ("Phantom Payment"), und die lokale DB wurde trotzdem als bezahlt committet. Betroffene Dateien: - src/services/stripe-poll-service.js - src/routes/invoices.js (der /:id/check-payment Pfad, ~Zeile 1129-1248) - src/services/qbo-service.js (recordStripePaymentInQbo, ~Zeile 235-327) - qbo_helper.js (makeQboApiCall, Fault-Erkennung) WICHTIG: Führe die Änderungen einzeln und nachvollziehbar durch. Zeige nach jeder Änderung ein kurzes Diff. Ändere NUR das Beschriebene, keine unrelated Refactorings. ### FIX 1 — 'Stripe' Literal korrigieren (kosmetisch, aber notwendig) In stripe-poll-service.js (~Zeile 112) und invoices.js (~Zeile 1224): Ändere `fullyPaid ? 'Stripe' : 'Partial'` zu `fullyPaid ? 'Paid' : 'Partial'`. Der Wert 'Stripe' war ein falsches Literal für die payment_status-Spalte. ### FIX 2 — Fault-Erkennung vereinheitlichen (case-insensitive) Problem: makeQboApiCall prüft `data.fault` (lowercase), recordStripePaymentInQbo prüft `paymentData.Fault` (uppercase). QBO liefert beide Varianten. Erstelle eine Helper-Funktion (z.B. in qbo_helper.js exportiert): function extractQboFault(data) { // Gibt das Fault-Objekt zurück (egal ob data.Fault oder data.fault), // oder null wenn kein Fault vorliegt. Berücksichtige auch die // Error-Array-Struktur (Fault.Error[] bzw. fault.error[]). } Nutze diese Funktion an BEIDEN Stellen (makeQboApiCall und recordStripePaymentInQbo), sodass ein Fault in jedem Case zuverlässig erkannt wird. ### FIX 3 — Post-Create-Verifikation in recordStripePaymentInQbo() Nach dem Payment-Create-POST, VOR dem "✅ created"-Log: 1. Prüfe, dass paymentData.Payment tatsächlich existiert (nicht nur paymentData.Payment?.Id). Wenn Payment fehlt oder Payment.Id leer/null → wirf einen aussagekräftigen Error (NICHT "created" loggen). 2. Führe danach einen QBO Invoice-GET auf invoice.qbo_id aus und prüfe, ob Balance === 0 ist. Wenn Balance > 0 → das Payment wurde nicht korrekt angewendet → wirf einen Error mit Invoice-Nummer, erwartetem und tatsächlichem Balance. (Das hätte das Phantom-Payment 39456 sofort erkannt.) 3. Erst wenn beide Prüfungen bestehen: "✅ QBO Payment created and verified: ID xxx, Invoice #yyy Balance=0" loggen. ### FIX 4 — QBO-Buchung von der lokalen DB-Transaktion entkoppeln Das ist der wichtigste Fix. Aktuell (stripe-poll-service.js): BEGIN → lokale Inserts/Updates → recordStripePaymentInQbo() in try/catch → COMMIT (auch wenn QBO fehlschlug) Das führt dazu, dass lokal "bezahlt" steht, während QBO nichts hat. Neues Verhalten: 1. Führe die QBO-Buchung (recordStripePaymentInQbo inkl. Verifikation aus FIX 3) ZUERST aus, VOR den lokalen DB-Schreibvorgängen — oder alternativ so, dass ein QBO-Fehler die lokale Buchung verhindert. 2. Wenn die QBO-Buchung fehlschlägt: - KEINE lokalen "bezahlt"-Felder setzen (kein paid_date, kein payment_status='Paid', kein payments/payment_invoices-Insert) - Stattdessen: setze einen sichtbaren Fehler-Marker auf der Invoice: eine neue Spalte `qbo_payment_error TEXT` (Migration mit ergänzen), die die Fehlermeldung + Zeitstempel enthält. - stripe_payment_status darf auf 'paid' gesetzt werden (Stripe HAT ja bezahlt), aber payment_status bleibt unverändert und paid_date bleibt NULL, damit der Poller die Invoice beim nächsten Lauf erneut versucht. - Logge deutlich: "⚠️ QBO booking FAILED for #yyy — marked for retry: " 3. Nur bei erfolgreicher UND verifizierter QBO-Buchung: lokale Felder als bezahlt setzen. Wichtig: Achte darauf, dass der Poller die Invoice beim nächsten Lauf wieder aufgreift, wenn die QBO-Buchung fehlschlug (Poller-Query prüft aktuell paid_date IS NULL und stripe_payment_status NOT IN ('paid') — stelle sicher, dass eine fehlgeschlagene Buchung nicht dauerhaft ausgeschlossen wird. Falls nötig, passe die Poller-Query an, z.B. um Invoices mit gesetztem qbo_payment_error erneut zu versuchen). ### FIX 5 — Migration Erstelle eine SQL-Migration, die die Spalte `qbo_payment_error TEXT` zu invoices hinzufügt (idempotent mit IF NOT EXISTS). ### FIX 6 — Frontend-Hinweis (nur wenn schnell machbar) In invoice-view.js: Wenn invoice.qbo_payment_error gesetzt ist, zeige ein rotes Warn-Badge "QBO ⚠" mit dem Fehlertext als title-Tooltip, plus optional einen "Retry QBO"-Button. Entferne den bisherigen kosmetischen 'Stripe'-Sonderfall (payment_status === 'Stripe'), da dieser Wert nicht mehr entstehen kann. ### Reihenfolge & Test Implementiere in dieser Reihenfolge: FIX 5 (Migration) → FIX 2 → FIX 3 → FIX 4 → FIX 1 → FIX 6. Zeige nach jedem Fix ein Diff. Erkläre am Ende, wie man einen fehlgeschlagenen QBO-Call testen kann (z.B. temporär eine ungültige qbo_id erzwingen), um zu verifizieren, dass die Invoice NICHT fälschlich als bezahlt markiert wird. Beginne mit FIX 5 und arbeite dich durch. Committe nichts automatisch. --- ## Assistant **Tool: todowrite** --- ## Assistant **Tool: read** **Tool: read** --- ## Assistant **Tool: read** --- ## Assistant Starting with **FIX 5** — Migration for `qbo_payment_error`: **Tool: write** --- ## Assistant **Tool: bash** --- ## Assistant **Tool: bash** --- ## Assistant **Tool: bash** --- ## Assistant **Tool: bash** --- ## Assistant The DB isn't running right now. I'll write the migration file and proceed with the code changes. The migration can be applied when the DB is up. **FIX 5 done** — `migrations/add-qbo-payment-error.sql` created. Now **FIX 2** — Fault-Erkennung vereinheitlichen: **Tool: todowrite** --- ## Assistant Adding the `extractQboFault` helper to `qbo_helper.js` and updating `makeQboApiCall`: **Tool: read** --- ## Assistant First, add `extractQboFault` to `qbo_helper.js` and update `makeQboApiCall`: **Tool: edit** --- ## Assistant Now update `makeQboApiCall` to use the new helper instead of the inline `data.fault` check: **Tool: edit** --- ## Assistant Export it from `qbo_helper.js`: **Tool: edit** --- ## Assistant Re-export via `src/config/qbo.js`: **Tool: edit** --- ## Assistant **Tool: edit** --- ## Assistant Now update `recordStripePaymentInQbo` in `qbo-service.js` to use `extractQboFault`: **Tool: read** --- ## Assistant **Tool: edit** --- ## Assistant Now update the Fault check in `recordStripePaymentInQbo`: **Tool: edit** --- ## Assistant **Tool: todowrite** --- ## Assistant Now **FIX 3** — add post-create verification after the Payment.Id check. Let me first see the current state of the code around the log line: **Tool: read** --- ## Assistant **Tool: edit** --- ## Assistant Now update the return values to use the verified `paymentId`: **Tool: edit** --- ## Assistant **Tool: read** --- ## Assistant **Tool: edit** --- ## Assistant **Tool: read** --- ## Assistant **Tool: edit** --- ## Assistant Let me verify the current state of `recordStripePaymentInQbo`: **Tool: read** --- ## Assistant Now **FIX 4** — this is the biggest change. Let me update `stripe-poll-service.js` to: 1. Do QBO booking FIRST, before local DB writes 2. If QBO fails: mark `qbo_payment_error`, don't mark as paid 3. Modify poller query to retry invoices with `qbo_payment_error` **Tool: todowrite** --- ## Assistant **Tool: read** --- ## Assistant **Tool: edit** --- ## Assistant Now do **FIX 1** (already done in poller, still needed in `invoices.js`) and apply the same FIX 4 pattern to the check-payment endpoint: **Tool: read** --- ## Assistant **Tool: edit** --- ## Assistant **Tool: todowrite** --- ## Assistant Now **FIX 6** — Frontend. Let me read the full badge area: **Tool: read** --- ## Assistant **Tool: read** --- ## Assistant **Tool: edit** --- ## Assistant Now make the "Check" button also visible for invoices with QBO errors (so the user can retry manually): **Tool: edit** --- ## Assistant Let me verify all changes compile correctly and check for lint errors: **Tool: bash** **Tool: bash** **Tool: bash** **Tool: bash** **Tool: bash** --- ## Assistant **Tool: bash** --- ## Assistant All files pass syntax checks. Let me show a summary of all changes: **Tool: bash** --- ## Assistant **Tool: todowrite** --- ## Assistant Alle 6 Fixes implementiert. Hier eine Zusammenfassung: --- ## Änderungsübersicht | Fix | Datei | Was | |-----|-------|-----| | **FIX 5** | `migrations/add-qbo-payment-error.sql` | Neue Migration: `qbo_payment_error TEXT` zu invoices | | **FIX 2** | `qbo_helper.js:104-148` | `extractQboFault()` helper — case-insensitive Fault-Erkennung, exportiert | | **FIX 2** | `qbo_helper.js:148-156` | `makeQboApiCall` nutzt jetzt `extractQboFault` | | **FIX 2** | `src/config/qbo.js` | Re-export von `extractQboFault` | | **FIX 3** | `src/services/qbo-service.js:269-308` | Payment-Existenzprüfung + Invoice-GET-Verifikation (Balance=0) nach QBO Create | | **FIX 4** | `src/services/stripe-poll-service.js:51-140` | QBO-Buchung VOR lokalen DB-Writes; bei Fehler: `qbo_payment_error` setzen, kein Paid-Date, kein local Payment | | **FIX 4** | `src/services/stripe-poll-service.js:28-36` | Poller-Query um `OR qbo_payment_error IS NOT NULL` erweitert (Retry) | | **FIX 4** | `src/routes/invoices.js:1154` | "Already processed?"-Guard berücksichtigt `qbo_payment_error` | | **FIX 4** | `src/routes/invoices.js:1190-1265` | Check-Payment: QBO vor local DB; bei Fehler: Error-Marker + HTTP 502 | | **FIX 1** | `src/services/stripe-poll-service.js` | `'Stripe'` → `'Paid'` im payment_status | | **FIX 1** | `src/routes/invoices.js` | `'Stripe'` → `'Paid'` im payment_status | | **FIX 6** | `public/js/views/invoice-view.js:347-350` | 'Stripe'-Badge entfernt (Wert entsteht nicht mehr) | | **FIX 6** | `public/js/views/invoice-view.js:367-372` | QBO ⚠ Fehler-Badge mit Tooltip | | **FIX 6** | `public/js/views/invoice-view.js:453` | Check-Button auch bei `qbo_payment_error` sichtbar | --- ## Test-Vorschlag Einen fehlgeschlagenen QBO-Call simulieren: 1. **Migration ausführen** (Container muss laufen): `docker exec quote_db psql -U quoteuser -d quotes_db -f /docker-entrypoint-initdb.d/../migrations/add-qbo-payment-error.sql` 2. **Test-Invoice mit falscher qbo_id:** ```sql UPDATE invoices SET qbo_id = '9999999' WHERE id = ; ``` Dann `/check-payment` aufrufen oder Poller abwarten. Die Invoice sollte: - `qbo_payment_error` gesetzt bekommen - `stripe_payment_status = 'paid'` (Stripe hat ja bezahlt) - `paid_date` bleibt NULL - `payment_status` bleibt unverändert - Im Frontend erscheint das rote "QBO ⚠" Badge 3. **Nach erfolgreicher QBO-Buchung prüfen:** ```sql UPDATE invoices SET qbo_id = WHERE id = ; ``` Nächster Poller-Lauf oder manueller Check-Button sollte die Invoice vollständig als bezahlt markieren und `qbo_payment_error` auf NULL setzen. ---