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
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user