import type { Prisma, ProductCategory } from "@/generated/prisma/client"; import { personalizationSchema, startDateRange, toIsoDate } 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 { pickHoneyProduct, withTakenStock, type HoneyCandidate } from "@/lib/sponsorship/honey-selection"; 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 eines 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. * * Welcher Honig das ist, entscheidet die automatische Wahl im Moment der * Bestellung (honey-selection.ts: nie cremig, neueste Ernte, genug freier * Bestand). Mehrere Pakete in einem Warenkorb werden nacheinander gewaehlt, * jedes gegen den Bestand, den die einzeln gekauften Glaeser und die Pakete * davor uebrig lassen — reicht der erste Honig nicht fuer alle, bekommt das * naechste Paket den naechsten. Das ist einfacher als ein Paket auf zwei * Sorten aufzuteilen, und jedes Paket bleibt sortenrein. * * 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; /** Angebote nach Traegerprodukt-ID. */ offers: ReadonlyMap; /** * Honig-Kandidaten mit FREIEM Bestand (honey-candidates.ts). Die Produkte * der gewaehlten Kandidaten muessen in `products` stehen. */ honeyCandidates: readonly HoneyCandidate[]; vatRateFor: (product: CheckoutProduct) => number; locale: Locale; now: Date; } /** * Erlaubtes Startdatum: frühestens der Bestelltag, spätestens sechs Monate * danach — dieselbe Regel wie im Formular (startDateRange), damit Grenze und * Klemmung auf das Monatsende (31.08. → 28./29.02.) ueberall gleich sind. */ export function startDateWindow(now: Date): { min: string; max: string } { return startDateRange(toIsoDate(now)); } /** * Glaeser, die der Warenkorb schon einzeln kauft, je Produkt. Sie gehen vom * freien Bestand ab, bevor ein Paket seinen Honig waehlt — sonst fiele ein * Paket auf denselben Honig, und die Bestandspruefung im Checkout lehnte * die ganze Bestellung ab, obwohl ein anderer Honig gereicht haette. */ function individuallyTakenJars(input: ExpandInput): Map { const taken = new Map(); for (const cartItem of input.cartItems) { const product = input.products.get(cartItem.productId); if (!product || product.category === "PATENSCHAFT") continue; taken.set(product.id, (taken.get(product.id) ?? 0) + cartItem.quantity); } return taken; } export function expandCheckoutItems(input: ExpandInput): ExpandResult { const items: OrderItemDraft[] = []; const reserve: ReservationLine[] = []; let physicalQuantity = 0; let hasSponsorship = false; const window = startDateWindow(input.now); // Verplante Glaeser je Honig: erst die einzeln gekauften, dann wachsend mit // jedem Paket (lokaler Zaehler dieser einen Berechnung). const takenJars = individuallyTakenJars(input); 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 picked = pickHoneyProduct(withTakenStock(input.honeyCandidates, takenJars), offer.honeyJars); const honey = picked ? input.products.get(picked.id) : undefined; if (!honey || !honey.isActive) { return { ok: false, error: "Für dieses Paket ist gerade nicht genug Honig da." }; } takenJars.set(honey.id, (takenJars.get(honey.id) ?? 0) + offer.honeyJars); 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(); 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()]; }