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:
1 parent
6ac7cc5d1f
commit
4ee5bafe20
28 files changed
+1272
-179
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user