import { prisma } from "@/lib/prisma"; import { asStringArray } from "@/lib/json-array"; import { loadHoneyCandidates } from "@/lib/sponsorship/honey-candidates"; import { pickHoneyProduct, type HoneyCandidate } from "@/lib/sponsorship/honey-selection"; import { quotaSubscriptionWhere } from "@/lib/sponsorship-subscription/pending"; import type { Locale } from "@/i18n/config"; import type { MedalTier, SponsorshipCycle, SponsorshipOfferKind, } from "@/generated/prisma/client"; /** * Angebote der Bienenpatenschaft, so aufbereitet, wie Verkaufsseite, * Warenkorb und Checkout sie brauchen: lokalisiert, mit Leistungsliste als * Array und mit der Aussage, ob das Angebot gerade bestellbar ist. * * "Bestellbar" haengt am Honig: ein Angebot mit Glaesern braucht einen Honig, * den die automatische Wahl nehmen darf (nie cremig, neueste Ernte, genug * FREIER Bestand, also abzueglich der fuer Abos reservierten Glaeser — * lib/sponsorship/honey-selection.ts). Gibt es keinen, ist das Angebot nicht * bestellbar — sonst bekaeme die Kundschaft eine Urkunde ohne den bezahlten * Honig. Bei Abos zaehlt zusaetzlich das Kontingent (maxActiveSubscriptions). * * Einen fest verknuepften Honig je Angebot gibt es nicht mehr: die Spalte * SponsorshipOffer.honeyProductId bleibt (nullable) im Schema stehen, um * keine Migration auf der laufenden Produktion zu brauchen, wird aber weder * gelesen noch geschrieben. */ export interface SponsorshipOfferView { id: string; slug: string; kind: SponsorshipOfferKind; name: string; description: string; features: string[]; /** EINMALIG: Paketpreis brutto. ABO: Monatspreis brutto. */ priceCents: number; isPhysical: boolean; honeyJars: number; honeyShareCentsPerJar: number; honeyShareCentsPerMonth: number; cycle: SponsorshipCycle | null; medalTier: MedalTier; /** Traegerprodukt (nur EINMALIG). */ productId: string | null; /** * Der Honig, den die automatische Wahl GERADE fuer dieses Angebot nimmt * (nur zur Anzeige und Pruefung; der Checkout waehlt bei der Bestellung neu, * weil mehrere Pakete im Warenkorb gemeinsam reichen muessen). */ honeyProduct: { id: string; name: string; weightGrams: number } | null; /** false = derzeit nicht bestellbar (Honig fehlt, Kontingent voll, Angebot aus). */ isAvailable: boolean; } async function loadOfferRows(where: { isActive?: boolean; kind?: SponsorshipOfferKind; slug?: string; productId?: { in: string[] } }) { return prisma.sponsorshipOffer.findMany({ where, orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], }); } type OfferRow = Awaited>[number]; function localize(row: OfferRow, locale: Locale, honey: HoneyCandidate | null, isAvailable: boolean): SponsorshipOfferView { const en = locale === "en"; const featuresEn = asStringArray(row.featuresEn); return { id: row.id, slug: row.slug, kind: row.kind, name: (en && row.nameEn) || row.name, description: (en && row.descriptionEn) || row.description, features: en && featuresEn.length > 0 ? featuresEn : asStringArray(row.features), priceCents: row.priceCents, isPhysical: row.isPhysical, honeyJars: row.honeyJars, honeyShareCentsPerJar: row.honeyShareCentsPerJar, honeyShareCentsPerMonth: row.honeyShareCentsPerMonth, cycle: row.cycle, medalTier: row.medalTier, productId: row.productId, honeyProduct: honey ? { id: honey.id, name: honey.name, weightGrams: honey.weightGrams } : null, isAvailable, }; } /** Kandidaten nur laden, wenn ein Angebot ueberhaupt Honig braucht (rein digitale Angebote nicht). */ async function honeyCandidatesFor(rows: OfferRow[]): Promise { return rows.some((row) => row.honeyJars > 0) ? loadHoneyCandidates() : []; } async function activeSubscriptionCounts(rows: OfferRow[]): Promise> { const aboIds = rows.filter((row) => row.kind === "ABO" && row.maxActiveSubscriptions !== null).map((row) => row.id); if (aboIds.length === 0) return new Map(); const groups = await prisma.sponsorshipSubscription.groupBy({ by: ["offerId"], // Laufende Abos plus offene Abschluesse der letzten Stunde (pending.ts): // sonst koennten gleichzeitige Checkouts das Kontingent ueberbuchen. where: { offerId: { in: aboIds }, ...quotaSubscriptionWhere(new Date()) }, _count: { _all: true }, }); return new Map(groups.map((group) => [group.offerId, group._count._all])); } /** Reine Entscheidung "bestellbar?", getrennt von den Abfragen, damit sie testbar bleibt. */ export function isOfferAvailable(input: { isActive: boolean; kind: SponsorshipOfferKind; hasCarrierProduct: boolean; honeyJars: number; /** Die automatische Honigwahl hat fuer honeyJars Glaeser einen Honig gefunden. */ hasEligibleHoney: boolean; maxActiveSubscriptions: number | null; activeSubscriptions: number; }): boolean { if (!input.isActive) return false; if (input.kind === "EINMALIG" && !input.hasCarrierProduct) return false; if (input.honeyJars > 0 && !input.hasEligibleHoney) return false; if (input.kind === "ABO" && input.maxActiveSubscriptions !== null) { if (input.activeSubscriptions >= input.maxActiveSubscriptions) return false; } return true; } async function toViews(rows: OfferRow[], locale: Locale): Promise { const [candidates, subscriptionCounts] = await Promise.all([honeyCandidatesFor(rows), activeSubscriptionCounts(rows)]); return rows.map((row) => { const honey = row.honeyJars > 0 ? pickHoneyProduct(candidates, row.honeyJars) : null; return localize( row, locale, honey, isOfferAvailable({ isActive: row.isActive, kind: row.kind, hasCarrierProduct: row.productId !== null, honeyJars: row.honeyJars, hasEligibleHoney: honey !== null, maxActiveSubscriptions: row.maxActiveSubscriptions, activeSubscriptions: subscriptionCounts.get(row.id) ?? 0, }) ); }); } export async function getActiveOffers(locale: Locale, kind?: SponsorshipOfferKind): Promise { return toViews(await loadOfferRows({ isActive: true, ...(kind ? { kind } : {}) }), locale); } export async function getOfferBySlug(slug: string, locale: Locale): Promise { const [view] = await toViews(await loadOfferRows({ slug }), locale); return view ?? null; } /** Angebote zu Traegerprodukten (der Checkout kennt nur productIds). */ export async function getOffersByProductIds( productIds: string[], locale: Locale ): Promise> { if (productIds.length === 0) return new Map(); const views = await toViews(await loadOfferRows({ productId: { in: productIds } }), locale); return new Map( views.flatMap((view) => (view.productId ? [[view.productId, view] as const] : [])) ); } /** Gibt es ueberhaupt ein aktives Angebot? Steuert Navigation und Verkaufsseite. */ export async function hasActiveOffers(): Promise { return (await prisma.sponsorshipOffer.count({ where: { isActive: true } })) > 0; }