' . '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 .= '
' . 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; }