Files
Artista-Filmcamp/prisma/schema.prisma
T
Nick SzaunigandClaude Opus 5.5 c1ad9ad2ca feat: Folgeraten über Stripe Billing einziehen
Statt eigener Off-Session-Einzüge durch den Worker legt die Anwendung nach
bezahlter erster Rate einen Stripe Subscription Schedule an. Stripe übernimmt
Einzug, Wiederholungen (Smart Retries), 3-D-Secure-Links und Mahnungen.

- Gateway: Zeitplan anlegen/beenden, Rechnungen lesen; chargeOffSession entfällt
- Webhook: invoice.paid, invoice.payment_failed, invoice.payment_action_required
- Worker: fehlende Zeitpläne anlegen, überfällige Raten abgleichen,
  Zeitpläne stornierter Buchungen beenden
- Storno beendet den Zeitplan und storniert offene Rechnungen
- "Rate bezahlen" führt auf Stripes Rechnungsseite
- Migration: Booking.stripeScheduleId, Installment.stripeInvoiceId

Zeitplan-Form mit Stripe-Testuhr gegen die Test-API geprüft.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-05 23:52:43 +02:00

540 lines
15 KiB
Plaintext

// 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)
/// Freigegebene Widerrufsbelehrung bzw. Hinweis zum Widerrufsrecht,
/// erscheint unter /widerruf. Ohne Freigabe kein Live-Verkauf.
withdrawalText String?
withdrawalApproved 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?
/// Stripe-Billing-Zeitplan, der die Folgeraten einzieht (nur Ratenzahlung).
/// Stripe uebernimmt Einzug, Wiederholungen und Mahnungen.
stripeScheduleId String? @unique
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
/// Stripe-Rechnung, ueber die Stripe Billing diese Folgerate einzieht.
stripeInvoiceId 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")
}