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
@@ -22,6 +22,9 @@ import {
|
||||
import { getBaseUrl } from "@/lib/url";
|
||||
import { vatRateForProduct } from "@/lib/vat";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { expandCheckoutItems, type CheckoutProduct } from "@/lib/sponsorship/checkout-expand";
|
||||
import { getOffersByProductIds } from "@/lib/sponsorship/offers";
|
||||
import { reservedJarsForProduct } from "@/lib/sponsorship-subscription/reservation";
|
||||
|
||||
class OutOfStockError extends Error {}
|
||||
// Der fruehe pendingUses-Check weiter unten liest ausserhalb jeder Transaktion
|
||||
@@ -221,8 +224,18 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
const productIds = input.items.map((item) => item.productId);
|
||||
// Eindeutig machen: zwei Patenschaften desselben Pakets sind zwei
|
||||
// Positionen mit derselben productId (je eigene Personalisierung). Der
|
||||
// Laengenvergleich unten wuerde sonst ein vorhandenes Produkt als fehlend
|
||||
// werten.
|
||||
const productIds = [...new Set(input.items.map((item) => item.productId))];
|
||||
const locale = await getLocale();
|
||||
const offersByProduct = await getOffersByProductIds(productIds, locale);
|
||||
const honeyIds = [...offersByProduct.values()]
|
||||
.map((offer) => offer.honeyProduct?.id)
|
||||
.filter((id): id is string => Boolean(id) && !productIds.includes(id as string));
|
||||
const products = await getProductsByIds(productIds);
|
||||
const honeyProducts = honeyIds.length > 0 ? await getProductsByIds(honeyIds) : [];
|
||||
|
||||
if (products.length !== productIds.length) {
|
||||
return NextResponse.json(
|
||||
@@ -265,31 +278,64 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const orderItemsData = input.items.map((cartItem) => {
|
||||
const product = products.find((p) => p.id === cartItem.productId)!;
|
||||
return {
|
||||
productId: product.id,
|
||||
batchId: product.batchId,
|
||||
productName: product.name,
|
||||
unitPriceCents: product.priceCents,
|
||||
quantity: cartItem.quantity,
|
||||
weightGrams: product.weightGrams,
|
||||
totalCents: product.priceCents * cartItem.quantity,
|
||||
// Teil des Snapshots wie der Preis: der Satz haengt an der Art der Ware
|
||||
// (Honig ermaessigt, Kerzen voll). Wird ein Produkt spaeter
|
||||
// umkategorisiert, darf das diese Bestellung nicht mehr veraendern.
|
||||
vatRatePercent: vatRateForProduct(product, process.env),
|
||||
};
|
||||
// Positionen bilden und Patenschaftspakete in Patenschaft + Honig
|
||||
// aufloesen (lib/sponsorship/checkout-expand.ts). Der Steuersatz ist Teil
|
||||
// des Snapshots wie der Preis: er haengt an der Art der Ware (Honig
|
||||
// ermaessigt, Kerzen und Patenschaft voll). Wird ein Produkt spaeter
|
||||
// umkategorisiert, darf das diese Bestellung nicht mehr veraendern.
|
||||
const productMap = new Map<string, CheckoutProduct>(
|
||||
[...products, ...honeyProducts].map((product) => [product.id, product])
|
||||
);
|
||||
const expanded = expandCheckoutItems({
|
||||
cartItems: input.items,
|
||||
products: productMap,
|
||||
offers: offersByProduct,
|
||||
vatRateFor: (product) => vatRateForProduct(product, process.env),
|
||||
locale,
|
||||
now: new Date(),
|
||||
});
|
||||
if (!expanded.ok) {
|
||||
return NextResponse.json({ error: expanded.error }, { status: 400 });
|
||||
}
|
||||
const orderItemsData = expanded.items;
|
||||
|
||||
// Ohne physische Ware gibt es nichts zu versenden oder abzuholen. Der
|
||||
// Server entscheidet das selbst aus dem Warenkorb; ein Browser, der
|
||||
// "DIGITAL" fuer einen Warenkorb mit Honig schickt, bekommt eine
|
||||
// Fehlermeldung statt eines Versands ohne Versandkosten.
|
||||
if (input.fulfillmentMethod === "DIGITAL" && expanded.hasPhysical) {
|
||||
return NextResponse.json({ error: "Bitte wähle Versand oder Abholung." }, { status: 400 });
|
||||
}
|
||||
const fulfillmentMethod = expanded.hasPhysical ? input.fulfillmentMethod : "DIGITAL";
|
||||
|
||||
// Patenschaften gibt es nur mit Konto: Urkunde, Medaille und Downloads
|
||||
// liegen im Kundenkonto, und nur ein Konto kann sie spaeter wieder abrufen.
|
||||
const customer = await getCurrentCustomer();
|
||||
if (expanded.hasSponsorship && !customer) {
|
||||
return NextResponse.json(
|
||||
{ error: "Für eine Bienenpatenschaft brauchst du ein Kundenkonto. Bitte melde dich an." },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const subtotalCents = orderItemsData.reduce((sum, item) => sum + item.totalCents, 0);
|
||||
// Versandkosten richten sich nach der admin-gepflegten Mengenstaffel und
|
||||
// der Gesamtmenge ueber alle Positionen hinweg (mehrere Artikel werden zu
|
||||
// einer Menge zusammengefasst, nicht separat berechnet) — kein
|
||||
// wertbasierter Freibetrag mehr.
|
||||
const totalQuantity = orderItemsData.reduce((sum, item) => sum + item.quantity, 0);
|
||||
// der Gesamtmenge der PHYSISCHEN Ware ueber alle Positionen hinweg
|
||||
// (mehrere Artikel werden zu einer Menge zusammengefasst) — eine Urkunde
|
||||
// ohne Paket wiegt nichts und loest keinen Versand aus.
|
||||
const totalQuantity = expanded.physicalQuantity;
|
||||
const shippingCents =
|
||||
input.fulfillmentMethod === "ABHOLUNG" ? 0 : await getShippingCostCents(totalQuantity);
|
||||
fulfillmentMethod === "VERSAND" ? await getShippingCostCents(totalQuantity) : 0;
|
||||
|
||||
// Fuer Abos reservierte Glaeser duerfen weder einzeln noch im Paket
|
||||
// verkauft werden (lib/sponsorship-subscription/reservation.ts). Vor der
|
||||
// Transaktion gelesen: die Reservierung aendert sich nur mit Abos, nicht
|
||||
// durch gleichzeitige Bestellungen — die verbindliche Bestandspruefung
|
||||
// bleibt das bedingte Update in der Transaktion.
|
||||
const reservedByProduct = new Map<string, number>();
|
||||
for (const line of expanded.reserve) {
|
||||
reservedByProduct.set(line.productId, await reservedJarsForProduct(line.productId));
|
||||
}
|
||||
|
||||
// Gutscheincode nur auf den Warenwert anwenden, nicht auf den Versand —
|
||||
// haelt die Rabattlogik einfach nachvollziehbar.
|
||||
@@ -349,7 +395,6 @@ export async function POST(request: Request) {
|
||||
// Gutschein-Nutzungsslot verbrauchen, ohne dass je bezahlt wurde.
|
||||
|
||||
const totalCents = subtotalCents - discountCents + shippingCents;
|
||||
const customer = await getCurrentCustomer();
|
||||
|
||||
// Gastbestellungen (kein Konto) sind davon unberuehrt — die Pflicht gilt
|
||||
// nur fuer registrierte, eingeloggte Kund:innen.
|
||||
@@ -363,8 +408,6 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const locale = await getLocale();
|
||||
|
||||
// Geht ausschliesslich in der Antwort an den Browser, der diese Bestellung
|
||||
// gerade anlegt, und gibt den reservierten Bestand frei, sobald die
|
||||
// Kundschaft den Zahlungsschritt verlaesst (siehe api/checkout/cancel).
|
||||
@@ -385,9 +428,13 @@ export async function POST(request: Request) {
|
||||
const runOrderTransaction = () =>
|
||||
prisma.$transaction(
|
||||
async (tx) => {
|
||||
for (const item of orderItemsData) {
|
||||
// Nur physische Ware reservieren (Patenschaft selbst: kein Bestand).
|
||||
// Die fuer Abos reservierten Glaeser muessen nach der Buchung
|
||||
// weiterhin da sein — daher "Bestand >= bestellt + Abo-Reservierung".
|
||||
for (const item of expanded.reserve) {
|
||||
const aboReserved = reservedByProduct.get(item.productId) ?? 0;
|
||||
const reserved = await tx.inventory.updateMany({
|
||||
where: { productId: item.productId, quantity: { gte: item.quantity } },
|
||||
where: { productId: item.productId, quantity: { gte: item.quantity + aboReserved } },
|
||||
data: { quantity: { decrement: item.quantity } },
|
||||
});
|
||||
if (reserved.count === 0) {
|
||||
@@ -421,7 +468,7 @@ export async function POST(request: Request) {
|
||||
cancelToken,
|
||||
customerId: customer?.id,
|
||||
locale,
|
||||
fulfillmentMethod: input.fulfillmentMethod,
|
||||
fulfillmentMethod,
|
||||
email: input.email,
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
|
||||
Reference in new issue
Block a user