diff --git a/app/billing.php b/app/billing.php index 0c1794d..85b37ad 100644 --- a/app/billing.php +++ b/app/billing.php @@ -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 diff --git a/docs/billing.md b/docs/billing.md index b0e4784..2eb11ed 100644 --- a/docs/billing.md +++ b/docs/billing.md @@ -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) diff --git a/mitarbeiterverwalten.php b/mitarbeiterverwalten.php index aa6740e..4303431 100644 --- a/mitarbeiterverwalten.php +++ b/mitarbeiterverwalten.php @@ -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){

Dev-Link zur Einladung:

+

Aktive Mitglieder: . + = $billingPlan['max_participants']): ?> + Limit erreicht – für weitere aktive Mitglieder ist ein Upgrade nötig. +

+

Bearbeiten von

"> diff --git a/scripts/check-billing-capacity.php b/scripts/check-billing-capacity.php new file mode 100644 index 0000000..b8e4658 --- /dev/null +++ b/scripts/check-billing-capacity.php @@ -0,0 +1,241 @@ + $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";