Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 2m57s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 3m7s
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
Docker Image bauen und veroeffentlichen / test (push) Canceled after 1m22s
- Oeffentliche Kuendigung: exakte Identifikation, Limits je Vertrag und Adresse, Bestaetigung nur an die Kontoadresse mit Ruecknahme-Link (HMAC) - Vorschau: Body-Grenze beim Lesen, Parallelitaets- und Minutenlimit, Cache - Rabatte gelten nicht fuer Patenschaftspositionen - Abo aktiv erst nach bestaetigter Zahlung, offene Abschluesse zaehlen zum Kontingent und werden nach 24 h aufgeraeumt, Sitzung 58 Minuten - Konto-Loeschung nur ohne laufendes Abo, Urkunden-Dateien werden entfernt - Abo-Lieferungen mit Zahlungszeile (DHL-Marke moeglich) - Shop zeigt freien Bestand nach Abo-Reservierung - Storno/volle Erstattung widerruft die Patenschaft (Status WIDERRUFEN) - Umsatzsteuer der Patenschaft per VAT_RATE_PERCENT_PATENSCHAFT (19 %) - Digitale Bestellungen archivieren sich, Kalendertage in Europe/Berlin, Monatsenden begrenzt, Datenauskunft um Patenschaften ergaenzt Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
114 lines
4.9 KiB
TypeScript
114 lines
4.9 KiB
TypeScript
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<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);
|
||
|
||
// 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
|
||
);
|
||
}
|
||
}
|