subscription base logic
This commit is contained in:
parent
c52e28d2eb
commit
62c5addbbb
1 changed files with 300 additions and 0 deletions
300
concepts/Subscription-State-Machine.md
Normal file
300
concepts/Subscription-State-Machine.md
Normal file
|
|
@ -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.
|
||||
Loading…
Reference in a new issue