import { NextResponse } from "next/server"; import { randomBytes } from "node:crypto"; import { prisma } from "@/lib/prisma"; 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 { getShippingCostCents } from "@/lib/shipping"; import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc"; import { ensureBulkDiscountCode } from "@/lib/bulk-discount"; import { getBaseUrl } from "@/lib/url"; import { getLocale } from "@/i18n/locale"; class OutOfStockError extends Error {} 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) { 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); 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 } ); } 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, }; }); 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); } } 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, subtotalCents, shippingCents, discountCents, discountCodeId: discountCode?.id, totalCents, giftMessage: input.giftMessage || null, items: { create: orderItemsData, }, }, }); }); } 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 } ); } 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 }]; } 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 }, }); await prisma.order.update({ where: { id: order.id }, data: { stripeCheckoutSessionId: session.id }, }); 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 } ); } }