Harte Teilnehmer-Obergrenze je Tarif statt automatischem Stufenwechsel

Statt automatisch hochzustufen, blockiert das Anlegen/Reaktivieren
weiterer aktiver Mitglieder, sobald das Limit des gebuchten Tarifs
erreicht ist (Neuanlage, Bearbeiten mit Statuswechsel, Aktivieren-
Button). mitarbeiterverwalten.php zeigt die aktuelle Auslastung an.
Neuer Regressionstest scripts/check-billing-capacity.php deckt alle
drei Enforcement-Stellen ab.
This commit is contained in:
2026-07-17 11:19:00 +02:00
parent 73d599f85b
commit d2330c81cd
4 changed files with 334 additions and 4 deletions
+28
View File
@@ -129,6 +129,34 @@ function billing_find_tenant_id_by_stripe_subscription(PDO $pdo, string $stripeS
return $tenantId !== false ? (int)$tenantId : null;
}
/**
* Hard cap enforcement: whether the tenant's current plan still has room
* for $additional more active participants. There is no automatic plan
* upgrade - tenants that outgrow their plan must upgrade themselves
* (mandant-einstellungen.php) before adding/reactivating more members.
*/
function billing_has_capacity_for(PDO $pdo, int $tenantId, int $additional = 1): bool
{
$billing = billing_fetch_or_init($pdo, $tenantId);
$max = billing_plans()[$billing['plan_code']]['max_participants'] ?? null;
if ($max === null) {
return true;
}
return billing_count_active_participants($pdo, $tenantId) + $additional <= $max;
}
function billing_capacity_error_message(PDO $pdo, int $tenantId): string
{
$billing = billing_fetch_or_init($pdo, $tenantId);
$plan = billing_plans()[$billing['plan_code']] ?? null;
$label = $plan['label'] ?? $billing['plan_code'];
$max = $plan['max_participants'] ?? null;
return "Euer aktueller Tarif „{$label}\" erlaubt maximal {$max} aktive Mitglieder. "
. 'Bitte upgradet unter Mandant-Einstellungen, um mehr Mitglieder freizuschalten.';
}
/**
* Compares the tenant's currently booked plan against what their active
* participant count actually requires. Purely informational until the
+41 -3
View File
@@ -110,9 +110,47 @@ mandant-einstellungen.php (Upgrade-/Portal-Buttons)
Stripe-Kunde existiert, „Zahlungsmethode verwalten / Abo kündigen"
(führt zum Customer Portal).
**Noch nicht umgesetzt:** automatischer Stufenwechsel bei wachsender
Teilnehmerzahl (aktuell muss der Kunde selbst erneut auf „Upgraden"
klicken, wenn `billing_check_tenant()` einen höheren Bedarf anzeigt).
**Bewusst nicht umgesetzt:** automatischer Stufenwechsel bei wachsender
Teilnehmerzahl. Stattdessen gilt eine harte Obergrenze (siehe unten):
wer mehr aktive Mitglieder braucht, muss selbst upgraden.
## Kapazitätsgrenze je Tarif (umgesetzt)
Statt automatisch hochzustufen, wird die Teilnehmerzahl pro Mandant hart
auf das Limit des aktuell gebuchten Tarifs begrenzt.
Umgesetzte Dateien:
```text
app/billing.php (billing_has_capacity_for(), billing_capacity_error_message())
mitarbeiterverwalten.php (Pruefung vor Anlegen/Aktivieren/Bearbeiten)
scripts/check-billing-capacity.php (Regressionstest)
```
- `billing_has_capacity_for($pdo, $tenantId, $additional = 1)`: prüft,
ob die aktive Teilnehmerzahl zzgl. `$additional` noch innerhalb des
Limits des gebuchten Tarifs liegt (`enterprise` = unbegrenzt).
- Geprüft an allen drei Stellen, die einen Teilnehmer aktiv anlegen oder
reaktivieren können: Neuanlage mit gesetztem „Aktiv"-Haken, Bearbeiten
eines bisher inaktiven Mitglieds mit gesetztem Haken, sowie der
dedizierte „Aktivieren"-Button. Deaktivieren, reines Bearbeiten ohne
Statuswechsel und CSV-Import (bucht nur Zahlungen auf bestehende
Mitglieder, legt keine neuen an) sind nicht betroffen.
- Bei Überschreitung erscheint eine Fehlermeldung mit Tarifname, Limit
und Verweis auf Mandant-Einstellungen zum Upgraden; die Aktion wird
nicht ausgeführt.
- `mitarbeiterverwalten.php` zeigt zusätzlich durchgehend „Aktive
Mitglieder: X von maximal Y" an, damit das Limit vor dem Anlegen
sichtbar ist.
- Live getestet (`scripts/check-billing-capacity.php`, 10 Assertionen):
10. aktives Mitglied bei Cap 10 wird angelegt, 11. wird abgelehnt und
landet nicht in der DB, inaktives Anlegen bei vollem Cap funktioniert
weiterhin, Aktivieren eines inaktiven Mitglieds bei vollem Cap wird
abgelehnt, nach Deaktivieren eines anderen Mitglieds klappt es, und
die Reaktivierung über das Bearbeiten-Formular wird ebenso geprüft.
Alle bestehenden Tenants lagen zum Zeitpunkt der Umsetzung deutlich
unter ihrem Free-Limit (max. 8 von 10 aktiven Teilnehmern), daher keine
Auswirkung auf laufenden Betrieb.
### Live getestet (Testmodus, kein echtes Geld)
+24 -1
View File
@@ -5,6 +5,7 @@ include "functions.php";
require_once __DIR__ . "/app/ledger.php";
require_once __DIR__ . "/app/saas-mail.php";
require_once __DIR__ . "/app/audit.php";
require_once __DIR__ . "/app/billing.php";
app_require_csrf();
include "header.php";
include "headerline.php";
@@ -59,6 +60,8 @@ if($hasAccess){
if ($name === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$fehler = 'Bitte einen Namen und eine gültige E-Mail-Adresse angeben.';
} elseif ($aktiv && !billing_has_capacity_for($pdo, $tenantId)) {
$fehler = billing_capacity_error_message($pdo, $tenantId);
} else {
try {
$neueId = ledger_create_participant($pdo, $tenantId, $name, $email, $paypalname, $aktiv);
@@ -75,8 +78,18 @@ if($hasAccess){
$paypalname = trim((string)($_POST["paypalname"] ?? ''));
$aktiv = isset($_POST["aktiv"]);
$warAktiv = false;
foreach (ledger_fetch_participants_for_admin($pdo, $tenantId) as $m) {
if ($m['participant_id'] === $participantId) {
$warAktiv = (bool)$m['active'];
break;
}
}
if ($name === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$fehler = 'Bitte einen Namen und eine gültige E-Mail-Adresse angeben.';
} elseif ($aktiv && !$warAktiv && !billing_has_capacity_for($pdo, $tenantId)) {
$fehler = billing_capacity_error_message($pdo, $tenantId);
} elseif (ledger_update_participant($pdo, $tenantId, $participantId, $name, $email, $paypalname, $aktiv)) {
app_audit_log($pdo, $tenantId, $actorUserId, 'participant.updated', 'participant', $participantId, ['name' => $name, 'email' => $email]);
$meldung = 'Mitglied wurde gespeichert.';
@@ -85,7 +98,9 @@ if($hasAccess){
}
} elseif ($aktion === 'aktivieren' || $aktion === 'deaktivieren') {
$participantId = (int)$_POST["mitgliedID"];
if (ledger_set_participant_active($pdo, $tenantId, $participantId, $aktion === 'aktivieren')) {
if ($aktion === 'aktivieren' && !billing_has_capacity_for($pdo, $tenantId)) {
$fehler = billing_capacity_error_message($pdo, $tenantId);
} elseif (ledger_set_participant_active($pdo, $tenantId, $participantId, $aktion === 'aktivieren')) {
app_audit_log($pdo, $tenantId, $actorUserId, $aktion === 'aktivieren' ? 'participant.activated' : 'participant.deactivated', 'participant', $participantId);
$meldung = $aktion === 'aktivieren' ? 'Mitglied wurde aktiviert.' : 'Mitglied wurde deaktiviert.';
} else {
@@ -144,6 +159,9 @@ if($hasAccess){
}
$mitglieder = ledger_fetch_participants_for_admin($pdo, $tenantId);
$billingInfo = billing_fetch_or_init($pdo, $tenantId);
$billingPlan = billing_plans()[$billingInfo['plan_code']] ?? null;
$aktiveMitgliederAnzahl = billing_count_active_participants($pdo, $tenantId);
$rollenLabels = ['owner' => 'Inhaber', 'admin' => 'Administrator', 'treasurer' => 'Kassenwart', 'member' => 'Mitglied', 'viewer' => 'Betrachter'];
$bearbeitenMitglied = null;
if ($bearbeitenId !== null) {
@@ -177,6 +195,11 @@ if($hasAccess){
<div class="hint-box"><p>Dev-Link zur Einladung: <a href="<?php echo saas_html($einladungslink); ?>"><?php echo saas_html($einladungslink); ?></a></p></div>
<?php endif; ?>
<p>Aktive Mitglieder: <?php echo (int)$aktiveMitgliederAnzahl; ?><?php echo $billingPlan['max_participants'] !== null ? ' von maximal ' . (int)$billingPlan['max_participants'] . ' im Tarif „' . saas_html($billingPlan['label']) . '"' : ' (Tarif „' . saas_html($billingPlan['label'] ?? $billingInfo['plan_code']) . '", unbegrenzt)'; ?>.
<?php if ($billingPlan['max_participants'] !== null && $aktiveMitgliederAnzahl >= $billingPlan['max_participants']): ?>
Limit erreicht für weitere aktive Mitglieder ist ein <a href="mandant-einstellungen.php">Upgrade</a> nötig.
<?php endif; ?></p>
<?php if ($bearbeitenMitglied !== null): ?>
<h3>Bearbeiten von <?php echo saas_html($bearbeitenMitglied['display_name']); ?></h3>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
+241
View File
@@ -0,0 +1,241 @@
<?php
declare(strict_types=1);
require __DIR__ . '/dev-db.php';
$baseUrl = rtrim((string)(getenv('SMOKE_BASE_URL') ?: 'http://127.0.0.1:8080'), '/');
$pdo = dev_pdo();
$suffix = bin2hex(random_bytes(4));
$password = 'BillingCapTest123!';
/**
* @param array<string,string> $cookies
* @return array{status:int, body:string}
*/
function bc_request(string $url, array &$cookies, string $method = 'GET', ?string $postBody = null): array
{
$cookieHeader = '';
if ($cookies !== []) {
$pairs = [];
foreach ($cookies as $name => $value) {
$pairs[] = "{$name}={$value}";
}
$cookieHeader = 'Cookie: ' . implode('; ', $pairs) . "\r\n";
}
$headers = "User-Agent: KaffeelisteBillingCap/1.0\r\n" . $cookieHeader;
if ($postBody !== null) {
$headers .= "Content-Type: application/x-www-form-urlencoded\r\n";
}
$context = stream_context_create([
'http' => [
'method' => $method,
'header' => $headers,
'content' => $postBody,
'timeout' => 15,
'ignore_errors' => true,
'follow_location' => 0,
],
]);
$body = @file_get_contents($url, false, $context);
$responseHeaders = $http_response_header ?? [];
$status = 0;
foreach ($responseHeaders as $header) {
if (preg_match('~^HTTP/\S+\s+(\d{3})~', $header, $m) === 1) {
$status = (int)$m[1];
}
if (preg_match('/^Set-Cookie:\s*([^=;]+)=([^;]+)/i', $header, $m) === 1) {
$cookies[$m[1]] = $m[2];
}
}
return ['status' => $status, 'body' => (string)$body];
}
function bc_csrf(string $body): string
{
preg_match('/name="csrf_token" value="([^"]+)"/', $body, $matches);
return $matches[1] ?? '';
}
$tenantId = null;
$userId = null;
$extraParticipantIds = [];
try {
$stmt = $pdo->prepare('INSERT INTO tenants (slug, name, status) VALUES (?, ?, ?)');
$stmt->execute(["billingcap-{$suffix}", 'Billing Cap Tenant', 'active']);
$tenantId = (int)$pdo->lastInsertId();
$pdo->prepare('INSERT INTO tenant_settings (tenant_id) VALUES (?)')->execute([$tenantId]);
$email = "billingcap-owner-{$suffix}@test.local";
$pdo->prepare('INSERT INTO users (email, email_norm, display_name, password_hash, status) VALUES (?, ?, ?, ?, ?)')
->execute([$email, $email, 'Billing Cap Owner', password_hash($password, PASSWORD_DEFAULT), 'active']);
$userId = (int)$pdo->lastInsertId();
$pdo->prepare('INSERT INTO tenant_memberships (tenant_id, user_id, role, status, joined_at) VALUES (?, ?, ?, ?, NOW())')
->execute([$tenantId, $userId, 'owner', 'active']);
$pdo->prepare('INSERT INTO participants (tenant_id, user_id, display_name, email, email_norm, active) VALUES (?, ?, ?, ?, ?, 1)')
->execute([$tenantId, $userId, 'Billing Cap Owner', $email, $email]);
// Free-Plan-Limit ist 10. Owner zaehlt schon als 1 aktiver Teilnehmer,
// also 8 weitere anlegen, um bei 9 von 10 zu landen.
for ($i = 1; $i <= 8; $i++) {
$pEmail = "billingcap-seed{$i}-{$suffix}@test.local";
$pdo->prepare('INSERT INTO participants (tenant_id, display_name, email, email_norm, active) VALUES (?, ?, ?, ?, 1)')
->execute([$tenantId, "Seed {$i}", $pEmail, $pEmail]);
$extraParticipantIds[] = (int)$pdo->lastInsertId();
}
} catch (Throwable $e) {
fwrite(STDERR, "Billing capacity test setup failed: {$e->getMessage()}\n");
exit(1);
}
$failures = [];
$passes = 0;
function bc_assert(string $label, bool $condition, array &$failures, int &$passes): void
{
if ($condition) {
$passes++;
echo "PASS {$label}\n";
} else {
$failures[] = $label;
echo "FAIL {$label}\n";
}
}
$cookies = [];
$loginPage = bc_request("{$baseUrl}/login.php", $cookies);
$csrf = bc_csrf($loginPage['body']);
bc_request("{$baseUrl}/login.php", $cookies, 'POST', http_build_query([
'csrf_token' => $csrf,
'email' => $email,
'password' => $password,
'tenant_slug' => "billingcap-{$suffix}",
]));
// 9 aktive Teilnehmer vorhanden (Cap 10) -> ein aktives Anlegen muss klappen (10/10).
$page = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies);
$body10 = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
'aktion' => 'anlegen',
'csrf_token' => bc_csrf($page['body']),
'name' => 'Zehntes Mitglied',
'email' => "billingcap-tenth-{$suffix}@test.local",
'paypalname' => '',
'aktiv' => '1',
]));
bc_assert('10. aktives Mitglied bei Cap 10 wird angelegt', str_contains($body10['body'], 'wurde angelegt'), $failures, $passes);
// Jetzt bei 10/10 -> ein weiteres aktives Anlegen muss abgelehnt werden.
$page = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies);
$body11 = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
'aktion' => 'anlegen',
'csrf_token' => bc_csrf($page['body']),
'name' => 'Elftes Mitglied',
'email' => "billingcap-eleventh-{$suffix}@test.local",
'paypalname' => '',
'aktiv' => '1',
]));
bc_assert('11. aktives Mitglied bei Cap 10 wird abgelehnt', str_contains($body11['body'], 'maximal 10 aktive Mitglieder'), $failures, $passes);
bc_assert('11. Mitglied wurde nicht in der DB angelegt', (int)$pdo->query("SELECT COUNT(*) FROM participants WHERE tenant_id = {$tenantId} AND email = 'billingcap-eleventh-{$suffix}@test.local'")->fetchColumn() === 0, $failures, $passes);
// Inaktives Anlegen bei vollem Cap muss weiterhin moeglich sein.
$page = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies);
$bodyInactive = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
'aktion' => 'anlegen',
'csrf_token' => bc_csrf($page['body']),
'name' => 'Inaktives Mitglied',
'email' => "billingcap-inactive-{$suffix}@test.local",
'paypalname' => '',
]));
bc_assert('Inaktives Mitglied bei vollem Cap wird angelegt', str_contains($bodyInactive['body'], 'wurde angelegt'), $failures, $passes);
$inactiveId = (int)$pdo->query("SELECT id FROM participants WHERE tenant_id = {$tenantId} AND email = 'billingcap-inactive-{$suffix}@test.local'")->fetchColumn();
bc_assert('Neu angelegtes Mitglied ist tatsaechlich inaktiv', (int)$pdo->query("SELECT active FROM participants WHERE id = {$inactiveId}")->fetchColumn() === 0, $failures, $passes);
// Aktivieren des inaktiven Mitglieds bei vollem Cap muss abgelehnt werden.
$page = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies);
$bodyActivateBlocked = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
'aktion' => 'aktivieren',
'csrf_token' => bc_csrf($page['body']),
'mitgliedID' => (string)$inactiveId,
]));
bc_assert('Aktivieren bei vollem Cap wird abgelehnt', str_contains($bodyActivateBlocked['body'], 'maximal 10 aktive Mitglieder'), $failures, $passes);
bc_assert('Mitglied ist nach abgelehntem Aktivieren weiterhin inaktiv', (int)$pdo->query("SELECT active FROM participants WHERE id = {$inactiveId}")->fetchColumn() === 0, $failures, $passes);
// Ein aktives Mitglied deaktivieren (Platz schaffen), dann klappt Aktivieren.
$freeUpId = $extraParticipantIds[0];
$page = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies);
bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
'aktion' => 'deaktivieren',
'csrf_token' => bc_csrf($page['body']),
'mitgliedID' => (string)$freeUpId,
]));
$page = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies);
$bodyActivateOk = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
'aktion' => 'aktivieren',
'csrf_token' => bc_csrf($page['body']),
'mitgliedID' => (string)$inactiveId,
]));
bc_assert('Aktivieren nach Platzschaffen (Deaktivierung) klappt', str_contains($bodyActivateOk['body'], 'wurde aktiviert'), $failures, $passes);
// bearbeitenspeichern: ein weiteres inaktives Mitglied anlegen, dann per
// Bearbeiten-Formular aktivieren versuchen, wieder bei vollem Cap (10/10).
$page = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies);
bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
'aktion' => 'anlegen',
'csrf_token' => bc_csrf($page['body']),
'name' => 'Editier-Testmitglied',
'email' => "billingcap-edit-{$suffix}@test.local",
'paypalname' => '',
]));
$editId = (int)$pdo->query("SELECT id FROM participants WHERE tenant_id = {$tenantId} AND email = 'billingcap-edit-{$suffix}@test.local'")->fetchColumn();
$page = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies);
$bodyEditBlocked = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
'aktion' => 'bearbeitenspeichern',
'csrf_token' => bc_csrf($page['body']),
'mitgliedID' => (string)$editId,
'name' => 'Editier-Testmitglied',
'email' => "billingcap-edit-{$suffix}@test.local",
'paypalname' => '',
'aktiv' => '1',
]));
bc_assert('bearbeitenspeichern-Reaktivierung bei vollem Cap wird abgelehnt', str_contains($bodyEditBlocked['body'], 'maximal 10 aktive Mitglieder'), $failures, $passes);
// Editieren ohne Statuswechsel (bleibt inaktiv) muss weiterhin klappen.
$page = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies);
$bodyEditOk = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
'aktion' => 'bearbeitenspeichern',
'csrf_token' => bc_csrf($page['body']),
'mitgliedID' => (string)$editId,
'name' => 'Editier-Testmitglied Neu',
'email' => "billingcap-edit-{$suffix}@test.local",
'paypalname' => '',
]));
bc_assert('bearbeitenspeichern ohne Aktivierung bei vollem Cap klappt', str_contains($bodyEditOk['body'], 'wurde gespeichert'), $failures, $passes);
// Aufraeumen
$pdo->prepare('DELETE FROM participants WHERE tenant_id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM tenant_memberships WHERE tenant_id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM tenant_billing WHERE tenant_id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM user_auth_tokens WHERE user_id = ?')->execute([$userId]);
$pdo->prepare('DELETE FROM users WHERE id = ?')->execute([$userId]);
$pdo->prepare('DELETE FROM tenant_settings WHERE tenant_id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]);
if ($failures !== []) {
echo "\nBilling capacity check failed with " . count($failures) . " failure(s):\n";
foreach ($failures as $failure) {
echo "- {$failure}\n";
}
exit(1);
}
echo "\nBilling capacity check passed with {$passes} assertions.\n";