Files
Wabenhain/prisma/schema.prisma
T
Nick SzaunigandClaude Opus 5.5 515fca1c16
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 2m57s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 3m7s
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
Docker Image bauen und veroeffentlichen / test (push) Canceled after 1m22s
fix: Befunde aus Sicherheits- und Korrektheitsreview der Bienenpatenschaft
- Oeffentliche Kuendigung: exakte Identifikation, Limits je Vertrag und
  Adresse, Bestaetigung nur an die Kontoadresse mit Ruecknahme-Link (HMAC)
- Vorschau: Body-Grenze beim Lesen, Parallelitaets- und Minutenlimit, Cache
- Rabatte gelten nicht fuer Patenschaftspositionen
- Abo aktiv erst nach bestaetigter Zahlung, offene Abschluesse zaehlen
  zum Kontingent und werden nach 24 h aufgeraeumt, Sitzung 58 Minuten
- Konto-Loeschung nur ohne laufendes Abo, Urkunden-Dateien werden entfernt
- Abo-Lieferungen mit Zahlungszeile (DHL-Marke moeglich)
- Shop zeigt freien Bestand nach Abo-Reservierung
- Storno/volle Erstattung widerruft die Patenschaft (Status WIDERRUFEN)
- Umsatzsteuer der Patenschaft per VAT_RATE_PERCENT_PATENSCHAFT (19 %)
- Digitale Bestellungen archivieren sich, Kalendertage in Europe/Berlin,
  Monatsenden begrenzt, Datenauskunft um Patenschaften ergaenzt

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 15:09:32 +02:00

1322 lines
50 KiB
Plaintext

