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
+22
View File
@@ -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;
}
+66
View File
@@ -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
*/