Subscription-Datenmodell
Seit dem Refactoring (März 2026) sind Stripe-Subscription-Daten nicht mehr am User-Modell gespeichert, sondern in einem eigenen Subscription-Modell das am Tenant hängt.
Warum?
Das alte Modell (Subscription am User) funktioniert nur für Einzelschulen. Sobald ein Schulträger mehrere Schulen (Tenants) unter einer Lizenz betreiben möchte, muss Billing von der Person getrennt und an die Organisation gekoppelt sein.
Datenbankmodell
// Einzelschule oder Schulträger mit mehreren Tenants
model Organization {
id String @id @default(uuid())
name String
slug String? @unique
stripeCustomerId String? @unique @map("stripe_customer_id")
tenants Tenant[]
subscription Subscription?
@@map("organizations")
}
// Subscription – gehört entweder direkt einem Tenant oder einer Organization
model Subscription {
id String @id @default(uuid())
tenantId String? @unique @map("tenant_id")
organizationId String? @unique @map("organization_id")
stripeCustomerId String? @unique @map("stripe_customer_id")
stripeSubscriptionId String? @unique @map("stripe_subscription_id")
status String? // active | trialing | past_due | canceled | paused
plan PlanType @default(HOBBY)
currentPeriodStart DateTime? @map("current_period_start")
currentPeriodEnd DateTime? @map("current_period_end")
cancelAtPeriodEnd Boolean @default(false) @map("cancel_at_period_end")
trialEnd DateTime? @map("trial_end")
maxTenants Int? @map("max_tenants")
maxStudents Int? @map("max_students")
tenant Tenant? @relation(...)
organization Organization? @relation(...)
@@map("subscriptions")
}
enum PlanType {
HOBBY // Einzelschule, eingeschränkt
STANDARD // Einzelschule, vollständig
ENTERPRISE // Schulträger, mehrere Tenants
}
Architektur-Regeln
| Regel | Erklärung |
|---|---|
| Subscription gehört zum Tenant | Nicht zum User. Der User ist nur die Person, die sich einloggt. |
| Organization ist optional | Ein Tenant ohne Organization hat eine direkte 1:1-Subscription. |
| Exklusivität | Eine Subscription hat entweder tenantId ODER organizationId — nie beides. |
| Stripe Webhooks → Subscription | Webhooks schreiben in die subscriptions-Tabelle, nie in users. |
Subscription laden
Verwende immer die Hilfsfunktion aus lib/subscription.ts:
import { getSubscriptionForTenant } from '@/lib/subscription';
// Prüft zuerst direkte Subscription, dann via Organization
const subscription = await getSubscriptionForTenant(tenantId);
// Status prüfen
import { isSubscriptionActive } from '@/lib/subscription';
if (isSubscriptionActive(subscription)) {
// Zugang erlaubt
}
Nie direkt prisma.user.findUnique({ select: { subscriptionStatus: true } }) verwenden — diese Felder existieren nicht mehr.
Webhook-Flow
Stripe Event
│
▼
/api/stripe/webhook
│
├─ customer.subscription.created/updated
│ └─ prisma.subscription.update({ where: { stripeCustomerId }, data: { status, plan, ... } })
│
├─ invoice.payment_succeeded
│ └─ prisma.subscription.update({ ..., data: { status: 'active' } })
│
├─ invoice.payment_failed
│ └─ prisma.subscription.update({ ..., data: { status: 'past_due' } })
│
└─ checkout.session.completed (Trial-Flow)
└─ prisma.subscription.create({ tenantId, stripeCustomerId, status: 'trialing', ... })
Registrierungsflow
Beim Trial-Checkout-Flow (Stripe Hosted Checkout) erstellt der Webhook automatisch User, Tenant und Subscription:
- User gibt E-Mail ein → Redirect zu Stripe Checkout
- Stripe sendet
checkout.session.completed - Webhook legt
User,TenantundSubscriptionin der DB an SubscriptionenthälttenantId,stripeCustomerId,stripeSubscriptionId,status: 'trialing'
Beim regulären Checkout-Flow (Stripe Elements im Wizard):
createAdminUser()legt User + Subscription (ohnetenantId) ancreateTenant()verknüpft den Tenant mit dem User- Subscription wird nachträglich mit
tenantIdverknüpft
Plan-Mapping
| Stripe-Preis | PlanType | Beschreibung |
|---|---|---|
| Trial / Professional | STANDARD | 14-Tage Trial, danach Standardpreis |
| Enterprise | ENTERPRISE | Schulträger mit mehreren Tenants |
| Hobby / Starter | HOBBY | Kostenloser oder eingeschränkter Plan |
Dev-Routen zum Testen
| Route | Methode | Beschreibung |
|---|---|---|
/api/user/subscription | GET | Aktuelle Subscription des eingeloggten Tenants |
/api/user/set-professional-plan | POST | Setzt Subscription lokal auf STANDARD/active (kein Stripe-Call) |
/api/test/trial-expired | POST | Simuliert abgelaufenen Trial lokal + in Stripe |