From dd2277c80328c978c2d19ab1171cfde6e0a00d06 Mon Sep 17 00:00:00 2001
From: Clemens Creutzburg
Date: Mon, 20 Jul 2026 20:19:05 +0000
Subject: [PATCH] Selbstbedienungs-Tarifverwaltung: Admin kann Abo im Backend
waehlen und bestellen
- mandant-einstellungen.php: vollstaendige Tarif-Vergleichstabelle statt nur
erzwungenem Upgrade-Hinweis. Jeder Tarif zeigt Limit, Preis und einen
Aktions-Button (Buchen/Kuendigen/Anfragen); aktueller Tarif markiert,
zu kleine Tarife fuer die aktuelle Teilnehmerzahl deaktiviert.
- abo-upgrade.php: unterscheidet jetzt Upgrade, Downgrade zwischen bezahlten
Stufen (Preis wird in-place gewechselt statt neuer Checkout-Session,
Stripe prorated automatisch) und Wechsel auf 'free' (echte Kuendigung der
bestehenden Subscription), statt nur eine Checkout-Session zu erzeugen.
- app/stripe.php: neue Helper stripe_get_subscription(),
stripe_update_subscription_price(), stripe_cancel_subscription().
- app/billing.php: billing_plan_selectable_for() prueft serverseitig, ob
ein Zieltarif die aktuelle Teilnehmerzahl noch abdeckt (Downgrade-Schutz).
- docs/billing.md: Phase 4 dokumentiert.
Getestet: php -l fuer alle geaenderten Dateien und das gesamte Repo (keine
Syntaxfehler), reine Funktionslogik-Tests ohne DB (billing_plans(),
billing_required_plan_code(), stripe_flatten_params()) gruen. Kein Live-Test
gegen echte Stripe-Testobjekte moeglich (keine PHP/DB-Laufzeitumgebung
verfuegbar) - vor Go-Live im Stripe-Testmodus nachholen.
---
abo-upgrade.php | 78 +++++++++++++++++++++++++++++++++++++--
app/billing.php | 22 +++++++++++
app/stripe.php | 66 +++++++++++++++++++++++++++++++++
docs/billing.md | 68 ++++++++++++++++++++++++++++++++++
mandant-einstellungen.php | 66 ++++++++++++++++++++++++++-------
5 files changed, 283 insertions(+), 17 deletions(-)
diff --git a/abo-upgrade.php b/abo-upgrade.php
index 8905d17..acb85ec 100644
--- a/abo-upgrade.php
+++ b/abo-upgrade.php
@@ -24,20 +24,92 @@ $tenantId = (int)$user['tenant_id'];
$planCode = (string)($_POST['plan_code'] ?? '');
$plans = billing_plans();
-if (!isset($plans[$planCode]) || $plans[$planCode]['stripe_lookup_key'] === null) {
+if (!isset($plans[$planCode])) {
http_response_code(400);
exit('Ungültiger Tarif.');
}
+if (!billing_plan_selectable_for($pdo, $tenantId, $planCode)) {
+ http_response_code(400);
+ exit('Dieser Tarif deckt eure aktuelle Teilnehmerzahl nicht ab. Bitte einen passenden Tarif wählen.');
+}
+
+$billing = billing_fetch_or_init($pdo, $tenantId);
+$baseUrl = saas_app_url('mandant-einstellungen.php');
+
+// Fall 1: Wechsel auf "free" (kein Stripe-Preis) mit bestehender bezahlter
+// Subscription -> echtes Kuendigen der Subscription, kein Checkout noetig.
+if ($plans[$planCode]['stripe_lookup_key'] === null) {
+ if ($planCode !== 'free') {
+ // enterprise hat ebenfalls keinen Stripe-Preis, ist aber kein
+ // Selbstbedienungs-Downgrade-Ziel - manuelle Absprache erforderlich.
+ http_response_code(400);
+ exit('Für diesen Tarif meldet euch bitte per E-Mail bei uns.');
+ }
+
+ if ($billing['stripe_subscription_id'] !== null) {
+ $cancelResult = stripe_cancel_subscription((string)$billing['stripe_subscription_id']);
+ if (!$cancelResult['ok']) {
+ app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.downgrade_failed', 'tenant', $tenantId, ['plan_code' => $planCode, 'error' => $cancelResult['error']]);
+ http_response_code(502);
+ exit('Das Abo konnte nicht gekündigt werden: ' . saas_html((string)$cancelResult['error']));
+ }
+ }
+
+ billing_update($pdo, $tenantId, [
+ 'plan_code' => 'free',
+ 'subscription_status' => 'canceled',
+ ]);
+ app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.downgraded_to_free', 'tenant', $tenantId);
+
+ header('Location: ' . $baseUrl . '?upgrade=success');
+ exit;
+}
+
$priceId = billing_stripe_price_id($planCode);
if ($priceId === null) {
http_response_code(502);
exit('Der Tarif ist bei Stripe aktuell nicht verfügbar. Bitte später erneut versuchen.');
}
-$billing = billing_fetch_or_init($pdo, $tenantId);
-$baseUrl = saas_app_url('mandant-einstellungen.php');
+// Fall 2: Es gibt bereits eine aktive/bezahlte Subscription bei Stripe ->
+// Preis in-place wechseln (Up- oder Downgrade zwischen bezahlten Stufen),
+// statt eine weitere Checkout-Session zu erzeugen. Stripe rechnet die
+// Differenz automatisch anteilig ab (Proration).
+if ($billing['stripe_subscription_id'] !== null) {
+ $subscription = stripe_get_subscription((string)$billing['stripe_subscription_id']);
+ if ($subscription['ok'] && in_array($subscription['status'], ['active', 'trialing', 'past_due'], true) && $subscription['item_id'] !== null) {
+ $switchResult = stripe_update_subscription_price(
+ (string)$billing['stripe_subscription_id'],
+ (string)$subscription['item_id'],
+ $priceId
+ );
+
+ if (!$switchResult['ok']) {
+ app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.plan_switch_failed', 'tenant', $tenantId, ['plan_code' => $planCode, 'error' => $switchResult['error']]);
+ http_response_code(502);
+ exit('Der Tarifwechsel konnte nicht durchgeführt werden: ' . saas_html((string)$switchResult['error']));
+ }
+
+ // Lokal sofort spiegeln statt auf den Webhook zu warten, damit die
+ // UI direkt den neuen Tarif zeigt; der Webhook (subscription.updated)
+ // bestaetigt denselben Stand nochmal redundant.
+ billing_update($pdo, $tenantId, [
+ 'plan_code' => $planCode,
+ 'subscription_status' => 'active',
+ ]);
+ app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.plan_switched', 'tenant', $tenantId, ['plan_code' => $planCode]);
+
+ header('Location: ' . $baseUrl . '?upgrade=success');
+ exit;
+ }
+ // Falls die bestehende Subscription nicht mehr aktiv abrufbar ist
+ // (z. B. bereits gekuendigt), faellt der Ablauf unten auf eine neue
+ // Checkout-Session zurueck.
+}
+
+// Fall 3: Kein aktives Abo bisher (Neu-Abschluss) -> Stripe Checkout Session.
$result = stripe_create_checkout_session(
$priceId,
(string)$user['email'],
diff --git a/app/billing.php b/app/billing.php
index 694dbd4..ade0a53 100644
--- a/app/billing.php
+++ b/app/billing.php
@@ -221,3 +221,25 @@ function billing_check_tenant(PDO $pdo, int $tenantId): array
'needs_upgrade' => $requiredRank !== false && $currentRank !== false && $requiredRank > $currentRank,
];
}
+
+/**
+ * Whether a tenant could self-service switch to $planCode given its current
+ * active participant count - i.e. the plan's limit (if any) still covers
+ * them. Used to grey out/reject plan selections that would immediately put
+ * the tenant over their own member count (a downgrade or lateral move that
+ * doesn't fit). Unknown plan codes are treated as not selectable.
+ */
+function billing_plan_selectable_for(PDO $pdo, int $tenantId, string $planCode): bool
+{
+ $plans = billing_plans();
+ if (!isset($plans[$planCode])) {
+ return false;
+ }
+
+ $max = $plans[$planCode]['max_participants'];
+ if ($max === null) {
+ return true;
+ }
+
+ return billing_count_active_participants($pdo, $tenantId) <= $max;
+}
diff --git a/app/stripe.php b/app/stripe.php
index 4e915d8..6c6d2ca 100644
--- a/app/stripe.php
+++ b/app/stripe.php
@@ -220,6 +220,72 @@ function stripe_verify_webhook_signature(string $payload, string $signatureHeade
return false;
}
+/**
+ * Fetches a subscription including its first item's price, so the caller
+ * can see the currently billed price and swap it out in place.
+ *
+ * @return array{ok: bool, id: ?string, item_id: ?string, price_lookup_key: ?string, status: ?string, cancel_at_period_end: bool, error: ?string}
+ */
+function stripe_get_subscription(string $subscriptionId): array
+{
+ $result = stripe_request('GET', "subscriptions/{$subscriptionId}");
+ if (!$result['ok']) {
+ return ['ok' => false, 'id' => null, 'item_id' => null, 'price_lookup_key' => null, 'status' => null, 'cancel_at_period_end' => false, 'error' => $result['error']];
+ }
+
+ $item = $result['data']['items']['data'][0] ?? null;
+
+ return [
+ 'ok' => true,
+ 'id' => (string)($result['data']['id'] ?? ''),
+ 'item_id' => $item !== null ? (string)($item['id'] ?? '') : null,
+ 'price_lookup_key' => $item !== null ? (string)($item['price']['lookup_key'] ?? '') : null,
+ 'status' => (string)($result['data']['status'] ?? ''),
+ 'cancel_at_period_end' => (bool)($result['data']['cancel_at_period_end'] ?? false),
+ 'error' => null,
+ ];
+}
+
+/**
+ * Switches an existing active subscription to a different Price in place
+ * (upgrade or downgrade between paid tiers), instead of creating a brand
+ * new Checkout Session. Stripe prorates the difference automatically.
+ * Also clears any pending cancel_at_period_end, since choosing a plan is
+ * an explicit signal the tenant wants to keep the subscription running.
+ *
+ * @return array{ok: bool, error: ?string}
+ */
+function stripe_update_subscription_price(string $subscriptionId, string $subscriptionItemId, string $newPriceId): array
+{
+ $result = stripe_request('POST', "subscriptions/{$subscriptionId}", [
+ 'items' => [['id' => $subscriptionItemId, 'price' => $newPriceId]],
+ 'proration_behavior' => 'create_prorations',
+ 'cancel_at_period_end' => 'false',
+ ]);
+
+ if (!$result['ok']) {
+ return ['ok' => false, 'error' => $result['error']];
+ }
+
+ return ['ok' => true, 'error' => null];
+}
+
+/**
+ * Cancels a subscription immediately (not at period end) - used when a
+ * tenant downgrades all the way to the free plan from the admin UI.
+ *
+ * @return array{ok: bool, error: ?string}
+ */
+function stripe_cancel_subscription(string $subscriptionId): array
+{
+ $result = stripe_request('DELETE', "subscriptions/{$subscriptionId}");
+ if (!$result['ok']) {
+ return ['ok' => false, 'error' => $result['error']];
+ }
+
+ return ['ok' => true, 'error' => null];
+}
+
/**
* @return array{id: string, type: string, data: array}|null
*/
diff --git a/docs/billing.md b/docs/billing.md
index 2eb11ed..44455f6 100644
--- a/docs/billing.md
+++ b/docs/billing.md
@@ -259,3 +259,71 @@ stripe-webhook.php (invoice.paid ruft dolibarr_sync_invoice_paid())
scharf zu bestätigen.
- Zahlungserfassung in Dolibarr (Rechnung als bezahlt markieren) als
möglicher Ausbau, sobald Bankkonto-/Zahlungsart-IDs bekannt sind.
+
+## Phase 4: Selbstbedienungs-Tarifverwaltung im Mandanten-Backend (umgesetzt)
+
+**Ausgangslage:** Bislang sah der Mandant nur einen erzwungenen
+Upgrade-Hinweis, sobald sein aktueller Tarif die Teilnehmerzahl nicht mehr
+deckte (`billingCheck['needs_upgrade']`); er konnte keinen anderen Tarif
+frei wählen, kein Downgrade vornehmen und keine echte Bestellung auslösen,
+solange kein Pflicht-Upgrade anstand.
+
+Umgesetzte Dateien:
+
+```text
+app/stripe.php (stripe_get_subscription, stripe_update_subscription_price, stripe_cancel_subscription)
+app/billing.php (billing_plan_selectable_for)
+abo-upgrade.php (Fallunterscheidung Upgrade/Downgrade/Kündigung)
+mandant-einstellungen.php (vollständige Tarif-Vergleichstabelle mit Bestell-Button je Tarif)
+```
+
+- **Tarif-Vergleichstabelle** in „Abo & Tarif" zeigt jetzt alle Tarifstufen
+ (nicht nur den benötigten) mit Limit, Preis und einem Aktions-Button je
+ Zeile: aktueller Tarif ist markiert, Tarife die die aktuelle
+ Teilnehmerzahl nicht abdecken sind deaktiviert
+ (`billing_plan_selectable_for()`), Tarife ohne Stripe-Preis (`enterprise`)
+ verlinken auf eine manuelle Anfrage per E-Mail.
+- **`abo-upgrade.php`** unterscheidet jetzt drei Fälle statt nur
+ „Checkout-Session erzeugen":
+ 1. Zielwert `free` mit bestehender bezahlter Subscription → echtes
+ `stripe_cancel_subscription()`, lokal auf `free`/`canceled` gesetzt.
+ 2. Zielwert ein anderer bezahlter Tarif **und** bereits eine
+ aktive/`trialing`/`past_due`-Subscription vorhanden → Preis wird
+ in-place über `stripe_update_subscription_price()` gewechselt
+ (Stripe-Proration übernimmt die anteilige Verrechnung), kein neuer
+ Checkout-Redirect nötig. Lokal sofort gespiegelt, der Webhook
+ (`customer.subscription.updated`) bestätigt denselben Stand redundant.
+ 3. Kein aktives Abo bisher → wie vorher eine neue Stripe-Checkout-Session.
+- **`app/stripe.php`** neu: `stripe_get_subscription()` (liest Status,
+ Subscription-Item-ID und aktuellen `lookup_key`),
+ `stripe_update_subscription_price()` (Preis am bestehenden
+ Subscription-Item austauschen, `cancel_at_period_end` explizit auf
+ `false` – ein Tarifwechsel ist ein Signal, das Abo fortzuführen),
+ `stripe_cancel_subscription()` (sofortige Kündigung, nicht zum
+ Periodenende).
+- **`app/billing.php`** neu: `billing_plan_selectable_for()` prüft, ob die
+ aktive Teilnehmerzahl das Limit eines Zieltarifs einhält – Basis für das
+ Deaktivieren nicht passender Tarife in der UI und für die
+ Server-seitige Validierung in `abo-upgrade.php`.
+
+### Bewusste Entscheidungen
+
+- Downgrade auf einen bezahlten, aber zu kleinen Tarif wird serverseitig
+ abgelehnt (`billing_plan_selectable_for()`), nicht nur clientseitig
+ ausgegraut – verhindert, dass ein Mandant sich selbst unter sein
+ eigenes Teilnehmerlimit bucht.
+- `enterprise` bleibt bewusst kein Selbstbedienungs-Ziel (kein
+ Stripe-Preis vorhanden, Preis „auf Anfrage"); Auswahl verlinkt auf
+ manuellen Kontakt statt eine falsche Buchung zu versuchen.
+- Bestätigungsdialog (JS `confirm()`) vor jedem Tarifwechsel, da ein
+ Wechsel bei Stripe sofort wirksam wird und anteilig verrechnet wird.
+
+### Noch offen
+
+- Kein automatisierter Live-Test gegen echte Stripe-Testobjekte für den
+ In-Place-Preiswechsel (`stripe_update_subscription_price`) – die
+ vorhandene Dev-Umgebung hat keinen PHP-Interpreter/DB-Zugriff für einen
+ Live-Check; nur Syntax- und reine Funktionslogik-Tests ohne DB/Stripe-
+ Zugriff wurden hier durchgeführt. Vor Go-Live einmal im Stripe-Testmodus
+ gegen einen echten Test-Mandanten durchspielen (Upgrade, Downgrade
+ zwischen zwei bezahlten Stufen, Downgrade auf `free`).
diff --git a/mandant-einstellungen.php b/mandant-einstellungen.php
index 4ee9d71..ca3193e 100644
--- a/mandant-einstellungen.php
+++ b/mandant-einstellungen.php
@@ -192,34 +192,72 @@ include 'nav.php';
Aktive Teilnehmer:
- Danke! Die Zahlung wird verarbeitet, der Tarif ist gleich aktuell.
+ Danke! Die Änderung wird verarbeitet, der Tarif ist gleich aktuell.
Der Bezahlvorgang wurde abgebrochen, es wurde nichts geändert.
-
Mit aktiven Teilnehmern benötigt ihr den
- Tarif „".
- Details zu den Stufen stehen unter Preise .
-
-
-
-
Für diese Teilnehmerzahl meldet euch bitte bei uns .
-
+
Mit aktiven Teilnehmern benötigt ihr mindestens den
+ Tarif „".
Euer aktueller Tarif deckt eure Teilnehmerzahl ab.
+
+
+ Tarif
+ Max. aktive Teilnehmer
+ Preis/Monat
+ Status
+
+
+ $plan): ?>
+
+ >
+
+
+ 0 ? saas_html(saas_format_money_cents($plan['price_cents'])) . ' €' : 'kostenlos'; ?>
+
+
+ aktuell gebucht
+
+ deckt eure Teilnehmer nicht ab
+
+
+
+
+ —
+
+ Anfragen
+
+ Nicht wählbar
+
+
+
+
+
+
+
+ Ein Wechsel zwischen bezahlten Tarifen wird bei Stripe anteilig verrechnet (Proration) und
+ gilt sofort. Details zu den Tarifgrenzen stehen unter Preise .
+