Stripe-Konfigurationsfehler in abo-upgrade.php sauber abfangen

billing_stripe_price_id() und der komplette abo-upgrade.php-Ablauf warfen
bisher eine ungefangene RuntimeException (Fatal Error), sobald
STRIPE_SECRET_KEY nicht gesetzt ist (z. B. lokale Dev-Umgebung ohne echte
Stripe-Zugangsdaten) - bestand bereits im alten Code, ist durch die neue
Tarif-Vergleichstabelle mit mehr Buchen-Buttons aber leichter auslösbar
geworden. Jetzt: sauberer 502 'Bezahldienst nicht verfuegbar' statt
Fatal-Error-Seite.

Erstmals lokal per HTTP end-to-end gegen echten Dev-Server + MariaDB
getestet (PHP 8.3 + MariaDB 11.4 als portable Binaries installiert, siehe
docs/dev-mysql.md): eigener Test-Mandant mit 12 aktiven Teilnehmern,
Tarif-Tabelle korrekt gerendert (5 Tarife, Pflicht-Upgrade-Hinweis, Buchen-
Buttons, Enterprise-Anfrage-Link), Buchung von 'free' bei Ueberschreitung
korrekt mit 400 abgelehnt, Buchung eines bezahlten Tarifs ohne Stripe-Key
jetzt sauber mit 502 statt Fatal-Error-Crash. Alle bestehenden
Regressionstests weiterhin gruen: Golden Master (104), Billing-Capacity
(10), M8-Isolation (10), M8-Rollenmatrix (55).
This commit is contained in:
2026-07-20 20:38:12 +00:00
parent dd2277c803
commit bafa5e7e61
2 changed files with 98 additions and 80 deletions
+32 -22
View File
@@ -37,9 +37,10 @@ if (!billing_plan_selectable_for($pdo, $tenantId, $planCode)) {
$billing = billing_fetch_or_init($pdo, $tenantId); $billing = billing_fetch_or_init($pdo, $tenantId);
$baseUrl = saas_app_url('mandant-einstellungen.php'); $baseUrl = saas_app_url('mandant-einstellungen.php');
// Fall 1: Wechsel auf "free" (kein Stripe-Preis) mit bestehender bezahlter try {
// Subscription -> echtes Kuendigen der Subscription, kein Checkout noetig. // Fall 1: Wechsel auf "free" (kein Stripe-Preis) mit bestehender bezahlter
if ($plans[$planCode]['stripe_lookup_key'] === null) { // Subscription -> echtes Kuendigen der Subscription, kein Checkout noetig.
if ($plans[$planCode]['stripe_lookup_key'] === null) {
if ($planCode !== 'free') { if ($planCode !== 'free') {
// enterprise hat ebenfalls keinen Stripe-Preis, ist aber kein // enterprise hat ebenfalls keinen Stripe-Preis, ist aber kein
// Selbstbedienungs-Downgrade-Ziel - manuelle Absprache erforderlich. // Selbstbedienungs-Downgrade-Ziel - manuelle Absprache erforderlich.
@@ -64,19 +65,19 @@ if ($plans[$planCode]['stripe_lookup_key'] === null) {
header('Location: ' . $baseUrl . '?upgrade=success'); header('Location: ' . $baseUrl . '?upgrade=success');
exit; exit;
} }
$priceId = billing_stripe_price_id($planCode); $priceId = billing_stripe_price_id($planCode);
if ($priceId === null) { if ($priceId === null) {
http_response_code(502); http_response_code(502);
exit('Der Tarif ist bei Stripe aktuell nicht verfügbar. Bitte später erneut versuchen.'); 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 -> // Fall 2: Es gibt bereits eine aktive/bezahlte Subscription bei Stripe ->
// Preis in-place wechseln (Up- oder Downgrade zwischen bezahlten Stufen), // Preis in-place wechseln (Up- oder Downgrade zwischen bezahlten Stufen),
// statt eine weitere Checkout-Session zu erzeugen. Stripe rechnet die // statt eine weitere Checkout-Session zu erzeugen. Stripe rechnet die
// Differenz automatisch anteilig ab (Proration). // Differenz automatisch anteilig ab (Proration).
if ($billing['stripe_subscription_id'] !== null) { if ($billing['stripe_subscription_id'] !== null) {
$subscription = stripe_get_subscription((string)$billing['stripe_subscription_id']); $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) { if ($subscription['ok'] && in_array($subscription['status'], ['active', 'trialing', 'past_due'], true) && $subscription['item_id'] !== null) {
@@ -107,25 +108,34 @@ if ($billing['stripe_subscription_id'] !== null) {
// Falls die bestehende Subscription nicht mehr aktiv abrufbar ist // Falls die bestehende Subscription nicht mehr aktiv abrufbar ist
// (z. B. bereits gekuendigt), faellt der Ablauf unten auf eine neue // (z. B. bereits gekuendigt), faellt der Ablauf unten auf eine neue
// Checkout-Session zurueck. // Checkout-Session zurueck.
} }
// Fall 3: Kein aktives Abo bisher (Neu-Abschluss) -> Stripe Checkout Session. // Fall 3: Kein aktives Abo bisher (Neu-Abschluss) -> Stripe Checkout Session.
$result = stripe_create_checkout_session( $result = stripe_create_checkout_session(
$priceId, $priceId,
(string)$user['email'], (string)$user['email'],
$billing['stripe_customer_id'], $billing['stripe_customer_id'],
$baseUrl . '?upgrade=success', $baseUrl . '?upgrade=success',
$baseUrl . '?upgrade=cancelled', $baseUrl . '?upgrade=cancelled',
['tenant_id' => $tenantId, 'plan_code' => $planCode] ['tenant_id' => $tenantId, 'plan_code' => $planCode]
); );
if (!$result['ok']) { if (!$result['ok']) {
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.checkout_failed', 'tenant', $tenantId, ['plan_code' => $planCode, 'error' => $result['error']]); app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.checkout_failed', 'tenant', $tenantId, ['plan_code' => $planCode, 'error' => $result['error']]);
http_response_code(502); http_response_code(502);
exit('Der Bezahlvorgang konnte nicht gestartet werden: ' . saas_html((string)$result['error'])); 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 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;
+9 -1
View File
@@ -27,7 +27,10 @@ function billing_plans(): array
/** /**
* Resolves the Stripe Price id for a plan by its lookup_key. Returns null * 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 function billing_stripe_price_id(string $planCode): ?string
{ {
@@ -37,7 +40,12 @@ function billing_stripe_price_id(string $planCode): ?string
return null; return null;
} }
try {
$result = stripe_request('GET', 'prices', ['lookup_keys' => [$lookupKey], 'active' => 'true']); $result = stripe_request('GET', 'prices', ['lookup_keys' => [$lookupKey], 'active' => 'true']);
} catch (RuntimeException $e) {
return null;
}
if ($result['ok'] && !empty($result['data']['data'][0]['id'])) { if ($result['ok'] && !empty($result['data']['data'][0]['id'])) {
return (string)$result['data']['data'][0]['id']; return (string)$result['data']['data'][0]['id'];
} }