// Wabenhain — Datenmodell
// Alle Geldbetraege werden als Cent-Integer gespeichert, um Rundungsfehler zu vermeiden.
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
// "provider" wird NICHT hier per Hand gepflegt: Prisma erlaubt kein env() an
// dieser Stelle, daher schreibt scripts/prisma-provider.mjs diese Zeile vor
// jedem generate/db push/migrate anhand von DATABASE_PROVIDER ("postgresql"
// [Standard] oder "mysql") neu. Siehe README, Abschnitt "Datenbank-Provider
// wechseln".
datasource db {
provider = "postgresql"
}
// ---------------------------------------------------------------------------
// Kunden
// ---------------------------------------------------------------------------
enum CustomerRole {
KUNDE
ADMIN
}
model Customer {
id String @id @default(cuid())
email String @unique
passwordHash String
firstName String
lastName String
phone String?
role CustomerRole @default(KUNDE)
/// Wird bei sicherheitsrelevanten Ereignissen (Passwort-Reset, "alle
/// Geraete abmelden", 2FA an/aus) hochgezaehlt. Im JWT eingebettet — bei
/// Abweichung von diesem Wert gilt das Token als widerrufen, obwohl es
/// technisch noch nicht abgelaufen ist.
sessionVersion Int @default(0)
/// Fuer die Konto-Sperre nach wiederholten Fehlversuchen (zusaetzlich zum
/// IP-basierten Rate-Limit — persistent und ueberlebt einen Neustart/
/// mehrere Server-Instanzen, anders als der In-Memory-Rate-Limiter).
failedLoginAttempts Int @default(0)
lockedUntil DateTime?
/// Base32-TOTP-Secret, erst gesetzt wenn 2FA aktiv bestaetigt wurde.
twoFactorSecret String?
twoFactorEnabled Boolean @default(false)
/// Zeitschritt (Unix-Zeit / 30s) des zuletzt beim Login akzeptierten
/// TOTP-Codes — verhindert, dass ein abgefangener/mitgelesener Code im
/// selben oder einem frueheren Zeitfenster ein zweites Mal funktioniert.
lastTotpStep Int?
/// Von einem Admin gesperrtes Konto (Benutzerverwaltung) — unabhaengig von
/// der automatischen Fehlversuchs-Sperre in lockedUntil. Verhindert den
/// Login bis ein Admin das Konto wieder entsperrt.
blockedAt DateTime?
/// Gesetzt, sobald die E-Mail-Adresse per Bestaetigungslink oder durch
/// erfolgreiches Setzen eines Passworts ueber einen zugesandten Reset-Link
/// nachweislich unter der Kontrolle des Kontoinhabers ist. Registrierte
/// Kund:innen benoetigen dies fuer den Checkout (siehe /api/checkout).
emailVerifiedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
orders Order[]
addresses Address[]
newsletterSubscriber NewsletterSubscriber?
passwordResetTokens PasswordResetToken[]
emailVerificationTokens EmailVerificationToken[]
twoFactorBackupCodes TwoFactorBackupCode[]
reviews Review[]
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")
}
model EmailVerificationToken {
id String @id @default(cuid())
customerId String
/// SHA-256-Hash des per E-Mail zugesandten Tokens — wie beim
/// Passwort-Reset wird nie der Klartext gespeichert.
tokenHash String @unique
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
@@map("email_verification_tokens")
}
model TwoFactorBackupCode {
id String @id @default(cuid())
customerId String
/// SHA-256-Hash des Codes — der Klartext wird dem Kunden nur einmalig bei
/// der Einrichtung angezeigt und nie wieder gespeichert.
codeHash String @unique
usedAt DateTime?
createdAt DateTime @default(now())
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
@@map("two_factor_backup_codes")
}
model Address {
id String @id @default(cuid())
customerId String
label String @default("Zuhause")
firstName String
lastName String
street String
zip String
city String
country String @default("DE")
/// Lieferung an eine DHL-Packstation statt an eine Hausanschrift. Enthaelt
/// die Packstation-Nummer, wie sie auf dem Geraet steht (z.B. "145") —
/// gefunden ueber die DHL-Standortsuche (lib/dhl-locations.ts).
/// Ist das Feld gesetzt, tragen street/zip/city zusaetzlich die lesbare
/// Anschrift der Packstation, damit Rechnung, E-Mails und Admin-Ansicht
/// unveraendert etwas Sinnvolles anzeigen.
packstationNumber String?
/// DHL-Postnummer der empfangenden Person (6-10 Ziffern). Bei Lieferung an
/// eine Packstation von DHL zwingend verlangt — ohne sie laesst sich keine
/// Versandmarke erstellen (siehe lib/dhl.ts).
postNumber String?
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
/// Adressliste und Standardadresse-Update im Kundenkonto filtern nach customerId.
@@index([customerId])
@@map("addresses")
}
// ---------------------------------------------------------------------------
// Ernten, Chargen, Lagerbestand, Produkte
// ---------------------------------------------------------------------------
model Harvest {
id String @id @default(cuid())
year Int
/// z.B. "Fruehtracht", "Sommertracht". Bewusst NICHT eindeutig je Jahr:
/// dieselbe Sorte wird oft mehrmals im Jahr geerntet.
type String
/// Oeffentliche Adresse /ernten/<slug> — siehe lib/harvest-slug.ts.
slug String @unique
/// ID der Ernte in ApiCore; Wiedererkennungs-Schluessel des Syncs
/// (admin/actions/apicore-sync.ts). null fuer von Hand angelegte Ernten.
apicoreHarvestId String? @unique
harvestDate DateTime
location String @default("Altenmuenster")
description String
/// Liste von Bild-URLs als JSON-Array (String[] ist nicht Provider-uebergreifend:
/// MySQL kennt keine nativen Skalar-Arrays) — siehe lib/json-array.ts fuer den
/// Normalisierungs-Helfer beim Lesen.
images Json @default("[]")
/// Steuert Sichtbarkeit auf den oeffentlichen Shop-/Ernten-Seiten.
/// Default true, damit alle VOR dieser Spalte angelegten Ernten sofort
/// sichtbar bleiben. Neu aus ApiCore synchronisierte Ernten werden explizit
/// mit false angelegt (siehe admin/actions/apicore-sync.ts) — vorbefuellt,
/// aber erst nach Admin-Review ueber "Veroeffentlichen" oeffentlich sichtbar.
isPublished Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
batches Batch[]
products Product[]
/// Nur noch fuer die Weiterleitung alter Adressen /ernten/<jahr>/<sorte>.
@@index([year, type])
@@map("harvests")
}
model Batch {
id String @id @default(cuid())
/// z.B. FH-26-01
code String @unique
/// ApiCore-ID der Abfuellung (BottlingBatch), aus der diese Charge stammt.
/// Stabiler Zuordnungsschluessel fuer den Sync: die Losnummer (code) kann in
/// ApiCore korrigiert werden, die ID nicht. Null bei manuell angelegten Chargen.
apicoreBottlingId String? @unique
harvestId String
fillDate DateTime
bestBeforeDate DateTime
glassCount Int
remainingGlasses Int
/// Liste von Bild-URLs als JSON-Array (String[] ist nicht Provider-uebergreifend:
/// MySQL kennt keine nativen Skalar-Arrays) — siehe lib/json-array.ts fuer den
/// Normalisierungs-Helfer beim Lesen. Eigene Fotos dieser konkreten Charge
/// (z.B. Etikett/Abfuellung), unabhaengig von Harvest.images.
images Json @default("[]")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
harvest Harvest @relation(fields: [harvestId], references: [id], onDelete: Restrict)
products Product[]
orderItems OrderItem[]
@@map("batches")
}
enum ProductCategory {
HONIG
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 {
id String @id @default(cuid())
slug String @unique
name String
category ProductCategory @default(HONIG)
/// ApiCore-ID der ABFUELLUNG (BottlingBatch), aus der dieses Produkt
/// synchronisiert wurde; null bei manuell angelegten Produkten.
///
/// Warum die Abfuellung und nicht das ApiCore-Produkt: dort ist "Waldhonig
/// 500 g" EIN Katalogartikel, an dem jedes Jahr eine neue Abfuellung haengt.
/// In Wabenhain gehoert ein Produkt dagegen zu genau einer Charge, weil hier
/// jeder Jahrgang eigenen Preis, eigenen Verkaufstext, eigenes
/// Geschmacksprofil und eigene Bewertungen hat. Ein ApiCore-Artikel wird
/// deshalb zu MEHREREN Wabenhain-Produkten, eines je Jahrgang.
///
/// Dient dem Sync als Idempotenz-Schluessel: daran erkennt ein erneuter
/// Lauf, dass diese Abfuellung schon importiert wurde. Bewusst NICHT ueber
/// den Slug geloest — der ist die oeffentliche Adresse und soll lesbar
/// bleiben ("waldhonig-2026"), darf sich also nicht nach einer fremden ID
/// richten.
apicoreBottlingId String? @unique
/// ApiCore-ID des ARTIKELS fuer hergestellte Waren (Kerzen, Propolis-
/// Produkte, ...): deren Bestand liegt in ApiCore je Herstellungscharge und
/// wird als Summe gefuehrt, ein Shop-Produkt je Artikel. Honig nutzt
/// stattdessen apicoreBottlingId. Hoechstens eines von beiden ist gesetzt;
/// ist eines gesetzt, kommt der Bestand aus ApiCore (lib/apicore-stock).
apicoreProductId String? @unique
/// Nur fuer Honig-Produkte gesetzt (Kerzen/Propolis sind keiner Ernte/Charge zugeordnet)
harvestId String?
batchId String?
description String
/// Geschmacksprofil-Tags als JSON-Array, z.B. ["mild", "floral", "feincremig"]
/// — String[] ist nicht Provider-uebergreifend (MySQL kennt keine nativen
/// Skalar-Arrays), siehe lib/json-array.ts fuer den Normalisierungs-Helfer.
tasteTags Json @default("[]")
/// Intensitaet 1-5 fuer die visuelle Geschmacksskala
intensityMild Int @default(3)
intensityFloral Int @default(3)
intensityKraeftig Int @default(3)
consistency String @default("feincremig")
weightGrams Int
priceCents Int
pricePerKgCents Int
/// Umsatzsteuersatz dieses Produkts in ganzen Prozent — uebersteuert den
/// Satz, der sich sonst aus der Kategorie ergibt (siehe lib/vat.ts).
///
/// Leer lassen ist der Normalfall: dann gilt der Satz der Kategorie, und
/// eine spaetere Aenderung der gesetzlichen Saetze wirkt ueber die
/// Konfiguration auf alle Produkte zugleich. Ein Wert hier ist fuer die
/// Ausnahmen gedacht, die sich nicht aus der Kategorie ergeben — etwa ein
/// Geschenkset, das anders einzuordnen ist als seine Kategorie vermuten
/// liesse.
vatRatePercent Int?
currency String @default("EUR")
/// Liste von Bild-URLs als JSON-Array, siehe tasteTags-Kommentar oben.
images Json @default("[]")
isActive Boolean @default(true)
/// Archiv (siehe lib/archive.ts): gesetzt = im Admin-Tab "Archiv" statt in
/// der aktiven Liste. Wird beim Oeffnen der Admin-Liste automatisch nach
/// den Regeln dort gesetzt und wieder geloescht; archivedManually
/// kennzeichnet einen Klick auf "Archivieren", den die Automatik nicht
/// rueckgaengig machen darf.
archivedAt DateTime?
archivedManually Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
harvest Harvest? @relation(fields: [harvestId], references: [id], onDelete: Restrict)
batch Batch? @relation(fields: [batchId], references: [id], onDelete: Restrict)
inventory Inventory?
orderItems OrderItem[]
reviews Review[]
apicoreStockEvents ApiCoreStockEvent[]
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")
}
model StockNotification {
id String @id @default(cuid())
productId String
email String
/// Spracheinstellung bei der Anmeldung — die Benachrichtigungs-E-Mail wird
/// spaeter asynchron (beim naechsten Bestandsupdate) versendet, ohne
/// Cookie-Kontext der urspruenglichen Anfrage.
locale String @default("de")
createdAt DateTime @default(now())
/// Gesetzt, sobald die Benachrichtigungs-E-Mail versendet wurde — verhindert
/// Mehrfachversand, falls der Bestand mehrfach zwischen 0 und >0 wechselt,
/// ohne dass sich die Kundin/der Kunde neu angemeldet hat.
notifiedAt DateTime?
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
/// Eine Anmeldung pro E-Mail-Adresse und Produkt.
@@unique([productId, email])
@@map("stock_notifications")
}
model WishlistItem {
id String @id @default(cuid())
customerId String
productId String
createdAt DateTime @default(now())
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
/// Ein Produkt kann pro Kunde nur einmal auf der Wunschliste stehen.
@@unique([customerId, productId])
@@map("wishlist_items")
}
model Review {
id String @id @default(cuid())
productId String
customerId String
/// 1-5
rating Int
comment String?
/// Nur freigeschaltete Bewertungen werden auf der Produktseite angezeigt —
/// einfache Missbrauchskontrolle durch den Admin vor Veroeffentlichung.
isApproved Boolean @default(false)
/// Vom Admin abgelehnt: bleibt im Archiv nachvollziehbar statt geloescht zu
/// werden. Offen ist eine Bewertung, solange weder isApproved noch
/// rejectedAt gesetzt ist (siehe lib/review-status.ts).
rejectedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
/// Ein Kunde kann ein Produkt nur einmal bewerten.
@@unique([productId, customerId])
@@map("reviews")
}
model Inventory {
id String @id @default(cuid())
productId String @unique
quantity Int @default(0)
updatedAt DateTime @updatedAt
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@map("inventory")
}
// ---------------------------------------------------------------------------
// Bestellungen
// ---------------------------------------------------------------------------
enum OrderStatus {
NEU
BEZAHLT
IN_VORBEREITUNG
VERSANDBEREIT
VERSENDET
ABHOLBEREIT
ABGEHOLT
STORNIERT
}
enum FulfillmentMethod {
VERSAND
ABHOLUNG
/// Nur digitale Leistungen (Patenschaft ohne Paket, Abo-Monatsrechnung):
/// nichts zu versenden, nichts abzuholen. Die Rechnungsanschrift bleibt.
DIGITAL
}
model Order {
id String @id @default(cuid())
orderNumber String @unique
customerId String?
status OrderStatus @default(NEU)
fulfillmentMethod FulfillmentMethod @default(VERSAND)
/// Spracheinstellung beim Checkout ("de"/"en") — wird fuer spaeter
/// asynchron versendete E-Mails (Stripe-Webhook) benoetigt, da dort kein
/// Cookie-/Request-Kontext mehr vorhanden ist, um die Sprache erneut zu bestimmen.
locale String @default("de")
// Kontakt- und Versanddaten werden am Bestellzeitpunkt gespeichert,
// damit Gastbestellungen unabhaengig von einem Customer-Datensatz funktionieren.
email String
firstName String
lastName String
phone String?
street String
zip String
city String
country String @default("DE")
/// Lieferung an eine DHL-Packstation statt an eine Hausanschrift. Enthaelt
/// die Packstation-Nummer, wie sie auf dem Geraet steht (z.B. "145") —
/// gefunden ueber die DHL-Standortsuche (lib/dhl-locations.ts).
/// Ist das Feld gesetzt, tragen street/zip/city zusaetzlich die lesbare
/// Anschrift der Packstation, damit Rechnung, E-Mails und Admin-Ansicht
/// unveraendert etwas Sinnvolles anzeigen.
packstationNumber String?
/// DHL-Postnummer der empfangenden Person (6-10 Ziffern). Bei Lieferung an
/// eine Packstation von DHL zwingend verlangt — ohne sie laesst sich keine
/// Versandmarke erstellen (siehe lib/dhl.ts).
postNumber String?
subtotalCents Int
shippingCents Int @default(0)
discountCents Int @default(0)
totalCents Int
currency String @default("EUR")
discountCodeId String?
stripeCheckoutSessionId String?
/// Nicht erratbares Kennzeichen, das ausschliesslich in der cancel_url der
/// Stripe-Sitzung steht. Bricht die Kundschaft dort ab, landet sie auf
/// /checkout/cancel?ref=<token>; erst dieser Token weist nach, dass der
/// Abbruch zu DIESER Bestellung gehoert, und gibt den reservierten Bestand
/// sofort wieder frei. Die Bestellnummer taugt dafuer nicht: sie steht in
/// jeder Bestaetigungsmail und auf jeder Rechnung, mit ihr koennte also
/// jemand die noch offene Zahlung einer fremden Person abbrechen.
cancelToken String? @unique
notes String?
/// Optionale persoenliche Nachricht bei Geschenkbestellungen — wird beim
/// Verpacken von Hand beigelegt (siehe Admin-Bestelldetail).
giftMessage String?
/// Archiv (siehe lib/archive.ts): gesetzt = im Admin-Tab "Archiv" statt in
/// der aktiven Liste. Wird beim Oeffnen der Admin-Liste automatisch nach
/// den Regeln dort gesetzt und wieder geloescht; archivedManually
/// kennzeichnet einen Klick auf "Archivieren", den die Automatik nicht
/// rueckgaengig machen darf.
archivedAt DateTime?
archivedManually Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
discountCode DiscountCode? @relation(fields: [discountCodeId], references: [id], onDelete: SetNull)
items OrderItem[]
payment Payment?
shipment Shipment?
pickup Pickup?
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.
@@index([customerId, createdAt])
/// Statusfilter im Admin-Bestellungen-Bereich (exakte Gleichheit).
@@index([status])
/// Datumsbereichs-Filter (Tages-/Monatsumsatz, Umsatztrend im Admin-Dashboard)
/// sowie Standard-Sortierung in Bestelllisten ohne weiteren Filter.
@@index([createdAt])
/// Nutzungszaehlung eines Rabattcodes beim Checkout (Order.count nach discountCodeId).
@@index([discountCodeId])
/// Tab-Filter Aktiv/Archiv im Admin-Bereich.
@@index([archivedAt])
@@map("orders")
}
enum ReturnStatus {
ANGEFRAGT
ANGENOMMEN
ABGELEHNT
ERSTATTET
}
/// Eine Rueckgabeanfrage pro Bestellung (orderId @unique) — deckt den Fall
/// "gesamte Bestellung zurueckschicken" ab, keine positionsweise Teil-Retoure.
model ReturnRequest {
id String @id @default(cuid())
orderId String @unique
reason String
status ReturnStatus @default(ANGEFRAGT)
/// Archiv (siehe lib/archive.ts): gesetzt = im Admin-Tab "Archiv" statt in
/// der aktiven Liste. Wird beim Oeffnen der Admin-Liste automatisch nach
/// den Regeln dort gesetzt und wieder geloescht; archivedManually
/// kennzeichnet einen Klick auf "Archivieren", den die Automatik nicht
/// rueckgaengig machen darf.
archivedAt DateTime?
archivedManually Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@index([archivedAt])
@@map("return_requests")
}
model OrderItem {
id String @id @default(cuid())
orderId String
productId String
/// Nur gesetzt, wenn das Produkt einer Charge zugeordnet ist (Honig)
batchId String?
// Snapshot der Produktdaten zum Bestellzeitpunkt (Preisaenderungen duerfen
// bestehende Bestellungen nicht rueckwirkend veraendern).
productName String
unitPriceCents Int
quantity Int
weightGrams Int
totalCents Int
/// Umsatzsteuersatz dieser Position in ganzen Prozent, abgeleitet aus der
/// Produktkategorie zum Bestellzeitpunkt (Honig ermaessigt, Kerzen voll —
/// siehe lib/vat.ts). Teil des Snapshots aus demselben Grund wie der Preis:
/// wird ein Produkt spaeter umkategorisiert oder aendert sich der Satz,
/// darf das bestehende Rechnungen nicht rueckwirkend veraendern.
///
/// Null bei Bestellungen aus der Zeit vor der Steuerausweisung — deren
/// Rechnungen zeigen weiterhin keine Steuerzeilen, weil rueckwirkend nicht
/// feststellbar ist, welcher Satz gegolten haette.
vatRatePercent Int?
/// Aus welchen ApiCore-Chargen die verkaufte Ware stammt, wie ApiCore sie
/// beim Verbuchen zugeordnet hat: [{ lotNumber, quantity }]. Fuer
/// Rueckverfolgung und Rueckruf; null, solange der Verkauf noch nicht
/// 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[]
/// Wird bei jedem Laden der Positionen einer Bestellung (Konto, Admin,
/// Rechnung) ueber die order.items-Relation abgefragt.
@@index([orderId])
/// PostgreSQL legt fuer eine Fremdschluessel-Spalte KEINEN Index an (anders
/// als MySQL/InnoDB) — ohne diesen hier laeuft jede Abfrage nach productId
/// als vollstaendiger Scan ueber die am schnellsten wachsende Tabelle des
/// Shops. Betroffen sind zwei Stellen, die das nicht vertragen:
///
/// 1. hasCustomerPurchased (lib/reviews.ts) — der Nachweis "verifizierter
/// Kauf", der bei JEDEM Aufruf einer Produktseite durch eine
/// angemeldete Kundschaft ausgefuehrt wird.
/// 2. onDelete: Restrict auf der product-Relation — die Datenbank muss
/// beim Loeschen eines Produkts pruefen, ob noch Positionen daran
/// haengen.
@@index([productId])
/// Aus demselben Grund wie productId: die batch-Relation ist ebenfalls
/// "Restrict", das Loeschen einer Charge im Admin-Bereich prueft also gegen
/// diese Tabelle.
@@index([batchId])
@@map("order_items")
}
// ---------------------------------------------------------------------------
// Bestandsabgleich mit ApiCore
// ---------------------------------------------------------------------------
enum ApiCoreStockEventKind {
SALE
RETURN
}
/// Warteschlange der Bestandsbuchungen an ApiCore (lib/apicore-stock).
///
/// Geschrieben in DERSELBEN Transaktion wie die ausloesende Statusaenderung
/// (bezahlt, storniert, erstattet), gesendet danach — sofort und per
/// CronJob nachgereicht. So geht keine Buchung verloren, wenn ApiCore gerade
/// nicht erreichbar ist. Die id geht als externalId an ApiCore; ApiCore bucht
/// jede id nur einmal, Wiederholungen sind also gefahrlos.
model ApiCoreStockEvent {
id String @id @default(cuid())
kind ApiCoreStockEventKind
orderItemId String
/// Wabenhain-Produkt, dessen Shop-Bestand von dieser Buchung abhaengt.
productId String
/// Ziel in ApiCore: eine Abfuellung (Honig) ODER ein Artikel (FIFO ueber
/// dessen Herstellungschargen).
apicoreBottlingId String?
apicoreProductId String?
quantity Int
/// Bei RETURN: der Verkauf, der zurueckgebucht wird.
reversesEventId String?
createdAt DateTime @default(now())
/// Von ApiCore bestaetigt (neu gebucht oder schon bekannt).
sentAt DateTime?
/// Von ApiCore abgelehnt — wird nicht wiederholt, das Dashboard zeigt es.
rejectedAt DateTime?
attempts Int @default(0)
lastError String?
nextAttemptAt DateTime @default(now())
orderItem OrderItem @relation(fields: [orderItemId], references: [id], onDelete: Restrict)
product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
reverses ApiCoreStockEvent? @relation("StockEventReversal", fields: [reversesEventId], references: [id], onDelete: Restrict)
reversedBy ApiCoreStockEvent[] @relation("StockEventReversal")
@@index([sentAt, rejectedAt, nextAttemptAt])
@@index([orderItemId])
@@index([productId])
@@map("apicore_stock_events")
}
enum PaymentStatus {
PENDING
SUCCEEDED
FAILED
REFUNDED
}
model Payment {
id String @id @default(cuid())
orderId String @unique
provider String @default("stripe")
stripePaymentIntentId String?
status PaymentStatus @default(PENDING)
amountCents Int
currency String @default("EUR")
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@map("payments")
}
model Shipment {
id String @id @default(cuid())
orderId String @unique
carrier String?
trackingNumber String?
shippedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@map("shipments")
}
model Pickup {
id String @id @default(cuid())
orderId String @unique
location String @default("Altenmuenster")
readyAt DateTime?
pickedUpAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@map("pickups")
}
/// Laufende Nummer je Kalenderjahr fuer die Rechnungsstellung.
///
/// § 14 Abs. 4 Nr. 4 UStG verlangt eine FORTLAUFENDE Nummer. Eine
/// Zufallsnummer waere zwar eindeutig, aber nicht fortlaufend — bei einer
/// Betriebspruefung liesse sich damit nicht zeigen, dass keine Rechnung
/// fehlt.
///
/// Eigene Tabelle statt "hoechste bestehende Nummer + 1": beim Hochzaehlen
/// sperrt die Datenbank genau diese eine Zeile, zwei gleichzeitig bezahlte
/// Bestellungen bekommen dadurch zwangslaeufig verschiedene Nummern. Ein
/// Maximum-Abfragen waere dagegen ein klassisches Wettrennen — beide lesen
/// dieselbe hoechste Nummer, beide zaehlen auf denselben Wert hoch.
model InvoiceCounter {
/// Kalenderjahr; je Jahr beginnt die Zaehlung neu bei 1.
year Int @id
lastNumber Int @default(0)
@@map("invoice_counters")
}
model Invoice {
id String @id @default(cuid())
orderId String @unique
invoiceNumber String @unique
issuedAt DateTime @default(now())
totalCents Int
// Kein Steuersatz an der Rechnung: eine Bestellung kann Waren mit
// VERSCHIEDENEN Saetzen enthalten (Honig 7%, Bienenwachskerze 19%). Der
// Satz haengt deshalb an der einzelnen Position (OrderItem.vatRatePercent),
// und die Aufstellung je Satz rechnet lib/vat.ts daraus aus.
/// Pfad des abgelegten PDFs, RELATIV zu INVOICE_DIR — z.B.
/// "2026/mustermann-erika/RE-2026-0001.pdf". Siehe lib/invoice-storage.ts.
///
/// Null bei Rechnungen aus der Zeit vor dieser Ablage: deren PDF wird beim
/// Abruf weiterhin neu erzeugt. Der Pfad wird gespeichert statt jedes Mal
/// aus Jahr und Namen neu berechnet, damit eine spaetere Aenderung an der
/// Pfadbildung keine bereits abgelegten Belege unauffindbar macht.
///
/// Hiess vorher "pdfUrl" und wurde nirgends gelesen — eine URL waere auch
/// das falsche Konzept gewesen: das Dokument liegt bewusst NICHT unter
/// einem oeffentlich abrufbaren Pfad.
pdfPath String?
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
cancellation CancellationInvoice?
@@map("invoices")
}
/// Eigener fortlaufender Nummernkreis der Stornorechnungen (ST-JJJJ-NNNN),
/// je Kalenderjahr — aus demselben Grund eine Zeile mit Sperre wie
/// InvoiceCounter.
model CancellationCounter {
year Int @id
lastNumber Int @default(0)
@@map("cancellation_counters")
}
/// Stornorechnung: hebt eine Rechnung vollstaendig auf. Eine Rechnung wird
/// nie geloescht oder geaendert (§ 14 UStG, GoBD); wird die Bestellung
/// storniert oder voll erstattet, entsteht stattdessen dieser Gegenbeleg mit
/// eigener Nummer und Bezug auf die Rechnung (lib/cancellation-invoice.ts).
/// Hoechstens einer je Rechnung. ERPNext uebernimmt ihn als
/// Rechnungskorrektur, die Ausgangsrechnung steht dort dann auf
/// "Credit Note Issued".
model CancellationInvoice {
id String @id @default(cuid())
invoiceId String @unique
number String @unique
issuedAt DateTime @default(now())
/// Betrag der aufgehobenen Rechnung (brutto, positiv).
totalCents Int
/// STORNIERUNG (Admin), RETOURE (Retoure erstattet) oder ERSTATTUNG (volle Erstattung bei Stripe).
reason String
/// Wie Invoice.pdfPath, relativ zu INVOICE_DIR.
pdfPath String?
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
@@index([issuedAt])
@@map("cancellation_invoices")
}
// ---------------------------------------------------------------------------
// Marketing / Kontakt
// ---------------------------------------------------------------------------
model NewsletterSubscriber {
id String @id @default(cuid())
email String @unique
customerId String? @unique
confirmed Boolean @default(false)
/// Dient sowohl der Bestaetigung (Double-Opt-In) als auch als dauerhafter
/// Abmelde-Link fuer Abonnenten ohne Kundenkonto.
confirmationToken String? @unique
subscribedAt DateTime @default(now())
/// Zeitpunkt des Klicks auf den Double-Opt-in-Link — Nachweis der
/// Einwilligung (Art. 7 Abs. 1 DSGVO). Null bei Altbestand vor Einfuehrung.
confirmedAt DateTime?
unsubscribedAt DateTime?
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
@@map("newsletter_subscribers")
}
model PasswordResetToken {
id String @id @default(cuid())
customerId String
/// SHA-256-Hash des dem Kunden per E-Mail zugesandten Tokens — wie bei
/// Passwoertern wird nie der Klartext gespeichert.
tokenHash String @unique
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
@@map("password_reset_tokens")
}
enum BusinessPackage {
KLASSIK
GESCHENK
INDIVIDUELL
}
enum InquiryStatus {
NEU
IN_BEARBEITUNG
ABGESCHLOSSEN
ABGELEHNT
}
// ---------------------------------------------------------------------------
// Versand
// ---------------------------------------------------------------------------
model ShippingTier {
id String @id @default(cuid())
/// Menge (Stueck ueber alle Artikel im Warenkorb zusammengezaehlt) ab der
/// diese Staffel gilt.
minQuantity Int
/// Bis einschliesslich dieser Menge — null bedeutet "und mehr" (offene,
/// oberste Staffel).
maxQuantity Int?
priceCents Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("shipping_tiers")
}
// ---------------------------------------------------------------------------
// Rabattcodes
// ---------------------------------------------------------------------------
enum DiscountType {
PERCENT
FIXED
}
model DiscountCode {
id String @id @default(cuid())
code String @unique
type DiscountType
/// PERCENT: Prozentpunkte (1-100). FIXED: Cent-Betrag.
value Int
/// Mindestbestellwert (Cent) fuer die Anwendung, 0 = keine Mindestsumme.
minOrderCents Int @default(0)
/// Mindestmenge (Summe aller Artikel im Warenkorb) fuer die Anwendung —
/// null = keine Mengenschwelle. Gesetzt macht aus dem Code einen
/// Mengenrabatt: Checkout wendet ihn automatisch an, sobald die Schwelle
/// erreicht ist (siehe api/checkout/route.ts), ohne dass der Code
/// eingegeben werden muss.
minQuantity Int?
/// Nutzungslimit insgesamt ueber alle Kunden — null = unbegrenzt.
maxUses Int?
usedCount Int @default(0)
isActive Boolean @default(true)
validFrom DateTime?
validUntil DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
orders Order[]
@@map("discount_codes")
}
// ---------------------------------------------------------------------------
// Admin-Audit-Log
// ---------------------------------------------------------------------------
/// Protokolliert sicherheitsrelevante Admin-Mutationen (wer hat wann was
/// geaendert) — wichtig bei mehreren Admin-Konten, um Aenderungen
/// nachvollziehen zu koennen. adminEmail ist ein Snapshot, damit der
/// Eintrag auch nach Loeschung des Admin-Kontos lesbar bleibt.
model AuditLog {
id String @id @default(cuid())
adminId String?
adminEmail String
/// z.B. "product.update", "order.status_change", "discount_code.delete"
action String
entityType String
entityId String?
detail String?
createdAt DateTime @default(now())
admin Customer? @relation(fields: [adminId], references: [id], onDelete: SetNull)
/// Admin-Audit-Log-Seite sortiert unbegrenzt wachsende Tabelle nach
/// createdAt absteigend (ORDER BY + LIMIT ohne weiteren Filter).
@@index([createdAt])
@@map("audit_logs")
}
/// Admin-editierbare Website-Inhalte (Firmendaten, Homepage-Texte,
/// Rechtstexte) als generisches Key-Value-Overlay ueber den statischen
/// i18n-Dictionaries — siehe lib/content-registry.ts fuer die Liste aller
/// editierbaren Schluessel und deren Fallback-Werte. Fehlt eine Zeile (kein
/// Admin-Override gepflegt), liefert getContentValue() den urspruenglichen
/// Dictionary-/site-config-Text — die Seite bricht also nie, auch bevor die
/// erste Bearbeitung stattgefunden hat.
model SiteContent {
/// z.B. "company.email", "home.philosophy.items", "legal.impressum"
key String @id
/// Bei nicht lokalisierten Feldern (Firmendaten) einziger Speicherort;
/// bei lokalisierten Feldern (Homepage/Rechtstexte) die deutsche Fassung.
valueDe String @db.Text
/// Nur bei lokalisierten Feldern gesetzt — null bei Firmendaten.
valueEn String? @db.Text
updatedAt DateTime @updatedAt
@@map("site_content")
}
model BusinessInquiry {
id String @id @default(cuid())
companyName String
contactPerson String
email String
phone String?
desiredQuantity Int
desiredDeliveryDate DateTime?
package BusinessPackage @default(KLASSIK)
message String?
status InquiryStatus @default(NEU)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("business_inquiries")
}
/// Widerrufserklaerung ueber die elektronische Widerrufsfunktion
/// (§ 356a BGB, Seite /vertrag-widerrufen). Wird IMMER gespeichert, auch wenn
/// Bestellnummer und E-Mail zu keiner Bestellung passen: der Widerruf ist mit
/// dem Zugang erklaert, die Zuordnung ist unsere Sache. orderId ist nur die
/// automatische Zuordnung, wenn beides passt.
model WithdrawalDeclaration {
id String @id @default(cuid())
name String
/// Wortlaut wie eingegeben, nicht normalisiert — Beleg dessen, was erklaert wurde.
orderNumber String
email String
message String?
locale String @default("de")
orderId String?
/// Zugangszeitpunkt, steht so in der Eingangsbestaetigung (§ 356a Abs. 4 BGB).
receivedAt DateTime @default(now())
/// Gesetzt, sobald der Mailserver die Eingangsbestaetigung angenommen hat.
confirmationSentAt DateTime?
/// Im Admin als erledigt markiert (Ware zurueck, Erstattung veranlasst).
processedAt DateTime?
order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull)
@@index([receivedAt])
@@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
/// Einmalkauf storniert oder voll erstattet (Stornorechnung): keine
/// Downloads, keine Uebertragung, keine Ausstellung mehr.
WIDERRUFEN
}
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")
}