Files
invoice-system/session-ses_0d6e.md
2026-07-03 16:42:52 -05:00

66 KiB
Raw Permalink Blame History

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

[fullyPaid ? 'Stripe' : 'Partial', invoice.id]   // ← 'Stripe' ist falsch

Stelle 2: src/routes/invoices.js:1224

[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:

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:

-- 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-ID>,                                                │
│            qbo_sync_token = <token>,                                         │
│            qbo_doc_number = <docNum>,                                        │
│            invoice_number = <docNum>                                         │
│          → 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 = <plink_xxx>,                             │
│            stripe_payment_link_url = <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:

// 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:

// 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

// 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

// 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?.Idundefined.
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

// 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):

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):

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:

// 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

// 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 paymentIntentIds. 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

-- 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 = '<qbo_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 = '<ref>' 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 donemigrations/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:

    UPDATE invoices SET qbo_id = '9999999' WHERE id = <test_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:

    UPDATE invoices SET qbo_id = <korrekte_id> WHERE id = <test_id>;
    

    Nächster Poller-Lauf oder manueller Check-Button sollte die Invoice vollständig als bezahlt markieren und qbo_payment_error auf NULL setzen.