diff --git a/src/Config/EnvironmentConfig.php b/src/Config/EnvironmentConfig.php index 81fb94e..461971c 100644 --- a/src/Config/EnvironmentConfig.php +++ b/src/Config/EnvironmentConfig.php @@ -20,15 +20,22 @@ { private const BASE_URL_PROD = 'https://sefin.nfse.gov.br/SefinNacional'; private const BASE_URL_SANDBOX = 'https://sefin.producaorestrita.nfse.gov.br/SefinNacional'; + private const DANFSE_BASE_URL_PROD = 'https://adn.nfse.gov.br/danfse'; + private const DANFSE_BASE_URL_SANDBOX = 'https://adn.producaorestrita.nfse.gov.br/danfse'; public string $baseUrl; + public string $danfseBaseUrl; public function __construct( public bool $sandboxMode = false, ?string $baseUrl = null, + ?string $danfseBaseUrl = null, ) { $this->baseUrl = $baseUrl ?? ($sandboxMode ? self::BASE_URL_SANDBOX : self::BASE_URL_PROD); + $this->danfseBaseUrl = $danfseBaseUrl ?? ($sandboxMode + ? self::DANFSE_BASE_URL_SANDBOX + : self::DANFSE_BASE_URL_PROD); } } diff --git a/src/Contracts/NfseClientInterface.php b/src/Contracts/NfseClientInterface.php index 8e7a071..b150419 100644 --- a/src/Contracts/NfseClientInterface.php +++ b/src/Contracts/NfseClientInterface.php @@ -26,4 +26,11 @@ public function query(string $chaveAcesso): ReceiptData; * Cancel an existing NFS-e. */ public function cancel(string $chaveAcesso, string $motivo): bool; + + /** + * Retrieve the DANFSE (PDF rendering document) for an NFS-e from ADN. + * + * Returns the raw PDF bytes as a string. + */ + public function getDanfse(string $chaveAcesso): string; } diff --git a/src/Exception/ArtifactException.php b/src/Exception/ArtifactException.php new file mode 100644 index 0000000..5a34be0 --- /dev/null +++ b/src/Exception/ArtifactException.php @@ -0,0 +1,15 @@ +buildCancelEventXml($chaveAcesso, $motivo); + $eventoXml = $this->buildCancelEventXml($chaveAcesso, $motivo); $signedEventoXml = $this->signer->sign($eventoXml, $this->cert->cnpj); $compressedEventoXml = gzencode($signedEventoXml); @@ -106,6 +107,34 @@ public function cancel(string $chaveAcesso, string $motivo): bool return true; } + #[\Override] + public function getDanfse(string $chaveAcesso): string + { + $url = $this->environment->danfseBaseUrl . '/' . $chaveAcesso; + + [$httpStatus, $body] = $this->getRawBytes($url); + + if ($httpStatus >= 400) { + throw new ArtifactException( + 'ADN gateway returned error for DANFSE retrieval (HTTP ' . $httpStatus . ')', + NfseErrorCode::ArtifactRetrievalFailed, + $httpStatus, + [], + ); + } + + if ($body === '') { + throw new ArtifactException( + 'ADN gateway returned empty body for DANFSE retrieval', + NfseErrorCode::ArtifactRetrievalFailed, + $httpStatus, + [], + ); + } + + return $body; + } + // ------------------------------------------------------------------------- // Internal HTTP helpers // ------------------------------------------------------------------------- @@ -211,13 +240,13 @@ private function buildCancelEventXml(string $chaveAcesso, string $motivo): strin private function sslContextOptions(): array { $options = [ - 'verify_peer' => true, + 'verify_peer' => true, 'verify_peer_name' => true, ]; if ($this->cert->transportCertificatePath !== null && $this->cert->transportPrivateKeyPath !== null) { $options['local_cert'] = $this->cert->transportCertificatePath; - $options['local_pk'] = $this->cert->transportPrivateKeyPath; + $options['local_pk'] = $this->cert->transportPrivateKeyPath; } return $options; @@ -257,6 +286,35 @@ private function fetchAndDecode(string $path, mixed $context): array return [$httpStatus, $decoded]; } + /** + * Fetch a URL and return raw response bytes without JSON decoding. + * + * Used for binary endpoints such as ADN DANFSE (PDF artifact retrieval). + * No mTLS is applied — DANFSE is accessible without client certificate. + * + * @return array{int, string} + */ + private function getRawBytes(string $url): array + { + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => "Accept: application/pdf\r\n", + 'ignore_errors' => true, + ], + ]); + + $http_response_header = []; + $body = file_get_contents($url, false, $context); + $httpStatus = $this->parseHttpStatus($http_response_header); + + if ($body === false) { + throw new NetworkException('Failed to connect to ADN DANFSE gateway at ' . $url); + } + + return [$httpStatus, $body]; + } + /** * Extract the HTTP status code from the first response header line. * diff --git a/tests/Unit/Config/EnvironmentConfigTest.php b/tests/Unit/Config/EnvironmentConfigTest.php index 7d5eba8..4f81bf4 100644 --- a/tests/Unit/Config/EnvironmentConfigTest.php +++ b/tests/Unit/Config/EnvironmentConfigTest.php @@ -53,4 +53,26 @@ public function testCustomBaseUrlOverridesSandboxUrl(): void self::assertSame($custom, $config->baseUrl); } + + public function testDanfseBaseUrlDefaultsToProductionAdn(): void + { + $config = new EnvironmentConfig(sandboxMode: false); + + self::assertSame('https://adn.nfse.gov.br/danfse', $config->danfseBaseUrl); + } + + public function testDanfseBaseUrlDefaultsToSandboxAdn(): void + { + $config = new EnvironmentConfig(sandboxMode: true); + + self::assertSame('https://adn.producaorestrita.nfse.gov.br/danfse', $config->danfseBaseUrl); + } + + public function testCustomDanfseBaseUrlOverridesDefault(): void + { + $custom = 'http://localhost:9999/danfse'; + $config = new EnvironmentConfig(danfseBaseUrl: $custom); + + self::assertSame($custom, $config->danfseBaseUrl); + } } diff --git a/tests/Unit/Http/NfseClientTest.php b/tests/Unit/Http/NfseClientTest.php index f3437d9..f35b793 100644 --- a/tests/Unit/Http/NfseClientTest.php +++ b/tests/Unit/Http/NfseClientTest.php @@ -13,6 +13,7 @@ use LibreCodeCoop\NfsePHP\Config\EnvironmentConfig; use LibreCodeCoop\NfsePHP\Contracts\XmlSignerInterface; use LibreCodeCoop\NfsePHP\Dto\DpsData; +use LibreCodeCoop\NfsePHP\Exception\ArtifactException; use LibreCodeCoop\NfsePHP\Exception\CancellationException; use LibreCodeCoop\NfsePHP\Exception\IssuanceException; use LibreCodeCoop\NfsePHP\Exception\NfseErrorCode; @@ -278,13 +279,65 @@ public function testCancellationExceptionCarriesErrorCodeAndHttpStatus(): void } } + // ------------------------------------------------------------------------- + // getDanfse tests + // ------------------------------------------------------------------------- + + public function testGetDanfseReturnsPdfBytesOnSuccess(): void + { + $fakePdfBytes = '%PDF-1.4 fake pdf content for testing'; + + self::$server->setResponseOfPath( + '/danfse/abc-danfse-key-123', + new Response($fakePdfBytes, ['Content-Type' => 'application/pdf'], 200) + ); + + $client = $this->makeClient($this->signer, danfseBaseUrl: self::$server->getServerRoot() . '/danfse'); + + $pdf = $client->getDanfse('abc-danfse-key-123'); + + self::assertSame($fakePdfBytes, $pdf); + } + + public function testGetDanfseThrowsArtifactExceptionWhenGatewayReturnsError(): void + { + self::$server->setResponseOfPath( + '/danfse/not-found-key', + new Response('not found', ['Content-Type' => 'text/plain'], 404) + ); + + $client = $this->makeClient($this->signer, danfseBaseUrl: self::$server->getServerRoot() . '/danfse'); + + $this->expectException(ArtifactException::class); + $client->getDanfse('not-found-key'); + } + + public function testArtifactExceptionCarriesErrorCodeAndHttpStatus(): void + { + self::$server->setResponseOfPath( + '/danfse/server-error-key', + new Response('internal error', ['Content-Type' => 'text/plain'], 500) + ); + + $client = $this->makeClient($this->signer, danfseBaseUrl: self::$server->getServerRoot() . '/danfse'); + + try { + $client->getDanfse('server-error-key'); + self::fail('Expected ArtifactException'); + } catch (ArtifactException $e) { + self::assertSame(NfseErrorCode::ArtifactRetrievalFailed, $e->errorCode); + self::assertSame(500, $e->httpStatus); + } + } + // ------------------------------------------------------------------------- - private function makeClient(?XmlSignerInterface $signer = null): NfseClient + private function makeClient(?XmlSignerInterface $signer = null, ?string $danfseBaseUrl = null): NfseClient { return new NfseClient( environment: new EnvironmentConfig( baseUrl: self::$server->getServerRoot() . '/SefinNacional', + danfseBaseUrl: $danfseBaseUrl ?? self::$server->getServerRoot() . '/danfse', ), cert: new CertConfig( cnpj: '29842527000145',