feat: Stripe Embedded Checkout statt Weiterleitung zu checkout.stripe.com

Bezahlt wird jetzt direkt auf /checkout: die Checkout-Session laeuft mit
ui_mode "embedded_page", das Zahlungsformular ist ein Stripe-iframe
(PCI SAQ A bleibt). PayPal, Karte, SEPA und Klarna kommen weiterhin
dynamisch aus dem Dashboard.

- Zahlungsschritt ersetzt das Formular; "Zurueck zu deinen Angaben" gibt
  Sitzung und Bestand sofort frei (POST /api/checkout/cancel) und behaelt
  alle Eingaben, Turnstile wird neu geladen
- Rueckkehrseite prueft serverseitig bei Stripe und gleicht die Sitzung mit
  der eigenen Bestellung ab; bei abgebrochener PayPal-Zahlung wird dieselbe
  Sitzung erneut eingebettet
- Erneuter Versuch und Abbruch sind per HttpOnly-Cookie an den bestellenden
  Browser gebunden, die session_id in der URL allein reicht nicht
- Alte /checkout/cancel?ref= Links aendern per GET nichts mehr
- CSP um die Stripe-Hosts fuer Embedded Checkout und 3-D-Secure erweitert
- STRIPE_PUBLISHABLE_KEY zur Laufzeit gelesen (kein NEXT_PUBLIC_), mit
  Preflight-Pruefung auf pk_live und Schutz gegen versehentliche sk_/rk_

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-25 08:01:18 +02:00
1 parent 6ac7cc5d1f
commit 4ee5bafe20
28 files changed
+1272 -179

No files matched your search

