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

+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")
}