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:
1 parent
dcb578f062
commit
f227aeb8f9
269 files changed
+28054
-141
No files matched your search
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user