// ARTISTA MODELCAMP 2027 — Datenmodell // // Geld: ausschliesslich ganzzahlige Cent-Betraege (Int), Waehrung immer EUR. // Zeit: Zeitstempel in UTC; fachliche Grenzen (Preisphasen, Faelligkeiten) // werden in src/lib/time.ts gegen Europe/Berlin berechnet. // // Test- und Livebuchungen liegen in denselben Tabellen, sind aber ueber // "livemode" strikt getrennt: Kapazitaet und Gutscheinkontingente zaehlen nur // Buchungen desselben Modus, damit Stripe-Testbuchungen nie echte Plaetze // blockieren. generator client { provider = "prisma-client" output = "../src/generated/prisma" } datasource db { provider = "postgresql" } // --------------------------------------------------------------------------- // Camp und Verkaufsfreigaben // --------------------------------------------------------------------------- enum PricePhase { PREVIEW EARLY_BIRD REGULAR } /// Genau eine Zeile (id = "modelcamp-2027"). Enthaelt die betrieblichen /// Angaben, die den Live-Verkauf freischalten. Die Preise selbst sind /// verbindlich vorgegeben und stehen in src/lib/pricing.ts, nicht hier. model Camp { id String @id /// Hauptschalter fuer den Live-Verkauf. Wirkt nur zusammen mit einer /// vollstaendigen Freigabe-Checkliste (src/lib/sales-gate.ts). salesEnabled Boolean @default(false) capacity Int @default(13) capacityConfirmed Boolean @default(false) stammmodelQuota Int @default(4) stammmodelQuotaConfirmed Boolean @default(false) campStartDate DateTime? @db.Date campEndDate DateTime? @db.Date dateConfirmed Boolean @default(false) locationName String? locationRegion String? locationConfirmed Boolean @default(false) /// Verbindliche Zimmer- und Bettbeschreibung, wird vor dem Kauf angezeigt /// und muss ausdruecklich bestaetigt werden. accommodationText String? accommodationConfirmed Boolean @default(false) /// Bestaetigt, dass die in src/content/site.ts als "confirmed" markierten /// Leistungen verbindlich sind. servicesConfirmed Boolean @default(false) organizerName String? organizerAddress String? organizerEmail String? organizerPhone String? organizerConfirmed Boolean @default(false) /// Steuerlich gepruefter Preis-Hinweis, z. B. wie die Umsatzsteuer /// ausgewiesen wird. Ohne Freigabe erscheint KEINE Steueraussage. taxNote String? taxConfirmed Boolean @default(false) /// Version der freigegebenen Buchungs-, Storno- und Zahlungsbedingungen. termsVersion String? termsUrl String? termsApproved Boolean @default(false) travelLawChecked Boolean @default(false) installmentsApproved Boolean @default(false) installmentRuleApproved Boolean @default(false) /// Reservierungsdauer fuer einen begonnenen Checkout. Stripe verlangt fuer /// expires_at mindestens 30 Minuten. reservationMinutes Int @default(30) finalInstallmentLeadDays Int @default(30) minInstallmentGapDays Int @default(14) /// Nur im Stripe-Testmodus wirksam: simuliert eine Preisphase, damit /// Testbuchungen auch vor dem 01.11.2026 moeglich sind. testPhaseOverride PricePhase? updatedAt DateTime @updatedAt updatedBy String? bookings Booking[] reservations SeatReservation[] } // --------------------------------------------------------------------------- // Buchung // --------------------------------------------------------------------------- enum BookingStatus { /// Platz reserviert, Zahlung der ersten Rate steht aus. PENDING CONFIRMED CANCELED /// Checkout abgebrochen oder abgelaufen, Platz wieder frei. EXPIRED /// Zahlung eingegangen, aber ein Konflikt (z. B. Platz inzwischen /// vergeben) verlangt eine manuelle Entscheidung. NEEDS_REVIEW } enum PaymentStatus { UNPAID /// Verzoegerte Zahlungsart (z. B. SEPA) ist unterwegs. PROCESSING PARTIALLY_PAID PAID FAILED PARTIALLY_REFUNDED REFUNDED } enum PlanType { FULL TWO_INSTALLMENTS THREE_INSTALLMENTS } model Booking { id String @id @default(cuid()) /// Oeffentliche Buchungsnummer, z. B. AMC27-7K3Q9P. reference String @unique campId String camp Camp @relation(fields: [campId], references: [id]) status BookingStatus @default(PENDING) paymentStatus PaymentStatus @default(UNPAID) livemode Boolean /// Vom Browser erzeugter Schluessel je Buchungsversuch: ein doppelt /// abgeschicktes Formular fuehrt zur selben Buchung statt zu zweien. idempotencyKey String @unique /// SHA-256 des Zugangstokens fuer die Statusseite. Das Token selbst wird /// nie gespeichert. statusTokenHash String @unique firstName String lastName String email String phone String country String notes String? adultConfirmedAt DateTime termsAcceptedAt DateTime termsVersion String accommodationAcknowledgedAt DateTime privacyAcknowledgedAt DateTime /// Zustimmung zum Einzug spaeterer Raten (nur bei Ratenzahlung). installmentMandateAt DateTime? // --- Preis-Snapshot (per Datenbank-Trigger unveraenderlich) ------------- pricePhase PricePhase basePriceCents Int phaseDiscountCents Int voucherDiscountCents Int totalCents Int planType PlanType priceSnapshot Json /// Bis hierhin gilt der gebundene Preis (= Ablauf der Stripe-Session). priceLockedUntil DateTime voucherId String? voucher Voucher? @relation(fields: [voucherId], references: [id]) stripeCustomerId String? stripeCheckoutSessionId String? @unique stripeCheckoutUrl String? /// Referenz (keine Kartendaten) auf die fuer Folgeraten gespeicherte /// Zahlungsmethode. Wird nach der letzten Rate bei Stripe getrennt. stripePaymentMethodId String? paymentMethodDetachedAt DateTime? paidCents Int @default(0) refundedCents Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt confirmedAt DateTime? canceledAt DateTime? cancelReason String? reviewReason String? installments Installment[] reservation SeatReservation? redemption VoucherRedemption? refunds Refund[] emails EmailMessage[] @@index([livemode, status]) @@index([email]) @@index([createdAt]) } enum InstallmentStatus { /// Noch nicht faellig bzw. erste Rate vor dem Checkout. PENDING /// Einzug laeuft (Worker hat die Rate beansprucht). PROCESSING PAID FAILED /// Bank verlangt zusaetzliche Authentifizierung: Kundin zahlt ueber einen /// Link auf der Statusseite selbst. REQUIRES_ACTION CANCELED } model Installment { id String @id @default(cuid()) bookingId String booking Booking @relation(fields: [bookingId], references: [id], onDelete: Restrict) seq Int amountCents Int /// null = faellig bei Buchung (erste Rate). dueAt DateTime? status InstallmentStatus @default(PENDING) attemptCount Int @default(0) lastAttemptAt DateTime? nextAttemptAt DateTime? failureCode String? paidAt DateTime? refundedCents Int @default(0) stripePaymentIntentId String? @unique stripeChargeId String? @unique /// Offene Checkout-Session, ueber die die Kundin diese Rate selbst zahlt. checkoutSessionId String? @unique checkoutExpiresAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt refunds Refund[] @@unique([bookingId, seq]) @@index([status, dueAt]) } model Refund { id String @id @default(cuid()) bookingId String booking Booking @relation(fields: [bookingId], references: [id], onDelete: Restrict) installmentId String? installment Installment? @relation(fields: [installmentId], references: [id]) stripeRefundId String @unique amountCents Int status String reason String? createdBy String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } // --------------------------------------------------------------------------- // Kapazitaet // --------------------------------------------------------------------------- enum ReservationStatus { /// Zeitlich begrenzt, verfaellt mit expiresAt. ACTIVE /// Verzoegerte Zahlung unterwegs: Platz bleibt bis zum Ergebnis gehalten. HELD CONVERTED RELEASED } model SeatReservation { id String @id @default(cuid()) bookingId String @unique booking Booking @relation(fields: [bookingId], references: [id], onDelete: Restrict) campId String camp Camp @relation(fields: [campId], references: [id]) livemode Boolean status ReservationStatus @default(ACTIVE) expiresAt DateTime releasedAt DateTime? releaseReason String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([campId, livemode, status, expiresAt]) } // --------------------------------------------------------------------------- // Gutscheine (Stammmodel-Codes) // --------------------------------------------------------------------------- model Voucher { id String @id @default(cuid()) /// Immer in Grossbuchstaben normalisiert gespeichert. code String @unique active Boolean @default(true) validFrom DateTime? validUntil DateTime? maxRedemptions Int @default(1) /// Optional: nur fuer diese E-Mail-Adresse (klein geschrieben) einloesbar. boundEmail String? /// Interne Zuordnung, z. B. Name des Stammmodels. Nie oeffentlich. internalNote String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt redemptions VoucherRedemption[] bookings Booking[] } enum RedemptionStatus { RESERVED COMPLETED RELEASED } model VoucherRedemption { id String @id @default(cuid()) voucherId String voucher Voucher @relation(fields: [voucherId], references: [id], onDelete: Restrict) bookingId String @unique booking Booking @relation(fields: [bookingId], references: [id], onDelete: Restrict) livemode Boolean status RedemptionStatus @default(RESERVED) releasedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([voucherId, livemode, status]) } // --------------------------------------------------------------------------- // Stripe-Ereignisse, E-Mail-Ausgang, Protokoll, Rate Limits // --------------------------------------------------------------------------- enum StripeEventStatus { PROCESSING PROCESSED IGNORED FAILED } model StripeEvent { /// Stripe-Event-ID (evt_...) — Primaerschluessel macht die Verarbeitung /// idempotent. id String @id type String livemode Boolean status StripeEventStatus @default(PROCESSING) error String? attempts Int @default(1) receivedAt DateTime @default(now()) processedAt DateTime? } enum EmailStatus { PENDING SENT FAILED } /// Transaktionaler Postausgang: E-Mails entstehen in derselben Transaktion /// wie die Statusaenderung und werden danach (bzw. vom Worker) versendet. model EmailMessage { id String @id @default(cuid()) /// Verhindert doppelte Mails bei wiederholten Webhooks. dedupeKey String @unique bookingId String? booking Booking? @relation(fields: [bookingId], references: [id]) toAddress String subject String textBody String htmlBody String status EmailStatus @default(PENDING) attempts Int @default(0) lastError String? sentAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([status, createdAt]) } enum ActorType { ADMIN CUSTOMER STRIPE WORKER SYSTEM } /// Nachvollziehbarkeit fuer Bestands-, Buchungs- und Zahlungsaktionen. model AuditLog { id String @id @default(cuid()) createdAt DateTime @default(now()) actorType ActorType actorId String? action String entityType String entityId String details Json? @@index([entityType, entityId]) @@index([createdAt]) } /// Gemeinsamer Zaehler fuer Rate Limits — funktioniert ueber mehrere /// Replikate hinweg. Der Schluessel enthaelt nur einen Hash der IP. model RateLimitBucket { key String @id windowStart DateTime count Int } // --------------------------------------------------------------------------- // Better Auth (Adminbereich) // --------------------------------------------------------------------------- model User { id String @id name String email String @unique emailVerified Boolean @default(false) image String? twoFactorEnabled Boolean? @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt sessions Session[] accounts Account[] twofactors TwoFactor[] @@map("user") } model Session { id String @id expiresAt DateTime token String @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt ipAddress String? userAgent String? userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) @@map("session") } model Account { id String @id accountId String providerId String userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) accessToken String? refreshToken String? idToken String? accessTokenExpiresAt DateTime? refreshTokenExpiresAt DateTime? scope String? password String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([userId]) @@map("account") } model Verification { id String @id identifier String value String expiresAt DateTime createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([identifier]) @@map("verification") } model TwoFactor { id String @id secret String backupCodes String userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) verified Boolean? @default(true) failedVerificationCount Int? @default(0) lockedUntil DateTime? @@index([secret]) @@index([userId]) @@map("twoFactor") } /// Rate Limits der Anmeldung (Better Auth, Modellname vom Adapter vorgegeben). model RateLimit { id String @id key String @unique count Int lastRequest BigInt @@map("rateLimit") }