Subscription-Kit —
Architektur & Engineering-Plan
Wie digitale Magazin-Abos verkauft und Leser-Zugang provisioniert werden — Stripe als zentrale Abo-State-Machine, übersetzt in Pressmatrix-Zugang über die unveränderte External-Auth-API.
Architektur-Schichten
Von der externen Source of Truth (Stripe) bis zur Persistenz. Jede Schicht hat eine klar abgegrenzte Verantwortung.
Zentrale Datenflüsse
Was bei jedem relevanten Lebensereignis eines Abos passiert — End-to-End, idempotent, ohne Doppelbelastung oder Zugangslücke.
Design-Entscheidungen
Die neun kritischen Engineering-Fragen eines Subscription-Systems — jeweils mit Problem, getroffener Entscheidung, Begründung und Trade-off, verortet in der Architektur. Das ist die Lektion aus dem produktiven Referenz-Projekt, sauber gelöst.
Stripe-State-Machine als Single Source of Truth
External SoT (Stripe) + Persistence (stripe_subscriptions als Read-Replica)Im Referenz-Projekt war der Abo-Status über vier Systeme verteilt (Shop, Subscription-App, Webhook-DB, Reader-Plattform) -> Drift, Sync-Aufwand, vier Fehlerdomänen
Stripe-Subscription-Objekt mit nativem status (incomplete/trialing/active/past_due/canceled/unpaid/paused) ist kanonisch. Die lokale stripe_subscriptions-Tabelle ist nur Cache + Audit. Status -> Zugang wird 1:1 übersetzt
Webhooks
Ingestion (stripe-webhook Edge Function)Stripe liefert at-least-once, ohne garantierte Event-Reihenfolge; rohe Payloads können veraltet/unvollständig sein
Single Webhook-Endpoint mit event.type-Router für invoice.paid, invoice.payment_failed, customer.subscription.updated/deleted, customer.updated. Webhook = Trigger, nicht Wahrheit: bei jedem Event wird der aktuelle Subscription-State frisch per Stripe-API geholt
Idempotenz / Advisory-Locks
Ingestion (stripe-webhook) + Persistence (stripe_webhook_events, Constraints)Doppelte Events und parallele Webhooks für dieselbe Subscription können Doppel-Provisioning oder Race-Conditions auslösen
Dreistufig: (1) Event-Dedup via UNIQUE(stripe_event_id), (2) Postgres Advisory-Lock pro stripe_customer_id/subscription serialisiert parallele Events, (3) DB-Unique-Constraint als letztes Netz
Customer-Portal / Checkout
Commerce (stripe-checkout, stripe-portal Edge Functions)Eigenes Checkout/Portal bedeutet PCI-Scope, Session-Management und UI-Wartung
Stripe Checkout (gehostet) und Stripe Customer Portal (gehostet) übernehmen Zahlung, Zahlungsmethoden-Update, Kündigung, Plan-Wechsel und Rechnungshistorie. Region-adaptiv: EU In-App-WebView, sonst externer Link
Dunning / Smart-Retries
External SoT (Stripe Dashboard) + Ingestion (Status-Mapping im Handler)Im Referenz-Projekt musste Dunning selbst gebaut werden (billing_failure_count, Grace-Period, pg_cron-Deaktivierung) über eine Blackbox-App
Dunning wird an Stripe Smart-Retries delegiert (ML-getimt, im Dashboard konfigurierbar: Retry-Fenster, max. Versuche, Endaktion cancel/unpaid). Der Code mappt nur status: past_due -> Zugang bleibt (Policy), canceled/unpaid -> Zugang weg
Pro-Rata Plan-Wechsel
Commerce (stripe-portal) + External SoT (Stripe)Im Shop-Stack war ein Mid-Cycle-Upgrade nur als Cancel+Neukauf möglich -> zwei parallele Abos, kein Preisabgleich, Zugangslücke
Plan-Wechsel ändert das Item am selben Stripe-Subscription-Objekt mit nativer Proration; Invoice-Preview zeigt dem Reader den exakten Betrag vor Bestätigung
Identity-Sync (mutable Email / stabiler Code)
Persistence (subscribers.stripe_customer_id) + Ingestion (customer.updated-Handler)Email ist mutabel; Keyen auf Email führt bei Email-Wechsel zu Doppel-Identitäten
Interner Anker ist stripe_customer_id (immutabel). Pressmatrix-Zugang hängt am stabilen access_code_hash + user_token. customer.updated synchronisiert nur die Email, der Code bleibt
Doppelkauf-Garantie
Commerce (stripe-checkout Pre-Check) + Persistence (Unique-Constraint)Im Referenz-Projekt brauchte der Doppelkauf-Schutz vier unabhängige Layer (Account-Pflicht, Theme-Snippet, App-Setting, DB-Constraint) -> vier Ausfallpunkte
Eine Pre-Checkout-Prüfung (aktives Abo des Customers für dieses Produkt? -> Portal-Redirect) plus DB-Unique-Constraint auf (stripe_customer_id, product/price) WHERE aktiv
Webhook-Reihenfolge / Eventual Consistency
Ingestion (Handler-Logik) + Consistency (reconcile-cron) + Persistence (stripe_webhook_events.raw_payload)Events können out-of-order und verspätet kommen, einzelne Events können ausfallen
Re-Fetch-on-Event + Advisory-Lock + Timestamp/State-Vergleich (ältere Events werden verworfen). Zusätzlich periodischer Reconcile-Cron, der aktive Abos gegen Stripe abgleicht und Drift korrigiert; raw_payload erlaubt Replay
Komponenten
Was bereits live bewiesen ist, was angepasst und was neu gebaut wird.
| Komponente | Schicht | Verantwortung | Status |
|---|---|---|---|
| pmx-api (Edge Function) | Access-Bridge | HTTP-Basic-Auth-API für die Reader-App: /authenticate (Email+Code -> user_token) und /authorize (Token+Kategorie -> Zugang ja/nein). Interface bleibt identisch zum Referenz-Projekt | Live bewiesen |
| forgot-code (Edge Function) | Access-Bridge | Rate-limitiertes Selbstbedienungs-Formular zum Code-Reset per Email, Anti-Enumeration | Live bewiesen |
| stripe-checkout (Edge Function) | Commerce | Erzeugt Stripe-Checkout-Session, führt Pre-Checkout-Doppelkauf-Prüfung durch (aktives Abo -> Portal-Redirect), region-adaptiv (EU WebView / extern) | Neu zu bauen |
| stripe-portal (Edge Function) | Commerce | Erzeugt Stripe-Billing-Portal-Session für Self-Service: Kündigung, Zahlungsmethode, Plan-Wechsel mit Pro-Rata-Vorschau | Neu zu bauen |
| reconcile-cron (pg_cron + Edge Function) | Consistency | Periodischer Re-Fetch aktiver Subscriptions gegen Stripe-API, korrigiert Drift, Safety-Net bei verpassten Webhooks | Anzupassen |
| Stripe Billing | External SoT | Kanonische State-Machine für Abos, native Renewals, Smart-Retries/Dunning, Pro-Rata, gehostetes Checkout + Customer-Portal | Neu zu bauen |
| stripe-webhook (Edge Function) | Ingestion | Empfängt Stripe-Events, verifiziert HMAC-Signatur (timestamp.payload), dedupliziert via event.id, holt frischen Subscription-State per API, dispatcht zu Event-Handlern | Neu zu bauen |
| Resend | Notifications | Transaktionale Emails (Welcome, Code-Reset, optional Renewal/Cancellation), Lazy-Init, Fire-and-Forget | Live bewiesen |
| subscribers (Tabelle) | Persistence | Reader-Identität: Email (mutabel), bcrypt access_code_hash (stabil), user_token, stripe_customer_id (stabiler interner Key) | Anzupassen |
| stripe_subscriptions (Tabelle) | Persistence | Lokaler Cache/Audit der Stripe-Subscription-State-Machine: status, current_period_start/end, price/product, billing_failure_count | Neu zu bauen |
| stripe_webhook_events (Tabelle) | Persistence | Idempotenz- und Audit-Log: UNIQUE(stripe_event_id), event_type, raw_payload für Replay | Neu zu bauen |
| check_access RPC | Persistence | Single-Roundtrip-Prüfung: aktives Abo der Kategorie im gültigen Zeitfenster ODER Einzelausgaben-Kauf. Quelle wechselt von Seal-Status zu Stripe-Status | Anzupassen |
| verify_access_code RPC | Persistence | Constant-Time bcrypt-Prüfung mit Dummy-Hash gegen Timing-Enumeration | Live bewiesen |
Migration Shopify+Seal → Stripe
Jedes Konzept des bisherigen Stacks und wie es im Kit auf Stripe abgebildet wird — meist einfacher.
| Bisher (Shop-Stack) | Subscription-Kit (Stripe) | Konsequenz | |
|---|---|---|---|
| Shop + Subscription-App (verteilter Abo-Status) | → | Stripe Billing als einzige State-Machine | Vier Systeme -> eine Source of Truth; lokale DB wird Read-Replica/Audit |
| shopify-webhook (Topic-Router: orders/paid, billing_attempts/*, contracts/update) | → | stripe-webhook (Event-Router: invoice.paid, invoice.payment_failed, subscription.updated/deleted, customer.updated) | Topic-basiert -> Event-basiert; Re-Fetch statt Payload-Vertrauen |
| Idempotenz via webhook_log UNIQUE(order_id, event_type) + Reservation | → | Dedup via stripe_webhook_events UNIQUE(stripe_event_id) + Advisory-Lock | Stripe-Event-IDs sind eindeutig; Lock ergänzt gegen Out-of-Order |
| renew_recurring_subscription RPC mit current_period_end<=heute+1 Race-Guard | → | Stripe renewt nativ; Webhook synct nur, Status ist Wahrheit | Eigene Renewal-Mathematik entfällt |
| Grace-Period via ends_at = current_period_end + GRACE_DAYS + pg_cron-Deaktivierung | → | status=past_due (Zugang per Policy offen) + Stripe Smart-Retries | Dunning von Custom-Logik zu Stripe-nativer ML-Retry delegiert |
| Subscription-App-Polling/Sync (Eventual-Consistency mit Lag) | → | Webhooks primär + täglicher Reconcile-Cron gegen Stripe-API | Kein Polling externer App nötig; Cron nur als Safety-Net |
| Doppelkauf-Schutz über 4 Layer (Account, Theme-Snippet, App-Setting, DB-Constraint) | → | 1 Pre-Checkout-Prüfung + 1 DB-Unique-Constraint | Stripe-Customer kennt aktive Abos als First-Class-Objekt |
| Plan-Wechsel = Cancel + Neukauf (Doppel-Abo-Risiko, keine Proration) | → | Item-Update am selben Abo mit nativer Proration + Invoice-Preview | Durchgehender Zugang, exakte anteilige Verrechnung |
| Identität via shopify_customer_id + Email (Email-Wechsel problematisch) | → | stripe_customer_id als immutabler Anker; access_code stabil | Email-Wechsel = reines Mapping-Update, kein Identitätsbruch |
| Customer Identity mutabel, kein Self-Service-Portal | → | Stripe Customer Portal (gehostet) für Self-Service | Kündigung/Zahlungsmethode/Plan-Wechsel ohne eigenen Code |
| GDPR via atomare RPC delete_subscriber_data (single system) | → | Koordinierte Löschung Reader-Zugang -> Stripe -> lokal | Multi-System-Orchestrierung mit Retry statt einer DB-Transaktion |
| pmx-api /authenticate + /authorize (check_access gegen App-Status) | → | pmx-api unverändert; check_access liest Stripe-abgeleiteten Status | Reader-Interface bleibt bitidentisch; nur die Statusquelle wechselt |
Sicherheitsmodell
- Stripe-Webhook-Signatur: HMAC-SHA256 über timestamp.payload gegen Endpoint-Secret, timing-safe; Timestamp-Fenster gegen Replay
- Event-Dedup über UNIQUE(stripe_event_id) verhindert doppelte Verarbeitung
- Advisory-Locks pro Customer/Subscription serialisieren parallele Events, DB-Unique-Constraints als letztes Netz
- Zugangscodes als bcrypt-Hash (cost 10), nie im Klartext gespeichert; Constant-Time-Verify mit Dummy-Hash gegen Username-Enumeration
- Rate-Limiting auf Auth-Endpoints: pro IP und pro Email (DB-basiert über security_events); Account-Lockout nach 5 Fehlversuchen (30 min)
- Anti-Enumeration: generische Antworten bei forgot-code und Login
- Re-Fetch-on-Event: Payload wird nie blind vertraut, kanonischer State kommt von Stripe-API
- RLS auf allen Tabellen, Zugriff nur via service_role; Secrets ausschließlich in ENV
- GDPR: koordinierte Löschung (zuerst Reader-Zugang entziehen, dann Stripe/lokal), Anonymisierung von Rechnungsdaten unter Aufbewahrungspflicht, Log-Retention 90 Tage via pg_cron
- PCI-Scope vollständig bei Stripe durch gehostetes Checkout + Portal
Tech-Stack & Begründung
Implementierungs-Roadmap
Phase 0 ist im produktiven Referenz-Projekt bereits bewiesen und übernehmbar. Die Stripe-spezifischen Phasen sind der eigentliche Bau-Aufwand.
Phase 0 — Bewiesene Fundamente (aus Referenz-Projekt übernehmbar)
- bcrypt Access-Code-Generierung + Constant-Time verify_access_code
- pmx-api /authenticate + /authorize (External-Auth-Interface)
- forgot-code mit Rate-Limiting + Anti-Enumeration
- Resend Email-Versand + Templates (Lazy-Init, Fire-and-Forget)
- security_events Audit + Rate-Limiting + pg_cron Log-Retention
- check_access RPC (Abo ODER Einzelausgabe, Zeitfenster)
- RLS-Schema (subscribers, issues), service_role-only
Phase 1 — Stripe-Webhook-Ingestion
- stripe-webhook Edge Function mit Signatur-Verify (timestamp.payload, Replay-Fenster)
- stripe_webhook_events Tabelle (Dedup + raw_payload)
- Event-Router invoice.paid / invoice.payment_failed / subscription.updated/deleted / customer.updated
- Re-Fetch-on-Event gegen Stripe-API
- Advisory-Lock pro stripe_customer_id
Phase 2 — State-Machine + Provisioning
- stripe_subscriptions Tabelle + Status-Mapping zu Zugang
- subscribers.stripe_customer_id Mapping + get_or_create_subscriber-Anpassung
- check_access auf Stripe-Status umstellen
- Provisioning/Deprovisioning gegen Pressmatrix
- Unique-Constraint Doppelkauf-Schutz
Phase 3 — Commerce (Checkout + Portal)
- stripe-checkout mit Pre-Checkout-Doppelkauf-Prüfung
- Region-adaptive Checkout-Auslieferung (EU WebView / extern)
- stripe-portal für Self-Service + Plan-Wechsel mit Invoice-Preview
- Welcome/Renewal/Cancellation-Emails
Phase 4 — Dunning, Consistency, GDPR
- Dunning-Policy-Mapping (past_due Zugang offen, canceled/unpaid weg)
- Stripe Smart-Retries im Dashboard konfigurieren
- reconcile-cron (täglicher Re-Fetch gegen Stripe-API)
- Koordinierte GDPR-Löschung + Rechnungs-Anonymisierung