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,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()];
|
||||
}
|
||||
Reference in new issue
Block a user