import { NextResponse } from "next/server"; import { randomBytes } from "node:crypto"; import { prisma } from "@/lib/prisma"; import { Prisma } from "@/generated/prisma/client"; import { getStripeClient } from "@/lib/stripe"; import { checkoutSchema } from "@/lib/validation"; import { getProductsByIds } from "@/lib/data"; import { getCurrentCustomer } from "@/lib/auth"; import { rateLimit, getClientIp } from "@/lib/rate-limit"; import { isTrustedOrigin } from "@/lib/csrf"; import { verifyTurnstileToken } from "@/lib/turnstile"; import { getShippingCostCents, getShippingRegion } from "@/lib/shipping"; import { findCustomsExcludedArea, isCountryAllowed } from "@/lib/shipping-region"; import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc"; import { ensureBulkDiscountCode } from "@/lib/bulk-discount"; 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 { vatRateForProduct } from "@/lib/vat"; import { getLocale } from "@/i18n/locale"; class OutOfStockError extends Error {} // Der fruehe pendingUses-Check weiter unten liest ausserhalb jeder Transaktion // — zwei zeitgleiche Checkouts mit demselben maxUses:1-Code koennten ihn // beide bestehen, bevor eine der beiden Bestellungen tatsaechlich angelegt // ist (TOCTOU). Dieser Fehlertyp markiert den verbindlichen Re-Check direkt // vor dem Anlegen der Bestellung (s.u., innerhalb der Serializable-Transaktion). class DiscountCodeExhaustedError extends Error {} // Postgres/MySQL brechen bei "Serializable"-Isolation eine von zwei // widerspruechlichen, gleichzeitigen Transaktionen mit einem // Serialisierungsfehler ab, statt still beide durchzulassen — das ist der // eigentliche Schutz gegen die Race-Condition, der explizite Re-Check oben // ist nur die freundlichere Fehlermeldung fuer den haeufigeren Fall. const SERIALIZATION_CONFLICT_CODE = "P2034"; /** * Wie oft eine an einem Serialisierungskonflikt gescheiterte Bestellanlage * wiederholt wird, bevor die Kundschaft einen Fehler sieht. * * Ein solcher Konflikt ist KEIN Fehler, sondern die vorgesehene Antwort der * Datenbank auf zwei gleichzeitige Transaktionen, die sich widersprechen * koennten — bei "Serializable" bricht sie eine davon ab, damit nicht beide * auf veralteten Staenden weiterrechnen. Erwartet wird, dass der Aufrufer es * einfach noch einmal versucht. * * Genau das fehlte: der Abbruch wurde direkt als 409 an die Kundschaft * durchgereicht ("bitte in ein paar Sekunden erneut versuchen"). Bei wenigen * Bestellungen faellt das nie auf, bei 100 gleichzeitigen schon — alle * greifen auf dieselbe Lagerbestandszeile zu, Konflikte sind dann der * Normalfall und nicht die Ausnahme. Ein Teil der Kundschaft bekaeme * ausgerechnet im Moment der Kaufentscheidung eine Fehlermeldung. * * Vier Versuche, weil jeder die Transaktion vollstaendig neu ausfuehrt * (inklusive frischer Bestandspruefung) und viermal hintereinander zu * verlieren unwahrscheinlich ist. Bleibt es dabei, ist der Andrang so gross, * dass "gleich nochmal" die ehrliche Antwort ist. */ const SERIALIZATION_MAX_ATTEMPTS = 4; /** * Wartezeit zwischen zwei Versuchen, mit Zufallsanteil. * * Ohne den Zufallsanteil starten alle gleichzeitig abgebrochenen Anfragen * ihren naechsten Versuch im selben Moment und kollidieren erneut — dasselbe * Problem, nur verschoben. */ function serializationBackoffMs(attempt: number): number { const base = 25 * 2 ** (attempt - 1); return base + Math.random() * base; } /** * Lebensdauer der Stripe-Sitzung. Stripe laesst zwischen 30 Minuten und 24 * Stunden zu und nimmt ohne Angabe die 24 Stunden. * * Das ist hier die falsche Voreinstellung: die Bestellung reserviert beim * Anlegen Lagerbestand, und erst wenn Stripe die Sitzung fuer abgelaufen * erklaert, kommt dieser Bestand zurueck (checkout.session.expired, siehe * api/checkout/webhook). Mit 24 Stunden haelt jeder nicht zu Ende gefuehrte * Checkout Ware einen Tag lang fest — und wer das absichtlich tut, kann den * Shop mit wenigen Klicks leerraeumen, ohne einen Cent zu zahlen. * * 32 statt 30 Minuten wegen der Untergrenze: zwischen dieser Zeile und dem * Eintreffen der Anfrage bei Stripe vergehen Sekundenbruchteile, und die * Uhren muessen nicht exakt gleich gehen. Genau 30 Minuten koennten deshalb * als "weniger als 30" ankommen und die Sitzungserstellung scheitern lassen. */ const CHECKOUT_SESSION_TTL_MINUTES = 32; /** * Fuehrt die Bestellanlage aus und wiederholt sie, wenn die Datenbank sie * wegen eines Serialisierungskonflikts abgebrochen hat. * * Wiederholt wird AUSSCHLIESSLICH dieser eine Fehlerfall. "Ausverkauft" und * "Gutschein aufgebraucht" sind Sachentscheidungen — sie faenden beim zweiten * Versuch dasselbe Ergebnis vor, und eine Wiederholung wuerde die Antwort nur * verzoegern. */ async function withSerializationRetry(run: () => Promise): Promise { for (let attempt = 1; ; attempt++) { try { return await run(); } catch (error: unknown) { const isConflict = error instanceof Prisma.PrismaClientKnownRequestError && error.code === SERIALIZATION_CONFLICT_CODE; if (!isConflict || attempt >= SERIALIZATION_MAX_ATTEMPTS) throw error; // Auf "info" statt "error": das ist der vorgesehene Ablauf, kein // Stoerfall. Sichtbar bleibt es trotzdem, weil eine Haeufung hier das // erste Anzeichen dafuer waere, dass der Andrang die Datenbank an ihre // Grenze bringt. console.info( `[checkout] Serialisierungskonflikt bei der Bestellanlage (Versuch ${attempt} von ${SERIALIZATION_MAX_ATTEMPTS}), wird wiederholt.` ); await new Promise((resolve) => setTimeout(resolve, serializationBackoffMs(attempt))); } } } function generateOrderNumber(): string { const year = new Date().getFullYear(); // Kryptografisch zufaellig statt Math.random(): nicht erratbar und // praktisch kollisionsfrei (das @unique-Constraint faengt den Rest ab). const random = randomBytes(4).toString("hex").toUpperCase(); return `WH-${year}-${random}`; } export async function POST(request: Request) { // Route-Handler haben (anders als Server Actions) keinen eingebauten // CSRF-Schutz — siehe lib/csrf.ts. Ohne diese Pruefung koennte eine fremde // Seite per gefaelschtem Cross-Site-POST Bestellungen unter dem Konto // einer eingeloggten Kundschaft anlegen und Lagerbestand reservieren. if (!isTrustedOrigin(request)) { return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 403 }); } const ip = await getClientIp(); if (!rateLimit(`checkout:${ip}`, 10, 60 * 60 * 1000)) { return NextResponse.json( { error: "Zu viele Bestellversuche. Bitte versuche es später erneut." }, { status: 429 } ); } const body = await request.json().catch(() => null); // CAPTCHA vor allem Weiteren: eine Bestellung legt Datensaetze an, // reserviert Lagerbestand und eroeffnet eine Stripe-Sitzung. Automatisiert // abgeschickt liesse sich damit der Bestand blockieren und die // Zahlungsstrecke fuer Kartentests missbrauchen. Das Token kommt hier aus // dem JSON-Body, weil der Checkout kein Formular abschickt. const turnstileToken = body && typeof body === "object" && "turnstileToken" in body ? String((body as { turnstileToken?: unknown }).turnstileToken ?? "") : ""; if (!(await verifyTurnstileToken(turnstileToken, ip))) { return NextResponse.json( { error: "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte lade die Seite neu und versuche es erneut." }, { status: 400 } ); } const parsed = checkoutSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( { error: "Ungültige Eingabe.", issues: parsed.error.flatten() }, { status: 400 } ); } const input = parsed.data; if (input.fulfillmentMethod === "VERSAND") { if (!input.street || !input.zip || !input.city) { return NextResponse.json( { error: "Bitte gib eine vollständige Lieferadresse an." }, { status: 400 } ); } // Verbindliche Pruefung des Liefergebiets. Die Laenderauswahl im Formular // bietet ohnehin nur zulaessige Laender an — das ist Bequemlichkeit, kein // Schutz: dieser Endpunkt nimmt JSON entgegen und laesst sich ohne // Formular aufrufen. Ohne die Pruefung hier entstuende eine Bestellung // mit einer Lieferadresse, fuer die weder Versandweg noch // Umsatzsteuerpflicht geklaert sind — und bezahlt waere sie dann auch // schon. const region = await getShippingRegion(); if (!isCountryAllowed(region, input.country)) { return NextResponse.json( { error: region === "DE" ? "Wir versenden derzeit ausschließlich innerhalb Deutschlands. Bitte gib eine Lieferadresse in Deutschland an oder wähle die Abholung." : "Wir versenden derzeit ausschließlich innerhalb der Europäischen Union. Bitte gib eine Lieferadresse in der EU an oder wähle die Abholung.", }, { status: 400 } ); } // Der Laendercode allein genuegt nicht: einige Gebiete gehoeren zu einem // EU-Mitgliedstaat, liegen aber ausserhalb des Zoll- und/oder // Steuergebiets der Union. Dorthin ist jede Sendung eine Ausfuhr mit // Zollinhaltserklaerung — zwei davon liegen in Deutschland (Helgoland und // Buesingen am Hochrhein), sind also selbst bei "Nur Deutschland" // betroffen. Erkennbar sind sie nur an der Postleitzahl. const excludedArea = findCustomsExcludedArea(input.country, input.zip); if (excludedArea) { return NextResponse.json( { error: `Nach ${excludedArea} können wir leider nicht versenden: dorthin ist jede Sendung zollpflichtig. Bitte gib eine andere Lieferadresse an oder wähle die Abholung.`, }, { status: 400 } ); } } const productIds = input.items.map((item) => item.productId); const products = await getProductsByIds(productIds); if (products.length !== productIds.length) { return NextResponse.json( { error: "Ein Produkt im Warenkorb ist nicht mehr verfügbar." }, { status: 400 } ); } // Ein im Admin deaktiviertes Produkt (isActive = false) darf nicht mehr // verkauft werden. getProductsByIds filtert bewusst nicht danach, weil es // auch fuer bereits bestellte, inzwischen deaktivierte Produkte genutzt // wird (siehe lib/data.ts) — die Pruefung gehoert deshalb hierher. // // Die Warenkorb-Oberflaeche zeigt ein solches Produkt ueber // /api/shop/cart-stock zwar bereits mit Bestand 0 an, aber das ist nur der // Client. Ein Warenkorb, der im Browser lag, bevor das Produkt deaktiviert // wurde, kam bis hierher ungeprueft durch: die Bestellung wurde angelegt // und tatsaechlich bezahlt, obwohl die Imkerei den Artikel gerade aus dem // Verkauf genommen hatte. if (products.some((product) => !product.isActive)) { return NextResponse.json( { error: "Ein Produkt im Warenkorb ist nicht mehr verfügbar." }, { status: 400 } ); } // Preis 0 ist kein Geschenk, sondern ein noch nicht gepflegter Preis. Aus // ApiCore importierte Produkte kommen genau so an (siehe // admin/actions/apicore-sync.ts: ApiCore fuehrt keine Verkaufspreise), und // auch ein manuell angelegtes Produkt kann versehentlich ohne Preis aktiv // geschaltet werden. Die Aktivierung im Admin weist das bereits ab; diese // Pruefung ist die zweite Haelfte davon und faengt Altbestaende sowie // direkte Datenbank-Aenderungen ab, die nie durch das Admin-Formular // gelaufen sind. Sonst waere die Bestellung tatsaechlich fuer 0 Euro // abgeschlossen worden. if (products.some((product) => product.priceCents <= 0)) { return NextResponse.json( { error: "Ein Produkt im Warenkorb ist nicht mehr verfügbar." }, { status: 400 } ); } const orderItemsData = input.items.map((cartItem) => { const product = products.find((p) => p.id === cartItem.productId)!; return { productId: product.id, batchId: product.batchId, productName: product.name, unitPriceCents: product.priceCents, quantity: cartItem.quantity, weightGrams: product.weightGrams, totalCents: product.priceCents * cartItem.quantity, // Teil des Snapshots wie der Preis: der Satz haengt an der Art der Ware // (Honig ermaessigt, Kerzen voll). Wird ein Produkt spaeter // umkategorisiert, darf das diese Bestellung nicht mehr veraendern. vatRatePercent: vatRateForProduct(product, process.env), }; }); const subtotalCents = orderItemsData.reduce((sum, item) => sum + item.totalCents, 0); // Versandkosten richten sich nach der admin-gepflegten Mengenstaffel und // der Gesamtmenge ueber alle Positionen hinweg (mehrere Artikel werden zu // einer Menge zusammengefasst, nicht separat berechnet) — kein // wertbasierter Freibetrag mehr. const totalQuantity = orderItemsData.reduce((sum, item) => sum + item.quantity, 0); const shippingCents = input.fulfillmentMethod === "ABHOLUNG" ? 0 : await getShippingCostCents(totalQuantity); // Gutscheincode nur auf den Warenwert anwenden, nicht auf den Versand — // haelt die Rabattlogik einfach nachvollziehbar. let discountCents = 0; let discountCode: { id: string; code: string } | null = null; if (input.discountCode) { const now = new Date(); const code = await prisma.discountCode.findUnique({ where: { code: input.discountCode.trim().toUpperCase() }, }); if (!code || !isDiscountCodeValid(code, subtotalCents, now, totalQuantity)) { return NextResponse.json( { error: "Der Gutscheincode ist ungültig, abgelaufen oder nicht mehr einlösbar." }, { status: 400 } ); } // usedCount allein reicht bei einem Nutzungslimit nicht: er wird erst bei // bestaetigter Zahlung erhoeht (s.u.), zaehlt also nicht bereits laufende, // noch unbezahlte Checkouts mit demselben Code mit. Ohne diese Pruefung // koennten mehrere parallele Checkout-Versuche (z.B. mehrere Tabs) einen // maxUses:1-Code alle gleichzeitig als gueltig durchgehen und am Ende // mehrfach tatsaechlich bezahlt einloesen. if (code.maxUses !== null) { const pendingUses = await prisma.order.count({ where: { discountCodeId: code.id, status: "NEU" }, }); if (code.usedCount + pendingUses >= code.maxUses) { return NextResponse.json( { error: "Der Gutscheincode ist ungültig, abgelaufen oder nicht mehr einlösbar." }, { status: 400 } ); } } discountCents = calculateDiscountCents(code, subtotalCents); discountCode = code; } else { // Kein manuell eingegebener Code — unter allen im Admin-Bereich // gepflegten Mengenrabatten (minQuantity gesetzt) den best passenden // waehlen (hoechste erreichte Schwelle). Keine Nutzungsgrenze noetig, // daher keine pendingUses-Pruefung wie beim manuellen Code oben. await ensureBulkDiscountCode(); const candidates = await prisma.discountCode.findMany({ where: { minQuantity: { not: null } } }); const now = new Date(); const code = candidates .filter((candidate) => isDiscountCodeValid(candidate, subtotalCents, now, totalQuantity)) .sort((a, b) => (b.minQuantity ?? 0) - (a.minQuantity ?? 0))[0]; if (code) { discountCents = calculateDiscountCents(code, subtotalCents); discountCode = code; } } // usedCount wird bewusst erst im Webhook bei bestaetigter Zahlung erhoeht, // nicht hier — sonst wuerde ein abgebrochener Stripe-Checkout einen // Gutschein-Nutzungsslot verbrauchen, ohne dass je bezahlt wurde. const totalCents = subtotalCents - discountCents + shippingCents; const customer = await getCurrentCustomer(); // Gastbestellungen (kein Konto) sind davon unberuehrt — die Pflicht gilt // nur fuer registrierte, eingeloggte Kund:innen. if (customer && !customer.emailVerifiedAt) { return NextResponse.json( { error: "Bitte bestätige zuerst deine E-Mail-Adresse (Link auf deiner Kontoseite erneut anfordern), bevor du mit deinem Konto bestellst.", }, { status: 403 } ); } const locale = await getLocale(); // Geht ausschliesslich in der Antwort an den Browser, der diese Bestellung // gerade anlegt, und gibt den reservierten Bestand frei, sobald die // Kundschaft den Zahlungsschritt verlaesst (siehe api/checkout/cancel). // 32 Byte, damit er nicht durchprobiert werden kann — mit einem ratbaren // 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"); // Bestellanlage und Bestandsreservierung atomar: das bedingte updateMany // (quantity >= bestellt) verhindert Ueberverkauf auch bei gleichzeitigen // Bestellungen — schlaegt eine Position fehl, wird die gesamte Transaktion // (inkl. bereits reservierter Positionen) verworfen. // // Als eigene Funktion, damit sie bei einem Serialisierungskonflikt // vollstaendig wiederholt werden kann (siehe SERIALIZATION_MAX_ATTEMPTS). // Jeder Versuch liest den Bestand frisch — eine Wiederholung kann also // durchaus zu "ausverkauft" fuehren, und das ist dann auch richtig so. const runOrderTransaction = () => prisma.$transaction( async (tx) => { for (const item of orderItemsData) { const reserved = await tx.inventory.updateMany({ where: { productId: item.productId, quantity: { gte: item.quantity } }, data: { quantity: { decrement: item.quantity } }, }); if (reserved.count === 0) { throw new OutOfStockError(item.productName); } } // Verbindlicher Re-Check direkt vor dem Anlegen der Bestellung: der // fruehe pendingUses-Check oben ist nur ein schneller Vorabfilter // (bessere Fehlermeldung fuer den Normalfall), dieser hier — innerhalb // derselben Serializable-Transaktion wie die Bestellanlage, mit einem // frischen Datenbank-Stand statt der aeusseren (potenziell leicht // veralteten) Momentaufnahme — ist der tatsaechlich verbindliche // Schutz gegen zwei gleichzeitige Checkouts mit demselben knapp // begrenzten Code. if (discountCode) { const freshCode = await tx.discountCode.findUnique({ where: { id: discountCode.id } }); if (freshCode && freshCode.maxUses !== null) { const pendingUses = await tx.order.count({ where: { discountCodeId: freshCode.id, status: "NEU" }, }); if (freshCode.usedCount + pendingUses >= freshCode.maxUses) { throw new DiscountCodeExhaustedError(); } } } return tx.order.create({ data: { orderNumber: generateOrderNumber(), cancelToken, customerId: customer?.id, locale, fulfillmentMethod: input.fulfillmentMethod, email: input.email, firstName: input.firstName, lastName: input.lastName, phone: input.phone, street: input.street ?? "", zip: input.zip ?? "", city: input.city ?? "", country: input.country, // Bei Packstation-Lieferung tragen street/zip/city zusaetzlich die // lesbare Anschrift der Packstation (siehe checkout-form.tsx), // damit Rechnung, E-Mails und Admin-Ansicht unveraendert etwas // Sinnvolles zeigen. Diese beiden Felder sind das, was DHL // technisch braucht. packstationNumber: input.packstationNumber || null, postNumber: input.postNumber || null, subtotalCents, shippingCents, discountCents, discountCodeId: discountCode?.id, totalCents, giftMessage: input.giftMessage || null, items: { create: orderItemsData, }, }, }); }, // Serializable statt der DB-Voreinstellung ("Read Committed"): erst // dadurch erkennt die Datenbank selbst den Konflikt zwischen zwei // gleichzeitigen Transaktionen, die beide denselben knapp begrenzten // Gutscheincode einloesen wollen, und bricht eine davon zuverlaessig ab // (statt beide auf Basis veralteter Zaehlerstaende durchzulassen). { isolationLevel: "Serializable", // Prisma gibt einer Transaktion voreingestellt 2 Sekunden, um // ueberhaupt eine Verbindung zu bekommen, und 5 Sekunden zum // Ausfuehren. Bei starkem Andrang sind alle Verbindungen belegt und // eine Bestellung scheitert dann nicht an der Sache, sondern am // Warten — mitten in der Kaufentscheidung. Die Transaktion selbst // bleibt kurz; die groesszuegigen Werte sind Puffer fuer die // Warteschlange, keine Einladung, lange zu rechnen. maxWait: 10_000, timeout: 15_000, } ); let order; try { order = await withSerializationRetry(runOrderTransaction); } catch (error: unknown) { if (error instanceof OutOfStockError) { return NextResponse.json( { error: `„${error.message}“ ist leider nicht mehr in der gewünschten Menge verfügbar. Bitte passe deinen Warenkorb an.`, }, { status: 409 } ); } if (error instanceof DiscountCodeExhaustedError) { return NextResponse.json( { error: "Der Gutscheincode ist ungültig, abgelaufen oder nicht mehr einlösbar." }, { status: 409 } ); } if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === SERIALIZATION_CONFLICT_CODE) { return NextResponse.json( { error: "Kurzzeitig zu viele gleichzeitige Bestellungen. Bitte versuche es in ein paar Sekunden erneut." }, { status: 409 } ); } throw error; } try { const stripe = getStripeClient(); // Bewusst NICHT aus dem Origin-Request-Header: der ist vom Aufrufer frei // waehlbar (z.B. direkter API-Aufruf statt ueber den Browser) und wuerde // sich sonst in die return_url der Stripe-Session einschleusen — // ein offenes Redirect direkt nach einer echten Zahlung auf Stripes // eigener Checkout-Seite waere hochgradig phishing-tauglich. const origin = getBaseUrl(); // Stripe erlaubt keine negativen Preise in line_items — ein Rabatt wird // stattdessen als einmaliger Coupon auf die Session angewendet, damit // Produktpositionen weiterhin ihre echten Einzelpreise zeigen. let discounts: { coupon: string }[] | undefined; if (discountCents > 0) { const coupon = await stripe.coupons.create({ amount_off: discountCents, currency: "eur", duration: "once", name: input.discountCode?.trim().toUpperCase() ?? discountCode?.code, }); discounts = [{ coupon: coupon.id }]; } // Kein payment_method_types (s. README "Zahlungsmethoden"): Stripe zeigt // dadurch dynamisch alle im Dashboard aktivierten, berechtigten // Zahlungsmethoden — PayPal eingeschlossen, sobald es dort aktiv ist. // // "embedded_page": das Zahlungsformular erscheint als Stripe-iframe // direkt auf /checkout statt auf checkout.stripe.com. Kartendaten // beruehren diesen Server trotzdem nie (PCI SAQ A bleibt). Apple Pay und // Google Pay braeuchten dafuer eine Domain-Registrierung im Dashboard; // ohne sie erscheinen sie schlicht nicht. const session = await stripe.checkout.sessions.create({ ui_mode: "embedded_page", mode: "payment", customer_email: input.email, line_items: [ ...orderItemsData.map((item) => ({ quantity: item.quantity, price_data: { currency: "eur", unit_amount: item.unitPriceCents, product_data: { name: `${item.productName} (${item.weightGrams} g)` }, }, })), ...(shippingCents > 0 ? [ { quantity: 1, price_data: { currency: "eur", unit_amount: shippingCents, product_data: { name: "Versand" }, }, }, ] : []), ], ...(discounts ? { discounts } : {}), // Ohne diese Angabe laeuft die Sitzung erst nach 24 Stunden ab und // haelt so lange Lagerbestand fest (siehe CHECKOUT_SESSION_TTL_MINUTES). expires_at: Math.floor(Date.now() / 1000) + CHECKOUT_SESSION_TTL_MINUTES * 60, // Jede Zahlungsart endet hier — Karte ebenso wie PayPal, das die // Kundschaft zwangslaeufig zu PayPal und zurueck schickt. Ein einziger // Rueckweg statt zweier (onComplete im Browser vs. Weiterleitung) heisst // auch: nur eine Stelle entscheidet ueber "bezahlt", und die fragt // 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 }, }); // 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 // Bestellung auf keinem Weg mehr aufloesen. Der Abbruch-Klick // (checkout/cancel) braucht sie, um bei Stripe nachzusehen, und der // Aufraeumlauf (scripts/release-stale-orders.ts) findet ohne sie keine // Sitzung, deren Ablauf er pruefen koennte. Die Bestellung bliebe als // "NEU" stehen und haette den reservierten Lagerbestand dauerhaft // blockiert — unbemerkt, weil sie im Admin-Bereich wie jede andere // offene Bestellung aussieht. // // Schlaegt das Speichern fehl, wird deshalb die gerade erzeugte Sitzung // sofort wieder geschlossen und die Bestellung aufgeloest (aeusseres // catch). Dass dabei eine bezahlbare Sitzung verlorengeht, ist folgenlos: // ihr client_secret steht ausschliesslich in der Antwort, die wir // daraufhin NICHT ausliefern — niemand ausser diesem Prozess hat ihn je // gesehen. try { await prisma.order.update({ where: { id: order.id }, data: { stripeCheckoutSessionId: session.id }, }); } catch (error: unknown) { console.error( `Speichern von stripeCheckoutSessionId fuer Bestellung ${order.orderNumber} fehlgeschlagen — Session ${session.id} wird geschlossen und die Bestellung aufgeloest:`, error ); // Eigenes try/catch: scheitert auch das Schliessen, bleibt der // urspruengliche Fehler der wichtigere. Die Sitzung laeuft dann // spaetestens nach CHECKOUT_SESSION_TTL_MINUTES von selbst ab. try { await stripe.checkout.sessions.expire(session.id); } catch (expireError: unknown) { console.error(`Schliessen der Session ${session.id} fehlgeschlagen:`, expireError); } throw error; } // 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) { // Die Stripe-Fehlermeldung nur serverseitig loggen, nicht an den Client // zurueckgeben — sie kann interne Konfigurations-/Kontodetails enthalten // und dieser Endpunkt ist ohne Authentifizierung erreichbar. const message = error instanceof Error ? error.message : "Unbekannter Fehler"; console.error(`Checkout-Session fuer Bestellung ${order.orderNumber} fehlgeschlagen:`, message); // Diese Bestellung ist nie bezahlbar geworden: es gibt keine Sitzung, zu // der die Kundschaft geschickt werden koennte, und damit auch kein // Ereignis von Stripe, das den reservierten Bestand je wieder freigeben // wuerde. Er muss deshalb hier zurueck — sonst fehlt die Ware dauerhaft // im Bestand, ohne dass irgendwo ein Fehler sichtbar waere. // // "cancel" statt "delete": die Bestellung bleibt als "STORNIERT" mit // Notiz stehen. Anders als ein weggeklickter Warenkorb ist das ein // Vorgang, dem die Imkerei nachgehen will — hier wollte jemand kaufen // und ist an der Zahlungsanbindung gescheitert. try { const released = await releaseUnpaidOrder(order.id, "cancel"); if (released) { await prisma.order.update({ where: { id: order.id }, data: { notes: "Automatisch storniert: Die Stripe-Zahlungssitzung konnte nicht erstellt werden, die Bestellung war nie bezahlbar. Der Lagerbestand wurde zurueckgebucht.", }, }); } } catch (releaseError: unknown) { // Laut, aber nicht weiterwerfen: die Kundschaft bekommt unten ohnehin // eine Fehlermeldung, und ein zusaetzlicher 500er wuerde nur die // Ursache verdecken. Der Aufraeumlauf greift als zweites Netz. console.error( `Bestellung ${order.orderNumber} konnte nach dem Stripe-Fehler nicht aufgeloest werden — Lagerbestand bleibt vorerst reserviert:`, releaseError ); } // Bewusst OHNE orderNumber: frueher schickte die Oberflaeche daraufhin auf // die Erfolgsseite ("wir melden uns"). Das war eine Zusage, die niemand // einloesen konnte — es gab keine Sitzung, keinen Zahlungsweg und keine // Moeglichkeit, die Bestellung nachtraeglich zu bezahlen. return NextResponse.json( { error: "Die Zahlung konnte nicht gestartet werden — es wurde nichts abgebucht. Bitte versuche es in ein paar Minuten erneut oder melde dich bei uns.", }, { status: 502 } ); } }