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

+59 -20
View File
@@ -14,6 +14,11 @@ import { findCustomsExcludedArea, isCountryAllowed } from "@/lib/shipping-region
import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc";
import { ensureBulkDiscountCode } from "@/lib/bulk-discount";
import { releaseUnpaidOrder } from "@/lib/order-release";
import {
CHECKOUT_COOKIE,
CHECKOUT_COOKIE_MAX_AGE_SECONDS,
CHECKOUT_COOKIE_PATH,
} from "@/lib/checkout-release";
import { getBaseUrl } from "@/lib/url";
import { vatRateForProduct } from "@/lib/vat";
import { getLocale } from "@/i18n/locale";
@@ -360,11 +365,12 @@ export async function POST(request: Request) {
const locale = await getLocale();
// Steht gleich ausschliesslich in der cancel_url der Stripe-Sitzung und
// gibt den reservierten Bestand frei, sobald die Kundschaft dort abbricht
// (siehe checkout/cancel). 32 Byte, damit er nicht durchprobiert werden
// kann — mit einem ratbaren Wert liesse sich die offene Zahlung fremder
// Leute abbrechen.
// Geht ausschliesslich in der Antwort an den Browser, der diese Bestellung
// gerade anlegt, und gibt den reservierten Bestand frei, sobald die
// Kundschaft den Zahlungsschritt verlaesst (siehe api/checkout/cancel).
// 32 Byte, damit er nicht durchprobiert werden kann — mit einem ratbaren
// Wert liesse sich die offene Zahlung fremder Leute abbrechen. Die
// Bestellnummer taugt dafuer nicht: sie steht in jeder Bestaetigungsmail.
const cancelToken = randomBytes(32).toString("hex");
// Bestellanlage und Bestandsreservierung atomar: das bedingte updateMany
@@ -493,7 +499,7 @@ export async function POST(request: Request) {
const stripe = getStripeClient();
// Bewusst NICHT aus dem Origin-Request-Header: der ist vom Aufrufer frei
// waehlbar (z.B. direkter API-Aufruf statt ueber den Browser) und wuerde
// sich sonst in success_url/cancel_url der Stripe-Session einschleusen —
// sich sonst in die return_url der Stripe-Session einschleusen —
// ein offenes Redirect direkt nach einer echten Zahlung auf Stripes
// eigener Checkout-Seite waere hochgradig phishing-tauglich.
const origin = getBaseUrl();
@@ -514,13 +520,15 @@ export async function POST(request: Request) {
// Kein payment_method_types (s. README "Zahlungsmethoden"): Stripe zeigt
// dadurch dynamisch alle im Dashboard aktivierten, berechtigten
// Zahlungsmethoden. Apple Pay/Google Pay sind bei dieser vollstaendig
// gehosteten Checkout-Seite (kein ui_mode: "embedded") laut Stripe-Doku
// automatisch enthalten, sobald Kartenzahlung aktiv ist — keine
// Codeaenderung und (anders als bei Elements/eingebetteter Checkout-Seite)
// keine Domain-Registrierung noetig, da checkout.stripe.com bereits
// Stripes eigene, verifizierte Domain ist.
// Zahlungsmethoden — PayPal eingeschlossen, sobald es dort aktiv ist.
//
// "embedded_page": das Zahlungsformular erscheint als Stripe-iframe
// direkt auf /checkout statt auf checkout.stripe.com. Kartendaten
// beruehren diesen Server trotzdem nie (PCI SAQ A bleibt). Apple Pay und
// Google Pay braeuchten dafuer eine Domain-Registrierung im Dashboard;
// ohne sie erscheinen sie schlicht nicht.
const session = await stripe.checkout.sessions.create({
ui_mode: "embedded_page",
mode: "payment",
customer_email: input.email,
line_items: [
@@ -549,14 +557,27 @@ export async function POST(request: Request) {
// Ohne diese Angabe laeuft die Sitzung erst nach 24 Stunden ab und
// haelt so lange Lagerbestand fest (siehe CHECKOUT_SESSION_TTL_MINUTES).
expires_at: Math.floor(Date.now() / 1000) + CHECKOUT_SESSION_TTL_MINUTES * 60,
success_url: `${origin}/checkout/success?order=${order.orderNumber}`,
// Der Token statt der Bestellnummer: die Bestellnummer steht in jeder
// Bestaetigungsmail und auf jeder Rechnung, mit ihr liesse sich also
// die noch offene Zahlung einer fremden Person abbrechen.
cancel_url: `${origin}/checkout/cancel?ref=${cancelToken}`,
// Jede Zahlungsart endet hier — Karte ebenso wie PayPal, das die
// Kundschaft zwangslaeufig zu PayPal und zurueck schickt. Ein einziger
// Rueckweg statt zweier (onComplete im Browser vs. Weiterleitung) heisst
// auch: nur eine Stelle entscheidet ueber "bezahlt", und die fragt
// serverseitig bei Stripe nach (siehe checkout/success). Den Platzhalter
// ersetzt Stripe selbst; die Bestellnummer steht bewusst nicht in der
// URL, sie wird erst nach der Pruefung aus der Datenbank gelesen.
return_url: `${origin}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
redirect_on_completion: "always",
metadata: { orderId: order.id, orderNumber: order.orderNumber },
});
// Ohne client_secret laesst sich das eingebettete Formular nicht
// anzeigen — dieselbe Lage wie eine gescheiterte Sitzungserstellung.
if (!session.client_secret) {
await stripe.checkout.sessions.expire(session.id).catch((expireError: unknown) => {
console.error(`Schliessen der Session ${session.id} fehlgeschlagen:`, expireError);
});
throw new Error(`Stripe-Sitzung ${session.id} ohne client_secret.`);
}
// KEIN Best-effort mehr: ohne hinterlegte Sitzungs-ID laesst sich die
// Bestellung auf keinem Weg mehr aufloesen. Der Abbruch-Klick
// (checkout/cancel) braucht sie, um bei Stripe nachzusehen, und der
@@ -569,8 +590,9 @@ export async function POST(request: Request) {
// Schlaegt das Speichern fehl, wird deshalb die gerade erzeugte Sitzung
// sofort wieder geschlossen und die Bestellung aufgeloest (aeusseres
// catch). Dass dabei eine bezahlbare Sitzung verlorengeht, ist folgenlos:
// ihre URL steht ausschliesslich in der Antwort, die wir daraufhin NICHT
// ausliefern — niemand ausser diesem Prozess hat sie je gesehen.
// ihr client_secret steht ausschliesslich in der Antwort, die wir
// daraufhin NICHT ausliefern — niemand ausser diesem Prozess hat ihn je
// gesehen.
try {
await prisma.order.update({
where: { id: order.id },
@@ -592,7 +614,24 @@ export async function POST(request: Request) {
throw error;
}
return NextResponse.json({ url: session.url });
// client_secret: oeffnet ausschliesslich das Zahlungsformular dieser einen
// Sitzung, sonst nichts. cancelToken: erlaubt "Zurueck zu deinen
// Angaben", ohne dass die Ware bis zum Sitzungsablauf blockiert bleibt.
// Beides geht nur an die Person, die gerade diese Bestellung angelegt hat.
const response = NextResponse.json(
{ clientSecret: session.client_secret, cancelToken },
{ headers: { "Cache-Control": "no-store" } }
);
// Siehe CHECKOUT_COOKIE: nur dieser Browser darf nach einem Umweg ueber
// PayPal dieselbe Zahlung erneut oeffnen oder abbrechen.
response.cookies.set(CHECKOUT_COOKIE, cancelToken, {
httpOnly: true,
secure: origin.startsWith("https://"),
sameSite: "lax",
path: CHECKOUT_COOKIE_PATH,
maxAge: CHECKOUT_COOKIE_MAX_AGE_SECONDS,
});
return response;
} catch (error: unknown) {
// Die Stripe-Fehlermeldung nur serverseitig loggen, nicht an den Client
// zurueckgeben — sie kann interne Konfigurations-/Kontodetails enthalten