From 9734c40eac0fcf3f225f8effbf6660373740d024 Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Thu, 30 Jul 2026 01:08:35 -0300 Subject: [PATCH] =?UTF-8?q?fix(inbound):=20migra=20os=2020=20m=C3=A9todos?= =?UTF-8?q?=20de=20inbound/CT-e=20para=20as=20rotas=20reais=20/inbound/?= =?UTF-8?q?=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Os dois resources do domínio de documentos de entrada nunca funcionaram na v3: as rotas usadas (/productinvoices/received/…, /productinvoices/inbound, /cte/…) não existem na API — 404 de rota ou colisão com /productinvoices/{id} (sondado ao vivo 2026-07-29/30). Migra tudo para o contrato /inbound/… da consulta-dfe-distribuicao-v2 (spec canônica), mantendo nomes e assinaturas públicas dos 20 métodos: - config NF-e/CT-e: POST|GET|DELETE …/inbound/{productinvoices|transportationinvoices} (habilitar era PUT, agora POST conforme a spec) - consultas: rotas genéricas …/inbound/{key}… e específicas …/inbound/productinvoices/{key}… - manifest(): POST …/inbound/{key}/manifest?tpEvent={código} — sondado: o binder só aceita código numérico SEFAZ; literais legados do SDK são mapeados (Confirmation→210200, Acknowledgement→210210, Unknown→210220, Refused→210240) - reprocessWebhook(): POST …/inbound/productinvoices/{key_or_nsu}/processwebhook, aceitando NSU via novo IdValidator::accessKeyOrNsu() Testes: verbo+path pinados por método (datasets), InboundSpecAlignmentTest (paths↔spec DF-e v2, rotas mortas ausentes, tpEvent integer, assinaturas dos 20 métodos por reflexão). Revalidado ao vivo com o código novo: settings e chaves falsas retornam erros de domínio, nunca 404 de rota. OpenSpec: fix-inbound-routes --- CHANGELOG.md | 32 +++++ docs/recursos/inbound-product-invoices.md | 35 ++++- docs/recursos/transportation-invoices.md | 7 + .../references/product-invoices-and-taxes.md | 4 + .../InboundProductInvoicesResource.php | 56 +++++--- .../TransportationInvoicesResource.php | 14 +- src/Util/IdValidator.php | 23 ++++ .../InboundProductInvoicesResourceTest.php | 126 +++++++++++++---- tests/Resource/InboundSpecAlignmentTest.php | 128 ++++++++++++++++++ .../TransportationInvoicesResourceTest.php | 60 ++++++-- tests/Util/IdValidatorTest.php | 15 ++ 11 files changed, 429 insertions(+), 71 deletions(-) create mode 100644 tests/Resource/InboundSpecAlignmentTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d58b87..f055016 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,38 @@ e este projeto segue [Versionamento Semântico](https://semver.org/lang/pt-BR/sp ## [Unreleased] +### Corrigido + +- **Domínio de documentos de entrada inteiro** (`fix-inbound-routes`): os 20 métodos + de `InboundProductInvoicesResource` (13) e `TransportationInvoicesResource` (7) + usavam rotas (`/productinvoices/received/…`, `/productinvoices/inbound`, `/cte/…`) + que **nunca existiram na API** — desde a v3.0, todo o domínio retornava 404 de rota + (ou colidia com `/productinvoices/{id}`). Sondado ao vivo em 2026-07-29/30. Todas as + rotas migraram para o contrato real `/inbound/…` de + `openapi/consulta-dfe-distribuicao-v2.yaml`, **mantendo nomes e assinaturas dos 20 + métodos** (pinado por teste de reflexão): + - Configuração: `POST|GET|DELETE /v2/companies/{id}/inbound/productinvoices` + (NF-e) e `…/inbound/transportationinvoices` (CT-e) — habilitar agora é `POST` + (era `PUT`). + - Consultas por chave: rotas genéricas `…/inbound/{accessKey}[/xml|/pdf|/events/…]` + e específicas `…/inbound/productinvoices/{accessKey}[/json|/events/…]`. + - `manifest()`: `POST …/inbound/{accessKey}/manifest?tpEvent={código}` — a API só + aceita o código numérico SEFAZ (sondado); o SDK aceita o código direto ou os + literais legados (`Confirmation`→210200, `Acknowledgement`→210210, + `Unknown`→210220, `Refused`→210240). + - `reprocessWebhook()`: `POST …/inbound/productinvoices/{key_or_nsu}/processwebhook` + — aceita chave de 44 dígitos **ou** NSU (1–15 dígitos). + +### Adicionado + +- `IdValidator::accessKeyOrNsu()` — normaliza chave de acesso (44 dígitos) ou NSU + (1–15 dígitos) para o reprocessamento de webhook. +- Teste de alinhamento `InboundSpecAlignmentTest`: amarra as 20 rotas à spec DF-e v2 + (verbo+path), pina a ausência dos esquemas de rota mortos e o tipo `integer` de + `tpEvent`; unit tests com verbo+path pinados por método. Rotas validadas ao vivo + com o SDK corrigido em 2026-07-30 (erros de domínio — settings/chave falsa/NSU — + nunca 404 de rota). + ## [3.3.1] — 2026-07-30 ### Corrigido diff --git a/docs/recursos/inbound-product-invoices.md b/docs/recursos/inbound-product-invoices.md index bd903cc..4963646 100644 --- a/docs/recursos/inbound-product-invoices.md +++ b/docs/recursos/inbound-product-invoices.md @@ -33,8 +33,14 @@ dados — sempre usa a chave principal. | `getEventXml($companyId, $accessKey, $eventKey)` | XML de um evento. | `string` | | `getPdf($companyId, $accessKey)` | DANFE em PDF. | `string` | | `getJson($companyId, $accessKey)` | Representação JSON do documento. | `array` | -| `manifest($companyId, $accessKey, $manifestType, $data = [])` | Manifestação do destinatário. | `array` | -| `reprocessWebhook($companyId, $accessKey)` | Reenvia o webhook do documento. | `array` | +| `manifest($companyId, $accessKey, $manifestType, $data = [])` | Manifestação do destinatário (`$manifestType` = código SEFAZ ou literal, ver abaixo). | `array` | +| `reprocessWebhook($companyId, $accessKey)` | Reenvia o webhook do documento; aceita a chave de 44 dígitos **ou** o NSU (1–15 dígitos). | `array` | + +:::warning Corrigido na v3.4.0 +Até a v3.3.x este recurso apontava para rotas que **não existem** na API — todos +os métodos retornavam 404/erro. A v3.4.0 migrou tudo para o contrato real +`/inbound/…` (`consulta-dfe-distribuicao-v2`), mantendo nomes e assinaturas. +::: ## Ativar a busca automática @@ -61,22 +67,37 @@ file_put_contents('entrada.pdf', $nfe->inboundProductInvoices->getPdf($companyId ## Manifestação do destinatário +A API espera o **código numérico SEFAZ** do evento (query `tpEvent`). O SDK +aceita o código direto ou os literais equivalentes: + +| Código | Literal aceito | Evento | +|---|---|---| +| `210200` | `Confirmation` | Confirmação da Operação | +| `210210` | `Acknowledgement` | Ciência da Operação | +| `210220` | `Unknown` | Desconhecimento da Operação | +| `210240` | `Refused` | Operação não Realizada | + ```php -// Ex.: confirmar a operação -$nfe->inboundProductInvoices->manifest($companyId, $accessKey, 'Confirmation'); +// Ex.: ciência da operação (código direto) +$nfe->inboundProductInvoices->manifest($companyId, $accessKey, '210210'); // Ex.: desconhecer a operação, com justificativa no corpo -$nfe->inboundProductInvoices->manifest($companyId, $accessKey, 'Ignorance', [ - 'reason' => 'Operação não reconhecida', +$nfe->inboundProductInvoices->manifest($companyId, $accessKey, 'Unknown', [ + 'justification' => 'Operação não reconhecida', ]); ``` +Qualquer outro valor lança `Nfe\Exception\InvalidRequestException` antes de +qualquer HTTP. + ## Reprocessar o webhook -Se a sua aplicação perdeu uma entrega, peça o reenvio do evento do documento: +Se a sua aplicação perdeu uma entrega, peça o reenvio do evento do documento — +pela chave de acesso ou pelo NSU: ```php $nfe->inboundProductInvoices->reprocessWebhook($companyId, $accessKey); +$nfe->inboundProductInvoices->reprocessWebhook($companyId, '123456789'); // NSU ``` ## Próximos passos diff --git a/docs/recursos/transportation-invoices.md b/docs/recursos/transportation-invoices.md index 065d5fd..44ef001 100644 --- a/docs/recursos/transportation-invoices.md +++ b/docs/recursos/transportation-invoices.md @@ -30,6 +30,13 @@ principal (não é família de dados). | `getEvent($companyId, $accessKey, $eventKey)` | Consulta um evento do CT-e. | `array` | | `downloadEventXml($companyId, $accessKey, $eventKey)` | XML de um evento. | `string` | +:::warning Corrigido na v3.4.0 +Até a v3.3.x este recurso apontava para rotas `/cte/…` que **não existem** na +API — todos os métodos retornavam 404/erro. A v3.4.0 migrou tudo para o +contrato real `/inbound/…` (`consulta-dfe-distribuicao-v2`), mantendo nomes e +assinaturas. +::: + ## Habilitar o recebimento ```php 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 dc7bbd2..3a462d9 100644 --- a/skills/nfeio-php-sdk/references/product-invoices-and-taxes.md +++ b/skills/nfeio-php-sdk/references/product-invoices-and-taxes.md @@ -95,6 +95,7 @@ downloadEventXml(string $companyId, string $accessKey, string $eventKey, ?Reques ``` - **Does not use `dataApiKey`** — always the main `apiKey`. +- Routes live under `/v2/companies/{id}/inbound/transportationinvoices` (config) and the generic `/inbound/{accessKey}…` (queries); `enable` is a POST. Fixed in v3.4.0 — before that, the resource hit dead `/cte/…` routes and every method 404'd. ## `$nfe->inboundProductInvoices` (Inbound NF-e / DFe distribution — Company-scoped) @@ -115,3 +116,6 @@ reprocessWebhook(string $companyId, string $accessKey, ?RequestOptions $options ``` - `getXml`/`getEventXml`/`getPdf` return raw byte `string`. **Does not use `dataApiKey`** — always the main `apiKey`. +- Routes live under `/v2/companies/{id}/inbound/…` (fixed in v3.4.0; the old `/productinvoices/received/…` and `/productinvoices/inbound` routes never existed on the API — every method 404'd until then). `enableAutoFetch` is a POST. +- `manifest()` sends the SEFAZ numeric event code as query `tpEvent` (probed: the API rejects literals). Pass the code (`'210210'`) or a legacy literal, mapped as `Confirmation`→210200, `Acknowledgement`→210210, `Unknown`→210220, `Refused`→210240; anything else throws `InvalidRequestException` locally. +- `reprocessWebhook()` accepts a 44-digit access key **or** a 1–15 digit NSU (`IdValidator::accessKeyOrNsu`), hitting `POST …/inbound/productinvoices/{key_or_nsu}/processwebhook`. diff --git a/src/Resource/InboundProductInvoicesResource.php b/src/Resource/InboundProductInvoicesResource.php index 88a81c6..01a2302 100644 --- a/src/Resource/InboundProductInvoicesResource.php +++ b/src/Resource/InboundProductInvoicesResource.php @@ -37,7 +37,7 @@ public function enableAutoFetch( ?RequestOptions $options = null, ): InboundSettings { $companyId = IdValidator::companyId($companyId); - $response = $this->httpPut("/companies/{$companyId}/productinvoices/inbound", $data, $options); + $response = $this->httpPost("/companies/{$companyId}/inbound/productinvoices", $data, $options); return $this->hydrate(InboundSettings::class, $this->decodeBody($response->body)); } @@ -45,7 +45,7 @@ public function enableAutoFetch( public function disableAutoFetch(string $companyId, ?RequestOptions $options = null): InboundSettings { $companyId = IdValidator::companyId($companyId); - $response = $this->httpDelete("/companies/{$companyId}/productinvoices/inbound", $options); + $response = $this->httpDelete("/companies/{$companyId}/inbound/productinvoices", $options); return $this->hydrate(InboundSettings::class, $this->decodeBody($response->body)); } @@ -53,7 +53,7 @@ public function disableAutoFetch(string $companyId, ?RequestOptions $options = n public function getSettings(string $companyId, ?RequestOptions $options = null): InboundSettings { $companyId = IdValidator::companyId($companyId); - $response = $this->httpGet("/companies/{$companyId}/productinvoices/inbound", options: $options); + $response = $this->httpGet("/companies/{$companyId}/inbound/productinvoices", options: $options); return $this->hydrate(InboundSettings::class, $this->decodeBody($response->body)); } @@ -66,7 +66,7 @@ public function getDetails(string $companyId, string $accessKey, ?RequestOptions $companyId = IdValidator::companyId($companyId); $accessKey = IdValidator::accessKey($accessKey); $response = $this->httpGet( - "/companies/{$companyId}/productinvoices/received/{$accessKey}", + "/companies/{$companyId}/inbound/{$accessKey}", options: $options, ); @@ -84,7 +84,7 @@ public function getProductInvoiceDetails( $companyId = IdValidator::companyId($companyId); $accessKey = IdValidator::accessKey($accessKey); $response = $this->httpGet( - "/companies/{$companyId}/productinvoices/received/{$accessKey}/productinvoice", + "/companies/{$companyId}/inbound/productinvoices/{$accessKey}", options: $options, ); @@ -104,7 +104,7 @@ public function getEventDetails( $accessKey = IdValidator::accessKey($accessKey); $eventKey = IdValidator::eventKey($eventKey); $response = $this->httpGet( - "/companies/{$companyId}/productinvoices/received/{$accessKey}/events/{$eventKey}", + "/companies/{$companyId}/inbound/{$accessKey}/events/{$eventKey}", options: $options, ); @@ -124,7 +124,7 @@ public function getProductInvoiceEventDetails( $accessKey = IdValidator::accessKey($accessKey); $eventKey = IdValidator::eventKey($eventKey); $response = $this->httpGet( - "/companies/{$companyId}/productinvoices/received/{$accessKey}/events/{$eventKey}/productinvoice", + "/companies/{$companyId}/inbound/productinvoices/{$accessKey}/events/{$eventKey}", options: $options, ); @@ -137,7 +137,7 @@ public function getXml(string $companyId, string $accessKey, ?RequestOptions $op $accessKey = IdValidator::accessKey($accessKey); return $this->download( - "/companies/{$companyId}/productinvoices/received/{$accessKey}/xml", + "/companies/{$companyId}/inbound/{$accessKey}/xml", options: $options, ); } @@ -153,7 +153,7 @@ public function getEventXml( $eventKey = IdValidator::eventKey($eventKey); return $this->download( - "/companies/{$companyId}/productinvoices/received/{$accessKey}/events/{$eventKey}/xml", + "/companies/{$companyId}/inbound/{$accessKey}/events/{$eventKey}/xml", options: $options, ); } @@ -164,7 +164,7 @@ public function getPdf(string $companyId, string $accessKey, ?RequestOptions $op $accessKey = IdValidator::accessKey($accessKey); return $this->download( - "/companies/{$companyId}/productinvoices/received/{$accessKey}/pdf", + "/companies/{$companyId}/inbound/{$accessKey}/pdf", options: $options, ); } @@ -177,7 +177,7 @@ public function getJson(string $companyId, string $accessKey, ?RequestOptions $o $companyId = IdValidator::companyId($companyId); $accessKey = IdValidator::accessKey($accessKey); $response = $this->httpGet( - "/companies/{$companyId}/productinvoices/received/{$accessKey}/json", + "/companies/{$companyId}/inbound/productinvoices/{$accessKey}/json", options: $options, ); @@ -185,7 +185,13 @@ public function getJson(string $companyId, string $accessKey, ?RequestOptions $o } /** - * Manifestar o destinatário (ciência, confirmação, desconhecimento, refutação). + * Manifestar o destinatário (ciência, confirmação, desconhecimento, operação não realizada). + * + * A API espera o código numérico SEFAZ no query param `tpEvent` (sondado + * 2026-07-30: literais são rejeitados pelo binder). Aceita o código direto + * (`'210210'`) ou os literais legados deste SDK, mapeados assim: + * `Confirmation`→210200, `Acknowledgement`→210210, `Unknown`→210220, + * `Refused`→210240. * * @param array $data Pode conter justification, etc. dependendo do tipo. * @return array @@ -199,11 +205,14 @@ public function manifest( ): array { $companyId = IdValidator::companyId($companyId); $accessKey = IdValidator::accessKey($accessKey); - if (trim($manifestType) === '') { - throw new \Nfe\Exception\InvalidRequestException('manifestType é obrigatório (Confirmation/Acknowledgement/Unknown/Refused).'); + $tpEvent = self::MANIFEST_EVENT_CODES[trim($manifestType)] ?? trim($manifestType); + if (preg_match('/^\d{6}$/', $tpEvent) !== 1) { + throw new \Nfe\Exception\InvalidRequestException( + 'manifestType inválido: use o código SEFAZ de 6 dígitos (210200/210210/210220/210240) ou Confirmation/Acknowledgement/Unknown/Refused.', + ); } - $response = $this->httpPut( - "/companies/{$companyId}/productinvoices/received/{$accessKey}/manifest/{$manifestType}", + $response = $this->httpPost( + "/companies/{$companyId}/inbound/{$accessKey}/manifest?" . http_build_query(['tpEvent' => $tpEvent]), $data, $options, ); @@ -212,7 +221,8 @@ public function manifest( } /** - * Reenvia o webhook para uma NF-e recebida. + * Reenvia o webhook para uma NF-e recebida, identificada pela chave de + * acesso (44 dígitos) OU pelo NSU (1–15 dígitos). * * @return array */ @@ -222,12 +232,20 @@ public function reprocessWebhook( ?RequestOptions $options = null, ): array { $companyId = IdValidator::companyId($companyId); - $accessKey = IdValidator::accessKey($accessKey); + $accessKey = IdValidator::accessKeyOrNsu($accessKey); $response = $this->httpPost( - "/companies/{$companyId}/productinvoices/received/{$accessKey}/webhook/reprocess", + "/companies/{$companyId}/inbound/productinvoices/{$accessKey}/processwebhook", options: $options, ); return $this->decodeBody($response->body); } + + /** Literais legados do SDK → código numérico SEFAZ do evento de manifestação. */ + private const MANIFEST_EVENT_CODES = [ + 'Confirmation' => '210200', + 'Acknowledgement' => '210210', + 'Unknown' => '210220', + 'Refused' => '210240', + ]; } diff --git a/src/Resource/TransportationInvoicesResource.php b/src/Resource/TransportationInvoicesResource.php index fdf064c..2315137 100644 --- a/src/Resource/TransportationInvoicesResource.php +++ b/src/Resource/TransportationInvoicesResource.php @@ -41,7 +41,7 @@ public function enable( ?RequestOptions $options = null, ): InboundSettings { $companyId = IdValidator::companyId($companyId); - $response = $this->httpPut("/companies/{$companyId}/cte/inbound", $data, $options); + $response = $this->httpPost("/companies/{$companyId}/inbound/transportationinvoices", $data, $options); $payload = $this->decodeBody($response->body); return $this->hydrate(InboundSettings::class, $payload); @@ -50,7 +50,7 @@ public function enable( public function disable(string $companyId, ?RequestOptions $options = null): InboundSettings { $companyId = IdValidator::companyId($companyId); - $response = $this->httpDelete("/companies/{$companyId}/cte/inbound", $options); + $response = $this->httpDelete("/companies/{$companyId}/inbound/transportationinvoices", $options); $payload = $this->decodeBody($response->body); return $this->hydrate(InboundSettings::class, $payload); @@ -59,7 +59,7 @@ public function disable(string $companyId, ?RequestOptions $options = null): Inb public function getSettings(string $companyId, ?RequestOptions $options = null): InboundSettings { $companyId = IdValidator::companyId($companyId); - $response = $this->httpGet("/companies/{$companyId}/cte/inbound", options: $options); + $response = $this->httpGet("/companies/{$companyId}/inbound/transportationinvoices", options: $options); $payload = $this->decodeBody($response->body); return $this->hydrate(InboundSettings::class, $payload); @@ -77,7 +77,7 @@ public function retrieve( ): array { $companyId = IdValidator::companyId($companyId); $accessKey = IdValidator::accessKey($accessKey); - $response = $this->httpGet("/companies/{$companyId}/cte/{$accessKey}", options: $options); + $response = $this->httpGet("/companies/{$companyId}/inbound/{$accessKey}", options: $options); return $this->decodeBody($response->body); } @@ -90,7 +90,7 @@ public function downloadXml( $companyId = IdValidator::companyId($companyId); $accessKey = IdValidator::accessKey($accessKey); - return $this->download("/companies/{$companyId}/cte/{$accessKey}/xml", options: $options); + return $this->download("/companies/{$companyId}/inbound/{$accessKey}/xml", options: $options); } /** @@ -106,7 +106,7 @@ public function getEvent( $accessKey = IdValidator::accessKey($accessKey); $eventKey = IdValidator::eventKey($eventKey); $response = $this->httpGet( - "/companies/{$companyId}/cte/{$accessKey}/events/{$eventKey}", + "/companies/{$companyId}/inbound/{$accessKey}/events/{$eventKey}", options: $options, ); @@ -124,7 +124,7 @@ public function downloadEventXml( $eventKey = IdValidator::eventKey($eventKey); return $this->download( - "/companies/{$companyId}/cte/{$accessKey}/events/{$eventKey}/xml", + "/companies/{$companyId}/inbound/{$accessKey}/events/{$eventKey}/xml", options: $options, ); } diff --git a/src/Util/IdValidator.php b/src/Util/IdValidator.php index 6b147ca..6434a09 100644 --- a/src/Util/IdValidator.php +++ b/src/Util/IdValidator.php @@ -82,6 +82,29 @@ public static function accessKey(string $value): string return $digits; } + /** + * Normalise a 44-digit access key OR an NSU (1–15 digits). + * + * O endpoint de reprocessamento de webhook aceita `{access_key_or_nsu}` + * (spec consulta-dfe-distribuicao-v2): 44 dígitos são tratados como chave + * de acesso; qualquer outra contagem de 1 a 15 dígitos é aceita como NSU. + */ + public static function accessKeyOrNsu(string $value): string + { + $digits = preg_replace('/\D+/', '', $value) ?? ''; + $len = strlen($digits); + if ($len === 44 || ($len >= 1 && $len <= 15)) { + return $digits; + } + throw new InvalidRequestException( + sprintf( + 'Chave de acesso ou NSU inválido: esperado 44 dígitos (chave) ou 1–15 dígitos (NSU), recebido %d (input: "%s").', + $len, + $value, + ), + ); + } + /** * Normalise a CNPJ (14 digits). Strips punctuation; does not validate check digits. */ diff --git a/tests/Resource/InboundProductInvoicesResourceTest.php b/tests/Resource/InboundProductInvoicesResourceTest.php index e4a7a6e..75b573d 100644 --- a/tests/Resource/InboundProductInvoicesResourceTest.php +++ b/tests/Resource/InboundProductInvoicesResourceTest.php @@ -14,51 +14,125 @@ function buildIpiClient(MockTransport $mock): Client return new Client(config: new Config(apiKey: 'k', retry: RetryPolicy::none(), transport: $mock)); } +const IPI_KEY = '35261234567890123456789012345678901234567890'; + it('InboundProductInvoices routes to api.nfse.io v2', function (): void { $mock = (new MockTransport())->push(new Response(200, [], '{"enabled":true}')); buildIpiClient($mock)->inboundProductInvoices->getSettings('abc'); $sent = $mock->lastRequest(); expect($sent?->baseUrl)->toBe('https://api.nfse.io'); - expect($sent?->path)->toBe('/v2/companies/abc/productinvoices/inbound'); + expect($sent?->path)->toBe('/v2/companies/abc/inbound/productinvoices'); }); -it('getDetails hits received/{accessKey}', function (): void { - $mock = (new MockTransport())->push(new Response(200, [], '{}')); - buildIpiClient($mock)->inboundProductInvoices->getDetails( - 'abc', - '35261234567890123456789012345678901234567890', - ); +// Verbo+path pinados por método — rotas do contrato /inbound/… da +// consulta-dfe-distribuicao-v2, confirmadas vivas por sonda (2026-07-29/30). +// As rotas antigas (/productinvoices/received/…, /productinvoices/inbound) +// não existem na API (404 de rota / colisão com /productinvoices/{id}). +dataset('inbound nfe routes', [ + 'enableAutoFetch (POST, era PUT)' => [ + fn(Client $c) => $c->inboundProductInvoices->enableAutoFetch('abc', ['environment' => 'Production']), + 'POST', '/v2/companies/abc/inbound/productinvoices', '{"enabled":true}', + ], + 'disableAutoFetch' => [ + fn(Client $c) => $c->inboundProductInvoices->disableAutoFetch('abc'), + 'DELETE', '/v2/companies/abc/inbound/productinvoices', '{"enabled":false}', + ], + 'getSettings' => [ + fn(Client $c) => $c->inboundProductInvoices->getSettings('abc'), + 'GET', '/v2/companies/abc/inbound/productinvoices', '{"enabled":true}', + ], + 'getDetails (rota genérica)' => [ + fn(Client $c) => $c->inboundProductInvoices->getDetails('abc', IPI_KEY), + 'GET', '/v2/companies/abc/inbound/' . IPI_KEY, '{}', + ], + 'getProductInvoiceDetails' => [ + fn(Client $c) => $c->inboundProductInvoices->getProductInvoiceDetails('abc', IPI_KEY), + 'GET', '/v2/companies/abc/inbound/productinvoices/' . IPI_KEY, '{}', + ], + 'getEventDetails (rota genérica)' => [ + fn(Client $c) => $c->inboundProductInvoices->getEventDetails('abc', IPI_KEY, 'evt-1'), + 'GET', '/v2/companies/abc/inbound/' . IPI_KEY . '/events/evt-1', '{}', + ], + 'getProductInvoiceEventDetails' => [ + fn(Client $c) => $c->inboundProductInvoices->getProductInvoiceEventDetails('abc', IPI_KEY, 'evt-1'), + 'GET', '/v2/companies/abc/inbound/productinvoices/' . IPI_KEY . '/events/evt-1', '{}', + ], + 'getXml (rota genérica)' => [ + fn(Client $c) => $c->inboundProductInvoices->getXml('abc', IPI_KEY), + 'GET', '/v2/companies/abc/inbound/' . IPI_KEY . '/xml', '', + ], + 'getEventXml (rota genérica)' => [ + fn(Client $c) => $c->inboundProductInvoices->getEventXml('abc', IPI_KEY, 'evt-1'), + 'GET', '/v2/companies/abc/inbound/' . IPI_KEY . '/events/evt-1/xml', '', + ], + 'getPdf (rota genérica)' => [ + fn(Client $c) => $c->inboundProductInvoices->getPdf('abc', IPI_KEY), + 'GET', '/v2/companies/abc/inbound/' . IPI_KEY . '/pdf', '%PDF-1.4', + ], + 'getJson' => [ + fn(Client $c) => $c->inboundProductInvoices->getJson('abc', IPI_KEY), + 'GET', '/v2/companies/abc/inbound/productinvoices/' . IPI_KEY . '/json', '{}', + ], + 'manifest (POST + tpEvent em query, era PUT /manifest/{type})' => [ + fn(Client $c) => $c->inboundProductInvoices->manifest('abc', IPI_KEY, '210210'), + 'POST', '/v2/companies/abc/inbound/' . IPI_KEY . '/manifest?tpEvent=210210', '{}', + ], + 'reprocessWebhook (processwebhook, era /webhook/reprocess)' => [ + fn(Client $c) => $c->inboundProductInvoices->reprocessWebhook('abc', IPI_KEY), + 'POST', '/v2/companies/abc/inbound/productinvoices/' . IPI_KEY . '/processwebhook', '{}', + ], +]); + +it('pins verb+path', function (callable $call, string $method, string $path, string $body): void { + $mock = (new MockTransport())->push(new Response(200, [], $body)); + $call(buildIpiClient($mock)); + + expect($mock->lastRequest()?->method)->toBe($method); + expect($mock->lastRequest()?->path)->toBe($path); +})->with('inbound nfe routes'); + +it('manifest maps the legacy literals to SEFAZ numeric codes', function (): void { + // Sondado 2026-07-30: o binder da API só aceita tpEvent numérico + // ("The value 'Confirmation' is not valid."). + $map = [ + 'Confirmation' => '210200', + 'Acknowledgement' => '210210', + 'Unknown' => '210220', + 'Refused' => '210240', + ]; + foreach ($map as $literal => $code) { + $mock = (new MockTransport())->push(new Response(200, [], '{}')); + buildIpiClient($mock)->inboundProductInvoices->manifest('abc', IPI_KEY, $literal); + expect($mock->lastRequest()?->path) + ->toBe('/v2/companies/abc/inbound/' . IPI_KEY . '/manifest?tpEvent=' . $code); + } +}); + +it('manifest rejects an empty or unknown manifestType', function (string $bad): void { + expect(fn() => buildIpiClient(new MockTransport())->inboundProductInvoices->manifest('abc', IPI_KEY, $bad)) + ->toThrow(InvalidRequestException::class); +})->with([' ', 'Ciencia', '21021']); + +it('reprocessWebhook accepts an NSU instead of a 44-digit access key', function (): void { + $mock = (new MockTransport())->push(new Response(202, [], '{}')); + buildIpiClient($mock)->inboundProductInvoices->reprocessWebhook('abc', '123456789'); expect($mock->lastRequest()?->path) - ->toBe('/v2/companies/abc/productinvoices/received/35261234567890123456789012345678901234567890'); + ->toBe('/v2/companies/abc/inbound/productinvoices/123456789/processwebhook'); }); -it('manifest requires a non-empty manifestType', function (): void { - expect(fn() => buildIpiClient(new MockTransport())->inboundProductInvoices->manifest( +it('reprocessWebhook rejects digit counts that are neither key nor NSU', function (): void { + expect(fn() => buildIpiClient(new MockTransport())->inboundProductInvoices->reprocessWebhook( 'abc', - '35261234567890123456789012345678901234567890', - ' ', + '12345678901234567890', // 20 dígitos: nem chave (44) nem NSU (1–15) ))->toThrow(InvalidRequestException::class); }); it('getPdf returns raw bytes', function (): void { $pdf = "%PDF-1.4\nfake-payload"; $mock = (new MockTransport())->push(new Response(200, [], $pdf)); - $bytes = buildIpiClient($mock)->inboundProductInvoices->getPdf( - 'abc', - '35261234567890123456789012345678901234567890', - ); + $bytes = buildIpiClient($mock)->inboundProductInvoices->getPdf('abc', IPI_KEY); expect($bytes)->toBe($pdf); }); - -it('reprocessWebhook is a POST', function (): void { - $mock = (new MockTransport())->push(new Response(202, [], '{}')); - buildIpiClient($mock)->inboundProductInvoices->reprocessWebhook( - 'abc', - '35261234567890123456789012345678901234567890', - ); - - expect($mock->lastRequest()?->method)->toBe('POST'); -}); diff --git a/tests/Resource/InboundSpecAlignmentTest.php b/tests/Resource/InboundSpecAlignmentTest.php new file mode 100644 index 0000000..667bddd --- /dev/null +++ b/tests/Resource/InboundSpecAlignmentTest.php @@ -0,0 +1,128 @@ + O spec OpenAPI completo, parseado. + */ +function loadDfeDistribuicaoSpec(): array +{ + static $spec = null; + if ($spec === null) { + $spec = Yaml::parseFile(__DIR__ . '/../../openapi/consulta-dfe-distribuicao-v2.yaml'); + expect($spec)->toBeArray(); + } + + return $spec; +} + +it('every SDK inbound route exists in the DF-e v2 spec with the matching verb', function (): void { + $paths = loadDfeDistribuicaoSpec()['paths']; + + // (template da spec, verbo) por método do SDK — espelho da tabela D2 do design. + $expected = [ + // InboundProductInvoicesResource + 'enableAutoFetch' => ['/v2/companies/{company_id}/inbound/productinvoices', 'post'], + 'disableAutoFetch' => ['/v2/companies/{company_id}/inbound/productinvoices', 'delete'], + 'getSettings (NF-e)' => ['/v2/companies/{company_id}/inbound/productinvoices', 'get'], + 'getDetails' => ['/v2/companies/{company_id}/inbound/{access_key}', 'get'], + 'getProductInvoiceDetails' => ['/v2/companies/{company_id}/inbound/productinvoices/{access_key}', 'get'], + 'getEventDetails' => ['/v2/companies/{company_id}/inbound/{access_key}/events/{event_key}', 'get'], + 'getProductInvoiceEventDetails' => ['/v2/companies/{company_id}/inbound/productinvoices/{access_key}/events/{event_key}', 'get'], + 'getXml' => ['/v2/companies/{company_id}/inbound/{access_key}/xml', 'get'], + 'getEventXml' => ['/v2/companies/{company_id}/inbound/{access_key}/events/{event_key}/xml', 'get'], + 'getPdf' => ['/v2/companies/{company_id}/inbound/{access_key}/pdf', 'get'], + 'getJson' => ['/v2/companies/{company_id}/inbound/productinvoices/{access_key}/json', 'get'], + 'manifest' => ['/v2/companies/{company_id}/inbound/{access_key}/manifest', 'post'], + 'reprocessWebhook' => ['/v2/companies/{company_id}/inbound/productinvoices/{access_key_or_nsu}/processwebhook', 'post'], + // TransportationInvoicesResource + 'enable (CT-e)' => ['/v2/companies/{companyId}/inbound/transportationinvoices', 'post'], + 'disable (CT-e)' => ['/v2/companies/{companyId}/inbound/transportationinvoices', 'delete'], + 'getSettings (CT-e)' => ['/v2/companies/{companyId}/inbound/transportationinvoices', 'get'], + 'retrieve (CT-e)' => ['/v2/companies/{company_id}/inbound/{access_key}', 'get'], + 'downloadXml (CT-e)' => ['/v2/companies/{company_id}/inbound/{access_key}/xml', 'get'], + 'getEvent (CT-e)' => ['/v2/companies/{company_id}/inbound/{access_key}/events/{event_key}', 'get'], + 'downloadEventXml (CT-e)' => ['/v2/companies/{company_id}/inbound/{access_key}/events/{event_key}/xml', 'get'], + ]; + + foreach ($expected as $method => [$template, $verb]) { + expect(array_key_exists($template, $paths))->toBeTrue("rota de {$method} sumiu da spec"); + expect(array_key_exists($verb, $paths[$template] ?? []))->toBeTrue("verbo de {$method} mudou na spec"); + } +}); + +it('the dead legacy route schemes stay absent from the spec', function (): void { + // /productinvoices/received/… , /productinvoices/inbound e /cte/… nunca + // existiram no contrato; sondado morto em 2026-07-29. Se um sync de spec + // os introduzir, reavaliar as rotas dos resources. + $paths = array_keys(loadDfeDistribuicaoSpec()['paths']); + + foreach ($paths as $path) { + expect($path)->not->toContain('/productinvoices/received'); + expect($path)->not->toContain('/cte/'); + } +}); + +it('manifest tpEvent is an integer query param in the spec (probed: literals rejected)', function (): void { + $op = loadDfeDistribuicaoSpec()['paths']['/v2/companies/{company_id}/inbound/{access_key}/manifest']['post']; + + $tpEvent = null; + foreach ($op['parameters'] as $param) { + if (($param['name'] ?? '') === 'tpEvent') { + $tpEvent = $param; + } + } + expect($tpEvent)->toBeArray(); + expect($tpEvent['in'])->toBe('query'); + expect($tpEvent['schema']['type'])->toBe('integer'); +}); + +it('keeps the public signatures of all 20 inbound methods stable', function (): void { + $expected = [ + InboundProductInvoicesResource::class => [ + 'enableAutoFetch' => ['companyId', 'data', 'options'], + 'disableAutoFetch' => ['companyId', 'options'], + 'getSettings' => ['companyId', 'options'], + 'getDetails' => ['companyId', 'accessKey', 'options'], + 'getProductInvoiceDetails' => ['companyId', 'accessKey', 'options'], + 'getEventDetails' => ['companyId', 'accessKey', 'eventKey', 'options'], + 'getProductInvoiceEventDetails' => ['companyId', 'accessKey', 'eventKey', 'options'], + 'getXml' => ['companyId', 'accessKey', 'options'], + 'getEventXml' => ['companyId', 'accessKey', 'eventKey', 'options'], + 'getPdf' => ['companyId', 'accessKey', 'options'], + 'getJson' => ['companyId', 'accessKey', 'options'], + 'manifest' => ['companyId', 'accessKey', 'manifestType', 'data', 'options'], + 'reprocessWebhook' => ['companyId', 'accessKey', 'options'], + ], + TransportationInvoicesResource::class => [ + 'enable' => ['companyId', 'data', 'options'], + 'disable' => ['companyId', 'options'], + 'getSettings' => ['companyId', 'options'], + 'retrieve' => ['companyId', 'accessKey', 'options'], + 'downloadXml' => ['companyId', 'accessKey', 'options'], + 'getEvent' => ['companyId', 'accessKey', 'eventKey', 'options'], + 'downloadEventXml' => ['companyId', 'accessKey', 'eventKey', 'options'], + ], + ]; + + foreach ($expected as $class => $methods) { + foreach ($methods as $method => $params) { + $actual = array_map( + fn(ReflectionParameter $p): string => $p->getName(), + (new ReflectionMethod($class, $method))->getParameters(), + ); + expect($actual)->toBe($params, "assinatura de {$class}::{$method} mudou"); + } + } +}); diff --git a/tests/Resource/TransportationInvoicesResourceTest.php b/tests/Resource/TransportationInvoicesResourceTest.php index 80416f4..23d5d4c 100644 --- a/tests/Resource/TransportationInvoicesResourceTest.php +++ b/tests/Resource/TransportationInvoicesResourceTest.php @@ -14,24 +14,66 @@ function buildTiClient(MockTransport $mock): Client return new Client(config: new Config(apiKey: 'k', retry: RetryPolicy::none(), transport: $mock)); } +const TI_KEY = '35261234567890123456789012345678901234567890'; + it('TransportationInvoices routes to api.nfse.io v2', function (): void { $mock = (new MockTransport())->push(new Response(200, [], '{"enabled":true}')); buildTiClient($mock)->transportationInvoices->getSettings('abc'); $sent = $mock->lastRequest(); expect($sent?->baseUrl)->toBe('https://api.nfse.io'); - expect($sent?->path)->toBe('/v2/companies/abc/cte/inbound'); + expect($sent?->path)->toBe('/v2/companies/abc/inbound/transportationinvoices'); }); -it('retrieve normalises accessKey and hits the right path', function (): void { +// Verbo+path pinados por método — rotas /inbound/… da consulta-dfe-distribuicao-v2 +// (as antigas /cte/… não existem na API; sondado 2026-07-29). +dataset('cte routes', [ + 'enable (POST, era PUT)' => [ + fn(Client $c) => $c->transportationInvoices->enable('abc', ['environment' => 'Production']), + 'POST', '/v2/companies/abc/inbound/transportationinvoices', '{"enabled":true}', + ], + 'disable' => [ + fn(Client $c) => $c->transportationInvoices->disable('abc'), + 'DELETE', '/v2/companies/abc/inbound/transportationinvoices', '{"enabled":false}', + ], + 'getSettings' => [ + fn(Client $c) => $c->transportationInvoices->getSettings('abc'), + 'GET', '/v2/companies/abc/inbound/transportationinvoices', '{"enabled":true}', + ], + 'retrieve (rota genérica /inbound/{key})' => [ + fn(Client $c) => $c->transportationInvoices->retrieve('abc', TI_KEY), + 'GET', '/v2/companies/abc/inbound/' . TI_KEY, '{}', + ], + 'downloadXml' => [ + fn(Client $c) => $c->transportationInvoices->downloadXml('abc', TI_KEY), + 'GET', '/v2/companies/abc/inbound/' . TI_KEY . '/xml', '', + ], + 'getEvent' => [ + fn(Client $c) => $c->transportationInvoices->getEvent('abc', TI_KEY, 'evt-1'), + 'GET', '/v2/companies/abc/inbound/' . TI_KEY . '/events/evt-1', '{}', + ], + 'downloadEventXml' => [ + fn(Client $c) => $c->transportationInvoices->downloadEventXml('abc', TI_KEY, 'evt-1'), + 'GET', '/v2/companies/abc/inbound/' . TI_KEY . '/events/evt-1/xml', '', + ], +]); + +it('pins verb+path', function (callable $call, string $method, string $path, string $body): void { + $mock = (new MockTransport())->push(new Response(200, [], $body)); + $call(buildTiClient($mock)); + + expect($mock->lastRequest()?->method)->toBe($method); + expect($mock->lastRequest()?->path)->toBe($path); +})->with('cte routes'); + +it('retrieve normalises accessKey (strips spaces)', function (): void { $mock = (new MockTransport())->push(new Response(200, [], '{}')); buildTiClient($mock)->transportationInvoices->retrieve( 'abc', '3526 1234 5678 9012 3456 7890 1234 5678 9012 3456 7890', ); - expect($mock->lastRequest()?->path) - ->toBe('/v2/companies/abc/cte/35261234567890123456789012345678901234567890'); + expect($mock->lastRequest()?->path)->toBe('/v2/companies/abc/inbound/' . TI_KEY); }); it('rejects malformed access keys', function (): void { @@ -39,16 +81,10 @@ function buildTiClient(MockTransport $mock): Client ->toThrow(InvalidRequestException::class); }); -it('downloadEventXml returns raw bytes from the right path', function (): void { +it('downloadEventXml returns raw bytes', function (): void { $xml = ''; $mock = (new MockTransport())->push(new Response(200, [], $xml)); - $bytes = buildTiClient($mock)->transportationInvoices->downloadEventXml( - 'abc', - '35261234567890123456789012345678901234567890', - 'evt-1', - ); + $bytes = buildTiClient($mock)->transportationInvoices->downloadEventXml('abc', TI_KEY, 'evt-1'); expect($bytes)->toBe($xml); - expect($mock->lastRequest()?->path) - ->toBe('/v2/companies/abc/cte/35261234567890123456789012345678901234567890/events/evt-1/xml'); }); diff --git a/tests/Util/IdValidatorTest.php b/tests/Util/IdValidatorTest.php index b2ba874..bd3f67d 100644 --- a/tests/Util/IdValidatorTest.php +++ b/tests/Util/IdValidatorTest.php @@ -28,6 +28,21 @@ expect(fn() => IdValidator::accessKey(str_repeat('1', 45)))->toThrow(InvalidRequestException::class); }); +it('accessKeyOrNsu accepts a 44-digit key or a 1–15 digit NSU', function (): void { + $key = '3526 1234 5678 9012 3456 7890 1234 5678 9012 3456 7890'; + expect(IdValidator::accessKeyOrNsu($key))->toBe('35261234567890123456789012345678901234567890'); + expect(IdValidator::accessKeyOrNsu('1'))->toBe('1'); + expect(IdValidator::accessKeyOrNsu('000123456789'))->toBe('000123456789'); + expect(IdValidator::accessKeyOrNsu(str_repeat('9', 15)))->toBe(str_repeat('9', 15)); +}); + +it('accessKeyOrNsu rejects lengths that are neither key nor NSU', function (): void { + expect(fn() => IdValidator::accessKeyOrNsu(''))->toThrow(InvalidRequestException::class); + expect(fn() => IdValidator::accessKeyOrNsu(str_repeat('1', 16)))->toThrow(InvalidRequestException::class); + expect(fn() => IdValidator::accessKeyOrNsu(str_repeat('1', 43)))->toThrow(InvalidRequestException::class); + expect(fn() => IdValidator::accessKeyOrNsu(str_repeat('1', 45)))->toThrow(InvalidRequestException::class); +}); + it('normalises CNPJ to 14 digits', function (): void { expect(IdValidator::cnpj('12.345.678/0001-90'))->toBe('12345678000190'); expect(IdValidator::cnpj('12345678000190'))->toBe('12345678000190');