Files
Wabenhain/src/app/api/patenschaft/abo/route.ts
T
Nick SzaunigandClaude Opus 5.5 515fca1c16
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
fix: Befunde aus Sicherheits- und Korrektheitsreview der Bienenpatenschaft
- 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]>
2026-10-16 15:09:32 +02:00

114 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
);
}
}