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,55 @@
|
||||
import { describe, test, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
const isTrustedOrigin = vi.fn<(request: Request) => boolean>(() => true);
|
||||
vi.mock("@/lib/csrf", () => ({ isTrustedOrigin: (request: Request) => isTrustedOrigin(request) }));
|
||||
|
||||
const releaseCheckout = vi.fn<(token: string) => Promise<void>>(async () => undefined);
|
||||
vi.mock("@/lib/checkout-release", () => ({
|
||||
CANCEL_TOKEN_PATTERN: /^[0-9a-f]{64}$/,
|
||||
releaseCheckout: (token: string) => releaseCheckout(token),
|
||||
}));
|
||||
|
||||
const { POST } = await import("./route");
|
||||
|
||||
const TOKEN = "c".repeat(64);
|
||||
|
||||
function post(body: unknown): Promise<Response> {
|
||||
return POST(
|
||||
new Request("https://wabenhain.test/api/checkout/cancel", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
isTrustedOrigin.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe("POST /api/checkout/cancel", () => {
|
||||
test("gibt die Bestellung zum Token frei", async () => {
|
||||
const response = await post({ cancelToken: TOKEN });
|
||||
expect(response.status).toBe(200);
|
||||
expect(releaseCheckout).toHaveBeenCalledWith(TOKEN);
|
||||
});
|
||||
|
||||
test("weist einen fremden Origin ab, ohne etwas freizugeben", async () => {
|
||||
isTrustedOrigin.mockReturnValue(false);
|
||||
const response = await post({ cancelToken: TOKEN });
|
||||
expect(response.status).toBe(403);
|
||||
expect(releaseCheckout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("weist die Bestellnummer als Ersatz fuer den Token ab", async () => {
|
||||
const response = await post({ cancelToken: "WH-2026-AABBCCDD" });
|
||||
expect(response.status).toBe(400);
|
||||
expect(releaseCheckout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("weist einen Body ohne Token ab", async () => {
|
||||
const response = await post(null);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isTrustedOrigin } from "@/lib/csrf";
|
||||
import { releaseCheckout, CANCEL_TOKEN_PATTERN } from "@/lib/checkout-release";
|
||||
|
||||
/**
|
||||
* Bricht eine offene, eingebettete Stripe-Zahlung ab und gibt den dafuer
|
||||
* reservierten Bestand sofort frei ("Zurueck zu deinen Angaben" im
|
||||
* Zahlungsschritt, "Zahlung abbrechen" auf der Rueckkehrseite).
|
||||
*
|
||||
* POST statt eines Links: der Aufruf aendert Daten und darf weder von einem
|
||||
* Prefetch noch von einem fremden <img src> ausgeloest werden. Der
|
||||
* Origin-Check schliesst gefaelschte Cross-Site-POSTs aus (siehe lib/csrf.ts).
|
||||
*
|
||||
* Die Antwort ist bei jedem wohlgeformten Token dieselbe — ob es ihn gab und
|
||||
* ob tatsaechlich etwas freigegeben wurde, verraet sie nicht.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
if (!isTrustedOrigin(request)) {
|
||||
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 403 });
|
||||
}
|
||||
|
||||
const body: unknown = await request.json().catch(() => null);
|
||||
const cancelToken =
|
||||
body && typeof body === "object" && "cancelToken" in body
|
||||
? (body as { cancelToken?: unknown }).cancelToken
|
||||
: undefined;
|
||||
|
||||
if (typeof cancelToken !== "string" || !CANCEL_TOKEN_PATTERN.test(cancelToken)) {
|
||||
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
|
||||
}
|
||||
|
||||
await releaseCheckout(cancelToken);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -121,7 +121,7 @@ beforeEach(() => {
|
||||
);
|
||||
sessionsCreate.mockResolvedValue({
|
||||
id: "cs_test_1",
|
||||
url: "https://checkout.stripe.com/c/pay/cs_test_1",
|
||||
client_secret: "cs_test_1_secret_abc",
|
||||
});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
@@ -243,15 +243,45 @@ describe("Checkout-Route", () => {
|
||||
const response = await post();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
url: "https://checkout.stripe.com/c/pay/cs_test_1",
|
||||
});
|
||||
const body = await response.json();
|
||||
expect(body.clientSecret).toBe("cs_test_1_secret_abc");
|
||||
// Der Abbruch-Token ist das, was "Zurueck zu deinen Angaben" braucht —
|
||||
// und derselbe, der an der Bestellung gespeichert wird.
|
||||
expect(body.cancelToken).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(db.order.create.mock.calls[0][0].data.cancelToken).toBe(body.cancelToken);
|
||||
|
||||
// Derselbe Token als HttpOnly-Cookie: bindet den erneuten Versuch nach
|
||||
// PayPal an genau diesen Browser (siehe lib/checkout-return.ts).
|
||||
const cookie = response.headers.get("set-cookie") ?? "";
|
||||
expect(cookie).toContain(`wh_checkout_ref=${body.cancelToken}`);
|
||||
expect(cookie).toMatch(/HttpOnly/i);
|
||||
expect(cookie).toMatch(/SameSite=Lax/i);
|
||||
expect(cookie).toMatch(/Secure/i);
|
||||
expect(cookie).toMatch(/Path=\/checkout/i);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(db.order.update).toHaveBeenCalledWith({
|
||||
where: { id: ORDER.id },
|
||||
data: { stripeCheckoutSessionId: "cs_test_1" },
|
||||
});
|
||||
});
|
||||
|
||||
test("bettet die Zahlung ein und schickt jede Zahlungsart auf die serverseitig pruefende Rueckkehrseite", async () => {
|
||||
await post();
|
||||
|
||||
const params = sessionsCreate.mock.calls[0][0];
|
||||
expect(params.ui_mode).toBe("embedded_page");
|
||||
expect(params.redirect_on_completion).toBe("always");
|
||||
// Feste Basis-URL statt Origin-Header, Sitzungs-ID statt Bestellnummer.
|
||||
expect(params.return_url).toBe(
|
||||
"https://wabenhain.test/checkout/success?session_id={CHECKOUT_SESSION_ID}"
|
||||
);
|
||||
// Eingebettete Sitzungen kennen diese beiden nicht — Stripe wiese sie ab.
|
||||
expect(params).not.toHaveProperty("success_url");
|
||||
expect(params).not.toHaveProperty("cancel_url");
|
||||
// Dynamische Zahlungsarten: PayPal & Co. kommen aus dem Dashboard.
|
||||
expect(params).not.toHaveProperty("payment_method_types");
|
||||
});
|
||||
|
||||
test("begrenzt die Lebensdauer der Sitzung, damit Bestand nicht tagelang blockiert bleibt", async () => {
|
||||
await post();
|
||||
|
||||
@@ -352,7 +382,7 @@ describe("Checkout-Route", () => {
|
||||
test("schliesst die Sitzung wieder, wenn sich ihre Kennung nicht speichern laesst", async () => {
|
||||
// Sonst entstuende genau der Zustand, den es nicht geben darf: eine
|
||||
// Bestellung auf "NEU" ohne Sitzungs-ID. Die Sitzung darf dabei
|
||||
// gefahrlos verfallen — ihre URL steht nur in der Antwort, die wir
|
||||
// gefahrlos verfallen — ihr client_secret steht nur in der Antwort, die wir
|
||||
// daraufhin nicht ausliefern.
|
||||
db.order.update.mockRejectedValueOnce(new Error("connection reset"));
|
||||
releaseUnpaidOrder.mockResolvedValue({
|
||||
@@ -368,5 +398,20 @@ describe("Checkout-Route", () => {
|
||||
expect(sessionsExpire).toHaveBeenCalledWith("cs_test_1");
|
||||
expect(releaseUnpaidOrder).toHaveBeenCalledWith(ORDER.id, "cancel");
|
||||
});
|
||||
|
||||
test("behandelt eine Sitzung ohne client_secret wie eine gescheiterte", async () => {
|
||||
// Ohne client_secret laesst sich das eingebettete Formular nicht
|
||||
// zeigen. Ein 200er ohne Zahlungsweg haette die Ware bis zum Ablauf
|
||||
// der Sitzung festgehalten.
|
||||
sessionsCreate.mockResolvedValue({ id: "cs_test_1", client_secret: null });
|
||||
sessionsExpire.mockResolvedValue({});
|
||||
releaseUnpaidOrder.mockResolvedValue(null);
|
||||
|
||||
const response = await post();
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
expect(sessionsExpire).toHaveBeenCalledWith("cs_test_1");
|
||||
expect(releaseUnpaidOrder).toHaveBeenCalledWith(ORDER.id, "cancel");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -1,118 +1,25 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getStripeClient } from "@/lib/stripe";
|
||||
import { releaseUnpaidOrder } from "@/lib/order-release";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export const metadata: Metadata = { title: "Bestellung abgebrochen", robots: { index: false } };
|
||||
// Diese Seite aendert Daten (siehe releaseCheckout) und darf deshalb nie aus
|
||||
// einem Cache beantwortet werden.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CANCEL_ATTEMPTS_PER_WINDOW = 20;
|
||||
const CANCEL_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Gibt den beim Checkout reservierten Lagerbestand sofort wieder frei, wenn
|
||||
* die Kundschaft die Zahlung bei Stripe abbricht.
|
||||
*
|
||||
* Ohne das bliebe die Ware bis zum Ablauf der Stripe-Sitzung blockiert. Die
|
||||
* Sitzung laeuft zwar nur noch 32 Minuten statt 24 Stunden (siehe
|
||||
* api/checkout/route.ts), aber eine halbe Stunde ist immer noch eine halbe
|
||||
* Stunde, in der das letzte Glas fuer niemanden kaufbar ist, obwohl die
|
||||
* Person gerade ausdruecklich abgebrochen hat.
|
||||
*
|
||||
* Reihenfolge ist hier entscheidend: erst bei Stripe nachsehen, dann erst
|
||||
* etwas zurueckbuchen. Wuerden wir umgekehrt vorgehen, koennte eine bereits
|
||||
* bezahlte Bestellung storniert und ihr Bestand doppelt verkauft werden —
|
||||
* etwa wenn jemand nach erfolgreicher Zahlung den Zurueck-Knopf benutzt und
|
||||
* dabei wieder auf dieser Seite landet.
|
||||
*/
|
||||
async function releaseCheckout(cancelToken: string): Promise<void> {
|
||||
// Der Token hat 32 Byte und ist praktisch nicht zu erraten; die Bremse ist
|
||||
// trotzdem sinnvoll, weil jeder Aufruf sonst eine Stripe-Abfrage ausloest.
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`checkout-cancel:${ip}`, CANCEL_ATTEMPTS_PER_WINDOW, CANCEL_WINDOW_MS)) return;
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { cancelToken },
|
||||
select: { id: true, orderNumber: true, status: true, stripeCheckoutSessionId: true },
|
||||
});
|
||||
|
||||
// Unbekannter Token oder die Bestellung ist laengst nicht mehr offen
|
||||
// (bezahlt, storniert, versendet) — nichts zu tun. Bewusst ohne
|
||||
// unterscheidbare Rueckmeldung nach aussen: die Seite sieht in jedem Fall
|
||||
// gleich aus und verraet damit nicht, ob es den Token gibt.
|
||||
if (!order || order.status !== "NEU") return;
|
||||
|
||||
if (!order.stripeCheckoutSessionId) {
|
||||
// Kann vorkommen, wenn das Speichern der Sitzungs-ID fehlgeschlagen ist
|
||||
// (best-effort, siehe api/checkout/route.ts). Ohne sie laesst sich bei
|
||||
// Stripe nicht pruefen, ob doch bezahlt wurde — also nichts zurueckbuchen
|
||||
// und den Ablauf der Sitzung abwarten, der ueber den Webhook ohnehin
|
||||
// greift.
|
||||
console.warn(
|
||||
`[checkout-cancel] Bestellung ${order.orderNumber} hat keine Stripe-Sitzung hinterlegt; Freigabe erst bei Ablauf der Sitzung.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stripe = getStripeClient();
|
||||
const session = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId);
|
||||
|
||||
// "complete" heisst bezahlt oder in Bearbeitung — dann ist dieser
|
||||
// Seitenaufruf kein Abbruch, sondern ein Zurueck-Klick nach erfolgreicher
|
||||
// Zahlung. Finger weg.
|
||||
if (session.status === "complete") return;
|
||||
|
||||
// "open" heisst: noch bezahlbar. Erst die Sitzung schliessen, damit
|
||||
// niemand die bereits freigegebene Ware im naechsten Moment doch noch
|
||||
// bezahlt, und erst danach zurueckbuchen.
|
||||
if (session.status === "open") {
|
||||
await stripe.checkout.sessions.expire(order.stripeCheckoutSessionId);
|
||||
}
|
||||
|
||||
// Uebrig bleiben "open" (gerade geschlossen) und "expired" — in beiden
|
||||
// Faellen kann nicht mehr bezahlt werden. Stripe schickt dazu ein
|
||||
// checkout.session.expired-Ereignis; der Webhook findet die Bestellung
|
||||
// dann nicht mehr und tut nichts weiter (siehe lib/order-release.ts).
|
||||
//
|
||||
// "delete": hier wurde nie bezahlt und nie etwas bestaetigt. Eine
|
||||
// stornierte Bestellung stehen zu lassen wuerde nur verwirren — im
|
||||
// Admin-Bereich saehe jeder weggeklickte Warenkorb aus wie ein echter,
|
||||
// gescheiterter Verkauf.
|
||||
const released = await releaseUnpaidOrder(order.id, "delete");
|
||||
if (released) {
|
||||
console.info(
|
||||
`[checkout-cancel] Bestellung ${released.orderNumber} abgebrochen, Bestand freigegeben und Vormerkung entfernt.`
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Nicht weiterwerfen: die Person hat gerade abgebrochen und soll die
|
||||
// Abbruchseite sehen, nicht einen Serverfehler. Der Bestand kommt dann
|
||||
// spaetestens mit dem Ablauf der Sitzung zurueck.
|
||||
console.error(
|
||||
`[checkout-cancel] Bestellung ${order.orderNumber} konnte nicht freigegeben werden:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
// Reine Anzeige, nachdem eine Zahlung per POST /api/checkout/cancel
|
||||
// abgebrochen wurde. Frueher gab ein GET mit ?ref=<Token> hier den Bestand
|
||||
// frei — ein Zustandswechsel beim blossen Aufruf, den auch Link-Vorschauen
|
||||
// und Mail-Scanner ausloesen. Alte Links fuehren deshalb nur noch hierher;
|
||||
// ihre Sitzung laeuft ohnehin nach spaetestens 32 Minuten ab und gibt den
|
||||
// Bestand dann ueber den Webhook frei.
|
||||
|
||||
export default async function CheckoutCancelPage(props: PageProps<"/checkout/cancel">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { ref } = await props.searchParams;
|
||||
|
||||
if (typeof ref === "string" && ref) {
|
||||
await releaseCheckout(ref);
|
||||
// Token aus Adresszeile, Verlauf und Referer nehmen, sobald er seinen
|
||||
// Zweck erfuellt hat. Er gibt zwar nur genau diese eine, gerade
|
||||
// abgebrochene Bestellung frei, aber ein Geheimnis gehoert nicht laenger
|
||||
// sichtbar herum, als es gebraucht wird.
|
||||
// Token aus Adresszeile, Verlauf und Referer nehmen.
|
||||
redirect("/checkout/cancel");
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,26 @@ vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: routerPush }),
|
||||
}));
|
||||
|
||||
// Stripe.js laesst sich in jsdom nicht laden — der Zahlungsschritt wird
|
||||
// durch einen Platzhalter ersetzt, der zeigt, was er bekommen hat, und den
|
||||
// Abbruch-Rueckruf ausloesen kann.
|
||||
vi.mock("./embedded-payment", () => ({
|
||||
EmbeddedPayment: (props: {
|
||||
clientSecret: string;
|
||||
cancelToken: string;
|
||||
cancelLabel: string;
|
||||
onCancelled: () => void;
|
||||
}) => (
|
||||
<div data-testid="embedded-payment" data-client-secret={props.clientSecret}>
|
||||
<button type="button" onClick={props.onCancelled}>
|
||||
{props.cancelLabel}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
const PUBLISHABLE_KEY = "pk_test_abc123";
|
||||
|
||||
const tiers: ShippingTierLike[] = [{ minQuantity: 0, maxQuantity: null, priceCents: 700 }];
|
||||
const bulkDiscountTiers: BulkDiscountTierLike[] = [];
|
||||
@@ -30,15 +49,29 @@ const item = (overrides: Partial<CartItem> = {}): CartItem => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function renderCheckout(items: CartItem[]) {
|
||||
function renderCheckout(items: CartItem[], stripePublishableKey: string | undefined = PUBLISHABLE_KEY) {
|
||||
useCartStore.setState({ items, isOpen: false });
|
||||
return render(
|
||||
<I18nProvider locale="de">
|
||||
<CheckoutForm tiers={tiers} bulkDiscountTiers={bulkDiscountTiers} />
|
||||
<CheckoutForm
|
||||
tiers={tiers}
|
||||
bulkDiscountTiers={bulkDiscountTiers}
|
||||
stripePublishableKey={stripePublishableKey}
|
||||
/>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
async function fillAndSubmit(userEventInstance: ReturnType<typeof userEvent.setup>) {
|
||||
await userEventInstance.type(screen.getByLabelText(/vorname/i), "Erika");
|
||||
await userEventInstance.type(screen.getByLabelText(/nachname/i), "Mustermann");
|
||||
await userEventInstance.type(screen.getByLabelText(/e-mail/i), "[email protected]");
|
||||
await userEventInstance.type(screen.getByLabelText(/straße|strasse/i), "Wiesenweg 1");
|
||||
await userEventInstance.type(screen.getByLabelText(/plz|postleitzahl/i), "86450");
|
||||
await userEventInstance.type(screen.getByLabelText(/ort|stadt/i), "Altenmünster");
|
||||
await userEventInstance.click(screen.getByRole("button", { name: /kostenpflichtig bestellen/i }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useCartStore.setState({ items: [], isOpen: false });
|
||||
routerPush.mockClear();
|
||||
@@ -102,4 +135,48 @@ describe("CheckoutForm", () => {
|
||||
// Erfolgsseite erneut einbaut. Genau dafuer steht sie hier.
|
||||
expect(routerPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows the embedded payment step on this page instead of redirecting to Stripe", async () => {
|
||||
const userEventInstance = userEvent.setup();
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ clientSecret: "cs_test_1_secret_abc", cancelToken: "d".repeat(64) }),
|
||||
} as Response);
|
||||
|
||||
renderCheckout([item()]);
|
||||
await fillAndSubmit(userEventInstance);
|
||||
|
||||
const payment = await screen.findByTestId("embedded-payment");
|
||||
expect(payment).toHaveAttribute("data-client-secret", "cs_test_1_secret_abc");
|
||||
// Das Formular tritt zurueck, bleibt aber erhalten.
|
||||
expect(screen.getByLabelText(/vorname/i)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("brings back the filled-in form after leaving the payment step", async () => {
|
||||
const userEventInstance = userEvent.setup();
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ clientSecret: "cs_test_1_secret_abc", cancelToken: "d".repeat(64) }),
|
||||
} as Response);
|
||||
|
||||
renderCheckout([item()]);
|
||||
await fillAndSubmit(userEventInstance);
|
||||
await userEventInstance.click(await screen.findByRole("button", { name: /zurück zu deinen angaben/i }));
|
||||
|
||||
expect(screen.queryByTestId("embedded-payment")).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/vorname/i)).toBeVisible();
|
||||
expect(screen.getByLabelText(/vorname/i)).toHaveValue("Erika");
|
||||
});
|
||||
|
||||
test("creates no order when no publishable key is configured", async () => {
|
||||
const userEventInstance = userEvent.setup();
|
||||
renderCheckout([item()], "");
|
||||
|
||||
await fillAndSubmit(userEventInstance);
|
||||
|
||||
expect(await screen.findByText(/zahlung ist gerade nicht verfügbar/i)).toBeInTheDocument();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type FormEvent, type HTMLAttributes } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { TrustBadges } from "@/components/trust-badges";
|
||||
import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store";
|
||||
@@ -12,6 +13,31 @@ import { useI18n } from "@/i18n/i18n-provider";
|
||||
import { TurnstileWidget } from "@/components/turnstile-widget";
|
||||
import { allowedCountryCodes, type ShippingRegion } from "@/lib/shipping-region";
|
||||
|
||||
// Erst nachladen, wenn wirklich bezahlt wird: Stripe.js und das React-Paket
|
||||
// gehoeren nicht in den ersten Aufbau der Kasse, und serverseitig gibt es
|
||||
// ohnehin nichts zu zeichnen (das Formular ist ein iframe).
|
||||
const EmbeddedPayment = dynamic(
|
||||
() => import("./embedded-payment").then((module) => module.EmbeddedPayment),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
/** Was /api/checkout fuer den Zahlungsschritt zurueckgibt. */
|
||||
interface PaymentSession {
|
||||
clientSecret: string;
|
||||
cancelToken: string;
|
||||
}
|
||||
|
||||
function isPaymentSession(value: unknown): value is PaymentSession {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof candidate.clientSecret === "string" &&
|
||||
candidate.clientSecret.length > 0 &&
|
||||
typeof candidate.cancelToken === "string" &&
|
||||
candidate.cancelToken.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
type Fulfillment = "VERSAND" | "ABHOLUNG";
|
||||
|
||||
/**
|
||||
@@ -62,6 +88,7 @@ export function CheckoutForm({
|
||||
packstationSearchAvailable = false,
|
||||
turnstileSiteKey,
|
||||
shippingRegion = "DE",
|
||||
stripePublishableKey,
|
||||
}: {
|
||||
initialValues?: CheckoutInitialValues;
|
||||
tiers: ShippingTierLike[];
|
||||
@@ -81,6 +108,12 @@ export function CheckoutForm({
|
||||
* Laender anbieten, als der Shop beliefert.
|
||||
*/
|
||||
shippingRegion?: ShippingRegion;
|
||||
/**
|
||||
* Oeffentlicher Stripe-Schluessel (pk_...) fuer das eingebettete
|
||||
* Zahlungsformular. Fehlt er, wird gar nicht erst eine Bestellung
|
||||
* angelegt — sie liesse sich auf dieser Seite nicht bezahlen.
|
||||
*/
|
||||
stripePublishableKey?: string;
|
||||
}) {
|
||||
const { t, locale } = useI18n();
|
||||
const items = useCartStore((s) => s.items);
|
||||
@@ -98,6 +131,13 @@ export function CheckoutForm({
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isGift, setIsGift] = useState(false);
|
||||
// Gesetzt, sobald die Bestellung angelegt ist: dann ersetzt der
|
||||
// Zahlungsschritt das Formular. Das Formular bleibt dabei nur versteckt,
|
||||
// damit "Zurueck zu deinen Angaben" alle Eingaben unveraendert vorfindet.
|
||||
const [payment, setPayment] = useState<PaymentSession | null>(null);
|
||||
// Ein Turnstile-Token gilt genau einmal und ist mit dem ersten Versuch
|
||||
// verbraucht. Nach "Zurueck" braucht es deshalb ein frisches Widget.
|
||||
const [turnstileKey, setTurnstileKey] = useState(0);
|
||||
|
||||
const [deliveryTarget, setDeliveryTarget] = useState<DeliveryTarget>("ADRESSE");
|
||||
const [packstationZip, setPackstationZip] = useState("");
|
||||
@@ -161,6 +201,11 @@ export function CheckoutForm({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stripePublishableKey) {
|
||||
setError(t.checkoutPage.paymentUnavailable);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
@@ -217,13 +262,26 @@ export function CheckoutForm({
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = data.url;
|
||||
if (!isPaymentSession(data)) {
|
||||
setError(t.checkoutPage.genericError);
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setPayment({ clientSecret: data.clientSecret, cancelToken: data.cancelToken });
|
||||
setSubmitting(false);
|
||||
} catch {
|
||||
setError(t.checkoutPage.genericError);
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaymentCancelled() {
|
||||
setPayment(null);
|
||||
setTurnstileToken(null);
|
||||
setTurnstileKey((value) => value + 1);
|
||||
}
|
||||
|
||||
// Der Cart-Store haelt seine Positionen nur im Browser (persist/localStorage)
|
||||
// und liest sie erst nach dem ersten Client-Render nach. Ohne diese Weiche
|
||||
// wuerde jede:r Kund:in mit gefuelltem Warenkorb kurz die "leer"-Meldung
|
||||
@@ -246,7 +304,21 @@ export function CheckoutForm({
|
||||
return (
|
||||
<div className="py-16">
|
||||
<Container className="grid lg:grid-cols-3 gap-14">
|
||||
<form onSubmit={handleSubmit} className="lg:col-span-2 space-y-8">
|
||||
{payment && stripePublishableKey ? (
|
||||
<div className="lg:col-span-3">
|
||||
<EmbeddedPayment
|
||||
publishableKey={stripePublishableKey}
|
||||
clientSecret={payment.clientSecret}
|
||||
cancelToken={payment.cancelToken}
|
||||
cancelLabel={t.checkoutPage.paymentBack}
|
||||
onCancelled={handlePaymentCancelled}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Stripe zeigt im Zahlungsschritt selbst Positionen und Summe —
|
||||
Formular und eigene Zusammenfassung treten dann zurueck. */}
|
||||
<form onSubmit={handleSubmit} hidden={payment !== null} className="lg:col-span-2 space-y-8">
|
||||
<SectionHeading eyebrow={t.checkoutPage.eyebrow} title={t.checkoutPage.yourDetails} />
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
@@ -520,7 +592,7 @@ export function CheckoutForm({
|
||||
|
||||
{turnstileSiteKey ? (
|
||||
<div className="mb-4">
|
||||
<TurnstileWidget siteKey={turnstileSiteKey} onVerify={setTurnstileToken} />
|
||||
<TurnstileWidget key={turnstileKey} siteKey={turnstileSiteKey} onVerify={setTurnstileToken} />
|
||||
</div>
|
||||
) : null}
|
||||
<Button type="submit" disabled={submitting} className="w-full sm:w-auto">
|
||||
@@ -531,7 +603,7 @@ export function CheckoutForm({
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<aside className="border border-ink/10 p-8 h-fit lg:sticky lg:top-24">
|
||||
<aside hidden={payment !== null} className="border border-ink/10 p-8 h-fit lg:sticky lg:top-24">
|
||||
<h2 className="text-lg text-forest mb-6">{t.checkoutPage.yourOrderHeading}</h2>
|
||||
<ul className="space-y-3 text-sm">
|
||||
{items.map((item) => (
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { loadStripe } from "@stripe/stripe-js/pure";
|
||||
import type { Stripe } from "@stripe/stripe-js";
|
||||
import { EmbeddedCheckout, EmbeddedCheckoutProvider } from "@stripe/react-stripe-js";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
|
||||
/**
|
||||
* Stripe.js genau einmal je Schluessel laden. "pure" statt des Standard-
|
||||
* Einstiegs: der haengt das Skript bereits beim blossen Import ein, also auf
|
||||
* jeder Seite, die dieses Modul irgendwann mitbringt. So kommt Stripe erst
|
||||
* ins Spiel, wenn wirklich bezahlt wird.
|
||||
*/
|
||||
const stripeByKey = new Map<string, Promise<Stripe | null>>();
|
||||
|
||||
function getStripe(publishableKey: string): Promise<Stripe | null> {
|
||||
const cached = stripeByKey.get(publishableKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const pending = loadStripe(publishableKey);
|
||||
stripeByKey.set(publishableKey, pending);
|
||||
// Nach einem Fehlschlag (Netz, Werbeblocker) darf "Erneut versuchen" nicht
|
||||
// auf demselben abgelehnten Versprechen haengen bleiben.
|
||||
pending.catch(() => stripeByKey.delete(publishableKey));
|
||||
return pending;
|
||||
}
|
||||
|
||||
type LoadState = { status: "loading" } | { status: "ready"; stripe: Stripe } | { status: "failed" };
|
||||
|
||||
export interface EmbeddedPaymentProps {
|
||||
publishableKey: string;
|
||||
clientSecret: string;
|
||||
/** Abbruch-Token der Bestellung (siehe api/checkout/cancel). */
|
||||
cancelToken: string;
|
||||
/** Beschriftung des Abbruch-Knopfs — je nach Ort "Zurueck" oder "Abbrechen". */
|
||||
cancelLabel: string;
|
||||
/** Wird erst aufgerufen, nachdem der Server den Bestand freigegeben hat. */
|
||||
onCancelled: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Stripe-Zahlungsformular als iframe auf der eigenen Seite.
|
||||
*
|
||||
* Kartendaten, PayPal-Anmeldung und 3-D-Secure laufen vollstaendig innerhalb
|
||||
* von Stripes iframe bzw. bei PayPal — dieser Code sieht davon nichts. Ueber
|
||||
* "bezahlt" entscheidet er ebenfalls nicht: Stripe leitet nach jeder Zahlung
|
||||
* auf /checkout/success weiter, und erst dort wird serverseitig nachgefragt.
|
||||
*/
|
||||
export function EmbeddedPayment({
|
||||
publishableKey,
|
||||
clientSecret,
|
||||
cancelToken,
|
||||
cancelLabel,
|
||||
onCancelled,
|
||||
}: EmbeddedPaymentProps) {
|
||||
const { t } = useI18n();
|
||||
const [load, setLoad] = useState<LoadState>({ status: "loading" });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [cancelError, setCancelError] = useState(false);
|
||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||
|
||||
// Fokus auf die neue Ueberschrift: der Schritt ersetzt das Formular, auf
|
||||
// dem der Fokus eben noch lag. Ohne das stuende er fuer Screenreader im
|
||||
// Nichts, und auf dem Handy bliebe die Seite unten am alten Knopf stehen.
|
||||
useEffect(() => {
|
||||
headingRef.current?.focus();
|
||||
headingRef.current?.scrollIntoView?.({ block: "start" });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getStripe(publishableKey)
|
||||
.then((stripe) => {
|
||||
if (cancelled) return;
|
||||
setLoad(stripe ? { status: "ready", stripe } : { status: "failed" });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
console.error("[checkout] Stripe.js konnte nicht geladen werden:", error);
|
||||
setLoad({ status: "failed" });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [publishableKey, attempt]);
|
||||
|
||||
async function handleCancel() {
|
||||
setCancelling(true);
|
||||
setCancelError(false);
|
||||
try {
|
||||
const response = await fetch("/api/checkout/cancel", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ cancelToken }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
onCancelled();
|
||||
} catch (error: unknown) {
|
||||
// Stehen bleiben statt trotzdem weiterzuschalten: sonst legte der
|
||||
// naechste Versuch eine zweite Bestellung an, waehrend die erste ihren
|
||||
// Bestand noch bis zum Sitzungsablauf festhaelt.
|
||||
console.error("[checkout] Abbruch fehlgeschlagen:", error);
|
||||
setCancelError(true);
|
||||
setCancelling(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby="payment-heading" className="space-y-6">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-4">
|
||||
<h2
|
||||
id="payment-heading"
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
className="scroll-mt-24 text-2xl text-forest outline-none"
|
||||
>
|
||||
{t.checkoutPage.paymentHeading}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={cancelling}
|
||||
className="text-sm text-ink/70 underline disabled:opacity-50"
|
||||
>
|
||||
{cancelling ? t.checkoutPage.paymentCancelling : cancelLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{cancelError ? (
|
||||
<p role="alert" className="text-sm text-red-700">
|
||||
{t.checkoutPage.paymentCancelError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Mindesthoehe haelt den Platz frei, bis Stripe das iframe gezeichnet
|
||||
hat — sonst springt alles darunter beim Laden nach oben. */}
|
||||
<div className="min-h-[36rem]">
|
||||
{load.status === "loading" ? (
|
||||
<p className="text-sm text-ink/70" aria-live="polite">
|
||||
{t.checkoutPage.paymentLoading}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{load.status === "failed" ? (
|
||||
<p role="alert" className="text-sm text-red-700">
|
||||
{t.checkoutPage.paymentLoadError}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLoad({ status: "loading" });
|
||||
setAttempt((value) => value + 1);
|
||||
}}
|
||||
className="underline"
|
||||
>
|
||||
{t.common.turnstile.retry}
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{load.status === "ready" ? (
|
||||
<EmbeddedCheckoutProvider stripe={load.stripe} options={{ clientSecret }}>
|
||||
<EmbeddedCheckout id="stripe-embedded-checkout" />
|
||||
</EmbeddedCheckoutProvider>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { CheckoutForm, type CheckoutInitialValues } from "./checkout-form";
|
||||
import { isPackstationSearchConfigured } from "@/lib/dhl-locations";
|
||||
import { getStripePublishableKey } from "@/lib/stripe";
|
||||
|
||||
// Vorausfuellen aus Kundendaten aendert sich pro Nutzer/Anmeldestatus, und
|
||||
// Versandstaffeln koennen sich im Admin-Bereich jederzeit aendern —
|
||||
@@ -41,6 +42,7 @@ export default async function CheckoutPage() {
|
||||
bulkDiscountTiers={bulkDiscountTiers}
|
||||
packstationSearchAvailable={isPackstationSearchConfigured()}
|
||||
shippingRegion={shippingRegion}
|
||||
stripePublishableKey={getStripePublishableKey()}
|
||||
/>;
|
||||
|
||||
// getAddressesForCustomer sortiert Standardadresse (isDefault) zuerst, sonst
|
||||
@@ -66,5 +68,6 @@ export default async function CheckoutPage() {
|
||||
bulkDiscountTiers={bulkDiscountTiers}
|
||||
packstationSearchAvailable={isPackstationSearchConfigured()}
|
||||
shippingRegion={shippingRegion}
|
||||
stripePublishableKey={getStripePublishableKey()}
|
||||
/>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useCartStore } from "@/store/cart-store";
|
||||
|
||||
/**
|
||||
* Leert den Warenkorb, sobald die Rueckkehrseite eine abgeschlossene Zahlung
|
||||
* bestaetigt hat. Eigene Komponente, weil die Seite selbst serverseitig
|
||||
* rendert (sie fragt dort bei Stripe nach), der Warenkorb aber nur im
|
||||
* Browser liegt.
|
||||
*/
|
||||
export function ClearCartOnMount() {
|
||||
const clear = useCartStore((s) => s.clear);
|
||||
|
||||
useEffect(() => {
|
||||
clear();
|
||||
}, [clear]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,47 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { use } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||
import { useCartStore } from "@/store/cart-store";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
import { CHECKOUT_COOKIE } from "@/lib/checkout-release";
|
||||
import { resolveCheckoutReturn, type CheckoutReturn } from "@/lib/checkout-return";
|
||||
import { getStripePublishableKey } from "@/lib/stripe";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { ClearCartOnMount } from "./clear-cart-on-mount";
|
||||
import { PaymentRetry } from "./payment-retry";
|
||||
|
||||
export default function CheckoutSuccessPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ order?: string; pending?: string }>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { order, pending } = use(searchParams);
|
||||
const clear = useCartStore((s) => s.clear);
|
||||
// Liest bei jedem Aufruf den aktuellen Zahlungsstand bei Stripe — nie aus
|
||||
// einem Cache.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
useEffect(() => {
|
||||
clear();
|
||||
// Warenkorb wird nur einmal nach erfolgreichem Checkout geleert.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
/**
|
||||
* Rueckkehrseite des eingebetteten Checkouts. Stripe leitet nach jedem
|
||||
* Zahlungsversuch hierher (return_url in api/checkout/route.ts), bei PayPal
|
||||
* nach dem Umweg ueber paypal.com.
|
||||
*/
|
||||
export default async function CheckoutSuccessPage(props: PageProps<"/checkout/success">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { session_id: sessionId, order: legacyOrder } = await props.searchParams;
|
||||
|
||||
let result: CheckoutReturn;
|
||||
if (typeof sessionId === "string" && sessionId) {
|
||||
const browserCancelToken = (await cookies()).get(CHECKOUT_COOKIE)?.value;
|
||||
result = await resolveCheckoutReturn(sessionId, browserCancelToken);
|
||||
} else if (typeof legacyOrder === "string" && legacyOrder) {
|
||||
// Sitzungen, die noch vor der Umstellung auf der gehosteten Stripe-Seite
|
||||
// geoeffnet wurden, kehren mit ?order= zurueck. Pruefen laesst sich das
|
||||
// nicht; die neutrale "in Bearbeitung"-Ansicht verspricht nichts, was
|
||||
// nicht die Bestaetigungsmail ohnehin haelt. Die Nummer aus der URL wird
|
||||
// bewusst nicht angezeigt.
|
||||
result = { kind: "processing", orderNumber: "" };
|
||||
} else {
|
||||
result = { kind: "notCompleted" };
|
||||
}
|
||||
|
||||
if (result.kind === "open") {
|
||||
const publishableKey = getStripePublishableKey();
|
||||
return (
|
||||
<div className="py-16">
|
||||
<Container className="max-w-3xl">
|
||||
<SectionHeading eyebrow={t.checkoutSuccessPage.openEyebrow} title={t.checkoutSuccessPage.openTitle} />
|
||||
<p role="status" className="mt-4 mb-10 text-ink/70">
|
||||
{t.checkoutSuccessPage.openText}
|
||||
</p>
|
||||
{publishableKey ? (
|
||||
<PaymentRetry
|
||||
publishableKey={publishableKey}
|
||||
clientSecret={result.clientSecret}
|
||||
cancelToken={result.cancelToken}
|
||||
/>
|
||||
) : (
|
||||
<LinkButton href="/warenkorb">{t.checkoutSuccessPage.backToCart}</LinkButton>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (result.kind === "notCompleted") {
|
||||
return (
|
||||
<div className="py-24">
|
||||
<Container className="max-w-xl text-center">
|
||||
<SectionHeading
|
||||
eyebrow={t.checkoutSuccessPage.notCompletedEyebrow}
|
||||
title={t.checkoutSuccessPage.notCompletedTitle}
|
||||
align="center"
|
||||
className="mx-auto"
|
||||
/>
|
||||
<p className="mt-6 text-ink/70">{t.checkoutSuccessPage.notCompletedText}</p>
|
||||
<LinkButton href="/warenkorb" className="mt-10">
|
||||
{t.checkoutSuccessPage.backToCart}
|
||||
</LinkButton>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isPaid = result.kind === "paid";
|
||||
const { orderNumber } = result;
|
||||
|
||||
return (
|
||||
<div className="py-24">
|
||||
{/* Erst hier, nach der Pruefung bei Stripe: ein fehlgeschlagener oder
|
||||
abgebrochener Versuch laesst den Warenkorb unangetastet. */}
|
||||
<ClearCartOnMount />
|
||||
<Container className="max-w-xl text-center">
|
||||
<SectionHeading
|
||||
eyebrow={pending ? t.checkoutSuccessPage.pendingEyebrow : t.checkoutSuccessPage.successEyebrow}
|
||||
title={pending ? t.checkoutSuccessPage.pendingTitle : t.checkoutSuccessPage.successTitle}
|
||||
eyebrow={isPaid ? t.checkoutSuccessPage.successEyebrow : t.checkoutSuccessPage.pendingEyebrow}
|
||||
title={isPaid ? t.checkoutSuccessPage.successTitle : t.checkoutSuccessPage.pendingTitle}
|
||||
align="center"
|
||||
className="mx-auto"
|
||||
/>
|
||||
{order ? (
|
||||
{orderNumber ? (
|
||||
<p className="mt-4 text-ink/70">
|
||||
{t.checkoutSuccessPage.orderNumberLabel}: <span className="text-forest">{order}</span>
|
||||
{t.checkoutSuccessPage.orderNumberLabel}: <span className="text-forest">{orderNumber}</span>
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-6 text-ink/70">
|
||||
{pending ? t.checkoutSuccessPage.pendingText : t.checkoutSuccessPage.successText}
|
||||
{isPaid ? t.checkoutSuccessPage.successText : t.checkoutSuccessPage.pendingText}
|
||||
</p>
|
||||
<div className="mt-10 flex items-center justify-center gap-4">
|
||||
<LinkButton href="/shop">{t.checkoutSuccessPage.continueShopping}</LinkButton>
|
||||
{order ? (
|
||||
<LinkButton href={`/bestellung-verfolgen?order=${encodeURIComponent(order)}`} variant="secondary">
|
||||
{orderNumber ? (
|
||||
<LinkButton href={`/bestellung-verfolgen?order=${encodeURIComponent(orderNumber)}`} variant="secondary">
|
||||
{t.checkoutSuccessPage.trackOrder}
|
||||
</LinkButton>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
|
||||
const EmbeddedPayment = dynamic(
|
||||
() => import("../embedded-payment").then((module) => module.EmbeddedPayment),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
interface PaymentRetryProps {
|
||||
publishableKey: string;
|
||||
clientSecret: string;
|
||||
cancelToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zweiter Versuch nach einer fehlgeschlagenen oder bei PayPal abgebrochenen
|
||||
* Zahlung: dieselbe, noch offene Sitzung erneut einbetten, statt die
|
||||
* Kundschaft alles noch einmal eintippen zu lassen. Wer stattdessen
|
||||
* abbricht, bekommt den Bestand sofort freigegeben und landet auf der
|
||||
* Abbruchseite.
|
||||
*/
|
||||
export function PaymentRetry({ publishableKey, clientSecret, cancelToken }: PaymentRetryProps) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<EmbeddedPayment
|
||||
publishableKey={publishableKey}
|
||||
clientSecret={clientSecret}
|
||||
cancelToken={cancelToken}
|
||||
cancelLabel={t.checkoutSuccessPage.cancelPayment}
|
||||
onCancelled={() => router.replace("/checkout/cancel")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1415,15 +1415,23 @@ export const de: Dictionary = {
|
||||
save: "Passwort speichern",
|
||||
},
|
||||
checkoutSuccessPage: {
|
||||
pendingEyebrow: "Bestellung vorgemerkt",
|
||||
pendingEyebrow: "Zahlung in Bearbeitung",
|
||||
successEyebrow: "Vielen Dank",
|
||||
pendingTitle: "Deine Bestellung ist eingegangen.",
|
||||
successTitle: "Deine Bestellung wurde bestätigt.",
|
||||
orderNumberLabel: "Bestellnummer",
|
||||
pendingText: "Die Zahlungsabwicklung war kurzfristig nicht erreichbar. Wir melden uns per E-Mail bei dir, um die Zahlung abzuschließen.",
|
||||
pendingText: "Deine Zahlung wird noch bestätigt (bei SEPA-Lastschrift kann das einige Werktage dauern). Sobald sie eingegangen ist, bekommst du eine Bestätigung per E-Mail.",
|
||||
successText: "Wir bereiten dein Glas Wabenhain-Honig mit Sorgfalt vor. Sobald deine Zahlung eingegangen ist, senden wir dir eine Bestätigung per E-Mail (bei SEPA-Lastschrift kann das einige Werktage dauern).",
|
||||
continueShopping: "Weiter stöbern",
|
||||
trackOrder: "Bestellung verfolgen",
|
||||
openEyebrow: "Zahlung nicht abgeschlossen",
|
||||
openTitle: "Das hat leider nicht geklappt.",
|
||||
openText: "Die Zahlung wurde abgebrochen oder abgelehnt, es wurde nichts abgebucht. Deine Bestellung ist noch für dich reserviert — versuche es einfach erneut, gern auch mit einer anderen Zahlungsart.",
|
||||
cancelPayment: "Bestellung abbrechen",
|
||||
notCompletedEyebrow: "Nicht abgeschlossen",
|
||||
notCompletedTitle: "Diese Bestellung wurde nicht bezahlt.",
|
||||
notCompletedText: "Die Zahlung ist abgelaufen oder wurde abgebrochen, es wurde nichts abgebucht. Dein Warenkorb ist noch gefüllt, du kannst jederzeit neu bestellen.",
|
||||
backToCart: "Zurück zum Warenkorb",
|
||||
},
|
||||
checkoutCancelPage: {
|
||||
eyebrow: "Abgebrochen",
|
||||
@@ -1433,6 +1441,13 @@ export const de: Dictionary = {
|
||||
backToCart: "Zurück zum Warenkorb",
|
||||
},
|
||||
checkoutPage: {
|
||||
paymentHeading: "Zahlung",
|
||||
paymentBack: "Zurück zu deinen Angaben",
|
||||
paymentCancelling: "Einen Moment…",
|
||||
paymentCancelError: "Das hat gerade nicht geklappt. Bitte versuche es noch einmal.",
|
||||
paymentLoading: "Sichere Zahlung wird geladen…",
|
||||
paymentLoadError: "Das Zahlungsformular konnte nicht geladen werden. Ein Werbeblocker kann das verhindern.",
|
||||
paymentUnavailable: "Die Zahlung ist gerade nicht verfügbar. Bitte versuche es später erneut oder melde dich bei uns.",
|
||||
eyebrow: "Checkout",
|
||||
emptyCart: "Dein Warenkorb ist leer",
|
||||
yourDetails: "Deine Daten",
|
||||
@@ -1474,7 +1489,7 @@ export const de: Dictionary = {
|
||||
submitting: "Wird verarbeitet…",
|
||||
placeOrder: "Kostenpflichtig bestellen",
|
||||
paymentMethodsNote:
|
||||
"Zahlungsmethoden: PayPal, Kreditkarte, Apple Pay, Google Pay, SEPA-Lastschrift, Klarna, bereitgestellt über Stripe.",
|
||||
"Im nächsten Schritt bezahlst du sicher direkt hier auf der Seite: PayPal, Kreditkarte, SEPA-Lastschrift oder Klarna, abgewickelt über Stripe.",
|
||||
yourOrderHeading: "Deine Bestellung",
|
||||
shippingWithCount: (count) => `Versand (${count} Stück)`,
|
||||
shippingPickup: "Versand (Abholung)",
|
||||
|
||||
@@ -1401,15 +1401,23 @@ export const en: Dictionary = {
|
||||
save: "Save password",
|
||||
},
|
||||
checkoutSuccessPage: {
|
||||
pendingEyebrow: "Order Received",
|
||||
pendingEyebrow: "Payment Processing",
|
||||
successEyebrow: "Thank You",
|
||||
pendingTitle: "Your order has been received.",
|
||||
successTitle: "Your order has been confirmed.",
|
||||
orderNumberLabel: "Order number",
|
||||
pendingText: "Payment processing was briefly unreachable. We'll email you to complete the payment.",
|
||||
pendingText: "Your payment is still being confirmed (this can take a few business days for SEPA direct debit). We'll email you as soon as it arrives.",
|
||||
successText: "We're carefully preparing your jar of Wabenhain honey. As soon as your payment arrives, we'll send you a confirmation by email (this can take a few business days for SEPA direct debit).",
|
||||
continueShopping: "Keep browsing",
|
||||
trackOrder: "Track order",
|
||||
openEyebrow: "Payment Not Completed",
|
||||
openTitle: "That didn't work, unfortunately.",
|
||||
openText: "The payment was cancelled or declined and nothing was charged. Your order is still reserved for you — just try again, with another payment method if you like.",
|
||||
cancelPayment: "Cancel order",
|
||||
notCompletedEyebrow: "Not Completed",
|
||||
notCompletedTitle: "This order was not paid.",
|
||||
notCompletedText: "The payment expired or was cancelled and nothing was charged. Your cart is still filled, so you can order again at any time.",
|
||||
backToCart: "Back to cart",
|
||||
},
|
||||
checkoutCancelPage: {
|
||||
eyebrow: "Cancelled",
|
||||
@@ -1419,6 +1427,13 @@ export const en: Dictionary = {
|
||||
backToCart: "Back to cart",
|
||||
},
|
||||
checkoutPage: {
|
||||
paymentHeading: "Payment",
|
||||
paymentBack: "Back to your details",
|
||||
paymentCancelling: "One moment…",
|
||||
paymentCancelError: "That didn't work just now. Please try again.",
|
||||
paymentLoading: "Loading secure payment…",
|
||||
paymentLoadError: "The payment form could not be loaded. An ad blocker can prevent this.",
|
||||
paymentUnavailable: "Payment is currently unavailable. Please try again later or get in touch with us.",
|
||||
eyebrow: "Checkout",
|
||||
emptyCart: "Your cart is empty",
|
||||
yourDetails: "Your Details",
|
||||
@@ -1460,7 +1475,7 @@ export const en: Dictionary = {
|
||||
submitting: "Processing…",
|
||||
placeOrder: "Place order",
|
||||
paymentMethodsNote:
|
||||
"Payment methods: PayPal, credit card, Apple Pay, Google Pay, SEPA direct debit, Klarna, provided via Stripe.",
|
||||
"In the next step you pay securely right here on this page: PayPal, credit card, SEPA direct debit or Klarna, processed by Stripe.",
|
||||
yourOrderHeading: "Your Order",
|
||||
shippingWithCount: (count) => `Shipping (${count} item${count === 1 ? "" : "s"})`,
|
||||
shippingPickup: "Shipping (pickup)",
|
||||
|
||||
@@ -1073,6 +1073,14 @@ export interface Dictionary {
|
||||
successText: string;
|
||||
continueShopping: string;
|
||||
trackOrder: string;
|
||||
openEyebrow: string;
|
||||
openTitle: string;
|
||||
openText: string;
|
||||
cancelPayment: string;
|
||||
notCompletedEyebrow: string;
|
||||
notCompletedTitle: string;
|
||||
notCompletedText: string;
|
||||
backToCart: string;
|
||||
};
|
||||
checkoutCancelPage: {
|
||||
eyebrow: string;
|
||||
@@ -1082,6 +1090,13 @@ export interface Dictionary {
|
||||
backToCart: string;
|
||||
};
|
||||
checkoutPage: {
|
||||
paymentHeading: string;
|
||||
paymentBack: string;
|
||||
paymentCancelling: string;
|
||||
paymentCancelError: string;
|
||||
paymentLoading: string;
|
||||
paymentLoadError: string;
|
||||
paymentUnavailable: string;
|
||||
eyebrow: string;
|
||||
emptyCart: string;
|
||||
yourDetails: string;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Tests fuer den Abbruch einer offenen Zahlung. Kernpunkt ist die
|
||||
* Reihenfolge: erst bei Stripe nachsehen, dann zurueckbuchen — sonst liesse
|
||||
* sich eine bereits bezahlte Bestellung stornieren und doppelt verkaufen.
|
||||
*/
|
||||
import { describe, test, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
const sessionsRetrieve = vi.fn();
|
||||
const sessionsExpire = vi.fn();
|
||||
vi.mock("@/lib/stripe", () => ({
|
||||
getStripeClient: () => ({
|
||||
checkout: { sessions: { retrieve: sessionsRetrieve, expire: sessionsExpire } },
|
||||
}),
|
||||
}));
|
||||
|
||||
const rateLimit = vi.fn<(key: string, limit: number, windowMs: number) => boolean>(() => true);
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
rateLimit: (key: string, limit: number, windowMs: number) => rateLimit(key, limit, windowMs),
|
||||
getClientIp: vi.fn(async () => "203.0.113.7"),
|
||||
}));
|
||||
|
||||
const releaseUnpaidOrder = vi.fn();
|
||||
vi.mock("@/lib/order-release", () => ({
|
||||
releaseUnpaidOrder: (...args: unknown[]) => releaseUnpaidOrder(...args),
|
||||
}));
|
||||
|
||||
const db = { order: { findUnique: vi.fn() } };
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: db }));
|
||||
|
||||
const { releaseCheckout } = await import("./checkout-release");
|
||||
|
||||
const TOKEN = "b".repeat(64);
|
||||
const ORDER = {
|
||||
id: "order_1",
|
||||
orderNumber: "WH-2026-AABBCCDD",
|
||||
status: "NEU",
|
||||
stripeCheckoutSessionId: "cs_test_1",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
rateLimit.mockReturnValue(true);
|
||||
db.order.findUnique.mockResolvedValue(ORDER);
|
||||
sessionsRetrieve.mockResolvedValue({ status: "open" });
|
||||
sessionsExpire.mockResolvedValue({});
|
||||
releaseUnpaidOrder.mockResolvedValue({ orderNumber: ORDER.orderNumber });
|
||||
vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe("releaseCheckout", () => {
|
||||
test("schliesst eine offene Sitzung erst und gibt danach den Bestand frei", async () => {
|
||||
await releaseCheckout(TOKEN);
|
||||
|
||||
expect(sessionsExpire).toHaveBeenCalledWith("cs_test_1");
|
||||
expect(releaseUnpaidOrder).toHaveBeenCalledWith(ORDER.id, "delete");
|
||||
expect(sessionsExpire.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
releaseUnpaidOrder.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
test("laesst eine bei Stripe bereits bezahlte Bestellung unangetastet", async () => {
|
||||
sessionsRetrieve.mockResolvedValue({ status: "complete" });
|
||||
await releaseCheckout(TOKEN);
|
||||
expect(sessionsExpire).not.toHaveBeenCalled();
|
||||
expect(releaseUnpaidOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("gibt nichts frei, wenn das Schliessen bei Stripe scheitert", async () => {
|
||||
sessionsExpire.mockRejectedValue(new Error("stripe down"));
|
||||
await expect(releaseCheckout(TOKEN)).resolves.toBeUndefined();
|
||||
expect(releaseUnpaidOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("ignoriert Tokens im falschen Format, ohne die Datenbank zu fragen", async () => {
|
||||
await releaseCheckout("WH-2026-AABBCCDD");
|
||||
expect(db.order.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("tut nichts bei einer nicht mehr offenen Bestellung", async () => {
|
||||
db.order.findUnique.mockResolvedValue({ ...ORDER, status: "BEZAHLT" });
|
||||
await releaseCheckout(TOKEN);
|
||||
expect(sessionsRetrieve).not.toHaveBeenCalled();
|
||||
expect(releaseUnpaidOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("bremst viele Abbrueche derselben Adresse", async () => {
|
||||
rateLimit.mockReturnValue(false);
|
||||
await releaseCheckout(TOKEN);
|
||||
expect(db.order.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getStripeClient } from "@/lib/stripe";
|
||||
import { releaseUnpaidOrder } from "@/lib/order-release";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
|
||||
const CANCEL_ATTEMPTS_PER_WINDOW = 20;
|
||||
const CANCEL_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
/** Format des Abbruch-Tokens aus api/checkout/route.ts: 32 Byte als Hex. */
|
||||
export const CANCEL_TOKEN_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
/**
|
||||
* HttpOnly-Cookie mit dem Abbruch-Token der zuletzt angelegten Bestellung.
|
||||
*
|
||||
* Bindet den "erneut bezahlen"-Weg auf der Rueckkehrseite an den Browser, der
|
||||
* die Bestellung angelegt hat. Die session_id steht in der Adresszeile und
|
||||
* damit in Verlauf, Proxy-Logs und Screenshots — allein darf sie weder das
|
||||
* Zahlungsformular einer fremden Bestellung oeffnen noch sie abbrechen.
|
||||
*
|
||||
* SameSite=Lax statt Strict: nach PayPal kommt die Kundschaft per
|
||||
* Top-Level-Navigation von paypal.com bzw. stripe.com zurueck, und nur Lax
|
||||
* schickt das Cookie dabei mit.
|
||||
*/
|
||||
export const CHECKOUT_COOKIE = "wh_checkout_ref";
|
||||
export const CHECKOUT_COOKIE_PATH = "/checkout";
|
||||
/** Etwas laenger als die Sitzung (32 Minuten), nie laenger als noetig. */
|
||||
export const CHECKOUT_COOKIE_MAX_AGE_SECONDS = 40 * 60;
|
||||
|
||||
/**
|
||||
* Gibt den beim Checkout reservierten Lagerbestand sofort wieder frei, wenn
|
||||
* die Kundschaft die Zahlung abbricht — ueber "Zurueck zu deinen Angaben" im
|
||||
* eingebetteten Zahlungsschritt, ueber die Rueckkehrseite nach einer nicht
|
||||
* abgeschlossenen Zahlung oder ueber einen alten Abbruch-Link.
|
||||
*
|
||||
* Ohne das bliebe die Ware bis zum Ablauf der Stripe-Sitzung blockiert. Die
|
||||
* Sitzung laeuft zwar nur noch 32 Minuten statt 24 Stunden (siehe
|
||||
* api/checkout/route.ts), aber eine halbe Stunde ist immer noch eine halbe
|
||||
* Stunde, in der das letzte Glas fuer niemanden kaufbar ist, obwohl die
|
||||
* Person gerade ausdruecklich abgebrochen hat.
|
||||
*
|
||||
* Reihenfolge ist hier entscheidend: erst bei Stripe nachsehen, dann erst
|
||||
* etwas zurueckbuchen. Wuerden wir umgekehrt vorgehen, koennte eine bereits
|
||||
* bezahlte Bestellung storniert und ihr Bestand doppelt verkauft werden —
|
||||
* etwa wenn die Zahlung in einem zweiten Tab abgeschlossen wurde.
|
||||
*
|
||||
* Wirft nie: die aufrufende Oberflaeche soll in jedem Fall weiterkommen. Der
|
||||
* Bestand kommt im Fehlerfall spaetestens mit dem Ablauf der Sitzung zurueck.
|
||||
*/
|
||||
export async function releaseCheckout(cancelToken: string): Promise<void> {
|
||||
if (!CANCEL_TOKEN_PATTERN.test(cancelToken)) return;
|
||||
|
||||
// Der Token hat 32 Byte und ist praktisch nicht zu erraten; die Bremse ist
|
||||
// trotzdem sinnvoll, weil jeder Aufruf sonst eine Stripe-Abfrage ausloest.
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`checkout-cancel:${ip}`, CANCEL_ATTEMPTS_PER_WINDOW, CANCEL_WINDOW_MS)) return;
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { cancelToken },
|
||||
select: { id: true, orderNumber: true, status: true, stripeCheckoutSessionId: true },
|
||||
});
|
||||
|
||||
// Unbekannter Token oder die Bestellung ist laengst nicht mehr offen
|
||||
// (bezahlt, storniert, versendet) — nichts zu tun. Bewusst ohne
|
||||
// unterscheidbare Rueckmeldung nach aussen: der Aufrufer sieht in jedem
|
||||
// Fall dasselbe und erfaehrt damit nicht, ob es den Token gibt.
|
||||
if (!order || order.status !== "NEU") return;
|
||||
|
||||
if (!order.stripeCheckoutSessionId) {
|
||||
// Ohne Sitzungs-ID laesst sich bei Stripe nicht pruefen, ob doch bezahlt
|
||||
// wurde — also nichts zurueckbuchen und den Aufraeumlauf abwarten.
|
||||
console.warn(
|
||||
`[checkout-cancel] Bestellung ${order.orderNumber} hat keine Stripe-Sitzung hinterlegt; Freigabe erst durch den Aufraeumlauf.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stripe = getStripeClient();
|
||||
const session = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId);
|
||||
|
||||
// "complete" heisst bezahlt oder in Bearbeitung — dann ist dieser Aufruf
|
||||
// kein Abbruch mehr. Finger weg.
|
||||
if (session.status === "complete") return;
|
||||
|
||||
// "open" heisst: noch bezahlbar. Erst die Sitzung schliessen, damit
|
||||
// niemand die bereits freigegebene Ware im naechsten Moment doch noch
|
||||
// bezahlt, und erst danach zurueckbuchen.
|
||||
if (session.status === "open") {
|
||||
await stripe.checkout.sessions.expire(order.stripeCheckoutSessionId);
|
||||
}
|
||||
|
||||
// Uebrig bleiben "open" (gerade geschlossen) und "expired" — in beiden
|
||||
// Faellen kann nicht mehr bezahlt werden. Stripe schickt dazu ein
|
||||
// checkout.session.expired-Ereignis; der Webhook findet die Bestellung
|
||||
// dann nicht mehr und tut nichts weiter (siehe lib/order-release.ts).
|
||||
//
|
||||
// "delete": hier wurde nie bezahlt und nie etwas bestaetigt. Eine
|
||||
// stornierte Bestellung stehen zu lassen wuerde nur verwirren — im
|
||||
// Admin-Bereich saehe jeder weggeklickte Warenkorb aus wie ein echter,
|
||||
// gescheiterter Verkauf.
|
||||
const released = await releaseUnpaidOrder(order.id, "delete");
|
||||
if (released) {
|
||||
console.info(
|
||||
`[checkout-cancel] Bestellung ${released.orderNumber} abgebrochen, Bestand freigegeben und Vormerkung entfernt.`
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
`[checkout-cancel] Bestellung ${order.orderNumber} konnte nicht freigegeben werden:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Tests fuer die Auswertung der Rueckkehr von Stripe. Die Adresszeile ist frei
|
||||
* waehlbar — "bezahlt" darf deshalb nur herauskommen, wenn Stripe es sagt und
|
||||
* die Sitzung zu einer eigenen Bestellung gehoert.
|
||||
*/
|
||||
import { describe, test, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
const sessionsRetrieve = vi.fn();
|
||||
vi.mock("@/lib/stripe", () => ({
|
||||
getStripeClient: () => ({ checkout: { sessions: { retrieve: sessionsRetrieve } } }),
|
||||
}));
|
||||
|
||||
const rateLimit = vi.fn<(key: string, limit: number, windowMs: number) => boolean>(() => true);
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
rateLimit: (key: string, limit: number, windowMs: number) => rateLimit(key, limit, windowMs),
|
||||
getClientIp: vi.fn(async () => "203.0.113.7"),
|
||||
}));
|
||||
|
||||
const db = { order: { findFirst: vi.fn() } };
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: db }));
|
||||
|
||||
const { resolveCheckoutReturn } = await import("./checkout-return");
|
||||
|
||||
const SESSION_ID = "cs_test_a1B2c3";
|
||||
const ORDER = {
|
||||
id: "order_1",
|
||||
orderNumber: "WH-2026-AABBCCDD",
|
||||
status: "NEU",
|
||||
cancelToken: "a".repeat(64),
|
||||
};
|
||||
|
||||
function stripeSession(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: SESSION_ID,
|
||||
status: "complete",
|
||||
payment_status: "paid",
|
||||
client_secret: "cs_test_a1B2c3_secret_x",
|
||||
metadata: { orderId: ORDER.id },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
rateLimit.mockReturnValue(true);
|
||||
db.order.findFirst.mockResolvedValue(ORDER);
|
||||
sessionsRetrieve.mockResolvedValue(stripeSession());
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe("resolveCheckoutReturn", () => {
|
||||
test("meldet eine bezahlte Sitzung mit der Bestellnummer aus der Datenbank", async () => {
|
||||
await expect(resolveCheckoutReturn(SESSION_ID)).resolves.toEqual({
|
||||
kind: "paid",
|
||||
orderNumber: ORDER.orderNumber,
|
||||
});
|
||||
});
|
||||
|
||||
test("meldet eine abgeschlossene, noch nicht eingegangene Zahlung als in Bearbeitung", async () => {
|
||||
sessionsRetrieve.mockResolvedValue(stripeSession({ payment_status: "unpaid" }));
|
||||
await expect(resolveCheckoutReturn(SESSION_ID)).resolves.toEqual({
|
||||
kind: "processing",
|
||||
orderNumber: ORDER.orderNumber,
|
||||
});
|
||||
});
|
||||
|
||||
test("bietet nach abgebrochener PayPal-Zahlung dem bestellenden Browser einen erneuten Versuch an", async () => {
|
||||
sessionsRetrieve.mockResolvedValue(stripeSession({ status: "open", payment_status: "unpaid" }));
|
||||
await expect(resolveCheckoutReturn(SESSION_ID, ORDER.cancelToken)).resolves.toEqual({
|
||||
kind: "open",
|
||||
clientSecret: "cs_test_a1B2c3_secret_x",
|
||||
cancelToken: ORDER.cancelToken,
|
||||
});
|
||||
});
|
||||
|
||||
test("gibt Zahlungsformular und Abbruch-Token nicht an einen fremden Browser mit derselben URL", async () => {
|
||||
// Die session_id steht in Verlauf, Proxy-Logs und Screenshots. Allein
|
||||
// darf sie keine fremde Bestellung oeffnen oder abbrechen.
|
||||
sessionsRetrieve.mockResolvedValue(stripeSession({ status: "open", payment_status: "unpaid" }));
|
||||
await expect(resolveCheckoutReturn(SESSION_ID)).resolves.toEqual({ kind: "notCompleted" });
|
||||
await expect(resolveCheckoutReturn(SESSION_ID, "f".repeat(64))).resolves.toEqual({
|
||||
kind: "notCompleted",
|
||||
});
|
||||
});
|
||||
|
||||
test("fragt bei einer fremd geformten ID weder Datenbank noch Stripe", async () => {
|
||||
await expect(resolveCheckoutReturn('cs_test_1"; DROP')).resolves.toEqual({ kind: "notCompleted" });
|
||||
await expect(resolveCheckoutReturn("pi_123")).resolves.toEqual({ kind: "notCompleted" });
|
||||
expect(db.order.findFirst).not.toHaveBeenCalled();
|
||||
expect(sessionsRetrieve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("fragt Stripe gar nicht erst, wenn es keine passende Bestellung gibt", async () => {
|
||||
db.order.findFirst.mockResolvedValue(null);
|
||||
await expect(resolveCheckoutReturn(SESSION_ID)).resolves.toEqual({ kind: "notCompleted" });
|
||||
expect(sessionsRetrieve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("glaubt keiner Sitzung, die laut Metadaten zu einer anderen Bestellung gehoert", async () => {
|
||||
sessionsRetrieve.mockResolvedValue(stripeSession({ metadata: { orderId: "order_other" } }));
|
||||
await expect(resolveCheckoutReturn(SESSION_ID)).resolves.toEqual({ kind: "notCompleted" });
|
||||
});
|
||||
|
||||
test("bietet eine abgelaufene Sitzung nicht zum Bezahlen an", async () => {
|
||||
sessionsRetrieve.mockResolvedValue(stripeSession({ status: "expired", payment_status: "unpaid" }));
|
||||
await expect(resolveCheckoutReturn(SESSION_ID)).resolves.toEqual({ kind: "notCompleted" });
|
||||
});
|
||||
|
||||
test("bremst viele Abfragen derselben Adresse, bevor Stripe gefragt wird", async () => {
|
||||
rateLimit.mockReturnValue(false);
|
||||
await expect(resolveCheckoutReturn(SESSION_ID)).resolves.toEqual({ kind: "notCompleted" });
|
||||
expect(sessionsRetrieve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("vertraut bei einem Stripe-Ausfall dem vom Webhook bereits verbuchten Status", async () => {
|
||||
db.order.findFirst.mockResolvedValue({ ...ORDER, status: "BEZAHLT" });
|
||||
sessionsRetrieve.mockRejectedValue(new Error("ECONNRESET"));
|
||||
await expect(resolveCheckoutReturn(SESSION_ID)).resolves.toEqual({
|
||||
kind: "paid",
|
||||
orderNumber: ORDER.orderNumber,
|
||||
});
|
||||
});
|
||||
|
||||
test("behauptet bei einem Stripe-Ausfall und offener Bestellung keine Zahlung", async () => {
|
||||
sessionsRetrieve.mockRejectedValue(new Error("ECONNRESET"));
|
||||
await expect(resolveCheckoutReturn(SESSION_ID)).resolves.toEqual({ kind: "notCompleted" });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -30,3 +30,27 @@ export function getStripeClient(): Stripe {
|
||||
stripeClient = new Stripe(secretKey);
|
||||
return stripeClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Oeffentlicher Schluessel (pk_...) fuer das eingebettete Zahlungsformular.
|
||||
*
|
||||
* Bewusst ohne NEXT_PUBLIC_-Praefix und zur Laufzeit gelesen: Next.js brennt
|
||||
* NEXT_PUBLIC_-Variablen beim Build fest ein, das Docker-Image wird aber
|
||||
* einmal gebaut und erst im Cluster konfiguriert. Die Seite reicht den Wert
|
||||
* deshalb als Prop an den Browser weiter.
|
||||
*
|
||||
* Alles, was nicht wie ein veroeffentlichbarer Schluessel aussieht, gilt als
|
||||
* nicht gesetzt — vor allem ein versehentlich hier eingetragener geheimer
|
||||
* Schluessel (sk_/rk_), der sonst im HTML jeder Kassenseite stuende.
|
||||
*/
|
||||
export function getStripePublishableKey(): string | undefined {
|
||||
const key = process.env.STRIPE_PUBLISHABLE_KEY?.trim();
|
||||
if (!key) return undefined;
|
||||
if (!/^pk_(test|live)_[A-Za-z0-9]+$/.test(key)) {
|
||||
console.error(
|
||||
"STRIPE_PUBLISHABLE_KEY ist kein veroeffentlichbarer Schluessel (pk_test_/pk_live_) und wird ignoriert."
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
+9
-4
@@ -23,12 +23,17 @@ const isDev = process.env.NODE_ENV === "development";
|
||||
function buildCsp(nonce: string): string {
|
||||
return [
|
||||
"default-src 'self'",
|
||||
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://challenges.cloudflare.com${isDev ? " 'unsafe-eval'" : ""}`,
|
||||
// Stripe-Hosts laut https://docs.stripe.com/security/guide (Abschnitte
|
||||
// "Checkout" und "Stripe.js") fuer den eingebetteten Checkout. In
|
||||
// script-src greifen sie nur in Browsern ohne 'strict-dynamic'; die
|
||||
// uebrigen laden Stripe.js, weil ein genoncetes Skript es nachlaedt.
|
||||
// hooks.stripe.com: 3-D-Secure und Weiterleitungs-Zahlungsarten.
|
||||
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://challenges.cloudflare.com https://js.stripe.com https://*.js.stripe.com https://checkout.stripe.com${isDev ? " 'unsafe-eval'" : ""}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob:",
|
||||
"img-src 'self' data: blob: https://*.stripe.com",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self' https://api.stripe.com https://challenges.cloudflare.com",
|
||||
"frame-src https://js.stripe.com https://checkout.stripe.com https://challenges.cloudflare.com",
|
||||
"connect-src 'self' https://api.stripe.com https://checkout.stripe.com https://challenges.cloudflare.com",
|
||||
"frame-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com https://checkout.stripe.com https://challenges.cloudflare.com",
|
||||
"form-action 'self'",
|
||||
"base-uri 'self'",
|
||||
"object-src 'none'",
|
||||
|
||||
Reference in new issue
Block a user