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, string $soapAction = ''): array { if ($soapAction === '') { $soapAction = self::SOAP_ACTION_GET_TICKET_COMMUNICATION_TEXT; } $headers = [ 'Content-Type: text/xml; charset=utf-8', 'Accept: text/xml,application/xml', 'SOAPAction: "' . $soapAction . '"', '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, ]; } /** * Download a ticket file attachment via SOAP GetTicketFile. * * Returns the decoded binary data or an error. Does not write to disk. * * @return array{ok: bool, data?: string, filename?: string, error?: string} */ public function getTicketFile(int $entryNo, string $contactNo = ''): array { if ($entryNo <= 0) { return ['ok' => false, 'error' => 'Invalid entry number']; } if (!$this->settingsGateway->isConfigured()) { return ['ok' => false, 'error' => 'BC connection not configured']; } $endpoint = $this->buildSoapEndpoint(); if ($endpoint === null) { return ['ok' => false, 'error' => 'Could not derive SOAP endpoint']; } $requestBody = $this->buildGetTicketFileEnvelope($entryNo, $contactNo); $request = $this->buildRequestContext($endpoint, $requestBody, self::SOAP_ACTION_GET_TICKET_FILE); if (isset($request['error'])) { return ['ok' => false, 'error' => (string) $request['error']]; } $response = $this->sendSoapRequest( $endpoint, self::SOAP_ACTION_GET_TICKET_FILE, $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) { // Try to extract SOAP fault from error response body $faultDetail = $this->extractSoapFault($rawBody); return ['ok' => false, 'error' => 'SOAP request failed: HTTP ' . $httpCode . ($faultDetail !== '' ? ' — ' . $faultDetail : '')]; } return $this->parseGetTicketFileResponse($rawBody); } private function buildGetTicketFileEnvelope(int $entryNo, string $contactNo): string { $escapedContactNo = htmlspecialchars($contactNo, ENT_XML1 | ENT_QUOTES, 'UTF-8'); // BC signature: GetTicketFile(TicketNo, EntryNo, ContactNo, ICISupportFilePort) // The old monolith omitted ticketNo — BC resolves it from EntryNo. return << {$entryNo} {$escapedContactNo} XML; } /** * @return array{ok: bool, data?: string, filename?: string, error?: string} */ private function parseGetTicketFileResponse(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' => 'SOAP fault: ' . trim((string) $faultNode->textContent)]; } $returnNode = $xpath->query("//*[local-name()='return_value']")->item(0); $returnValue = ($returnNode instanceof \DOMNode && is_numeric(trim((string) $returnNode->textContent))) ? (int) trim((string) $returnNode->textContent) : 0; if ($returnValue !== 100) { return ['ok' => false, 'error' => 'BC returned error code ' . $returnValue]; } // Collect Base64 parts $partNodes = $xpath->query("//*[local-name()='PartData']"); $base64 = ''; if ($partNodes !== false) { foreach ($partNodes as $node) { $base64 .= trim((string) $node->textContent); } } if ($base64 === '') { return ['ok' => false, 'error' => 'No file data in response']; } // Handle data URI prefix (data:application/octet-stream;base64,...) if (str_contains($base64, ',')) { $base64 = substr($base64, strpos($base64, ',') + 1); } $decoded = base64_decode($base64, true); if ($decoded === false) { return ['ok' => false, 'error' => 'Base64 decode failed']; } return ['ok' => true, 'data' => $decoded]; } private function extractSoapFault(string $body): string { if ($body === '') { return ''; } $dom = new \DOMDocument(); $prev = libxml_use_internal_errors(true); $loaded = $dom->loadXML($body); libxml_clear_errors(); libxml_use_internal_errors($prev); if (!$loaded) { return mb_substr(trim($body), 0, 200); } $xpath = new \DOMXPath($dom); $faultNode = $xpath->query("//*[local-name()='Fault']/*[local-name()='faultstring']")->item(0); if ($faultNode instanceof \DOMNode) { return trim((string) $faultNode->textContent); } return mb_substr(trim($body), 0, 200); } 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; } }