Files
Wabenhain/src/lib/sponsorship/checkout-expand.ts
T
Nick.RosenloecherandClaude Opus 5.5 1d88bac52e
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 2m18s
Docker Image bauen und veroeffentlichen / test (push) Successful in 5m32s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 3m41s
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 20m38s
feat: Patenschafts-Honig automatisch waehlen (nie cremig, neueste Ernte)
Pakete, Abo-Reservierung und Abo-Lieferungen nehmen immer den aktiven,
nicht cremigen Honig der neuesten Ernte mit genug freiem Bestand, bei
gleichem Jahrgang den mit dem meisten Bestand. Feste Honig-Zuordnung
im Admin entfaellt (Spalte bleibt, wird nicht mehr genutzt).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 18:35:04 +02:00

251 lines
9.3 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 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<string, CheckoutProduct>;
/** Angebote nach Traegerprodukt-ID. */
offers: ReadonlyMap<string, SponsorshipOfferView>;
/**
* 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<string, number> {
const taken = new Map<string, number>();
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<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()];
}