From 2dafd7967740db4336c66c1b001ab6315f595d57 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sat, 21 Mar 2026 23:47:42 -0300 Subject: [PATCH 1/8] feat(exceptions): add NfseErrorCode machine error code enum Signed-off-by: Vitor Mattos --- src/Exception/NfseErrorCode.php | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/Exception/NfseErrorCode.php diff --git a/src/Exception/NfseErrorCode.php b/src/Exception/NfseErrorCode.php new file mode 100644 index 0000000..a6ac94f --- /dev/null +++ b/src/Exception/NfseErrorCode.php @@ -0,0 +1,32 @@ + Date: Sat, 21 Mar 2026 23:47:42 -0300 Subject: [PATCH 2/8] feat(exceptions): add GatewayException for HTTP gateway errors Signed-off-by: Vitor Mattos --- src/Exception/GatewayException.php | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src/Exception/GatewayException.php diff --git a/src/Exception/GatewayException.php b/src/Exception/GatewayException.php new file mode 100644 index 0000000..345ff1a --- /dev/null +++ b/src/Exception/GatewayException.php @@ -0,0 +1,30 @@ + $upstreamPayload Raw decoded response body from the gateway. + */ + public function __construct( + string $message, + public readonly NfseErrorCode $errorCode, + public readonly int $httpStatus = 0, + public readonly array $upstreamPayload = [], + ?\Throwable $previous = null, + ) { + parent::__construct($message, 0, $previous); + } +} From d36b84dd4512d3559f184b7cb09e02dde2dd7f5d Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sat, 21 Mar 2026 23:47:42 -0300 Subject: [PATCH 3/8] feat(exceptions): add NetworkException for connectivity failures Signed-off-by: Vitor Mattos --- src/Exception/NetworkException.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/Exception/NetworkException.php diff --git a/src/Exception/NetworkException.php b/src/Exception/NetworkException.php new file mode 100644 index 0000000..c8f40bb --- /dev/null +++ b/src/Exception/NetworkException.php @@ -0,0 +1,23 @@ + Date: Sat, 21 Mar 2026 23:47:42 -0300 Subject: [PATCH 4/8] feat(exceptions): add IssuanceException for rejected issuance Signed-off-by: Vitor Mattos --- src/Exception/IssuanceException.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/Exception/IssuanceException.php diff --git a/src/Exception/IssuanceException.php b/src/Exception/IssuanceException.php new file mode 100644 index 0000000..ad060b7 --- /dev/null +++ b/src/Exception/IssuanceException.php @@ -0,0 +1,15 @@ + Date: Sat, 21 Mar 2026 23:47:42 -0300 Subject: [PATCH 5/8] feat(exceptions): add CancellationException for rejected cancellation Signed-off-by: Vitor Mattos --- src/Exception/CancellationException.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/Exception/CancellationException.php diff --git a/src/Exception/CancellationException.php b/src/Exception/CancellationException.php new file mode 100644 index 0000000..8d73a15 --- /dev/null +++ b/src/Exception/CancellationException.php @@ -0,0 +1,15 @@ + Date: Sat, 21 Mar 2026 23:47:42 -0300 Subject: [PATCH 6/8] feat(exceptions): add QueryException for failed NFS-e queries Signed-off-by: Vitor Mattos --- src/Exception/QueryException.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/Exception/QueryException.php diff --git a/src/Exception/QueryException.php b/src/Exception/QueryException.php new file mode 100644 index 0000000..5dc03ef --- /dev/null +++ b/src/Exception/QueryException.php @@ -0,0 +1,15 @@ + Date: Sat, 21 Mar 2026 23:47:47 -0300 Subject: [PATCH 7/8] refactor(client): throw typed exceptions based on HTTP gateway status Signed-off-by: Vitor Mattos --- src/Http/NfseClient.php | 109 ++++++++++++++++++++++++++++++++-------- 1 file changed, 87 insertions(+), 22 deletions(-) diff --git a/src/Http/NfseClient.php b/src/Http/NfseClient.php index 320a79d..9f86e71 100644 --- a/src/Http/NfseClient.php +++ b/src/Http/NfseClient.php @@ -12,7 +12,11 @@ use LibreCodeCoop\NfsePHP\Contracts\XmlSignerInterface; use LibreCodeCoop\NfsePHP\Dto\DpsData; use LibreCodeCoop\NfsePHP\Dto\ReceiptData; -use LibreCodeCoop\NfsePHP\Exception\NfseException; +use LibreCodeCoop\NfsePHP\Exception\CancellationException; +use LibreCodeCoop\NfsePHP\Exception\IssuanceException; +use LibreCodeCoop\NfsePHP\Exception\NetworkException; +use LibreCodeCoop\NfsePHP\Exception\NfseErrorCode; +use LibreCodeCoop\NfsePHP\Exception\QueryException; use LibreCodeCoop\NfsePHP\Xml\DpsSigner; use LibreCodeCoop\NfsePHP\Xml\XmlBuilder; @@ -48,21 +52,48 @@ public function emit(DpsData $dps): ReceiptData $xml = (new XmlBuilder())->buildDps($dps); $signed = $this->signer->sign($xml, $dps->cnpjPrestador); - $response = $this->post('/dps', $signed); + [$httpStatus, $body] = $this->post('/dps', $signed); - return $this->parseReceiptResponse($response); + if ($httpStatus >= 400) { + throw new IssuanceException( + 'SEFIN gateway rejected issuance (HTTP ' . $httpStatus . ')', + NfseErrorCode::IssuanceRejected, + $httpStatus, + $body, + ); + } + + return $this->parseReceiptResponse($body); } public function query(string $chaveAcesso): ReceiptData { - $response = $this->get('/dps/' . $chaveAcesso); + [$httpStatus, $body] = $this->get('/dps/' . $chaveAcesso); + + if ($httpStatus >= 400) { + throw new QueryException( + 'SEFIN gateway returned error for query (HTTP ' . $httpStatus . ')', + NfseErrorCode::QueryFailed, + $httpStatus, + $body, + ); + } - return $this->parseReceiptResponse($response); + return $this->parseReceiptResponse($body); } public function cancel(string $chaveAcesso, string $motivo): bool { - $this->delete('/dps/' . $chaveAcesso, $motivo); + [$httpStatus, $body] = $this->delete('/dps/' . $chaveAcesso, $motivo); + + if ($httpStatus >= 400) { + throw new CancellationException( + 'SEFIN gateway rejected cancellation (HTTP ' . $httpStatus . ')', + NfseErrorCode::CancellationRejected, + $httpStatus, + $body, + ); + } return true; } @@ -72,24 +103,24 @@ public function cancel(string $chaveAcesso, string $motivo): bool // ------------------------------------------------------------------------- /** - * @return array + * @return array{int, array} */ private function post(string $path, string $xmlPayload): array { $context = stream_context_create([ 'http' => [ - 'method' => 'POST', - 'header' => "Content-Type: application/xml\r\nAccept: application/json\r\n", - 'content' => $xmlPayload, + 'method' => 'POST', + 'header' => "Content-Type: application/xml\r\nAccept: application/json\r\n", + 'content' => $xmlPayload, 'ignore_errors' => true, ], ]); - return $this->request($path, $context); + return $this->fetchAndDecode($path, $context); } /** - * @return array + * @return array{int, array} */ private function get(string $path): array { @@ -101,10 +132,13 @@ private function get(string $path): array ], ]); - return $this->request($path, $context); + return $this->fetchAndDecode($path, $context); } - private function delete(string $path, string $motivo): void + /** + * @return array{int, array} + */ + private function delete(string $path, string $motivo): array { $payload = json_encode(['motivo' => $motivo], JSON_THROW_ON_ERROR); $context = stream_context_create([ @@ -116,28 +150,59 @@ private function delete(string $path, string $motivo): void ], ]); - $this->request($path, $context); + return $this->fetchAndDecode($path, $context); } /** - * @return array + * Perform the raw HTTP request and decode the JSON body. + * + * PHP sets $http_response_header in the calling scope when file_get_contents + * uses an HTTP wrapper. We initialize it to [] so static analysers have a + * typed baseline; the HTTP wrapper will overwrite it on a successful + * connection, even when the server responds with 4xx/5xx. + * + * @return array{int, array} */ - private function request(string $path, mixed $context): array + private function fetchAndDecode(string $path, mixed $context): array { - $url = $this->baseUrl . $path; - $body = file_get_contents($url, false, $context); + $url = $this->baseUrl . $path; + + $http_response_header = []; + $body = file_get_contents($url, false, $context); + $httpStatus = $this->parseHttpStatus($http_response_header); if ($body === false) { - throw new NfseException('Failed to connect to SEFIN gateway at ' . $url); + throw new NetworkException('Failed to connect to SEFIN gateway at ' . $url); } $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); if (!is_array($decoded)) { - throw new NfseException('Unexpected response format from SEFIN gateway'); + throw new NetworkException( + 'Unexpected response format from SEFIN gateway', + NfseErrorCode::InvalidResponse, + ); + } + + return [$httpStatus, $decoded]; + } + + /** + * Extract the HTTP status code from the first response header line. + * + * @param list $headers + */ + private function parseHttpStatus(array $headers): int + { + if (!isset($headers[0])) { + return 0; + } + + if (preg_match('/HTTP\/[\d.]+ (\d{3})/', $headers[0], $m)) { + return (int) $m[1]; } - return $decoded; + return 0; } /** From 70592449bc3e00f45a5c049b0591f64e027aae8e Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sat, 21 Mar 2026 23:47:47 -0300 Subject: [PATCH 8/8] test(client): cover typed exception behavior on gateway error responses Signed-off-by: Vitor Mattos --- tests/Unit/Http/NfseClientTest.php | 126 +++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/tests/Unit/Http/NfseClientTest.php b/tests/Unit/Http/NfseClientTest.php index 15c09ba..ff02251 100644 --- a/tests/Unit/Http/NfseClientTest.php +++ b/tests/Unit/Http/NfseClientTest.php @@ -11,6 +11,10 @@ use donatj\MockWebServer\Response; use LibreCodeCoop\NfsePHP\Contracts\XmlSignerInterface; use LibreCodeCoop\NfsePHP\Dto\DpsData; +use LibreCodeCoop\NfsePHP\Exception\CancellationException; +use LibreCodeCoop\NfsePHP\Exception\IssuanceException; +use LibreCodeCoop\NfsePHP\Exception\NfseErrorCode; +use LibreCodeCoop\NfsePHP\Exception\QueryException; use LibreCodeCoop\NfsePHP\Http\NfseClient; use LibreCodeCoop\NfsePHP\SecretStore\NoOpSecretStore; use LibreCodeCoop\NfsePHP\Tests\TestCase; @@ -117,6 +121,128 @@ public function testCancelReturnsTrueOnSuccess(): void self::assertTrue($client->cancel('abc-123', 'Cancelamento a pedido do tomador')); } + // ------------------------------------------------------------------------- + // Typed exception tests + // ------------------------------------------------------------------------- + + public function testEmitThrowsIssuanceExceptionWhenGatewayRejects(): void + { + $payload = json_encode(['codigo' => 'E422', 'mensagem' => 'CNPJ inválido'], JSON_THROW_ON_ERROR); + + self::$server->setResponseOfPath( + '/NFS-e/api/v1/dps', + new Response($payload, ['Content-Type' => 'application/json'], 422), + ); + + $client = new NfseClient( + secretStore: new NoOpSecretStore(), + baseUrlOverride: self::$server->getServerRoot() . '/NFS-e/api/v1', + signer: $this->signer, + ); + + $this->expectException(IssuanceException::class); + $client->emit($this->makeDps()); + } + + public function testIssuanceExceptionCarriesErrorCodeHttpStatusAndUpstreamPayload(): void + { + $errorData = ['codigo' => 'E422', 'mensagem' => 'CNPJ inválido']; + + self::$server->setResponseOfPath( + '/NFS-e/api/v1/dps', + new Response(json_encode($errorData, JSON_THROW_ON_ERROR), ['Content-Type' => 'application/json'], 422), + ); + + $client = new NfseClient( + secretStore: new NoOpSecretStore(), + baseUrlOverride: self::$server->getServerRoot() . '/NFS-e/api/v1', + signer: $this->signer, + ); + + try { + $client->emit($this->makeDps()); + self::fail('Expected IssuanceException'); + } catch (IssuanceException $e) { + self::assertSame(NfseErrorCode::IssuanceRejected, $e->errorCode); + self::assertSame(422, $e->httpStatus); + self::assertSame($errorData, $e->upstreamPayload); + } + } + + public function testQueryThrowsQueryExceptionWhenGatewayReturnsError(): void + { + self::$server->setResponseOfPath( + '/NFS-e/api/v1/dps/missing-key', + new Response('{"error":"not found"}', ['Content-Type' => 'application/json'], 404), + ); + + $client = new NfseClient( + secretStore: new NoOpSecretStore(), + baseUrlOverride: self::$server->getServerRoot() . '/NFS-e/api/v1', + ); + + $this->expectException(QueryException::class); + $client->query('missing-key'); + } + + public function testQueryExceptionCarriesErrorCodeAndHttpStatus(): void + { + self::$server->setResponseOfPath( + '/NFS-e/api/v1/dps/missing-key', + new Response('{"error":"not found"}', ['Content-Type' => 'application/json'], 404), + ); + + $client = new NfseClient( + secretStore: new NoOpSecretStore(), + baseUrlOverride: self::$server->getServerRoot() . '/NFS-e/api/v1', + ); + + try { + $client->query('missing-key'); + self::fail('Expected QueryException'); + } catch (QueryException $e) { + self::assertSame(NfseErrorCode::QueryFailed, $e->errorCode); + self::assertSame(404, $e->httpStatus); + } + } + + public function testCancelThrowsCancellationExceptionWhenGatewayReturnsError(): void + { + self::$server->setResponseOfPath( + '/NFS-e/api/v1/dps/blocked-key', + new Response('{"error":"cannot cancel"}', ['Content-Type' => 'application/json'], 409), + ); + + $client = new NfseClient( + secretStore: new NoOpSecretStore(), + baseUrlOverride: self::$server->getServerRoot() . '/NFS-e/api/v1', + ); + + $this->expectException(CancellationException::class); + $client->cancel('blocked-key', 'a pedido do tomador'); + } + + public function testCancellationExceptionCarriesErrorCodeAndHttpStatus(): void + { + self::$server->setResponseOfPath( + '/NFS-e/api/v1/dps/blocked-key', + new Response('{"error":"cannot cancel"}', ['Content-Type' => 'application/json'], 409), + ); + + $client = new NfseClient( + secretStore: new NoOpSecretStore(), + baseUrlOverride: self::$server->getServerRoot() . '/NFS-e/api/v1', + ); + + try { + $client->cancel('blocked-key', 'a pedido do tomador'); + self::fail('Expected CancellationException'); + } catch (CancellationException $e) { + self::assertSame(NfseErrorCode::CancellationRejected, $e->errorCode); + self::assertSame(409, $e->httpStatus); + } + } + // ------------------------------------------------------------------------- private function makeDps(): DpsData