Playbook
Entwurf der Zahlungs-Zustandsmaschine: Checkout- und Payment-Lebenszyklus (Entwurf Der Zahlungs Zustandsmaschine)
Payment.Succeeded bedeutet nicht Checkout.Completed. Wer Checkout- und Payment-Lebenszyklus nicht trennt, lässt zwei Wahrheiten in Produktion kollidieren.
Verteilte Payment Engine (Distributed Payment Engine)
Teil 2 von 22
Serie zur verteilten Payment-Architektur — die Lücke zwischen Capture und Complete.
Zwei Zeitachsen, ein Bildschirm
Der Kunde sieht auf dem Bildschirm nur einen „Bestellstatus“, doch im Hintergrund laufen mindestens zwei unabhängige Zustandsmaschinen: der Checkout-Lebenszyklus und der Payment-Lebenszyklus. Beide über ein einziges status-Feld zu verwalten, ist die Zustandsmaschinen-Version der „einen Wahrheit“-Illusion aus Teil eins.
Checkout: Init → Processing → FinalizePending → Completed
↘ Failed / Expired
Payment: Init → Processing → Captured → Completed
↘ Failed / Expired
Dieser Teil erklärt, warum diese zwei Maschinen getrennt entworfen werden müssen und warum die Lücke zwischen ihnen eine Design-Entscheidung ist, kein Bug.
Begriffe, dort definiert, wo sie zuerst auftauchen
📦 Checkout Lifecycle
Die Phasen, die eine Bestellung aus Kundensicht durchläuft: gestartet, in Bearbeitung, wartet auf Finalisierung, abgeschlossen.
📦 Payment Lifecycle
Die Phasen, die eine Geldbewegung aus PSP-Sicht durchläuft: gestartet, in Bearbeitung, abgebucht (captured), abgeschlossen.
📦 Terminal-Zustand
Ein unumkehrbarer Zustand, in dem ein Zweig der Maschine endet (Completed, Failed, Expired).
📦 Transition Guard
Eine Vorbedingung, die vor einem Übergang von einem Zustand in den nächsten geprüft wird.
📦 State Drift
Wenn zwei verwandte Zustandsmaschinen an einer Stelle, an der sie synchron sein sollten, auseinanderlaufen.
Captured bedeutet, der PSP sagt „Ich habe das Geld genommen“. Completed bedeutet, Sie sagen „Ich habe die Bestellung fertiggestellt“. Das ist nicht dasselbe Ereignis — dazwischen liegt ein Wartezustand namens FinalizePending, der Minuten dauern kann, nicht Sekunden.
Warum ein einziges status-Feld nicht reicht
Viele Systeme haben eine einzige status-Spalte in der Bestelltabelle, in die sowohl „Zahlungsstatus“ als auch „Bestellstatus“ gepresst werden. Das ist das klassische Symptom, zwei Verantwortlichkeiten in ein Feld zu zwängen: Kommt der Zahlungs-Webhook an und setzt den Status auf Completed, behauptet die Zeile nun, die Bestellung sei „fertig“ — obwohl Bestand nicht reserviert oder keine Rechnung gestellt wurde.
Orders
id | status
1 | Completed ← Webhook kam an, aber die Finalisierungs-Saga lief noch nicht
Die zwei Maschinen trennen
Das richtige Modell definiert Checkout und Payment als getrennte Zustandsmaschinen mit einer einseitigen Trigger-Beziehung: Der Wechsel von Payment zu Captured löst den Wechsel von Checkout zu FinalizePending aus; aber Checkout erreicht Completed erst, wenn die eigene Saga fertig ist.
Payment.Captured --(triggert)--> Checkout.FinalizePending
|
sobald Bestand, Ledger, Benachrichtigung, Warenkorb-Bereinigung fertig sind
↓
Checkout.Completed
Mit dieser Trennung ist „Zahlung erfolgreich, aber Bestellung wird noch bearbeitet“ kein Bug mehr, sondern ein erwarteter, anzeigbarer Zwischenzustand. Dem Kunden zu sagen „Ihre Zahlung ist eingegangen, Ihre Bestellung wird vorbereitet“ spiegelt jetzt den tatsächlichen Systemzustand korrekt wider.
Wer löst Failed und Expired aus
Beide Maschinen haben eigene Failed- und Expired-Zweige, und diese können unabhängig ausgelöst werden. Auf der Payment-Seite bedeutet Expired meist, dass der PSP innerhalb eines Zeitfensters nicht geantwortet hat (etwa eine 3D-Secure-Bestätigung, die nie abgeschlossen wurde). Auf der Checkout-Seite bedeutet Expired, dass die Finalisierungs-Saga ihr Zeitfenster nicht einhielt — selbst wenn die Zahlung bereits Captured war.
Payment.Captured + Checkout-Finalisierung nicht innerhalb von 30 Minuten fertig
↓
Checkout.Expired (aber Payment.Captured bleibt gültig — Rückerstattung oder Saga-Retry ist eine operative Entscheidung)
Dieses Szenario zeigt den eigentlichen Nutzen der Trennung: Sie können auf der Checkout-Seite einen unabhängigen Wiederherstellungsprozess starten, ohne den Zustand Payment.Captured zu beschädigen. Mit einem einzigen status-Feld könnten Sie diese zwei Wahrheiten nicht gleichzeitig darstellen.
Guards: Übergänge absichern
Jeder Übergang braucht einen Guard. Der Übergang Checkout.Processing → Checkout.FinalizePending sollte zum Beispiel erst erfolgen, nachdem bestätigt wurde, dass der verknüpfte Payment-Datensatz tatsächlich im Zustand Captured ist. Eine Zustandsmaschine ohne Guards kann in ungültige Zustände abrutschen, wenn Webhooks in falscher Reihenfolge eintreffen — ein Problem, das wir in Teil sechs dieser Serie vertiefen.
Guard: Übergang zu Checkout.FinalizePending
→ Existiert ein verknüpfter Payment-Datensatz?
→ Ist Payment.Status == Captured?
→ Stimmt Payment.Amount mit dem Checkout-Snapshot überein?
Sind alle Bedingungen erfüllt, wird der Übergang erlaubt; sonst wird er abgelehnt und das Ereignis in eine Warteschlange gestellt.
Die am häufigsten verwechselten Zuordnungen
❌ Payment.Succeeded = Checkout.Completed
✓ Payment.Succeeded löst Checkout.FinalizePending aus; Completed ist eine eigene Entscheidung
❌ Ein einziges status-Feld kann Zahlungs- und Bestellstatus gemeinsam tragen
✓ Zwei unabhängige Lebenszyklen brauchen zwei unabhängige Felder (oder Tabellen)
❌ Ein Failed-Zustand bedeutet immer „das Geld ging zurück“
✓ Checkout.Failed macht Payment.Captured nicht ungültig; es braucht einen eigenen Kompensationsprozess
❌ Ein Übergang ohne Guard ist nur „eine zusätzliche Prüfung“
✓ Guards sind die einzige Verteidigung gegen nicht geordnete oder doppelte Ereignisse
Checkliste für Ihre Zustandsmaschine
- Wird der Zahlungsstatus in derselben Spalte wie der Bestellstatus gespeichert? Trennen Sie beide.
- Welche Guards prüft der Code, der den Übergang
Captured → Completedauslöst, tatsächlich? - Was passiert, wenn die Checkout-Saga nach
Capturednicht innerhalb von 30 Minuten fertig wird? Gibt es einen Alarm? - Können Sie klar auflisten, welches Ereignis jeden Übergang zu
FailedundExpiredauslöst? - Wenn zwei Webhooks für dieselbe Zahlung in falscher Reihenfolge eintreffen — fängt Ihr Guard das ab, oder erlaubt er einen ungültigen Übergang?
Können Sie diese fünf Fragen nicht sicher beantworten, haben Sie wahrscheinlich zwei Zustandsmaschinen in ein Feld zusammengelegt.
Was aus diesem Teil bleiben sollte
- Checkout und Payment sind zwei verwandte, aber unabhängige Zustandsmaschinen; sie lassen sich nicht in ein
status-Feld zwängen. - Payment.Succeeded garantiert nicht Checkout.Completed; dazwischen liegt eine messbare, anzeigbare
FinalizePending-Lücke. - Jeder Übergang braucht einen Guard; ohne ihn treiben nicht geordnete oder doppelte Ereignisse die Maschine in ungültige Zustände.
FailedundExpiredkönnen auf beiden Maschinen unabhängig ausgelöst werden; eines macht das andere nicht automatisch ungültig.
An dem Tag, an dem Sie Zahlungsstatus und Bestellstatus in dieselbe Spalte schreiben, verschmelzen Sie zwei getrennte Wahrheiten zu einer Lüge.
FAQ
Häufige Fragen
Was ist Checkout Lifecycle?
Die Phasen, die eine Bestellung aus Kundensicht durchläuft: gestartet, in Bearbeitung, wartet auf Finalisierung, abgeschlossen.
Was ist Payment Lifecycle?
Die Phasen, die eine Geldbewegung aus PSP-Sicht durchläuft: gestartet, in Bearbeitung, abgebucht (captured), abgeschlossen.
Stimmt es, dass „Payment.Succeeded = Checkout.Completed“?
Payment.Succeeded löst Checkout.FinalizePending aus; Completed ist eine eigene Entscheidung
Was legt dieser Teil fest?
Dieser Teil erklärt, warum diese zwei Maschinen getrennt entworfen werden müssen und warum die Lücke zwischen ihnen eine Design-Entscheidung ist, kein Bug. Checkout und Payment sind zwei verwandte, aber unabhängige Zustandsmaschinen; sie lassen sich nicht in ein `status`-Feld zwängen. Der Kunde sieht auf dem Bildschirm nur einen „Bestellstatus“, doch im Hintergrund laufen mindestens zwei unabhängige Zustandsmaschinen: der Checkout-Lebenszyklus und der Payment-Lebenszyklus. Beide über ein einziges `status`-Feld zu verwalten, ist die Zustandsmaschinen-Version der „einen Wahrheit“-Illusion aus Teil eins.
Gelernte Engineering-Prinzipien
- Checkout- und Payment-Lebenszyklus sind verwandt, aber unabhängig; sie dürfen sich kein Feld teilen.
- Payment.Succeeded ist ein Trigger, kein Ergebnis; Checkout.Completed hängt vom Abschluss der eigenen Saga ab.
- Ein Übergang ohne Guard öffnet die Tür zu ungültigen Zuständen, sobald Ereignisse ungeordnet oder doppelt eintreffen.
Weiterlesen
Weiterlesen
Nachster Teil der Serie
Warum Capture einfach ist, aber Finalisierung schwer
Dass der PSP das Geld nimmt, ist ein Schritt. Die Bestellung fertigzustellen ist eine Saga, die Bestand, Finanzen, Benachrichtigung und Aufräumen braucht.
Nachster Teil der Serie
Warum Zahlungssysteme verteilte Systeme sind
Eine Zahlung ist nie die Aufgabe eines einzigen Services: Warenkorb, Bestand, Provider-Gateway und Ledger müssen sich einigen.…
Aus derselben Serie
Unveränderliches Payment-Snapshot-Design: Der Warenkorb wird eingefroren
Den Live-Warenkorb während der Zahlung erneut zu lesen, macht Betrag und Währung unentschieden. Ohne einen eingefrorenen Snapshot ist Finalisierung nicht…