Files
Wabenhain/src/lib/sponsorship/offers.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

173 lines
7.0 KiB
TypeScript

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<ReturnType<typeof loadOfferRows>>[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<HoneyCandidate[]> {
return rows.some((row) => row.honeyJars > 0) ? loadHoneyCandidates() : [];
}
async function activeSubscriptionCounts(rows: OfferRow[]): Promise<Map<string, number>> {
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<SponsorshipOfferView[]> {
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<SponsorshipOfferView[]> {
return toViews(await loadOfferRows({ isActive: true, ...(kind ? { kind } : {}) }), locale);
}
export async function getOfferBySlug(slug: string, locale: Locale): Promise<SponsorshipOfferView | null> {
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<Map<string, SponsorshipOfferView>> {
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<boolean> {
return (await prisma.sponsorshipOffer.count({ where: { isActive: true } })) > 0;
}