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]>
686 lines
31 KiB
TypeScript
686 lines
31 KiB
TypeScript
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<T>(run: () => Promise<T>): Promise<T> {
|
|
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 }
|
|
);
|
|
}
|
|
}
|