Journal
Technik3 Min. Lesezeit

Event-getrieben von Grund auf: Wie aus einer Zahlung Treuepunkte, Kundenbindung und ein Beleg werden

Eine Buchungsplattform steht und fällt mit ihrer Konsistenz. Hier steht, wie sich eine einzige Stripe-Zahlung in Treuepunkte, Kundenbindung, einen Beleg und einen Buchungssatz auffächert, warum jeder Effekt an eine echte Zustandsänderung gekoppelt ist und wohin wir die Architektur als Nächstes entwickeln.

DBDas Bookatu-Technikteam

Wenn ein Kunde eine Buchung, eine Anzahlung, ein Paket oder eine Mitgliedschaft bezahlt, soll dieses eine Ereignis das ganze System in Bewegung setzen. Sein Treueguthaben soll wachsen. Er soll wieder als aktiv gelten, nicht als abgesprungen. Er soll einen Beleg bekommen. Und das Geld soll für den Abgleich in der Buchhaltung landen. Geht davon eines schief, verliert das Produkt still und leise an Vertrauen: Einem Mitglied wird abgebucht, aber sein Guthaben taucht nie auf, oder ein erneuter Zustellversuch von Stripe zählt einen Verkauf doppelt.

Eine Zeit lang war jedes davon eine eigene Insel. Der Webhook markierte etwas als bezahlt und war damit fertig. Also haben wir die Zahlung zu einem Ereignis gemacht und den Rest des Systems darauf reagieren lassen.

Das Muster: auffächern, aber nur bei einer echten Zustandsänderung

Diagramm: Eine Stripe-Zahlung trifft auf einen idempotenten Webhook, der applyPaidRipple aufruft und sich zu Treueprogramm, Kundenbindung, Beleg und Buchhaltung auffächert.
Eine Zahlung, vier Effekte. Der Webhook ist die Nahtstelle, die Welle läuft als Best Effort und blockiert nie den Weg des Geldes.

Stripe schickt checkout.session.completed an einen einzigen Webhook. Der Handler erledigt zuerst den geldkritischen Schreibvorgang (Anzahlung als bezahlt markieren, Rechnung auf bezahlt setzen, Paketguthaben gutschreiben) und ruft dann eine einzige gemeinsame Funktion auf, applyPaidRipple, für alles rund um die Kundenbeziehung. Die Welle läuft als Best Effort: Jeder Schritt ist so gekapselt, dass ein Fehler protokolliert und geschluckt wird, denn ein wackliger E-Mail-Anbieter darf uns nie daran hindern, eine echte Zahlung festzuhalten.

ts
// In the webhook, after the money-critical write succeeds:if (fresh) {  await applyPaidRipple({    orgId,    customerId,    amountCents,    loyaltyReason: "Package purchase",    receiptTo: email,    receiptDedupeKey: `payment_receipt:package:${session.id}`,  });}// applyPaidRipple: loyalty -> lastVisitAt bump -> transactional receipt,// each in its own try/catch. The ledger write runs unconditionally below.

Idempotenz ist das A und O

Stripe liefert dasselbe Ereignis mehr als einmal aus. Das ist ein Feature, kein Bug, und es bedeutet, dass jeder Handler zweimal laufen können muss, ohne Schaden anzurichten. Zwei Schichten schützen uns. Erstens wird die Event-ID mit einem Unique-Constraint in einer Tabelle für verarbeitete Ereignisse festgehalten, sodass die erneute Zustellung genau desselben Ereignisses sofort abbricht. Zweitens, und das ist wichtiger, koppelt jeder Pfad seine Welle an seine eigene echte Zustandsänderung: ein Boolean, der nur beim allerersten Mal wahr ist, wenn die Zeile tatsächlich umspringt.

ts
// markInvoicePaid flips 'open' -> 'paid' and returns true ONLY on that// real transition. A redelivery sees 'paid' already and returns false.const flipped = await markInvoicePaid(invoiceId, paymentIntentId, amountTotal);if (flipped) {  await applyPaidRipple({ /* ...loyalty, retention, receipt */ });}// So even if the event-id dedupe is bypassed, the ripple still fires once.

Der Dedupe-Key ist kein nettes Extra. Er ist der Unterschied zwischen einem Mitglied, dem einmal gedankt wird, und einem, dem zweimal abgebucht wird.

Wohin die Reise geht

Heute ist die Welle ein Fan-out im selben Prozess, und das ist genau die richtige Menge Maschinerie für unseren Stand: einfach, transaktional und leicht zu durchschauen. Aber der Webhook ist bewusst eine Nahtstelle, und auf der Roadmap wird daraus ein dauerhafter Event-Bus. Die Form bleibt gleich, die Zustellung wird robuster.

  • Eine Queue vor jedem Effekt (Treueprogramm, Kundenbindung, Nachrichten), damit ein langsamer Consumer den Webhook nie aufhält und Wiederholungen automatisch mit Backoff laufen.
  • Eine Outbox-Tabelle, die in derselben Transaktion wie das Geld geschrieben und danach an den Bus weitergereicht wird, damit zwischen „bezahlt“ und „veröffentlicht“ nie ein Effekt verloren geht.
  • Die schwersten Consumer (Benachrichtigungen, Retention-Scoring) mit wachsender Last in eigene deploybare Dienste auslagern, während der Kern des Buchungsschreibens eine einzige, stark konsistente Einheit bleibt.
  • Eine verwaltete Event- und Queue-Schicht in unserer Cloud, damit Skalierung eine Konfigurationsänderung ist statt einer Migration.

Die Lehre, die über allem steht: Halte den Weg des Geldes stark konsistent und synchron, und mache alles, was daran hängt, event-getrieben, idempotent und Best Effort. Eine Zahlung ist eine Tatsache. Alles Gute, das daraus folgt, ist eine Reaktion, und Reaktionen sollten sich gefahrlos wiederholen lassen.

softwareentwicklungevent-drivenarchitekturstripeidempotenzroadmap
Willst du das umsetzen?

Bookatu gibt dir eine Buchungsseite in deinem eigenen Look, Anzahlungen, Mitgliedschaften, Gutscheine und Erinnerungen, mit 0 % Provision auf deine Buchungen.

Kostenlos starten