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

@@ -0,0 +1,342 @@
-- Bienenpatenschaft: Angebote, Patenschaften mit Urkunden-Revisionen,
-- Uebertragungen, Abos (Stripe Billing) mit Zahlungen und Lieferungen,
-- Erntefenster und Treuestufen. Rein additiv; die beiden neuen Enum-Werte
-- (PATENSCHAFT, DIGITAL) werden in dieser Migration nicht benutzt, ALTER TYPE
-- ADD VALUE ist deshalb auch innerhalb der Migrationstransaktion zulaessig.
-- CreateEnum
CREATE TYPE "SponsorshipOfferKind" AS ENUM ('EINMALIG', 'ABO');
-- CreateEnum
CREATE TYPE "SponsorshipCycle" AS ENUM ('VIERTELJAEHRLICH', 'ERNTE');
-- CreateEnum
CREATE TYPE "MedalTier" AS ENUM ('BRONZE', 'SILBER', 'GOLD', 'ABO');
-- CreateEnum
CREATE TYPE "SponsorshipStatus" AS ENUM ('AUSSTEHEND', 'FEHLER', 'BEREIT', 'GEDRUCKT');
-- CreateEnum
CREATE TYPE "SubscriptionStatus" AS ENUM ('AUSSTEHEND', 'AKTIV', 'IM_VERZUG', 'GEKUENDIGT', 'BEENDET');
-- CreateEnum
CREATE TYPE "DeliveryStatus" AS ENUM ('ANGELEGT', 'ZURUECKGESTELLT');
-- CreateEnum
CREATE TYPE "LoyaltyKind" AS ENUM ('STAMMKUNDE', 'ABO');
-- AlterEnum
ALTER TYPE "ProductCategory" ADD VALUE 'PATENSCHAFT';
-- AlterEnum
ALTER TYPE "FulfillmentMethod" ADD VALUE 'DIGITAL';
-- AlterTable
ALTER TABLE "customers" ADD COLUMN "sepaBlockedAt" TIMESTAMP(3),
ADD COLUMN "stripeCustomerId" TEXT;
-- AlterTable
ALTER TABLE "order_items" ADD COLUMN "personalization" JSONB,
ADD COLUMN "sponsorshipOfferId" TEXT,
ADD COLUMN "sponsorshipRole" TEXT;
-- CreateTable
CREATE TABLE "sponsorship_offers" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"kind" "SponsorshipOfferKind" NOT NULL,
"name" TEXT NOT NULL,
"nameEn" TEXT,
"description" TEXT NOT NULL,
"descriptionEn" TEXT,
"features" JSONB NOT NULL DEFAULT '[]',
"featuresEn" JSONB NOT NULL DEFAULT '[]',
"priceCents" INTEGER NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isPhysical" BOOLEAN NOT NULL DEFAULT false,
"productId" TEXT,
"honeyProductId" TEXT,
"honeyJars" INTEGER NOT NULL DEFAULT 0,
"honeyShareCentsPerJar" INTEGER NOT NULL DEFAULT 0,
"honeyShareCentsPerMonth" INTEGER NOT NULL DEFAULT 0,
"cycle" "SponsorshipCycle",
"maxActiveSubscriptions" INTEGER,
"stripeProductId" TEXT,
"stripePriceId" TEXT,
"stripePriceAmountCents" INTEGER,
"medalTier" "MedalTier" NOT NULL DEFAULT 'BRONZE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "sponsorship_offers_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sponsorships" (
"id" TEXT NOT NULL,
"number" TEXT NOT NULL,
"ownerId" TEXT NOT NULL,
"offerId" TEXT NOT NULL,
"orderItemId" TEXT,
"subscriptionId" TEXT,
"locale" TEXT NOT NULL DEFAULT 'de',
"startDate" DATE NOT NULL,
"endDate" DATE,
"status" "SponsorshipStatus" NOT NULL DEFAULT 'AUSSTEHEND',
"currentRevision" INTEGER NOT NULL DEFAULT 0,
"pdfAttempts" INTEGER NOT NULL DEFAULT 0,
"nextAttemptAt" TIMESTAMP(3),
"lastError" TEXT,
"mailSentAt" TIMESTAMP(3),
"printedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "sponsorships_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sponsorship_revisions" (
"id" TEXT NOT NULL,
"sponsorshipId" TEXT NOT NULL,
"revision" INTEGER NOT NULL,
"personalization" JSONB NOT NULL,
"term" JSONB NOT NULL,
"text" JSONB NOT NULL,
"templateVersion" TEXT NOT NULL,
"pdfPath" TEXT NOT NULL,
"pdfSha256" TEXT NOT NULL,
"createdById" TEXT,
"createdByLabel" TEXT NOT NULL,
"reason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "sponsorship_revisions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sponsorship_counters" (
"year" INTEGER NOT NULL,
"lastNumber" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "sponsorship_counters_pkey" PRIMARY KEY ("year")
);
-- CreateTable
CREATE TABLE "sponsorship_transfers" (
"id" TEXT NOT NULL,
"sponsorshipId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdById" TEXT NOT NULL,
"claimedById" TEXT,
"claimedAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "sponsorship_transfers_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sponsorship_subscriptions" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"offerId" TEXT NOT NULL,
"status" "SubscriptionStatus" NOT NULL DEFAULT 'AUSSTEHEND',
"personalization" JSONB NOT NULL,
"locale" TEXT NOT NULL DEFAULT 'de',
"stripeCheckoutSessionId" TEXT,
"stripeSubscriptionId" TEXT,
"startedAt" TIMESTAMP(3),
"currentPeriodEnd" TIMESTAMP(3),
"cancelAt" TIMESTAMP(3),
"endedAt" TIMESTAMP(3),
"delinquentSince" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "sponsorship_subscriptions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "subscription_payments" (
"id" TEXT NOT NULL,
"subscriptionId" TEXT NOT NULL,
"stripeInvoiceId" TEXT NOT NULL,
"stripePaymentIntentId" TEXT,
"paymentMethodType" TEXT,
"amountCents" INTEGER NOT NULL,
"paidAt" TIMESTAMP(3) NOT NULL,
"periodStart" TIMESTAMP(3) NOT NULL,
"periodEnd" TIMESTAMP(3) NOT NULL,
"countsFrom" TIMESTAMP(3) NOT NULL,
"reversedAt" TIMESTAMP(3),
"deliveryId" TEXT,
"orderId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "subscription_payments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "subscription_deliveries" (
"id" TEXT NOT NULL,
"subscriptionId" TEXT NOT NULL,
"dueAt" TIMESTAMP(3) NOT NULL,
"jars" INTEGER NOT NULL,
"status" "DeliveryStatus" NOT NULL,
"orderId" TEXT,
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "subscription_deliveries_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "harvest_windows" (
"id" TEXT NOT NULL,
"offerId" TEXT NOT NULL,
"startsOn" DATE NOT NULL,
"endsOn" DATE NOT NULL,
"plannedJars" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "harvest_windows_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "loyalty_levels" (
"id" TEXT NOT NULL,
"kind" "LoyaltyKind" NOT NULL,
"threshold" INTEGER,
"name" TEXT NOT NULL,
"nameEn" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "loyalty_levels_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "sponsorship_offers_slug_key" ON "sponsorship_offers"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "sponsorship_offers_productId_key" ON "sponsorship_offers"("productId");
-- CreateIndex
CREATE INDEX "sponsorship_offers_kind_isActive_sortOrder_idx" ON "sponsorship_offers"("kind", "isActive", "sortOrder");
-- CreateIndex
CREATE INDEX "sponsorship_offers_honeyProductId_idx" ON "sponsorship_offers"("honeyProductId");
-- CreateIndex
CREATE UNIQUE INDEX "sponsorships_number_key" ON "sponsorships"("number");
-- CreateIndex
CREATE UNIQUE INDEX "sponsorships_orderItemId_key" ON "sponsorships"("orderItemId");
-- CreateIndex
CREATE UNIQUE INDEX "sponsorships_subscriptionId_key" ON "sponsorships"("subscriptionId");
-- CreateIndex
CREATE INDEX "sponsorships_ownerId_idx" ON "sponsorships"("ownerId");
-- CreateIndex
CREATE INDEX "sponsorships_status_nextAttemptAt_idx" ON "sponsorships"("status", "nextAttemptAt");
-- CreateIndex
CREATE INDEX "sponsorships_offerId_idx" ON "sponsorships"("offerId");
-- CreateIndex
CREATE UNIQUE INDEX "sponsorship_revisions_sponsorshipId_revision_key" ON "sponsorship_revisions"("sponsorshipId", "revision");
-- CreateIndex
CREATE UNIQUE INDEX "sponsorship_transfers_tokenHash_key" ON "sponsorship_transfers"("tokenHash");
-- CreateIndex
CREATE INDEX "sponsorship_transfers_sponsorshipId_idx" ON "sponsorship_transfers"("sponsorshipId");
-- CreateIndex
CREATE UNIQUE INDEX "sponsorship_subscriptions_stripeCheckoutSessionId_key" ON "sponsorship_subscriptions"("stripeCheckoutSessionId");
-- CreateIndex
CREATE UNIQUE INDEX "sponsorship_subscriptions_stripeSubscriptionId_key" ON "sponsorship_subscriptions"("stripeSubscriptionId");
-- CreateIndex
CREATE INDEX "sponsorship_subscriptions_customerId_idx" ON "sponsorship_subscriptions"("customerId");
-- CreateIndex
CREATE INDEX "sponsorship_subscriptions_offerId_status_idx" ON "sponsorship_subscriptions"("offerId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "subscription_payments_stripeInvoiceId_key" ON "subscription_payments"("stripeInvoiceId");
-- CreateIndex
CREATE UNIQUE INDEX "subscription_payments_orderId_key" ON "subscription_payments"("orderId");
-- CreateIndex
CREATE INDEX "subscription_payments_subscriptionId_paidAt_idx" ON "subscription_payments"("subscriptionId", "paidAt");
-- CreateIndex
CREATE UNIQUE INDEX "subscription_deliveries_orderId_key" ON "subscription_deliveries"("orderId");
-- CreateIndex
CREATE INDEX "subscription_deliveries_subscriptionId_dueAt_idx" ON "subscription_deliveries"("subscriptionId", "dueAt");
-- CreateIndex
CREATE INDEX "harvest_windows_offerId_startsOn_idx" ON "harvest_windows"("offerId", "startsOn");
-- CreateIndex
CREATE UNIQUE INDEX "loyalty_levels_kind_threshold_key" ON "loyalty_levels"("kind", "threshold");
-- CreateIndex
CREATE UNIQUE INDEX "customers_stripeCustomerId_key" ON "customers"("stripeCustomerId");
-- AddForeignKey
ALTER TABLE "sponsorship_offers" ADD CONSTRAINT "sponsorship_offers_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sponsorship_offers" ADD CONSTRAINT "sponsorship_offers_honeyProductId_fkey" FOREIGN KEY ("honeyProductId") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sponsorships" ADD CONSTRAINT "sponsorships_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sponsorships" ADD CONSTRAINT "sponsorships_offerId_fkey" FOREIGN KEY ("offerId") REFERENCES "sponsorship_offers"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sponsorships" ADD CONSTRAINT "sponsorships_orderItemId_fkey" FOREIGN KEY ("orderItemId") REFERENCES "order_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sponsorships" ADD CONSTRAINT "sponsorships_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "sponsorship_subscriptions"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sponsorship_revisions" ADD CONSTRAINT "sponsorship_revisions_sponsorshipId_fkey" FOREIGN KEY ("sponsorshipId") REFERENCES "sponsorships"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sponsorship_transfers" ADD CONSTRAINT "sponsorship_transfers_sponsorshipId_fkey" FOREIGN KEY ("sponsorshipId") REFERENCES "sponsorships"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sponsorship_subscriptions" ADD CONSTRAINT "sponsorship_subscriptions_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sponsorship_subscriptions" ADD CONSTRAINT "sponsorship_subscriptions_offerId_fkey" FOREIGN KEY ("offerId") REFERENCES "sponsorship_offers"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "subscription_payments" ADD CONSTRAINT "subscription_payments_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "sponsorship_subscriptions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "subscription_payments" ADD CONSTRAINT "subscription_payments_deliveryId_fkey" FOREIGN KEY ("deliveryId") REFERENCES "subscription_deliveries"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "subscription_payments" ADD CONSTRAINT "subscription_payments_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "subscription_deliveries" ADD CONSTRAINT "subscription_deliveries_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "sponsorship_subscriptions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "subscription_deliveries" ADD CONSTRAINT "subscription_deliveries_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "harvest_windows" ADD CONSTRAINT "harvest_windows_offerId_fkey" FOREIGN KEY ("offerId") REFERENCES "sponsorship_offers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+365
View File
@@ -76,6 +76,17 @@ model Customer {
wishlistItems WishlistItem[]
auditLogs AuditLog[]
/// Stripe-Kunde fuer Abos (Stripe Billing). Erst beim ersten Abo-Abschluss
/// angelegt; Einmalkaeufe laufen weiter ohne Stripe-Kunde.
stripeCustomerId String? @unique
/// Gesetzt nach einer SEPA-Rueckbuchung: neue Abos dieses Kontos nur noch
/// mit Karte oder Wallet (siehe lib/sponsorship-subscription). Der Admin
/// kann die Sperre aufheben.
sepaBlockedAt DateTime?
sponsorships Sponsorship[]
subscriptions SponsorshipSubscription[]
@@map("customers")
}
@@ -213,6 +224,10 @@ enum ProductCategory {
KERZEN
PROPOLIS
SONSTIGES
/// Traegerprodukt einer Einmal-Patenschaft (siehe SponsorshipOffer). Nicht
/// im Shop gelistet; es haelt Preis, Steuersatz und Rechnungsposition, damit
/// Warenkorb, Checkout und Rechnung keinen Sonderweg brauchen.
PATENSCHAFT
}
model Product {
@@ -291,6 +306,11 @@ model Product {
wishlistItems WishlistItem[]
stockNotifications StockNotification[]
/// Einmal-Patenschaft, deren Traegerprodukt dieses Produkt ist.
sponsorshipOffer SponsorshipOffer? @relation("SponsorshipOfferProduct")
/// Patenschafts-Angebote, deren Honig aus diesem Produkt kommt.
sponsorshipHoneyFor SponsorshipOffer[] @relation("SponsorshipOfferHoney")
@@index([archivedAt])
@@map("products")
}
@@ -384,6 +404,9 @@ enum OrderStatus {
enum FulfillmentMethod {
VERSAND
ABHOLUNG
/// Nur digitale Leistungen (Patenschaft ohne Paket, Abo-Monatsrechnung):
/// nichts zu versenden, nichts abzuholen. Die Rechnungsanschrift bleibt.
DIGITAL
}
model Order {
@@ -462,6 +485,10 @@ model Order {
invoice Invoice?
returnRequest ReturnRequest?
withdrawalDeclarations WithdrawalDeclaration[]
/// Gesetzt, wenn diese Bestellung eine bezahlte Abo-Monatsrechnung abbildet.
subscriptionPayment SubscriptionPayment?
/// Gesetzt, wenn diese Bestellung eine Abo-Honiglieferung ist (0 €).
subscriptionDelivery SubscriptionDelivery?
/// Bedient sowohl "Meine Bestellungen" (Filter nach customerId, sortiert nach
/// createdAt) als auch den reinen customerId-Filter beim Datenexport.
@@ -539,7 +566,20 @@ model OrderItem {
/// bestaetigt ist oder das Produkt nicht mit ApiCore verknuepft ist.
apicoreAllocations Json?
/// Personalisierung einer Patenschaft, wie sie beim Kauf bestaetigt wurde
/// (lib/certificate/schema.ts), samt Vorlagenversion. Nur an der
/// Hauptposition (sponsorshipRole "MAIN"); daraus entsteht nach der Zahlung
/// die erste Revision der Urkunde.
personalization Json?
/// Angebot, zu dem diese Position gehoert — an Haupt- UND Honigposition,
/// damit Warenkorb, Rechnung und Admin sie als ein Paket zeigen koennen.
sponsorshipOfferId String?
/// "MAIN" (Patenschaft, Regelsteuersatz) oder "HONEY" (Honiganteil des
/// Pakets zum Honig-Satz). Null bei gewoehnlichen Positionen.
sponsorshipRole String?
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
sponsorship Sponsorship?
product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
batch Batch? @relation(fields: [batchId], references: [id], onDelete: Restrict)
apicoreStockEvents ApiCoreStockEvent[]
@@ -951,3 +991,328 @@ model WithdrawalDeclaration {
@@index([processedAt])
@@map("withdrawal_declarations")
}
// ---------------------------------------------------------------------------
// Bienenpatenschaft (docs/superpowers/specs/2026-10-16-bienenpatenschaft-design.md)
// ---------------------------------------------------------------------------
enum SponsorshipOfferKind {
EINMALIG
ABO
}
enum SponsorshipCycle {
/// Lieferanspruch nach jeweils drei bezahlten Monaten.
VIERTELJAEHRLICH
/// Lieferung in den Erntefenstern, frueheste nach sechs bezahlten Monaten
/// seit der letzten Lieferung.
ERNTE
}
/// Praegung der Patenschafts-Medaille (lib/medals).
enum MedalTier {
BRONZE
SILBER
GOLD
ABO
}
enum SponsorshipStatus {
/// Bezahlt, PDF noch nicht erzeugt oder Mail noch nicht versendet.
AUSSTEHEND
/// Alle automatischen Versuche aufgebraucht — braucht den Admin.
FEHLER
/// PDF liegt vor, Mail ist raus.
BEREIT
/// Nur physische Pakete: Urkunde gedruckt.
GEDRUCKT
}
enum SubscriptionStatus {
/// Checkout gestartet, bei Stripe noch nicht abgeschlossen.
AUSSTEHEND
AKTIV
/// Zahlung offen oder zurueckgebucht — keine Lieferungen, Stufe ruht.
IM_VERZUG
/// Gekuendigt, laeuft bis zum Ende des bezahlten Monats.
GEKUENDIGT
BEENDET
}
enum DeliveryStatus {
/// Bestellung fuer den Versand angelegt.
ANGELEGT
/// Faellig, aber ohne Bestand oder Anspruch zurueckgestellt.
ZURUECKGESTELLT
}
enum LoyaltyKind {
STAMMKUNDE
ABO
}
/// Ein im Admin pflegbares Patenschafts-Angebot (Einmalpaket oder Abo).
model SponsorshipOffer {
id String @id @default(cuid())
slug String @unique
kind SponsorshipOfferKind
name String
nameEn String?
description String @db.Text
descriptionEn String? @db.Text
/// Leistungsliste als JSON-Array von Strings (lib/json-array.ts).
features Json @default("[]")
featuresEn Json @default("[]")
/// EINMALIG: Bruttopreis des ganzen Pakets. ABO: Monatspreis brutto.
priceCents Int
isActive Boolean @default(true)
sortOrder Int @default(0)
/// Mit gedruckter Urkunde, Honig, Grusskarte und Versand.
isPhysical Boolean @default(false)
/// Nur EINMALIG: Traegerprodukt (Kategorie PATENSCHAFT) fuer Warenkorb,
/// Checkout und Rechnung. Sein Preis ist Paketpreis minus Honiganteil.
productId String? @unique
/// Honigprodukt, dessen Bestand das Paket bzw. Abo verbraucht.
honeyProductId String?
/// EINMALIG: Glaeser im Paket. ABO: Glaeser je Lieferung.
honeyJars Int @default(0)
/// EINMALIG: Honiganteil des Paketpreises je Glas (zum Honig-Steuersatz).
honeyShareCentsPerJar Int @default(0)
/// ABO: Honiganteil des Monatspreises (zum Honig-Steuersatz).
honeyShareCentsPerMonth Int @default(0)
/// Nur ABO.
cycle SponsorshipCycle?
/// Nur ABO: Hoechstzahl aktiver Abos (Kontingent); null = unbegrenzt.
maxActiveSubscriptions Int?
/// Nur ABO: Stripe-Produkt und der Preis, der priceCents abbildet. Bei einer
/// Preisaenderung entsteht ein neuer Stripe-Preis; laufende Abos behalten
/// ihren alten.
stripeProductId String?
stripePriceId String?
stripePriceAmountCents Int?
medalTier MedalTier @default(BRONZE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
product Product? @relation("SponsorshipOfferProduct", fields: [productId], references: [id], onDelete: Restrict)
honeyProduct Product? @relation("SponsorshipOfferHoney", fields: [honeyProductId], references: [id], onDelete: SetNull)
sponsorships Sponsorship[]
subscriptions SponsorshipSubscription[]
harvestWindows HarvestWindow[]
@@index([kind, isActive, sortOrder])
@@index([honeyProductId])
@@map("sponsorship_offers")
}
/// Eine gekaufte Patenschaft (eine Biene, ein Name, eine Urkunde).
model Sponsorship {
id String @id @default(cuid())
/// WH-P-JJJJ-NNNNN, fortlaufend ueber SponsorshipCounter.
number String @unique
ownerId String
offerId String
/// Einmalkauf: die Hauptposition der Bestellung. Eindeutig — das ist der
/// Idempotenzschluessel gegen doppelt zugestellte Webhooks.
orderItemId String? @unique
/// Abo: das Abo, aus dem die Patenschaft entstand.
subscriptionId String? @unique
locale String @default("de")
startDate DateTime @db.Date
/// Leer bei laufendem Abo.
endDate DateTime? @db.Date
status SponsorshipStatus @default(AUSSTEHEND)
/// Nummer der aktuellen Revision; 0 = noch keine Urkunde erzeugt.
currentRevision Int @default(0)
pdfAttempts Int @default(0)
nextAttemptAt DateTime?
lastError String? @db.Text
mailSentAt DateTime?
printedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner Customer @relation(fields: [ownerId], references: [id], onDelete: Cascade)
offer SponsorshipOffer @relation(fields: [offerId], references: [id], onDelete: Restrict)
orderItem OrderItem? @relation(fields: [orderItemId], references: [id], onDelete: SetNull)
subscription SponsorshipSubscription? @relation(fields: [subscriptionId], references: [id], onDelete: SetNull)
revisions SponsorshipRevision[]
transfers SponsorshipTransfer[]
@@index([ownerId])
@@index([status, nextAttemptAt])
@@index([offerId])
@@map("sponsorships")
}
/// Unveraenderlicher Stand einer Urkunde. Korrekturen und das Abo-Ende
/// erzeugen neue Revisionen; alte bleiben samt PDF erhalten.
model SponsorshipRevision {
id String @id @default(cuid())
sponsorshipId String
revision Int
/// Personalisierung wie bestaetigt (lib/certificate/schema.ts).
personalization Json
/// Laufzeitform (FIXED/OPEN/ENDED) mit Daten.
term Json
/// Aufgeloeste Textbausteine — Beleg dessen, was auf der Urkunde steht.
text Json
templateVersion String
/// Relativ zu CERTIFICATE_DIR.
pdfPath String
pdfSha256 String
/// Admin-Konto bei Korrekturen, null bei System-Revisionen.
createdById String?
/// Lesbarer Ersteller ("System" oder die Admin-Adresse) — bleibt lesbar,
/// auch wenn das Admin-Konto spaeter geloescht wird.
createdByLabel String
reason String?
createdAt DateTime @default(now())
sponsorship Sponsorship @relation(fields: [sponsorshipId], references: [id], onDelete: Cascade)
@@unique([sponsorshipId, revision])
@@map("sponsorship_revisions")
}
/// Jahreszaehler der Patenschaftsnummern, Muster wie InvoiceCounter.
model SponsorshipCounter {
year Int @id
lastNumber Int @default(0)
@@map("sponsorship_counters")
}
/// Befristeter, einmal nutzbarer Link zur Uebertragung einer
/// Einmal-Patenschaft an ein anderes Konto. Gespeichert wird nur der Hash.
model SponsorshipTransfer {
id String @id @default(cuid())
sponsorshipId String
tokenHash String @unique
expiresAt DateTime
createdById String
claimedById String?
claimedAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
sponsorship Sponsorship @relation(fields: [sponsorshipId], references: [id], onDelete: Cascade)
@@index([sponsorshipId])
@@map("sponsorship_transfers")
}
/// Abo-Patenschaft ueber Stripe Billing.
model SponsorshipSubscription {
id String @id @default(cuid())
customerId String
offerId String
status SubscriptionStatus @default(AUSSTEHEND)
/// Personalisierung beim Abschluss bestaetigt; daraus entsteht mit der
/// ersten bezahlten Rechnung die Patenschaft.
personalization Json
locale String @default("de")
stripeCheckoutSessionId String? @unique
stripeSubscriptionId String? @unique
startedAt DateTime?
currentPeriodEnd DateTime?
/// Gesetzt nach Kuendigung: Ende des bezahlten Zeitraums.
cancelAt DateTime?
endedAt DateTime?
/// Beginn des Verzugs (fehlgeschlagene Zahlung oder Rueckbuchung).
delinquentSince DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
offer SponsorshipOffer @relation(fields: [offerId], references: [id], onDelete: Restrict)
payments SubscriptionPayment[]
deliveries SubscriptionDelivery[]
sponsorship Sponsorship?
@@index([customerId])
@@index([offerId, status])
@@map("sponsorship_subscriptions")
}
/// Eine bezahlte Abo-Monatsrechnung von Stripe.
model SubscriptionPayment {
id String @id @default(cuid())
subscriptionId String
/// Idempotenzschluessel gegen doppelt zugestellte invoice.paid-Ereignisse.
stripeInvoiceId String @unique
stripePaymentIntentId String?
paymentMethodType String?
amountCents Int
paidAt DateTime
periodStart DateTime
periodEnd DateTime
/// Ab wann diese Zahlung fuer den Lieferanspruch zaehlt (SEPA-Neukunden-
/// Frist, siehe lib/sponsorship-subscription/entitlement.ts).
countsFrom DateTime
/// Zurueckgebucht — zaehlt nie mehr fuer eine Lieferung.
reversedAt DateTime?
/// Lieferung, die diese Zahlung verbraucht hat.
deliveryId String?
/// Wabenhain-Bestellung samt Rechnung zu dieser Zahlung.
orderId String? @unique
createdAt DateTime @default(now())
subscription SponsorshipSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
delivery SubscriptionDelivery? @relation(fields: [deliveryId], references: [id], onDelete: SetNull)
order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull)
@@index([subscriptionId, paidAt])
@@map("subscription_payments")
}
/// Eine Abo-Honiglieferung (0-€-Bestellung mit Honigpositionen).
model SubscriptionDelivery {
id String @id @default(cuid())
subscriptionId String
dueAt DateTime
jars Int
status DeliveryStatus
orderId String? @unique
note String?
createdAt DateTime @default(now())
subscription SponsorshipSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull)
payments SubscriptionPayment[]
@@index([subscriptionId, dueAt])
@@map("subscription_deliveries")
}
/// Erntefenster eines Abo-Angebots mit Zyklus ERNTE.
model HarvestWindow {
id String @id @default(cuid())
offerId String
startsOn DateTime @db.Date
endsOn DateTime @db.Date
/// Fuer Abos geplante Glaeser — werden reserviert, bevor sie in den
/// Freiverkauf gehen.
plannedJars Int @default(0)
createdAt DateTime @default(now())
offer SponsorshipOffer @relation(fields: [offerId], references: [id], onDelete: Cascade)
@@index([offerId, startsOn])
@@map("harvest_windows")
}
/// Treuestufe (Stammkunde nach bezahlten Bestellungen, Abo nach bezahlten
/// Monaten). threshold null = VIP (nur ABO, nach der hoechsten Zahl).
model LoyaltyLevel {
id String @id @default(cuid())
kind LoyaltyKind
threshold Int?
name String
nameEn String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([kind, threshold])
@@map("loyalty_levels")
}
+115
View File
@@ -83,6 +83,121 @@ async function main() {
"Siehe README, wie ein bestehendes Konto nachtraeglich zu ADMIN befoerdert wird."
);
}
await seedSponsorshipDefaults();
}
/**
* Startangebote der Bienenpatenschaft und Treuestufen — nur wenn es noch
* keine gibt, und die Angebote INAKTIV: dieser Seed laeuft bei jedem
* Containerstart auch in Produktion, und Preise, Honig und Rechtstexte muss
* der Inhaber vor der Veroeffentlichung pruefen (Spec, "Geschaeftliche
* Konfiguration"). Erst das Aktivieren im Admin macht /patenschaft sichtbar.
*/
async function seedSponsorshipDefaults(): Promise<void> {
if ((await prisma.sponsorshipOffer.count()) === 0) {
const packages = [
{
slug: "bienenfreund",
name: "Bienenfreund",
nameEn: "Bee Friend",
priceCents: 1900,
isPhysical: false,
honeyJars: 0,
medalTier: "BRONZE" as const,
description: "Symbolische Namensgebung mit individuell gestalteter Urkunde als PDF.",
descriptionEn: "Symbolic naming with an individually designed certificate as a PDF.",
features: ["Symbolische Namensgebung", "Individuell gestaltete PDF-Urkunde", "Medaille und 3D-Ansicht im Kundenkonto", "Sicherer Download"],
featuresEn: ["Symbolic naming", "Individually designed PDF certificate", "Medal and 3D view in your account", "Secure download"],
},
{
slug: "honigfreund",
name: "Honigfreund",
nameEn: "Honey Friend",
priceCents: 3900,
isPhysical: true,
honeyJars: 1,
medalTier: "SILBER" as const,
description: "Digitale Leistungen plus gedruckte Urkunde, ein Glas Wabenhain-Honig, Grußkarte und Geschenkverpackung.",
descriptionEn: "Digital items plus a printed certificate, a jar of Wabenhain honey, a greeting card and gift wrapping.",
features: ["Alle digitalen Leistungen", "Hochwertig gedruckte Urkunde", "1 × 500 g Wabenhain-Honig", "Grußkarte und Geschenkverpackung"],
featuresEn: ["All digital items", "High-quality printed certificate", "1 × 500 g Wabenhain honey", "Greeting card and gift wrapping"],
},
{
slug: "wabenfreund",
name: "Wabenfreund",
nameEn: "Comb Friend",
priceCents: 5900,
isPhysical: true,
honeyJars: 3,
medalTier: "GOLD" as const,
description: "Digitale Leistungen plus gedruckte Urkunde, drei Gläser Wabenhain-Honig, Grußkarte und Geschenkverpackung.",
descriptionEn: "Digital items plus a printed certificate, three jars of Wabenhain honey, a greeting card and gift wrapping.",
features: ["Alle digitalen Leistungen", "Hochwertig gedruckte Urkunde", "3 × 500 g Wabenhain-Honig", "Grußkarte und Geschenkverpackung"],
featuresEn: ["All digital items", "High-quality printed certificate", "3 × 500 g Wabenhain honey", "Greeting card and gift wrapping"],
},
];
for (const [index, pkg] of packages.entries()) {
// Honiganteil als Vorschlag (7 € je Glas) — der Inhaber prueft ihn mit
// der Steuerberatung, bevor er das Angebot aktiviert.
const honeyShareCentsPerJar = pkg.honeyJars > 0 ? 700 : 0;
const product = await prisma.product.create({
data: {
slug: `patenschaft-${pkg.slug}`,
name: `Bienenpatenschaft ${pkg.name}`,
category: "PATENSCHAFT",
description: pkg.description,
weightGrams: 0,
priceCents: pkg.priceCents - pkg.honeyJars * honeyShareCentsPerJar,
pricePerKgCents: 0,
isActive: true,
},
});
await prisma.sponsorshipOffer.create({
data: {
slug: pkg.slug,
kind: "EINMALIG",
name: pkg.name,
nameEn: pkg.nameEn,
description: pkg.description,
descriptionEn: pkg.descriptionEn,
features: pkg.features,
featuresEn: pkg.featuresEn,
priceCents: pkg.priceCents,
isActive: false,
sortOrder: index,
isPhysical: pkg.isPhysical,
productId: product.id,
honeyJars: pkg.honeyJars,
honeyShareCentsPerJar,
medalTier: pkg.medalTier,
},
});
}
console.log("Bienenpatenschaft: drei Startangebote angelegt (inaktiv, bitte im Admin pruefen und aktivieren).");
}
if ((await prisma.loyaltyLevel.count()) === 0) {
const regular = [
{ threshold: 2, name: "Blütenbesuch", nameEn: "Blossom Visitor" },
{ threshold: 5, name: "Wabenbau", nameEn: "Comb Builder" },
{ threshold: 10, name: "Honigschatz", nameEn: "Honey Treasure" },
{ threshold: 25, name: "Goldwabe", nameEn: "Golden Comb" },
];
const subscription = Array.from({ length: 24 }, (_, i) => ({
threshold: i + 1,
name: `Stufe ${i + 1}`,
nameEn: `Level ${i + 1}`,
}));
await prisma.loyaltyLevel.createMany({
data: [
...regular.map((level) => ({ kind: "STAMMKUNDE" as const, ...level })),
...subscription.map((level) => ({ kind: "ABO" as const, ...level })),
{ kind: "ABO" as const, threshold: null, name: "VIP", nameEn: "VIP" },
],
});
console.log("Treuestufen angelegt (Stammkunde 2/5/10/25, Abo 1–24 und VIP).");
}
}
main()