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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/recursos/product-invoices.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions skills/nfeio-php-sdk/references/product-invoices-and-taxes.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,16 @@ 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' => ...]`.
- `create()` is async (202 → `Pending`); completion is **webhook-driven** — prefer webhooks over polling.
- `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)

Expand All @@ -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)

Expand Down
25 changes: 20 additions & 5 deletions src/Resource/ProductInvoicesResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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<string, mixed> $data
* @return array<string, mixed>
Expand All @@ -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<string, mixed> $data
* @return array<string, mixed>
Expand All @@ -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);
}
Expand Down
70 changes: 70 additions & 0 deletions tests/Resource/ProductInvoiceSpecAlignmentTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

use Symfony\Component\Yaml\Yaml;

/**
* Amarra as rotas de inutilização e EPEC de ProductInvoicesResource ao contrato
* de openapi/nf-produto-v2.yaml: um sync de spec que mova esses paths quebra o
* build em vez de driftar silenciosamente. Rotas confirmadas por sonda ao vivo
* em 2026-07-29 (vault review-07-14-2026/09 — Sondas 2026-07-29).
*/

/**
* @return array<string, mixed> 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');
});
51 changes: 51 additions & 0 deletions tests/Resource/ProductInvoicesResourceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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, [], '<EPEC/>'));
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, [], '<rej/>'));
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');
});