Zum Hauptinhalt springen

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​

RegelErklärung
Subscription gehört zum TenantNicht zum User. Der User ist nur die Person, die sich einloggt.
Organization ist optionalEin Tenant ohne Organization hat eine direkte 1:1-Subscription.
ExklusivitätEine Subscription hat entweder tenantId ODER organizationId — nie beides.
Stripe Webhooks → SubscriptionWebhooks 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:

  1. User gibt E-Mail ein → Redirect zu Stripe Checkout
  2. Stripe sendet checkout.session.completed
  3. Webhook legt User, Tenant und Subscription in der DB an
  4. Subscription enthält tenantId, stripeCustomerId, stripeSubscriptionId, status: 'trialing'

Beim regulären Checkout-Flow (Stripe Elements im Wizard):

  1. createAdminUser() legt User + Subscription (ohne tenantId) an
  2. createTenant() verknüpft den Tenant mit dem User
  3. Subscription wird nachträglich mit tenantId verknüpft

Plan-Mapping​

Stripe-PreisPlanTypeBeschreibung
Trial / ProfessionalSTANDARD14-Tage Trial, danach Standardpreis
EnterpriseENTERPRISESchulträger mit mehreren Tenants
Hobby / StarterHOBBYKostenloser oder eingeschränkter Plan

Dev-Routen zum Testen​

RouteMethodeBeschreibung
/api/user/subscriptionGETAktuelle Subscription des eingeloggten Tenants
/api/user/set-professional-planPOSTSetzt Subscription lokal auf STANDARD/active (kein Stripe-Call)
/api/test/trial-expiredPOSTSimuliert abgelaufenen Trial lokal + in Stripe