Skip to content
15 changes: 15 additions & 0 deletions src/Exception/CancellationException.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 SEFIN gateway rejects an NFS-e cancellation request.
*/
class CancellationException extends GatewayException
{
}
30 changes: 30 additions & 0 deletions src/Exception/GatewayException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?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 SEFIN gateway responds with an HTTP error status (4xx/5xx).
*
* The upstream error payload is preserved to allow callers to surface
* gateway-specific diagnostic information (e.g. fiscal rejection codes).
*/
class GatewayException extends NfseException
{
/**
* @param array<string, mixed> $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);
}
}
15 changes: 15 additions & 0 deletions src/Exception/IssuanceException.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 SEFIN gateway rejects an NFS-e issuance request.
*/
class IssuanceException extends GatewayException
{
}
23 changes: 23 additions & 0 deletions src/Exception/NetworkException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?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 a network-level failure prevents communication with the gateway,
* or when the gateway returns an unparseable response.
*/
class NetworkException extends NfseException
{
public function __construct(
string $message,
public readonly NfseErrorCode $errorCode = NfseErrorCode::NetworkFailure,
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
32 changes: 32 additions & 0 deletions src/Exception/NfseErrorCode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

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

declare(strict_types=1);

namespace LibreCodeCoop\NfsePHP\Exception;

/**
* Machine-readable error codes for NFS-e operations.
*
* These codes provide a deterministic, framework-agnostic way to identify
* the type of failure without relying on human-readable messages.
*/
enum NfseErrorCode: string
{
/** Connection to the SEFIN gateway could not be established. */
case NetworkFailure = 'NETWORK_FAILURE';

/** Gateway returned a response that could not be parsed. */
case InvalidResponse = 'INVALID_RESPONSE';

/** Gateway rejected the NFS-e issuance request (HTTP 4xx/5xx). */
case IssuanceRejected = 'ISSUANCE_REJECTED';

/** Gateway rejected the NFS-e cancellation request (HTTP 4xx/5xx). */
case CancellationRejected = 'CANCELLATION_REJECTED';

/** Gateway returned an error when querying an NFS-e (HTTP 4xx/5xx). */
case QueryFailed = 'QUERY_FAILED';
}
15 changes: 15 additions & 0 deletions src/Exception/QueryException.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 SEFIN gateway returns an error for an NFS-e query request.
*/
class QueryException extends GatewayException
{
}
109 changes: 87 additions & 22 deletions src/Http/NfseClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
Expand All @@ -72,24 +103,24 @@ public function cancel(string $chaveAcesso, string $motivo): bool
// -------------------------------------------------------------------------

/**
* @return array<string, mixed>
* @return array{int, array<string, mixed>}
*/
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<string, mixed>
* @return array{int, array<string, mixed>}
*/
private function get(string $path): array
{
Expand All @@ -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<string, mixed>}
*/
private function delete(string $path, string $motivo): array
{
$payload = json_encode(['motivo' => $motivo], JSON_THROW_ON_ERROR);
$context = stream_context_create([
Expand All @@ -116,28 +150,59 @@ private function delete(string $path, string $motivo): void
],
]);

$this->request($path, $context);
return $this->fetchAndDecode($path, $context);
}

/**
* @return array<string, mixed>
* 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<string, mixed>}
*/
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<string> $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;
}

/**
Expand Down
Loading
Loading