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

+74 -27
View File
@@ -22,6 +22,9 @@ import {
import { getBaseUrl } from "@/lib/url";
import { vatRateForProduct } from "@/lib/vat";
import { getLocale } from "@/i18n/locale";
import { expandCheckoutItems, type CheckoutProduct } from "@/lib/sponsorship/checkout-expand";
import { getOffersByProductIds } from "@/lib/sponsorship/offers";
import { reservedJarsForProduct } from "@/lib/sponsorship-subscription/reservation";
class OutOfStockError extends Error {}
// Der fruehe pendingUses-Check weiter unten liest ausserhalb jeder Transaktion
@@ -221,8 +224,18 @@ export async function POST(request: Request) {
}
}
const productIds = input.items.map((item) => item.productId);
// Eindeutig machen: zwei Patenschaften desselben Pakets sind zwei
// Positionen mit derselben productId (je eigene Personalisierung). Der
// Laengenvergleich unten wuerde sonst ein vorhandenes Produkt als fehlend
// werten.
const productIds = [...new Set(input.items.map((item) => item.productId))];
const locale = await getLocale();
const offersByProduct = await getOffersByProductIds(productIds, locale);
const honeyIds = [...offersByProduct.values()]
.map((offer) => offer.honeyProduct?.id)
.filter((id): id is string => Boolean(id) && !productIds.includes(id as string));
const products = await getProductsByIds(productIds);
const honeyProducts = honeyIds.length > 0 ? await getProductsByIds(honeyIds) : [];
if (products.length !== productIds.length) {
return NextResponse.json(
@@ -265,31 +278,64 @@ export async function POST(request: Request) {
);
}
const orderItemsData = input.items.map((cartItem) => {
const product = products.find((p) => p.id === cartItem.productId)!;
return {
productId: product.id,
batchId: product.batchId,
productName: product.name,
unitPriceCents: product.priceCents,
quantity: cartItem.quantity,
weightGrams: product.weightGrams,
totalCents: product.priceCents * cartItem.quantity,
// Teil des Snapshots wie der Preis: der Satz haengt an der Art der Ware
// (Honig ermaessigt, Kerzen voll). Wird ein Produkt spaeter
// umkategorisiert, darf das diese Bestellung nicht mehr veraendern.
vatRatePercent: vatRateForProduct(product, process.env),
};
// Positionen bilden und Patenschaftspakete in Patenschaft + Honig
// aufloesen (lib/sponsorship/checkout-expand.ts). Der Steuersatz ist Teil
// des Snapshots wie der Preis: er haengt an der Art der Ware (Honig
// ermaessigt, Kerzen und Patenschaft voll). Wird ein Produkt spaeter
// umkategorisiert, darf das diese Bestellung nicht mehr veraendern.
const productMap = new Map<string, CheckoutProduct>(
[...products, ...honeyProducts].map((product) => [product.id, product])
);
const expanded = expandCheckoutItems({
cartItems: input.items,
products: productMap,
offers: offersByProduct,
vatRateFor: (product) => vatRateForProduct(product, process.env),
locale,
now: new Date(),
});
if (!expanded.ok) {
return NextResponse.json({ error: expanded.error }, { status: 400 });
}
const orderItemsData = expanded.items;
// Ohne physische Ware gibt es nichts zu versenden oder abzuholen. Der
// Server entscheidet das selbst aus dem Warenkorb; ein Browser, der
// "DIGITAL" fuer einen Warenkorb mit Honig schickt, bekommt eine
// Fehlermeldung statt eines Versands ohne Versandkosten.
if (input.fulfillmentMethod === "DIGITAL" && expanded.hasPhysical) {
return NextResponse.json({ error: "Bitte wähle Versand oder Abholung." }, { status: 400 });
}
const fulfillmentMethod = expanded.hasPhysical ? input.fulfillmentMethod : "DIGITAL";
// Patenschaften gibt es nur mit Konto: Urkunde, Medaille und Downloads
// liegen im Kundenkonto, und nur ein Konto kann sie spaeter wieder abrufen.
const customer = await getCurrentCustomer();
if (expanded.hasSponsorship && !customer) {
return NextResponse.json(
{ error: "Für eine Bienenpatenschaft brauchst du ein Kundenkonto. Bitte melde dich an." },
{ status: 401 }
);
}
const subtotalCents = orderItemsData.reduce((sum, item) => sum + item.totalCents, 0);
// Versandkosten richten sich nach der admin-gepflegten Mengenstaffel und
// der Gesamtmenge ueber alle Positionen hinweg (mehrere Artikel werden zu
// einer Menge zusammengefasst, nicht separat berechnet) — kein
// wertbasierter Freibetrag mehr.
const totalQuantity = orderItemsData.reduce((sum, item) => sum + item.quantity, 0);
// der Gesamtmenge der PHYSISCHEN Ware ueber alle Positionen hinweg
// (mehrere Artikel werden zu einer Menge zusammengefasst) — eine Urkunde
// ohne Paket wiegt nichts und loest keinen Versand aus.
const totalQuantity = expanded.physicalQuantity;
const shippingCents =
input.fulfillmentMethod === "ABHOLUNG" ? 0 : await getShippingCostCents(totalQuantity);
fulfillmentMethod === "VERSAND" ? await getShippingCostCents(totalQuantity) : 0;
// Fuer Abos reservierte Glaeser duerfen weder einzeln noch im Paket
// verkauft werden (lib/sponsorship-subscription/reservation.ts). Vor der
// Transaktion gelesen: die Reservierung aendert sich nur mit Abos, nicht
// durch gleichzeitige Bestellungen — die verbindliche Bestandspruefung
// bleibt das bedingte Update in der Transaktion.
const reservedByProduct = new Map<string, number>();
for (const line of expanded.reserve) {
reservedByProduct.set(line.productId, await reservedJarsForProduct(line.productId));
}
// Gutscheincode nur auf den Warenwert anwenden, nicht auf den Versand —
// haelt die Rabattlogik einfach nachvollziehbar.
@@ -349,7 +395,6 @@ export async function POST(request: Request) {
// Gutschein-Nutzungsslot verbrauchen, ohne dass je bezahlt wurde.
const totalCents = subtotalCents - discountCents + shippingCents;
const customer = await getCurrentCustomer();
// Gastbestellungen (kein Konto) sind davon unberuehrt — die Pflicht gilt
// nur fuer registrierte, eingeloggte Kund:innen.
@@ -363,8 +408,6 @@ export async function POST(request: Request) {
);
}
const locale = await getLocale();
// Geht ausschliesslich in der Antwort an den Browser, der diese Bestellung
// gerade anlegt, und gibt den reservierten Bestand frei, sobald die
// Kundschaft den Zahlungsschritt verlaesst (siehe api/checkout/cancel).
@@ -385,9 +428,13 @@ export async function POST(request: Request) {
const runOrderTransaction = () =>
prisma.$transaction(
async (tx) => {
for (const item of orderItemsData) {
// Nur physische Ware reservieren (Patenschaft selbst: kein Bestand).
// Die fuer Abos reservierten Glaeser muessen nach der Buchung
// weiterhin da sein — daher "Bestand >= bestellt + Abo-Reservierung".
for (const item of expanded.reserve) {
const aboReserved = reservedByProduct.get(item.productId) ?? 0;
const reserved = await tx.inventory.updateMany({
where: { productId: item.productId, quantity: { gte: item.quantity } },
where: { productId: item.productId, quantity: { gte: item.quantity + aboReserved } },
data: { quantity: { decrement: item.quantity } },
});
if (reserved.count === 0) {
@@ -421,7 +468,7 @@ export async function POST(request: Request) {
cancelToken,
customerId: customer?.id,
locale,
fulfillmentMethod: input.fulfillmentMethod,
fulfillmentMethod,
email: input.email,
firstName: input.firstName,
lastName: input.lastName,