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

+216
View File
@@ -0,0 +1,216 @@
import type { Prisma, ProductCategory } from "@/generated/prisma/client";
import { personalizationSchema } from "@/lib/certificate/schema";
import { checkCertificateFits } from "@/lib/certificate/layout";
import { CURRENT_TEMPLATE_VERSION } from "@/lib/certificate/templates";
import type { SponsorshipOfferView } from "@/lib/sponsorship/offers";
import type { Locale } from "@/i18n/config";
/**
* Wandelt die Warenkorbpositionen in Bestellpositionen um und loest dabei
* Patenschaftspakete in ihre Bestandteile auf.
*
* Ein Paket wird zu bis zu zwei Positionen:
* - MAIN: die Patenschaft (Traegerprodukt, Regelsteuersatz), traegt die
* Personalisierung. Keine Bestandsreservierung — eine Urkunde ist nie
* ausverkauft.
* - HONEY: der Honiganteil als Position des verknuepften Honigprodukts zum
* Honig-Steuersatz. Dadurch reserviert der Checkout den Honig wie jedes
* andere Glas, die Rechnung zeigt die Steueraufteilung, und die
* ApiCore-Abbuchung bei Zahlung (recordOrderSale) bucht den Honig ohne
* Sonderweg aus dem Bestand.
*
* Bewusst als reine Funktion ohne Datenbankzugriff: der Checkout laedt alles
* vorher, und diese Regeln lassen sich ohne Datenbank testen.
*/
export interface CheckoutCartItem {
productId: string;
quantity: number;
personalization?: unknown;
}
export interface CheckoutProduct {
id: string;
name: string;
batchId: string | null;
priceCents: number;
weightGrams: number;
category: ProductCategory;
isActive: boolean;
}
export interface OrderItemDraft {
productId: string;
batchId: string | null;
productName: string;
unitPriceCents: number;
quantity: number;
weightGrams: number;
totalCents: number;
vatRatePercent: number;
personalization?: Prisma.InputJsonValue;
sponsorshipOfferId?: string;
sponsorshipRole?: "MAIN" | "HONEY";
}
export interface ReservationLine {
productId: string;
quantity: number;
productName: string;
}
export type ExpandResult =
| {
ok: true;
items: OrderItemDraft[];
reserve: ReservationLine[];
/** Menge fuer Versandstaffel und Mengenrabatte (nur physische Ware). */
physicalQuantity: number;
hasPhysical: boolean;
hasSponsorship: boolean;
}
| { ok: false; error: string };
export interface ExpandInput {
cartItems: CheckoutCartItem[];
products: ReadonlyMap<string, CheckoutProduct>;
/** Angebote nach Traegerprodukt-ID. */
offers: ReadonlyMap<string, SponsorshipOfferView>;
vatRateFor: (product: CheckoutProduct) => number;
locale: Locale;
now: Date;
}
/** Spaetester Patenschaftsbeginn: sechs Monate nach dem Bestelltag. */
const MAX_START_OFFSET_MONTHS = 6;
export function startDateWindow(now: Date): { min: string; max: string } {
const min = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const max = new Date(min);
max.setUTCMonth(max.getUTCMonth() + MAX_START_OFFSET_MONTHS);
return { min: min.toISOString().slice(0, 10), max: max.toISOString().slice(0, 10) };
}
export function expandCheckoutItems(input: ExpandInput): ExpandResult {
const items: OrderItemDraft[] = [];
const reserve: ReservationLine[] = [];
let physicalQuantity = 0;
let hasSponsorship = false;
const window = startDateWindow(input.now);
for (const cartItem of input.cartItems) {
const product = input.products.get(cartItem.productId);
if (!product || !product.isActive) {
return { ok: false, error: "Ein Produkt im Warenkorb ist nicht mehr verfügbar." };
}
if (product.category !== "PATENSCHAFT") {
if (cartItem.personalization !== undefined) {
return { ok: false, error: "Ungültige Eingabe." };
}
if (product.priceCents <= 0) {
return { ok: false, error: "Ein Produkt im Warenkorb ist nicht mehr verfügbar." };
}
items.push({
productId: product.id,
batchId: product.batchId,
productName: product.name,
unitPriceCents: product.priceCents,
quantity: cartItem.quantity,
weightGrams: product.weightGrams,
totalCents: product.priceCents * cartItem.quantity,
vatRatePercent: input.vatRateFor(product),
});
reserve.push({ productId: product.id, quantity: cartItem.quantity, productName: product.name });
physicalQuantity += cartItem.quantity;
continue;
}
hasSponsorship = true;
const offer = input.offers.get(product.id);
if (!offer || offer.kind !== "EINMALIG" || !offer.isAvailable) {
return { ok: false, error: "Diese Patenschaft ist derzeit nicht bestellbar." };
}
// Jede Patenschaft hat ihre eigene Personalisierung und ihre eigene
// Urkunde — eine Menge > 1 haette eine Widmung fuer mehrere Urkunden.
if (cartItem.quantity !== 1) {
return { ok: false, error: "Jede Patenschaft wird einzeln personalisiert." };
}
const parsed = personalizationSchema.safeParse(cartItem.personalization);
if (!parsed.success) {
return { ok: false, error: parsed.error.issues[0]?.message ?? "Die Personalisierung ist ungültig." };
}
const personalization = parsed.data;
if (personalization.startDate < window.min || personalization.startDate > window.max) {
return { ok: false, error: "Das Startdatum muss zwischen heute und in sechs Monaten liegen." };
}
const fit = checkCertificateFits(personalization, input.locale);
if (!fit.ok) {
return { ok: false, error: fit.message };
}
const honeyTotal = offer.honeyJars * offer.honeyShareCentsPerJar;
const mainCents = offer.priceCents - honeyTotal;
if (mainCents <= 0) {
return { ok: false, error: "Diese Patenschaft ist derzeit nicht bestellbar." };
}
items.push({
productId: product.id,
batchId: null,
productName: offer.name,
unitPriceCents: mainCents,
quantity: 1,
weightGrams: 0,
totalCents: mainCents,
vatRatePercent: input.vatRateFor(product),
personalization: { personalization, templateVersion: CURRENT_TEMPLATE_VERSION } as unknown as Prisma.InputJsonValue,
sponsorshipOfferId: offer.id,
sponsorshipRole: "MAIN",
});
if (offer.honeyJars > 0) {
const honey = offer.honeyProduct ? input.products.get(offer.honeyProduct.id) : undefined;
if (!honey || !honey.isActive) {
return { ok: false, error: "Diese Patenschaft ist derzeit nicht bestellbar." };
}
items.push({
productId: honey.id,
batchId: honey.batchId,
productName: `${honey.name} – ${offer.name}`,
unitPriceCents: offer.honeyShareCentsPerJar,
quantity: offer.honeyJars,
weightGrams: honey.weightGrams,
totalCents: honeyTotal,
vatRatePercent: input.vatRateFor(honey),
sponsorshipOfferId: offer.id,
sponsorshipRole: "HONEY",
});
reserve.push({ productId: honey.id, quantity: offer.honeyJars, productName: honey.name });
physicalQuantity += offer.honeyJars;
}
}
return {
ok: true,
items,
reserve: mergeReservations(reserve),
physicalQuantity,
hasPhysical: physicalQuantity > 0,
hasSponsorship,
};
}
/**
* Fasst Reservierungen desselben Produkts zusammen. Ohne das wuerden zwei
* Pakete mit demselben Honig zwei getrennte "noch mindestens n da?"-Pruefungen
* bestehen, obwohl zusammen zu wenig da ist.
*/
function mergeReservations(lines: ReservationLine[]): ReservationLine[] {
const merged = new Map<string, ReservationLine>();
for (const line of lines) {
const existing = merged.get(line.productId);
merged.set(line.productId, existing ? { ...existing, quantity: existing.quantity + line.quantity } : line);
}
return [...merged.values()];
}