feat: Stripe Embedded Checkout statt Weiterleitung zu checkout.stripe.com
Bezahlt wird jetzt direkt auf /checkout: die Checkout-Session laeuft mit ui_mode "embedded_page", das Zahlungsformular ist ein Stripe-iframe (PCI SAQ A bleibt). PayPal, Karte, SEPA und Klarna kommen weiterhin dynamisch aus dem Dashboard. - Zahlungsschritt ersetzt das Formular; "Zurueck zu deinen Angaben" gibt Sitzung und Bestand sofort frei (POST /api/checkout/cancel) und behaelt alle Eingaben, Turnstile wird neu geladen - Rueckkehrseite prueft serverseitig bei Stripe und gleicht die Sitzung mit der eigenen Bestellung ab; bei abgebrochener PayPal-Zahlung wird dieselbe Sitzung erneut eingebettet - Erneuter Versuch und Abbruch sind per HttpOnly-Cookie an den bestellenden Browser gebunden, die session_id in der URL allein reicht nicht - Alte /checkout/cancel?ref= Links aendern per GET nichts mehr - CSP um die Stripe-Hosts fuer Embedded Checkout und 3-D-Secure erweitert - STRIPE_PUBLISHABLE_KEY zur Laufzeit gelesen (kein NEXT_PUBLIC_), mit Preflight-Pruefung auf pk_live und Schutz gegen versehentliche sk_/rk_ Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
6ac7cc5d1f
commit
4ee5bafe20
28 files changed
+1272
-179
No files matched your search
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Tests fuer den Abbruch einer offenen Zahlung. Kernpunkt ist die
|
||||
* Reihenfolge: erst bei Stripe nachsehen, dann zurueckbuchen — sonst liesse
|
||||
* sich eine bereits bezahlte Bestellung stornieren und doppelt verkaufen.
|
||||
*/
|
||||
import { describe, test, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
const sessionsRetrieve = vi.fn();
|
||||
const sessionsExpire = vi.fn();
|
||||
vi.mock("@/lib/stripe", () => ({
|
||||
getStripeClient: () => ({
|
||||
checkout: { sessions: { retrieve: sessionsRetrieve, expire: sessionsExpire } },
|
||||
}),
|
||||
}));
|
||||
|
||||
const rateLimit = vi.fn<(key: string, limit: number, windowMs: number) => boolean>(() => true);
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
rateLimit: (key: string, limit: number, windowMs: number) => rateLimit(key, limit, windowMs),
|
||||
getClientIp: vi.fn(async () => "203.0.113.7"),
|
||||
}));
|
||||
|
||||
const releaseUnpaidOrder = vi.fn();
|
||||
vi.mock("@/lib/order-release", () => ({
|
||||
releaseUnpaidOrder: (...args: unknown[]) => releaseUnpaidOrder(...args),
|
||||
}));
|
||||
|
||||
const db = { order: { findUnique: vi.fn() } };
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: db }));
|
||||
|
||||
const { releaseCheckout } = await import("./checkout-release");
|
||||
|
||||
const TOKEN = "b".repeat(64);
|
||||
const ORDER = {
|
||||
id: "order_1",
|
||||
orderNumber: "WH-2026-AABBCCDD",
|
||||
status: "NEU",
|
||||
stripeCheckoutSessionId: "cs_test_1",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
rateLimit.mockReturnValue(true);
|
||||
db.order.findUnique.mockResolvedValue(ORDER);
|
||||
sessionsRetrieve.mockResolvedValue({ status: "open" });
|
||||
sessionsExpire.mockResolvedValue({});
|
||||
releaseUnpaidOrder.mockResolvedValue({ orderNumber: ORDER.orderNumber });
|
||||
vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe("releaseCheckout", () => {
|
||||
test("schliesst eine offene Sitzung erst und gibt danach den Bestand frei", async () => {
|
||||
await releaseCheckout(TOKEN);
|
||||
|
||||
expect(sessionsExpire).toHaveBeenCalledWith("cs_test_1");
|
||||
expect(releaseUnpaidOrder).toHaveBeenCalledWith(ORDER.id, "delete");
|
||||
expect(sessionsExpire.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
releaseUnpaidOrder.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
test("laesst eine bei Stripe bereits bezahlte Bestellung unangetastet", async () => {
|
||||
sessionsRetrieve.mockResolvedValue({ status: "complete" });
|
||||
await releaseCheckout(TOKEN);
|
||||
expect(sessionsExpire).not.toHaveBeenCalled();
|
||||
expect(releaseUnpaidOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("gibt nichts frei, wenn das Schliessen bei Stripe scheitert", async () => {
|
||||
sessionsExpire.mockRejectedValue(new Error("stripe down"));
|
||||
await expect(releaseCheckout(TOKEN)).resolves.toBeUndefined();
|
||||
expect(releaseUnpaidOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("ignoriert Tokens im falschen Format, ohne die Datenbank zu fragen", async () => {
|
||||
await releaseCheckout("WH-2026-AABBCCDD");
|
||||
expect(db.order.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("tut nichts bei einer nicht mehr offenen Bestellung", async () => {
|
||||
db.order.findUnique.mockResolvedValue({ ...ORDER, status: "BEZAHLT" });
|
||||
await releaseCheckout(TOKEN);
|
||||
expect(sessionsRetrieve).not.toHaveBeenCalled();
|
||||
expect(releaseUnpaidOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("bremst viele Abbrueche derselben Adresse", async () => {
|
||||
rateLimit.mockReturnValue(false);
|
||||
await releaseCheckout(TOKEN);
|
||||
expect(db.order.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getStripeClient } from "@/lib/stripe";
|
||||
import { releaseUnpaidOrder } from "@/lib/order-release";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
|
||||
const CANCEL_ATTEMPTS_PER_WINDOW = 20;
|
||||
const CANCEL_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
/** Format des Abbruch-Tokens aus api/checkout/route.ts: 32 Byte als Hex. */
|
||||
export const CANCEL_TOKEN_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
/**
|
||||
* HttpOnly-Cookie mit dem Abbruch-Token der zuletzt angelegten Bestellung.
|
||||
*
|
||||
* Bindet den "erneut bezahlen"-Weg auf der Rueckkehrseite an den Browser, der
|
||||
* die Bestellung angelegt hat. Die session_id steht in der Adresszeile und
|
||||
* damit in Verlauf, Proxy-Logs und Screenshots — allein darf sie weder das
|
||||
* Zahlungsformular einer fremden Bestellung oeffnen noch sie abbrechen.
|
||||
*
|
||||
* SameSite=Lax statt Strict: nach PayPal kommt die Kundschaft per
|
||||
* Top-Level-Navigation von paypal.com bzw. stripe.com zurueck, und nur Lax
|
||||
* schickt das Cookie dabei mit.
|
||||
*/
|
||||
export const CHECKOUT_COOKIE = "wh_checkout_ref";
|
||||
export const CHECKOUT_COOKIE_PATH = "/checkout";
|
||||
/** Etwas laenger als die Sitzung (32 Minuten), nie laenger als noetig. */
|
||||
export const CHECKOUT_COOKIE_MAX_AGE_SECONDS = 40 * 60;
|
||||
|
||||
/**
|
||||
* Gibt den beim Checkout reservierten Lagerbestand sofort wieder frei, wenn
|
||||
* die Kundschaft die Zahlung abbricht — ueber "Zurueck zu deinen Angaben" im
|
||||
* eingebetteten Zahlungsschritt, ueber die Rueckkehrseite nach einer nicht
|
||||
* abgeschlossenen Zahlung oder ueber einen alten Abbruch-Link.
|
||||
*
|
||||
* Ohne das bliebe die Ware bis zum Ablauf der Stripe-Sitzung blockiert. Die
|
||||
* Sitzung laeuft zwar nur noch 32 Minuten statt 24 Stunden (siehe
|
||||
* api/checkout/route.ts), aber eine halbe Stunde ist immer noch eine halbe
|
||||
* Stunde, in der das letzte Glas fuer niemanden kaufbar ist, obwohl die
|
||||
* Person gerade ausdruecklich abgebrochen hat.
|
||||
*
|
||||
* Reihenfolge ist hier entscheidend: erst bei Stripe nachsehen, dann erst
|
||||
* etwas zurueckbuchen. Wuerden wir umgekehrt vorgehen, koennte eine bereits
|
||||
* bezahlte Bestellung storniert und ihr Bestand doppelt verkauft werden —
|
||||
* etwa wenn die Zahlung in einem zweiten Tab abgeschlossen wurde.
|
||||
*
|
||||
* Wirft nie: die aufrufende Oberflaeche soll in jedem Fall weiterkommen. Der
|
||||
* Bestand kommt im Fehlerfall spaetestens mit dem Ablauf der Sitzung zurueck.
|
||||
*/
|
||||
export async function releaseCheckout(cancelToken: string): Promise<void> {
|
||||
if (!CANCEL_TOKEN_PATTERN.test(cancelToken)) return;
|
||||
|
||||
// Der Token hat 32 Byte und ist praktisch nicht zu erraten; die Bremse ist
|
||||
// trotzdem sinnvoll, weil jeder Aufruf sonst eine Stripe-Abfrage ausloest.
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`checkout-cancel:${ip}`, CANCEL_ATTEMPTS_PER_WINDOW, CANCEL_WINDOW_MS)) return;
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { cancelToken },
|
||||
select: { id: true, orderNumber: true, status: true, stripeCheckoutSessionId: true },
|
||||
});
|
||||
|
||||
// Unbekannter Token oder die Bestellung ist laengst nicht mehr offen
|
||||
// (bezahlt, storniert, versendet) — nichts zu tun. Bewusst ohne
|
||||
// unterscheidbare Rueckmeldung nach aussen: der Aufrufer sieht in jedem
|
||||
// Fall dasselbe und erfaehrt damit nicht, ob es den Token gibt.
|
||||
if (!order || order.status !== "NEU") return;
|
||||
|
||||
if (!order.stripeCheckoutSessionId) {
|
||||
// Ohne Sitzungs-ID laesst sich bei Stripe nicht pruefen, ob doch bezahlt
|
||||
// wurde — also nichts zurueckbuchen und den Aufraeumlauf abwarten.
|
||||
console.warn(
|
||||
`[checkout-cancel] Bestellung ${order.orderNumber} hat keine Stripe-Sitzung hinterlegt; Freigabe erst durch den Aufraeumlauf.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stripe = getStripeClient();
|
||||
const session = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId);
|
||||
|
||||
// "complete" heisst bezahlt oder in Bearbeitung — dann ist dieser Aufruf
|
||||
// kein Abbruch mehr. Finger weg.
|
||||
if (session.status === "complete") return;
|
||||
|
||||
// "open" heisst: noch bezahlbar. Erst die Sitzung schliessen, damit
|
||||
// niemand die bereits freigegebene Ware im naechsten Moment doch noch
|
||||
// bezahlt, und erst danach zurueckbuchen.
|
||||
if (session.status === "open") {
|
||||
await stripe.checkout.sessions.expire(order.stripeCheckoutSessionId);
|
||||
}
|
||||
|
||||
// Uebrig bleiben "open" (gerade geschlossen) und "expired" — in beiden
|
||||
// Faellen kann nicht mehr bezahlt werden. Stripe schickt dazu ein
|
||||
// checkout.session.expired-Ereignis; der Webhook findet die Bestellung
|
||||
// dann nicht mehr und tut nichts weiter (siehe lib/order-release.ts).
|
||||
//
|
||||
// "delete": hier wurde nie bezahlt und nie etwas bestaetigt. Eine
|
||||
// stornierte Bestellung stehen zu lassen wuerde nur verwirren — im
|
||||
// Admin-Bereich saehe jeder weggeklickte Warenkorb aus wie ein echter,
|
||||
// gescheiterter Verkauf.
|
||||
const released = await releaseUnpaidOrder(order.id, "delete");
|
||||
if (released) {
|
||||
console.info(
|
||||
`[checkout-cancel] Bestellung ${released.orderNumber} abgebrochen, Bestand freigegeben und Vormerkung entfernt.`
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
`[checkout-cancel] Bestellung ${order.orderNumber} konnte nicht freigegeben werden:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Tests fuer die Auswertung der Rueckkehr von Stripe. Die Adresszeile ist frei
|
||||
* waehlbar — "bezahlt" darf deshalb nur herauskommen, wenn Stripe es sagt und
|
||||
* die Sitzung zu einer eigenen Bestellung gehoert.
|
||||
*/
|
||||
import { describe, test, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
const sessionsRetrieve = vi.fn();
|
||||
vi.mock("@/lib/stripe", () => ({
|
||||
getStripeClient: () => ({ checkout: { sessions: { retrieve: sessionsRetrieve } } }),
|
||||
}));
|
||||
|
||||
const rateLimit = vi.fn<(key: string, limit: number, windowMs: number) => boolean>(() => true);
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
rateLimit: (key: string, limit: number, windowMs: number) => rateLimit(key, limit, windowMs),
|
||||
getClientIp: vi.fn(async () => "203.0.113.7"),
|
||||
}));
|
||||
|
||||
const db = { order: { findFirst: vi.fn() } };
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: db }));
|
||||
|
||||
const { resolveCheckoutReturn } = await import("./checkout-return");
|
||||
|
||||
const SESSION_ID = "cs_test_a1B2c3";
|
||||
const ORDER = {
|
||||
id: "order_1",
|
||||
orderNumber: "WH-2026-AABBCCDD",
|
||||
status: "NEU",
|
||||
cancelToken: "a".repeat(64),
|
||||
};
|
||||
|
||||
function stripeSession(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: SESSION_ID,
|
||||
status: "complete",
|
||||
payment_status: "paid",
|
||||
client_secret: "cs_test_a1B2c3_secret_x",
|
||||
metadata: { orderId: ORDER.id },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
rateLimit.mockReturnValue(true);
|
||||
db.order.findFirst.mockResolvedValue(ORDER);
|
||||
sessionsRetrieve.mockResolvedValue(stripeSession());
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe("resolveCheckoutReturn", () => {
|
||||
test("meldet eine bezahlte Sitzung mit der Bestellnummer aus der Datenbank", async () => {
|
||||
await expect(resolveCheckoutReturn(SESSION_ID)).resolves.toEqual({
|
||||
kind: "paid",
|
||||
orderNumber: ORDER.orderNumber,
|
||||
});
|
||||
});
|
||||
|
||||
test("meldet eine abgeschlossene, noch nicht eingegangene Zahlung als in Bearbeitung", async () => {
|
||||
sessionsRetrieve.mockResolvedValue(stripeSession({ payment_status: "unpaid" }));
|
||||
await expect(resolveCheckoutReturn(SESSION_ID)).resolves.toEqual({
|
||||
kind: "processing",
|
||||
orderNumber: ORDER.orderNumber,
|
||||
});
|
||||
});
|
||||
|
||||
test("bietet nach abgebrochener PayPal-Zahlung dem bestellenden Browser einen erneuten Versuch an", async () => {
|
||||
sessionsRetrieve.mockResolvedValue(stripeSession({ status: "open", payment_status: "unpaid" }));
|
||||
await expect(resolveCheckoutReturn(SESSION_ID, ORDER.cancelToken)).resolves.toEqual({
|
||||
kind: "open",
|
||||
clientSecret: "cs_test_a1B2c3_secret_x",
|
||||
cancelToken: ORDER.cancelToken,
|
||||
});
|
||||
});
|
||||
|
||||
test("gibt Zahlungsformular und Abbruch-Token nicht an einen fremden Browser mit derselben URL", async () => {
|
||||
// Die session_id steht in Verlauf, Proxy-Logs und Screenshots. Allein
|
||||
// darf sie keine fremde Bestellung oeffnen oder abbrechen.
|
||||
sessionsRetrieve.mockResolvedValue(stripeSession({ status: "open", payment_status: "unpaid" }));
|
||||
await expect(resolveCheckoutReturn(SESSION_ID)).resolves.toEqual({ kind: "notCompleted" });
|
||||
await expect(resolveCheckoutReturn(SESSION_ID, "f".repeat(64))).resolves.toEqual({
|
||||
kind: "notCompleted",
|
||||
});
|
||||
});
|
||||
|
||||
test("fragt bei einer fremd geformten ID weder Datenbank noch Stripe", async () => {
|
||||
await expect(resolveCheckoutReturn('cs_test_1"; DROP')).resolves.toEqual({ kind: "notCompleted" });
|
||||
await expect(resolveCheckoutReturn("pi_123")).resolves.toEqual({ kind: "notCompleted" });
|
||||
expect(db.order.findFirst).not.toHaveBeenCalled();
|
||||
expect(sessionsRetrieve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("fragt Stripe gar nicht erst, wenn es keine passende Bestellung gibt", async () => {
|
||||
db.order.findFirst.mockResolvedValue(null);
|
||||
await expect(resolveCheckoutReturn(SESSION_ID)).resolves.toEqual({ kind: "notCompleted" });
|
||||
expect(sessionsRetrieve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("glaubt keiner Sitzung, die laut Metadaten zu einer anderen Bestellung gehoert", async () => {
|
||||
sessionsRetrieve.mockResolvedValue(stripeSession({ metadata: { orderId: "order_other" } }));
|
||||
await expect(resolveCheckoutReturn(SESSION_ID)).resolves.toEqual({ kind: "notCompleted" });
|
||||
});
|
||||
|
||||
test("bietet eine abgelaufene Sitzung nicht zum Bezahlen an", async () => {
|
||||
sessionsRetrieve.mockResolvedValue(stripeSession({ status: "expired", payment_status: "unpaid" }));
|
||||
await expect(resolveCheckoutReturn(SESSION_ID)).resolves.toEqual({ kind: "notCompleted" });
|
||||
});
|
||||
|
||||
test("bremst viele Abfragen derselben Adresse, bevor Stripe gefragt wird", async () => {
|
||||
rateLimit.mockReturnValue(false);
|
||||
await expect(resolveCheckoutReturn(SESSION_ID)).resolves.toEqual({ kind: "notCompleted" });
|
||||
expect(sessionsRetrieve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("vertraut bei einem Stripe-Ausfall dem vom Webhook bereits verbuchten Status", async () => {
|
||||
db.order.findFirst.mockResolvedValue({ ...ORDER, status: "BEZAHLT" });
|
||||
sessionsRetrieve.mockRejectedValue(new Error("ECONNRESET"));
|
||||
await expect(resolveCheckoutReturn(SESSION_ID)).resolves.toEqual({
|
||||
kind: "paid",
|
||||
orderNumber: ORDER.orderNumber,
|
||||
});
|
||||
});
|
||||
|
||||
test("behauptet bei einem Stripe-Ausfall und offener Bestellung keine Zahlung", async () => {
|
||||
sessionsRetrieve.mockRejectedValue(new Error("ECONNRESET"));
|
||||
await expect(resolveCheckoutReturn(SESSION_ID)).resolves.toEqual({ kind: "notCompleted" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getStripeClient } from "@/lib/stripe";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
|
||||
const RETURN_LOOKUPS_PER_WINDOW = 60;
|
||||
const RETURN_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
/** Stripe-Checkout-Sitzungs-IDs: "cs_test_..." bzw. "cs_live_...". */
|
||||
const SESSION_ID_PATTERN = /^cs_(test|live)_[A-Za-z0-9]{1,255}$/;
|
||||
|
||||
export type CheckoutReturn =
|
||||
/** Zahlung eingegangen (Karte, PayPal). */
|
||||
| { kind: "paid"; orderNumber: string }
|
||||
/** Abgeschlossen, Geld aber noch unterwegs (z.B. SEPA-Lastschrift). */
|
||||
| { kind: "processing"; orderNumber: string }
|
||||
/** Zahlung fehlgeschlagen oder bei PayPal abgebrochen — noch bezahlbar. */
|
||||
| { kind: "open"; clientSecret: string; cancelToken: string }
|
||||
/** Abgelaufen, abgebrochen, unbekannt oder nicht pruefbar. */
|
||||
| { kind: "notCompleted" };
|
||||
|
||||
const NOT_COMPLETED: CheckoutReturn = { kind: "notCompleted" };
|
||||
|
||||
function tokensMatch(a: string | undefined, b: string | null): boolean {
|
||||
if (!a || !b) return false;
|
||||
const left = Buffer.from(a);
|
||||
const right = Buffer.from(b);
|
||||
return left.length === right.length && timingSafeEqual(left, right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wertet die Rueckkehr von Stripe auf /checkout/success aus.
|
||||
*
|
||||
* Die URL beweist nichts: jeder kann ?session_id=... von Hand aufrufen. Ob
|
||||
* bezahlt wurde, entscheidet deshalb allein die Abfrage bei Stripe, und die
|
||||
* Bestellnummer kommt aus der eigenen Datenbank — nie aus der Adresszeile.
|
||||
* Die eigentliche Verbuchung (Rechnung, Mail, Status) bleibt beim Webhook;
|
||||
* diese Seite zeigt nur an.
|
||||
*
|
||||
* Unbekannte, fremde oder fehlerhafte IDs landen ununterscheidbar bei
|
||||
* "notCompleted", damit sich ueber diese Seite nichts ausforschen laesst.
|
||||
*
|
||||
* browserCancelToken ist der Inhalt des CHECKOUT_COOKIE: nur wenn er zur
|
||||
* Bestellung passt, wird ein erneuter Zahlungsversuch angeboten (siehe dort).
|
||||
*/
|
||||
export async function resolveCheckoutReturn(
|
||||
sessionId: string,
|
||||
browserCancelToken?: string
|
||||
): Promise<CheckoutReturn> {
|
||||
if (!SESSION_ID_PATTERN.test(sessionId)) return NOT_COMPLETED;
|
||||
|
||||
// Jeder Aufruf kostet eine Stripe-Abfrage — die Bremse haelt Durchprobieren
|
||||
// und versehentliche Neulade-Schleifen klein.
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`checkout-return:${ip}`, RETURN_LOOKUPS_PER_WINDOW, RETURN_WINDOW_MS)) {
|
||||
return NOT_COMPLETED;
|
||||
}
|
||||
|
||||
// Zuerst die eigene Datenbank: eine ID, zu der es keine Bestellung gibt,
|
||||
// loest gar nicht erst eine Anfrage bei Stripe aus.
|
||||
const order = await prisma.order.findFirst({
|
||||
where: { stripeCheckoutSessionId: sessionId },
|
||||
select: { id: true, orderNumber: true, status: true, cancelToken: true },
|
||||
});
|
||||
if (!order || order.status === "STORNIERT") return NOT_COMPLETED;
|
||||
|
||||
try {
|
||||
const session = await getStripeClient().checkout.sessions.retrieve(sessionId);
|
||||
|
||||
// Zweite Absicherung: die Sitzung muss zu genau dieser Bestellung gehoeren.
|
||||
if (session.metadata?.orderId !== order.id) {
|
||||
console.error(
|
||||
`[checkout-return] Sitzung ${sessionId} gehoert laut Metadaten nicht zu Bestellung ${order.orderNumber}.`
|
||||
);
|
||||
return NOT_COMPLETED;
|
||||
}
|
||||
|
||||
if (session.status === "complete") {
|
||||
const isPaid =
|
||||
session.payment_status === "paid" || session.payment_status === "no_payment_required";
|
||||
return { kind: isPaid ? "paid" : "processing", orderNumber: order.orderNumber };
|
||||
}
|
||||
|
||||
// Nur eine noch offene Bestellung, und nur fuer den Browser, der sie
|
||||
// angelegt hat, darf erneut bezahlt werden.
|
||||
if (
|
||||
session.status === "open" &&
|
||||
order.status === "NEU" &&
|
||||
session.client_secret &&
|
||||
order.cancelToken &&
|
||||
tokensMatch(browserCancelToken, order.cancelToken)
|
||||
) {
|
||||
return { kind: "open", clientSecret: session.client_secret, cancelToken: order.cancelToken };
|
||||
}
|
||||
|
||||
return NOT_COMPLETED;
|
||||
} catch (error: unknown) {
|
||||
console.error(`[checkout-return] Sitzung ${sessionId} konnte nicht abgefragt werden:`, error);
|
||||
// Hat der Webhook die Zahlung schon verbucht, ist das die verlaessliche
|
||||
// Auskunft — dann soll ein Stripe-Aussetzer die Freude nicht verderben.
|
||||
if (order.status !== "NEU") return { kind: "paid", orderNumber: order.orderNumber };
|
||||
return NOT_COMPLETED;
|
||||
}
|
||||
}
|
||||
@@ -30,3 +30,27 @@ export function getStripeClient(): Stripe {
|
||||
stripeClient = new Stripe(secretKey);
|
||||
return stripeClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Oeffentlicher Schluessel (pk_...) fuer das eingebettete Zahlungsformular.
|
||||
*
|
||||
* Bewusst ohne NEXT_PUBLIC_-Praefix und zur Laufzeit gelesen: Next.js brennt
|
||||
* NEXT_PUBLIC_-Variablen beim Build fest ein, das Docker-Image wird aber
|
||||
* einmal gebaut und erst im Cluster konfiguriert. Die Seite reicht den Wert
|
||||
* deshalb als Prop an den Browser weiter.
|
||||
*
|
||||
* Alles, was nicht wie ein veroeffentlichbarer Schluessel aussieht, gilt als
|
||||
* nicht gesetzt — vor allem ein versehentlich hier eingetragener geheimer
|
||||
* Schluessel (sk_/rk_), der sonst im HTML jeder Kassenseite stuende.
|
||||
*/
|
||||
export function getStripePublishableKey(): string | undefined {
|
||||
const key = process.env.STRIPE_PUBLISHABLE_KEY?.trim();
|
||||
if (!key) return undefined;
|
||||
if (!/^pk_(test|live)_[A-Za-z0-9]+$/.test(key)) {
|
||||
console.error(
|
||||
"STRIPE_PUBLISHABLE_KEY ist kein veroeffentlichbarer Schluessel (pk_test_/pk_live_) und wird ignoriert."
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
Reference in new issue
Block a user