Rechnungskopie: unter Einstellungen > Firma > "Rechnungskopie an" laesst sich eine Adresse hinterlegen; jede Rechnung und Stornorechnung geht zusaetzlich als eigene Mail mit derselben E-Rechnung dorthin (lib/invoice-mail.ts). Stornorechnung: eine Rechnung wird nie geloescht. Bei Stornierung im Admin, erstatteter Retoure oder voller Erstattung bei Stripe entsteht genau eine Stornorechnung ST-JJJJ-NNNN (eigener lueckenloser Nummernkreis) mit allen Positionen und Bezug auf die Rechnung, als E-Rechnung (Gutschrift 381) an die Kundschaft und als Kopie. Gleichzeitige Ausloeser (Admin-Storno und Stripe-Webhook derselben Erstattung) enden bei einer Stornorechnung. Bestellungen mit Stornorechnung lassen sich nicht wieder oeffnen. ERP-Export: GET /api/erp/rechnungen?art=storno liefert Stornorechnungen mit Bezug (cancels), Rechnungen tragen cancelledBy; PDFs auch fuer ST-Nummern. Admin-Bestelldetail zeigt die Stornorechnung mit Download. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
954 lines
36 KiB
Plaintext
954 lines
36 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". 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
|
|
}
|
|
|
|
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[]
|
|
|
|
@@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
|
|
}
|
|
|
|
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[]
|
|
|
|
/// 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?
|
|
|
|
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)
|
|
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")
|
|
}
|