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]>
105 lines
4.2 KiB
TypeScript
105 lines
4.2 KiB
TypeScript
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;
|
|
}
|
|
}
|