Die Rechnung wies bisher gar keine Steuer aus — weder Satz und Betrag noch
den Pflichthinweis nach § 19 UStG. Der Satz kommt jetzt aus
VAT_RATE_PERCENT und steht auf PDF und HTML-Ansicht: 7 (ermaessigt, Honig
als Lebensmittel), 19 (voll) oder 0 fuer die Kleinunternehmerregelung, bei
der anstelle der Steuerzeilen der vorgeschriebene Hinweis erscheint.
DIE SHOP-PREISE SIND BRUTTOPREISE. Gegenueber Verbrauchern ist der
Gesamtpreis inklusive Umsatzsteuer anzugeben (§ 1 PAngV), und genau dieser
Betrag steht in Product.priceCents und Order.totalCents. Die Steuer wird
deshalb herausgerechnet, nicht aufgeschlagen — ein Aufschlag ergaebe einen
anderen Endpreis als den, den die Kundschaft im Warenkorb gesehen und
bezahlt hat.
Gerechnet wird auf der Gesamtsumme statt je Position, damit sich Rundungen
einzelner Zeilen nicht aufsummieren, und der Steuerbetrag ergibt sich als
Rest statt aus einer zweiten Rundung: nur so gilt immer netto + steuer ===
brutto. Eine Rechnung, die sich nicht aufaddiert, ist keine brauchbare
Rechnung. Der Versand traegt den Satz der Ware, er ist umsatzsteuerlich
eine Nebenleistung.
Der Satz wird als Invoice.vatRatePercent FESTGESCHRIEBEN statt bei jeder
Anzeige aus der Umgebung gelesen. Wechselt der Steuerstatus des Betriebs,
muessen alte Rechnungen weiterhin den damals ausgewiesenen Satz zeigen — ein
Beleg ist die Aufzeichnung eines vergangenen Vorgangs, keine Ansicht auf die
heutige Konfiguration. Bestehende Rechnungen tragen null und zeigen wie
bisher keine Steuerzeilen, weil rueckwirkend nicht feststellbar ist, welcher
Satz gegolten haette.
Ein unbrauchbarer Wert faellt auf 7% zurueck und wird laut geloggt, statt
eine bereits bezahlte Bestellung scheitern zu lassen. Damit das nicht
unbemerkt bleibt, prueft "pnpm preflight" den Wert jetzt als Blocker und
warnt zusaetzlich bei einem in Deutschland unueblichen Satz.
Der bisherige legalNote war ein Platzhalter ("Kleinunternehmerregelung /
Umsatzsteuerausweis gemaess tatsaechlichem Status ergaenzen") und ist
ersetzt, da die Angabe nun tatsaechlich erfolgt.
Bekannte Grenze, im README vermerkt: es gibt genau EINEN Satz. Honig ist
ermaessigt besteuert, Kerzen nicht — werden beide verkauft, braucht es einen
Satz je ProductCategory.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
727 lines
25 KiB
Plaintext
727 lines
25 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
|
|
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
|
|
|
|
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
|
|
/// Umsatzsteuersatz in ganzen Prozent, wie er zum Zeitpunkt der
|
|
/// Rechnungsstellung galt (0 = Kleinunternehmerregelung nach § 19 UStG).
|
|
///
|
|
/// Wird FESTGESCHRIEBEN statt bei jeder Anzeige aus VAT_RATE_PERCENT
|
|
/// gelesen: aendert sich der Steuerstatus des Betriebs, muessen alte
|
|
/// Rechnungen weiterhin den damals ausgewiesenen Satz zeigen. Eine Rechnung
|
|
/// ist ein Beleg ueber einen vergangenen Vorgang, keine Ansicht auf die
|
|
/// heutige Konfiguration.
|
|
///
|
|
/// Null bei Rechnungen aus der Zeit vor der Steuerausweisung — die zeigen
|
|
/// weiterhin keine Steuerzeilen, weil rueckwirkend nicht feststellbar ist,
|
|
/// welcher Satz damals richtig gewesen waere.
|
|
vatRatePercent Int?
|
|
/// 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")
|
|
}
|