feat: Stripe Embedded Checkout statt Weiterleitung zu checkout.stripe.com
Bezahlt wird jetzt direkt auf /checkout: die Checkout-Session laeuft mit ui_mode "embedded_page", das Zahlungsformular ist ein Stripe-iframe (PCI SAQ A bleibt). PayPal, Karte, SEPA und Klarna kommen weiterhin dynamisch aus dem Dashboard. - Zahlungsschritt ersetzt das Formular; "Zurueck zu deinen Angaben" gibt Sitzung und Bestand sofort frei (POST /api/checkout/cancel) und behaelt alle Eingaben, Turnstile wird neu geladen - Rueckkehrseite prueft serverseitig bei Stripe und gleicht die Sitzung mit der eigenen Bestellung ab; bei abgebrochener PayPal-Zahlung wird dieselbe Sitzung erneut eingebettet - Erneuter Versuch und Abbruch sind per HttpOnly-Cookie an den bestellenden Browser gebunden, die session_id in der URL allein reicht nicht - Alte /checkout/cancel?ref= Links aendern per GET nichts mehr - CSP um die Stripe-Hosts fuer Embedded Checkout und 3-D-Secure erweitert - STRIPE_PUBLISHABLE_KEY zur Laufzeit gelesen (kein NEXT_PUBLIC_), mit Preflight-Pruefung auf pk_live und Schutz gegen versehentliche sk_/rk_ Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
6ac7cc5d1f
commit
4ee5bafe20
28 files changed
+1272
-179
No files matched your search
@@ -1,118 +1,25 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getStripeClient } from "@/lib/stripe";
|
||||
import { releaseUnpaidOrder } from "@/lib/order-release";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export const metadata: Metadata = { title: "Bestellung abgebrochen", robots: { index: false } };
|
||||
// Diese Seite aendert Daten (siehe releaseCheckout) und darf deshalb nie aus
|
||||
// einem Cache beantwortet werden.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CANCEL_ATTEMPTS_PER_WINDOW = 20;
|
||||
const CANCEL_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Gibt den beim Checkout reservierten Lagerbestand sofort wieder frei, wenn
|
||||
* die Kundschaft die Zahlung bei Stripe abbricht.
|
||||
*
|
||||
* Ohne das bliebe die Ware bis zum Ablauf der Stripe-Sitzung blockiert. Die
|
||||
* Sitzung laeuft zwar nur noch 32 Minuten statt 24 Stunden (siehe
|
||||
* api/checkout/route.ts), aber eine halbe Stunde ist immer noch eine halbe
|
||||
* Stunde, in der das letzte Glas fuer niemanden kaufbar ist, obwohl die
|
||||
* Person gerade ausdruecklich abgebrochen hat.
|
||||
*
|
||||
* Reihenfolge ist hier entscheidend: erst bei Stripe nachsehen, dann erst
|
||||
* etwas zurueckbuchen. Wuerden wir umgekehrt vorgehen, koennte eine bereits
|
||||
* bezahlte Bestellung storniert und ihr Bestand doppelt verkauft werden —
|
||||
* etwa wenn jemand nach erfolgreicher Zahlung den Zurueck-Knopf benutzt und
|
||||
* dabei wieder auf dieser Seite landet.
|
||||
*/
|
||||
async function releaseCheckout(cancelToken: string): Promise<void> {
|
||||
// Der Token hat 32 Byte und ist praktisch nicht zu erraten; die Bremse ist
|
||||
// trotzdem sinnvoll, weil jeder Aufruf sonst eine Stripe-Abfrage ausloest.
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`checkout-cancel:${ip}`, CANCEL_ATTEMPTS_PER_WINDOW, CANCEL_WINDOW_MS)) return;
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { cancelToken },
|
||||
select: { id: true, orderNumber: true, status: true, stripeCheckoutSessionId: true },
|
||||
});
|
||||
|
||||
// Unbekannter Token oder die Bestellung ist laengst nicht mehr offen
|
||||
// (bezahlt, storniert, versendet) — nichts zu tun. Bewusst ohne
|
||||
// unterscheidbare Rueckmeldung nach aussen: die Seite sieht in jedem Fall
|
||||
// gleich aus und verraet damit nicht, ob es den Token gibt.
|
||||
if (!order || order.status !== "NEU") return;
|
||||
|
||||
if (!order.stripeCheckoutSessionId) {
|
||||
// Kann vorkommen, wenn das Speichern der Sitzungs-ID fehlgeschlagen ist
|
||||
// (best-effort, siehe api/checkout/route.ts). Ohne sie laesst sich bei
|
||||
// Stripe nicht pruefen, ob doch bezahlt wurde — also nichts zurueckbuchen
|
||||
// und den Ablauf der Sitzung abwarten, der ueber den Webhook ohnehin
|
||||
// greift.
|
||||
console.warn(
|
||||
`[checkout-cancel] Bestellung ${order.orderNumber} hat keine Stripe-Sitzung hinterlegt; Freigabe erst bei Ablauf der Sitzung.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stripe = getStripeClient();
|
||||
const session = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId);
|
||||
|
||||
// "complete" heisst bezahlt oder in Bearbeitung — dann ist dieser
|
||||
// Seitenaufruf kein Abbruch, sondern ein Zurueck-Klick nach erfolgreicher
|
||||
// Zahlung. Finger weg.
|
||||
if (session.status === "complete") return;
|
||||
|
||||
// "open" heisst: noch bezahlbar. Erst die Sitzung schliessen, damit
|
||||
// niemand die bereits freigegebene Ware im naechsten Moment doch noch
|
||||
// bezahlt, und erst danach zurueckbuchen.
|
||||
if (session.status === "open") {
|
||||
await stripe.checkout.sessions.expire(order.stripeCheckoutSessionId);
|
||||
}
|
||||
|
||||
// Uebrig bleiben "open" (gerade geschlossen) und "expired" — in beiden
|
||||
// Faellen kann nicht mehr bezahlt werden. Stripe schickt dazu ein
|
||||
// checkout.session.expired-Ereignis; der Webhook findet die Bestellung
|
||||
// dann nicht mehr und tut nichts weiter (siehe lib/order-release.ts).
|
||||
//
|
||||
// "delete": hier wurde nie bezahlt und nie etwas bestaetigt. Eine
|
||||
// stornierte Bestellung stehen zu lassen wuerde nur verwirren — im
|
||||
// Admin-Bereich saehe jeder weggeklickte Warenkorb aus wie ein echter,
|
||||
// gescheiterter Verkauf.
|
||||
const released = await releaseUnpaidOrder(order.id, "delete");
|
||||
if (released) {
|
||||
console.info(
|
||||
`[checkout-cancel] Bestellung ${released.orderNumber} abgebrochen, Bestand freigegeben und Vormerkung entfernt.`
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Nicht weiterwerfen: die Person hat gerade abgebrochen und soll die
|
||||
// Abbruchseite sehen, nicht einen Serverfehler. Der Bestand kommt dann
|
||||
// spaetestens mit dem Ablauf der Sitzung zurueck.
|
||||
console.error(
|
||||
`[checkout-cancel] Bestellung ${order.orderNumber} konnte nicht freigegeben werden:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
// Reine Anzeige, nachdem eine Zahlung per POST /api/checkout/cancel
|
||||
// abgebrochen wurde. Frueher gab ein GET mit ?ref=<Token> hier den Bestand
|
||||
// frei — ein Zustandswechsel beim blossen Aufruf, den auch Link-Vorschauen
|
||||
// und Mail-Scanner ausloesen. Alte Links fuehren deshalb nur noch hierher;
|
||||
// ihre Sitzung laeuft ohnehin nach spaetestens 32 Minuten ab und gibt den
|
||||
// Bestand dann ueber den Webhook frei.
|
||||
|
||||
export default async function CheckoutCancelPage(props: PageProps<"/checkout/cancel">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { ref } = await props.searchParams;
|
||||
|
||||
if (typeof ref === "string" && ref) {
|
||||
await releaseCheckout(ref);
|
||||
// Token aus Adresszeile, Verlauf und Referer nehmen, sobald er seinen
|
||||
// Zweck erfuellt hat. Er gibt zwar nur genau diese eine, gerade
|
||||
// abgebrochene Bestellung frei, aber ein Geheimnis gehoert nicht laenger
|
||||
// sichtbar herum, als es gebraucht wird.
|
||||
// Token aus Adresszeile, Verlauf und Referer nehmen.
|
||||
redirect("/checkout/cancel");
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user