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:
+75
-3
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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`).
|
||||
|
||||
+52
-14
@@ -192,34 +192,72 @@ include 'nav.php';
|
||||
Aktive Teilnehmer: <?php echo (int)$billingCheck['active_participants']; ?>
|
||||
</p>
|
||||
<?php if (isset($_GET['upgrade']) && $_GET['upgrade'] === 'success'): ?>
|
||||
<div class="hint-box success"><p>Danke! Die Zahlung wird verarbeitet, der Tarif ist gleich aktuell.</p></div>
|
||||
<div class="hint-box success"><p>Danke! Die Änderung wird verarbeitet, der Tarif ist gleich aktuell.</p></div>
|
||||
<?php elseif (isset($_GET['upgrade']) && $_GET['upgrade'] === 'cancelled'): ?>
|
||||
<div class="hint-box error"><p>Der Bezahlvorgang wurde abgebrochen, es wurde nichts geändert.</p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($billingCheck['needs_upgrade']): ?>
|
||||
<div class="hint-box error">
|
||||
<p>Mit <?php echo (int)$billingCheck['active_participants']; ?> aktiven Teilnehmern benötigt ihr den
|
||||
Tarif „<?php echo saas_html($billingPlans[$billingCheck['required_plan']]['label'] ?? $billingCheck['required_plan']); ?>".
|
||||
Details zu den Stufen stehen unter <a href="preise.php">Preise</a>.</p>
|
||||
<?php if (($billingPlans[$billingCheck['required_plan']]['stripe_lookup_key'] ?? null) !== null): ?>
|
||||
<form method="post" action="abo-upgrade.php">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="plan_code" value="<?php echo saas_html($billingCheck['required_plan']); ?>">
|
||||
<button type="submit">Jetzt auf „<?php echo saas_html($billingPlans[$billingCheck['required_plan']]['label']); ?>" upgraden</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<p>Für diese Teilnehmerzahl <a href="mailto:info@ctb-it.de">meldet euch bitte bei uns</a>.</p>
|
||||
<?php endif; ?>
|
||||
<p>Mit <?php echo (int)$billingCheck['active_participants']; ?> aktiven Teilnehmern benötigt ihr mindestens den
|
||||
Tarif „<?php echo saas_html($billingPlans[$billingCheck['required_plan']]['label'] ?? $billingCheck['required_plan']); ?>".</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="hint-box success"><p>Euer aktueller Tarif deckt eure Teilnehmerzahl ab.</p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Tarif</th>
|
||||
<th>Max. aktive Teilnehmer</th>
|
||||
<th>Preis/Monat</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<?php foreach ($billingPlans as $planCode => $plan): ?>
|
||||
<?php
|
||||
$isCurrent = $planCode === $billingCheck['current_plan'];
|
||||
$selectable = billing_plan_selectable_for($pdo, (int)$user['tenant_id'], $planCode);
|
||||
$isSelfService = $plan['stripe_lookup_key'] !== null || $planCode === 'free';
|
||||
?>
|
||||
<tr<?php echo $isCurrent ? ' class="hint-box success"' : ''; ?>>
|
||||
<td><?php echo saas_html($plan['label']); ?></td>
|
||||
<td><?php echo $plan['max_participants'] !== null ? (int)$plan['max_participants'] : 'unbegrenzt'; ?></td>
|
||||
<td><?php echo $plan['price_cents'] > 0 ? saas_html(saas_format_money_cents($plan['price_cents'])) . ' €' : 'kostenlos'; ?></td>
|
||||
<td>
|
||||
<?php if ($isCurrent): ?>
|
||||
aktuell gebucht
|
||||
<?php elseif (!$selectable): ?>
|
||||
deckt eure <?php echo (int)$billingCheck['active_participants']; ?> Teilnehmer nicht ab
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($isCurrent): ?>
|
||||
—
|
||||
<?php elseif (!$isSelfService): ?>
|
||||
<a href="mailto:info@ctb-it.de" class="button small">Anfragen</a>
|
||||
<?php elseif (!$selectable): ?>
|
||||
<button type="button" class="button small" disabled>Nicht wählbar</button>
|
||||
<?php else: ?>
|
||||
<form method="post" action="abo-upgrade.php" onsubmit="return confirm('Tarif wirklich auf ' + <?php echo json_encode($plan['label']); ?> + ' ändern?');">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="plan_code" value="<?php echo saas_html($planCode); ?>">
|
||||
<button type="submit" class="button small primary">
|
||||
<?php echo $planCode === 'free' ? 'Kündigen / auf Kostenlos wechseln' : 'Buchen'; ?>
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<p><small>Ein Wechsel zwischen bezahlten Tarifen wird bei Stripe anteilig verrechnet (Proration) und
|
||||
gilt sofort. Details zu den Tarifgrenzen stehen unter <a href="preise.php">Preise</a>.</small></p>
|
||||
|
||||
<?php if (($billing['stripe_customer_id'] ?? null) !== null): ?>
|
||||
<form method="post" action="abo-portal.php">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<button type="submit">Zahlungsmethode verwalten / Abo kündigen</button>
|
||||
<button type="submit">Zahlungsmethode verwalten</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user