+6 -5
View File
@@ -22,12 +22,13 @@ MYSQL_DATABASE=wabenhain
# Stripe (Test-Keys aus dem Stripe-Dashboard, https://dashboard.stripe.com/test/apikeys) # Stripe (Test-Keys aus dem Stripe-Dashboard, https://dashboard.stripe.com/test/apikeys)
# #
# Nur der geheime Schluessel wird gebraucht: der Checkout laeuft vollstaendig # Serverseitiger Schluessel fuer Checkout-Sessions, Erstattungen und Webhooks.
# serverseitig ueber eine Stripe-Checkout-Session (src/app/api/checkout/), der # Empfohlen: ein eingeschraenkter Schluessel (rk_...) statt des vollen sk_...
# Browser wird lediglich weitergeleitet. Einen veroeffentlichbaren Schluessel
# (pk_...) braeuchte erst eine clientseitige Einbindung wie Stripe Elements —
# solange es die nicht gibt, waere er Konfiguration, die nichts tut.
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
# Oeffentlicher Schluessel fuer das eingebettete Zahlungsformular auf /checkout.
# Bewusst OHNE NEXT_PUBLIC_: wird zur Laufzeit gelesen, nicht beim Build
# eingebrannt. Muss zum Modus des geheimen Schluessels passen (test/live).
STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxx
# Webhook-Secret aus `stripe listen` bzw. dem Stripe-Dashboard-Webhook-Endpoint # Webhook-Secret aus `stripe listen` bzw. dem Stripe-Dashboard-Webhook-Endpoint
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx
+19 -7
View File
@@ -142,15 +142,27 @@ Nur `db:push`/`db:migrate`/`db:deploy`/`db:studio` (Befehle, die tatsächlich ge
## Stripe ## Stripe
1. Test-Key aus dem [Stripe-Dashboard](https://dashboard.stripe.com/test/apikeys) in `.env` eintragen (`STRIPE_SECRET_KEY`). Einen veröffentlichbaren `pk_`-Schlüssel braucht der Shop nicht: der Checkout läuft vollständig serverseitig über eine Stripe-Checkout-Session, der Browser wird nur weitergeleitet. 1. Test-Keys aus dem [Stripe-Dashboard](https://dashboard.stripe.com/test/apikeys) in `.env` eintragen: `STRIPE_SECRET_KEY` (empfohlen: eingeschränkter `rk_`-Schlüssel) und `STRIPE_PUBLISHABLE_KEY` (`pk_test_…`). Der öffentliche Schlüssel wird zur Laufzeit gelesen (kein `NEXT_PUBLIC_`), damit dasselbe Docker-Image in Test und Produktion läuft.
2. Für Webhooks lokal: `stripe listen --forward-to localhost:3000/api/checkout/webhook` und das ausgegebene Secret als `STRIPE_WEBHOOK_SECRET` eintragen. 2. Für Webhooks lokal: `stripe listen --forward-to localhost:3000/api/checkout/webhook` und das ausgegebene Secret als `STRIPE_WEBHOOK_SECRET` eintragen.
3. Ohne konfigurierte Stripe-Keys bleibt der Checkout funktionsfähig: Die Bestellung wird angelegt, der Nutzer landet mit Hinweis auf der Erfolgsseite, die Bestellung kann im Admin-Bereich manuell nachbearbeitet werden. 3. Ohne `STRIPE_PUBLISHABLE_KEY` nimmt der Checkout keine Bestellung an und sagt das im Formular — eine Bestellung, die sich auf der Seite nicht bezahlen ließe, soll gar nicht erst entstehen. Für reine UI-Arbeit ohne Zahlung reicht es, die Keys leer zu lassen.
### Zahlungsmethoden (Karte, SEPA-Lastschrift, Klarna) ### Eingebetteter Checkout
Die Checkout Session gibt bewusst kein `payment_method_types` vor — Stripe zeigt Kundinnen und Kunden dadurch automatisch alle im Dashboard aktivierten, für Betrag/Währung/Land berechtigten Zahlungsmethoden an ("dynamische Zahlungsmethoden"). Um SEPA-Lastschrift und Klarna neben Kartenzahlung anzubieten, reicht es, sie unter [Dashboard → Zahlungsmethoden](https://dashboard.stripe.com/settings/payment_methods) zu aktivieren — dafür ist keine Codeänderung nötig. Bezahlt wird direkt auf `/checkout`, ohne Umleitung auf `checkout.stripe.com`: Die Checkout-Session läuft mit `ui_mode: "embedded_page"`, das Zahlungsformular ist ein Stripe-iframe. Kartendaten und PayPal-Anmeldung berühren den Server nie (PCI SAQ A bleibt).
**Apple Pay / Google Pay**: Diese Wallets sind bei der von Stripe gehosteten Checkout-Seite (der hier verwendete Modus, `stripe.checkout.sessions.create(...)` ohne `ui_mode: "embedded"`) laut Stripe-Dokumentation "ohne Änderungen an der Integration" automatisch enthalten, solange Kartenzahlung aktiviert ist (Standardeinstellung) — sie erscheinen als zusätzliche Option innerhalb des Kartenformulars auf unterstützten Geräten/Browsern (Safari mit hinterlegter Karte in Apple Wallet, Chrome/Android mit hinterlegter Karte in Google Pay). Die separate Domain-Registrierung unter [Dashboard → Zahlungsmethoden-Domains](https://dashboard.stripe.com/settings/payment_method_domains) ist laut Stripe nur für Elements bzw. die *eingebettete* Checkout-Zahlungsseite nötig, **nicht** für die vollständig gehostete Checkout-Seite (eigene, von Stripe bereits verifizierte Domain `checkout.stripe.com`) — hier also kein weiterer Schritt nötig. Falls die Wallets bei einem Testkauf trotzdem nicht erscheinen, liegt es fast immer am Testgerät/-browser (kein Apple Pay/Google Pay eingerichtet) oder daran, dass Kartenzahlung im Dashboard deaktiviert wurde, nicht am Code. Ablauf:
1. Angaben ausfüllen → `POST /api/checkout` legt die Bestellung an, reserviert Bestand und liefert `clientSecret` + `cancelToken`.
2. Das Formular tritt zurück, das Stripe-Formular erscheint (Stripe.js wird erst jetzt geladen). „Zurück zu deinen Angaben" ruft `POST /api/checkout/cancel` auf: Sitzung schließen, Bestand sofort freigeben, alle Eingaben bleiben erhalten.
3. Nach jedem Zahlungsversuch — bei PayPal nach dem Umweg über paypal.com — leitet Stripe auf `/checkout/success?session_id=…`. Die Seite fragt **serverseitig** bei Stripe nach und gleicht die Sitzung mit der eigenen Bestellung ab; die URL allein beweist nichts. Bei `open` (abgebrochen/abgelehnt) wird dieselbe Sitzung erneut eingebettet, der Warenkorb bleibt erhalten — aber nur für den Browser, der die Bestellung angelegt hat: `POST /api/checkout` setzt dafür das HttpOnly-Cookie `wh_checkout_ref` (SameSite=Lax, Pfad `/checkout`, 40 Minuten). Die `session_id` in der Adresszeile allein öffnet weder das Zahlungsformular noch den Abbruch. Verbucht wird weiterhin ausschließlich im Webhook.
Die Content-Security-Policy in `src/proxy.ts` enthält die dafür nötigen Stripe-Hosts laut [Stripe-Sicherheitsleitfaden](https://docs.stripe.com/security/guide#content-security-policy). Farben und Schrift des Formulars stellt man im Dashboard unter [Branding](https://dashboard.stripe.com/settings/branding) ein.
### Zahlungsmethoden (PayPal, Karte, SEPA-Lastschrift, Klarna)
Die Checkout Session gibt bewusst kein `payment_method_types` vor — Stripe zeigt Kundinnen und Kunden dadurch automatisch alle im Dashboard aktivierten, für Betrag/Währung/Land berechtigten Zahlungsmethoden an ("dynamische Zahlungsmethoden"). Um PayPal, SEPA-Lastschrift und Klarna neben Kartenzahlung anzubieten, reicht es, sie unter [Dashboard → Zahlungsmethoden](https://dashboard.stripe.com/settings/payment_methods) zu aktivieren — dafür ist keine Codeänderung nötig. PayPal über Stripe ist für Konten in Deutschland verfügbar und muss für den Live-Betrieb einmal [aktiviert](https://docs.stripe.com/payments/paypal/activate) werden.
**Apple Pay / Google Pay**: werden bewusst nicht angeboten. Im eingebetteten Checkout erscheinen sie nur nach einer Domain-Registrierung unter [Dashboard → Zahlungsmethoden-Domains](https://dashboard.stripe.com/settings/payment_method_domains); ohne sie bleiben sie ausgeblendet. Die `Permissions-Policy` (`payment=(self)` in `next.config.ts`) müsste dafür zusätzlich für `https://js.stripe.com` geöffnet werden.
SEPA-Lastschrift und teils Klarna bestätigen die Zahlung **verzögert** (bei SEPA i. d. R. einige Werktage), nicht sofort wie eine Kartenzahlung. Der Webhook-Handler (`/api/checkout/webhook`) berücksichtigt das: SEPA-Lastschrift und teils Klarna bestätigen die Zahlung **verzögert** (bei SEPA i. d. R. einige Werktage), nicht sofort wie eine Kartenzahlung. Der Webhook-Handler (`/api/checkout/webhook`) berücksichtigt das:
@@ -174,7 +186,7 @@ Eine Bestellung entsteht **vor** der Zahlung und reserviert dabei sofort Lagerbe
| Wann | Was passiert | | Wann | Was passiert |
|------|--------------| |------|--------------|
| Klick auf „Abbrechen“ bei Stripe | `/checkout/cancel?ref=<Token>` schließt die Stripe-Sitzung, bucht den Bestand **sofort** zurück und löscht die Bestellung | | „Zurück zu deinen Angaben“ / „Bestellung abbrechen“ | `POST /api/checkout/cancel` schließt die Stripe-Sitzung, bucht den Bestand **sofort** zurück und löscht die Bestellung (alte Links `/checkout/cancel?ref=<Token>` ändern nichts mehr — ein GET darf keinen Bestand freigeben, Link-Vorschauen und Mail-Scanner lösen ihn aus) |
| Checkout einfach verlassen | Die Sitzung läuft nach **32 Minuten** ab, Stripe schickt `checkout.session.expired`, der Webhook bucht zurück und löscht | | Checkout einfach verlassen | Die Sitzung läuft nach **32 Minuten** ab, Stripe schickt `checkout.session.expired`, der Webhook bucht zurück und löscht |
| Webhook kommt nicht an | Der CronJob `wabenhain-release-stale-orders` prüft alle 30 Minuten bei Stripe nach und bucht zurück, was dort abgelaufen ist | | Webhook kommt nicht an | Der CronJob `wabenhain-release-stale-orders` prüft alle 30 Minuten bei Stripe nach und bucht zurück, was dort abgelaufen ist |
| Stripe-Sitzung lässt sich gar nicht erst anlegen | `api/checkout/route.ts` bucht den Bestand sofort zurück und hinterlässt die Bestellung als **STORNIERT** mit Notiz — sie war nie bezahlbar | | Stripe-Sitzung lässt sich gar nicht erst anlegen | `api/checkout/route.ts` bucht den Bestand sofort zurück und hinterlässt die Bestellung als **STORNIERT** mit Notiz — sie war nie bezahlbar |
@@ -197,7 +209,7 @@ Setzt die Imkerei eine Bestellung unter `/admin/bestellungen/<id>` auf **STORNIE
Die Stripe-Aufrufe laufen bewusst **nach** der Transaktion: ein Netzaufruf dauert ein Vielfaches einer Datenbankoperation und würde die Sperren auf Bestellung und Lagerbestand so lange halten. Schlägt einer davon fehl, wird die Stornierung **nicht** zurückgenommen — die Ware ist bereits wieder im Bestand und die Absicht war eindeutig. Stattdessen erscheint der offene Punkt als Fehlermeldung im Admin-Bereich („bitte im Stripe-Dashboard von Hand erstatten") und als Eintrag `order.cancel_incomplete` im Audit-Log. Die Stripe-Aufrufe laufen bewusst **nach** der Transaktion: ein Netzaufruf dauert ein Vielfaches einer Datenbankoperation und würde die Sperren auf Bestellung und Lagerbestand so lange halten. Schlägt einer davon fehl, wird die Stornierung **nicht** zurückgenommen — die Ware ist bereits wieder im Bestand und die Absicht war eindeutig. Stattdessen erscheint der offene Punkt als Fehlermeldung im Admin-Bereich („bitte im Stripe-Dashboard von Hand erstatten") und als Eintrag `order.cancel_incomplete` im Audit-Log.
Der **Abbruch-Token** (`Order.cancelToken`, 32 Byte) steht ausschließlich in der `cancel_url` und wird nach Gebrauch aus der Adresszeile entfernt. Die Bestellnummer taugt dafür nicht: sie steht in jeder Bestätigungsmail und auf jeder Rechnung, mit ihr ließe sich also die noch offene Zahlung einer fremden Person abbrechen. Vor dem Zurückbuchen wird immer erst bei Stripe nachgesehen — ist die Sitzung dort `complete`, war der Aufruf kein Abbruch, sondern ein Zurück-Klick nach erfolgreicher Zahlung, und es passiert nichts. Der **Abbruch-Token** (`Order.cancelToken`, 32 Byte) geht ausschließlich an den Browser, der die Bestellung gerade anlegt (bzw. an die Rückkehrseite derselben, noch offenen Sitzung), und wird nur per POST mit Origin-Prüfung eingelöst. Die Bestellnummer taugt dafür nicht: sie steht in jeder Bestätigungsmail und auf jeder Rechnung, mit ihr ließe sich also die noch offene Zahlung einer fremden Person abbrechen. Vor dem Zurückbuchen wird immer erst bei Stripe nachgesehen — ist die Sitzung dort `complete`, war der Aufruf kein Abbruch, sondern ein Zurück-Klick nach erfolgreicher Zahlung, und es passiert nichts.
Der Aufräumlauf lässt sich auch von Hand anstoßen: `pnpm orders:release-stale`. Er bucht nur zurück, was bei Stripe nachweislich abgelaufen ist, nie nach Alter allein. Findet er eine Bestellung, die bei Stripe **bezahlt** ist, hier aber noch auf „NEU“ steht, rührt er sie nicht an, meldet sie und endet mit Exit-Code 1 — dieser Fall bedeutet, dass ein `completed`-Webhook verlorengegangen ist, und er gehört als fehlgeschlagener Job sichtbar gemacht statt in eine Logzeile. Der Aufräumlauf lässt sich auch von Hand anstoßen: `pnpm orders:release-stale`. Er bucht nur zurück, was bei Stripe nachweislich abgelaufen ist, nie nach Alter allein. Findet er eine Bestellung, die bei Stripe **bezahlt** ist, hier aber noch auf „NEU“ steht, rührt er sie nicht an, meldet sie und endet mit Exit-Code 1 — dieser Fall bedeutet, dass ein `completed`-Webhook verlorengegangen ist, und er gehört als fehlgeschlagener Job sichtbar gemacht statt in eine Logzeile.
+1
View File
@@ -109,6 +109,7 @@ services:
# gelesen (siehe .env.example). # gelesen (siehe .env.example).
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET} STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY:-}
# Nur fuer den Seed-Schritt relevant (legt das initiale Admin-Konto an, # Nur fuer den Seed-Schritt relevant (legt das initiale Admin-Konto an,
# siehe README "Admin-Zugang") — kein Basic-Auth-Geheimnis mehr. Bewusst # siehe README "Admin-Zugang") — kein Basic-Auth-Geheimnis mehr. Bewusst
# OHNE Default-Wert: ein vorhersehbarer Wert wie "[email protected]" # OHNE Default-Wert: ein vorhersehbarer Wert wie "[email protected]"
+6
View File
@@ -107,6 +107,12 @@ data:
# Wirkt nur, wenn die DHL-Zugangsdaten im Secret gesetzt sind. # Wirkt nur, wenn die DHL-Zugangsdaten im Secret gesetzt sind.
DHL_ENVIRONMENT: "sandbox" DHL_ENVIRONMENT: "sandbox"
# Oeffentlicher Stripe-Schluessel (pk_live_...) fuer das eingebettete
# Zahlungsformular. Nicht geheim — er steht ohnehin im HTML der Kasse.
# Muss zum STRIPE_SECRET_KEY im Secret passen (beide live). Leer = der
# Checkout nimmt keine Bestellungen an.
STRIPE_PUBLISHABLE_KEY: ""
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
# OPTIONAL — leer lassen, solange die Funktion nicht genutzt wird # OPTIONAL — leer lassen, solange die Funktion nicht genutzt wird
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
+2
View File
@@ -25,6 +25,8 @@
"@prisma/adapter-mariadb": "^7.10.0", "@prisma/adapter-mariadb": "^7.10.0",
"@prisma/adapter-pg": "^7.10.0", "@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0", "@prisma/client": "^7.10.0",
"@stripe/react-stripe-js": "^6.12.0",
"@stripe/stripe-js": "^9.17.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"ffmpeg-static": "^5.3.0", "ffmpeg-static": "^5.3.0",
"jose": "^6.2.8", "jose": "^6.2.8",
+26
View File
@@ -24,6 +24,12 @@ importers:
'@prisma/client': '@prisma/client':
specifier: ^7.10.0 specifier: ^7.10.0
version: 7.10.0([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]) version: 7.10.0([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected])
'@stripe/react-stripe-js':
specifier: ^6.12.0
version: 6.12.0(@stripe/[email protected])([email protected]([email protected]))([email protected])
'@stripe/stripe-js':
specifier: ^9.17.0
version: 9.17.0
bcryptjs: bcryptjs:
specifier: ^3.0.3 specifier: ^3.0.3
version: 3.0.3 version: 3.0.3
@@ -1045,6 +1051,17 @@ packages:
'@standard-schema/[email protected]': '@standard-schema/[email protected]':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@stripe/[email protected]':
resolution: {integrity: sha512-5RVfx92MTb66+ELuQTreUqX2oFvma5wybZ7MaifoNo0QgrjbJiLZcysGw5kIHU7p1TCd1Lx5JnasoqteSwlUsg==}
peerDependencies:
'@stripe/stripe-js': '>=9.16.0 <10.0.0'
react: '>=16.8.0 <20.0.0'
react-dom: '>=16.8.0 <20.0.0'
'@stripe/[email protected]':
resolution: {integrity: sha512-uidL4OJum5kivsOAQnVfQmYLJ2zdgirtmT0zWgskASZ4TPXxpXQ0MNrzW4NsXB90uEqmPHpSEtciLFbF1QQvNA==}
engines: {node: '>=12.16'}
'@swc/[email protected]': '@swc/[email protected]':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -4523,6 +4540,15 @@ snapshots:
'@standard-schema/[email protected]': {} '@standard-schema/[email protected]': {}
'@stripe/[email protected](@stripe/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@stripe/stripe-js': 9.17.0
prop-types: 15.8.1
react: 19.2.8
react-dom: 19.2.8([email protected])
'@stripe/[email protected]': {}
'@swc/[email protected]': '@swc/[email protected]':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
+11
View File
@@ -133,6 +133,17 @@ mustWhenSet(
(raw) => !raw.startsWith("sk_test"), (raw) => !raw.startsWith("sk_test"),
"Mit einem Test-Schluessel zahlt niemand echtes Geld — Bestellungen sehen erfolgreich aus, ohne dass Umsatz entsteht." "Mit einem Test-Schluessel zahlt niemand echtes Geld — Bestellungen sehen erfolgreich aus, ohne dass Umsatz entsteht."
); );
must(
"STRIPE_PUBLISHABLE_KEY gesetzt",
isSet("STRIPE_PUBLISHABLE_KEY"),
"Ohne oeffentlichen Schluessel laesst sich das eingebettete Zahlungsformular nicht anzeigen — der Checkout nimmt keine Bestellung an. Steht in k8s/01-configmap.yaml."
);
mustWhenSet(
"STRIPE_PUBLISHABLE_KEY",
"STRIPE_PUBLISHABLE_KEY ist ein Live-Schluessel (pk_live_...)",
(raw) => raw.startsWith("pk_live_"),
"Ein pk_test-Schluessel passt nicht zum Live-Geheimschluessel: Stripe weist das Zahlungsformular dann ab. Steht hier gar ein sk_/rk_-Schluessel, gehoert er sofort rotiert — dieser Wert landet im HTML jeder Kassenseite."
);
// --- E-Mail ---------------------------------------------------------------- // --- E-Mail ----------------------------------------------------------------
must( must(
+55
View File
@@ -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);
});
});
+34
View File
@@ -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 });
}
+50 -5
View File
@@ -121,7 +121,7 @@ beforeEach(() => {
); );
sessionsCreate.mockResolvedValue({ sessionsCreate.mockResolvedValue({
id: "cs_test_1", 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(() => {}); vi.spyOn(console, "error").mockImplementation(() => {});
}); });
@@ -243,15 +243,45 @@ describe("Checkout-Route", () => {
const response = await post(); const response = await post();
expect(response.status).toBe(200); expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ const body = await response.json();
url: "https://checkout.stripe.com/c/pay/cs_test_1", 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({ expect(db.order.update).toHaveBeenCalledWith({
where: { id: ORDER.id }, where: { id: ORDER.id },
data: { stripeCheckoutSessionId: "cs_test_1" }, 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 () => { test("begrenzt die Lebensdauer der Sitzung, damit Bestand nicht tagelang blockiert bleibt", async () => {
await post(); await post();
@@ -352,7 +382,7 @@ describe("Checkout-Route", () => {
test("schliesst die Sitzung wieder, wenn sich ihre Kennung nicht speichern laesst", async () => { test("schliesst die Sitzung wieder, wenn sich ihre Kennung nicht speichern laesst", async () => {
// Sonst entstuende genau der Zustand, den es nicht geben darf: eine // Sonst entstuende genau der Zustand, den es nicht geben darf: eine
// Bestellung auf "NEU" ohne Sitzungs-ID. Die Sitzung darf dabei // 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. // daraufhin nicht ausliefern.
db.order.update.mockRejectedValueOnce(new Error("connection reset")); db.order.update.mockRejectedValueOnce(new Error("connection reset"));
releaseUnpaidOrder.mockResolvedValue({ releaseUnpaidOrder.mockResolvedValue({
@@ -368,5 +398,20 @@ describe("Checkout-Route", () => {
expect(sessionsExpire).toHaveBeenCalledWith("cs_test_1"); expect(sessionsExpire).toHaveBeenCalledWith("cs_test_1");
expect(releaseUnpaidOrder).toHaveBeenCalledWith(ORDER.id, "cancel"); 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");
});
}); });
}); });
+59 -20
View File
@@ -14,6 +14,11 @@ import { findCustomsExcludedArea, isCountryAllowed } from "@/lib/shipping-region
import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc"; import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc";
import { ensureBulkDiscountCode } from "@/lib/bulk-discount"; import { ensureBulkDiscountCode } from "@/lib/bulk-discount";
import { releaseUnpaidOrder } from "@/lib/order-release"; 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 { getBaseUrl } from "@/lib/url";
import { vatRateForProduct } from "@/lib/vat"; import { vatRateForProduct } from "@/lib/vat";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
@@ -360,11 +365,12 @@ export async function POST(request: Request) {
const locale = await getLocale(); const locale = await getLocale();
// Steht gleich ausschliesslich in der cancel_url der Stripe-Sitzung und // Geht ausschliesslich in der Antwort an den Browser, der diese Bestellung
// gibt den reservierten Bestand frei, sobald die Kundschaft dort abbricht // gerade anlegt, und gibt den reservierten Bestand frei, sobald die
// (siehe checkout/cancel). 32 Byte, damit er nicht durchprobiert werden // Kundschaft den Zahlungsschritt verlaesst (siehe api/checkout/cancel).
// kann — mit einem ratbaren Wert liesse sich die offene Zahlung fremder // 32 Byte, damit er nicht durchprobiert werden kann — mit einem ratbaren
// Leute abbrechen. // 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"); const cancelToken = randomBytes(32).toString("hex");
// Bestellanlage und Bestandsreservierung atomar: das bedingte updateMany // Bestellanlage und Bestandsreservierung atomar: das bedingte updateMany
@@ -493,7 +499,7 @@ export async function POST(request: Request) {
const stripe = getStripeClient(); const stripe = getStripeClient();
// Bewusst NICHT aus dem Origin-Request-Header: der ist vom Aufrufer frei // Bewusst NICHT aus dem Origin-Request-Header: der ist vom Aufrufer frei
// waehlbar (z.B. direkter API-Aufruf statt ueber den Browser) und wuerde // 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 // ein offenes Redirect direkt nach einer echten Zahlung auf Stripes
// eigener Checkout-Seite waere hochgradig phishing-tauglich. // eigener Checkout-Seite waere hochgradig phishing-tauglich.
const origin = getBaseUrl(); const origin = getBaseUrl();
@@ -514,13 +520,15 @@ export async function POST(request: Request) {
// Kein payment_method_types (s. README "Zahlungsmethoden"): Stripe zeigt // Kein payment_method_types (s. README "Zahlungsmethoden"): Stripe zeigt
// dadurch dynamisch alle im Dashboard aktivierten, berechtigten // dadurch dynamisch alle im Dashboard aktivierten, berechtigten
// Zahlungsmethoden. Apple Pay/Google Pay sind bei dieser vollstaendig // Zahlungsmethoden — PayPal eingeschlossen, sobald es dort aktiv ist.
// gehosteten Checkout-Seite (kein ui_mode: "embedded") laut Stripe-Doku //
// automatisch enthalten, sobald Kartenzahlung aktiv ist — keine // "embedded_page": das Zahlungsformular erscheint als Stripe-iframe
// Codeaenderung und (anders als bei Elements/eingebetteter Checkout-Seite) // direkt auf /checkout statt auf checkout.stripe.com. Kartendaten
// keine Domain-Registrierung noetig, da checkout.stripe.com bereits // beruehren diesen Server trotzdem nie (PCI SAQ A bleibt). Apple Pay und
// Stripes eigene, verifizierte Domain ist. // Google Pay braeuchten dafuer eine Domain-Registrierung im Dashboard;
// ohne sie erscheinen sie schlicht nicht.
const session = await stripe.checkout.sessions.create({ const session = await stripe.checkout.sessions.create({
ui_mode: "embedded_page",
mode: "payment", mode: "payment",
customer_email: input.email, customer_email: input.email,
line_items: [ line_items: [
@@ -549,14 +557,27 @@ export async function POST(request: Request) {
// Ohne diese Angabe laeuft die Sitzung erst nach 24 Stunden ab und // Ohne diese Angabe laeuft die Sitzung erst nach 24 Stunden ab und
// haelt so lange Lagerbestand fest (siehe CHECKOUT_SESSION_TTL_MINUTES). // haelt so lange Lagerbestand fest (siehe CHECKOUT_SESSION_TTL_MINUTES).
expires_at: Math.floor(Date.now() / 1000) + CHECKOUT_SESSION_TTL_MINUTES * 60, expires_at: Math.floor(Date.now() / 1000) + CHECKOUT_SESSION_TTL_MINUTES * 60,
success_url: `${origin}/checkout/success?order=${order.orderNumber}`, // Jede Zahlungsart endet hier — Karte ebenso wie PayPal, das die
// Der Token statt der Bestellnummer: die Bestellnummer steht in jeder // Kundschaft zwangslaeufig zu PayPal und zurueck schickt. Ein einziger
// Bestaetigungsmail und auf jeder Rechnung, mit ihr liesse sich also // Rueckweg statt zweier (onComplete im Browser vs. Weiterleitung) heisst
// die noch offene Zahlung einer fremden Person abbrechen. // auch: nur eine Stelle entscheidet ueber "bezahlt", und die fragt
cancel_url: `${origin}/checkout/cancel?ref=${cancelToken}`, // 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 }, 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 // KEIN Best-effort mehr: ohne hinterlegte Sitzungs-ID laesst sich die
// Bestellung auf keinem Weg mehr aufloesen. Der Abbruch-Klick // Bestellung auf keinem Weg mehr aufloesen. Der Abbruch-Klick
// (checkout/cancel) braucht sie, um bei Stripe nachzusehen, und der // (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 // Schlaegt das Speichern fehl, wird deshalb die gerade erzeugte Sitzung
// sofort wieder geschlossen und die Bestellung aufgeloest (aeusseres // sofort wieder geschlossen und die Bestellung aufgeloest (aeusseres
// catch). Dass dabei eine bezahlbare Sitzung verlorengeht, ist folgenlos: // catch). Dass dabei eine bezahlbare Sitzung verlorengeht, ist folgenlos:
// ihre URL steht ausschliesslich in der Antwort, die wir daraufhin NICHT // ihr client_secret steht ausschliesslich in der Antwort, die wir
// ausliefern — niemand ausser diesem Prozess hat sie je gesehen. // daraufhin NICHT ausliefern — niemand ausser diesem Prozess hat ihn je
// gesehen.
try { try {
await prisma.order.update({ await prisma.order.update({
where: { id: order.id }, where: { id: order.id },
@@ -592,7 +614,24 @@ export async function POST(request: Request) {
throw error; 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) { } catch (error: unknown) {
// Die Stripe-Fehlermeldung nur serverseitig loggen, nicht an den Client // Die Stripe-Fehlermeldung nur serverseitig loggen, nicht an den Client
// zurueckgeben — sie kann interne Konfigurations-/Kontodetails enthalten // zurueckgeben — sie kann interne Konfigurations-/Kontodetails enthalten
+7 -100
View File
@@ -1,118 +1,25 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { Container, SectionHeading, LinkButton } from "@/components/ui"; 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 { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
export const metadata: Metadata = { title: "Bestellung abgebrochen", robots: { index: false } }; 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"; export const dynamic = "force-dynamic";
const CANCEL_ATTEMPTS_PER_WINDOW = 20; // Reine Anzeige, nachdem eine Zahlung per POST /api/checkout/cancel
const CANCEL_WINDOW_MS = 15 * 60 * 1000; // 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;
* Gibt den beim Checkout reservierten Lagerbestand sofort wieder frei, wenn // ihre Sitzung laeuft ohnehin nach spaetestens 32 Minuten ab und gibt den
* die Kundschaft die Zahlung bei Stripe abbricht. // Bestand dann ueber den Webhook frei.
*
* 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
);
}
}
export default async function CheckoutCancelPage(props: PageProps<"/checkout/cancel">) { export default async function CheckoutCancelPage(props: PageProps<"/checkout/cancel">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const { ref } = await props.searchParams; const { ref } = await props.searchParams;
if (typeof ref === "string" && ref) { if (typeof ref === "string" && ref) {
await releaseCheckout(ref); // Token aus Adresszeile, Verlauf und Referer nehmen.
// 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.
redirect("/checkout/cancel"); redirect("/checkout/cancel");
} }
+79 -2
View File
@@ -14,7 +14,26 @@ vi.mock("next/navigation", () => ({
useRouter: () => ({ push: routerPush }), 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 fetchMock = vi.fn();
const PUBLISHABLE_KEY = "pk_test_abc123";
const tiers: ShippingTierLike[] = [{ minQuantity: 0, maxQuantity: null, priceCents: 700 }]; const tiers: ShippingTierLike[] = [{ minQuantity: 0, maxQuantity: null, priceCents: 700 }];
const bulkDiscountTiers: BulkDiscountTierLike[] = []; const bulkDiscountTiers: BulkDiscountTierLike[] = [];
@@ -30,15 +49,29 @@ const item = (overrides: Partial<CartItem> = {}): CartItem => ({
...overrides, ...overrides,
}); });
function renderCheckout(items: CartItem[]) { function renderCheckout(items: CartItem[], stripePublishableKey: string | undefined = PUBLISHABLE_KEY) {
useCartStore.setState({ items, isOpen: false }); useCartStore.setState({ items, isOpen: false });
return render( return render(
<I18nProvider locale="de"> <I18nProvider locale="de">
<CheckoutForm tiers={tiers} bulkDiscountTiers={bulkDiscountTiers} /> <CheckoutForm
tiers={tiers}
bulkDiscountTiers={bulkDiscountTiers}
stripePublishableKey={stripePublishableKey}
/>
</I18nProvider> </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(() => { beforeEach(() => {
useCartStore.setState({ items: [], isOpen: false }); useCartStore.setState({ items: [], isOpen: false });
routerPush.mockClear(); routerPush.mockClear();
@@ -102,4 +135,48 @@ describe("CheckoutForm", () => {
// Erfolgsseite erneut einbaut. Genau dafuer steht sie hier. // Erfolgsseite erneut einbaut. Genau dafuer steht sie hier.
expect(routerPush).not.toHaveBeenCalled(); 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();
});
}); });
+76 -4
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useRef, useState, type FormEvent, type HTMLAttributes } from "react"; import { useEffect, useRef, useState, type FormEvent, type HTMLAttributes } from "react";
import dynamic from "next/dynamic";
import { Container, SectionHeading, Button } from "@/components/ui"; import { Container, SectionHeading, Button } from "@/components/ui";
import { TrustBadges } from "@/components/trust-badges"; import { TrustBadges } from "@/components/trust-badges";
import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store"; 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 { TurnstileWidget } from "@/components/turnstile-widget";
import { allowedCountryCodes, type ShippingRegion } from "@/lib/shipping-region"; 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"; type Fulfillment = "VERSAND" | "ABHOLUNG";
/** /**
@@ -62,6 +88,7 @@ export function CheckoutForm({
packstationSearchAvailable = false, packstationSearchAvailable = false,
turnstileSiteKey, turnstileSiteKey,
shippingRegion = "DE", shippingRegion = "DE",
stripePublishableKey,
}: { }: {
initialValues?: CheckoutInitialValues; initialValues?: CheckoutInitialValues;
tiers: ShippingTierLike[]; tiers: ShippingTierLike[];
@@ -81,6 +108,12 @@ export function CheckoutForm({
* Laender anbieten, als der Shop beliefert. * Laender anbieten, als der Shop beliefert.
*/ */
shippingRegion?: ShippingRegion; 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 { t, locale } = useI18n();
const items = useCartStore((s) => s.items); const items = useCartStore((s) => s.items);
@@ -98,6 +131,13 @@ export function CheckoutForm({
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isGift, setIsGift] = useState(false); 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 [deliveryTarget, setDeliveryTarget] = useState<DeliveryTarget>("ADRESSE");
const [packstationZip, setPackstationZip] = useState(""); const [packstationZip, setPackstationZip] = useState("");
@@ -161,6 +201,11 @@ export function CheckoutForm({
return; return;
} }
if (!stripePublishableKey) {
setError(t.checkoutPage.paymentUnavailable);
return;
}
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
@@ -217,13 +262,26 @@ export function CheckoutForm({
return; 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 { } catch {
setError(t.checkoutPage.genericError); setError(t.checkoutPage.genericError);
setSubmitting(false); setSubmitting(false);
} }
} }
function handlePaymentCancelled() {
setPayment(null);
setTurnstileToken(null);
setTurnstileKey((value) => value + 1);
}
// Der Cart-Store haelt seine Positionen nur im Browser (persist/localStorage) // Der Cart-Store haelt seine Positionen nur im Browser (persist/localStorage)
// und liest sie erst nach dem ersten Client-Render nach. Ohne diese Weiche // 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 // wuerde jede:r Kund:in mit gefuelltem Warenkorb kurz die "leer"-Meldung
@@ -246,7 +304,21 @@ export function CheckoutForm({
return ( return (
<div className="py-16"> <div className="py-16">
<Container className="grid lg:grid-cols-3 gap-14"> <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} /> <SectionHeading eyebrow={t.checkoutPage.eyebrow} title={t.checkoutPage.yourDetails} />
<div className="grid sm:grid-cols-2 gap-4"> <div className="grid sm:grid-cols-2 gap-4">
@@ -520,7 +592,7 @@ export function CheckoutForm({
{turnstileSiteKey ? ( {turnstileSiteKey ? (
<div className="mb-4"> <div className="mb-4">
<TurnstileWidget siteKey={turnstileSiteKey} onVerify={setTurnstileToken} /> <TurnstileWidget key={turnstileKey} siteKey={turnstileSiteKey} onVerify={setTurnstileToken} />
</div> </div>
) : null} ) : null}
<Button type="submit" disabled={submitting} className="w-full sm:w-auto"> <Button type="submit" disabled={submitting} className="w-full sm:w-auto">
@@ -531,7 +603,7 @@ export function CheckoutForm({
</p> </p>
</form> </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> <h2 className="text-lg text-forest mb-6">{t.checkoutPage.yourOrderHeading}</h2>
<ul className="space-y-3 text-sm"> <ul className="space-y-3 text-sm">
{items.map((item) => ( {items.map((item) => (
+170
View File
@@ -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>
);
}
+3
View File
@@ -8,6 +8,7 @@ import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
import { CheckoutForm, type CheckoutInitialValues } from "./checkout-form"; import { CheckoutForm, type CheckoutInitialValues } from "./checkout-form";
import { isPackstationSearchConfigured } from "@/lib/dhl-locations"; import { isPackstationSearchConfigured } from "@/lib/dhl-locations";
import { getStripePublishableKey } from "@/lib/stripe";
// Vorausfuellen aus Kundendaten aendert sich pro Nutzer/Anmeldestatus, und // Vorausfuellen aus Kundendaten aendert sich pro Nutzer/Anmeldestatus, und
// Versandstaffeln koennen sich im Admin-Bereich jederzeit aendern — // Versandstaffeln koennen sich im Admin-Bereich jederzeit aendern —
@@ -41,6 +42,7 @@ export default async function CheckoutPage() {
bulkDiscountTiers={bulkDiscountTiers} bulkDiscountTiers={bulkDiscountTiers}
packstationSearchAvailable={isPackstationSearchConfigured()} packstationSearchAvailable={isPackstationSearchConfigured()}
shippingRegion={shippingRegion} shippingRegion={shippingRegion}
stripePublishableKey={getStripePublishableKey()}
/>; />;
// getAddressesForCustomer sortiert Standardadresse (isDefault) zuerst, sonst // getAddressesForCustomer sortiert Standardadresse (isDefault) zuerst, sonst
@@ -66,5 +68,6 @@ export default async function CheckoutPage() {
bulkDiscountTiers={bulkDiscountTiers} bulkDiscountTiers={bulkDiscountTiers}
packstationSearchAvailable={isPackstationSearchConfigured()} packstationSearchAvailable={isPackstationSearchConfigured()}
shippingRegion={shippingRegion} 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;
}
+89 -26
View File
@@ -1,47 +1,110 @@
"use client"; import { cookies } from "next/headers";
import { useEffect } from "react";
import { use } from "react";
import { Container, SectionHeading, LinkButton } from "@/components/ui"; import { Container, SectionHeading, LinkButton } from "@/components/ui";
import { useCartStore } from "@/store/cart-store"; import { CHECKOUT_COOKIE } from "@/lib/checkout-release";
import { useI18n } from "@/i18n/i18n-provider"; 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({ // Liest bei jedem Aufruf den aktuellen Zahlungsstand bei Stripe — nie aus
searchParams, // einem Cache.
}: { export const dynamic = "force-dynamic";
searchParams: Promise<{ order?: string; pending?: string }>;
}) {
const { t } = useI18n();
const { order, pending } = use(searchParams);
const clear = useCartStore((s) => s.clear);
useEffect(() => { /**
clear(); * Rueckkehrseite des eingebetteten Checkouts. Stripe leitet nach jedem
// Warenkorb wird nur einmal nach erfolgreichem Checkout geleert. * Zahlungsversuch hierher (return_url in api/checkout/route.ts), bei PayPal
// eslint-disable-next-line react-hooks/exhaustive-deps * 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 ( return (
<div className="py-24"> <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"> <Container className="max-w-xl text-center">
<SectionHeading <SectionHeading
eyebrow={pending ? t.checkoutSuccessPage.pendingEyebrow : t.checkoutSuccessPage.successEyebrow} eyebrow={isPaid ? t.checkoutSuccessPage.successEyebrow : t.checkoutSuccessPage.pendingEyebrow}
title={pending ? t.checkoutSuccessPage.pendingTitle : t.checkoutSuccessPage.successTitle} title={isPaid ? t.checkoutSuccessPage.successTitle : t.checkoutSuccessPage.pendingTitle}
align="center" align="center"
className="mx-auto" className="mx-auto"
/> />
{order ? ( {orderNumber ? (
<p className="mt-4 text-ink/70"> <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> </p>
) : null} ) : null}
<p className="mt-6 text-ink/70"> <p className="mt-6 text-ink/70">
{pending ? t.checkoutSuccessPage.pendingText : t.checkoutSuccessPage.successText} {isPaid ? t.checkoutSuccessPage.successText : t.checkoutSuccessPage.pendingText}
</p> </p>
<div className="mt-10 flex items-center justify-center gap-4"> <div className="mt-10 flex items-center justify-center gap-4">
<LinkButton href="/shop">{t.checkoutSuccessPage.continueShopping}</LinkButton> <LinkButton href="/shop">{t.checkoutSuccessPage.continueShopping}</LinkButton>
{order ? ( {orderNumber ? (
<LinkButton href={`/bestellung-verfolgen?order=${encodeURIComponent(order)}`} variant="secondary"> <LinkButton href={`/bestellung-verfolgen?order=${encodeURIComponent(orderNumber)}`} variant="secondary">
{t.checkoutSuccessPage.trackOrder} {t.checkoutSuccessPage.trackOrder}
</LinkButton> </LinkButton>
) : null} ) : 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")}
/>
);
}
+18 -3
View File
@@ -1415,15 +1415,23 @@ export const de: Dictionary = {
save: "Passwort speichern", save: "Passwort speichern",
}, },
checkoutSuccessPage: { checkoutSuccessPage: {
pendingEyebrow: "Bestellung vorgemerkt", pendingEyebrow: "Zahlung in Bearbeitung",
successEyebrow: "Vielen Dank", successEyebrow: "Vielen Dank",
pendingTitle: "Deine Bestellung ist eingegangen.", pendingTitle: "Deine Bestellung ist eingegangen.",
successTitle: "Deine Bestellung wurde bestätigt.", successTitle: "Deine Bestellung wurde bestätigt.",
orderNumberLabel: "Bestellnummer", 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).", 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", continueShopping: "Weiter stöbern",
trackOrder: "Bestellung verfolgen", 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: { checkoutCancelPage: {
eyebrow: "Abgebrochen", eyebrow: "Abgebrochen",
@@ -1433,6 +1441,13 @@ export const de: Dictionary = {
backToCart: "Zurück zum Warenkorb", backToCart: "Zurück zum Warenkorb",
}, },
checkoutPage: { 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", eyebrow: "Checkout",
emptyCart: "Dein Warenkorb ist leer", emptyCart: "Dein Warenkorb ist leer",
yourDetails: "Deine Daten", yourDetails: "Deine Daten",
@@ -1474,7 +1489,7 @@ export const de: Dictionary = {
submitting: "Wird verarbeitet…", submitting: "Wird verarbeitet…",
placeOrder: "Kostenpflichtig bestellen", placeOrder: "Kostenpflichtig bestellen",
paymentMethodsNote: 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", yourOrderHeading: "Deine Bestellung",
shippingWithCount: (count) => `Versand (${count} Stück)`, shippingWithCount: (count) => `Versand (${count} Stück)`,
shippingPickup: "Versand (Abholung)", shippingPickup: "Versand (Abholung)",
+18 -3
View File
@@ -1401,15 +1401,23 @@ export const en: Dictionary = {
save: "Save password", save: "Save password",
}, },
checkoutSuccessPage: { checkoutSuccessPage: {
pendingEyebrow: "Order Received", pendingEyebrow: "Payment Processing",
successEyebrow: "Thank You", successEyebrow: "Thank You",
pendingTitle: "Your order has been received.", pendingTitle: "Your order has been received.",
successTitle: "Your order has been confirmed.", successTitle: "Your order has been confirmed.",
orderNumberLabel: "Order number", 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).", 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", continueShopping: "Keep browsing",
trackOrder: "Track order", 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: { checkoutCancelPage: {
eyebrow: "Cancelled", eyebrow: "Cancelled",
@@ -1419,6 +1427,13 @@ export const en: Dictionary = {
backToCart: "Back to cart", backToCart: "Back to cart",
}, },
checkoutPage: { 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", eyebrow: "Checkout",
emptyCart: "Your cart is empty", emptyCart: "Your cart is empty",
yourDetails: "Your Details", yourDetails: "Your Details",
@@ -1460,7 +1475,7 @@ export const en: Dictionary = {
submitting: "Processing…", submitting: "Processing…",
placeOrder: "Place order", placeOrder: "Place order",
paymentMethodsNote: 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", yourOrderHeading: "Your Order",
shippingWithCount: (count) => `Shipping (${count} item${count === 1 ? "" : "s"})`, shippingWithCount: (count) => `Shipping (${count} item${count === 1 ? "" : "s"})`,
shippingPickup: "Shipping (pickup)", shippingPickup: "Shipping (pickup)",
+15
View File
@@ -1073,6 +1073,14 @@ export interface Dictionary {
successText: string; successText: string;
continueShopping: string; continueShopping: string;
trackOrder: string; trackOrder: string;
openEyebrow: string;
openTitle: string;
openText: string;
cancelPayment: string;
notCompletedEyebrow: string;
notCompletedTitle: string;
notCompletedText: string;
backToCart: string;
}; };
checkoutCancelPage: { checkoutCancelPage: {
eyebrow: string; eyebrow: string;
@@ -1082,6 +1090,13 @@ export interface Dictionary {
backToCart: string; backToCart: string;
}; };
checkoutPage: { checkoutPage: {
paymentHeading: string;
paymentBack: string;
paymentCancelling: string;
paymentCancelError: string;
paymentLoading: string;
paymentLoadError: string;
paymentUnavailable: string;
eyebrow: string; eyebrow: string;
emptyCart: string; emptyCart: string;
yourDetails: string; yourDetails: string;
+92
View File
@@ -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();
});
});
+113
View File
@@ -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
);
}
}
+128
View File
@@ -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" });
});
});
+104
View File
@@ -0,0 +1,104 @@
import { timingSafeEqual } from "node:crypto";
import { prisma } from "@/lib/prisma";
import { getStripeClient } from "@/lib/stripe";
import { rateLimit, getClientIp } from "@/lib/rate-limit";
const RETURN_LOOKUPS_PER_WINDOW = 60;
const RETURN_WINDOW_MS = 15 * 60 * 1000;
/** Stripe-Checkout-Sitzungs-IDs: "cs_test_..." bzw. "cs_live_...". */
const SESSION_ID_PATTERN = /^cs_(test|live)_[A-Za-z0-9]{1,255}$/;
export type CheckoutReturn =
/** Zahlung eingegangen (Karte, PayPal). */
| { kind: "paid"; orderNumber: string }
/** Abgeschlossen, Geld aber noch unterwegs (z.B. SEPA-Lastschrift). */
| { kind: "processing"; orderNumber: string }
/** Zahlung fehlgeschlagen oder bei PayPal abgebrochen — noch bezahlbar. */
| { kind: "open"; clientSecret: string; cancelToken: string }
/** Abgelaufen, abgebrochen, unbekannt oder nicht pruefbar. */
| { kind: "notCompleted" };
const NOT_COMPLETED: CheckoutReturn = { kind: "notCompleted" };
function tokensMatch(a: string | undefined, b: string | null): boolean {
if (!a || !b) return false;
const left = Buffer.from(a);
const right = Buffer.from(b);
return left.length === right.length && timingSafeEqual(left, right);
}
/**
* Wertet die Rueckkehr von Stripe auf /checkout/success aus.
*
* Die URL beweist nichts: jeder kann ?session_id=... von Hand aufrufen. Ob
* bezahlt wurde, entscheidet deshalb allein die Abfrage bei Stripe, und die
* Bestellnummer kommt aus der eigenen Datenbank — nie aus der Adresszeile.
* Die eigentliche Verbuchung (Rechnung, Mail, Status) bleibt beim Webhook;
* diese Seite zeigt nur an.
*
* Unbekannte, fremde oder fehlerhafte IDs landen ununterscheidbar bei
* "notCompleted", damit sich ueber diese Seite nichts ausforschen laesst.
*
* browserCancelToken ist der Inhalt des CHECKOUT_COOKIE: nur wenn er zur
* Bestellung passt, wird ein erneuter Zahlungsversuch angeboten (siehe dort).
*/
export async function resolveCheckoutReturn(
sessionId: string,
browserCancelToken?: string
): Promise<CheckoutReturn> {
if (!SESSION_ID_PATTERN.test(sessionId)) return NOT_COMPLETED;
// Jeder Aufruf kostet eine Stripe-Abfrage — die Bremse haelt Durchprobieren
// und versehentliche Neulade-Schleifen klein.
const ip = await getClientIp();
if (!rateLimit(`checkout-return:${ip}`, RETURN_LOOKUPS_PER_WINDOW, RETURN_WINDOW_MS)) {
return NOT_COMPLETED;
}
// Zuerst die eigene Datenbank: eine ID, zu der es keine Bestellung gibt,
// loest gar nicht erst eine Anfrage bei Stripe aus.
const order = await prisma.order.findFirst({
where: { stripeCheckoutSessionId: sessionId },
select: { id: true, orderNumber: true, status: true, cancelToken: true },
});
if (!order || order.status === "STORNIERT") return NOT_COMPLETED;
try {
const session = await getStripeClient().checkout.sessions.retrieve(sessionId);
// Zweite Absicherung: die Sitzung muss zu genau dieser Bestellung gehoeren.
if (session.metadata?.orderId !== order.id) {
console.error(
`[checkout-return] Sitzung ${sessionId} gehoert laut Metadaten nicht zu Bestellung ${order.orderNumber}.`
);
return NOT_COMPLETED;
}
if (session.status === "complete") {
const isPaid =
session.payment_status === "paid" || session.payment_status === "no_payment_required";
return { kind: isPaid ? "paid" : "processing", orderNumber: order.orderNumber };
}
// Nur eine noch offene Bestellung, und nur fuer den Browser, der sie
// angelegt hat, darf erneut bezahlt werden.
if (
session.status === "open" &&
order.status === "NEU" &&
session.client_secret &&
order.cancelToken &&
tokensMatch(browserCancelToken, order.cancelToken)
) {
return { kind: "open", clientSecret: session.client_secret, cancelToken: order.cancelToken };
}
return NOT_COMPLETED;
} catch (error: unknown) {
console.error(`[checkout-return] Sitzung ${sessionId} konnte nicht abgefragt werden:`, error);
// Hat der Webhook die Zahlung schon verbucht, ist das die verlaessliche
// Auskunft — dann soll ein Stripe-Aussetzer die Freude nicht verderben.
if (order.status !== "NEU") return { kind: "paid", orderNumber: order.orderNumber };
return NOT_COMPLETED;
}
}
+24
View File
@@ -30,3 +30,27 @@ export function getStripeClient(): Stripe {
stripeClient = new Stripe(secretKey); stripeClient = new Stripe(secretKey);
return stripeClient; 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
View File
@@ -23,12 +23,17 @@ const isDev = process.env.NODE_ENV === "development";
function buildCsp(nonce: string): string { function buildCsp(nonce: string): string {
return [ return [
"default-src 'self'", "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'", "style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:", "img-src 'self' data: blob: https://*.stripe.com",
"font-src 'self' data:", "font-src 'self' data:",
"connect-src 'self' https://api.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://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'", "form-action 'self'",
"base-uri 'self'", "base-uri 'self'",
"object-src 'none'", "object-src 'none'",