10 KiB
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
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 = falsesetzen (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.updatedstatus="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
- Status → EXPIRED,
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
- Status → EXPIRED,
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.updatedstatus="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
- Status → EXPIRED,
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
- Falls stripeSubscriptionId vorhanden: Stripe
Erlaubte Transitionen (Guard-Tabelle)
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.