wiki/concepts/Subscription-State-Machine.md
2026-03-22 17:23:50 +01:00

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: booltrue: 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 = 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)

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.