subscription base logic

This commit is contained in:
ValueOn AG 2026-03-22 17:23:50 +01:00
parent c52e28d2eb
commit 62c5addbbb

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