false, 'soap_used' => false, 'return_value' => 0, 'communication_text' => '', 'message_entries' => '', 'error' => 'Missing ticket number', ]; } if (!$this->settingsGateway->isConfigured()) { return [ 'ok' => false, 'soap_used' => false, 'return_value' => 0, 'communication_text' => '', 'message_entries' => '', 'error' => 'BC connection not configured', ]; } $endpoint = $this->buildSoapEndpoint(); if ($endpoint === null) { return [ 'ok' => false, 'soap_used' => false, 'return_value' => 0, 'communication_text' => '', 'message_entries' => '', 'error' => 'Could not derive SOAP endpoint from BC URL', ]; } $requestBody = $this->buildGetTicketCommunicationTextEnvelope($ticketNo, $messageEntries); $request = $this->buildRequestContext($endpoint, $requestBody); if (isset($request['error'])) { return [ 'ok' => false, 'soap_used' => false, 'return_value' => 0, 'communication_text' => '', 'message_entries' => '', 'error' => (string) $request['error'], ]; } $response = $this->sendSoapRequest( $endpoint, self::SOAP_ACTION_GET_TICKET_COMMUNICATION_TEXT, $requestBody, (array) ($request['headers'] ?? []), (string) ($request['basic_user'] ?? ''), (string) ($request['basic_password'] ?? '') ); $httpCode = (int) ($response['http_code'] ?? 0); $rawBody = (string) ($response['body'] ?? ''); if ($httpCode < 200 || $httpCode >= 300) { $errorSnippet = trim((string) ($response['error'] ?? '')); if ($errorSnippet === '' && $rawBody !== '') { $errorSnippet = mb_substr($rawBody, 0, 250); } return [ 'ok' => false, 'soap_used' => false, 'return_value' => 0, 'communication_text' => '', 'message_entries' => '', 'error' => 'BC SOAP request failed: HTTP ' . $httpCode . ($errorSnippet !== '' ? ' - ' . $errorSnippet : ''), ]; } $parsed = $this->parseGetTicketCommunicationTextResponse($rawBody); if (!$parsed['ok']) { return [ 'ok' => false, 'soap_used' => false, 'return_value' => 0, 'communication_text' => '', 'message_entries' => '', 'error' => isset($parsed['error']) ? (string) $parsed['error'] : 'Could not parse SOAP response', ]; } $returnValue = (int) ($parsed['return_value'] ?? 0); if ($returnValue >= 400) { return [ 'ok' => false, 'soap_used' => false, 'return_value' => $returnValue, 'communication_text' => (string) ($parsed['communication_text'] ?? ''), 'message_entries' => (string) ($parsed['message_entries'] ?? ''), 'error' => 'BC SOAP returned error code ' . $returnValue, ]; } return [ 'ok' => true, 'soap_used' => true, 'return_value' => $returnValue, 'communication_text' => (string) ($parsed['communication_text'] ?? ''), 'message_entries' => (string) ($parsed['message_entries'] ?? ''), ]; } private function buildGetTicketCommunicationTextEnvelope(string $ticketNo, string $messageEntries): string { $escapedTicketNo = htmlspecialchars($ticketNo, ENT_XML1 | ENT_QUOTES, 'UTF-8'); $normalizedMessageEntries = trim($messageEntries); if ($normalizedMessageEntries === '') { $normalizedMessageEntries = '0'; } $escapedMessageEntries = htmlspecialchars($normalizedMessageEntries, ENT_XML1 | ENT_QUOTES, 'UTF-8'); return << {$escapedTicketNo} {$escapedMessageEntries} XML; } /** * @return array{headers?: array, basic_user?: string, basic_password?: string, error?: string} */ private function buildRequestContext(string $endpoint, string $requestBody): array { $headers = [ 'Content-Type: text/xml; charset=utf-8', 'Accept: text/xml,application/xml', 'SOAPAction: "' . self::SOAP_ACTION_GET_TICKET_COMMUNICATION_TEXT . '"', 'Content-Length: ' . strlen($requestBody), ]; $authMode = $this->settingsGateway->getAuthMode(); if ($authMode === HelpdeskSettingsGateway::AUTH_MODE_BASIC) { return [ 'headers' => $headers, 'basic_user' => $this->settingsGateway->getBasicUser() ?? '', 'basic_password' => $this->settingsGateway->getBasicPassword() ?? '', ]; } if ($authMode !== HelpdeskSettingsGateway::AUTH_MODE_OAUTH2) { return ['headers' => $headers]; } $tenantId = $this->resolveCurrentTenantId(); if ($tenantId <= 0) { return ['error' => 'OAuth2 mode requires active tenant context']; } $audience = $this->buildOAuthAudienceFromEndpoint($endpoint); if ($audience === null) { return ['error' => 'Could not build OAuth audience for SOAP endpoint']; } $token = $this->oauthTokenService->getAccessToken($tenantId, $audience); if ($token === null || trim($token) === '') { return ['error' => 'Could not obtain OAuth2 access token for SOAP request']; } $headers[] = 'Authorization: Bearer ' . $token; return ['headers' => $headers]; } /** * @return array{ok: bool, return_value?: int, communication_text?: string, message_entries?: string, error?: string} */ private function parseGetTicketCommunicationTextResponse(string $body): array { $body = trim($body); if ($body === '') { return ['ok' => false, 'error' => 'Empty SOAP response']; } $dom = new \DOMDocument(); $previousLibxmlState = libxml_use_internal_errors(true); $loaded = $dom->loadXML($body); libxml_clear_errors(); libxml_use_internal_errors($previousLibxmlState); if (!$loaded) { return ['ok' => false, 'error' => 'Invalid SOAP XML response']; } $xpath = new \DOMXPath($dom); $faultNode = $xpath->query("//*[local-name()='Fault']/*[local-name()='faultstring']")->item(0); if ($faultNode instanceof \DOMNode) { return ['ok' => false, 'error' => trim((string) $faultNode->textContent) ?: 'SOAP fault']; } $returnNode = $xpath->query("//*[local-name()='return_value']")->item(0); $communicationNode = $xpath->query("//*[local-name()='communicationText']")->item(0); $messageEntriesNode = $xpath->query("//*[local-name()='messageEntries']")->item(0); return [ 'ok' => true, 'return_value' => $returnNode instanceof \DOMNode && is_numeric(trim((string) $returnNode->textContent)) ? (int) trim((string) $returnNode->textContent) : 0, 'communication_text' => $communicationNode instanceof \DOMNode ? (string) $communicationNode->textContent : '', 'message_entries' => $messageEntriesNode instanceof \DOMNode ? (string) $messageEntriesNode->textContent : '', ]; } protected function sendSoapRequest( string $endpoint, string $soapAction, string $requestBody, array $headers, string $basicUser, string $basicPassword ): array { $ch = curl_init(); if ($ch === false) { return ['http_code' => 0, 'body' => '', 'error' => 'curl_init failed']; } curl_setopt_array($ch, [ CURLOPT_URL => $endpoint, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT, CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $requestBody, CURLOPT_HTTPHEADER => $headers, ]); if ($basicUser !== '' || $basicPassword !== '') { curl_setopt($ch, CURLOPT_USERPWD, $basicUser . ':' . $basicPassword); curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); } $responseBody = curl_exec($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); unset($ch); return [ 'http_code' => $httpCode, 'body' => is_string($responseBody) ? $responseBody : '', 'error' => $error, ]; } private function buildSoapEndpoint(): ?string { $baseUrl = trim($this->settingsGateway->getODataBaseUrl()); if ($baseUrl === '') { return null; } $parsed = parse_url($baseUrl); if (!is_array($parsed)) { return null; } $scheme = strtolower((string) ($parsed['scheme'] ?? 'https')); $host = trim((string) ($parsed['host'] ?? '')); if ($host === '') { return null; } $odataPath = '/' . ltrim((string) ($parsed['path'] ?? ''), '/'); $instancePath = (string) preg_replace('#/ODataV4(?:/.*)?$#i', '', $odataPath); $instancePath = rtrim($instancePath !== '' ? $instancePath : '/BusinessCentral', '/'); if ($instancePath === '') { $instancePath = '/BusinessCentral'; } $odataPort = (int) ($parsed['port'] ?? 0); $soapPort = $odataPort === 7048 ? 7047 : $odataPort; $authority = $host; if ($soapPort > 0 && !(($scheme === 'https' && $soapPort === 443) || ($scheme === 'http' && $soapPort === 80))) { $authority .= ':' . $soapPort; } $company = rawurlencode($this->settingsGateway->getCompanyName()); return $scheme . '://' . $authority . $instancePath . '/WS/' . $company . '/Codeunit/' . self::CODEUNIT_NAME; } private function buildOAuthAudienceFromEndpoint(string $endpoint): ?string { $parsed = parse_url($endpoint); if (!is_array($parsed)) { return null; } $scheme = strtolower((string) ($parsed['scheme'] ?? 'https')); $host = trim((string) ($parsed['host'] ?? '')); if ($host === '') { return null; } $port = (int) ($parsed['port'] ?? 0); $authority = $host; if ($port > 0 && !(($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80))) { $authority .= ':' . $port; } return $scheme . '://' . $authority; } private function resolveCurrentTenantId(): int { $session = $this->sessionStore->all(); $tenantId = (int) (($session['current_tenant']['id'] ?? 0)); return $tenantId > 0 ? $tenantId : 0; } }