> */ public function searchCustomers(string $query): array { $query = trim($query); if ($query === '') { return []; } $escaped = $this->escapeODataStrictUserInput($query); $upperQuery = mb_strtoupper($escaped); $select = '&$select=No,Name,Search_Name,Address,City,Post_Code,Phone_No,E_Mail'; $baseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER); $strategies = [ "contains(Search_Name,'" . $upperQuery . "') or contains(Name,'" . $escaped . "') or contains(No,'" . $escaped . "')", "startswith(Search_Name,'" . $upperQuery . "') or startswith(Name,'" . $escaped . "') or startswith(No,'" . $escaped . "')", "Search_Name ge '" . $upperQuery . "' and Search_Name lt '" . $upperQuery . "z'", ]; $lastException = null; foreach ($strategies as $filter) { $url = $baseUrl . '?$filter=' . rawurlencode($filter) . '&$top=50' . $select; try { $response = $this->request('GET', $url); if ($response !== null) { return $this->extractODataValues($response); } throw new \RuntimeException('BC OData request failed for URL: ' . $url); } catch (\RuntimeException $e) { $lastException = $e; continue; } } throw $lastException ?? new \RuntimeException('All OData search strategies failed'); } /** * Get active domains for a customer. * * @return array> */ public function listDomainsForCustomer(string $customerNo): array { $customerNo = trim($customerNo); if ($customerNo === '') { return []; } $escaped = $this->escapeODataStrictUserInput($customerNo); $filter = "Customer_No eq '" . $escaped . "' and State eq 'Aktiv'"; $select = 'No,Customer_No,URL,State'; $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_DOMAINS) . '?$filter=' . rawurlencode($filter) . '&$top=200' . '&$select=' . rawurlencode($select) . '&$orderby=' . rawurlencode('URL asc'); $response = $this->request('GET', $url); if ($response === null) { return []; } return $this->extractODataValues($response); } /** * Test the configured BC connection. * * @return array{ok: bool, error?: string, url?: string, http_code?: int} */ public function testConnection(): array { if (!$this->settingsGateway->isConfigured()) { return ['ok' => false, 'error' => 'Configuration incomplete']; } $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER) . '?$top=1&$select=No'; $ch = curl_init(); if ($ch === false) { return ['ok' => false, 'error' => 'curl_init failed']; } curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT, CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT, CURLOPT_HTTPHEADER => ['Accept: application/json', 'OData-Version: 4.0'], CURLOPT_CUSTOMREQUEST => 'GET', CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, ]); $this->applyAuth($ch); $responseBody = curl_exec($ch); $curlErrno = curl_errno($ch); $curlError = curl_error($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); if ($curlErrno !== 0) { return ['ok' => false, 'error' => 'cURL error ' . $curlErrno . ': ' . $curlError, 'url' => $url]; } if ($httpCode < 200 || $httpCode >= 300) { $hint = match ($httpCode) { 401 => 'Unauthorized — check credentials', 403 => 'Forbidden — user has no access to this endpoint', 404 => 'Not found — check OData URL and company name', default => 'HTTP ' . $httpCode, }; return ['ok' => false, 'error' => $hint, 'url' => $url, 'http_code' => $httpCode]; } if (!is_string($responseBody)) { return ['ok' => false, 'error' => 'Empty response body', 'url' => $url, 'http_code' => $httpCode]; } $decoded = json_decode($responseBody, true); if (!is_array($decoded)) { return ['ok' => false, 'error' => 'Invalid JSON response', 'url' => $url, 'http_code' => $httpCode]; } return ['ok' => true, 'url' => $url, 'http_code' => $httpCode]; } /** * @return array|null */ private function request(string $method, string $url): ?array { if (!$this->settingsGateway->isConfigured()) { return null; } $ch = curl_init(); if ($ch === false) { return null; } curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT, CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT, CURLOPT_HTTPHEADER => ['Accept: application/json', 'OData-Version: 4.0'], CURLOPT_CUSTOMREQUEST => $method, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, ]); $this->applyAuth($ch); $responseBody = curl_exec($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); unset($ch); if (!is_string($responseBody) || $httpCode < 200 || $httpCode >= 300) { if ($httpCode >= 400 && $httpCode < 500) { throw new \RuntimeException('BC OData client error: HTTP ' . $httpCode); } return null; } $decoded = json_decode($responseBody, true); if (!is_array($decoded)) { return null; } return $decoded; } /** * @param \CurlHandle $ch */ private function applyAuth($ch): void { $authMode = $this->settingsGateway->getAuthMode(); if ($authMode === SecurityBcSettingsGateway::AUTH_MODE_BASIC) { $user = $this->settingsGateway->getBasicUser() ?? ''; $password = $this->settingsGateway->getBasicPassword() ?? ''; curl_setopt($ch, CURLOPT_USERPWD, $user . ':' . $password); curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); return; } if ($authMode !== SecurityBcSettingsGateway::AUTH_MODE_OAUTH2) { return; } $tenantId = $this->resolveCurrentTenantId(); if ($tenantId <= 0) { return; } $token = $this->oauthTokenService->getAccessToken($tenantId, $this->buildOAuthAudience()); if ($token === null || trim($token) === '') { return; } curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Accept: application/json', 'OData-Version: 4.0', 'Authorization: Bearer ' . $token, ]); } /** * @param array $response * @return array> */ private function extractODataValues(array $response): array { $values = $response['value'] ?? []; if (!is_array($values)) { return []; } return array_values($values); } /** * @throws \InvalidArgumentException If the value contains disallowed characters. */ private function escapeODataStrictUserInput(string $value): string { if (preg_match('/^[\p{L}\p{N}\s.\-_@\/&,;:#+*]+$/u', $value) !== 1) { throw new \InvalidArgumentException('Search query contains invalid characters.'); } return str_replace("'", "''", $value); } private function resolveCurrentTenantId(): int { try { $session = $this->sessionStore->all(); } catch (\Throwable) { return 0; } $tenantId = (int) ($session['current_tenant']['id'] ?? 0); return $tenantId > 0 ? $tenantId : 0; } private function buildOAuthAudience(): ?string { $baseUrl = trim($this->settingsGateway->getODataBaseUrl()); if ($baseUrl === '') { return null; } $parsed = parse_url($baseUrl); if (!is_array($parsed) || empty($parsed['host'])) { return null; } $scheme = strtolower((string) ($parsed['scheme'] ?? 'https')); $host = (string) $parsed['host']; $port = isset($parsed['port']) ? ':' . (int) $parsed['port'] : ''; return $scheme . '://' . $host . $port; } }