import { NextResponse } from "next/server"; import { z } from "zod"; import { getCurrentCustomer } from "@/lib/auth"; import { rateLimit, getClientIp } from "@/lib/rate-limit"; import { isTrustedOrigin } from "@/lib/csrf"; import { getLocale } from "@/i18n/locale"; import { locales, type Locale } from "@/i18n/config"; import { personalizationSchema, toIsoDate } from "@/lib/certificate/schema"; import { checkCertificateFits } from "@/lib/certificate/layout"; import { getOfferBySlug } from "@/lib/sponsorship/offers"; import { createSubscriptionCheckout } from "@/lib/sponsorship-subscription/checkout"; import { preparePendingCheckout } from "@/lib/sponsorship-subscription/pending"; /** * Abschluss eines Patenschafts-Abos: prueft Konto, Personalisierung und * Angebot, legt das Abo an und oeffnet eine eingebettete Stripe-Sitzung im * Modus "subscription". Antwort: { clientSecret } fuer das Zahlungsformular. * * Abo und Einmalkauf werden getrennt bezahlt (Spec "Abo-Patenschaften") — * dieser Weg beruehrt den Warenkorb nicht. */ const ATTEMPTS_PER_WINDOW = 10; const WINDOW_MS = 60 * 60 * 1000; const bodySchema = z.object({ offerSlug: z.string().trim().min(1).max(100), personalization: z.record(z.string(), z.unknown()), locale: z.enum(locales).optional(), }); function error(message: string, status: number, extra: Record = {}): NextResponse { return NextResponse.json({ error: message, ...extra }, { status, headers: { "Cache-Control": "no-store" } }); } export async function POST(request: Request) { // Route-Handler haben keinen eingebauten CSRF-Schutz (lib/csrf.ts): ohne // diese Pruefung koennte eine fremde Seite im Namen eines angemeldeten // Kontos Abos anstossen. if (!isTrustedOrigin(request)) return error("Ungültige Anfrage.", 403); const ip = await getClientIp(); if (!rateLimit(`abo-checkout:${ip}`, ATTEMPTS_PER_WINDOW, WINDOW_MS)) { return error("Zu viele Versuche. Bitte versuche es später erneut.", 429); } const customer = await getCurrentCustomer(); if (!customer) { return error("Für eine Bienenpatenschaft brauchst du ein Kundenkonto. Bitte melde dich an.", 401); } if (!customer.emailVerifiedAt) { return error( "Bitte bestätige zuerst deine E-Mail-Adresse (Link auf deiner Kontoseite erneut anfordern), bevor du mit deinem Konto bestellst.", 403 ); } const parsedBody = bodySchema.safeParse(await request.json().catch(() => null)); if (!parsedBody.success) return error("Ungültige Eingabe.", 400); const locale: Locale = parsedBody.data.locale ?? (await getLocale()); // Ein Abo beginnt mit der ersten Zahlung (Spec "Personalisierung"): das // Startdatum aus dem Formular zaehlt nicht, es wird bei der ersten // bezahlten Rechnung durch deren Tag ersetzt. const today = toIsoDate(new Date()); const personalization = personalizationSchema.safeParse({ ...parsedBody.data.personalization, startDate: today }); if (!personalization.success) { const issue = personalization.error.issues[0]; return error("Die Personalisierung ist ungültig.", 400, { field: typeof issue?.path[0] === "string" ? issue.path[0] : null, code: issue?.message ?? "invalid", }); } const fit = checkCertificateFits(personalization.data, locale, { term: { kind: "OPEN", start: today } }); if (!fit.ok) { return error(fit.message, 400, { field: fit.field, code: fit.reason }); } const offer = await getOfferBySlug(parsedBody.data.offerSlug, locale); if (!offer || offer.kind !== "ABO") return error("Dieses Abo gibt es nicht.", 404); // Kontingent voll oder Honig reicht nicht: keine neuen Abschluesse (Spec // "Honigbestand und Kontingent"). if (!offer.isAvailable) return error("Dieses Abo ist derzeit ausgebucht.", 409); // Ein alter offener Abschluss desselben Angebots wird ersetzt; mehr als // drei offene je Konto nimmt der Shop nicht an (pending.ts). const pending = await preparePendingCheckout({ customerId: customer.id, offerId: offer.id }); if (!pending.ok) { return error( "Du hast bereits mehrere offene Abo-Abschlüsse. Bitte schließe einen davon ab oder versuche es morgen erneut.", 409, { code: "too_many_pending" } ); } try { const { clientSecret } = await createSubscriptionCheckout({ customer, offerId: offer.id, personalization: personalization.data, locale, }); return NextResponse.json({ clientSecret }, { headers: { "Cache-Control": "no-store" } }); } catch (cause: unknown) { // Stripe-Fehlertexte nur ins Log: sie koennen Konfigurationsdetails enthalten. const message = cause instanceof Error ? cause.message : "Unbekannter Fehler"; console.error(`[abo] Abschluss fuer Konto ${customer.id} (${offer.slug}) fehlgeschlagen:`, message); return error( "Die Zahlung konnte nicht gestartet werden – es wurde nichts abgebucht. Bitte versuche es in ein paar Minuten erneut.", 502 ); } }