From e8a922c54e6ac54290dc2c834e508cd169cc941c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:37:06 -0300 Subject: [PATCH 1/7] feat(exception): add ArtifactException for DANFSE retrieval errors Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Exception/ArtifactException.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/Exception/ArtifactException.php 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 @@ + Date: Mon, 13 Apr 2026 17:37:17 -0300 Subject: [PATCH 2/7] feat(exception): add ArtifactRetrievalFailed error code Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Exception/NfseErrorCode.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Exception/NfseErrorCode.php b/src/Exception/NfseErrorCode.php index a6ac94f..2c00563 100644 --- a/src/Exception/NfseErrorCode.php +++ b/src/Exception/NfseErrorCode.php @@ -29,4 +29,7 @@ enum NfseErrorCode: string /** Gateway returned an error when querying an NFS-e (HTTP 4xx/5xx). */ case QueryFailed = 'QUERY_FAILED'; + + /** ADN gateway returned an error when retrieving DANFSE artifact (HTTP 4xx/5xx). */ + case ArtifactRetrievalFailed = 'ARTIFACT_RETRIEVAL_FAILED'; } From bb2bdc80c1fb64fb9f5b5b0c24e3327e2f681e12 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:37:22 -0300 Subject: [PATCH 3/7] feat(config): add danfseBaseUrl to EnvironmentConfig Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Config/EnvironmentConfig.php | 7 +++++++ 1 file changed, 7 insertions(+) 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); } } From 4739fe7cf633ab6c0e40b5cef58c16fd3533881f Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:37:22 -0300 Subject: [PATCH 4/7] feat(contract): add getDanfse to NfseClientInterface Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Contracts/NfseClientInterface.php | 7 +++++++ 1 file changed, 7 insertions(+) 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; } From 55679c57e812a087b9faf3953a0306ef08c6cad4 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:37:22 -0300 Subject: [PATCH 5/7] feat(http): implement getDanfse via ADN DANFSE endpoint Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Http/NfseClient.php | 64 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 3 deletions(-) diff --git a/src/Http/NfseClient.php b/src/Http/NfseClient.php index 11ec0c0..775e828 100644 --- a/src/Http/NfseClient.php +++ b/src/Http/NfseClient.php @@ -14,6 +14,7 @@ use LibreCodeCoop\NfsePHP\Contracts\XmlSignerInterface; use LibreCodeCoop\NfsePHP\Dto\DpsData; use LibreCodeCoop\NfsePHP\Dto\ReceiptData; +use LibreCodeCoop\NfsePHP\Exception\ArtifactException; use LibreCodeCoop\NfsePHP\Exception\CancellationException; use LibreCodeCoop\NfsePHP\Exception\IssuanceException; use LibreCodeCoop\NfsePHP\Exception\NetworkException; @@ -80,7 +81,7 @@ public function query(string $chaveAcesso): ReceiptData public function cancel(string $chaveAcesso, string $motivo): bool { - $eventoXml = $this->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. * From 2583deec29cbc3e9dd0b3ed8f5dc0c95e153cda5 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:37:28 -0300 Subject: [PATCH 6/7] test(config): cover danfseBaseUrl defaults and override Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/Unit/Config/EnvironmentConfigTest.php | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) 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); + } } From 3a21e1957b75dabfba0ff802afaff97f3a7607f9 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:37:28 -0300 Subject: [PATCH 7/7] test(http): cover getDanfse success and error paths Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/Unit/Http/NfseClientTest.php | 55 +++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) 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',