From 62c5addbbb5ac9a0b9237b3907fce2afa47f0c48 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Sun, 22 Mar 2026 17:23:50 +0100
Subject: [PATCH] subscription base logic
---
concepts/Subscription-State-Machine.md | 300 +++++++++++++++++++++++++
1 file changed, 300 insertions(+)
create mode 100644 concepts/Subscription-State-Machine.md
diff --git a/concepts/Subscription-State-Machine.md b/concepts/Subscription-State-Machine.md
new file mode 100644
index 0000000..720b281
--- /dev/null
+++ b/concepts/Subscription-State-Machine.md
@@ -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.