diff --git a/concepts/Subscription-State-Machine.md b/concepts/Subscription-State-Machine.md new file mode 100644 index 0000000..720b281 --- /dev/null +++ b/concepts/Subscription-State-Machine.md @@ -0,0 +1,300 @@ +# Subscription State Machine + +Dieses Dokument definiert die State Machine für Mandanten-Subscriptions. +Es ersetzt die bisherige ad-hoc-Logik durch eine deterministische, ID-basierte Transaktionslogik. + +--- + +## Grundregel + +**Pro Mandant ist zu jedem Zeitpunkt maximal EINE Subscription operativ (ACTIVE oder TRIALING).** +Es kann zusätzlich maximal eine Subscription in einem vorbereitenden Zustand existieren (PENDING oder SCHEDULED). + +Wenn eine neue Subscription starten soll während eine bestehende noch läuft, wird die bestehende auf `recurring=false` gesetzt, damit sie am Periodenende regulär ausläuft. Die neue Subscription startet erst nach dem Ablauf der bestehenden. + +--- + +## States + +| State | Bedeutung | Mandant operativ? | +|---------------|----------------------------------------------------------------|--------------------| +| **PENDING** | Checkout gestartet, Zahlung noch nicht bestätigt | Nein (es sei denn ein anderes Abo ist noch ACTIVE) | +| **SCHEDULED** | Bezahlt/bestätigt, wartet auf Ablauf des Vorgängers | Nein (Vorgänger ist noch ACTIVE) | +| **TRIALING** | Testphase, voll operativ | Ja | +| **ACTIVE** | Bezahlt und operativ | Ja | +| **PAST_DUE** | Zahlung fehlgeschlagen, Stripe versucht erneut | Ja (Grace Period) | +| **EXPIRED** | Terminal, Subscription beendet | Nein | + +### Entfernt + +`CANCELLED` existiert nicht mehr als Status. Eine gekündigte Subscription ist `ACTIVE` mit `recurring=false` — sie bleibt voll operativ bis zum Periodenende und läuft dann regulär aus. + +### Neue Felder auf MandateSubscription + +- **`recurring: bool`** — `true`: Stripe verlängert automatisch am Periodenende. `false`: läuft am Periodenende aus, wird dann EXPIRED. +- **`effectiveFrom: Optional[datetime]`** — Bei SCHEDULED: Zeitpunkt, ab dem die Subscription ACTIVE wird (= Periodenende des Vorgängers). `None` = sofort wirksam. + +--- + +## State-Diagramm + +```mermaid +stateDiagram-v2 + [*] --> PENDING: User startet Checkout + + PENDING --> ACTIVE: checkout.completed, kein Vorgänger + PENDING --> SCHEDULED: checkout.completed, Vorgänger läuft noch + PENDING --> EXPIRED: User bricht ab / Timeout + + SCHEDULED --> ACTIVE: effectiveFrom erreicht + SCHEDULED --> EXPIRED: User/Sysadmin cancel + + TRIALING --> EXPIRED: Trial endet + + ACTIVE --> PAST_DUE: payment.failed + ACTIVE --> EXPIRED: Periodenende (recurring=false) + ACTIVE --> EXPIRED: Sysadmin Force-Cancel + + PAST_DUE --> ACTIVE: payment.succeeded + PAST_DUE --> EXPIRED: Retries erschöpft +``` + +Zusätzlich: `ACTIVE(recurring=true) ↔ ACTIVE(recurring=false)` ändert nur das Flag, nicht den Status. + +--- + +## Transitionen + +Jede Transition ist durch **Guard**, **Trigger** und **Side Effects** definiert. +Alle Write-Operationen arbeiten mit expliziter `subscriptionId` — kein Status-Scan, kein Guessing. + +### T1: PENDING → ACTIVE + +- **Trigger:** Stripe Webhook `checkout.session.completed`, kein Vorgänger aktiv +- **Guard:** Sub.status == PENDING (lookup by ID aus Webhook-Metadata `subscriptionRecordId`) +- **Side Effects:** + - Stripe-Daten auf Record schreiben (stripeSubscriptionId, periods, itemIds) + - `recurring = true` + - Cache invalidieren + - Notification an Mandate-Admins + +### T2: PENDING → SCHEDULED + +- **Trigger:** Stripe Webhook `checkout.session.completed`, Vorgänger noch ACTIVE/TRIALING +- **Guard:** Sub.status == PENDING (lookup by ID aus Webhook-Metadata) +- **Side Effects:** + - Stripe-Daten auf Record schreiben + - `effectiveFrom = Vorgänger.currentPeriodEnd` + - Vorgänger: `recurring = false` setzen (Stripe: `cancel_at_period_end=true`) + - Cache invalidieren + +### T3: PENDING → EXPIRED + +- **Trigger:** User bricht Checkout ab, oder Auto-Timeout (30 min ohne stripeSubscriptionId) +- **Guard:** Sub.status == PENDING (by ID) +- **Side Effects:** + - `endedAt = now` + - Cache invalidieren + +### T4: SCHEDULED → ACTIVE + +- **Trigger:** Stripe Webhook `customer.subscription.updated` status="active" (Stripe trial_end erreicht), oder Vorgänger-EXPIRED-Event +- **Guard:** Sub.status == SCHEDULED (lookup by stripeSubscriptionId) +- **Side Effects:** + - Status → ACTIVE + - Cache invalidieren + - Notification an Mandate-Admins + +### T5: SCHEDULED → EXPIRED + +- **Trigger:** User oder Sysadmin cancel +- **Guard:** Sub.status == SCHEDULED (by ID) +- **Side Effects:** + - Stripe Subscription sofort canceln + - `endedAt = now` + - Cache invalidieren + +### T6: TRIALING → EXPIRED + +- **Trigger:** Trial-Ende erreicht (Cron oder Stripe Webhook) +- **Guard:** Sub.status == TRIALING (by ID), `trialEndsAt <= now` +- **Side Effects:** + - Status → EXPIRED, `endedAt = now` + - Notification: Admins sollen Plan wählen + +### T7: ACTIVE — Kündigung (recurring-Flag) + +- **Trigger:** Mandate-Admin +- **Guard:** Sub.status == ACTIVE, `recurring == true` (by ID) +- **Side Effects:** + - `recurring = false` + - Stripe `cancel_at_period_end = true` + - Notification an Mandate-Admins + - **Status bleibt ACTIVE!** + +### T8: ACTIVE — Reaktivierung (recurring-Flag) + +- **Trigger:** Mandate-Admin +- **Guard:** Sub.status == ACTIVE, `recurring == false`, Periodenende noch nicht erreicht (by ID) +- **Side Effects:** + - `recurring = true` + - Stripe `cancel_at_period_end = false` + +### T9: ACTIVE(recurring=false) → EXPIRED + +- **Trigger:** Stripe Webhook `customer.subscription.deleted` (Periodenende erreicht) +- **Guard:** Sub.status == ACTIVE (lookup by stripeSubscriptionId) +- **Side Effects:** + - Status → EXPIRED, `endedAt = now` + - Cache invalidieren + - Falls SCHEDULED-Nachfolger existiert → Transition T4 triggern + +### T10: ACTIVE → PAST_DUE + +- **Trigger:** Stripe Webhook `invoice.payment_failed` +- **Guard:** Sub.status == ACTIVE (lookup by stripeSubscriptionId) +- **Side Effects:** + - Status → PAST_DUE + - Notification an Mandate-Admins + +### T11: PAST_DUE → ACTIVE + +- **Trigger:** Stripe Webhook `customer.subscription.updated` status="active" +- **Guard:** Sub.status == PAST_DUE (lookup by stripeSubscriptionId) +- **Side Effects:** + - Status → ACTIVE + - Perioden-Daten aktualisieren + - Cache invalidieren + +### T12: PAST_DUE → EXPIRED + +- **Trigger:** Stripe Webhook `customer.subscription.deleted` +- **Guard:** Sub.status == PAST_DUE (lookup by stripeSubscriptionId) +- **Side Effects:** + - Status → EXPIRED, `endedAt = now` + - Cache invalidieren + - Notification + +### T13: ANY non-terminal → EXPIRED (Sysadmin Force-Cancel) + +- **Trigger:** Sysadmin-Aktion +- **Guard:** Sub.status nicht EXPIRED (by ID) +- **Side Effects:** + - Falls stripeSubscriptionId vorhanden: Stripe `Subscription.cancel()` sofort + - Status → EXPIRED, `endedAt = now` + - Cache invalidieren + +--- + +## Erlaubte Transitionen (Guard-Tabelle) + +```python +ALLOWED_TRANSITIONS = { + ("PENDING", "ACTIVE"), + ("PENDING", "SCHEDULED"), + ("PENDING", "EXPIRED"), + ("SCHEDULED", "ACTIVE"), + ("SCHEDULED", "EXPIRED"), + ("TRIALING", "EXPIRED"), + ("ACTIVE", "PAST_DUE"), + ("ACTIVE", "EXPIRED"), + ("PAST_DUE", "ACTIVE"), + ("PAST_DUE", "EXPIRED"), +} +``` + +Jede Statusänderung MUSS durch `transitionStatus(subscriptionId, fromStatus, toStatus, data)` laufen, die den Übergang validiert. + +--- + +## Flows + +### Flow A: Erste Subscription (kein Vorgänger) + +``` +User wählt Plan + → POST /activate {planKey, returnUrl} + → Backend: createSubscription(PENDING) + Stripe Checkout Session + → Frontend: Redirect zu Stripe + → User zahlt + → Webhook checkout.session.completed + → Load by metadata.subscriptionRecordId + → Kein aktiver Vorgänger → T1: PENDING → ACTIVE + → Mandant operativ +``` + +### Flow B: Planwechsel (Vorgänger aktiv) + +``` +User wählt neuen Plan + → POST /activate {planKey, returnUrl} + → Backend: + 1. Bestehende PENDING/SCHEDULED für Mandant expiren (by ID) + 2. createSubscription(PENDING) + 3. Stripe Checkout mit trial_end = aktuellerSub.currentPeriodEnd + → Frontend: Redirect zu Stripe + → User zahlt (Payment Method captured, keine Belastung) + → Webhook checkout.session.completed + → Vorgänger aktiv → T2: PENDING → SCHEDULED + → Vorgänger: recurring = false (T7) + → Vorgänger läuft bis Periodenende + → Webhook customer.subscription.deleted (Vorgänger expired) + → T9: Vorgänger → EXPIRED + → SCHEDULED-Nachfolger → T4: SCHEDULED → ACTIVE + → Neues Abo operativ +``` + +### Flow C: Kündigung (ohne Nachfolger) + +``` +User kündigt + → POST /cancel {subscriptionId} + → Load by subscriptionId + → T7: recurring = false, Stripe cancel_at_period_end + → Abo bleibt aktiv bis Periodenende + → Webhook customer.subscription.deleted + → T9: ACTIVE → EXPIRED +``` + +### Flow D: Sysadmin Force-Cancel + +``` +Sysadmin cancelt sofort + → POST /admin/subscription/{id}/force-cancel + → Load by subscriptionId + → T13: ANY → EXPIRED, Stripe cancel immediately +``` + +### Flow E: Reaktivierung vor Periodenende + +``` +User reaktiviert gekündigtes Abo + → POST /reactivate {subscriptionId} + → Load by subscriptionId + → T8: recurring = true, Stripe cancel_at_period_end = false + → Abo wird am Periodenende erneuert +``` + +--- + +## Architektur-Regeln + +### 1. Jede Mutation per subscriptionId + +Keine Funktion darf einen Subscription-Record per Status-Scan finden und dann mutieren. Der Caller kennt die ID und gibt sie explizit mit. + +### 2. Webhook-Auflösung per stripeSubscriptionId + +Stripe-Webhooks liefern die `stripeSubscriptionId`. Der Record wird per direktem DB-Lookup gefunden — nicht über `mandateId` + Status-Guess. + +### 3. Zentrale Guard-Funktion + +Jeder Status-Übergang läuft durch `transitionStatus()`, die prüft ob der Übergang erlaubt ist. Ungültige Übergänge werden geloggt und abgelehnt. + +### 4. Billing Gate + +`assertActive(mandateId)` ist die einzige Read-Operation, die per mandateId arbeitet. Sie gibt den Status der operativen Subscription zurück (ACTIVE, TRIALING, PAST_DUE) oder EXPIRED falls keine existiert. Sie modifiziert keine Daten. + +### 5. Kapazitätsprüfung + +`assertCapacity(mandateId, resourceType, delta)` arbeitet gegen die operative Subscription (ACTIVE/TRIALING). Es gibt immer genau eine.