feat: Stripe Embedded Checkout statt Weiterleitung zu checkout.stripe.com

Bezahlt wird jetzt direkt auf /checkout: die Checkout-Session laeuft mit
ui_mode "embedded_page", das Zahlungsformular ist ein Stripe-iframe
(PCI SAQ A bleibt). PayPal, Karte, SEPA und Klarna kommen weiterhin
dynamisch aus dem Dashboard.

- Zahlungsschritt ersetzt das Formular; "Zurueck zu deinen Angaben" gibt
  Sitzung und Bestand sofort frei (POST /api/checkout/cancel) und behaelt
  alle Eingaben, Turnstile wird neu geladen
- Rueckkehrseite prueft serverseitig bei Stripe und gleicht die Sitzung mit
  der eigenen Bestellung ab; bei abgebrochener PayPal-Zahlung wird dieselbe
  Sitzung erneut eingebettet
- Erneuter Versuch und Abbruch sind per HttpOnly-Cookie an den bestellenden
  Browser gebunden, die session_id in der URL allein reicht nicht
- Alte /checkout/cancel?ref= Links aendern per GET nichts mehr
- CSP um die Stripe-Hosts fuer Embedded Checkout und 3-D-Secure erweitert
- STRIPE_PUBLISHABLE_KEY zur Laufzeit gelesen (kein NEXT_PUBLIC_), mit
  Preflight-Pruefung auf pk_live und Schutz gegen versehentliche sk_/rk_

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-25 08:01:18 +02:00
1 parent 6ac7cc5d1f
commit 4ee5bafe20
28 files changed
+1272 -179

No files matched your search

