Rechtstexte und B2C-Vertragsabläufe absichern

This commit is contained in:
2026-08-22 14:31:56 +02:00
parent d320a4fd7a
commit a31a235422
58 changed files with 2502 additions and 316 deletions
+154
View File
@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
require __DIR__ . '/dev-db.php';
require_once __DIR__ . '/../app/saas-auth.php';
require_once __DIR__ . '/../app/legal-requests.php';
require_once __DIR__ . '/../app/data-export.php';
$pdo = dev_pdo();
$suffix = bin2hex(random_bytes(4));
$slug = 'legal-check-' . $suffix;
$email = $slug . '@test.local';
$failures = [];
$passes = 0;
$assert = static function (string $label, bool $condition) use (&$failures, &$passes): void {
if ($condition) {
$passes++;
echo "PASS {$label}\n";
} else {
$failures[] = $label;
echo "FAIL {$label}\n";
}
};
$missingAcceptance = saas_register_tenant_owner($pdo, [
'tenant_name' => 'Legal Check Invalid',
'tenant_slug' => $slug . '-invalid',
'display_name' => 'Legal Check',
'email' => 'invalid-' . $email,
'password' => 'Legal-check-123!',
'password_confirm' => 'Legal-check-123!',
'customer_type' => 'consumer',
]);
$assert('Registrierung ohne Rechtstextannahme wird abgelehnt', $missingAcceptance['ok'] === false);
$registration = saas_register_tenant_owner($pdo, [
'tenant_name' => 'Legal Compliance Check',
'tenant_slug' => $slug,
'display_name' => 'Legal Check',
'email' => $email,
'password' => 'Legal-check-123!',
'password_confirm' => 'Legal-check-123!',
'customer_type' => 'consumer',
'accept_terms' => true,
'acknowledge_privacy' => true,
'accept_dpa' => true,
]);
$assert('B2C-Registrierung gelingt mit Pflichtbestätigungen', $registration['ok'] === true);
$tenantId = (int)($registration['identity']['tenant_id'] ?? 0);
$userId = (int)($registration['identity']['user_id'] ?? 0);
if ($tenantId > 0) {
$stmt = $pdo->prepare('SELECT customer_type FROM tenants WHERE id = ?');
$stmt->execute([$tenantId]);
$assert('Vertragstyp Verbraucher wird gespeichert', $stmt->fetchColumn() === 'consumer');
$stmt = $pdo->prepare('SELECT document_type, document_version, metadata_json FROM legal_acceptances WHERE tenant_id = ? ORDER BY document_type');
$stmt->execute([$tenantId]);
$acceptances = $stmt->fetchAll();
$types = array_column($acceptances, 'document_type');
$assert('AGB, AVV und Datenschutzhinweis werden versioniert protokolliert', $types === ['dpa', 'privacy_notice', 'terms']);
$acceptanceEvidence = json_decode((string)$acceptances[0]['metadata_json'], true);
$assert('Rechtstext-Hash ist im Nachweis enthalten', !empty($acceptanceEvidence['document_sha256']));
$assert('Vertragsreferenz bleibt im Nachweis auch nach Kontolöschung erhalten', ($acceptanceEvidence['tenant_slug'] ?? null) === $slug && ($acceptanceEvidence['user_email'] ?? null) === $email);
$input = [
'request_type' => 'cancellation',
'requester_name' => 'Legal Check',
'requester_email' => $email,
'contract_reference' => $slug,
'cancellation_kind' => 'ordinary',
'requested_end' => 'earliest',
'request_reason' => '',
'metadata' => ['cancellation_kind' => 'ordinary'],
];
$stored = app_store_legal_request($pdo, $input);
$processing = app_process_cancellation_request($pdo, $stored, $input);
$assert('Kündigung wird eindeutig dem Vertrag zugeordnet', $stored['tenant_id'] === $tenantId);
$assert('Kostenloser Vertrag wird zum bestätigten Zeitpunkt beendet', $processing['status'] === 'contract_ended');
$confirmation = app_fetch_legal_request_by_token($pdo, $stored['access_token']);
$assert('Dauerhafte Kündigungsbestätigung ist per Geheimtoken abrufbar', $confirmation !== null && $confirmation['reference_code'] === $stored['reference_code']);
$_GET = ['token' => $stored['access_token']];
ob_start();
include __DIR__ . '/../rechtserklaerung-bestaetigung.php';
$confirmationText = (string)ob_get_clean();
$_GET = [];
$assert('Bestätigungsdownload lässt sich mit einem gültigen Token rendern', str_contains($confirmationText, $stored['reference_code']));
$export = app_export_tenant_data($pdo, $tenantId);
$assert('Datenexport enthält Legal-Nachweise', count($export['legal_acceptances']) === 3 && count($export['legal_requests']) === 1);
$pdo->prepare('DELETE FROM legal_requests WHERE reference_code = ?')->execute([$stored['reference_code']]);
$pdo->prepare('DELETE FROM legal_acceptances WHERE tenant_id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM users WHERE id = ?')->execute([$userId]);
}
$withdrawalSlug = $slug . '-withdrawal';
$withdrawalEmail = 'withdrawal-' . $email;
$withdrawalRegistration = saas_register_tenant_owner($pdo, [
'tenant_name' => 'Legal Withdrawal Check',
'tenant_slug' => $withdrawalSlug,
'display_name' => 'Withdrawal Check',
'email' => $withdrawalEmail,
'password' => 'Legal-check-123!',
'password_confirm' => 'Legal-check-123!',
'customer_type' => 'consumer',
'accept_terms' => true,
'acknowledge_privacy' => true,
'accept_dpa' => true,
]);
if ($withdrawalRegistration['ok']) {
$withdrawalTenantId = (int)$withdrawalRegistration['identity']['tenant_id'];
$withdrawalUserId = (int)$withdrawalRegistration['identity']['user_id'];
$withdrawalInput = [
'request_type' => 'withdrawal',
'requester_name' => 'Withdrawal Check',
'requester_email' => $withdrawalEmail,
'contract_reference' => $withdrawalSlug,
'request_reason' => '',
];
$withdrawalStored = app_store_legal_request($pdo, $withdrawalInput);
$withdrawalProcessing = app_process_withdrawal_request($pdo, $withdrawalStored);
$assert('B2C-Widerruf innerhalb von 14 Tagen beendet den kostenlosen Vertrag', $withdrawalProcessing['status'] === 'withdrawal_effective');
$stmt = $pdo->prepare('SELECT contract_ends_at FROM tenants WHERE id = ?');
$stmt->execute([$withdrawalTenantId]);
$assert('Widerruf sperrt den Vertrag technisch', $stmt->fetchColumn() !== null);
$pdo->prepare('DELETE FROM legal_requests WHERE reference_code = ?')->execute([$withdrawalStored['reference_code']]);
$pdo->prepare('DELETE FROM legal_acceptances WHERE tenant_id = ?')->execute([$withdrawalTenantId]);
$pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$withdrawalTenantId]);
$pdo->prepare('DELETE FROM users WHERE id = ?')->execute([$withdrawalUserId]);
} else {
$assert('B2C-Widerrufstest konnte registriert werden', false);
}
$css = file_get_contents(__DIR__ . '/../assets/css/main.css') ?: '';
$assert('Kein dynamischer Google-Fonts-Abruf im Haupt-CSS', !str_contains($css, 'fonts.googleapis.com'));
$assert('Widerrufsfunktion ist hervorgehoben verlinkt', str_contains(app_public_legal_footer(), 'Vertrag widerrufen'));
$assert('Kündigungsschaltfläche ist ständig verlinkt', str_contains(app_public_legal_footer(), 'Verträge hier kündigen'));
$assert('Bestellschaltfläche weist eindeutig auf die Zahlungspflicht hin', str_contains((string)file_get_contents(__DIR__ . '/../abo-bestellen.php'), 'zahlungspflichtig bestellen'));
$stripeCode = (string)file_get_contents(__DIR__ . '/../app/stripe.php');
$assert('Stripe-Portal ist auf das Ändern des Zahlungsmittels beschränkt', str_contains($stripeCode, "'type' => 'payment_method_update'"));
if ($failures !== []) {
echo "\nLegal compliance flow failed with " . count($failures) . " failure(s):\n";
foreach ($failures as $failure) {
echo "- {$failure}\n";
}
exit(1);
}
echo "\nLegal compliance flow passed with {$passes} assertions.\n";
+4
View File
@@ -53,6 +53,10 @@ $result = saas_register_tenant_owner($pdo, [
'email' => $email,
'password' => $password,
'password_confirm' => $password,
'customer_type' => 'business',
'accept_terms' => true,
'acknowledge_privacy' => true,
'accept_dpa' => true,
]);
auth_check_assert('registration succeeds', $result['ok'] === true, $failures, $passes);
+4
View File
@@ -54,6 +54,10 @@ $registration = saas_register_tenant_owner($pdo, [
'email' => $email,
'password' => $password,
'password_confirm' => $password,
'customer_type' => 'business',
'accept_terms' => true,
'acknowledge_privacy' => true,
'accept_dpa' => true,
]);
password_email_check_assert('registration succeeds', $registration['ok'] === true, $failures, $passes);
+6
View File
@@ -53,6 +53,10 @@ $registration = saas_register_tenant_owner($pdo, [
'email' => $email,
'password' => $password,
'password_confirm' => $password,
'customer_type' => 'business',
'accept_terms' => true,
'acknowledge_privacy' => true,
'accept_dpa' => true,
]);
settings_check_assert('registration succeeds', $registration['ok'] === true, $failures, $passes);
@@ -79,6 +83,7 @@ if ($registration['ok']) {
// (pdf_row_height_px, payment_reminder_interval_days).
$update = saas_update_tenant_settings($pdo, $tenantId, [
'tenant_name' => 'M3 Settings Flow Updated',
'customer_type' => 'business',
'timezone' => 'Europe/Berlin',
'locale' => 'de-DE',
'currency_code' => 'EUR',
@@ -114,6 +119,7 @@ if ($registration['ok']) {
// Ab einem bezahlten Tarif entscheidet der Mandant selbst.
$paidInput = [
'tenant_name' => 'M3 Settings Flow Updated',
'customer_type' => 'business',
'timezone' => 'Europe/Berlin',
'locale' => 'de-DE',
'currency_code' => 'EUR',
@@ -58,6 +58,10 @@ $first = saas_register_tenant_owner($pdo, [
'email' => $email,
'password' => $password,
'password_confirm' => $password,
'customer_type' => 'business',
'accept_terms' => true,
'acknowledge_privacy' => true,
'accept_dpa' => true,
]);
$second = saas_register_tenant_owner($pdo, [
'tenant_name' => 'M3 Tenant Choice B',
@@ -66,6 +70,10 @@ $second = saas_register_tenant_owner($pdo, [
'email' => $email,
'password' => $password,
'password_confirm' => $password,
'customer_type' => 'business',
'accept_terms' => true,
'acknowledge_privacy' => true,
'accept_dpa' => true,
]);
tenant_resolution_assert('first registration succeeds', $first['ok'] === true, $failures, $passes);
+5
View File
@@ -10,6 +10,10 @@ $suffix = bin2hex(random_bytes(4));
$password = 'RoleMatrixTest123!';
$roles = ['owner', 'admin', 'treasurer', 'member', 'viewer'];
// Wiederholte lokale Testläufe dürfen nicht am produktiven Login-Limit für
// 127.0.0.1 scheitern und dadurch Rollenprüfungen als falsch positiv werten.
$pdo->prepare('DELETE FROM rate_limit_attempts WHERE bucket = ?')->execute(['login_ip:127.0.0.1']);
// Seite => Rollen, die Zugriff haben sollen. Alle anderen Rollen muessen
// abgewiesen werden. Muss mit den saas_user_has_role()-Aufrufen in den
// jeweiligen Dateien uebereinstimmen.
@@ -171,6 +175,7 @@ foreach ($userIds as $uid) {
}
$pdo->prepare('DELETE FROM tenant_settings WHERE tenant_id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM rate_limit_attempts WHERE bucket = ?')->execute(['login_ip:127.0.0.1']);
if ($failures !== []) {
echo "\nM8 role matrix check failed with " . count($failures) . " failure(s):\n";
+99
View File
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/bootstrap.php';
require_once __DIR__ . '/../app/database.php';
require_once __DIR__ . '/../app/legal.php';
$errors = [];
$warnings = [];
$requireValue = static function (string $name) use (&$errors): string {
$value = trim((string)app_env($name, ''));
if ($value === '') {
$errors[] = "{$name} ist nicht gesetzt.";
}
return $value;
};
if (app_env('APP_ENV', 'prod') !== 'prod') {
$errors[] = 'APP_ENV muss für die Freigabe auf prod stehen.';
}
$appHost = $requireValue('APP_HOST');
$baseUrl = $requireValue('APP_BASE_URL');
if ($baseUrl !== '' && (!str_starts_with($baseUrl, 'https://') || parse_url($baseUrl, PHP_URL_HOST) !== $appHost)) {
$errors[] = 'APP_BASE_URL muss eine HTTPS-URL auf APP_HOST sein.';
}
$phone = app_legal_phone();
if (strlen(preg_replace('/\D/', '', $phone) ?? '') < 7) {
$errors[] = 'LEGAL_PHONE fehlt oder ist unplausibel; B2C darf so nicht freigeschaltet werden.';
}
if (!filter_var(app_legal_email(), FILTER_VALIDATE_EMAIL)) {
$errors[] = 'LEGAL_EMAIL ist ungültig.';
}
if (!str_starts_with(app_legal_ticket_url(), 'https://')) {
$errors[] = 'LEGAL_TICKET_URL muss HTTPS verwenden.';
}
if (app_env('APP_MAIL_TRANSPORT', 'mail') === 'log') {
$errors[] = 'APP_MAIL_TRANSPORT darf in Produktion nicht log sein.';
}
if (!filter_var((string)app_env('APP_MAIL_FROM', ''), FILTER_VALIDATE_EMAIL)) {
$errors[] = 'APP_MAIL_FROM ist nicht als gültige E-Mail-Adresse konfiguriert.';
}
foreach (['STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET', 'DOLIBARR_API_KEY'] as $secretName) {
if (strlen(trim((string)app_env($secretName, ''))) < 16) {
$errors[] = "{$secretName} fehlt oder ist unplausibel.";
}
}
foreach ([APP_ROOT . '/env.local.php', APP_ROOT . '/.env.local'] as $secretFile) {
if (!is_file($secretFile)) {
continue;
}
$mode = fileperms($secretFile) & 0777;
if (($mode & 0077) !== 0) {
$errors[] = basename($secretFile) . ' ist zu weit lesbar; Dateirechte auf 0600 setzen.';
}
}
$mainCss = file_get_contents(APP_ROOT . '/assets/css/main.css') ?: '';
if (preg_match('~@import\s+url\(["\']?https?://|url\(["\']?https?://~i', $mainCss)) {
$errors[] = 'assets/css/main.css lädt noch externe Ressourcen.';
}
try {
$pdo = app_db_pdo();
foreach (['legal_acceptances', 'legal_requests', 'tenant_billing', 'rate_limit_attempts'] as $table) {
$stmt = $pdo->prepare('SHOW TABLES LIKE ?');
$stmt->execute([$table]);
if ($stmt->fetchColumn() === false) {
$errors[] = "Datenbanktabelle {$table} fehlt; Migrationen ausführen.";
}
}
$stmt = $pdo->prepare('SELECT COUNT(*) FROM schema_migrations WHERE version = ?');
$stmt->execute(['0030_contract_termination.sql']);
if ((int)$stmt->fetchColumn() !== 1) {
$errors[] = 'Migration 0030_contract_termination.sql ist nicht angewendet.';
}
$unknownTenants = (int)$pdo->query("SELECT COUNT(*) FROM tenants WHERE customer_type = 'unknown'")->fetchColumn();
if ($unknownTenants > 0) {
$warnings[] = "{$unknownTenants} bestehende Mandant(en) haben noch keinen Vertragstyp; vor kostenpflichtiger Buchung festlegen.";
}
} catch (Throwable $e) {
$errors[] = 'Datenbankprüfung fehlgeschlagen: ' . $e->getMessage();
}
foreach ($warnings as $warning) {
echo "WARNUNG: {$warning}\n";
}
if ($errors !== []) {
foreach ($errors as $error) {
echo "FEHLER: {$error}\n";
}
echo "\nProduktionsfreigabe: NICHT BEREIT\n";
exit(1);
}
echo "Produktionsfreigabe: technische Pflichtprüfungen bestanden.\n";
+31 -5
View File
@@ -35,12 +35,12 @@ $checks = [
[
'label' => 'Impressum',
'path' => 'impressum.php',
'contains' => ['Impressum', 'In den Sieben Stücken 9d', '30655 Hannover'],
'contains' => ['Impressum', 'In den Sieben Stücken 9d', '30655 Hannover', 'ticketsystem.ctb-it.de'],
],
[
'label' => 'AGB',
'path' => 'agb.php',
'contains' => ['Allgemeine Geschäftsbedingungen', 'Entwurf'],
'contains' => ['Allgemeine Geschäftsbedingungen', 'Verbraucher', 'Verträge hier kündigen'],
],
[
'label' => 'Preise',
@@ -52,6 +52,26 @@ $checks = [
'path' => 'datenschutz.php',
'contains' => ['Datenschutzerklärung', 'Verantwortlicher', 'Auftragsverarbeiter'],
],
[
'label' => 'AVV',
'path' => 'avv.php',
'contains' => ['Auftragsverarbeitungsvertrag', 'Technische und organisatorische Maßnahmen', 'netcup'],
],
[
'label' => 'Widerrufsbelehrung',
'path' => 'widerruf.php',
'contains' => ['Widerrufsbelehrung', 'vierzehn Tagen', 'Vertrag widerrufen'],
],
[
'label' => 'Elektronische Widerrufsfunktion',
'path' => 'widerrufen.php',
'contains' => ['Vertrag widerrufen', 'Widerruf prüfen'],
],
[
'label' => 'Kündigungsschaltfläche',
'path' => 'kuendigen.php',
'contains' => ['Vertrag kündigen', 'Kündigung prüfen'],
],
[
'label' => 'Dashboard',
'path' => 'index.php',
@@ -396,6 +416,10 @@ $passes = 0;
echo "HTTP smoke base URL: {$baseUrl}\n";
// Der Smoke-Test erzeugt absichtlich einen Login. Frühere lokale Testläufe
// dürfen diesen nicht über das produktive IP-Limit beeinflussen.
$pdo->prepare('DELETE FROM rate_limit_attempts WHERE bucket = ?')->execute(['login_ip:127.0.0.1']);
// Testmandant anlegen und per HTTP anmelden.
try {
$seeded = smoke_seed($pdo, $smokeSlug, $smokeEmail, $smokePassword, $suffix);
@@ -415,16 +439,17 @@ foreach ($checks as $i => $check) {
$authCookies = [];
$loginPage = smoke_fetch(smoke_url($baseUrl, 'login.php'), $authCookies);
preg_match('/name="csrf_token" value="([^"]+)"/', $loginPage['body'], $csrfMatch);
smoke_fetch(smoke_url($baseUrl, 'login.php'), $authCookies, 'POST', http_build_query([
$loginResponse = smoke_fetch(smoke_url($baseUrl, 'login.php'), $authCookies, 'POST', http_build_query([
'csrf_token' => $csrfMatch[1] ?? '',
'email' => $smokeEmail,
'password' => $smokePassword,
'tenant_slug' => $smokeSlug,
]));
if (empty($authCookies)) {
if ($loginResponse['status'] !== 302 || !str_contains((string)$loginResponse['location'], 'index.php')) {
smoke_cleanup($pdo, $smokeTenantId, $smokeEmail);
fwrite(STDERR, "HTTP smoke: Anmeldung fehlgeschlagen (keine Session).\n");
$pdo->prepare('DELETE FROM rate_limit_attempts WHERE bucket = ?')->execute(['login_ip:127.0.0.1']);
fwrite(STDERR, "HTTP smoke: Anmeldung fehlgeschlagen (kein Redirect auf index.php).\n");
exit(1);
}
@@ -519,6 +544,7 @@ foreach ($skippedUnsafe as $skip) {
}
smoke_cleanup($pdo, $smokeTenantId, $smokeEmail);
$pdo->prepare('DELETE FROM rate_limit_attempts WHERE bucket = ?')->execute(['login_ip:127.0.0.1']);
if ($failures !== []) {
echo "\nHTTP smoke failed with " . count($failures) . " failure(s):\n";
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
require __DIR__ . '/dev-db.php';
require_once __DIR__ . '/../app/tenant-logo.php';
$pdo = dev_pdo();
$dryRun = in_array('--dry-run', $argv, true);
$stmt = $pdo->query(
"SELECT t.id, t.slug, ts.brand_logo, ts.pdf_watermark_logo,
tb.plan_code, tb.subscription_status
FROM tenants t
LEFT JOIN tenant_settings ts ON ts.tenant_id = t.id
LEFT JOIN tenant_billing tb ON tb.tenant_id = t.id
WHERE t.contract_ends_at IS NOT NULL
AND t.contract_ends_at < DATE_SUB(NOW(), INTERVAL 30 DAY)
AND t.slug <> 'default'
ORDER BY t.id"
);
$tenants = $stmt->fetchAll();
$deleted = 0;
$skipped = 0;
foreach ($tenants as $tenant) {
if ((string)($tenant['plan_code'] ?? 'free') !== 'free'
&& (string)($tenant['subscription_status'] ?? '') !== 'canceled'
) {
$skipped++;
echo "SKIP {$tenant['slug']}: kostenpflichtiges Abo ist noch nicht beendet.\n";
continue;
}
if ($dryRun) {
echo "WOULD_DELETE {$tenant['slug']}\n";
continue;
}
$memberStmt = $pdo->prepare('SELECT user_id FROM tenant_memberships WHERE tenant_id = ?');
$memberStmt->execute([(int)$tenant['id']]);
$userIds = array_map('intval', $memberStmt->fetchAll(PDO::FETCH_COLUMN));
try {
$pdo->beginTransaction();
$pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([(int)$tenant['id']]);
foreach ($userIds as $userId) {
$membershipStmt = $pdo->prepare('SELECT COUNT(*) FROM tenant_memberships WHERE user_id = ?');
$membershipStmt->execute([$userId]);
$adminStmt = $pdo->prepare('SELECT COUNT(*) FROM platform_admins WHERE user_id = ?');
$adminStmt->execute([$userId]);
if ((int)$membershipStmt->fetchColumn() === 0 && (int)$adminStmt->fetchColumn() === 0) {
$pdo->prepare('DELETE FROM users WHERE id = ?')->execute([$userId]);
}
}
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
$skipped++;
echo "ERROR {$tenant['slug']}: {$e->getMessage()}\n";
continue;
}
tenant_logo_delete((string)($tenant['brand_logo'] ?? ''));
tenant_logo_delete((string)($tenant['pdf_watermark_logo'] ?? ''));
$deleted++;
echo "DELETED {$tenant['slug']}\n";
}
echo "Fertig: {$deleted} gelöscht, {$skipped} übersprungen.\n";
exit($skipped > 0 ? 1 : 0);
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
require __DIR__ . '/dev-db.php';
$pdo = dev_pdo();
$dryRun = in_array('--dry-run', $argv, true);
$rules = [
// Bei stuendlichem Lauf bleibt der echte Maximalwert unter 24 Stunden.
'rate_limit_attempts' => ['rate_limit_attempts', 'created_at < DATE_SUB(NOW(), INTERVAL 23 HOUR)'],
// Taeglicher/stuendlicher Lauf mit einer Tagesreserve: hoechstens 180 Tage.
'audit_log' => ['audit_log', 'created_at < DATE_SUB(NOW(), INTERVAL 179 DAY)'],
'outbound_emails' => ['outbound_emails', 'created_at < DATE_SUB(NOW(), INTERVAL 179 DAY)'],
'auth_tokens' => ['user_auth_tokens', 'expires_at < NOW() OR consumed_at < DATE_SUB(NOW(), INTERVAL 1 DAY)'],
'stripe_webhook_events' => ['stripe_webhook_events', 'processed_at < DATE_SUB(NOW(), INTERVAL 2 YEAR)'],
// Allgemeine Verjährung: erst nach drei vollständig abgelaufenen
// Kalenderjahren. Offene Fälle und Nachweise aktiver Verträge bleiben.
'processed_legal_requests' => ['legal_requests', "status = 'processed' AND received_at < MAKEDATE(YEAR(CURDATE()) - 3, 1)"],
'detached_legal_acceptances' => ['legal_acceptances', 'tenant_id IS NULL AND accepted_at < MAKEDATE(YEAR(CURDATE()) - 3, 1)'],
];
$failed = false;
foreach ($rules as $label => [$table, $where]) {
try {
if ($dryRun) {
$count = (int)$pdo->query("SELECT COUNT(*) FROM {$table} WHERE {$where}")->fetchColumn();
echo "{$label}: {$count} würde(n) gelöscht\n";
continue;
}
$count = $pdo->exec("DELETE FROM {$table} WHERE {$where}");
echo "{$label}: " . (int)$count . " gelöscht\n";
} catch (Throwable $e) {
$failed = true;
echo "ERROR {$label}: {$e->getMessage()}\n";
}
}
exit($failed ? 1 : 0);