Ein einziger Satz fuer den ganzen Shop war fachlich falsch: der Steuersatz haengt in Deutschland an der Art der Ware, nicht am Betrieb. Honig ist ein Lebensmittel und ermaessigt besteuert (Anlage 2 zum UStG), eine Bienenwachskerze nicht. Der Satz wird jetzt in drei Stufen bestimmt, die erste gewinnt: 1. Produkt — neues Feld "Umsatzsteuersatz" unter Admin -> Produkte, fuer Ausnahmen, die sich nicht aus der Kategorie ergeben. 2. Kategorie — VAT_RATE_PERCENT_HONIG / _KERZEN / _PROPOLIS. 3. VAT_RATE_PERCENT als gemeinsamer Rueckfallwert. Die Kategorie-Stufe ist der Normalfall: aendern sich die gesetzlichen Saetze, genuegt eine Aenderung an der Konfiguration statt an jedem Produkt. Leer am Produkt heisst ausdruecklich "Satz der Kategorie" und NICHT 0 — 0 bedeutet steuerfrei nach § 19 UStG. Das Formularfeld hat dafuer einen eigenen Zweig im Validierungsschema, sonst haette z.coerce.number() aus dem leeren Feld eine 0 gemacht. Damit kann eine Rechnung mehrere Saetze tragen, sobald Honig und Kerze im selben Paket liegen. Sie weist deshalb je Satz eine eigene Netto- und Steuerzeile aus. Versand und Rabatt verteilen sich im Verhaeltnis der Warenwerte: der Versand ist umsatzsteuerlich eine Nebenleistung und teilt das Schicksal der Hauptleistung — gibt es mehrere, teilt er sich anteilig auf. Die Verteilung nutzt Hare-Niemeyer, damit dabei keine Cent verschwinden oder entstehen; zugesichert ist, dass die Summe aller Zeilen exakt den Rechnungsbetrag ergibt. Der Satz wandert von der Rechnung an die BESTELLPOSITION (OrderItem.vatRatePercent) und gehoert damit zum Snapshot wie der Preis: wird ein Produkt spaeter umkategorisiert, bleiben bestehende Rechnungen unveraendert. Invoice.vatRatePercent entfaellt wieder — ein einzelner Satz je Rechnung ist bei gemischten Saetzen nicht darstellbar. Die Spalte entstand in der vorigen Migration und wurde nie in Betrieb beschrieben. VAT_RATE_PERCENT_PROPOLIS steht auf 19, weil der volle Satz im Zweifel der sicherere ist — als Lebensmittel waeren 7 richtig, als Nahrungsergaenzungs- oder Kosmetikprodukt 19. Im README als zu pruefen vermerkt. Der Manifest-Test hat die neuen Variablen zunaechst als "liest niemand" gemeldet: lib/vat.ts bekommt die Umgebung als Parameter, damit sich die Saetze ohne globalen Zustand testen lassen, und greift auf die Kategorie-Saetze ueber einen berechneten Schluessel zu — beides kann eine Regex nicht sehen. Dafuer gibt es jetzt eine begruendete Ausnahmeliste. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
738 lines
26 KiB
Plaintext
738 lines
26 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[]
|
|
|
|
@@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"
|
|
type String
|
|
slug 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[]
|
|
|
|
@@unique([year, type])
|
|
@@map("harvests")
|
|
}
|
|
|
|
model Batch {
|
|
id String @id @default(cuid())
|
|
/// z.B. FH-26-01
|
|
code 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
|
|
}
|
|
|
|
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
|
|
/// 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)
|
|
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[]
|
|
wishlistItems WishlistItem[]
|
|
stockNotifications StockNotification[]
|
|
|
|
@@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)
|
|
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
|
|
}
|
|
|
|
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?
|
|
notes String?
|
|
/// Optionale persoenliche Nachricht bei Geschenkbestellungen — wird beim
|
|
/// Verpacken von Hand beigelegt (siehe Admin-Bestelldetail).
|
|
giftMessage String?
|
|
|
|
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?
|
|
|
|
/// 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])
|
|
@@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)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
|
|
|
@@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?
|
|
|
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
|
product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
|
|
batch Batch? @relation(fields: [batchId], references: [id], onDelete: Restrict)
|
|
|
|
/// Wird bei jedem Laden der Positionen einer Bestellung (Konto, Admin,
|
|
/// Rechnung) ueber die order.items-Relation abgefragt.
|
|
@@index([orderId])
|
|
@@map("order_items")
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
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-A3F9C1B2.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)
|
|
|
|
@@map("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())
|
|
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")
|
|
}
|