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.

+ + + + + + + + + $plan): ?> + + > + + + + + + + +
TarifMax. aktive TeilnehmerPreis/MonatStatus
0 ? saas_html(saas_format_money_cents($plan['price_cents'])) . ' €' : 'kostenlos'; ?> + + aktuell gebucht + + deckt eure Teilnehmer nicht ab + + + + — + + Anfragen + + + +
+ + + +
+ +
+

Ein Wechsel zwischen bezahlten Tarifen wird bei Stripe anteilig verrechnet (Proration) und + gilt sofort. Details zu den Tarifgrenzen stehen unter Preise.

+
- +