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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 14:07:30 +02:00
1 parent dcb578f062
commit f227aeb8f9
269 files changed
+28054 -141

No files matched your search

+167
View File
@@ -0,0 +1,167 @@
import { prisma } from "@/lib/prisma";
import { asStringArray } from "@/lib/json-array";
import { availableForSale } from "@/lib/sponsorship-subscription/reservation";
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 physisches Paket ohne genug Glaeser des
* verknuepften Honigprodukts darf nicht verkauft werden — sonst bekaeme die
* Kundschaft eine Urkunde ohne den bezahlten Honig. Gezaehlt wird der FREIE
* Bestand, also abzueglich der fuer Abos reservierten Glaeser. Bei Abos
* zaehlt zusaetzlich das Kontingent (maxActiveSubscriptions).
*/
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;
honeyProduct: { id: string; name: string; weightGrams: number } | null;
/** false = derzeit nicht bestellbar (Honig fehlt, Kontingent voll, Angebot aus). */
isAvailable: boolean;
}
const offerInclude = {
honeyProduct: { select: { id: true, name: true, weightGrams: true, isActive: true } },
} as const;
async function loadOfferRows(where: { isActive?: boolean; kind?: SponsorshipOfferKind; slug?: string; productId?: { in: string[] } }) {
return prisma.sponsorshipOffer.findMany({
where,
include: offerInclude,
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
});
}
type OfferRow = Awaited<ReturnType<typeof loadOfferRows>>[number];
function localize(row: OfferRow, locale: Locale, 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: row.honeyProduct
? { id: row.honeyProduct.id, name: row.honeyProduct.name, weightGrams: row.honeyProduct.weightGrams }
: null,
isAvailable,
};
}
async function freeJarsByProduct(rows: OfferRow[]): Promise<Map<string, number>> {
const ids = [...new Set(rows.map((row) => row.honeyProduct?.id).filter((id): id is string => Boolean(id)))];
const entries = await Promise.all(ids.map(async (id) => [id, await availableForSale(id)] as const));
return new Map(entries);
}
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"],
where: { offerId: { in: aboIds }, status: { in: ["AKTIV", "IM_VERZUG", "GEKUENDIGT"] } },
_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;
honeyProductActive: boolean | null;
freeJars: number;
maxActiveSubscriptions: number | null;
activeSubscriptions: number;
}): boolean {
if (!input.isActive) return false;
if (input.kind === "EINMALIG" && !input.hasCarrierProduct) return false;
if (input.honeyJars > 0) {
if (!input.honeyProductActive) return false;
if (input.freeJars < input.honeyJars) 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 [jars, subscriptionCounts] = await Promise.all([freeJarsByProduct(rows), activeSubscriptionCounts(rows)]);
return rows.map((row) =>
localize(
row,
locale,
isOfferAvailable({
isActive: row.isActive,
kind: row.kind,
hasCarrierProduct: row.productId !== null,
honeyJars: row.honeyJars,
honeyProductActive: row.honeyProduct ? row.honeyProduct.isActive : null,
freeJars: row.honeyProduct ? (jars.get(row.honeyProduct.id) ?? 0) : 0,
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;
}