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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions docs/recursos/service-invoices.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions src/Resource/ServiceInvoicesResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
Expand Down
2 changes: 1 addition & 1 deletion src/Version.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@
*/
final class Version
{
public const CURRENT = '3.4.1';
public const CURRENT = '3.5.0';
}
22 changes: 22 additions & 0 deletions tests/Resource/ServiceInvoiceSpecAlignmentTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
39 changes: 39 additions & 0 deletions tests/Resource/ServiceInvoicesResourceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<?xml version=\"1.0\"?><evento>e110001</evento>";
$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,
Expand Down