feat: Bienenpatenschaft mit Urkunde, Abo, Medaillen und 3D-Konto

Setzt Teilprojekte 1-5 der Spec 2026-10-16 um:
- Urkunden-Engine (lib/certificate): ein Layout fuer PDF, Vorschau und
  3D-Textur, Vorlage classic@1, eingebettete OFL-Schriften, Testmatrix
- Einmal-Patenschaft ueber Traegerprodukte (Kategorie PATENSCHAFT),
  Paket wird im Checkout in Patenschaft (Regelsatz) und Honig aufgeteilt,
  Kontopflicht, rein digitale Bestellungen (FulfillmentMethod DIGITAL)
- Ausstellung nach Zahlung idempotent, Wiederholung im App-Prozess
  (instrumentation + background-jobs), Ablage in CERTIFICATE_DIR
- Verkaufsseite /patenschaft mit Konfigurator und Live-Vorschau,
  Warenkorb mit Schluessel je Position
- Kundenkonto: 3D-Ansicht von Urkunde und Medaille, Downloads, ZIP,
  Uebertragung per befristetem Einmal-Link, Treuestufen bis VIP
- Admin: Angebote, Patenschaften, Druck, Korrektur mit Revisionen,
  Abos, Erntefenster, Deckung, SEPA-Frist, Treuestufen
- Abo ueber Stripe Billing: Lieferung erst nach bezahlten Monaten,
  SEPA-Neukunden-Frist und Rueckbuchungsschutz, Honigreservierung,
  Kuendigungsbutton, Monatsrechnungen, Stripe-Einrichtungsskript

Startangebote werden inaktiv angelegt; Livebetrieb erst nach Freigabe.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 14:07:30 +02:00
1 parent dcb578f062
commit f227aeb8f9
269 files changed
+28054 -141

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
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";
/**
* 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<string, unknown> = {}): 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);
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
);
}
}