diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f0a002..f249273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ e este projeto segue [Versionamento Semântico](https://semver.org/lang/pt-BR/sp ## [Unreleased] +## [3.5.0] — 2026-08-01 + +### Adicionado + +- **`ServiceInvoicesResource::downloadCancellationXml()`** + (`add-service-invoice-cancellation-xml`): baixa o XML do evento de + cancelamento da NFS-e (`GET …/serviceinvoices/{id}/cancellation-xml`) — + único gap funcional exigido por **duas specs** (`nf-servico-v1` e + `service-invoice-rtc-v1`). Disponível para notas do ambiente **Nacional** + (evento `e110001`, Reforma Tributária); mesmo padrão dos downloads + existentes (bytes crus, redirect ao CDN sem vazar `Authorization`). + O `404` é **resposta esperada** quando não há XML de cancelamento + (provedor legado, ambiente não Nacional ou nota não cancelada — sondado + ao vivo em 2026-07-30) e chega como `NotFoundException`; quando existem o + XML de envio e o autorizado, a API retorna o autorizado. Paridade-plus: + o SDK Node não expõe este download. + ## [3.4.1] — 2026-07-31 ### Corrigido diff --git a/docs/recursos/service-invoices.md b/docs/recursos/service-invoices.md index 36c470c..e789f55 100644 --- a/docs/recursos/service-invoices.md +++ b/docs/recursos/service-invoices.md @@ -35,6 +35,7 @@ recurso de nota que usa o host `api.nfe.io`; os demais usam `api.nfse.io`. | `sendEmail($companyId, $invoiceId)` | Reenvia a nota por e-mail ao tomador. | `array` (típico: `{sent, message}`) | | `downloadPdf($companyId, $invoiceId)` | PDF da nota. | `string` (bytes crus) | | `downloadXml($companyId, $invoiceId)` | XML da nota. | `string` (bytes crus) | +| `downloadCancellationXml($companyId, $invoiceId)` | XML do evento de cancelamento (`e110001`, ambiente Nacional). | `string` (bytes crus) | | `getStatus($companyId, $invoiceId)` | Snapshot leve de status. | `array` (`flowStatus`, `flowMessage`, …) | As opções de `list()` incluem `pageIndex` (**1-based**), `pageCount`, @@ -199,8 +200,20 @@ Os downloads retornam uma `string` com os bytes do arquivo — grave com ```php file_put_contents('nfse.pdf', $nfe->serviceInvoices->downloadPdf($companyId, $invoiceId)); file_put_contents('nfse.xml', $nfe->serviceInvoices->downloadXml($companyId, $invoiceId)); + +// XML do evento de cancelamento (só para notas canceladas do ambiente Nacional): +file_put_contents('nfse-cancelamento.xml', $nfe->serviceInvoices->downloadCancellationXml($companyId, $invoiceId)); ``` +::: warning Quando o 404 do cancellation-xml é esperado +`downloadCancellationXml()` lança `NotFoundException` quando **não existe** XML de +evento de cancelamento — o que é resposta normal em três casos: a nota é de um +provedor legado (ABRASF, Paulistana etc.), não é do ambiente Nacional (evento +`e110001` da Reforma Tributária), ou ainda não foi cancelada. Trate esse 404 como +"não há XML de cancelamento", não como id inválido. Quando existem o XML de envio +e o autorizado, a API retorna o **autorizado**. +::: + ## Cancelar e reenviar por e-mail ```php diff --git a/skills/nfeio-php-sdk/references/service-invoices-and-polling.md b/skills/nfeio-php-sdk/references/service-invoices-and-polling.md index fa3760c..a4ae3e2 100644 --- a/skills/nfeio-php-sdk/references/service-invoices-and-polling.md +++ b/skills/nfeio-php-sdk/references/service-invoices-and-polling.md @@ -12,6 +12,7 @@ cancel(string $companyId, string $invoiceId, ?RequestOptions $options = null): S sendEmail(string $companyId, string $invoiceId, ?RequestOptions $options = null): array downloadPdf(string $companyId, string $invoiceId, ?RequestOptions $options = null): string // raw bytes downloadXml(string $companyId, string $invoiceId, ?RequestOptions $options = null): string +downloadCancellationXml(string $companyId, string $invoiceId, ?RequestOptions $options = null): string // cancellation-event XML (v3.5.0) getStatus(string $companyId, string $invoiceId, ?RequestOptions $options = null): array // {flowStatus, flowMessage, ...} findByExternalId(string $companyId, string $externalId, ?RequestOptions $options = null): ?ServiceInvoice // dedicated /external route (v3.2.0) static isDuplicateExternalId(ApiErrorException $e): bool // matches the 400 duplicate-externalId rejection (v3.2.0) @@ -21,6 +22,7 @@ static isDuplicateExternalId(ApiErrorException $e): bool // matches the 400 du - `list()` is **page-style**, `pageIndex` **1-based**: `['pageIndex' => 1, 'pageCount' => 50, 'issuedBegin' => ..., 'issuedEnd' => ...]`. Returns `ListResponse` (`->data` = `ServiceInvoice[]`, `->page`). - `cancel()` is synchronous and returns the updated `ServiceInvoice` DTO. - `getStatus()` is unique to service invoices (lightweight status endpoint). Product/consumer have none. +- `downloadCancellationXml()` (v3.5.0) returns the cancellation-event XML (`e110001`, **National environment only**). A `NotFoundException` is the **expected** answer when no cancellation XML exists: legacy providers (ABRASF, Paulistana…), non-National environment, or invoice not yet cancelled — treat it as "no cancellation XML", not as a bad ID. When both submitted and authorised event XML exist, the API returns the authorised one. - **No `createAndWait`, no `createBatch`** (deferred post-v3.0). ### ServiceInvoice DTO (v3.3.0) diff --git a/src/Resource/ServiceInvoicesResource.php b/src/Resource/ServiceInvoicesResource.php index 3e36116..6cf308a 100644 --- a/src/Resource/ServiceInvoicesResource.php +++ b/src/Resource/ServiceInvoicesResource.php @@ -186,6 +186,30 @@ public function downloadXml( return $this->download("/companies/{$companyId}/serviceinvoices/{$invoiceId}/xml", options: $options); } + /** + * Baixa o XML do evento de cancelamento da NFS-e como bytes crus. + * + * Disponível apenas para notas do ambiente **Nacional** (evento `e110001`, + * Reforma Tributária). A API responde `404` — e o SDK lança + * `NotFoundException` — quando não há XML de cancelamento, o que é + * **resposta esperada** em três situações (sondado ao vivo em 2026-07-30): + * provedor legado (ABRASF, Paulistana etc., que não têm evento de + * cancelamento em XML), nota fora do ambiente Nacional, ou nota ainda não + * cancelada. Trate o 404 como "não há XML de cancelamento", não como id + * inválido. Quando existem o XML de envio e o de retorno autorizado, a API + * retorna o **autorizado**. + */ + public function downloadCancellationXml( + string $companyId, + string $invoiceId, + ?RequestOptions $options = null, + ): string { + $companyId = IdValidator::companyId($companyId); + $invoiceId = IdValidator::invoiceId($invoiceId); + + return $this->download("/companies/{$companyId}/serviceinvoices/{$invoiceId}/cancellation-xml", options: $options); + } + /** * Snapshot de status (flowStatus + flowMessage no mínimo). * diff --git a/src/Version.php b/src/Version.php index c12409f..7aad052 100644 --- a/src/Version.php +++ b/src/Version.php @@ -12,5 +12,5 @@ */ final class Version { - public const CURRENT = '3.4.1'; + public const CURRENT = '3.5.0'; } diff --git a/tests/Resource/ServiceInvoiceSpecAlignmentTest.php b/tests/Resource/ServiceInvoiceSpecAlignmentTest.php index bca20f4..2608899 100644 --- a/tests/Resource/ServiceInvoiceSpecAlignmentTest.php +++ b/tests/Resource/ServiceInvoiceSpecAlignmentTest.php @@ -67,6 +67,28 @@ function dtoConstructorFields(string $dto): array expect(array_keys(nfServicoRetrieveProps()))->not->toContain('totalAmount'); }); +it('cancellation-xml path+verb exists in nf-servico-v1 (ServiceInvoices_GetCancellationXml)', function (): void { + $spec = Yaml::parseFile(__DIR__ . '/../../openapi/nf-servico-v1.yaml'); + $op = $spec['paths']['/v1/companies/{company_id}/serviceinvoices/{id}/cancellation-xml']['get'] ?? null; + + expect($op)->toBeArray(); + expect($op['operationId'] ?? null)->toBe('ServiceInvoices_GetCancellationXml'); +}); + +it('cancellation-xml path+verb exists in service-invoice-rtc-v1 (declared with :param style)', function (): void { + $spec = Yaml::parseFile(__DIR__ . '/../../openapi/service-invoice-rtc-v1.yaml'); + + // A spec RTC declara parâmetros no estilo `:param`; normalizamos para + // `{param}` antes de comparar com a rota que o SDK emite. + $normalized = []; + foreach (array_keys($spec['paths'] ?? []) as $path) { + $normalized[] = preg_replace('/:([A-Za-z_]+)/', '{$1}', (string) $path); + } + + expect($normalized)->toContain('/v1/companies/{company_id}/serviceinvoices/{id}/cancellation-xml'); + expect($spec['paths']['/v1/companies/:company_id/serviceinvoices/:id/cancellation-xml']['get'] ?? null)->toBeArray(); +}); + it('the retrieve path exists and its operationId collides (why we anchor by path)', function (): void { $spec = Yaml::parseFile(__DIR__ . '/../../openapi/nf-servico-v1.yaml'); $retrieveId = $spec['paths']['/v1/companies/{company_id}/serviceinvoices/{id}']['get']['operationId'] ?? null; diff --git a/tests/Resource/ServiceInvoicesResourceTest.php b/tests/Resource/ServiceInvoicesResourceTest.php index ff6d88c..b8414ec 100644 --- a/tests/Resource/ServiceInvoicesResourceTest.php +++ b/tests/Resource/ServiceInvoicesResourceTest.php @@ -96,6 +96,45 @@ function buildSvcClient(MockTransport $mock): Client expect($bytes)->toBe($pdfBytes); }); +it('downloadCancellationXml issues GET on /cancellation-xml and returns raw bytes', function (): void { + $xmlBytes = "e110001"; + $mock = (new MockTransport())->push(new Response(200, [], $xmlBytes)); + $client = buildSvcClient($mock); + + $bytes = $client->serviceInvoices->downloadCancellationXml('abc', 'inv-001'); + + expect($bytes)->toBe($xmlBytes); + $sent = $mock->lastRequest(); + expect($sent?->method)->toBe('GET'); + expect($sent?->path)->toBe('/v1/companies/abc/serviceinvoices/inv-001/cancellation-xml'); +}); + +it('downloadCancellationXml surfaces the semantic 404 as NotFoundException', function (): void { + // 404 is the documented answer for legacy providers, non-National + // environment, or an invoice not yet cancelled — it must reach the + // caller as NotFoundException, never be swallowed. + $mock = (new MockTransport())->push(new Response( + 404, + [], + '{"message":"Cancellation event XML not available for this service invoice"}', + )); + $client = buildSvcClient($mock); + + expect(fn() => $client->serviceInvoices->downloadCancellationXml('abc', 'inv-001')) + ->toThrow(NotFoundException::class); +}); + +it('downloadCancellationXml rejects empty IDs synchronously', function (): void { + $mock = new MockTransport(); + $client = buildSvcClient($mock); + + expect(fn() => $client->serviceInvoices->downloadCancellationXml('', 'inv')) + ->toThrow(InvalidRequestException::class); + expect(fn() => $client->serviceInvoices->downloadCancellationXml('abc', '')) + ->toThrow(InvalidRequestException::class); + expect($mock->sent())->toHaveCount(0); +}); + it('cancel issues DELETE and returns updated DTO', function (): void { $mock = (new MockTransport())->push(new Response( 200,