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:
1 parent
dcb578f062
commit
f227aeb8f9
269 files changed
+28054
-141
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user