Vollstaendige Next.js/Prisma-E-Commerce-Anwendung fuer eine Imkerei: Shop, Warenkorb, Stripe-Checkout, Kundenkonto mit 2FA, Admin-Bereich (Produkte, Ernten, Chargen, Bestellungen, Gutscheine, admin-editierbare Website-Inhalte), i18n (DE/EN), Rate-Limiting/CSP/Audit-Log-Haertung, PostgreSQL- und MySQL-Unterstuetzung ueber austauschbare Prisma- Treiber-Adapter, Unit-/Komponenten-/E2E-Tests.
294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
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 }
|
|
);
|
|
}
|
|
}
|