300 lines
10 KiB
Markdown
300 lines
10 KiB
Markdown
# 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.
|