# 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.