Files
Wabenhain/src/app/api/checkout/route.ts
T
Nick SzaunigandClaude Opus 5 33580b3290 feat(security): Turnstile schuetzt jetzt alle oeffentlichen Eingabeformulare
Bisher waren nur Registrierung, Newsletter und Firmenanfrage mit einem
CAPTCHA versehen. Damit standen ausgerechnet die Formulare ungeschuetzt
offen, die am meisten hergeben:

- Login und Zwei-Faktor-Eingabe — Durchprobieren erbeuteter Zugangsdaten
  bzw. des sechsstelligen Codes.
- Passwort vergessen/zuruecksetzen — Mailversand an fremde Adressen und ein
  Weg herauszufinden, welche Adressen registriert sind.
- Checkout — automatisierte Bestellungen blockieren Lagerbestand und
  eroeffnen Stripe-Sitzungen, was die Zahlungsstrecke fuer Kartentests
  brauchbar macht.
- Verfuegbarkeits-Benachrichtigung — nimmt eine fremde E-Mail-Adresse
  entgegen und loest spaeter Versand an sie aus.

Die Pruefung sitzt jeweils VOR jeder weiteren Arbeit, damit automatisierte
Versuche Datenbank, Mailversand und Ratenlimit gar nicht erst belasten. Der
Checkout bekommt das Token ueber den JSON-Body statt ueber ein Formularfeld
— er schickt kein Formular ab.

Neu ist isTurnstileValid(formData, ip) als gemeinsame Stelle: haette eine
Seite den Feldnamen anders geschrieben, waere das Token leer angekommen —
und weil ein leeres Token bei nicht konfiguriertem Secret durchgeht, waere
der Schutz dort lautlos ausgefallen.

Genau deshalb ist die Turnstile-Pruefung in "pnpm preflight" jetzt ein
BLOCKER statt einer Warnung: fehlt der Schluessel, schlaegt die Pruefung
nicht fehl, sondern wird still uebersprungen (lib/turnstile.ts). Ohne diesen
Blocker koennte der Shop mit dem Eindruck live gehen, geschuetzt zu sein,
waehrend jedes Formular offensteht.

Bewusst NICHT geschuetzt ist die Sendungsverfolgung: sie ist ein
GET-Formular, dessen Ergebnis verlinkbar und neu ladbar bleiben soll — ein
Token in der Adresszeile waere nach dem ersten Aufruf verbraucht. Sie bleibt
rate-limitiert und verlangt Bestellnummer UND zugehoerige E-Mail-Adresse.
Angemeldete Bereiche (Adressen, Bewertungen, Wunschliste, Retouren) bleiben
ebenfalls ohne CAPTCHA: dort ist die Anmeldung die Huerde, ein zusaetzliches
Raetsel kostete nur Bedienbarkeit.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 19:07:12 +02:00

442 lines
19 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 } from "@/lib/shipping";
import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc";
import { ensureBulkDiscountCode } from "@/lib/bulk-discount";
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";
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 }
);
}
}
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();
let order;
try {
// 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.
order = await 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(),
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" }
);
} 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 success_url/cancel_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. Apple Pay/Google Pay sind bei dieser vollstaendig
// gehosteten Checkout-Seite (kein ui_mode: "embedded") laut Stripe-Doku
// automatisch enthalten, sobald Kartenzahlung aktiv ist — keine
// Codeaenderung und (anders als bei Elements/eingebetteter Checkout-Seite)
// keine Domain-Registrierung noetig, da checkout.stripe.com bereits
// Stripes eigene, verifizierte Domain ist.
const session = await stripe.checkout.sessions.create({
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 } : {}),
success_url: `${origin}/checkout/success?order=${order.orderNumber}`,
cancel_url: `${origin}/checkout/cancel`,
metadata: { orderId: order.id, orderNumber: order.orderNumber },
});
// Best-effort: stripeCheckoutSessionId wird aktuell von keinem Lesepfad
// ausgewertet (fulfillOrder im Webhook identifiziert die Bestellung ueber
// session.metadata.orderId, nicht ueber dieses Feld) — schlaegt nur
// dieser Schreibzugriff fehl, waere es falsch, die bereits erstellte,
// echte und bezahlbare Stripe-Session deswegen zu verwerfen und der
// Kundschaft einen Fehler zu zeigen (Risiko: Retry legt eine zweite
// Bestellung samt zweiter Bestandsreservierung an).
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} bleibt trotzdem gueltig):`,
error
);
}
return NextResponse.json({ url: session.url });
} catch (error: unknown) {
// Bestellung bleibt als "NEU" erhalten, damit sie im Admin-Bereich manuell
// nachbearbeitet werden kann, auch wenn Stripe (noch) nicht konfiguriert ist.
// 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);
return NextResponse.json(
{
error:
"Die Zahlungsabwicklung ist aktuell nicht verfügbar. Deine Bestellung wurde vorgemerkt, wir melden uns bei dir.",
orderNumber: order.orderNumber,
},
{ status: 502 }
);
}
}