diff --git a/abo-upgrade.php b/abo-upgrade.php index acb85ec..fe65057 100644 --- a/abo-upgrade.php +++ b/abo-upgrade.php @@ -37,95 +37,105 @@ if (!billing_plan_selectable_for($pdo, $tenantId, $planCode)) { $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'])); +try { + // 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.'); } - } - - 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.'); -} - -// 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'])); + 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'])); + } } - // 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', + 'plan_code' => 'free', + 'subscription_status' => 'canceled', ]); - app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.plan_switched', 'tenant', $tenantId, ['plan_code' => $planCode]); + app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.downgraded_to_free', 'tenant', $tenantId); 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'], - $billing['stripe_customer_id'], - $baseUrl . '?upgrade=success', - $baseUrl . '?upgrade=cancelled', - ['tenant_id' => $tenantId, 'plan_code' => $planCode] -); + $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.'); + } -if (!$result['ok']) { - app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.checkout_failed', 'tenant', $tenantId, ['plan_code' => $planCode, 'error' => $result['error']]); + // 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'], + $billing['stripe_customer_id'], + $baseUrl . '?upgrade=success', + $baseUrl . '?upgrade=cancelled', + ['tenant_id' => $tenantId, 'plan_code' => $planCode] + ); + + if (!$result['ok']) { + app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.checkout_failed', 'tenant', $tenantId, ['plan_code' => $planCode, 'error' => $result['error']]); + http_response_code(502); + exit('Der Bezahlvorgang konnte nicht gestartet werden: ' . saas_html((string)$result['error'])); + } + + app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.checkout_started', 'tenant', $tenantId, ['plan_code' => $planCode]); + + header('Location: ' . $result['url']); + exit; +} catch (RuntimeException $e) { + // Stripe ist in dieser Umgebung nicht konfiguriert (fehlender + // STRIPE_SECRET_KEY, z. B. lokale Dev-Umgebung ohne echte Zugangsdaten) + // oder eine andere Konfigurationsvoraussetzung fehlt - sauber als + // Dienst-nicht-verfuegbar melden statt eine Fatal-Error-Seite zu zeigen. + app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.stripe_unavailable', 'tenant', $tenantId, ['plan_code' => $planCode, 'error' => $e->getMessage()]); http_response_code(502); - exit('Der Bezahlvorgang konnte nicht gestartet werden: ' . saas_html((string)$result['error'])); + exit('Der Bezahldienst ist aktuell nicht verfügbar. Bitte später erneut versuchen oder uns kontaktieren.'); } - -app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.checkout_started', 'tenant', $tenantId, ['plan_code' => $planCode]); - -header('Location: ' . $result['url']); -exit; diff --git a/app/billing.php b/app/billing.php index ade0a53..6cc90ed 100644 --- a/app/billing.php +++ b/app/billing.php @@ -27,7 +27,10 @@ function billing_plans(): array /** * Resolves the Stripe Price id for a plan by its lookup_key. Returns null - * for plans without a Stripe price (free, enterprise) or on API failure. + * for plans without a Stripe price (free, enterprise), on API failure, or + * when Stripe isn't configured at all (missing STRIPE_SECRET_KEY, e.g. in + * a local/dev environment) - callers already treat null as "not available + * right now", so this degrades gracefully instead of a fatal error. */ function billing_stripe_price_id(string $planCode): ?string { @@ -37,7 +40,12 @@ function billing_stripe_price_id(string $planCode): ?string return null; } - $result = stripe_request('GET', 'prices', ['lookup_keys' => [$lookupKey], 'active' => 'true']); + try { + $result = stripe_request('GET', 'prices', ['lookup_keys' => [$lookupKey], 'active' => 'true']); + } catch (RuntimeException $e) { + return null; + } + if ($result['ok'] && !empty($result['data']['data'][0]['id'])) { return (string)$result['data']['data'][0]['id']; }