diff --git a/CHANGELOG.md b/CHANGELOG.md index 275a264..4d58b87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ e este projeto segue [Versionamento Semântico](https://semver.org/lang/pt-BR/sp ## [Unreleased] +## [3.3.1] — 2026-07-30 + +### Corrigido + +- **Inutilização de NF-e** (`fix-product-invoice-disablement-epec`): `disable()` e + `disableRange()` de `ProductInvoicesResource` emitiam `PUT …/disable` — rota que + **não existe** na API (405/404, sondado ao vivo em 2026-07-29). Agora emitem + `POST …/{invoiceId}/disablement` e `POST …/productinvoices/disablement`, conforme + `openapi/nf-produto-v2.yaml`. Assinaturas públicas inalteradas. + - Em `disable()`, o `reason` de `$data` é promovido a query param (contrato do + endpoint individual); a API exige 15–255 caracteres (erro `40001`). + - Em `disableRange()`, o body segue o `DisablementResource` da spec; `reason` é + obrigatório (`400 "The Reason field is required"`). +- **Download de EPEC**: `downloadEpecXml()` usava `GET …/xml/epec` (404 de rota + inexistente); agora usa a rota real `GET …/xml-epec`. `downloadRejectionXml()` + não muda (`/xml/rejection` e `/xml-rejection` são aliases vivos, sondado). +- Testes: verbo+path dos três métodos pinados em unit tests; novo teste de + alinhamento `ProductInvoiceSpecAlignmentTest` amarra as rotas à spec (e pina a + ausência das rotas mortas). Rotas novas validadas ao vivo com o SDK corrigido + em 2026-07-30 (400 de validação / `40401 invoice not found` — nada inutilizado). + ## [3.3.0] — 2026-07-09 ### Adicionado diff --git a/docs/recursos/product-invoices.md b/docs/recursos/product-invoices.md index 14549e4..2265558 100644 --- a/docs/recursos/product-invoices.md +++ b/docs/recursos/product-invoices.md @@ -154,6 +154,14 @@ $nfe->productInvoices->disableRange($companyId, [ ]); ``` +:::note `reason` é obrigatório na inutilização +Em `disableRange()`, a API rejeita a ausência de `reason` com +`400 "The Reason field is required"`. Em `disable()`, o `reason` informado em +`$data` é enviado como parâmetro de query (contrato da API para o endpoint +individual) e precisa ter entre 15 e 255 caracteres (erro `40001`). Para +inutilizar um único número via faixa, use `beginNumber` igual a `lastNumber`. +::: + ## Próximos passos - [Notas de consumidor (NFC-e)](./consumer-invoices.md) — modelo 65. diff --git a/skills/nfeio-php-sdk/references/product-invoices-and-taxes.md b/skills/nfeio-php-sdk/references/product-invoices-and-taxes.md index a9a796f..dc7bbd2 100644 --- a/skills/nfeio-php-sdk/references/product-invoices-and-taxes.md +++ b/skills/nfeio-php-sdk/references/product-invoices-and-taxes.md @@ -19,8 +19,8 @@ downloadEpecXml(string $companyId, string $invoiceId, ?RequestOptions $options = sendCorrectionLetter(string $companyId, string $invoiceId, string $correction, ?RequestOptions $options = null): array downloadCorrectionLetterPdf(string $companyId, string $invoiceId, ?RequestOptions $options = null): string downloadCorrectionLetterXml(string $companyId, string $invoiceId, ?RequestOptions $options = null): string -disable(string $companyId, string $invoiceId, array $data, ?RequestOptions $options = null): array // single: PUT .../{id}/disable -disableRange(string $companyId, array $data, ?RequestOptions $options = null): array // range: PUT .../productinvoices/disable +disable(string $companyId, string $invoiceId, array $data, ?RequestOptions $options = null): array // single: POST .../{id}/disablement +disableRange(string $companyId, array $data, ?RequestOptions $options = null): array // range: POST .../productinvoices/disablement ``` - **`list()` requires `environment`** and its `$options` arg is mandatory (cursor pagination): `['environment' => 'Production', 'limit' => 25, 'startingAfter' => $id, 'q' => ...]`. @@ -28,6 +28,7 @@ disableRange(string $companyId, array $data, ?RequestOptions $options = null): a - `cancel()` takes an optional `$reason` (appended as `?reason=`). - `sendCorrectionLetter()` throws `InvalidRequestException` locally if the text is empty/whitespace. - `listItems`/`listEvents` return raw associative arrays, not DTOs. +- Inutilization: `disableRange()`'s `$data` follows the spec's `DisablementResource` (`serie`, `beginNumber`, `lastNumber`, `state`, `environment`, `reason`) — the API rejects a missing `reason` with `400 "The Reason field is required"`. In `disable()`, a `reason` key in `$data` is promoted to the query string (per-invoice endpoint has no request body in the spec). ## `$nfe->consumerInvoices` (NFC-e — Company-scoped) @@ -46,7 +47,7 @@ disableRange(string $companyId, array $data, ?RequestOptions $options = null): a ``` - NFC-e emission is webhook-driven. No `getStatus()`, no correction letter, no EPEC. -- `disableRange()` here POSTs to `.../disablement` (different verb/path from product's `disable`). No per-invoice disable. +- `disableRange()` POSTs to `.../consumerinvoices/disablement` (same verb/path shape as product's). No per-invoice disable. ## `$nfe->stateTaxes` (Inscrição Estadual / IE — Company-scoped, prerequisite for NF-e) diff --git a/src/Resource/ProductInvoicesResource.php b/src/Resource/ProductInvoicesResource.php index 3b6099c..ac11590 100644 --- a/src/Resource/ProductInvoicesResource.php +++ b/src/Resource/ProductInvoicesResource.php @@ -187,7 +187,7 @@ public function downloadEpecXml(string $companyId, string $invoiceId, ?RequestOp $companyId = IdValidator::companyId($companyId); $invoiceId = IdValidator::invoiceId($invoiceId); - return $this->download("/companies/{$companyId}/productinvoices/{$invoiceId}/xml/epec", options: $options); + return $this->download("/companies/{$companyId}/productinvoices/{$invoiceId}/xml-epec", options: $options); } /** @@ -232,7 +232,12 @@ public function downloadCorrectionLetterXml(string $companyId, string $invoiceId } /** - * Inutiliza uma NF-e específica. + * Inutiliza uma NF-e específica (`POST …/{invoiceId}/disablement`). + * + * A spec declara `reason` como query param neste endpoint (não há request + * body); se `$data['reason']` for informado, é promovido à query string e + * o restante de `$data` segue como body. A API exige `reason` entre 15 e + * 255 caracteres (erro 40001). * * @param array $data * @return array @@ -245,13 +250,23 @@ public function disable( ): array { $companyId = IdValidator::companyId($companyId); $invoiceId = IdValidator::invoiceId($invoiceId); - $response = $this->httpPut("/companies/{$companyId}/productinvoices/{$invoiceId}/disable", $data, $options); + $path = "/companies/{$companyId}/productinvoices/{$invoiceId}/disablement"; + $reason = $data['reason'] ?? null; + if (is_string($reason) && $reason !== '') { + unset($data['reason']); + $path .= '?' . http_build_query(['reason' => $reason]); + } + $response = $this->httpPost($path, $data === [] ? null : $data, $options); return $this->decodeBody($response->body); } /** - * Inutiliza uma faixa de numeração. + * Inutiliza uma faixa de numeração (`POST …/productinvoices/disablement`). + * + * `$data` segue o schema `DisablementResource` da spec: `serie`, + * `beginNumber`, `lastNumber`, `state`, `environment` e `reason` — + * a API rejeita a ausência de `reason` com `400 "The Reason field is required"`. * * @param array $data * @return array @@ -262,7 +277,7 @@ public function disableRange( ?RequestOptions $options = null, ): array { $companyId = IdValidator::companyId($companyId); - $response = $this->httpPut("/companies/{$companyId}/productinvoices/disable", $data, $options); + $response = $this->httpPost("/companies/{$companyId}/productinvoices/disablement", $data, $options); return $this->decodeBody($response->body); } diff --git a/tests/Resource/ProductInvoiceSpecAlignmentTest.php b/tests/Resource/ProductInvoiceSpecAlignmentTest.php new file mode 100644 index 0000000..95c4a8e --- /dev/null +++ b/tests/Resource/ProductInvoiceSpecAlignmentTest.php @@ -0,0 +1,70 @@ + O spec OpenAPI completo, parseado. + */ +function loadProductInvoiceSpec(): array +{ + static $spec = null; + if ($spec === null) { + $spec = Yaml::parseFile(__DIR__ . '/../../openapi/nf-produto-v2.yaml'); + expect($spec)->toBeArray(); + } + + return $spec; +} + +it('collective disablement is POST /v2/companies/{companyId}/productinvoices/disablement', function (): void { + $spec = loadProductInvoiceSpec(); + + $op = $spec['paths']['/v2/companies/{companyId}/productinvoices/disablement']['post'] ?? null; + expect($op)->toBeArray(); + + // O body segue o schema DisablementResource, que declara `reason` + // (obrigatório no fio: 400 "The Reason field is required", sondado 2026-07-29). + $ref = $op['requestBody']['content']['application/json']['schema']['$ref'] ?? null; + expect($ref)->toBe('#/components/schemas/DisablementResource'); + expect($spec['components']['schemas']['DisablementResource']['properties'])->toHaveKey('reason'); +}); + +it('per-invoice disablement is POST …/{invoiceId}/disablement with reason as query param', function (): void { + $spec = loadProductInvoiceSpec(); + + $op = $spec['paths']['/v2/companies/{companyId}/productinvoices/{invoiceId}/disablement']['post'] ?? null; + expect($op)->toBeArray(); + + $queryParams = array_column( + array_filter($op['parameters'] ?? [], fn(array $p): bool => ($p['in'] ?? '') === 'query'), + 'name', + ); + expect($queryParams)->toContain('reason'); + expect($op)->not->toHaveKey('requestBody'); +}); + +it('EPEC XML download is GET …/{invoiceId}/xml-epec (hyphenated)', function (): void { + $spec = loadProductInvoiceSpec(); + + expect($spec['paths']['/v2/companies/{companyId}/productinvoices/{invoiceId}/xml-epec'] ?? null) + ->toBeArray()->toHaveKey('get'); +}); + +it('the dead PUT …/disable and GET …/xml/epec routes stay absent from the spec', function (): void { + // Sondado 2026-07-29: 405 / 404 de rota inexistente. Se um sync de spec os + // ressuscitar, reavaliar as rotas do resource. + $paths = array_keys(loadProductInvoiceSpec()['paths']); + + expect($paths)->not->toContain('/v2/companies/{companyId}/productinvoices/disable'); + expect($paths)->not->toContain('/v2/companies/{companyId}/productinvoices/{invoiceId}/disable'); + expect($paths)->not->toContain('/v2/companies/{companyId}/productinvoices/{invoiceId}/xml/epec'); +}); diff --git a/tests/Resource/ProductInvoicesResourceTest.php b/tests/Resource/ProductInvoicesResourceTest.php index 4f5db46..3515710 100644 --- a/tests/Resource/ProductInvoicesResourceTest.php +++ b/tests/Resource/ProductInvoicesResourceTest.php @@ -92,3 +92,54 @@ function buildPiClient(MockTransport $mock): Client expect($bytes)->toBe($xml); }); + +it('downloadEpecXml is GET to the hyphenated /xml-epec route', function (): void { + // Sondado 2026-07-29: /xml/epec é 404 de rota inexistente; a rota viva é /xml-epec. + $mock = (new MockTransport())->push(new Response(200, [], '')); + buildPiClient($mock)->productInvoices->downloadEpecXml('abc', 'inv-1'); + + expect($mock->lastRequest()?->method)->toBe('GET'); + expect($mock->lastRequest()?->path)->toBe('/v2/companies/abc/productinvoices/inv-1/xml-epec'); +}); + +it('downloadRejectionXml keeps the live /xml/rejection alias', function (): void { + $mock = (new MockTransport())->push(new Response(200, [], '')); + buildPiClient($mock)->productInvoices->downloadRejectionXml('abc', 'inv-1'); + + expect($mock->lastRequest()?->path)->toBe('/v2/companies/abc/productinvoices/inv-1/xml/rejection'); +}); + +it('disableRange is POST to /productinvoices/disablement', function (): void { + // Sondado 2026-07-29: PUT …/disable → 405; a rota viva é POST …/disablement. + $mock = (new MockTransport())->push(new Response(204, [], '')); + buildPiClient($mock)->productInvoices->disableRange('co-1', [ + 'serie' => 1, + 'beginNumber' => 10, + 'lastNumber' => 10, + 'reason' => 'quebra de sequência', + ]); + + expect($mock->lastRequest()?->method)->toBe('POST'); + expect($mock->lastRequest()?->path)->toBe('/v2/companies/co-1/productinvoices/disablement'); + $body = json_decode($mock->lastRequest()?->body ?? 'null', associative: true); + expect($body)->toMatchArray(['reason' => 'quebra de sequência', 'serie' => 1]); +}); + +it('disable is POST to /productinvoices/{id}/disablement with reason promoted to query', function (): void { + // Spec: no endpoint individual, `reason` é query param (não há request body declarado). + $mock = (new MockTransport())->push(new Response(204, [], '')); + buildPiClient($mock)->productInvoices->disable('co-1', 'inv-1', ['reason' => 'nota em duplicidade']); + + $sent = $mock->lastRequest(); + expect($sent?->method)->toBe('POST'); + expect($sent?->path)->toBe('/v2/companies/co-1/productinvoices/inv-1/disablement?' . http_build_query(['reason' => 'nota em duplicidade'])); + expect($sent?->body)->toBeNull(); +}); + +it('disable without reason posts to /disablement with no query', function (): void { + $mock = (new MockTransport())->push(new Response(204, [], '')); + buildPiClient($mock)->productInvoices->disable('co-1', 'inv-1', []); + + expect($mock->lastRequest()?->method)->toBe('POST'); + expect($mock->lastRequest()?->path)->toBe('/v2/companies/co-1/productinvoices/inv-1/disablement'); +});