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 | ++ |
|---|---|---|---|---|
| + | + | 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.
+