Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/Config/EnvironmentConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
7 changes: 7 additions & 0 deletions src/Contracts/NfseClientInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
15 changes: 15 additions & 0 deletions src/Exception/ArtifactException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

declare(strict_types=1);

namespace LibreCodeCoop\NfsePHP\Exception;

/**
* Thrown when the ADN gateway returns an error during DANFSE artifact retrieval.
*/
class ArtifactException extends GatewayException
{
}
3 changes: 3 additions & 0 deletions src/Exception/NfseErrorCode.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
64 changes: 61 additions & 3 deletions src/Http/NfseClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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
// -------------------------------------------------------------------------
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down
22 changes: 22 additions & 0 deletions tests/Unit/Config/EnvironmentConfigTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
55 changes: 54 additions & 1 deletion tests/Unit/Http/NfseClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down
Loading