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]>
114 lines
5.1 KiB
TypeScript
114 lines
5.1 KiB
TypeScript
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
|
|
);
|
|
}
|
|
}
|