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.
This commit is contained in:
2026-07-20 20:19:05 +00:00
parent ef5d0e1822
commit dd2277c803
5 changed files with 283 additions and 17 deletions
+75 -3
View File
@@ -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'],