diff --git a/.htaccess b/.htaccess index c1103f2..a4274cd 100644 --- a/.htaccess +++ b/.htaccess @@ -1,4 +1,5 @@ -# Nur *.php im Webroot und assets/ sollen direkt aufrufbar sein. Interne +# Nur *.php im Webroot, assets/ und versionierte Rechtstexte sollen direkt +# aufrufbar sein. Interne # Verzeichnisse (Sessions, Mail-Logs, Migrationen, CLI-Skripte, Doku, # App-Bausteine, Legacy-DB-Shim, Vendor-Bibliotheken), die Git-Metadaten und # Konfigurationsdateien mit Zugangsdaten duerfen nicht direkt per URL @@ -19,6 +20,12 @@ RewriteCond %{REQUEST_URI} ^/(var|database|scripts|docs|app|lib)/ [NC] RewriteRule ^ - [F] + # Rechtstexte sind als speicherbare Textfassung absichtlich öffentlich; + # alle anderen/versehentlich abgelegten Dateien im Ordner bleiben gesperrt. + RewriteCond %{REQUEST_URI} ^/legal/ [NC] + RewriteCond %{REQUEST_URI} !^/legal/(agb|datenschutz|avv|widerruf)-[0-9]{4}-[0-9]{2}-[0-9]{2}\.txt$ [NC] + RewriteRule ^ - [F] + # Vendor-Verzeichnisse werden ausschliesslich serverseitig eingebunden # (TCPDF ueber exportKaffeeliste.php); direkte URL-Aufrufe ihrer PHP- # Dateien sind nie erwuenscht. diff --git a/abo-bestellen.php b/abo-bestellen.php new file mode 100644 index 0000000..132fbb1 --- /dev/null +++ b/abo-bestellen.php @@ -0,0 +1,106 @@ + + + + + Tarif kostenpflichtig bestellen – Kaffeeliste + + + + + + +
+ +
+ + + + + + + + diff --git a/abo-upgrade.php b/abo-upgrade.php index fe65057..0bc50fa 100644 --- a/abo-upgrade.php +++ b/abo-upgrade.php @@ -4,6 +4,7 @@ require_once __DIR__ . '/functions.php'; require_once __DIR__ . '/app/billing.php'; require_once __DIR__ . '/app/audit.php'; require_once __DIR__ . '/app/saas-mail.php'; +require_once __DIR__ . '/app/legal.php'; $pdo = app_db_pdo(); $user = saas_require_login(); @@ -34,12 +35,22 @@ if (!billing_plan_selectable_for($pdo, $tenantId, $planCode)) { exit('Dieser Tarif deckt eure aktuelle Teilnehmerzahl nicht ab. Bitte einen passenden Tarif wählen.'); } +if ($planCode !== 'free' && !in_array((string)($user['customer_type'] ?? ''), ['business', 'consumer'], true)) { + http_response_code(400); + exit('Bitte wähle zuerst in den Mandant-Einstellungen, ob der Vertrag geschäftlich oder als Verbraucher geführt wird.'); +} + $billing = billing_fetch_or_init($pdo, $tenantId); $baseUrl = saas_app_url('mandant-einstellungen.php'); +if ($planCode !== 'free' && !empty($user['contract_ends_at'])) { + http_response_code(409); + exit('Für diesen Vertrag liegt bereits eine Kündigung vor. Bitte nutze das Ticketsystem, wenn du sie zurücknehmen möchtest.'); +} + try { - // Fall 1: Wechsel auf "free" (kein Stripe-Preis) mit bestehender bezahlter - // Subscription -> echtes Kuendigen der Subscription, kein Checkout noetig. + // Wechsel auf "free": die bezahlte Subscription wird zum Ende der bereits + // bezahlten Periode gekündigt. Bis dahin bleiben Tarif und Funktionen aktiv. if ($plans[$planCode]['stripe_lookup_key'] === null) { if ($planCode !== 'free') { // enterprise hat ebenfalls keinen Stripe-Preis, ist aber kein @@ -49,7 +60,7 @@ try { } if ($billing['stripe_subscription_id'] !== null) { - $cancelResult = stripe_cancel_subscription((string)$billing['stripe_subscription_id']); + $cancelResult = stripe_schedule_subscription_cancellation((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); @@ -57,11 +68,20 @@ try { } } - billing_update($pdo, $tenantId, [ - 'plan_code' => 'free', - 'subscription_status' => 'canceled', + if ($billing['stripe_subscription_id'] === null) { + billing_update($pdo, $tenantId, [ + 'plan_code' => 'free', + 'subscription_status' => 'active', + ]); + } else { + billing_update($pdo, $tenantId, [ + 'subscription_status' => 'canceling', + 'current_period_end' => $cancelResult['current_period_end'], + ]); + } + app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.cancellation_scheduled', 'tenant', $tenantId, [ + 'current_period_end' => $cancelResult['current_period_end'] ?? null, ]); - app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.downgraded_to_free', 'tenant', $tenantId); header('Location: ' . $baseUrl . '?upgrade=success'); exit; @@ -73,10 +93,24 @@ try { exit('Der Tarif ist bei Stripe aktuell nicht verfügbar. Bitte später erneut versuchen.'); } + if (empty($_POST['accept_terms']) || empty($_POST['acknowledge_withdrawal']) || empty($_POST['request_early_performance'])) { + http_response_code(400); + exit('Bitte bestätige AGB, Widerrufsbelehrung und den gewünschten sofortigen Leistungsbeginn auf der Bestellseite.'); + } + + $orderMetadata = [ + 'plan_code' => $planCode, + 'monthly_price_cents' => (int)$plans[$planCode]['price_cents'], + 'customer_type' => (string)($user['customer_type'] ?? 'business'), + ]; + app_record_legal_acceptance($pdo, $tenantId, (int)$user['user_id'], 'terms', 'paid_order', $orderMetadata); + app_record_legal_acceptance($pdo, $tenantId, (int)$user['user_id'], 'withdrawal_information', 'paid_order', $orderMetadata); + app_record_legal_acceptance($pdo, $tenantId, (int)$user['user_id'], 'early_performance', 'paid_order', $orderMetadata); + // 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). + // neue Preis gilt ohne Zwischenbelastung ab der nächsten Verlängerung. if ($billing['stripe_subscription_id'] !== null) { $subscription = stripe_get_subscription((string)$billing['stripe_subscription_id']); @@ -102,6 +136,15 @@ try { ]); app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.plan_switched', 'tenant', $tenantId, ['plan_code' => $planCode]); + $confirmation = saas_send_paid_contract_confirmation($pdo, $tenantId, $planCode); + if (empty($confirmation['ok'])) { + app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.contract_confirmation_failed', 'tenant', $tenantId, [ + 'error' => $confirmation['error'] ?? 'unknown', + ]); + header('Location: ' . $baseUrl . '?upgrade=success&contract_mail_failed=1'); + exit; + } + header('Location: ' . $baseUrl . '?upgrade=success'); exit; } diff --git a/agb.php b/agb.php index f25da56..d18e96f 100644 --- a/agb.php +++ b/agb.php @@ -1,127 +1,29 @@ - + AGB – Kaffeeliste - - - - + + + +
- - - + diff --git a/app/audit.php b/app/audit.php index ea98aa6..9ef8dd6 100644 --- a/app/audit.php +++ b/app/audit.php @@ -18,6 +18,7 @@ function app_audit_log( ?int $subjectId, array $metadata = [] ): void { + $pdo->exec('DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL 180 DAY)'); $stmt = $pdo->prepare( 'INSERT INTO audit_log (tenant_id, actor_user_id, action, subject_type, subject_id, metadata_json, ip) VALUES (?, ?, ?, ?, ?, ?, ?)' diff --git a/app/data-export.php b/app/data-export.php index 6f26877..f402c6e 100644 --- a/app/data-export.php +++ b/app/data-export.php @@ -3,6 +3,7 @@ declare(strict_types=1); require_once __DIR__ . '/bootstrap.php'; +require_once __DIR__ . '/tenant-logo.php'; /** * Assembles a full export of everything stored for one tenant (data @@ -13,7 +14,7 @@ require_once __DIR__ . '/bootstrap.php'; */ function app_export_tenant_data(PDO $pdo, int $tenantId): array { - $stmt = $pdo->prepare('SELECT id, slug, name, status, timezone, locale, currency_code, created_at, updated_at FROM tenants WHERE id = ?'); + $stmt = $pdo->prepare('SELECT id, slug, paypal_inbox_token, name, customer_type, status, timezone, locale, currency_code, created_at, updated_at FROM tenants WHERE id = ?'); $stmt->execute([$tenantId]); $tenant = $stmt->fetch(); @@ -66,6 +67,51 @@ function app_export_tenant_data(PDO $pdo, int $tenantId): array $stmt->execute([$tenantId]); $auditLog = $stmt->fetchAll(); + $stmt = $pdo->prepare('SELECT * FROM faq_entries WHERE tenant_id = ? ORDER BY id'); + $stmt->execute([$tenantId]); + $faqEntries = $stmt->fetchAll(); + + $stmt = $pdo->prepare('SELECT * FROM paypal_payments WHERE tenant_id = ? ORDER BY id'); + $stmt->execute([$tenantId]); + $paypalPayments = $stmt->fetchAll(); + + $stmt = $pdo->prepare('SELECT * FROM tenant_billing WHERE tenant_id = ?'); + $stmt->execute([$tenantId]); + $billing = $stmt->fetch(); + + $stmt = $pdo->prepare('SELECT * FROM tenant_features WHERE tenant_id = ? ORDER BY id'); + $stmt->execute([$tenantId]); + $features = $stmt->fetchAll(); + + $stmt = $pdo->prepare( + 'SELECT document_type, document_version, context, metadata_json, accepted_at + FROM legal_acceptances WHERE tenant_id = ? ORDER BY id' + ); + $stmt->execute([$tenantId]); + $legalAcceptances = $stmt->fetchAll(); + + $stmt = $pdo->prepare( + 'SELECT reference_code, request_type, requester_name, requester_email, + contract_reference, request_reason, requested_end, status, + metadata_json, received_at, processed_at + FROM legal_requests WHERE tenant_id = ? ORDER BY id' + ); + $stmt->execute([$tenantId]); + $legalRequests = $stmt->fetchAll(); + + $logos = []; + foreach (['brand_logo', 'pdf_watermark_logo'] as $logoField) { + $filename = (string)($settings[$logoField] ?? ''); + $path = tenant_logo_path($filename); + if ($path !== null) { + $logos[$logoField] = [ + 'filename' => $filename, + 'mime_type' => mime_content_type($path) ?: 'application/octet-stream', + 'base64' => base64_encode((string)file_get_contents($path)), + ]; + } + } + return [ 'exported_at' => date('c'), 'tenant' => $tenant ?: null, @@ -78,5 +124,12 @@ function app_export_tenant_data(PDO $pdo, int $tenantId): array 'payment_import_rows' => $importRows, 'outbound_emails' => $outboundEmails, 'audit_log' => $auditLog, + 'faq_entries' => $faqEntries, + 'paypal_payments' => $paypalPayments, + 'billing' => $billing ?: null, + 'features' => $features, + 'legal_acceptances' => $legalAcceptances, + 'legal_requests' => $legalRequests, + 'logos' => $logos, ]; } diff --git a/app/features.php b/app/features.php index 4c72ad6..9073715 100644 --- a/app/features.php +++ b/app/features.php @@ -110,6 +110,12 @@ function app_feature_enabled(PDO $pdo, int $tenantId, string $featureKey): bool { static $cache = []; + // Export und Rückgabe der eigenen Daten sind Bestandteil von Vertrag und + // AVV und dürfen nicht versehentlich im Back-Office abgeschaltet werden. + if ($featureKey === 'data_export') { + return true; + } + if ($tenantId <= 0) { return app_feature_default($featureKey); } @@ -141,7 +147,7 @@ function app_features_update(PDO $pdo, int $tenantId, array $submittedFeatures): ); foreach (array_keys(app_feature_catalog()) as $key) { - $enabled = !empty($submittedFeatures[$key]); + $enabled = $key === 'data_export' || !empty($submittedFeatures[$key]); $stmt->execute([$tenantId, $key, $enabled ? 1 : 0]); if (($before[$key] ?? true) !== $enabled) { $changed[] = $key; diff --git a/app/legal-requests.php b/app/legal-requests.php new file mode 100644 index 0000000..ca63e6f --- /dev/null +++ b/app/legal-requests.php @@ -0,0 +1,301 @@ + */ +function app_validate_legal_request(array $input, string $requestType): array +{ + $errors = []; + $name = trim((string)($input['requester_name'] ?? '')); + $email = trim((string)($input['requester_email'] ?? '')); + $reference = trim((string)($input['contract_reference'] ?? '')); + if (strlen($name) < 2 || strlen($name) > 255) { + $errors[] = 'Bitte gib deinen vollständigen Namen an.'; + } + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + $errors[] = 'Bitte gib eine gültige E-Mail-Adresse für die Bestätigung an.'; + } + if (!preg_match('/^[a-z0-9][a-z0-9-]{1,98}[a-z0-9]$/', strtolower($reference))) { + $errors[] = 'Bitte gib das Kundenkürzel aus deinem Konto an.'; + } + + if ($requestType === 'cancellation') { + $kind = (string)($input['cancellation_kind'] ?? ''); + if (!in_array($kind, ['ordinary', 'extraordinary'], true)) { + $errors[] = 'Bitte wähle die Art der Kündigung.'; + } + if ($kind === 'extraordinary' && trim((string)($input['request_reason'] ?? '')) === '') { + $errors[] = 'Bitte nenne bei einer außerordentlichen Kündigung den Kündigungsgrund.'; + } + $requestedEnd = (string)($input['requested_end'] ?? ''); + if ($requestedEnd !== 'earliest') { + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $requestedEnd); + if ($date === false || $date->format('Y-m-d') !== $requestedEnd) { + $errors[] = 'Bitte wähle „frühestmöglich“ oder ein gültiges Beendigungsdatum.'; + } + } + } + + if (strlen((string)($input['request_reason'] ?? '')) > 5000) { + $errors[] = 'Der Grund ist zu lang (höchstens 5.000 Zeichen).'; + } + + return $errors; +} + +function app_update_legal_request(PDO $pdo, string $referenceCode, string $status, array $metadata = []): void +{ + $stmt = $pdo->prepare( + 'UPDATE legal_requests SET status = ?, metadata_json = ?, processed_at = NOW() WHERE reference_code = ?' + ); + $stmt->execute([ + $status, + $metadata !== [] ? json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null, + $referenceCode, + ]); +} + +/** + * Ordentliche Kündigungen eines eindeutig zugeordneten Stripe-Abos werden + * sofort zum Periodenende vorgemerkt. Alle anderen Erklärungen bleiben als + * fristwahrender Eingang in der DB und werden manuell bearbeitet. + * + * @return array{status: string, effective_end: string, processing_note: string} + */ +function app_process_cancellation_request(PDO $pdo, array $stored, array $input): array +{ + $effectiveEnd = (string)$input['requested_end'] === 'earliest' + ? 'zum frühestmöglichen Zeitpunkt' + : (string)$input['requested_end']; + $manual = [ + 'status' => 'received_manual', + 'effective_end' => $effectiveEnd, + 'processing_note' => 'Die Erklärung ist eingegangen und wird anhand der Vertragsdaten bearbeitet.', + ]; + + if ($stored['tenant_id'] === null + || (string)$input['cancellation_kind'] !== 'ordinary' + || (string)$input['requested_end'] !== 'earliest' + ) { + app_update_legal_request($pdo, (string)$stored['reference_code'], 'received_manual', [ + 'cancellation_kind' => (string)$input['cancellation_kind'], + ]); + return $manual; + } + + $billing = billing_fetch_or_init($pdo, (int)$stored['tenant_id']); + if (empty($billing['stripe_subscription_id'])) { + if ((string)$billing['plan_code'] === 'free') { + $pdo->prepare('UPDATE tenants SET termination_requested_at = NOW(), contract_ends_at = NOW() WHERE id = ?') + ->execute([(int)$stored['tenant_id']]); + $effectiveEnd = date('d.m.Y, H:i') . ' Uhr'; + app_update_legal_request($pdo, (string)$stored['reference_code'], 'contract_ended', [ + 'cancellation_kind' => (string)$input['cancellation_kind'], + 'effective_end' => $effectiveEnd, + ]); + return [ + 'status' => 'contract_ended', + 'effective_end' => $effectiveEnd, + 'processing_note' => 'Der kostenlose Vertrag ist beendet. Die Anmeldung ist nicht mehr möglich; operative Mandantendaten werden nach 30 Tagen gelöscht.', + ]; + } + app_update_legal_request($pdo, (string)$stored['reference_code'], 'received_manual', [ + 'cancellation_kind' => (string)$input['cancellation_kind'], + ]); + return $manual; + } + + try { + $result = stripe_schedule_subscription_cancellation((string)$billing['stripe_subscription_id']); + } catch (Throwable $e) { + $result = ['ok' => false, 'current_period_end' => null, 'error' => $e->getMessage()]; + } + if (!$result['ok']) { + app_update_legal_request($pdo, (string)$stored['reference_code'], 'received_manual', [ + 'cancellation_kind' => (string)$input['cancellation_kind'], + 'stripe_error' => (string)$result['error'], + ]); + return $manual; + } + + billing_update($pdo, (int)$stored['tenant_id'], [ + 'subscription_status' => 'canceling', + 'current_period_end' => $result['current_period_end'], + ]); + if ($result['current_period_end'] !== null) { + $pdo->prepare('UPDATE tenants SET termination_requested_at = NOW(), contract_ends_at = ? WHERE id = ?') + ->execute([$result['current_period_end'], (int)$stored['tenant_id']]); + } + $effectiveEnd = $result['current_period_end'] !== null + ? date('d.m.Y, H:i', strtotime((string)$result['current_period_end'])) . ' Uhr' + : 'zum Ende der laufenden Abrechnungsperiode'; + app_update_legal_request($pdo, (string)$stored['reference_code'], 'cancellation_scheduled', [ + 'cancellation_kind' => (string)$input['cancellation_kind'], + 'effective_end' => $effectiveEnd, + ]); + + return [ + 'status' => 'cancellation_scheduled', + 'effective_end' => $effectiveEnd, + 'processing_note' => 'Das kostenpflichtige Abonnement wurde bei Stripe zum Ende der laufenden Abrechnungsperiode vorgemerkt.', + ]; +} + +/** + * @return array{status: string, effective_end: string, processing_note: string} + */ +function app_process_withdrawal_request(PDO $pdo, array $stored): array +{ + $manual = [ + 'status' => 'received_manual', + 'effective_end' => 'mit Eingang des Widerrufs, sofern das Widerrufsrecht besteht', + 'processing_note' => 'Der Widerruf ist eingegangen und wird unverzüglich anhand der Vertragsdaten geprüft.', + ]; + if ($stored['tenant_id'] === null) { + app_update_legal_request($pdo, (string)$stored['reference_code'], 'received_manual'); + return $manual; + } + + $stmt = $pdo->prepare('SELECT customer_type, created_at FROM tenants WHERE id = ?'); + $stmt->execute([(int)$stored['tenant_id']]); + $tenant = $stmt->fetch(); + if ($tenant === false) { + app_update_legal_request($pdo, (string)$stored['reference_code'], 'received_manual'); + return $manual; + } + + $billing = billing_fetch_or_init($pdo, (int)$stored['tenant_id']); + if ((string)$billing['plan_code'] === 'free') { + $stmt = $pdo->prepare( + "SELECT accepted_at, metadata_json FROM legal_acceptances + WHERE tenant_id = ? AND document_type = 'terms' AND context = 'registration' + ORDER BY accepted_at ASC LIMIT 1" + ); + $stmt->execute([(int)$stored['tenant_id']]); + $contractEvidence = $stmt->fetch(); + $contractStart = $contractEvidence !== false + ? strtotime((string)$contractEvidence['accepted_at']) + : strtotime((string)$tenant['created_at']); + } else { + $stmt = $pdo->prepare( + "SELECT accepted_at, metadata_json FROM legal_acceptances + WHERE tenant_id = ? AND document_type = 'withdrawal_information' AND context = 'paid_order' + ORDER BY accepted_at DESC LIMIT 1" + ); + $stmt->execute([(int)$stored['tenant_id']]); + $contractEvidence = $stmt->fetch(); + $contractStart = $contractEvidence !== false ? strtotime((string)$contractEvidence['accepted_at']) : false; + } + $contractMetadata = $contractEvidence !== false + ? json_decode((string)($contractEvidence['metadata_json'] ?? ''), true) + : null; + $contractCustomerType = is_array($contractMetadata) + ? (string)($contractMetadata['customer_type'] ?? $tenant['customer_type']) + : (string)$tenant['customer_type']; + if ($contractCustomerType !== 'consumer') { + app_update_legal_request($pdo, (string)$stored['reference_code'], 'received_manual', [ + 'review_reason' => 'contract_not_recorded_as_consumer', + ]); + return $manual; + } + if ($contractStart === false || $contractStart < strtotime('-14 days')) { + app_update_legal_request($pdo, (string)$stored['reference_code'], 'received_manual', [ + 'review_reason' => 'automatic_14_day_window_not_proven', + ]); + return $manual; + } + + $stripeError = null; + if (!empty($billing['stripe_subscription_id'])) { + try { + $cancel = stripe_cancel_subscription((string)$billing['stripe_subscription_id']); + if (!$cancel['ok']) { + $stripeError = (string)$cancel['error']; + } + } catch (Throwable $e) { + $stripeError = $e->getMessage(); + } + } + + $pdo->prepare('UPDATE tenants SET termination_requested_at = NOW(), contract_ends_at = NOW() WHERE id = ?') + ->execute([(int)$stored['tenant_id']]); + if ($stripeError === null && !empty($billing['stripe_subscription_id'])) { + billing_update($pdo, (int)$stored['tenant_id'], [ + 'plan_code' => 'free', + 'subscription_status' => 'canceled', + ]); + } + + $status = $stripeError === null ? 'withdrawal_effective' : 'withdrawal_action_required'; + $effectiveEnd = date('d.m.Y, H:i') . ' Uhr'; + app_update_legal_request($pdo, (string)$stored['reference_code'], $status, [ + 'effective_end' => $effectiveEnd, + 'stripe_error' => $stripeError, + 'refund_review_required' => (string)$billing['plan_code'] !== 'free', + ]); + + return [ + 'status' => $status, + 'effective_end' => $effectiveEnd, + 'processing_note' => $stripeError === null + ? 'Der Vertrag wurde beendet. Eine gegebenenfalls geschuldete Rückzahlung wird unverzüglich über das ursprüngliche Zahlungsmittel bearbeitet.' + : 'Der Vertrag wurde lokal beendet. Die Beendigung beim Zahlungsdienst und eine gegebenenfalls geschuldete Rückzahlung müssen noch manuell abgeschlossen werden.', + ]; +} + +function app_legal_request_confirmation_text(array $input, array $stored, array $processing): string +{ + $isCancellation = (string)$input['request_type'] === 'cancellation'; + $type = $isCancellation ? 'Kündigung' : 'Widerruf'; + $lines = [ + "Eingangsbestätigung {$type} – Kaffeeliste", + '', + 'Referenz: ' . $stored['reference_code'], + 'Eingang: ' . $stored['received_at'] . ' (Europe/Berlin)', + 'Name: ' . $input['requester_name'], + 'Bestätigungs-E-Mail: ' . $input['requester_email'], + 'Kundenkürzel / Vertragsreferenz: ' . $input['contract_reference'], + ]; + if ($isCancellation) { + $lines[] = 'Art: ' . ((string)$input['cancellation_kind'] === 'extraordinary' ? 'außerordentliche Kündigung' : 'ordentliche Kündigung'); + $lines[] = 'Beendigung: ' . $processing['effective_end']; + } + if (trim((string)($input['request_reason'] ?? '')) !== '') { + $lines[] = 'Erklärung / Grund: ' . trim((string)$input['request_reason']); + } + $lines[] = ''; + $lines[] = $processing['processing_note']; + $lines[] = ''; + $lines[] = 'Clemens Creutzburg, CTB-IT'; + $lines[] = 'In den Sieben Stücken 9d, 30655 Hannover'; + $lines[] = app_legal_email(); + + return implode("\n", $lines); +} + +function app_send_legal_request_confirmations(array $input, array $stored, array $processing): array +{ + $text = app_legal_request_confirmation_text($input, $stored, $processing); + $downloadUrl = saas_app_url('rechtserklaerung-bestaetigung.php?token=' . urlencode((string)$stored['access_token']) . '&download=1'); + $type = (string)$input['request_type'] === 'cancellation' ? 'Kündigung' : 'Widerruf'; + $customerResult = saas_send_mail( + (string)$input['requester_email'], + "Kaffeeliste: Eingang {$type} " . $stored['reference_code'], + $text . "\n\nBestätigung als Textdatei speichern:\n" . $downloadUrl + ); + $operatorResult = saas_send_mail( + app_legal_email(), + "Kaffeeliste: {$type} eingegangen " . $stored['reference_code'], + $text . "\n\nInterner Status: " . $processing['status'] + ); + + return [ + 'customer_ok' => (bool)($customerResult['ok'] ?? false), + 'operator_ok' => (bool)($operatorResult['ok'] ?? false), + ]; +} diff --git a/app/legal.php b/app/legal.php new file mode 100644 index 0000000..93b7bd8 --- /dev/null +++ b/app/legal.php @@ -0,0 +1,254 @@ +' + . 'Impressum' + . 'AGB' + . 'Datenschutz' + . 'AVV' + . 'Widerrufsbelehrung' + . 'Vertrag widerrufen' + . 'Preise' + . 'Verträge hier kündigen' + . ''; +} + +function app_legal_document_version(string $documentType): string +{ + return match ($documentType) { + 'terms' => APP_TERMS_VERSION, + 'privacy_notice' => APP_PRIVACY_VERSION, + 'dpa' => APP_DPA_VERSION, + 'withdrawal_information', 'early_performance' => APP_WITHDRAWAL_VERSION, + default => throw new InvalidArgumentException('Unbekannter Rechtstext.'), + }; +} + +function app_legal_document_text(string $documentType): string +{ + $filename = match ($documentType) { + 'terms' => 'agb-' . APP_TERMS_VERSION . '.txt', + 'privacy_notice' => 'datenschutz-' . APP_PRIVACY_VERSION . '.txt', + 'dpa' => 'avv-' . APP_DPA_VERSION . '.txt', + 'withdrawal_information', 'early_performance' => 'widerruf-' . APP_WITHDRAWAL_VERSION . '.txt', + default => throw new InvalidArgumentException('Unbekannter Rechtstext.'), + }; + $path = APP_ROOT . '/legal/' . $filename; + $content = is_file($path) ? file_get_contents($path) : false; + if ($content === false) { + throw new RuntimeException('Rechtstext fehlt: ' . $filename); + } + + return trim($content); +} + +function app_render_legal_document(string $documentType): string +{ + $blocks = preg_split('/\R\s*\R/', app_legal_document_text($documentType)) ?: []; + $html = ''; + foreach ($blocks as $block) { + $block = trim($block); + if ($block === '') { + continue; + } + if (str_starts_with($block, '## ')) { + $html .= '

' . htmlspecialchars(substr($block, 3), ENT_QUOTES, 'UTF-8') . '

'; + continue; + } + if (str_starts_with($block, '### ')) { + $html .= '

' . htmlspecialchars(substr($block, 4), ENT_QUOTES, 'UTF-8') . '

'; + continue; + } + $lines = preg_split('/\R/', $block) ?: []; + if ($lines !== [] && count(array_filter($lines, static fn(string $line): bool => str_starts_with(trim($line), '- '))) === count($lines)) { + $html .= ''; + continue; + } + $escaped = htmlspecialchars($block, ENT_QUOTES, 'UTF-8'); + $escaped = preg_replace_callback( + '~\[(.+?)\]\((https?://[^)]+|[a-z0-9-]+\.php)\)~i', + static fn(array $match): string => '' . htmlspecialchars($match[1], ENT_QUOTES, 'UTF-8') . '', + $escaped + ) ?? $escaped; + $html .= '

' . nl2br($escaped) . '

'; + } + + return $html; +} + +function app_record_legal_acceptance( + PDO $pdo, + ?int $tenantId, + ?int $userId, + string $documentType, + string $context, + array $metadata = [] +): void { + $evidence = [ + 'document_sha256' => hash('sha256', app_legal_document_text($documentType)), + ]; + if ($tenantId !== null) { + $snapshotStmt = $pdo->prepare('SELECT slug, name FROM tenants WHERE id = ?'); + $snapshotStmt->execute([$tenantId]); + $tenantSnapshot = $snapshotStmt->fetch(); + if ($tenantSnapshot !== false) { + $evidence['tenant_slug'] = (string)$tenantSnapshot['slug']; + $evidence['tenant_name'] = (string)$tenantSnapshot['name']; + } + } + if ($userId !== null) { + $snapshotStmt = $pdo->prepare('SELECT email_norm FROM users WHERE id = ?'); + $snapshotStmt->execute([$userId]); + $userEmail = $snapshotStmt->fetchColumn(); + if ($userEmail !== false) { + $evidence['user_email'] = (string)$userEmail; + } + } + + $stmt = $pdo->prepare( + 'INSERT INTO legal_acceptances + (tenant_id, user_id, document_type, document_version, context, ip, user_agent, metadata_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + ); + $stmt->execute([ + $tenantId, + $userId, + $documentType, + app_legal_document_version($documentType), + $context, + $_SERVER['REMOTE_ADDR'] ?? null, + substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500), + json_encode( + $evidence + $metadata, + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES + ), + ]); +} + +/** + * Findet einen Vertrag nur dann automatisch, wenn Kundenkürzel und die + * E-Mail eines aktiven Inhabers zusammenpassen. Die Erklärung wird auch bei + * fehlender Zuordnung gespeichert: Eine fehlerhafte Referenz darf nicht zum + * Verlust einer fristgebundenen Kündigung oder eines Widerrufs führen. + * + * @return array{tenant_id: ?int, user_id: ?int, customer_type: ?string} + */ +function app_match_legal_contract(PDO $pdo, string $contractReference, string $email): array +{ + $slug = strtolower(trim($contractReference)); + $emailNorm = strtolower(trim($email)); + $stmt = $pdo->prepare( + "SELECT t.id AS tenant_id, t.customer_type, u.id AS user_id + FROM tenants t + JOIN tenant_memberships tm ON tm.tenant_id = t.id AND tm.role = 'owner' AND tm.status = 'active' + JOIN users u ON u.id = tm.user_id AND u.status = 'active' + WHERE t.slug = ? AND u.email_norm = ? + LIMIT 1" + ); + $stmt->execute([$slug, $emailNorm]); + $row = $stmt->fetch(); + + if ($row === false) { + return ['tenant_id' => null, 'user_id' => null, 'customer_type' => null]; + } + + return [ + 'tenant_id' => (int)$row['tenant_id'], + 'user_id' => (int)$row['user_id'], + 'customer_type' => (string)$row['customer_type'], + ]; +} + +/** + * @return array{reference_code: string, access_token: string, received_at: string, tenant_id: ?int, user_id: ?int} + */ +function app_store_legal_request(PDO $pdo, array $input): array +{ + $match = app_match_legal_contract( + $pdo, + (string)$input['contract_reference'], + (string)$input['requester_email'] + ); + $accessToken = bin2hex(random_bytes(32)); + $referenceCode = 'KL-' . strtoupper(bin2hex(random_bytes(6))); + $receivedAt = date('Y-m-d H:i:s'); + + $stmt = $pdo->prepare( + 'INSERT INTO legal_requests + (reference_code, access_token_hash, request_type, tenant_id, user_id, + requester_name, requester_email, contract_reference, request_reason, + requested_end, status, ip, user_agent, metadata_json, received_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + $stmt->execute([ + $referenceCode, + hash('sha256', $accessToken), + (string)$input['request_type'], + $match['tenant_id'], + $match['user_id'], + (string)$input['requester_name'], + (string)$input['requester_email'], + (string)$input['contract_reference'], + ($input['request_reason'] ?? '') !== '' ? (string)$input['request_reason'] : null, + ($input['requested_end'] ?? '') !== '' ? (string)$input['requested_end'] : null, + 'received', + $_SERVER['REMOTE_ADDR'] ?? null, + substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500), + isset($input['metadata']) ? json_encode($input['metadata'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null, + $receivedAt, + ]); + + return [ + 'reference_code' => $referenceCode, + 'access_token' => $accessToken, + 'received_at' => $receivedAt, + 'tenant_id' => $match['tenant_id'], + 'user_id' => $match['user_id'], + ]; +} + +function app_fetch_legal_request_by_token(PDO $pdo, string $token): ?array +{ + if (!preg_match('/^[a-f0-9]{64}$/', $token)) { + return null; + } + + $stmt = $pdo->prepare( + 'SELECT reference_code, request_type, requester_name, requester_email, + contract_reference, request_reason, requested_end, status, metadata_json, received_at + FROM legal_requests WHERE access_token_hash = ? LIMIT 1' + ); + $stmt->execute([hash('sha256', $token)]); + $row = $stmt->fetch(); + + return $row !== false ? $row : null; +} diff --git a/app/rate-limit.php b/app/rate-limit.php index a6a0410..bf37d5b 100644 --- a/app/rate-limit.php +++ b/app/rate-limit.php @@ -20,10 +20,9 @@ function app_rate_limit_check(PDO $pdo, string $bucket, int $maxAttempts, int $w { $pdo->prepare('INSERT INTO rate_limit_attempts (bucket) VALUES (?)')->execute([$bucket]); - // Opportunistic cleanup so the table doesn't grow unbounded; scoped to - // this bucket to keep each call cheap. - $pdo->prepare('DELETE FROM rate_limit_attempts WHERE bucket = ? AND created_at < DATE_SUB(NOW(), INTERVAL ? SECOND)') - ->execute([$bucket, $windowSeconds]); + // Globaler Maximalhorizont: unabhängig davon, ob derselbe Bucket je wieder + // benutzt wird, bleibt keine IP-basierte Rate-Limit-Zeile länger als 24h. + $pdo->exec('DELETE FROM rate_limit_attempts WHERE created_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)'); $stmt = $pdo->prepare( 'SELECT COUNT(*) FROM rate_limit_attempts WHERE bucket = ? AND created_at >= DATE_SUB(NOW(), INTERVAL ? SECOND)' diff --git a/app/saas-auth.php b/app/saas-auth.php index bab5934..3cb4659 100644 --- a/app/saas-auth.php +++ b/app/saas-auth.php @@ -6,6 +6,7 @@ require_once __DIR__ . '/bootstrap.php'; require_once __DIR__ . '/database.php'; require_once __DIR__ . '/faq.php'; require_once __DIR__ . '/billing.php'; +require_once __DIR__ . '/legal.php'; function saas_email_norm(string $email): string { @@ -55,7 +56,7 @@ function saas_normalize_host(?string $host): string function saas_primary_app_host(): string { - return saas_normalize_host(app_env('APP_PRIMARY_HOST', '')); + return saas_normalize_host(app_env('APP_PRIMARY_HOST', app_primary_host() ?? '')); } function saas_is_primary_app_host(?string $host = null): bool @@ -92,6 +93,7 @@ function saas_resolve_tenant_domain(PDO $pdo, ?string $host = null): ?array WHERE td.domain_norm = ? AND td.status = ? AND t.status = ? + AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW()) LIMIT 1' ); $stmt->execute([$host, 'active', 'active']); @@ -166,6 +168,8 @@ function saas_current_user(?PDO $pdo = null): ?array t.id AS tenant_id, t.slug AS tenant_slug, t.name AS tenant_name, + t.customer_type, + t.contract_ends_at, t.status AS tenant_status, tm.role, tm.status AS membership_status @@ -177,6 +181,7 @@ function saas_current_user(?PDO $pdo = null): ?array AND u.status = ? AND tm.status = ? AND t.status = ? + AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW()) LIMIT 1' ); $stmt->execute([$userId, $tenantId, 'active', 'active', 'active']); @@ -264,6 +269,7 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array t.id AS tenant_id, t.slug, t.name, + t.customer_type, t.status, t.timezone, t.locale, @@ -312,6 +318,7 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array 'tenant_id' => (int)$settings['tenant_id'], 'slug' => (string)$settings['slug'], 'name' => (string)$settings['name'], + 'customer_type' => (string)$settings['customer_type'], 'status' => (string)$settings['status'], 'timezone' => (string)$settings['timezone'], 'locale' => (string)$settings['locale'], @@ -344,6 +351,7 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): array { $tenantName = trim((string)($input['tenant_name'] ?? '')); + $customerType = (string)($input['customer_type'] ?? ''); $timezone = trim((string)($input['timezone'] ?? 'Europe/Berlin')); $locale = trim((string)($input['locale'] ?? 'de-DE')); $currencyCode = strtoupper(trim((string)($input['currency_code'] ?? 'EUR'))); @@ -370,6 +378,9 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr if (strlen($tenantName) < 3 || strlen($tenantName) > 255) { $errors[] = 'Der Kundenname muss zwischen 3 und 255 Zeichen lang sein.'; } + if (!in_array($customerType, ['business', 'consumer'], true)) { + $errors[] = 'Bitte gib an, ob der Vertrag geschäftlich oder als Verbraucher geführt wird.'; + } if (!in_array($timezone, DateTimeZone::listIdentifiers(), true)) { $errors[] = 'Die Zeitzone ist ungültig.'; } @@ -442,10 +453,10 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr $stmt = $pdo->prepare( 'UPDATE tenants - SET name = ?, timezone = ?, locale = ?, currency_code = ? + SET name = ?, customer_type = ?, timezone = ?, locale = ?, currency_code = ? WHERE id = ?' ); - $stmt->execute([$tenantName, $timezone, $locale, $currencyCode, $tenantId]); + $stmt->execute([$tenantName, $customerType, $timezone, $locale, $currencyCode, $tenantId]); $stmt = $pdo->prepare( 'INSERT INTO tenant_settings @@ -554,7 +565,8 @@ function saas_find_auth_identity(PDO $pdo, string $email, string $tenantSlug = ' WHERE u.email_norm = ? AND u.status = ? AND tm.status = ? - AND t.status = ?' . $tenantFilter . ' + AND t.status = ? + AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())' . $tenantFilter . ' ORDER BY CASE tm.role WHEN \'owner\' THEN 1 @@ -593,6 +605,7 @@ function saas_list_user_memberships(PDO $pdo, int $userId): array AND u.status = ? AND tm.status = ? AND t.status = ? + AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW()) ORDER BY CASE tm.role WHEN \'owner\' THEN 1 @@ -630,6 +643,7 @@ function saas_identity_for_user_tenant(PDO $pdo, int $userId, int $tenantId): ?a AND u.status = ? AND tm.status = ? AND t.status = ? + AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW()) LIMIT 1' ); $stmt->execute([$userId, $tenantId, 'active', 'active', 'active']); @@ -1021,7 +1035,8 @@ function saas_authenticate(PDO $pdo, string $email, string $password, string $te JOIN tenants t ON t.id = tm.tenant_id WHERE tm.user_id = ? AND tm.status = ? - AND t.status = ?' . $tenantFilter . ' + AND t.status = ? + AND (t.contract_ends_at IS NULL OR t.contract_ends_at > NOW())' . $tenantFilter . ' ORDER BY CASE tm.role WHEN \'owner\' THEN 1 @@ -1206,6 +1221,7 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array $emailNorm = saas_email_norm($email); $password = (string)($input['password'] ?? ''); $passwordConfirm = (string)($input['password_confirm'] ?? ''); + $customerType = (string)($input['customer_type'] ?? ''); $errors = []; if (strlen($tenantName) < 3 || strlen($tenantName) > 255) { @@ -1226,6 +1242,18 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array if ($password !== $passwordConfirm) { $errors[] = 'Die Passwort-Wiederholung stimmt nicht.'; } + if (!in_array($customerType, ['business', 'consumer'], true)) { + $errors[] = 'Bitte gib an, ob du als Unternehmen oder als Verbraucher handelst.'; + } + if (empty($input['accept_terms'])) { + $errors[] = 'Bitte akzeptiere die AGB.'; + } + if (empty($input['acknowledge_privacy'])) { + $errors[] = 'Bitte bestätige, dass du die Datenschutzerklärung zur Kenntnis genommen hast.'; + } + if (empty($input['accept_dpa'])) { + $errors[] = 'Bitte vereinbare den AVV für den Fall, dass du Daten anderer Personen verwaltest.'; + } if ($errors !== []) { return ['ok' => false, 'errors' => $errors]; @@ -1265,10 +1293,10 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array $pdo->beginTransaction(); $stmt = $pdo->prepare( - 'INSERT INTO tenants (slug, name, status, timezone, locale, currency_code) - VALUES (?, ?, ?, ?, ?, ?)' + 'INSERT INTO tenants (slug, name, customer_type, status, timezone, locale, currency_code) + VALUES (?, ?, ?, ?, ?, ?, ?)' ); - $stmt->execute([$tenantSlug, $tenantName, 'active', 'Europe/Berlin', 'de-DE', 'EUR']); + $stmt->execute([$tenantSlug, $tenantName, $customerType, 'active', 'Europe/Berlin', 'de-DE', 'EUR']); $tenantId = (int)$pdo->lastInsertId(); $stmt = $pdo->prepare( @@ -1311,6 +1339,11 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array ); $stmt->execute([$tenantId, $userId, $displayName, $email, $emailNorm, 1]); + $acceptanceMetadata = ['customer_type' => $customerType]; + app_record_legal_acceptance($pdo, $tenantId, $userId, 'terms', 'registration', $acceptanceMetadata); + app_record_legal_acceptance($pdo, $tenantId, $userId, 'privacy_notice', 'registration', $acceptanceMetadata); + app_record_legal_acceptance($pdo, $tenantId, $userId, 'dpa', 'registration', $acceptanceMetadata); + $verificationToken = saas_create_auth_token($pdo, $userId, 'email_verification', $tenantId, 1440); $pdo->commit(); @@ -1335,6 +1368,7 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array 'tenant_id' => $tenantId, 'tenant_slug' => $tenantSlug, 'tenant_name' => $tenantName, + 'customer_type' => $customerType, 'role' => 'owner', ], 'email_verification_token' => $verificationToken['token'], diff --git a/app/saas-mail.php b/app/saas-mail.php index edd330f..813a6ad 100644 --- a/app/saas-mail.php +++ b/app/saas-mail.php @@ -3,6 +3,7 @@ declare(strict_types=1); require_once __DIR__ . '/bootstrap.php'; +require_once __DIR__ . '/legal.php'; function saas_mail_transport(): string { @@ -172,6 +173,7 @@ function saas_log_outbound_email( ?string $error, ?int $createdByUserId ): int { + $pdo->exec('DELETE FROM outbound_emails WHERE created_at < DATE_SUB(NOW(), INTERVAL 180 DAY)'); $stmt = $pdo->prepare( 'INSERT INTO outbound_emails (tenant_id, participant_id, template, subject, status, sent_at, error, created_by_user_id) @@ -267,6 +269,71 @@ function saas_send_email_verification_mail(string $to, string $token): array return saas_send_mail($to, 'Kaffeeliste E-Mail bestätigen', $body); } +function saas_send_registration_contract_mail(string $to, string $tenantName, string $tenantSlug, string $customerType): array +{ + $body = implode("\n", [ + 'Vertragsbestätigung Kaffeeliste', + '', + 'Kunde: ' . $tenantName, + 'Kundenkürzel: ' . $tenantSlug, + 'Kundentyp: ' . ($customerType === 'consumer' ? 'Verbraucher' : 'Unternehmer'), + 'Tarif: Kostenlos, 0,00 € pro Monat, unbefristet, jederzeit kündbar', + 'Vertragsschluss: ' . date('d.m.Y H:i') . ' Uhr (Europe/Berlin)', + '', + 'Nachfolgend erhältst du die bei Vertragsschluss geltenden Texte auf einem dauerhaften Datenträger.', + '', + '================ AGB ================', + app_legal_document_text('terms'), + '', + '================ AVV ================', + app_legal_document_text('dpa'), + '', + '========== WIDERRUFSBELEHRUNG ==========', + app_legal_document_text('withdrawal_information'), + ]); + + return saas_send_mail($to, 'Kaffeeliste Vertragsbestätigung – kostenloser Tarif', $body); +} + +function saas_send_paid_contract_confirmation(PDO $pdo, int $tenantId, string $planCode): array +{ + require_once __DIR__ . '/billing.php'; + $stmt = $pdo->prepare( + "SELECT t.name, t.slug, t.customer_type, u.email + FROM tenants t + JOIN tenant_memberships tm ON tm.tenant_id = t.id AND tm.role = 'owner' AND tm.status = 'active' + JOIN users u ON u.id = tm.user_id AND u.status = 'active' + WHERE t.id = ? ORDER BY tm.id LIMIT 1" + ); + $stmt->execute([$tenantId]); + $contract = $stmt->fetch(); + $plan = billing_plans()[$planCode] ?? null; + if ($contract === false || $plan === null) { + return ['ok' => false, 'transport' => saas_mail_transport(), 'error' => 'contract confirmation data missing']; + } + + $body = implode("\n", [ + 'Vertragsbestätigung Kaffeeliste – kostenpflichtiger Tarif', + '', + 'Kunde: ' . $contract['name'], + 'Kundenkürzel: ' . $contract['slug'], + 'Kundentyp: ' . ($contract['customer_type'] === 'consumer' ? 'Verbraucher' : 'Unternehmer'), + 'Tarif: ' . $plan['label'], + 'Gesamtpreis: ' . number_format(((int)$plan['price_cents']) / 100, 2, ',', '') . ' € pro Monat', + 'Laufzeit: unbefristet; monatliche Abrechnung; keine längere Mindestlaufzeit', + 'Kündigung: jederzeit zum Ende der laufenden Abrechnungsperiode', + 'Bestätigung: ' . date('d.m.Y H:i') . ' Uhr (Europe/Berlin)', + '', + '================ AGB ================', + app_legal_document_text('terms'), + '', + '========== WIDERRUFSBELEHRUNG ==========', + app_legal_document_text('withdrawal_information'), + ]); + + return saas_send_mail((string)$contract['email'], 'Kaffeeliste Vertragsbestätigung – ' . $plan['label'], $body); +} + /** * Textkoerper der automatischen Zahlungserinnerung an ein Mitglied mit * offenem Betrag oberhalb der Warnschwelle des Mandanten. diff --git a/app/stripe.php b/app/stripe.php index 6c6d2ca..5d7141b 100644 --- a/app/stripe.php +++ b/app/stripe.php @@ -151,6 +151,12 @@ function stripe_create_checkout_session(string $priceId, string $customerEmail, // .deleted-Events liefern kein Checkout-Session-Objekt, aber die // Metadaten der Subscription, darueber loesen wir tenant_id auf. 'subscription_data' => ['metadata' => $metadata], + 'locale' => 'de', + 'custom_text' => [ + 'submit' => [ + 'message' => 'Monatlich kündbar zum Ende der laufenden Abrechnungsperiode. Es gelten die vorab bestätigten AGB und die Widerrufsbelehrung.', + ], + ], ]; if ($existingCustomerId !== null) { @@ -175,6 +181,17 @@ function stripe_create_billing_portal_session(string $customerId, string $return $result = stripe_request('POST', 'billing_portal/sessions', [ 'customer' => $customerId, 'return_url' => $returnUrl, + 'locale' => 'de', + // Als gezielter Stripe-Flow sind Navigation und andere Portalaktionen + // ausgeblendet. Tarifwechsel und Kündigung bleiben damit in unseren + // rechtlich dokumentierten Bestell- bzw. Kündigungsabläufen. + 'flow_data' => [ + 'type' => 'payment_method_update', + 'after_completion' => [ + 'type' => 'redirect', + 'redirect' => ['return_url' => $returnUrl], + ], + ], ]); if (!$result['ok']) { return ['ok' => false, 'url' => null, 'error' => $result['error']]; @@ -247,9 +264,9 @@ function stripe_get_subscription(string $subscriptionId): array } /** - * 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. + * Switches an existing active subscription to a different Price in place. + * The billing cycle stays unchanged and no unclear mid-cycle debit/credit is + * created; the new full monthly amount applies at the next renewal. * Also clears any pending cancel_at_period_end, since choosing a plan is * an explicit signal the tenant wants to keep the subscription running. * @@ -259,7 +276,7 @@ function stripe_update_subscription_price(string $subscriptionId, string $subscr { $result = stripe_request('POST', "subscriptions/{$subscriptionId}", [ 'items' => [['id' => $subscriptionItemId, 'price' => $newPriceId]], - 'proration_behavior' => 'create_prorations', + 'proration_behavior' => 'none', 'cancel_at_period_end' => 'false', ]); @@ -271,8 +288,10 @@ function stripe_update_subscription_price(string $subscriptionId, string $subscr } /** - * Cancels a subscription immediately (not at period end) - used when a - * tenant downgrades all the way to the free plan from the admin UI. + * Cancels a subscription immediately. This is reserved for exceptional + * workflows such as a confirmed withdrawal; ordinary cancellations use + * stripe_schedule_subscription_cancellation() so paid access remains until + * the end of the already paid billing period. * * @return array{ok: bool, error: ?string} */ @@ -286,6 +305,25 @@ function stripe_cancel_subscription(string $subscriptionId): array return ['ok' => true, 'error' => null]; } +/** + * @return array{ok: bool, current_period_end: ?string, error: ?string} + */ +function stripe_schedule_subscription_cancellation(string $subscriptionId): array +{ + $result = stripe_request('POST', "subscriptions/{$subscriptionId}", [ + 'cancel_at_period_end' => 'true', + ]); + if (!$result['ok']) { + return ['ok' => false, 'current_period_end' => null, 'error' => $result['error']]; + } + + $periodEnd = isset($result['data']['current_period_end']) + ? date('Y-m-d H:i:s', (int)$result['data']['current_period_end']) + : null; + + return ['ok' => true, 'current_period_end' => $periodEnd, 'error' => null]; +} + /** * @return array{id: string, type: string, data: array}|null */ diff --git a/assets/css/main.css b/assets/css/main.css index 34970cd..0281f50 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -1,5 +1,4 @@ @import url(fontawesome-all.min.css); -@import url("https://fonts.googleapis.com/css?family=Open+Sans:400,600,400italic,600italic|Roboto+Slab:400,700"); /* Editorial by HTML5 UP html5up.net | @ajlkn diff --git a/assets/css/public.css b/assets/css/public.css index 0ac0761..7642da3 100644 --- a/assets/css/public.css +++ b/assets/css/public.css @@ -343,6 +343,14 @@ body.public-page { margin: 0 0.5rem; } +.public-footer-links .legal-action-link { + border: solid 1px #38761d; + border-radius: 4px; + display: inline-block; + margin-top: 0.5rem; + padding: 0.25rem 0.55rem; +} + .public-cta ul.actions { justify-content: center; } diff --git a/avv.php b/avv.php new file mode 100644 index 0000000..5a5a5b0 --- /dev/null +++ b/avv.php @@ -0,0 +1,33 @@ + + + + + Auftragsverarbeitungsvertrag – Kaffeeliste + + + + + + +
+ +
+ + + + + + + + diff --git a/backoffice.php b/backoffice.php index 03c6602..94cdc6c 100644 --- a/backoffice.php +++ b/backoffice.php @@ -9,6 +9,16 @@ $user = app_require_platform_admin($pdo); $tenants = app_backoffice_fetch_tenants($pdo); $billingPlans = billing_plans(); +$legalRequests = $pdo->query( + "SELECT lr.id, lr.reference_code, lr.request_type, lr.requester_name, + lr.requester_email, lr.contract_reference, lr.request_reason, + lr.requested_end, lr.status, lr.received_at, t.name AS tenant_name + FROM legal_requests lr + LEFT JOIN tenants t ON t.id = lr.tenant_id + WHERE lr.status <> 'processed' + ORDER BY lr.received_at ASC + LIMIT 100" +)->fetchAll(); include 'header.php'; include 'headerline.php'; @@ -21,6 +31,31 @@ include 'nav.php';

Nur für Betreiber-Admins sichtbar: Übersicht aller Mandanten, unabhängig von deren eigener Mitgliedschaft. Jeder Zugriff wird im Audit-Log des jeweiligen Mandanten protokolliert.

+

Offene Kündigungen und Widerrufe

+ +

Keine offenen Rechtserklärungen.

+ + + + + + + + + + + + + +
EingangArt / ReferenzKundeErklärungStatus



' : ''; ?> +
+ + + +
+
+ + @@ -44,6 +79,7 @@ include 'nav.php';
benötigt: + diff --git a/database/migrations/0029_legal_compliance.sql b/database/migrations/0029_legal_compliance.sql new file mode 100644 index 0000000..ee89542 --- /dev/null +++ b/database/migrations/0029_legal_compliance.sql @@ -0,0 +1,63 @@ +ALTER TABLE tenants + ADD COLUMN customer_type VARCHAR(20) NOT NULL DEFAULT 'unknown' AFTER name, + ADD KEY idx_tenants_customer_type (customer_type); + +ALTER TABLE rate_limit_attempts + ADD KEY idx_rate_limit_attempts_created (created_at); + +ALTER TABLE audit_log + ADD KEY idx_audit_log_created (created_at); + +ALTER TABLE outbound_emails + ADD KEY idx_outbound_emails_created (created_at); + +CREATE TABLE legal_acceptances ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id INT NULL, + user_id INT NULL, + document_type VARCHAR(40) NOT NULL, + document_version VARCHAR(20) NOT NULL, + context VARCHAR(60) NOT NULL, + ip VARCHAR(45) NULL, + user_agent VARCHAR(500) NULL, + metadata_json TEXT NULL, + accepted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_legal_acceptances_tenant (tenant_id, accepted_at), + KEY idx_legal_acceptances_user (user_id, accepted_at), + CONSTRAINT fk_legal_acceptances_tenant + FOREIGN KEY (tenant_id) REFERENCES tenants(id) + ON DELETE SET NULL, + CONSTRAINT fk_legal_acceptances_user + FOREIGN KEY (user_id) REFERENCES users(id) + ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE legal_requests ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + reference_code VARCHAR(40) NOT NULL, + access_token_hash CHAR(64) NOT NULL, + request_type VARCHAR(30) NOT NULL, + tenant_id INT NULL, + user_id INT NULL, + requester_name VARCHAR(255) NOT NULL, + requester_email VARCHAR(255) NOT NULL, + contract_reference VARCHAR(255) NOT NULL, + request_reason TEXT NULL, + requested_end VARCHAR(40) NULL, + status VARCHAR(30) NOT NULL DEFAULT 'received', + ip VARCHAR(45) NULL, + user_agent VARCHAR(500) NULL, + metadata_json TEXT NULL, + received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + processed_at DATETIME NULL, + UNIQUE KEY uq_legal_requests_reference (reference_code), + UNIQUE KEY uq_legal_requests_access_token (access_token_hash), + KEY idx_legal_requests_tenant (tenant_id, received_at), + KEY idx_legal_requests_type_status (request_type, status, received_at), + CONSTRAINT fk_legal_requests_tenant + FOREIGN KEY (tenant_id) REFERENCES tenants(id) + ON DELETE SET NULL, + CONSTRAINT fk_legal_requests_user + FOREIGN KEY (user_id) REFERENCES users(id) + ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/database/migrations/0030_contract_termination.sql b/database/migrations/0030_contract_termination.sql new file mode 100644 index 0000000..cdd5df9 --- /dev/null +++ b/database/migrations/0030_contract_termination.sql @@ -0,0 +1,4 @@ +ALTER TABLE tenants + ADD COLUMN termination_requested_at DATETIME NULL AFTER customer_type, + ADD COLUMN contract_ends_at DATETIME NULL AFTER termination_requested_at, + ADD KEY idx_tenants_contract_end (contract_ends_at); diff --git a/datenexport.php b/datenexport.php index 0060118..b4f0388 100644 --- a/datenexport.php +++ b/datenexport.php @@ -44,9 +44,10 @@ include 'nav.php';

Datenexport

Lädt alle für gespeicherten Daten als - JSON-Datei herunter: Mitglieder, Zugänge und Rollen, alle Buchungen, Hinweise, - CSV-Importe, Mail-Versandlog und das Admin-Protokoll. Passwörter werden nicht - exportiert.

+ JSON-Datei herunter: Einstellungen und Logos, Mitglieder, Zugänge und Rollen, + alle Buchungen, PayPal-Zuordnungen, Hinweise und FAQ, CSV-Importe, Tarif- und + Funktionsstatus, Rechtstextnachweise, Mail-Versandlog und Admin-Protokoll. + Passwörter, Token-Hashes und Stripe-Schlüssel werden nicht exportiert.

diff --git a/datenschutz.php b/datenschutz.php index a86fb60..1078830 100644 --- a/datenschutz.php +++ b/datenschutz.php @@ -1,125 +1,29 @@ - + Datenschutzerklärung – Kaffeeliste - - - - + + + +
- - - + diff --git a/docs/billing.md b/docs/billing.md index bbe782b..3eb481c 100644 --- a/docs/billing.md +++ b/docs/billing.md @@ -94,9 +94,10 @@ mandant-einstellungen.php (Upgrade-/Portal-Buttons) als auch auf der Subscription selbst als Metadaten gesetzt, damit spätere Subscription-Events (die kein Checkout-Session-Objekt mehr enthalten) trotzdem einem Mandanten zugeordnet werden können. -- `abo-portal.php`: öffnet das Stripe Customer Portal für den bestehenden - Stripe-Kunden (Zahlungsmittel ändern, Abo kündigen) – Self-Service ohne - eigene UI dafür. +- `abo-portal.php`: öffnet für den bestehenden Stripe-Kunden einen gezielten + Customer-Portal-Flow ausschließlich zum Ändern des Zahlungsmittels. Die + Portalnavigation ist in diesem Flow ausgeblendet; Tarifwechsel und + Kündigungen laufen über die dokumentierten Funktionen der Anwendung. - `stripe-webhook.php`: verifiziert die `Stripe-Signature` nach dem von Stripe dokumentierten HMAC-SHA256-Schema (inklusive Zeitstempel-Toleranz gegen Replay), verarbeitet `checkout.session.completed`, @@ -106,9 +107,9 @@ mandant-einstellungen.php (Upgrade-/Portal-Buttons) unauthentifiziert von außen auf), stattdessen ausschließlich die Signaturprüfung als Echtheitsnachweis. - `mandant-einstellungen.php`: zeigt bei Bedarf einen echten - „Jetzt upgraden"-Button (führt zu Stripe Checkout) sowie, sobald ein - Stripe-Kunde existiert, „Zahlungsmethode verwalten / Abo kündigen" - (führt zum Customer Portal). + Bestellablauf (führt bei einer Neubuchung anschließend zu Stripe Checkout) + sowie, sobald ein Stripe-Kunde existiert, „Zahlungsmethode verwalten" + (gezielter Stripe-Portal-Flow). **Bewusst nicht umgesetzt:** automatischer Stufenwechsel bei wachsender Teilnehmerzahl. Stattdessen gilt eine harte Obergrenze (siehe unten): @@ -168,7 +169,8 @@ scripts/check-billing-capacity.php (Regressionstest) `400` abgelehnt. - Customer Portal: mit einem echten, per API angelegten Stripe-Test-Kunden liefert `abo-portal.php` einen echten 302-Redirect zu - `billing.stripe.com`. + `billing.stripe.com`; vor Produktivfreigabe den neuen eingeschränkten + Zahlungsarten-Flow erneut im Testmodus prüfen. - Alle bestehenden Regressionstests (Golden Master, M8-Isolation, M8-Rollenmatrix, HTTP-Smoke mit 36 Seiten) weiterhin grün. - Alle Testdaten (Stripe-Testobjekte kosten nichts und wurden im @@ -271,8 +273,9 @@ solange kein Pflicht-Upgrade anstand. Umgesetzte Dateien: ```text -app/stripe.php (stripe_get_subscription, stripe_update_subscription_price, stripe_cancel_subscription) +app/stripe.php (stripe_get_subscription, stripe_update_subscription_price, stripe_schedule_subscription_cancellation) app/billing.php (billing_plan_selectable_for) +abo-bestellen.php (Bestellzusammenfassung und Rechtstextbestätigung) abo-upgrade.php (Fallunterscheidung Upgrade/Downgrade/Kündigung) mandant-einstellungen.php (vollständige Tarif-Vergleichstabelle mit Bestell-Button je Tarif) ``` @@ -286,12 +289,13 @@ mandant-einstellungen.php (vollständige Tarif-Vergleichstabelle mit Bestell-But - **`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. + `stripe_schedule_subscription_cancellation()` zum Ende der bereits + bezahlten Periode; Tarif und Zugriff bleiben bis dahin erhalten. 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 + (ohne Zwischenbelastung oder Gutschrift in der laufenden Periode), 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, @@ -299,8 +303,9 @@ mandant-einstellungen.php (vollständige Tarif-Vergleichstabelle mit Bestell-But `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). + `stripe_schedule_subscription_cancellation()` (ordentliche Kündigung zum + Periodenende). `stripe_cancel_subscription()` bleibt dem wirksamen + Verbraucherwiderruf vorbehalten. - **`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 @@ -315,8 +320,12 @@ mandant-einstellungen.php (vollständige Tarif-Vergleichstabelle mit Bestell-But - `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. +- Vor kostenpflichtiger Neubuchung oder Tarifwechsel zeigt + `abo-bestellen.php` Leistung, Monatsgesamtpreis, Laufzeit, Kündigung und + technische Voraussetzungen. Der Abschluss erfolgt über die eindeutig + beschriftete Schaltfläche „zahlungspflichtig bestellen"; AGB, + Widerrufsbelehrung und gewünschter sofortiger Leistungsbeginn werden + versioniert protokolliert. ### Noch offen diff --git a/docs/deployment.md b/docs/deployment.md index 2f40add..43089a9 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,6 +1,6 @@ # Deployment und Go-Live -Stand: 2026-08-06 +Stand: 2026-08-22 Dieses Dokument beschreibt, wie die Anwendung auf den Netcup-Webspace (Plesk) ausgerollt und in Betrieb genommen wird. Es ergänzt @@ -54,6 +54,8 @@ die `.htaccess` gesperrt. Zusätzlich sperrt die `.htaccess` `var|database|scripts|docs|app|lib`, die Vendor-Verzeichnisse `TCPDF|PHPMailer|DataTables`, Dotfile-Ordner sowie Konfigurationsdateien und `.sql`/`.md`/`.jsonc`/`.sh`/`.log`-Dateien. +Im Ordner `legal/` sind ausschließlich datierte `.txt`-Fassungen von AGB, +Datenschutz, AVV und Widerrufsbelehrung als dauerhafte Downloads erlaubt. Sie erzwingt außerdem HTTPS — ohne Redirect käme ein Nutzer per `http` an und bekäme eine Session-Cookie ohne `secure`-Flag. @@ -135,7 +137,7 @@ den CSV-Import. Beide sind per `.htaccess` von außen gesperrt. ### 6. Migrationen einspielen -Es gibt 24 versionierte Migrationen in `database/migrations/`, angewendet +Es gibt 30 versionierte Migrationen in `database/migrations/`, angewendet über `scripts/migrate.php`. Das Skript ist idempotent und wendet nur fehlende Migrationen an. @@ -173,6 +175,8 @@ PayPal-Verbuchung und — kritisch — die Backups. | --- | --- | --- | | Zahlungserinnerungen | `/httpdocs/scripts/send-payment-reminders.php` | täglich, z. B. 07:00 | | PayPal-Mailabruf | `/httpdocs/scripts/fetch-paypal-payments.php` | alle 15–30 Minuten | +| Technische Löschfristen | `/httpdocs/scripts/purge-expired-operational-data.php` | stündlich | +| Beendete Verträge löschen | `/httpdocs/scripts/purge-ended-contracts.php` | täglich | | Datenbank-Backup | Shell-Befehl, siehe `docs/betrieb-backup-monitoring.md` | täglich nachts | Zum Backup-Task: Das Zielverzeichnis muss **außerhalb** von `httpdocs` @@ -181,8 +185,10 @@ liegen (z. B. `/backups` auf Vhost-Ebene), sonst wären die Dumps Ort übertragen — ein Backup, das nur auf demselben Webspace liegt, schützt nicht gegen den Ausfall des Anbieters. -Vor der Scharfschaltung beide PHP-Tasks einmal manuell auslösen. -`send-payment-reminders.php` unterstützt dafür `--dry-run`. +Vor der Scharfschaltung die Aufgaben mit Lösch- oder Buchungswirkung zuerst +manuell mit `--dry-run` auslösen. Dies unterstützen +`send-payment-reminders.php`, `fetch-paypal-payments.php`, +`purge-expired-operational-data.php` und `purge-ended-contracts.php`. ## Mailversand und DNS @@ -227,6 +233,12 @@ Details zur Funktionsweise stehen in `docs/billing.md`. Für den Go-Live: echte Zahlungseingang ist dessen erster Test. Danach in Dolibarr kontrollieren, ob die Rechnung korrekt angelegt und validiert wurde. +Der Link „Zahlungsmethode verwalten“ startet technisch ausschließlich einen +Stripe-Flow zur Aktualisierung der Zahlungsart. Im Stripe-Dashboard dürfen der +öffentliche No-Code-Portal-Login und Tarifwechsel außerhalb der Anwendung nicht +zusätzlich freigeschaltet werden; Bestellungen und Kündigungen sollen nur über +die dokumentierten Abläufe der Kaffeeliste erfolgen. + ## PayPal-Postfach Für die automatische Verbuchung weitergeleiteter PayPal-Zahlungsmails @@ -299,6 +311,16 @@ frisches Backup ziehen. Vor der Umstellung auf `app.kaffeeliste.de`: +Zuerst aus dem Projektverzeichnis ausführen: + +``` +php scripts/check-production-readiness.php +``` + +Der Check blockiert unter anderem bei fehlender B2C-Telefonnummer, +unsicherer Basis-URL, fehlenden Zahlungs-/Abrechnungsschlüsseln, zu offenen +Rechten der Secret-Dateien oder einer nicht angewendeten Legal-Migration. + - [ ] Kompletter Funktionsdurchlauf auf Staging erfolgreich (Registrierung, Mailversand, Mitglieder, Striche, Einzahlungen, CSV-Import, PDF-Export, Jahresauswertung, Stripe im Testmodus) @@ -308,8 +330,24 @@ Vor der Umstellung auf `app.kaffeeliste.de`: - [ ] Backup-Task läuft und ein Restore wurde einmal testweise eingespielt (`docs/betrieb-backup-monitoring.md`) - [ ] Stripe-Live-Keys und Produktiv-Webhook eingetragen -- [ ] Impressum, AGB und Datenschutzerklärung inhaltlich freigegeben -- [ ] Auftragsverarbeitungsvertrag (AVV) für Kunden vorbereitet +- [ ] `LEGAL_PHONE`, `LEGAL_EMAIL` und `LEGAL_TICKET_URL` zeigen auf + tatsächlich betreute Kontaktwege; Tickets werden werktags regelmäßig + bearbeitet +- [ ] Impressum, AGB, Datenschutz, AVV und Widerrufsbelehrung inhaltlich + anwaltlich für B2B und B2C freigegeben +- [ ] Kündigungs- und Widerrufsformular erzeugen beim Kunden und bei + `LEGAL_EMAIL` eine E-Mail; die Textbestätigung lässt sich speichern +- [ ] Offene Rechtserklärungen werden täglich im Back-Office geprüft; + Rückzahlungen nach Widerruf werden manuell über Stripe fristgerecht + bearbeitet (die Anwendung beendet das Abo, erstattet aber nicht automatisch) +- [ ] Stripe Checkout läuft auf Deutsch, zeigt Endpreis und Monatslaufzeit; + Test von Neubuchung, Tarifwechsel und Kündigung zum Periodenende +- [ ] Webserver-Access-Logs werden spätestens nach sieben Tagen gelöscht oder + anonymisiert; TLS/HSTS ist auf allen öffentlichen Domains aktiv +- [ ] Täglicher Task `php scripts/purge-ended-contracts.php` ist eingerichtet; + ein Vorlauf mit `--dry-run` wurde kontrolliert +- [ ] Stündlicher Task `php scripts/purge-expired-operational-data.php` ist + eingerichtet; ein Vorlauf mit `--dry-run` wurde kontrolliert - [ ] Frische Produktivdatenbank ohne Testdaten ## Bewusst offen diff --git a/email-verifizieren.php b/email-verifizieren.php index bfbc38e..5a5331e 100644 --- a/email-verifizieren.php +++ b/email-verifizieren.php @@ -62,7 +62,11 @@ $result = $token !== '' Impressum AGB Datenschutz + AVV + Widerrufsbelehrung + Vertrag widerrufen Preise + Verträge hier kündigen diff --git a/env.local.example.php b/env.local.example.php index ba7ae1a..b3aab9b 100644 --- a/env.local.example.php +++ b/env.local.example.php @@ -23,6 +23,7 @@ putenv('APP_TIMEZONE=Europe/Berlin'); // Dashboard/Login/etc.; jeder andere Host (z. B. kaffeeliste.de) zeigt // stattdessen die Landingpage. Siehe index.php. putenv('APP_HOST=app.kaffeeliste.de'); +putenv('APP_BASE_URL=https://app.kaffeeliste.de'); // Ziel des Wasserzeichens im kostenlosen Tarif (unten rechts in der App). // Ohne Angabe wird die Marketing-Domain aus APP_HOST abgeleitet, indem die @@ -44,6 +45,12 @@ putenv('APP_MAIL_TRANSPORT=mail'); putenv('APP_MAIL_FROM=noreply@kaffeeliste.de'); putenv('APP_MAIL_FROM_NAME=Kaffeeliste'); +// Pflichtangaben und schneller Kontakt. Fuer B2C muss eine Telefonnummer +// angegeben werden; das Ticketsystem ergaenzt sie als dokumentierbarer Kanal. +putenv('LEGAL_EMAIL=info@ctb-it.de'); +putenv('LEGAL_PHONE='); +putenv('LEGAL_TICKET_URL=https://ticketsystem.ctb-it.de'); + putenv('STRIPE_SECRET_KEY='); putenv('STRIPE_PUBLISHABLE_KEY='); putenv('STRIPE_WEBHOOK_SECRET='); diff --git a/footer.php b/footer.php index 01ef441..4a64a4d 100644 --- a/footer.php +++ b/footer.php @@ -133,7 +133,7 @@ if ($saasNavUser !== null) { diff --git a/impressum.php b/impressum.php index dc79eeb..e1ff489 100644 --- a/impressum.php +++ b/impressum.php @@ -1,4 +1,4 @@ - + @@ -32,12 +32,23 @@

Geschäftsbezeichnung: CTB-IT

Kontakt

-

E-Mail: info@ctb-it.de

+

+ E-Mail:
+ Ticketsystem:
+ + Telefon: + + Telefonnummer vor Produktivfreigabe über LEGAL_PHONE ergänzen. + +

+

Das Ticketsystem dokumentiert Inhalt, Datum und Uhrzeit der Korrespondenz und wird werktags regelmäßig bearbeitet.

Umsatzsteuer

Aufgrund der Kleinunternehmerregelung gemäß § 19 UStG sind die Umsätze von der Umsatzsteuer befreit.

- +

Verbraucherstreitbeilegung

+

Wir sind nicht verpflichtet und derzeit nicht bereit, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.

+

Haftung für Links

Diese App enthält gegebenenfalls Links zu externen Websites Dritter, auf deren Inhalte wir keinen Einfluss haben. Deshalb können wir für diese fremden @@ -49,12 +60,7 @@

- + diff --git a/konto.php b/konto.php index 5b1985f..8962dd2 100644 --- a/konto.php +++ b/konto.php @@ -5,6 +5,9 @@ require_once __DIR__ . '/app/features.php'; $user = saas_require_login(); $devVerificationToken = null; +$contractMailFailed = !empty($_SESSION['saas_contract_mail_failed']); +$verificationMailFailed = !empty($_SESSION['saas_verification_mail_failed']); +unset($_SESSION['saas_contract_mail_failed'], $_SESSION['saas_verification_mail_failed']); if (saas_should_show_auth_links() && !empty($_SESSION['saas_dev_email_verification_token'])) { $devVerificationToken = (string)$_SESSION['saas_dev_email_verification_token']; unset($_SESSION['saas_dev_email_verification_token']); @@ -45,6 +48,16 @@ include 'nav.php';

Das Kundenkonto wurde angelegt.

+ +
+

Die Vertragsbestätigung konnte nicht per E-Mail versendet werden. Bitte speichere jetzt die AGB, den AVV und bei privater Nutzung die Widerrufsbelehrung. Falls der Fehler bleibt, nutze bitte das Ticketsystem.

+
+ + + +

Die Verifizierungs-E-Mail konnte nicht versendet werden. Bitte versuche den Versand unten erneut oder nutze das Ticketsystem.

+ +

Der Verifizierungslink wurde vorbereitet.

diff --git a/kuendigen.php b/kuendigen.php new file mode 100644 index 0000000..9f8b251 --- /dev/null +++ b/kuendigen.php @@ -0,0 +1,154 @@ + htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8'); +$stage = (string)($_POST['stage'] ?? 'form'); +$postedRequestedEnd = (string)($_POST['requested_end'] ?? ''); +if ($postedRequestedEnd === '') { + $postedRequestedEnd = (string)($_POST['end_choice'] ?? 'earliest') === 'specific' + ? (string)($_POST['requested_end_date'] ?? '') + : 'earliest'; +} +$values = [ + 'requester_name' => trim((string)($_POST['requester_name'] ?? '')), + 'requester_email' => trim((string)($_POST['requester_email'] ?? '')), + 'contract_reference' => strtolower(trim((string)($_POST['contract_reference'] ?? ''))), + 'cancellation_kind' => (string)($_POST['cancellation_kind'] ?? 'ordinary'), + 'request_reason' => trim((string)($_POST['request_reason'] ?? '')), + 'requested_end' => $postedRequestedEnd, +]; +$errors = []; +$stored = null; +$processing = null; +$mailStatus = null; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + app_require_csrf(); + if ($stage === 'form') { + // Die Werte aus der Prüfseite werden wieder im Formular angezeigt. + } else { + $errors = app_validate_legal_request($values, 'cancellation'); + } + if ($stage !== 'form' && $errors !== [] ) { + $stage = 'form'; + } elseif ($stage === 'submit') { + if (!app_rate_limit_check($pdo, 'legal_request_ip:' . app_client_ip(), 5, 3600)) { + $errors[] = 'Zu viele Erklärungen von diesem Anschluss. Bitte nutze alternativ E-Mail oder das Ticketsystem.'; + $stage = 'form'; + } else { + $input = $values + [ + 'request_type' => 'cancellation', + 'metadata' => ['cancellation_kind' => $values['cancellation_kind']], + ]; + $stored = app_store_legal_request($pdo, $input); + $processing = app_process_cancellation_request($pdo, $stored, $input); + $mailStatus = app_send_legal_request_confirmations($input, $stored, $processing); + $stage = 'complete'; + } + } elseif ($stage !== 'form') { + $stage = 'review'; + } +} +?> + + + + Vertrag kündigen – Kaffeeliste + + + + + + +
+ +
+ + + + + + + + diff --git a/landing.php b/landing.php index 855e1da..a508b04 100644 --- a/landing.php +++ b/landing.php @@ -283,7 +283,11 @@ $jsonFlags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; Impressum AGB Datenschutz + AVV + Widerrufsbelehrung + Vertrag widerrufen Preise + Verträge hier kündigen diff --git a/legal/agb-2026-08-22.txt b/legal/agb-2026-08-22.txt new file mode 100644 index 0000000..4ca8978 --- /dev/null +++ b/legal/agb-2026-08-22.txt @@ -0,0 +1,89 @@ +## 1. Anbieter, Geltungsbereich und Begriffe + +Diese Allgemeinen Geschäftsbedingungen gelten für die Nutzung der webbasierten Anwendung „Kaffeeliste“ durch Unternehmer im Sinne des § 14 BGB und durch Verbraucher im Sinne des § 13 BGB. + +Anbieter ist Clemens Creutzburg, CTB-IT, In den Sieben Stücken 9d, 30655 Hannover, E-Mail: info@ctb-it.de. Weitere Kontaktmöglichkeiten stehen im [Impressum](impressum.php). + +„Kunde“ ist die Person, die einen Kundenbereich (Mandanten) registriert. „Teilnehmer“ sind Personen, deren Kaffee- oder Getränkebuchungen der Kunde innerhalb dieses Bereichs verwaltet. + +## 2. Vertragsschluss und Vertragstext + +Der kostenlose Nutzungsvertrag kommt zustande, wenn der Kunde das Registrierungsformular vollständig absendet, diese AGB akzeptiert und der Anbieter den Kundenbereich freischaltet. Der Kunde kann Eingabefehler vor dem Absenden über die üblichen Formularfunktionen berichtigen. Vertragssprache ist Deutsch. + +Ein kostenpflichtiges Abonnement kommt erst zustande, wenn der Kunde nach Anzeige des Tarifs, des monatlichen Gesamtpreises, der Laufzeit und der Kündigungsbedingungen den Bestellvorgang beim Zahlungsdienstleister Stripe mit einer eindeutig auf die Zahlungspflicht hinweisenden Schaltfläche abschließt und die Buchung bestätigt wird. Bis dahin kann der Vorgang abgebrochen oder berichtigt werden. + +Der Anbieter protokolliert die angenommenen Versionen der Rechtstexte und übersendet dem Kunden eine Vertragsbestätigung in Textform. Der Kunde kann die aktuellen Texte außerdem jederzeit herunterladen oder ausdrucken. + +## 3. Leistungen und technische Voraussetzungen + +Der Anbieter stellt eine mandantenfähige Webanwendung zur Verwaltung einer internen Kaffee- oder Getränkekasse bereit. Zum Kern gehören die Verwaltung von Teilnehmern und Rollen, die Erfassung von Verbrauch und Einzahlungen, die Berechnung von Salden sowie die im gebuchten Tarif ausgewiesenen Export- und Verwaltungsfunktionen. + +Erforderlich sind ein aktueller Browser, eine Internetverbindung und eine erreichbare E-Mail-Adresse. Die Anwendung ist für die Nutzung im Browser vorgesehen. Ein Anspruch auf eine mobile App, bestimmte Schnittstellen oder Funktionen außerhalb der veröffentlichten Leistungsbeschreibung besteht nicht. + +Der Anbieter darf die Anwendung aktualisieren und sicherheitsbedingt ändern. Bei Verbraucherverträgen über digitale Produkte bleiben die gesetzlichen Aktualisierungs-, Bereitstellungs- und Mängelrechte, insbesondere nach §§ 327 ff. BGB, unberührt. Wesentliche, vertraglich geschuldete Funktionen werden nicht ohne sachlichen Grund entzogen. + +## 4. Tarife, Preise und Zahlung + +Tarifgrenzen und monatliche Endpreise stehen auf der Seite [Preise](preise.php). Wegen Anwendung der Kleinunternehmerregelung nach § 19 UStG wird keine Umsatzsteuer ausgewiesen. Es fallen keine Versandkosten an. + +Kostenpflichtige Tarife werden monatlich im Voraus über Stripe abgerechnet. Eine automatische Hochstufung allein wegen einer höheren Teilnehmerzahl findet nicht statt. Ist die Grenze des gebuchten Tarifs erreicht, können keine weiteren aktiven Teilnehmer angelegt oder reaktiviert werden, bis der Kunde selbst einen passenden Tarif bucht oder Teilnehmer deaktiviert. + +Ein Wechsel zwischen kostenpflichtigen Tarifen schaltet die neue Teilnehmergrenze sofort frei. Für die bereits laufende, bezahlte Abrechnungsperiode erfolgt weder eine zusätzliche zeitanteilige Belastung noch eine Gutschrift; der neue volle Monatspreis gilt ab der nächsten Verlängerung. Ein Wechsel in den kostenlosen Tarif ist eine Kündigung des kostenpflichtigen Abonnements zum Ende des laufenden Abrechnungszeitraums. + +Gerät der Kunde in Zahlungsverzug, darf der Anbieter kostenpflichtige Zusatzfunktionen nach vorherigem Hinweis vorübergehend sperren. Gesetzliche Rechte bleiben unberührt. + +## 5. Pflichten des Kunden + +Der Kunde macht richtige Angaben, hält seine E-Mail-Adresse aktuell, schützt Zugangsdaten und informiert den Anbieter unverzüglich über vermuteten Missbrauch. Inhalte und Daten dürfen nicht rechtswidrig sein, Rechte Dritter verletzen, Schadsoftware enthalten oder den Dienst übermäßig belasten. + +Wer Daten anderer Personen verwaltet, stellt eine hierfür erforderliche Rechtsgrundlage und Information der Betroffenen sicher. Soweit der Kunde Verantwortlicher im Sinne der DSGVO ist und der Anbieter in seinem Auftrag verarbeitet, gilt der elektronisch vereinbarte [Auftragsverarbeitungsvertrag](avv.php). Für ausschließlich persönliche oder familiäre Tätigkeiten kann die Haushaltsausnahme nach Art. 2 Abs. 2 lit. c DSGVO gelten. + +## 6. Verfügbarkeit, Wartung und Support + +Der Anbieter betreibt den Dienst mit der im Verkehr üblichen Sorgfalt. Ohne gesonderte Service-Level-Vereinbarung wird keine bestimmte prozentuale Verfügbarkeit garantiert. Vorhersehbare Wartungen werden möglichst zu nutzungsarmen Zeiten vorgenommen. Kurzzeitige Einschränkungen durch Wartung, Sicherheitsmaßnahmen oder Ereignisse außerhalb des Einflussbereichs des Anbieters sind möglich. + +Supportanfragen können per E-Mail oder über das im Impressum verlinkte Ticketsystem gestellt werden. Gesetzliche Mängelrechte werden dadurch nicht eingeschränkt. + +## 7. Laufzeit und Kündigung + +Der kostenlose Vertrag läuft auf unbestimmte Zeit und kann jederzeit ohne Frist gekündigt werden. Kostenpflichtige Abonnements laufen ebenfalls auf unbestimmte Zeit, haben eine monatliche Abrechnungsperiode und können jederzeit zum Ende der laufenden Abrechnungsperiode gekündigt werden. Es gibt keine darüber hinausgehende Mindestlaufzeit. + +Verbraucher können die ständig erreichbare Funktion [Verträge hier kündigen](kuendigen.php) verwenden. Eine Kündigung in Textform per E-Mail ist ebenfalls möglich. Das Recht zur außerordentlichen Kündigung aus wichtigem Grund bleibt bestehen. Der Anbieter kann ordentlich mit einer Frist von einem Monat zum Ende der laufenden Abrechnungsperiode kündigen; bei kostenlosen Verträgen mit einer Frist von einem Monat. + +Der Kunde sollte seinen Datenexport vor dem Vertragsende herunterladen. Nach Vertragsende wird der Zugang gesperrt; die operativen Mandantendaten werden 30 Tage später gelöscht. Eine vom Kunden im Konto ausdrücklich ausgelöste Mandantenlöschung wirkt sofort und ist nicht rückgängig zu machen. Abrechnungsunterlagen, Vertragsnachweise und offene Rechtsfälle werden nur im gesetzlich erforderlichen Umfang getrennt weiter aufbewahrt. + +## 8. Widerrufsrecht für Verbraucher + +Verbrauchern steht bei Fernabsatzverträgen grundsätzlich ein vierzehntägiges Widerrufsrecht zu. Einzelheiten, Musterformular und die elektronische Widerrufsfunktion stehen unter [Widerruf](widerruf.php). Verlangt ein Verbraucher ausdrücklich, dass eine kostenpflichtige Leistung vor Ablauf der Widerrufsfrist beginnt, kann er bei einem Widerruf Wertersatz für die bis dahin erbrachte Leistung schulden. Das Widerrufsrecht erlischt bei einer laufenden Dienstleistung nicht bereits durch den bloßen Leistungsbeginn. + +## 9. Mängelrechte + +Es gelten die gesetzlichen Mängelrechte. Verbraucher haben bei digitalen Produkten insbesondere Anspruch auf vertragsgemäße Bereitstellung und erforderliche Aktualisierungen sowie bei Mängeln die gesetzlichen Rechte auf Nacherfüllung, Vertragsbeendigung, Minderung und gegebenenfalls Schadensersatz. Garantien werden nur übernommen, wenn sie ausdrücklich als solche bezeichnet sind. + +## 10. Haftung + +Der Anbieter haftet unbeschränkt bei Vorsatz und grober Fahrlässigkeit, bei Schäden aus der Verletzung des Lebens, des Körpers oder der Gesundheit, nach dem Produkthaftungsgesetz, bei arglistigem Verschweigen eines Mangels und im Umfang einer ausdrücklich übernommenen Garantie. + +Bei leicht fahrlässiger Verletzung einer wesentlichen Vertragspflicht ist die Haftung auf den bei Vertragsschluss typischerweise vorhersehbaren Schaden begrenzt. Wesentliche Vertragspflichten sind solche, deren Erfüllung die ordnungsgemäße Durchführung des Vertrags erst ermöglicht und auf deren Einhaltung der Kunde regelmäßig vertrauen darf. Im Übrigen ist die Haftung für leichte Fahrlässigkeit ausgeschlossen, soweit dies gesetzlich zulässig ist. + +Die vorstehenden Begrenzungen gelten entsprechend zugunsten der Beschäftigten und Erfüllungsgehilfen des Anbieters. Zwingende Verbraucherrechte und Ansprüche nach den Vorschriften über digitale Produkte bleiben unberührt. + +## 11. Änderungen dieser Bedingungen und der Leistung + +Änderungen dieser AGB für bestehende Verträge erfolgen nur aus einem sachlichen Grund, insbesondere wegen einer Rechtsänderung, Sicherheitsanforderung oder einer notwendigen technischen Weiterentwicklung, und nur soweit das Verhältnis von Leistung und Gegenleistung nicht unangemessen zulasten des Kunden verschoben wird. Der Anbieter teilt Änderungen mindestens sechs Wochen vor ihrem Wirksamwerden in Textform mit. + +Eine Änderung, die Hauptleistung, Preis oder wesentliche Verbraucherrechte betrifft, wird nur mit ausdrücklicher Zustimmung wirksam. Stimmt der Kunde einer notwendigen Änderung nicht zu, dürfen beide Seiten den Vertrag nach den bisherigen Bedingungen ordentlich beenden. + +## 12. Verbraucherstreitbeilegung + +Der Anbieter ist nicht verpflichtet und derzeit nicht bereit, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen. Unabhängig davon können Beschwerden über die Kontaktwege im Impressum eingereicht werden. + +## 13. Schlussbestimmungen + +Es gilt deutsches Recht. Bei Verbrauchern gilt diese Rechtswahl nur, soweit dadurch zwingender Schutz des Staates ihres gewöhnlichen Aufenthalts nicht entzogen wird. Das UN-Kaufrecht ist ausgeschlossen. + +Ausschließlicher Gerichtsstand Hannover wird nur vereinbart, wenn der Kunde Kaufmann, juristische Person des öffentlichen Rechts oder öffentlich-rechtliches Sondervermögen ist oder keinen allgemeinen Gerichtsstand in Deutschland hat und die Vereinbarung gesetzlich zulässig ist. Im Übrigen gelten die gesetzlichen Gerichtsstände. + +Sollte eine Regelung unwirksam sein, gelten an ihrer Stelle die gesetzlichen Vorschriften; der übrige Vertrag bleibt bestehen. + +Stand: 22. August 2026 diff --git a/legal/avv-2026-08-22.txt b/legal/avv-2026-08-22.txt new file mode 100644 index 0000000..70e0eaa --- /dev/null +++ b/legal/avv-2026-08-22.txt @@ -0,0 +1,84 @@ +## Vertrag über Auftragsverarbeitung nach Art. 28 DSGVO + +zwischen dem im Kundenkonto bezeichneten Kunden („Verantwortlicher“) und Clemens Creutzburg, CTB-IT, In den Sieben Stücken 9d, 30655 Hannover („Auftragsverarbeiter“). + +Diese Vereinbarung gilt, soweit der Kunde personenbezogene Daten anderer Personen in der Kaffeeliste in eigener Verantwortlichkeit verarbeitet. Bei einer ausschließlich persönlichen oder familiären Tätigkeit kann die Haushaltsausnahme nach Art. 2 Abs. 2 lit. c DSGVO gelten; die nachfolgenden Schutz- und Vertraulichkeitspflichten gelten vertraglich dennoch. + +## 1. Gegenstand, Dauer, Art und Zweck + +Gegenstand ist der Betrieb der webbasierten Kaffeeliste nach dem Hauptvertrag. Die Verarbeitung beginnt mit Anlage von Daten anderer Personen und dauert bis zu deren Löschung oder dem Ende des Hauptvertrags. Sie umfasst Erheben, Erfassen, Ordnen, Speichern, Auslesen, Abfragen, Berechnen, Übermitteln an vom Kunden bestimmte Empfänger, Einschränken, Anonymisieren und Löschen. + +Zweck ist die Verwaltung einer internen Kaffee- oder Getränkekasse, insbesondere von Teilnehmern, Verbrauchsbuchungen, Einzahlungen, Salden, Benachrichtigungen und Exporten. + +## 2. Datenarten und betroffene Personen + +- Stammdaten: Name, Anzeigename, optional E-Mail-Adresse und PayPal-Anzeigename +- Nutzungs- und Buchungsdaten: Verbrauch, Einzahlungen, Salden, Zeitpunkte, Bemerkungen und Zuordnungen +- Kommunikationsdaten: Empfänger, Betreff, Versandstatus und Fehlermeldungen von Benachrichtigungen +- Zugangs- und Protokolldaten: Rollen, technische Kennungen und sicherheitsrelevante Aktionen +- Betroffene: Teilnehmer, Beschäftigte, Vereinsmitglieder, Familien- oder Gruppenmitglieder sowie vom Kunden berechtigte Nutzer + +Besondere Kategorien personenbezogener Daten nach Art. 9 DSGVO sind nicht vorgesehen. Der Kunde weist den Auftragsverarbeiter nicht an, solche Daten einzugeben. + +## 3. Weisungen + +Der Auftragsverarbeiter verarbeitet die Daten nur auf dokumentierte Weisung des Verantwortlichen, soweit keine gesetzliche Verpflichtung eine andere Verarbeitung verlangt. Der Hauptvertrag, die Einstellungen und die Nutzung der vorgesehenen Funktionen gelten als dokumentierte Weisungen. Weitergehende Weisungen können in Textform über die Kontaktwege im Impressum erteilt werden. + +Hält der Auftragsverarbeiter eine Weisung für datenschutzwidrig, informiert er den Verantwortlichen unverzüglich und darf die Ausführung bis zur Klärung aussetzen. Zusätzlicher Aufwand außerhalb des vereinbarten Leistungsumfangs kann nach vorheriger Vereinbarung berechnet werden. + +## 4. Vertraulichkeit und Personal + +Der Auftragsverarbeiter setzt nur Personen ein, die zur Vertraulichkeit verpflichtet und angemessen über Datenschutz und Informationssicherheit unterwiesen wurden. Zugriffe werden auf das zur Aufgabenerfüllung erforderliche Maß beschränkt. + +## 5. Technische und organisatorische Maßnahmen + +- verschlüsselte Übertragung über TLS und HSTS auf der Produktivdomain +- Passwörter nur als zeitgemäße, gesalzene Hashwerte; geheime Schlüssel außerhalb des Quellcodes +- rollen- und mandantenbezogene serverseitige Zugriffskontrollen nach dem Minimalprinzip +- Sitzungs-Cookies mit HttpOnly, Secure bei HTTPS und SameSite; CSRF-Schutz bei schreibenden Vorgängen +- Begrenzung automatisierter Anmelde- und Rücksetzversuche sowie Protokollierung sicherheitsrelevanter Administrationsvorgänge +- getrennte Mandantenkennungen, Fremdschlüssel und automatisierte Isolationstests +- regelmäßige Sicherheitsaktualisierungen, eingeschränkte Administrationszugänge und gesicherte Betriebszugänge +- regelmäßige verschlüsselte oder zugriffsgeschützte Datensicherungen nach dem dokumentierten Backupkonzept sowie Wiederherstellungstests +- Lösch-, Export- und Bereinigungskonzepte für Mandantendaten, Protokolle und temporäre Daten +- Verfahren zur Behandlung von Sicherheitsvorfällen und zur Bewertung meldepflichtiger Datenschutzverletzungen + +Der Verantwortliche prüft diese Maßnahmen im Hinblick auf sein Risiko und informiert den Auftragsverarbeiter vor Vertragsbeginn, wenn ergänzende Maßnahmen erforderlich sind. Änderungen bleiben zulässig, sofern das Schutzniveau nicht unterschritten wird. + +## 6. Unterauftragsverarbeiter + +Der Verantwortliche erteilt die allgemeine Genehmigung zum Einsatz folgender Unterauftragsverarbeiter: + +- netcup GmbH, Emmy-Noether-Straße 10, 76131 Karlsruhe, Deutschland – Rechenzentrum, Hosting, Speicher, Backup-Infrastruktur und Mailtransport; Verarbeitungsort Deutschland beziehungsweise Europäische Union + +Stripe und das interne Abrechnungssystem Dolibarr erhalten grundsätzlich nur Vertrags-, Rechnungs- und Zahlungsdaten des Kunden, nicht die im Auftrag verwalteten Teilnehmer- und Kasseninhalte. Soweit im Einzelfall doch Auftragsdaten an einen weiteren Dienst übermittelt werden sollen, wird dieser vorab als Unterauftragsverarbeiter einbezogen. + +Über beabsichtigte Änderungen der Unterauftragsverarbeiter informiert der Auftragsverarbeiter mindestens 30 Tage vorher in Textform. Der Verantwortliche kann aus wichtigem datenschutzrechtlichem Grund widersprechen. Ist keine zumutbare Alternative möglich, kann jede Partei den betroffenen Dienst zum Änderungszeitpunkt kündigen. + +Der Auftragsverarbeiter verpflichtet Unterauftragsverarbeiter nach Art. 28 Abs. 4 DSGVO mindestens zu gleichwertigen Pflichten und bleibt gegenüber dem Verantwortlichen für deren Erfüllung verantwortlich. + +## 7. Unterstützung des Verantwortlichen + +Der Auftragsverarbeiter unterstützt den Verantwortlichen unter Berücksichtigung der Art der Verarbeitung angemessen bei Betroffenenanfragen, Datenschutz-Folgenabschätzungen, Konsultationen mit Aufsichtsbehörden und bei der Erfüllung der Pflichten aus Art. 32 bis 36 DSGVO. Geht eine Betroffenenanfrage unmittelbar ein, leitet er sie ohne eigene inhaltliche Entscheidung an den Verantwortlichen weiter, soweit der Anbieter nicht für eigene Datenverarbeitungen selbst verantwortlich ist. + +## 8. Datenschutzverletzungen + +Der Auftragsverarbeiter informiert den Verantwortlichen unverzüglich, nachdem ihm eine Verletzung des Schutzes von Auftragsdaten bekannt wurde. Die Meldung enthält, soweit verfügbar, Art und Umfang des Vorfalls, betroffene Daten und Personen, wahrscheinliche Folgen, ergriffene oder vorgeschlagene Maßnahmen sowie einen Kontaktpunkt. Fehlende Angaben werden nachgereicht. + +## 9. Nachweise und Kontrollen + +Der Auftragsverarbeiter stellt die zur Darlegung der Einhaltung von Art. 28 DSGVO erforderlichen Informationen bereit. Der Verantwortliche darf nach angemessener Vorankündigung und unter Wahrung der Sicherheit und Vertraulichkeit selbst oder durch einen zur Verschwiegenheit verpflichteten Prüfer kontrollieren. Vorrangig werden aktuelle Dokumentationen und geeignete Nachweise genutzt. Vor-Ort-Prüfungen erfolgen bei berechtigtem Anlass und während üblicher Geschäftszeiten; vermeidbarer Zusatzaufwand kann nach vorheriger Vereinbarung berechnet werden. Rechte der Aufsichtsbehörden bleiben unberührt. + +## 10. Rückgabe und Löschung + +Der Verantwortliche kann die Auftragsdaten vor Vertragsende über die Exportfunktion abrufen. Nach Vertragsende sperrt der Auftragsverarbeiter den Zugang und löscht die operativen Auftragsdaten spätestens nach 30 Tagen, sofern keine gesetzliche Aufbewahrungspflicht entgegensteht. Eine ausdrücklich ausgelöste Mandantenlöschung wirkt sofort. In turnusmäßig überschriebenen Sicherungen können Daten danach noch bis zu 14 Tage enthalten; sie werden nicht erneut produktiv verwendet und bei einer Wiederherstellung erneut dem Löschlauf unterzogen. + +Abrechnungs- und Vertragsdaten, die der Anbieter in eigener Verantwortlichkeit aufbewahren muss, sind nicht Gegenstand der Löschung nach diesem Abschnitt. + +## 11. Verantwortungsbereiche und Schlussbestimmungen + +Der Verantwortliche ist für Zulässigkeit, Transparenz, Datenrichtigkeit, Rechtsgrundlagen, Betroffenenrechte und seine Nutzerberechtigungen verantwortlich. Der Auftragsverarbeiter ist für die vertragsgemäße, sichere Verarbeitung im Auftrag verantwortlich. + +Im Übrigen gelten Hauptvertrag und AGB. Bei Widersprüchen zu Datenschutzpflichten geht dieser AVV vor. Änderungen bedürfen der Textform. Die elektronische Annahme wird mit Version, Zeit, Nutzer und technischen Nachweisdaten protokolliert. + +Stand: 22. August 2026 diff --git a/legal/datenschutz-2026-08-22.txt b/legal/datenschutz-2026-08-22.txt new file mode 100644 index 0000000..2b9ed21 --- /dev/null +++ b/legal/datenschutz-2026-08-22.txt @@ -0,0 +1,89 @@ +## 1. Verantwortlicher und Kontakt + +Verantwortlich für die eigenen Verarbeitungen des Kaffeeliste-Dienstes ist Clemens Creutzburg, CTB-IT, In den Sieben Stücken 9d, 30655 Hannover, E-Mail: info@ctb-it.de. Telefonnummer und Ticketsystem stehen im [Impressum](impressum.php). Ein Datenschutzbeauftragter ist derzeit nicht bestellt, weil keine gesetzliche Bestellpflicht festgestellt wurde. + +## 2. Unsere Rollen + +Für Registrierung, Kundenkonto, Vertragsabwicklung, Abrechnung, Support, Sicherheit und den Betrieb der öffentlichen Website ist CTB-IT Verantwortlicher. + +Verwaltet ein Kunde Daten anderer Personen innerhalb seines Mandanten, ist grundsätzlich der Kunde Verantwortlicher und CTB-IT Auftragsverarbeiter. Dies regelt der [AVV](avv.php). Bei ausschließlich persönlichen oder familiären Tätigkeiten eines privaten Kunden kann die Haushaltsausnahme nach Art. 2 Abs. 2 lit. c DSGVO gelten. CTB-IT schützt die Daten technisch und vertraglich unabhängig davon. + +## 3. Website, Serverprotokolle und Sicherheit + +Beim Aufruf werden technisch erforderliche Verbindungsdaten verarbeitet: IP-Adresse, Datum und Uhrzeit, angeforderte Adresse, übertragene Datenmenge, Referrer, Browser, Betriebssystem und HTTP-Status. Zweck sind sichere Auslieferung, Fehleranalyse und Abwehr von Angriffen. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO; das berechtigte Interesse ist ein sicherer und stabiler Dienst. + +Anwendungsseitige IP-Daten aus der Begrenzung automatisierter Versuche werden spätestens nach 24 Stunden gelöscht. Webserver-Protokolle sollen im Produktivbetrieb nach spätestens sieben Tagen gelöscht oder anonymisiert werden, sofern ein Sicherheitsvorfall keine längere, zweckgebundene Sicherung erfordert. + +## 4. Registrierung und Kundenkonto + +Wir verarbeiten Kundenname und -kürzel, Kundentyp, Name, E-Mail-Adresse, Passwort-Hash, E-Mail-Bestätigung, Rollen, Anmeldezeitpunkte, Vertrags- und Rechtstextversionen, Zeitpunkt der Annahme, IP-Adresse und User-Agent. Zwecke sind Vertragsschluss, Authentifizierung, Berechtigungen, Vertragsnachweis und Kommunikation. Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b DSGVO sowie für Sicherheits- und Nachweisdaten Art. 6 Abs. 1 lit. f DSGVO. Pflichtfelder sind für Vertragsschluss und sichere Kontoführung erforderlich; ohne sie ist keine Registrierung möglich. + +Ein Klartextpasswort wird nicht gespeichert. Die Kenntnisnahme der Datenschutzerklärung ist keine Einwilligung in Werbung oder Tracking. + +## 5. Mandanten-, Teilnehmer- und Kassendaten + +Je nach Nutzung werden Einstellungen, Marken- und Zahlungsangaben des Kunden, Teilnehmername, E-Mail-Adresse, PayPal-Anzeigename und -E-Mail, Nutzerzuordnung, Rollen, Aktivstatus, Verbrauch, Einzahlungen, Salden, Bemerkungen, Hinweise, FAQ-Inhalte, Importe, Benachrichtigungsstatus und Administrationsprotokolle verarbeitet. + +Bei Auftragsverarbeitung bestimmt der Kunde Zwecke, Rechtsgrundlagen, Inhalte und Empfänger. Die Daten stammen vom Kunden, seinen Administratoren, den Teilnehmern oder aus vom Kunden veranlassten Importen. Teilnehmer sollten sich für Auskunft, Berichtigung oder Löschung zuerst an den jeweiligen Kunden wenden; CTB-IT unterstützt diesen nach dem AVV. + +## 6. Vertrag, Abrechnung, Stripe und Dolibarr + +Bei kostenpflichtigen Tarifen verarbeiten wir Tarif, Teilnehmerzahl, Abrechnungsstatus, Stripe-Kunden- und Abonnementkennung, Laufzeit, Rechnungsbetrag und Zahlungsstatus zur Vertragsdurchführung nach Art. 6 Abs. 1 lit. b DSGVO und für Buchführung und Steuerrecht nach Art. 6 Abs. 1 lit. c DSGVO. + +Zahlungen werden durch Stripe Payments Europe, Limited, 1 Grand Canal Street Lower, Grand Canal Dock, Dublin, Irland, abgewickelt. Stripe erhält insbesondere Kontakt-, Vertrags-, Geräte- und Zahlungsdaten. Stripe verarbeitet einen Teil als unser Auftragsverarbeiter und einen Teil in eigener Verantwortlichkeit, etwa zur Betrugsprävention und Erfüllung finanzrechtlicher Pflichten. Übermittlungen in die USA oder andere Drittländer können auf Angemessenheitsbeschlüssen, insbesondere dem EU-US Data Privacy Framework für zertifizierte Empfänger, oder EU-Standardvertragsklauseln beruhen. Weitere Informationen: [Stripe-Datenschutz](https://stripe.com/de/privacy). + +Bezahlte Rechnungen werden im selbst betriebenen Dolibarr-System unter verwaltung.ctb-it.de für Buchführung und Kundenverwaltung gespiegelt. Dabei werden Kunden-, Vertrags-, Rechnungs- und Zahlungsdaten verarbeitet, aber grundsätzlich keine Teilnehmer- oder Kasseninhalte. + +## 7. PayPal-Zahlungsmails + +Aktiviert ein Kunde die PayPal-Zuordnung, werden an die mandantenspezifische Zahlungsadresse weitergeleitete PayPal-Benachrichtigungen automatisiert ausgelesen. Verarbeitet werden unter anderem Absender- und Empfängeradresse, PayPal-Transaktionskennung, Name und E-Mail des Zahlers, Betrag, Währung, Zahlungszeit und Zuordnungsstatus. Zweck ist die vom Kunden gewünschte Zuordnung zu Teilnehmern und Einzahlungen. Bei Teilnehmerdaten erfolgt dies im Auftrag des Kunden; für die Bereitstellung und Sicherheit der Funktion zusätzlich auf Art. 6 Abs. 1 lit. b und f DSGVO. + +PayPal selbst verarbeitet Daten in eigener Verantwortlichkeit nach den dortigen Datenschutzinformationen. Die Kaffeeliste ruft keine PayPal-Kontodaten des Kunden über eine PayPal-API ab. + +## 8. E-Mail, Support und Ticketsystem + +Wir senden notwendige E-Mails zur Verifizierung, Anmeldung, Passwortzurücksetzung, Einladung, Vertragsbestätigung, Kündigung, Widerruf und zu vom Kunden aktivierten Kassenbenachrichtigungen. Verarbeitet werden Empfänger, Betreff, Status, Zeitpunkt und gegebenenfalls Fehler. Grundlage sind Art. 6 Abs. 1 lit. b DSGVO beziehungsweise bei Teilnehmernachrichten der Auftrag des Kunden. Versandprotokolle werden regelmäßig nach 180 Tagen gelöscht, soweit sie nicht als Vertragsnachweis länger erforderlich sind. + +Bei Kontakt per E-Mail oder über ticketsystem.ctb-it.de verarbeiten wir die übermittelten Kontaktdaten, Inhalte, Anhänge, Zeitpunkte und technischen Protokolldaten, um die Anfrage zu bearbeiten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO bei vorvertraglichen oder vertraglichen Anliegen, sonst Art. 6 Abs. 1 lit. f DSGVO. Unser Interesse ist nachvollziehbarer und sicherer Support. Bitte übermitteln Sie im Ticket keine unnötigen besonderen Kategorien personenbezogener Daten. + +## 9. Hosting, Empfänger und Auftragsverarbeiter + +Die Anwendung, Datenbank, Sitzungen, Sicherungen und der Mailtransport werden auf Infrastruktur der netcup GmbH, Emmy-Noether-Straße 10, 76131 Karlsruhe, Deutschland, innerhalb Deutschlands beziehungsweise der Europäischen Union betrieben. Mit netcup besteht ein Vertrag über Auftragsverarbeitung. + +Weitere Empfänger sind Stripe für Zahlung und Abrechnung, Behörden oder Berater bei gesetzlicher Pflicht beziehungsweise Rechtsverteidigung und die vom Kunden bestimmten Nutzer und Empfänger. Eine Weitergabe für fremde Werbung findet nicht statt. + +## 10. Cookies und externe Inhalte + +Die Anwendung setzt ausschließlich das Sitzungs-Cookie „kaffeeliste_session“. Es ist für Login, Mandantenzuordnung, Sicherheit und CSRF-Schutz erforderlich, enthält eine zufällige Sitzungskennung, ist für JavaScript nicht lesbar und wird beim Schließen des Browsers beendet. Grundlage für den Zugriff auf das Endgerät ist § 25 Abs. 2 Nr. 2 TDDDG; die anschließende Verarbeitung beruht auf Art. 6 Abs. 1 lit. b und f DSGVO. + +Es gibt keine Analyse-, Werbe- oder Social-Media-Cookies. Schriften und Gestaltungselemente werden lokal ausgeliefert; beim bloßen Seitenaufruf wird keine Verbindung zu Google Fonts hergestellt. + +## 11. Speicherdauer und Löschung + +- Kundenkonto und operative Mandantendaten: für die Vertragsdauer; bei einer ausdrücklich ausgelösten Mandantenlöschung sofort aus dem Produktivsystem, sonst 30 Tage nach Vertragsende +- technische Rate-Limit-Daten: höchstens 24 Stunden +- Admin- und Sicherheitsprotokoll: regelmäßig 180 Tage; bei einem konkreten Vorfall bis zu dessen Abschluss und Ablauf einschlägiger Verjährungsfristen +- E-Mail-Versandprotokoll: regelmäßig 180 Tage +- Vertragsannahmen, Kündigungen, Widerrufe und erforderliche Korrespondenz: regelmäßig bis zum Ablauf der zivilrechtlichen Verjährung, grundsätzlich drei Jahre ab Ende des Jahres, bei Rechtsstreit länger +- Rechnungen, Zahlungs- und Buchungsbelege des Anbieters: acht Jahre oder eine andere gesetzlich vorgeschriebene Dauer nach § 257 HGB und § 147 AO +- Sicherungskopien: rollierend bis zu 14 Tage; danach Überschreibung. Bei Wiederherstellung werden zuvor fällige Löschungen erneut ausgeführt. + +Interne Kaffee- oder Getränkebuchungen des Kunden werden nicht pauschal als steuerliche Belege des Anbieters behandelt. Der Kunde bestimmt im Rahmen seiner Verantwortung eigene Aufbewahrungsfristen und kann den Mandanten löschen. Gesetzlich aufzubewahrende Abrechnungsdaten des Anbieters bleiben getrennt erhalten. + +## 12. Ihre Rechte + +Soweit CTB-IT Verantwortlicher ist, bestehen nach den gesetzlichen Voraussetzungen Rechte auf Auskunft, Berichtigung, Löschung, Einschränkung, Datenübertragbarkeit und Widerspruch. Eine erteilte Einwilligung kann jederzeit für die Zukunft widerrufen werden; derzeit stützt der Dienst keine Werbung oder Analyse auf eine Einwilligung. + +Gegen Verarbeitungen nach Art. 6 Abs. 1 lit. f DSGVO können Sie aus Gründen Ihrer besonderen Situation widersprechen. Bei Direktwerbung wäre ein Widerspruch jederzeit ohne Begründung möglich; Direktwerbung ist derzeit nicht vorgesehen. + +Anfragen richten Sie an info@ctb-it.de oder über das Ticketsystem im Impressum. Sie können sich außerdem bei einer Datenschutzaufsichtsbehörde beschweren. Für den Anbieter ist insbesondere die Landesbeauftragte für den Datenschutz Niedersachsen, Prinzenstraße 5, 30159 Hannover, zuständig. + +## 13. Automatisierte Entscheidungen + +Es findet keine ausschließlich automatisierte Entscheidungsfindung einschließlich Profiling statt, die rechtliche Wirkung entfaltet oder Sie ähnlich erheblich beeinträchtigt. Sicherheitslimits können Anfragen kurzfristig technisch blockieren; eine Vertragsentscheidung wird damit nicht getroffen. + +## 14. Änderungen + +Wir aktualisieren diese Erklärung, wenn sich Verarbeitung oder Rechtslage ändern. Bei wesentlichen Änderungen für bestehende Kunden informieren wir über einen geeigneten Kontaktweg. + +Stand: 22. August 2026 diff --git a/legal/widerruf-2026-08-22.txt b/legal/widerruf-2026-08-22.txt new file mode 100644 index 0000000..c20a3a2 --- /dev/null +++ b/legal/widerruf-2026-08-22.txt @@ -0,0 +1,32 @@ +## Widerrufsbelehrung für Verbraucher + +### Widerrufsrecht + +Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen. + +Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses. + +Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (Clemens Creutzburg, CTB-IT, In den Sieben Stücken 9d, 30655 Hannover, E-Mail: info@ctb-it.de; Telefonnummer siehe Impressum) mittels einer eindeutigen Erklärung, zum Beispiel per Brief oder E-Mail, über Ihren Entschluss informieren, diesen Vertrag zu widerrufen. Sie können außerdem die hervorgehoben bereitgestellte elektronische Funktion [Vertrag widerrufen](widerrufen.php) verwenden. Sie können das nachstehende Muster-Widerrufsformular nutzen; dies ist nicht vorgeschrieben. + +Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden. + +### Folgen des Widerrufs + +Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf bei uns eingegangen ist. Für die Rückzahlung verwenden wir dasselbe Zahlungsmittel wie bei der ursprünglichen Transaktion, sofern nicht ausdrücklich etwas anderes vereinbart wurde; Ihnen werden wegen dieser Rückzahlung keine Entgelte berechnet. + +Haben Sie verlangt, dass eine kostenpflichtige Dienstleistung während der Widerrufsfrist beginnen soll, zahlen Sie uns einen angemessenen Betrag. Dieser entspricht dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns über den Widerruf unterrichten, bereits erbrachten Leistung im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Leistung. + +## Muster-Widerrufsformular + +Wenn Sie den Vertrag widerrufen wollen, können Sie folgenden Text an Clemens Creutzburg, CTB-IT, In den Sieben Stücken 9d, 30655 Hannover, E-Mail: info@ctb-it.de senden: + +Hiermit widerrufe ich den von mir abgeschlossenen Vertrag über die Erbringung der Kaffeeliste-Dienstleistung. + +- Bestellt am: +- Name des Verbrauchers: +- Anschrift des Verbrauchers: +- Kundenkürzel oder Vertragsreferenz: +- Datum: +- Unterschrift (nur bei Mitteilung auf Papier): + +Stand: 22. August 2026 diff --git a/login.php b/login.php index 5b56d3f..9e2353c 100644 --- a/login.php +++ b/login.php @@ -115,7 +115,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { Impressum AGB Datenschutz + AVV + Widerrufsbelehrung + Vertrag widerrufen Preise + Verträge hier kündigen diff --git a/mandant-einstellungen.php b/mandant-einstellungen.php index e1915ea..82e9beb 100644 --- a/mandant-einstellungen.php +++ b/mandant-einstellungen.php @@ -67,6 +67,7 @@ if ($canManageSettings) { $errors = $result['errors']; $settings = [ 'name' => $_POST['tenant_name'] ?? '', + 'customer_type' => $_POST['customer_type'] ?? 'unknown', 'timezone' => $_POST['timezone'] ?? 'Europe/Berlin', 'locale' => $_POST['locale'] ?? 'de-DE', 'currency_code' => $_POST['currency_code'] ?? 'EUR', @@ -137,6 +138,14 @@ include 'nav.php';
+
+ + +
@@ -299,12 +308,20 @@ $watermarkAn = $watermarkErzwungen || (int)($settings['watermark_enabled'] ?? 1)

Aktueller Tarif:
Aktive Teilnehmer: + +
Vertrag gekündigt: Der Zugang endet am Uhr. Bitte lade vorher den Datenexport herunter. + +
Gekündigt: Der kostenpflichtige Tarif endet . Danach gilt der kostenlose Tarif. +

Danke! Die Änderung wird verarbeitet, der Tarif ist gleich aktuell.

Der Bezahlvorgang wurde abgebrochen, es wurde nichts geändert.

+ +

Die Tarifänderung wurde ausgeführt, aber die Vertragsbestätigung konnte nicht per E-Mail versendet werden. Bitte speichere die AGB und bei privater Nutzung die Widerrufsbelehrung und melde dich über das Ticketsystem.

+
@@ -344,15 +361,15 @@ $watermarkAn = $watermarkErzwungen || (int)($settings['watermark_enabled'] ?? 1) - Anfragen + Anfragen -
+ @@ -360,8 +377,7 @@ $watermarkAn = $watermarkErzwungen || (int)($settings['watermark_enabled'] ?? 1)
Kunde / Ansehen
-

Ein Wechsel zwischen bezahlten Tarifen wird bei Stripe anteilig verrechnet (Proration) und - gilt sofort. Details zu den Tarifgrenzen stehen unter Preise.

+

Bei einem Wechsel zwischen bezahlten Tarifen gilt die neue Teilnehmergrenze sofort; der neue Monatspreis wird ab der nächsten Verlängerung berechnet. Die Kündigung zum kostenlosen Tarif wirkt zum Ende der laufenden Abrechnungsperiode. Details stehen unter Preise.

diff --git a/mandant-loeschen.php b/mandant-loeschen.php index d38d49f..220b59a 100644 --- a/mandant-loeschen.php +++ b/mandant-loeschen.php @@ -2,6 +2,8 @@ require_once __DIR__ . '/functions.php'; require_once __DIR__ . '/app/ledger.php'; +require_once __DIR__ . '/app/billing.php'; +require_once __DIR__ . '/app/tenant-logo.php'; $pdo = app_db_pdo(); $user = saas_require_login(); @@ -13,25 +15,63 @@ if (!saas_user_has_role('owner', $user)) { $tenantId = (int)$user['tenant_id']; $isDefaultTenant = ledger_is_default_tenant($pdo, $tenantId); +$billing = billing_fetch_or_init($pdo, $tenantId); +$hasRunningPaidSubscription = (string)$billing['plan_code'] !== 'free' + && (string)$billing['subscription_status'] !== 'canceled'; $errors = []; $confirmInput = ''; -if (!$isDefaultTenant && $_SERVER['REQUEST_METHOD'] === 'POST') { +if (!$isDefaultTenant && !$hasRunningPaidSubscription && $_SERVER['REQUEST_METHOD'] === 'POST') { app_require_csrf(); $confirmInput = trim((string)($_POST['confirm_slug'] ?? '')); if ($confirmInput !== $user['tenant_slug']) { $errors[] = 'Bitte das Kundenkürzel exakt eingeben, um die Löschung zu bestätigen.'; } else { - // Loeschen der tenants-Zeile kaskadiert per FK auf alles - // tenant-scoped (participants, ledger_entries, notices, - // tenant_memberships, audit_log, outbound_emails, - // payment_import_batches/rows, tenant_settings). users bleiben - // erhalten, da ein Login-Konto zu mehreren Mandanten gehoeren kann. - $pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]); - saas_logout(); - header('Location: landing.php?tenant_deleted=1'); - exit; + $settings = saas_fetch_tenant_settings($pdo, $tenantId) ?? []; + $logoFiles = [ + (string)($settings['brand_logo'] ?? ''), + (string)($settings['pdf_watermark_logo'] ?? ''), + ]; + $memberStmt = $pdo->prepare('SELECT user_id FROM tenant_memberships WHERE tenant_id = ?'); + $memberStmt->execute([$tenantId]); + $userIds = array_map('intval', $memberStmt->fetchAll(PDO::FETCH_COLUMN)); + try { + $pdo->beginTransaction(); + // Tenant-bezogene Tabellen werden über ihre Fremdschlüssel + // kaskadierend gelöscht. Rechtserklärungen bleiben entkoppelt als + // notwendiger Vertragsnachweis erhalten. + $pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]); + + foreach ($userIds as $userId) { + $stmt = $pdo->prepare('SELECT COUNT(*) FROM tenant_memberships WHERE user_id = ?'); + $stmt->execute([$userId]); + $membershipCount = (int)$stmt->fetchColumn(); + $stmt = $pdo->prepare('SELECT COUNT(*) FROM platform_admins WHERE user_id = ?'); + $stmt->execute([$userId]); + $isPlatformAdmin = (int)$stmt->fetchColumn() > 0; + if ($membershipCount === 0 && !$isPlatformAdmin) { + $pdo->prepare('DELETE FROM users WHERE id = ?')->execute([$userId]); + } + } + $pdo->commit(); + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + $errors[] = 'Die Löschung konnte nicht vollständig ausgeführt werden. Bitte versuche es erneut oder nutze das Ticketsystem.'; + } + + if ($errors !== []) { + // Das Formular mit Fehlermeldung erneut anzeigen. + } else { + foreach ($logoFiles as $logoFile) { + tenant_logo_delete($logoFile); + } + saas_logout(); + header('Location: landing.php?tenant_deleted=1'); + exit; + } } } @@ -50,6 +90,14 @@ include 'nav.php'; selbstständig gelöscht werden. Bitte wende dich für diesen Fall an den Betreiber.

+ +
+

Der Mandant hat noch einen laufenden kostenpflichtigen Tarif. Kündige + ihn zuerst unter Mandant-Einstellungen. + Der Tarif bleibt bis zum Ende der bereits bezahlten Periode nutzbar; + anschließend kannst du den Mandanten und seine operativen Daten hier + endgültig löschen.

+

Diese Aktion kann nicht rückgängig gemacht werden. Alle Daten von diff --git a/passwort-vergessen.php b/passwort-vergessen.php index f393a71..a429a64 100644 --- a/passwort-vergessen.php +++ b/passwort-vergessen.php @@ -112,7 +112,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { Impressum AGB Datenschutz + AVV + Widerrufsbelehrung + Vertrag widerrufen Preise + Verträge hier kündigen diff --git a/passwort-zuruecksetzen.php b/passwort-zuruecksetzen.php index b30f357..bc2d160 100644 --- a/passwort-zuruecksetzen.php +++ b/passwort-zuruecksetzen.php @@ -105,7 +105,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { Impressum AGB Datenschutz + AVV + Widerrufsbelehrung + Vertrag widerrufen Preise + Verträge hier kündigen diff --git a/preise.php b/preise.php index be6811c..2ca74a4 100644 --- a/preise.php +++ b/preise.php @@ -1,4 +1,4 @@ - + @@ -52,9 +52,17 @@ -

Beim Überschreiten einer Stufe wird automatisch zur nächsthöheren - gewechselt. Gemäß § 19 UStG wird keine Umsatzsteuer berechnet – die - genannten Beträge sind die tatsächlich zu zahlenden Endpreise.

+

Es gibt keine automatische Hochstufung. Ist die Grenze erreicht, + wählst du selbst einen passenden Tarif oder deaktivierst Teilnehmer. + Gemäß § 19 UStG wird keine Umsatzsteuer berechnet – die genannten + Beträge sind die tatsächlich zu zahlenden monatlichen Endpreise.

+ +

Alle Tarife laufen unbefristet und haben eine monatliche + Abrechnungsperiode. Kostenpflichtige Tarife sind jederzeit zum Ende der + laufenden Periode kündbar; es gibt keine längere Mindestlaufzeit. Zahlung + erfolgt monatlich im Voraus über Stripe. Beim Tarifwechsel gilt die neue + Teilnehmergrenze sofort und der neue volle Preis ab der nächsten + Verlängerung.

- + diff --git a/rechtserklaerung-bearbeiten.php b/rechtserklaerung-bearbeiten.php new file mode 100644 index 0000000..2209635 --- /dev/null +++ b/rechtserklaerung-bearbeiten.php @@ -0,0 +1,40 @@ +prepare('SELECT tenant_id, reference_code FROM legal_requests WHERE id = ? LIMIT 1'); +$stmt->execute([$requestId]); +$request = $stmt->fetch(); +if ($request === false) { + http_response_code(404); + exit('Rechtserklärung nicht gefunden.'); +} +$pdo->prepare("UPDATE legal_requests SET status = 'processed', processed_at = NOW() WHERE id = ?") + ->execute([$requestId]); +app_audit_log( + $pdo, + $request['tenant_id'] !== null ? (int)$request['tenant_id'] : null, + (int)$user['user_id'], + 'legal_request.processed', + 'legal_request', + (int)$requestId, + ['reference_code' => (string)$request['reference_code']] +); + +header('Location: backoffice.php?legal_request=processed'); +exit; diff --git a/rechtserklaerung-bestaetigung.php b/rechtserklaerung-bestaetigung.php new file mode 100644 index 0000000..d89690d --- /dev/null +++ b/rechtserklaerung-bestaetigung.php @@ -0,0 +1,38 @@ + $request['request_type'], + 'requester_name' => $request['requester_name'], + 'requester_email' => $request['requester_email'], + 'contract_reference' => $request['contract_reference'], + 'request_reason' => $request['request_reason'], + 'cancellation_kind' => is_array($requestMetadata) && ($requestMetadata['cancellation_kind'] ?? null) === 'extraordinary' ? 'extraordinary' : 'ordinary', +]; +$processing = [ + 'effective_end' => is_array($requestMetadata) && !empty($requestMetadata['effective_end']) + ? (string)$requestMetadata['effective_end'] + : ($request['requested_end'] === 'earliest' ? 'zum frühestmöglichen Zeitpunkt' : (string)($request['requested_end'] ?? '')), + 'processing_note' => 'Die Erklärung wurde unter dem angegebenen Zeitpunkt gespeichert.', +]; +$stored = ['reference_code' => $request['reference_code'], 'received_at' => $request['received_at']]; +$text = app_legal_request_confirmation_text($input, $stored, $processing); + +if (!headers_sent()) { + header('Referrer-Policy: no-referrer'); + header('Content-Type: text/plain; charset=UTF-8'); + if (isset($_GET['download'])) { + header('Content-Disposition: attachment; filename="kaffeeliste-bestaetigung-' . $request['reference_code'] . '.txt"'); + } +} +echo $text; diff --git a/register.php b/register.php index 98178c7..39b811a 100644 --- a/register.php +++ b/register.php @@ -3,6 +3,7 @@ require_once __DIR__ . '/functions.php'; require_once __DIR__ . '/app/saas-mail.php'; require_once __DIR__ . '/app/rate-limit.php'; +require_once __DIR__ . '/app/legal.php'; $pdo = app_db_pdo(); $errors = []; @@ -11,6 +12,7 @@ $values = [ 'tenant_slug' => trim((string)($_POST['tenant_slug'] ?? '')), 'display_name' => trim((string)($_POST['display_name'] ?? '')), 'email' => trim((string)($_POST['email'] ?? '')), + 'customer_type' => (string)($_POST['customer_type'] ?? ''), ]; if ($_SERVER['REQUEST_METHOD'] === 'POST') { @@ -26,13 +28,30 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 'email' => $values['email'], 'password' => (string)($_POST['password'] ?? ''), 'password_confirm' => (string)($_POST['password_confirm'] ?? ''), + 'customer_type' => $values['customer_type'], + 'accept_terms' => !empty($_POST['accept_terms']), + 'acknowledge_privacy' => !empty($_POST['acknowledge_privacy']), + 'accept_dpa' => !empty($_POST['accept_dpa']), ]); if ($result['ok']) { + $contractMail = saas_send_registration_contract_mail( + $values['email'], + $values['tenant_name'], + (string)$result['identity']['tenant_slug'], + $values['customer_type'] + ); + $verificationMail = ['ok' => true]; if (!empty($result['email_verification_token'])) { - saas_send_email_verification_mail($values['email'], (string)$result['email_verification_token']); + $verificationMail = saas_send_email_verification_mail($values['email'], (string)$result['email_verification_token']); } saas_session_login($result['identity']); + if (empty($contractMail['ok'])) { + $_SESSION['saas_contract_mail_failed'] = true; + } + if (empty($verificationMail['ok'])) { + $_SESSION['saas_verification_mail_failed'] = true; + } if (saas_should_show_auth_links() && !empty($result['email_verification_token'])) { $_SESSION['saas_dev_email_verification_token'] = (string)$result['email_verification_token']; } @@ -68,7 +87,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {

Registrierung

-

Lege einen neuen Kundenbereich an. Danach bist du als Owner angemeldet und kannst die Kaffeeliste für dein Team einrichten.

+

Lege kostenlos einen neuen Kundenbereich an. Das Angebot kann privat oder geschäftlich genutzt werden.

@@ -85,6 +104,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+
+
+ Du registrierst dich als + required> + + required> + +
+
@@ -109,9 +137,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+
+ + +
+
+ + +
+
+ + +
+

Das Kundenkonto ist kostenlos. Verbraucher haben ein 14-tägiges Widerrufsrecht.

    -
  • +
  • Zum Login
@@ -124,7 +165,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { Impressum AGB Datenschutz + AVV + Widerrufsbelehrung + Vertrag widerrufen Preise + Verträge hier kündigen diff --git a/robots.txt b/robots.txt index f258867..12445eb 100644 --- a/robots.txt +++ b/robots.txt @@ -5,6 +5,11 @@ Allow: /preise.php Allow: /impressum.php Allow: /agb.php Allow: /datenschutz.php +Allow: /avv.php +Allow: /widerruf.php +Allow: /widerrufen.php +Allow: /kuendigen.php +Allow: /legal/ Disallow: /index.php Disallow: /login.php Disallow: /register.php diff --git a/scripts/check-legal-compliance-flow.php b/scripts/check-legal-compliance-flow.php new file mode 100644 index 0000000..cdef3fc --- /dev/null +++ b/scripts/check-legal-compliance-flow.php @@ -0,0 +1,154 @@ + '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"; diff --git a/scripts/check-m3-auth-flow.php b/scripts/check-m3-auth-flow.php index d52061f..36f241a 100644 --- a/scripts/check-m3-auth-flow.php +++ b/scripts/check-m3-auth-flow.php @@ -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); diff --git a/scripts/check-m3-password-email-flow.php b/scripts/check-m3-password-email-flow.php index b3b9b9d..ab4947a 100644 --- a/scripts/check-m3-password-email-flow.php +++ b/scripts/check-m3-password-email-flow.php @@ -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); diff --git a/scripts/check-m3-settings-flow.php b/scripts/check-m3-settings-flow.php index 3956877..d301650 100644 --- a/scripts/check-m3-settings-flow.php +++ b/scripts/check-m3-settings-flow.php @@ -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', diff --git a/scripts/check-m3-tenant-resolution-flow.php b/scripts/check-m3-tenant-resolution-flow.php index 0bd4f6e..845c99b 100644 --- a/scripts/check-m3-tenant-resolution-flow.php +++ b/scripts/check-m3-tenant-resolution-flow.php @@ -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); diff --git a/scripts/check-m8-role-matrix.php b/scripts/check-m8-role-matrix.php index 89e5c7c..e445ecc 100644 --- a/scripts/check-m8-role-matrix.php +++ b/scripts/check-m8-role-matrix.php @@ -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"; diff --git a/scripts/check-production-readiness.php b/scripts/check-production-readiness.php new file mode 100644 index 0000000..9b7d4dc --- /dev/null +++ b/scripts/check-production-readiness.php @@ -0,0 +1,99 @@ +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"; diff --git a/scripts/http-smoke.php b/scripts/http-smoke.php index 288ecbd..df72635 100644 --- a/scripts/http-smoke.php +++ b/scripts/http-smoke.php @@ -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"; diff --git a/scripts/purge-ended-contracts.php b/scripts/purge-ended-contracts.php new file mode 100644 index 0000000..8b0a688 --- /dev/null +++ b/scripts/purge-ended-contracts.php @@ -0,0 +1,70 @@ +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); diff --git a/scripts/purge-expired-operational-data.php b/scripts/purge-expired-operational-data.php new file mode 100644 index 0000000..8b6ddbc --- /dev/null +++ b/scripts/purge-expired-operational-data.php @@ -0,0 +1,39 @@ + ['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); diff --git a/sitemap.php b/sitemap.php index 8c16adc..56935b9 100644 --- a/sitemap.php +++ b/sitemap.php @@ -8,7 +8,17 @@ $scheme = app_is_https() ? 'https' : 'http'; $host = (string)($_SERVER['HTTP_HOST'] ?? ''); $base = $host !== '' ? $scheme . '://' . $host : ''; -$pages = ['', 'preise.php', 'impressum.php', 'agb.php', 'datenschutz.php']; +$pages = [ + '', + 'preise.php', + 'impressum.php', + 'agb.php', + 'datenschutz.php', + 'avv.php', + 'widerruf.php', + 'widerrufen.php', + 'kuendigen.php', +]; header('Content-Type: application/xml; charset=utf-8'); echo '' . "\n"; diff --git a/stripe-webhook.php b/stripe-webhook.php index 1e3b815..a8c3fd1 100644 --- a/stripe-webhook.php +++ b/stripe-webhook.php @@ -6,6 +6,7 @@ require_once __DIR__ . '/app/database.php'; require_once __DIR__ . '/app/billing.php'; require_once __DIR__ . '/app/audit.php'; require_once __DIR__ . '/app/dolibarr.php'; +require_once __DIR__ . '/app/saas-mail.php'; // Kein app_require_csrf()/saas_require_login(): Stripe ruft diesen // Endpunkt unauthentifiziert von aussen auf. Die Echtheit wird @@ -55,6 +56,12 @@ switch ($event['type']) { 'stripe_subscription_id' => (string)$object['subscription'], ]); app_audit_log($pdo, $tenantId, null, 'billing.subscription_started', 'tenant', $tenantId, ['plan_code' => $planCode]); + $confirmation = saas_send_paid_contract_confirmation($pdo, $tenantId, $planCode); + if (!$confirmation['ok']) { + app_audit_log($pdo, $tenantId, null, 'billing.contract_confirmation_failed', 'tenant', $tenantId, [ + 'error' => $confirmation['error'] ?? 'unknown', + ]); + } } break; @@ -63,6 +70,9 @@ switch ($event['type']) { $tenantId ??= billing_find_tenant_id_by_stripe_subscription($pdo, (string)($object['id'] ?? '')); if ($tenantId !== null) { $status = (string)($object['status'] ?? 'active'); + if (!empty($object['cancel_at_period_end']) && in_array($status, ['active', 'trialing', 'past_due'], true)) { + $status = 'canceling'; + } $periodEnd = isset($object['current_period_end']) ? date('Y-m-d H:i:s', (int)$object['current_period_end']) : null; @@ -77,7 +87,7 @@ switch ($event['type']) { // eindeutiger Zuordnung, sonst bleibt der bisherige Plan bestehen. $lookupKey = (string)($object['items']['data'][0]['price']['lookup_key'] ?? ''); $mappedPlan = billing_plan_code_by_lookup_key($lookupKey); - if ($mappedPlan !== null && in_array($status, ['active', 'trialing', 'past_due'], true)) { + if ($mappedPlan !== null && in_array($status, ['active', 'trialing', 'past_due', 'canceling'], true)) { $updateFields['plan_code'] = $mappedPlan; } diff --git a/widerruf.php b/widerruf.php new file mode 100644 index 0000000..b26d2ad --- /dev/null +++ b/widerruf.php @@ -0,0 +1,34 @@ + + + + + Widerrufsbelehrung – Kaffeeliste + + + + + + + + + + + + + + + diff --git a/widerrufen.php b/widerrufen.php new file mode 100644 index 0000000..04dbd3a --- /dev/null +++ b/widerrufen.php @@ -0,0 +1,121 @@ + htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8'); +$stage = (string)($_POST['stage'] ?? 'form'); +$values = [ + 'requester_name' => trim((string)($_POST['requester_name'] ?? '')), + 'requester_email' => trim((string)($_POST['requester_email'] ?? '')), + 'contract_reference' => strtolower(trim((string)($_POST['contract_reference'] ?? ''))), + 'request_reason' => trim((string)($_POST['request_reason'] ?? '')), +]; +$errors = []; +$stored = null; +$processing = null; +$mailStatus = null; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + app_require_csrf(); + if ($stage === 'form') { + // Die Werte aus der Prüfseite werden wieder im Formular angezeigt. + } else { + $errors = app_validate_legal_request($values, 'withdrawal'); + } + if ($stage !== 'form' && $errors !== []) { + $stage = 'form'; + } elseif ($stage === 'submit') { + if (!app_rate_limit_check($pdo, 'legal_request_ip:' . app_client_ip(), 5, 3600)) { + $errors[] = 'Zu viele Erklärungen von diesem Anschluss. Bitte nutze alternativ E-Mail oder das Ticketsystem.'; + $stage = 'form'; + } else { + $input = $values + ['request_type' => 'withdrawal']; + $stored = app_store_legal_request($pdo, $input); + $processing = app_process_withdrawal_request($pdo, $stored); + $mailStatus = app_send_legal_request_confirmations($input, $stored, $processing); + $stage = 'complete'; + } + } elseif ($stage !== 'form') { + $stage = 'review'; + } +} +?> + + + + Vertrag widerrufen – Kaffeeliste + + + + + + +
+ +
+ + + + + + + +