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
@@ -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")
|
||||
}
|
||||
Reference in new issue
Block a user