+19 -7
View File
@@ -142,15 +142,27 @@ Nur `db:push`/`db:migrate`/`db:deploy`/`db:studio` (Befehle, die tatsächlich ge
## Stripe
1. Test-Key aus dem [Stripe-Dashboard](https://dashboard.stripe.com/test/apikeys) in `.env` eintragen (`STRIPE_SECRET_KEY`). Einen veröffentlichbaren `pk_`-Schlüssel braucht der Shop nicht: der Checkout läuft vollständig serverseitig über eine Stripe-Checkout-Session, der Browser wird nur weitergeleitet.
1. Test-Keys aus dem [Stripe-Dashboard](https://dashboard.stripe.com/test/apikeys) in `.env` eintragen: `STRIPE_SECRET_KEY` (empfohlen: eingeschränkter `rk_`-Schlüssel) und `STRIPE_PUBLISHABLE_KEY` (`pk_test_…`). Der öffentliche Schlüssel wird zur Laufzeit gelesen (kein `NEXT_PUBLIC_`), damit dasselbe Docker-Image in Test und Produktion läuft.
2. Für Webhooks lokal: `stripe listen --forward-to localhost:3000/api/checkout/webhook` und das ausgegebene Secret als `STRIPE_WEBHOOK_SECRET` eintragen.
3. Ohne konfigurierte Stripe-Keys bleibt der Checkout funktionsfähig: Die Bestellung wird angelegt, der Nutzer landet mit Hinweis auf der Erfolgsseite, die Bestellung kann im Admin-Bereich manuell nachbearbeitet werden.
3. Ohne `STRIPE_PUBLISHABLE_KEY` nimmt der Checkout keine Bestellung an und sagt das im Formular — eine Bestellung, die sich auf der Seite nicht bezahlen ließe, soll gar nicht erst entstehen. Für reine UI-Arbeit ohne Zahlung reicht es, die Keys leer zu lassen.
### Zahlungsmethoden (Karte, SEPA-Lastschrift, Klarna)
### Eingebetteter Checkout
Die Checkout Session gibt bewusst kein `payment_method_types` vor — Stripe zeigt Kundinnen und Kunden dadurch automatisch alle im Dashboard aktivierten, für Betrag/Währung/Land berechtigten Zahlungsmethoden an ("dynamische Zahlungsmethoden"). Um SEPA-Lastschrift und Klarna neben Kartenzahlung anzubieten, reicht es, sie unter [Dashboard → Zahlungsmethoden](https://dashboard.stripe.com/settings/payment_methods) zu aktivieren — dafür ist keine Codeänderung nötig.
Bezahlt wird direkt auf `/checkout`, ohne Umleitung auf `checkout.stripe.com`: Die Checkout-Session läuft mit `ui_mode: "embedded_page"`, das Zahlungsformular ist ein Stripe-iframe. Kartendaten und PayPal-Anmeldung berühren den Server nie (PCI SAQ A bleibt).
**Apple Pay / Google Pay**: Diese Wallets sind bei der von Stripe gehosteten Checkout-Seite (der hier verwendete Modus, `stripe.checkout.sessions.create(...)` ohne `ui_mode: "embedded"`) laut Stripe-Dokumentation "ohne Änderungen an der Integration" automatisch enthalten, solange Kartenzahlung aktiviert ist (Standardeinstellung) — sie erscheinen als zusätzliche Option innerhalb des Kartenformulars auf unterstützten Geräten/Browsern (Safari mit hinterlegter Karte in Apple Wallet, Chrome/Android mit hinterlegter Karte in Google Pay). Die separate Domain-Registrierung unter [Dashboard → Zahlungsmethoden-Domains](https://dashboard.stripe.com/settings/payment_method_domains) ist laut Stripe nur für Elements bzw. die *eingebettete* Checkout-Zahlungsseite nötig, **nicht** für die vollständig gehostete Checkout-Seite (eigene, von Stripe bereits verifizierte Domain `checkout.stripe.com`) — hier also kein weiterer Schritt nötig. Falls die Wallets bei einem Testkauf trotzdem nicht erscheinen, liegt es fast immer am Testgerät/-browser (kein Apple Pay/Google Pay eingerichtet) oder daran, dass Kartenzahlung im Dashboard deaktiviert wurde, nicht am Code.
Ablauf:
1. Angaben ausfüllen → `POST /api/checkout` legt die Bestellung an, reserviert Bestand und liefert `clientSecret` + `cancelToken`.
2. Das Formular tritt zurück, das Stripe-Formular erscheint (Stripe.js wird erst jetzt geladen). „Zurück zu deinen Angaben" ruft `POST /api/checkout/cancel` auf: Sitzung schließen, Bestand sofort freigeben, alle Eingaben bleiben erhalten.
3. Nach jedem Zahlungsversuch — bei PayPal nach dem Umweg über paypal.com — leitet Stripe auf `/checkout/success?session_id=…`. Die Seite fragt **serverseitig** bei Stripe nach und gleicht die Sitzung mit der eigenen Bestellung ab; die URL allein beweist nichts. Bei `open` (abgebrochen/abgelehnt) wird dieselbe Sitzung erneut eingebettet, der Warenkorb bleibt erhalten — aber nur für den Browser, der die Bestellung angelegt hat: `POST /api/checkout` setzt dafür das HttpOnly-Cookie `wh_checkout_ref` (SameSite=Lax, Pfad `/checkout`, 40 Minuten). Die `session_id` in der Adresszeile allein öffnet weder das Zahlungsformular noch den Abbruch. Verbucht wird weiterhin ausschließlich im Webhook.
Die Content-Security-Policy in `src/proxy.ts` enthält die dafür nötigen Stripe-Hosts laut [Stripe-Sicherheitsleitfaden](https://docs.stripe.com/security/guide#content-security-policy). Farben und Schrift des Formulars stellt man im Dashboard unter [Branding](https://dashboard.stripe.com/settings/branding) ein.
### Zahlungsmethoden (PayPal, Karte, SEPA-Lastschrift, Klarna)
Die Checkout Session gibt bewusst kein `payment_method_types` vor — Stripe zeigt Kundinnen und Kunden dadurch automatisch alle im Dashboard aktivierten, für Betrag/Währung/Land berechtigten Zahlungsmethoden an ("dynamische Zahlungsmethoden"). Um PayPal, SEPA-Lastschrift und Klarna neben Kartenzahlung anzubieten, reicht es, sie unter [Dashboard → Zahlungsmethoden](https://dashboard.stripe.com/settings/payment_methods) zu aktivieren — dafür ist keine Codeänderung nötig. PayPal über Stripe ist für Konten in Deutschland verfügbar und muss für den Live-Betrieb einmal [aktiviert](https://docs.stripe.com/payments/paypal/activate) werden.
**Apple Pay / Google Pay**: werden bewusst nicht angeboten. Im eingebetteten Checkout erscheinen sie nur nach einer Domain-Registrierung unter [Dashboard → Zahlungsmethoden-Domains](https://dashboard.stripe.com/settings/payment_method_domains); ohne sie bleiben sie ausgeblendet. Die `Permissions-Policy` (`payment=(self)` in `next.config.ts`) müsste dafür zusätzlich für `https://js.stripe.com` geöffnet werden.
SEPA-Lastschrift und teils Klarna bestätigen die Zahlung **verzögert** (bei SEPA i. d. R. einige Werktage), nicht sofort wie eine Kartenzahlung. Der Webhook-Handler (`/api/checkout/webhook`) berücksichtigt das:
@@ -174,7 +186,7 @@ Eine Bestellung entsteht **vor** der Zahlung und reserviert dabei sofort Lagerbe
| Wann | Was passiert |
|------|--------------|
| Klick auf „Abbrechen“ bei Stripe | `/checkout/cancel?ref=<Token>` schließt die Stripe-Sitzung, bucht den Bestand **sofort** zurück und löscht die Bestellung |
| „Zurück zu deinen Angaben“ / „Bestellung abbrechen“ | `POST /api/checkout/cancel` schließt die Stripe-Sitzung, bucht den Bestand **sofort** zurück und löscht die Bestellung (alte Links `/checkout/cancel?ref=<Token>` ändern nichts mehr — ein GET darf keinen Bestand freigeben, Link-Vorschauen und Mail-Scanner lösen ihn aus) |
| Checkout einfach verlassen | Die Sitzung läuft nach **32 Minuten** ab, Stripe schickt `checkout.session.expired`, der Webhook bucht zurück und löscht |
| Webhook kommt nicht an | Der CronJob `wabenhain-release-stale-orders` prüft alle 30 Minuten bei Stripe nach und bucht zurück, was dort abgelaufen ist |
| Stripe-Sitzung lässt sich gar nicht erst anlegen | `api/checkout/route.ts` bucht den Bestand sofort zurück und hinterlässt die Bestellung als **STORNIERT** mit Notiz — sie war nie bezahlbar |
@@ -197,7 +209,7 @@ Setzt die Imkerei eine Bestellung unter `/admin/bestellungen/<id>` auf **STORNIE
Die Stripe-Aufrufe laufen bewusst **nach** der Transaktion: ein Netzaufruf dauert ein Vielfaches einer Datenbankoperation und würde die Sperren auf Bestellung und Lagerbestand so lange halten. Schlägt einer davon fehl, wird die Stornierung **nicht** zurückgenommen — die Ware ist bereits wieder im Bestand und die Absicht war eindeutig. Stattdessen erscheint der offene Punkt als Fehlermeldung im Admin-Bereich („bitte im Stripe-Dashboard von Hand erstatten") und als Eintrag `order.cancel_incomplete` im Audit-Log.
Der **Abbruch-Token** (`Order.cancelToken`, 32 Byte) steht ausschließlich in der `cancel_url` und wird nach Gebrauch aus der Adresszeile entfernt. Die Bestellnummer taugt dafür nicht: sie steht in jeder Bestätigungsmail und auf jeder Rechnung, mit ihr ließe sich also die noch offene Zahlung einer fremden Person abbrechen. Vor dem Zurückbuchen wird immer erst bei Stripe nachgesehen — ist die Sitzung dort `complete`, war der Aufruf kein Abbruch, sondern ein Zurück-Klick nach erfolgreicher Zahlung, und es passiert nichts.
Der **Abbruch-Token** (`Order.cancelToken`, 32 Byte) geht ausschließlich an den Browser, der die Bestellung gerade anlegt (bzw. an die Rückkehrseite derselben, noch offenen Sitzung), und wird nur per POST mit Origin-Prüfung eingelöst. Die Bestellnummer taugt dafür nicht: sie steht in jeder Bestätigungsmail und auf jeder Rechnung, mit ihr ließe sich also die noch offene Zahlung einer fremden Person abbrechen. Vor dem Zurückbuchen wird immer erst bei Stripe nachgesehen — ist die Sitzung dort `complete`, war der Aufruf kein Abbruch, sondern ein Zurück-Klick nach erfolgreicher Zahlung, und es passiert nichts.
Der Aufräumlauf lässt sich auch von Hand anstoßen: `pnpm orders:release-stale`. Er bucht nur zurück, was bei Stripe nachweislich abgelaufen ist, nie nach Alter allein. Findet er eine Bestellung, die bei Stripe **bezahlt** ist, hier aber noch auf „NEU“ steht, rührt er sie nicht an, meldet sie und endet mit Exit-Code 1 — dieser Fall bedeutet, dass ein `completed`-Webhook verlorengegangen ist, und er gehört als fehlgeschlagener Job sichtbar gemacht statt in eine Logzeile.