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

+104
View File
@@ -0,0 +1,104 @@
import { timingSafeEqual } from "node:crypto";
import { prisma } from "@/lib/prisma";
import { getStripeClient } from "@/lib/stripe";
import { rateLimit, getClientIp } from "@/lib/rate-limit";
const RETURN_LOOKUPS_PER_WINDOW = 60;
const RETURN_WINDOW_MS = 15 * 60 * 1000;
/** Stripe-Checkout-Sitzungs-IDs: "cs_test_..." bzw. "cs_live_...". */
const SESSION_ID_PATTERN = /^cs_(test|live)_[A-Za-z0-9]{1,255}$/;
export type CheckoutReturn =
/** Zahlung eingegangen (Karte, PayPal). */
| { kind: "paid"; orderNumber: string }
/** Abgeschlossen, Geld aber noch unterwegs (z.B. SEPA-Lastschrift). */
| { kind: "processing"; orderNumber: string }
/** Zahlung fehlgeschlagen oder bei PayPal abgebrochen — noch bezahlbar. */
| { kind: "open"; clientSecret: string; cancelToken: string }
/** Abgelaufen, abgebrochen, unbekannt oder nicht pruefbar. */
| { kind: "notCompleted" };
const NOT_COMPLETED: CheckoutReturn = { kind: "notCompleted" };
function tokensMatch(a: string | undefined, b: string | null): boolean {
if (!a || !b) return false;
const left = Buffer.from(a);
const right = Buffer.from(b);
return left.length === right.length && timingSafeEqual(left, right);
}
/**
* Wertet die Rueckkehr von Stripe auf /checkout/success aus.
*
* Die URL beweist nichts: jeder kann ?session_id=... von Hand aufrufen. Ob
* bezahlt wurde, entscheidet deshalb allein die Abfrage bei Stripe, und die
* Bestellnummer kommt aus der eigenen Datenbank — nie aus der Adresszeile.
* Die eigentliche Verbuchung (Rechnung, Mail, Status) bleibt beim Webhook;
* diese Seite zeigt nur an.
*
* Unbekannte, fremde oder fehlerhafte IDs landen ununterscheidbar bei
* "notCompleted", damit sich ueber diese Seite nichts ausforschen laesst.
*
* browserCancelToken ist der Inhalt des CHECKOUT_COOKIE: nur wenn er zur
* Bestellung passt, wird ein erneuter Zahlungsversuch angeboten (siehe dort).
*/
export async function resolveCheckoutReturn(
sessionId: string,
browserCancelToken?: string
): Promise<CheckoutReturn> {
if (!SESSION_ID_PATTERN.test(sessionId)) return NOT_COMPLETED;
// Jeder Aufruf kostet eine Stripe-Abfrage — die Bremse haelt Durchprobieren
// und versehentliche Neulade-Schleifen klein.
const ip = await getClientIp();
if (!rateLimit(`checkout-return:${ip}`, RETURN_LOOKUPS_PER_WINDOW, RETURN_WINDOW_MS)) {
return NOT_COMPLETED;
}
// Zuerst die eigene Datenbank: eine ID, zu der es keine Bestellung gibt,
// loest gar nicht erst eine Anfrage bei Stripe aus.
const order = await prisma.order.findFirst({
where: { stripeCheckoutSessionId: sessionId },
select: { id: true, orderNumber: true, status: true, cancelToken: true },
});
if (!order || order.status === "STORNIERT") return NOT_COMPLETED;
try {
const session = await getStripeClient().checkout.sessions.retrieve(sessionId);
// Zweite Absicherung: die Sitzung muss zu genau dieser Bestellung gehoeren.
if (session.metadata?.orderId !== order.id) {
console.error(
`[checkout-return] Sitzung ${sessionId} gehoert laut Metadaten nicht zu Bestellung ${order.orderNumber}.`
);
return NOT_COMPLETED;
}
if (session.status === "complete") {
const isPaid =
session.payment_status === "paid" || session.payment_status === "no_payment_required";
return { kind: isPaid ? "paid" : "processing", orderNumber: order.orderNumber };
}
// Nur eine noch offene Bestellung, und nur fuer den Browser, der sie
// angelegt hat, darf erneut bezahlt werden.
if (
session.status === "open" &&
order.status === "NEU" &&
session.client_secret &&
order.cancelToken &&
tokensMatch(browserCancelToken, order.cancelToken)
) {
return { kind: "open", clientSecret: session.client_secret, cancelToken: order.cancelToken };
}
return NOT_COMPLETED;
} catch (error: unknown) {
console.error(`[checkout-return] Sitzung ${sessionId} konnte nicht abgefragt werden:`, error);
// Hat der Webhook die Zahlung schon verbucht, ist das die verlaessliche
// Auskunft — dann soll ein Stripe-Aussetzer die Freude nicht verderben.
if (order.status !== "NEU") return { kind: "paid", orderNumber: order.orderNumber };
return NOT_COMPLETED;
}
}