← Alle Artikel

Stripe als Source-of-Truth: warum Plan-Updates nur in eine Richtung fließen sollten

Wer den Plan eines Users in der eigenen Datenbank aktualisiert und dann hofft, dass Stripe mitkommt, baut sich Out-of-Sync-Bugs ein. Wie ich das in Portchecks rigoros andersherum aufgesetzt habe.

#Laravel #DevOps

Wer eine SaaS-Anwendung mit Stripe verheiratet, steht früh vor einer scheinbar harmlosen Frage: Wo lebt eigentlich der „aktuelle Plan" einer Userin?

Die naheliegende Antwort wäre: in der eigenen Datenbank. Du hast eine Spalte users.plan, du kennst dein Datenmodell, alles bleibt unter deiner Kontrolle. Und wenn jemand upgradet, machst du eben beides: lokal die Spalte updaten und parallel den Stripe-Aufruf absetzen.

Das funktioniert genau so lange, bis einer der beiden Calls fehlschlägt.

Das Out-of-Sync-Problem

Stell dir den klassischen Ablauf vor:

  1. User klickt auf „Upgrade auf Solo".
  2. Du updatest users.plan = 'solo'.
  3. Du rufst die Stripe-API auf.
  4. Stripe ist gerade kurz nicht erreichbar – Exception.

Jetzt hast du in deiner Datenbank einen Solo-User, der Stripe gegenüber aber nichts bezahlt. Im Best Case fällt das im nächsten Deploy auf. Im Worst Case nutzt jemand drei Wochen lang die Solo-Limits umsonst, bis du es bemerkst.

Reihenfolge umkehren? Hilft auch nicht: Dann hast du im Worst Case einen User, der Stripe etwas zahlt, aber lokal noch im Free-Plan steckt.

Die ehrliche Antwort: Du hast einen Zwei-Phasen-Commit über zwei Systeme gebaut, und die gibt es nun einmal nicht ohne Aufwand.

Die andere Richtung: Stripe ansagen, lokal nur reagieren

In Portchecks ist die Regel scharf: Plan-Änderungen passieren nie in der App. Sie passieren in Stripe, und die App lernt davon über Webhooks.

Konkret heißt das:

  1. User klickt auf „Upgrade".
  2. Du leitest auf Stripe Checkout um.
  3. Stripe legt die Subscription an, kassiert ggf. die Karte, schickt einen customer.subscription.created-Webhook zurück.
  4. Cashier dispatcht ein Event, der Listener mappt die Stripe-Price-ID auf den Plan-Enum und schreibt users.plan.

Der Listener ist ungefähr so klein, wie es klingt:

class SyncUserPlanFromStripe
{
    public function handle(WebhookReceived $event): void
    {
        $payload = $event->payload;
        $type = $payload['type'] ?? null;

        if (! str_starts_with($type, 'customer.subscription.')) {
            return;
        }

        $stripeId = data_get($payload, 'data.object.customer');
        $user = User::where('stripe_id', $stripeId)->first();
        if (! $user) {
            return;
        }

        $priceId = data_get($payload, 'data.object.items.data.0.price.id');
        $newPlan = $type === 'customer.subscription.deleted'
            ? Plan::Free
            : Plan::fromStripePriceId($priceId);

        if ($newPlan !== null && $user->plan !== $newPlan) {
            $user->update(['plan' => $newPlan]);
        }
    }
}

Drei Eigenschaften, die mir wichtig sind:

  • Idempotent. Wenn Stripe einen Webhook zweimal sendet (kommt vor) und der Plan schon stimmt, passiert nichts. Kein erneutes Update, keine Event-Schleife.
  • Robust gegen unbekannte Price-IDs. Plan::fromStripePriceId() gibt null zurück, wenn etwas nicht zuzuordnen ist – und dann wird einfach nichts geschrieben statt einen falschen Plan zu setzen.
  • Klare Default-Regel bei Cancellation. customer.subscription.deleted setzt zurück auf Free. Punkt.

Was du dafür im Frontend machst

Das Frontend wird einfacher, nicht komplizierter. Statt nach dem Klick auf „Upgrade" auf einen Erfolg-Status warten zu müssen, leitest du direkt auf Stripe Checkout um. Stripe übernimmt die UI, schickt den User danach zurück auf eine /billing/success-Seite, die im einfachsten Fall nichts macht außer „du wirst dort weitergeleitet".

Wenn du sicher gehen willst, dass beim Reload die UI schon den neuen Plan zeigt, kannst du auf der Success-Seite kurz pollen, bis auth()->user()->plan der erwartete ist – meist sind die Webhooks innerhalb von Sekunden durch.

Wann das nicht passt

Wenn deine App ohne Internet-Verbindung zu Stripe Plan-Änderungen erlauben muss (Offline-First, lokale Installationen), funktioniert das nicht. Dann brauchst du eine echte Synchronisations-Schicht mit Konfliktlösung.

Für 99 % der SaaS-Anwendungen ist das aber Overkill. Stripe ist ohnehin fast immer erreichbar, und wenn nicht, ist eine kurze „kommt gleich"-Seite ehrlicher als ein lokales Update, das man später wieder zurücknehmen muss.

Was du gewinnst

Du hast keinen einzigen Code-Pfad mehr, der zwei Systeme synchron halten muss. Stripe ist die Wahrheit, deine DB ist der Cache. Wenn die beiden auseinanderlaufen, weißt du genau, in welche Richtung du repariren musst – und ein einfacher „Webhooks der letzten 7 Tage erneut abspielen"-Job schiebt sie wieder zusammen.

In Portchecks war das die zweite Architektur-Entscheidung nach den Queue-Lanes. Beide haben den gleichen Charakter: Sie machen die App einfacher, indem sie auf Kontrolle verzichten, die du sowieso nie wirklich hattest.

Diese Seite nutzt ausschließlich technisch notwendige Cookies. Kein Tracking, keine Werbung.