diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fb6d16..be065b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,147 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR ## [Não lançado] +## [6.0.1] - 2026-10-16 + +> **Patch de contrato e de documentação.** Dois bugs de código, provados por sonda ao vivo +> contra a API real (2026-10-14 a 10-16), e uma rodada de correções na documentação, que +> ensinava métodos sem caminho de sucesso e uma regra de chaves imprecisa. Nenhuma +> assinatura pública muda. Evidência versionada em `tests/fixtures/live-contracts/`. + +### Corrigido + +- **Quatro métodos da distribuição de NF-e chamavam uma rota que não existe.** + `inboundProductInvoices.getProductInvoiceDetails`, `getProductInvoiceEventDetails`, + `getJson` e `reprocessWebhook` montavam `/inbound/productinvoice/` (singular). A plataforma + serve só o plural, `/inbound/productinvoices/`. Medido em 2026-10-14: o singular responde + `404` de corpo vazio mesmo sem credencial, igual a um path inventado; o plural responde + `401` sem credencial, e `200` com ela nos dois métodos de leitura sondados + (`getProductInvoiceDetails` e `getJson`). A spec local `consulta-nfe-distribuicao-v1.yaml` ainda + diz singular; o upstream corrigiu em `nfe/docs@51d8816`. Cinco testes unitários afirmavam o + path errado e foram corrigidos junto. + + O bug era latente. Em 30 dias de log de gateway (2026-09-14 a 10-14), o plural teve 28.543 + respostas `200` e o singular, 9 respostas `404`, nenhuma com User-Agent do SDK. Evidência em + `inbound-distribution-routes.json`. + +- **`municipalTaxes`, `stateTaxes` e `notifications` devolviam o envelope no lugar da + entidade.** A API responde `{ "municipalTax": {…} }`, `{ "stateTax": {…} }` e + `{ "notification": {…} }`, e o SDK repassava o corpo tipado como a entidade: `resultado.id` + era `undefined`, sem erro de compilação. Agora `create`, `retrieve` e `update` de + `municipalTaxes` e `stateTaxes`, e `notifications.retrieve`, devolvem a entidade. + + O envelope foi medido no `retrieve` dos três em 2026-10-15. O `create` e o `update` não + foram sondados, porque exigiriam escrever num registro real; seguem a spec, que declara o + mesmo envelope. O desembrulho é defensivo: se a API responder a entidade direta, ela chega + intacta. + + **Migração:** quem contornava o bug lendo `(resultado as any).municipalTax` (ou `.stateTax`, + `.notification`) passa a receber `undefined` nessa leitura. Use o resultado direto. Código + que precisa rodar nas duas versões pode ler `(resultado as any).municipalTax ?? resultado`. + + Evidência em `response-envelopes.json`, que registra também três casos em que o SDK já + estava certo e a spec erra: `legalPeople.list`, `naturalPeople.retrieve` e + `addresses.lookupByPostalCode`. + +### Documentação + +- **`municipalTaxes.update` é um PUT que substitui a inscrição, e nada avisava.** Pela + documentação da plataforma (`nfe/docs@b9e7953`), omitir `taxNumber` apaga o número da + inscrição, e omitir `environment` volta para `Development`. `rpsNumber` e `lastRpsSent` são a + exceção: omitidos, preservam o contador de RPS; enviados, sobrescrevem o contador, inclusive + para trás. O JSDoc, `docs/recursos/municipal-taxes.md` e a skill agora mostram o roteiro: ler + com `retrieve`, alterar e reenviar sem os dois campos do contador. O SDK não sondou isso, + porque exigiria escrever sobre uma inscrição real. + +- **A documentação ensinava como funcionais métodos que só respondem `404`.** + `municipalTaxes.getSeries()`/`updatePrefecture()` e + `consumerInvoiceQuery.retrieve()`/`downloadXml()` estão `@deprecated` desde a 6.0.0, porque + a plataforma não serve as rotas. Mesmo assim, `docs/recursos/`, o README, `docs/API.md`, a + skill e os exemplos ainda os mostravam como caminho de uso; agora aparecem como + descontinuados. O exemplo de webhook por empresa do README passou ao webhook de conta + (`createAccountWebhook`), e a seção v2 → v3 do `MIGRATION.md` ganhou o aviso de que os + métodos por empresa estão descontinuados. + + `tests/unit/docs-drift.test.ts` passa a falhar quando um documento cita, sem aviso por + perto, um método `@deprecated` por rota não servida. O conjunto sai do código, não de uma + lista mantida à mão: entram os métodos com `@deprecated`, inclusive no nível da classe, + menos as depreciações funcionais declaradas com motivo (`companies.list`, + `webhooks.getAvailableEvents`). + +- **Links quebrados (issue #47).** O README apontava para + `docs/MIGRATION-TO-GENERATED-TYPES.md`, e a entrada 3.0 deste CHANGELOG para + `RELEASE_NOTES_v3.md`; nenhum dos dois existe, e ambos passam ao `MIGRATION.md`. + `docs/API.md` e `docs/EXTENDING.md` linkavam arquivos fora de `docs/` (`../src/core/types.ts`, + `../CONTRIBUTING.md`, `../LICENSE`), que quebram no portal, onde `docs/` é espelhado sozinho; + passam a URLs absolutas do repositório. O `docs-drift.test.ts` agora confere os links + relativos de README, `MIGRATION.md`, CHANGELOG, `docs/**` e `skills/**`: o arquivo precisa + existir e, em `docs/**`, o link não pode sair de `docs/`. + +- **Arquivo de licença (issue #47).** O repositório nunca teve um: o MIT existia só no campo + `license` do `package.json`, e a API do GitHub reportava o repositório como sem licença. + Entra o `LICENSE` com o texto MIT e a linha + `Copyright (c) 2026 NFE.IO HUB SERVICOS DE TECNOLOGIA LTDA`. O README, os cabeçalhos de + `src/` e o banner do bundle citam o mesmo titular. O banner usa `/*! … */`, que os + minificadores preservam quando a aplicação re-empacota o SDK. A licença cobre o SDK; o uso + da API segue o contrato da conta. `tests/unit/license.test.ts` compara o SPDX declarado com o + texto do arquivo e confere o arquivo no tarball. + +- **A skill publicada ensinava assinaturas de antes da 6.0.0.** Comparada ao código, ela tinha + 32 métodos com parâmetro, retorno ou nome errado, quase todos de quebras da 6.0.0 que não + chegaram a ela: + - downloads de `productInvoices`, `consumerInvoices`, `inboundProductInvoices` e + `transportationInvoices` descritos como `Buffer` ou string, quando devolvem um recurso com + URI; + - `productInvoices.sendCorrectionLetter`, `disable` e `disableRange`; o `cancel`, os + downloads e o `getStatus` de NFS-e; as leituras de NFC-e com um `environment` que não existe + mais; + - `transportationInvoices.retrieveEvent`, que não existe: o método é `getEvent`. + + Também estavam errados o CRUD de webhook por empresa, que aparecia como funcional numa tabela, + o pitfall de chaves, que dizia que o cálculo de tributos usa a chave de dados, e o tipo + `NfeStateTax`. Tudo foi conferido contra o código: os usos que a skill ensina agora compilam em + modo estrito, e os que ela ensinava antes não compilam. Entraram também as notas desta release + (distribuição no plural, envelopes). + +### Corrigido — registro, não código + +**A regra de chaves da 6.0.0 vale por rota, não por host.** A entrada 6.0.0 diz que as +duas chaves "são complementares, e cada uma responde `403` no território da outra". Isso +generalizava uma sonda de 2026-09-01 que só testou rotas de documento fiscal em +`api.nfse.io`. A matriz foi refeita em 2026-10-14 sobre 16 rotas: + +- a chave de dados é recusada (`403`) nas rotas de documento fiscal: tax-codes, NF-e, NFC-e, + distribuição, NFS-e v1 e, medido em 2026-10-16, o cálculo de tributos; +- a chave principal é recusada (`403`) nos hosts de consulta medidos: CNPJ, CEP e NF-e por + chave de acesso; +- na leitura, as rotas de cadastro da conta aceitam as duas: empresas (lista na v1, item e + lista na v2), inscrições municipais e estaduais, certificados, e webhooks nos dois hosts. A + escrita com a chave de dados não foi testada. + +O SDK não tinha bug: ele envia a chave principal a todo recurso de `api.nfe.io` e +`api.nfse.io`, e ela funciona em todas essas rotas. A aceitação da chave de dados no cadastro +não é contrato documentado, e o SDK não depende dela. README, `MIGRATION.md`, +`docs/multi-host-routing.md`, a skill e os JSDoc agora descrevem a regra por rota. + +Evidência em `api-key-route-matrix.json`. O fixture de 2026-09-01 continua como registro +histórico, com `supersededBy` apontando para o novo, e a entrada 6.0.0 abaixo não foi +reescrita. + +### Manutenção + +- **O `package-lock.json` estava na 5.2.0**, porque a 6.0.0 só atualizou o `package.json`. + Agora acompanha a versão, e `tests/unit/version.test.ts` falha se os dois divergirem. +- **O cabeçalho de `src/index.ts` ainda dizia `@version 5.1.0`.** A tag saiu, porque a versão + vive só em `src/version.ts`, e o `version.test.ts` passa a recusar `@version X.Y.Z` em + `src/`. A varredura de literais continua ignorando comentários, que citam versões antigas + como histórico. +- **Os testes de integração de `companies` que criam, alteram ou apagam empresas passam a + exigir `NFE_ALLOW_ACCOUNT_WRITES=true`.** Fora da CI, o `npm test` roda também a integração + sempre que há chave no `.env`, e cada rodada deixava sete empresas na conta: o + `companies.remove` responde com sucesso, mas a empresa continua lá, com status `Inactive` + (medido em 2026-10-16). Sem a variável, rodam só o `404` de empresa inexistente e a recusa + de corpo inválido, que não gravam nada. Ligue a variável só com uma conta descartável. + ## [6.0.0] - 2026-09-03 > **Major de correção de contrato.** Nada aqui é funcionalidade nova: são bugs provados por @@ -1168,7 +1309,7 @@ Todos os métodos mantêm a mesma assinatura básica, mas agora retornam Promise Para migrar da v2 para v3, consulte: - **Guia completo:** [MIGRATION.md](./MIGRATION.md) -- **Release notes:** [RELEASE_NOTES_v3.md](./RELEASE_NOTES_v3.md) +- **Release notes:** [Guia de migração v2 → v3](./MIGRATION.md#guia-de-migração-v2--v3) **Checklist rápido:** 1. ✅ Atualizar Node.js para >= 18.0.0 @@ -1215,9 +1356,17 @@ SDK JavaScript legado com API baseada em callbacks. ## Links -[Unreleased]: https://github.com/nfe/client-nodejs/compare/v5.2.0...HEAD +[Não lançado]: https://github.com/nfe/client-nodejs/compare/v6.0.1...HEAD +[6.0.1]: https://github.com/nfe/client-nodejs/compare/v6.0.0...v6.0.1 +[6.0.0]: https://github.com/nfe/client-nodejs/compare/v5.2.0...v6.0.0 [5.2.0]: https://github.com/nfe/client-nodejs/compare/v5.1.0...v5.2.0 [5.1.0]: https://github.com/nfe/client-nodejs/compare/v5.0.0...v5.1.0 [5.0.0]: https://github.com/nfe/client-nodejs/releases/tag/v5.0.0 -[3.0.0]: https://github.com/nfe/client-nodejs/releases/tag/v3.0.0 -[2.0.0]: https://github.com/nfe/client-nodejs/releases/tag/v2.0.0 +[4.0.0]: https://github.com/nfe/client-nodejs/compare/v3.2.1...v4.0.0 +[3.2.1]: https://github.com/nfe/client-nodejs/compare/v3.2.0...v3.2.1 +[3.2.0]: https://github.com/nfe/client-nodejs/compare/v3.1.0...v3.2.0 +[3.1.0]: https://github.com/nfe/client-nodejs/compare/v3.0.2...v3.1.0 +[3.0.2]: https://github.com/nfe/client-nodejs/compare/3.0.1...v3.0.2 +[3.0.1]: https://github.com/nfe/client-nodejs/compare/3.0.0...3.0.1 +[3.0.0]: https://github.com/nfe/client-nodejs/releases/tag/3.0.0 +[2.0.0]: https://www.npmjs.com/package/nfe-io/v/2.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index da9b745..0b1feca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -281,7 +281,7 @@ import { HttpClient } from 'nfe-io/dist/core/http/client'; ## 📜 Licença -Ao contribuir, você concorda que suas contribuições serão licenciadas sob a mesma licença do projeto (MIT). +Ao contribuir, você concorda que suas contribuições serão licenciadas sob a mesma licença do projeto, a MIT, cujo texto está em [LICENSE](./LICENSE). Não há CLA: a contribuição entra sob a mesma licença com que o projeto é distribuído. --- diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..52ab3d9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 NFE.IO HUB SERVICOS DE TECNOLOGIA LTDA + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MIGRATION.md b/MIGRATION.md index e6e1ea3..3d01a62 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -91,9 +91,12 @@ Empresa sem certificado responde `200` com lista vazia, não `404`. ### 6. A chave de dados não serve mais nos hosts fiscais -As duas chaves são **complementares**: cada uma responde `403` no território da outra. Nove -recursos de `api.nfse.io` resolviam a chave **de dados** num host **fiscal** e só funcionavam -por acidente, via o fallback `dataApiKey → apiKey`. +As duas chaves são **complementares**: a principal vale em `api.nfe.io` e `api.nfse.io`, e a de +dados, nos hosts de consulta. Nove recursos de `api.nfse.io` resolviam a chave **de dados**, que as +rotas de documento fiscal desse host recusam com `403`; eles só funcionavam por acidente, via o +fallback `dataApiKey → apiKey`. A v6 passou a enviar a principal a todos eles. (Correção de +2026-10-14: algumas rotas de cadastro da conta também aceitam a chave de dados, mas isso não é +contrato documentado, e o SDK não depende disso.) **Como migrar:** se você usa `dataApiKey`, nada a fazer — os nove passam a funcionar. Se você configurava **somente** `dataApiKey`, agora é preciso informar também `apiKey`: o acesso @@ -593,6 +596,11 @@ await nfe.naturalPeople.delete('company-id', 'person-id'); ### Webhooks ```javascript +// ⚠️ Desde a v5.1, os métodos de webhook por empresa estão descontinuados (@deprecated): +// a rota /v1/companies/{id}/webhooks responde 404. Use os métodos por conta — +// createAccountWebhook, listAccountWebhooks, retrieveAccountWebhook, updateAccountWebhook +// e deleteAccountWebhook. O bloco abaixo fica como registro histórico da v3. + // v2 nfe.webhooks.create(webhookData, callback); nfe.webhooks.list(callback); diff --git a/README.md b/README.md index 8e0bf23..91ae9f8 100644 --- a/README.md +++ b/README.md @@ -646,27 +646,13 @@ for (const event of result.events ?? []) { > **Nota:** A API de Consulta NF-e usa um host separado (`nfe.api.nfe.io`). Você pode configurar uma chave API específica com `dataApiKey`, ou o SDK usará `apiKey` como fallback. -#### 🧾 Consulta de Cupom Fiscal Eletrônico - CFe-SAT (`nfe.consumerInvoiceQuery`) +#### 🧾 Consulta de Cupom Fiscal Eletrônico - CFe-SAT (`nfe.consumerInvoiceQuery`) — descontinuado -Consultar CFe-SAT (Cupom Fiscal Eletrônico) por chave de acesso. Recurso somente leitura sem necessidade de escopo de empresa: - -```typescript -// Consultar dados completos do cupom fiscal -const coupon = await nfe.consumerInvoiceQuery.retrieve( - '35240112345678000190590000000012341234567890' -); -console.log('Status:', coupon.currentStatus); // 'Authorized' -console.log('Emissor:', coupon.issuer?.name); -console.log('Valor:', coupon.totals?.couponAmount); - -// Baixar XML do CFe -const xml = await nfe.consumerInvoiceQuery.downloadXml( - '35240112345678000190590000000012341234567890' -); -fs.writeFileSync('cfe.xml', xml); -``` - -> **Nota:** A API de Consulta CFe-SAT usa o mesmo host (`nfe.api.nfe.io`) e chave de API que a consulta de NF-e. +> ⚠️ **Descontinuado: a plataforma não serve estas rotas.** `/v1/consumerinvoices/coupon/{chave}` +> e `.xml` estão declaradas na OpenAPI, mas respondem `404` igual ao de um caminho +> inexistente, inclusive sem credencial (medido em 2026-09-02). Os métodos `retrieve()` e +> `downloadXml()` estão marcados `@deprecated`: continuam emitindo a requisição, e o `404` +> vem com essa explicação. Não há substituto na API hoje. #### 🏢 Consulta CNPJ / Pessoa Jurídica (`nfe.legalEntityLookup`) @@ -969,40 +955,38 @@ emitirNotaFiscal().catch(console.error); ### Configuração de Webhook ```typescript -// Configurar webhook para receber eventos de notas fiscais -const webhook = await nfe.webhooks.create(empresaId, { - url: 'https://meuapp.com.br/api/webhooks/nfe', - events: [ - 'invoice.issued', - 'invoice.cancelled', - 'invoice.error' - ], - active: true +// Webhooks são por conta (/v2/webhooks), sem companyId. Na criação, a NFE.io faz um +// ping na `uri`: o endpoint precisa estar no ar e responder 2xx. +const webhook = await nfe.webhooks.createAccountWebhook({ + uri: 'https://meuapp.com.br/api/webhooks/nfe', + contentType: 'json', + secret: process.env.WEBHOOK_SECRET!, // 32 a 64 caracteres + filters: ['service_invoice.issued_successfully', 'service_invoice.cancelled_successfully'], }); -// No seu endpoint de webhook (capture o corpo cru: app.use(express.raw({ type: '*/*' }))) -app.post('/api/webhooks/nfe', (req, res) => { - const assinatura = req.headers['x-hub-signature']; // header correto (HMAC-SHA1), não 'x-nfe-signature' +// No endpoint, valide a assinatura sobre o corpo bruto (Buffer), não sobre o JSON parseado +app.post('/api/webhooks/nfe', express.raw({ type: '*/*' }), (req, res) => { const ehValido = nfe.webhooks.validateSignature( - req.body, - assinatura, + req.body, // Buffer bruto + req.headers['x-hub-signature'], // HMAC-SHA1 process.env.WEBHOOK_SECRET ); - + if (!ehValido) { return res.status(401).send('Assinatura inválida'); } - - const { event, data } = req.body; - - if (event === 'invoice.issued') { - console.log('Nota fiscal emitida:', data.id); - } - + + const evento = JSON.parse(req.body.toString('utf8')); + // ... processe o evento + res.status(200).send('OK'); }); ``` +> Os métodos de webhook por empresa (`webhooks.list/create/retrieve/update/delete/test` +> com `companyId`) estão descontinuados: a rota `/v1/companies/{id}/webhooks` responde +> `404`. Use os métodos por conta acima. Lista de eventos ao vivo: `nfe.webhooks.fetchEventTypes()`. + ### Criação de Notas Fiscais em Lote ```typescript @@ -1097,7 +1081,7 @@ npm run build **Nota**: Arquivos gerados não devem ser editados manualmente. Edite as specs OpenAPI e regenere. -Para orientações de migração, veja [docs/MIGRATION-TO-GENERATED-TYPES.md](./docs/MIGRATION-TO-GENERATED-TYPES.md). +Para orientações de migração, veja [MIGRATION.md](./MIGRATION.md). ### Verificação de Tipos @@ -1117,7 +1101,11 @@ Contribuições são bem-vindas! Por favor, veja [CONTRIBUTING.md](./CONTRIBUTIN ## 📄 Licença -MIT © [NFE.io](https://nfe.io) +O código deste SDK é distribuído sob a licença [MIT](./LICENSE). +Copyright (c) 2026 NFE.IO HUB SERVICOS DE TECNOLOGIA LTDA. + +A licença cobre o SDK, não a API: o uso da API da NFE.io segue o contrato da sua conta +([Termos de Serviço](https://p.nfe.io/pt-br/termos-de-servico)). ## 🆘 Suporte diff --git a/docs/API.md b/docs/API.md index a666d7c..a10c86a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2034,58 +2034,23 @@ for (const event of result.events ?? []) { --- -### Consumer Invoice Query (Consulta CFe-SAT) +### Consumer Invoice Query (Consulta CFe-SAT) — deprecated **Resource:** `nfe.consumerInvoiceQuery` -Query CFe-SAT (Cupom Fiscal Eletrônico) consumer invoices by access key. This is a read-only resource that does not require company scope. +> ⚠️ **Deprecated — the platform does not serve these routes.** `/v1/consumerinvoices/coupon/{key}` +> and `.xml` are declared in the OpenAPI spec but answer `404`, identical to an unknown path, +> even without credentials (measured 2026-09-02); 90 days of gateway logs have no `200`. Both +> methods are marked `@deprecated`: they still send the request, and the `404` explains that the +> route is not served. There is no replacement in the API today. -> **Note:** This resource uses a separate API host (`nfe.api.nfe.io`). You can configure a specific API key with `dataApiKey`, or the SDK will use `apiKey` as fallback. - -#### `retrieve(accessKey: string): Promise` - -Retrieve full CFe-SAT coupon details by access key. - -```typescript -const coupon = await nfe.consumerInvoiceQuery.retrieve( - '35240112345678000190590000000012341234567890' -); -console.log(coupon.currentStatus); // 'Authorized' -console.log(coupon.issuer?.name); -console.log(coupon.totals?.couponAmount); -``` - -**Parameters:** - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `accessKey` | `string` | Yes | 44-digit numeric access key (Chave de Acesso) | - -**Returns:** `TaxCoupon` — Full coupon details including issuer, buyer, items, totals, and payment. - -**Throws:** -- `ValidationError` if access key format is invalid -- `NotFoundError` if no coupon matches the access key (HTTP 404) -- `AuthenticationError` if API key is invalid (HTTP 401) - -#### `downloadXml(accessKey: string): Promise` +#### `retrieve(accessKey: string): Promise` — deprecated -Download the raw CFe XML for a consumer invoice. +Would retrieve the CFe-SAT coupon by access key. The route answers `404` (see above). -```typescript -const xmlBuffer = await nfe.consumerInvoiceQuery.downloadXml( - '35240112345678000190590000000012341234567890' -); -fs.writeFileSync('cfe.xml', xmlBuffer); -``` +#### `downloadXml(accessKey: string): Promise` — deprecated -**Parameters:** - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `accessKey` | `string` | Yes | 44-digit numeric access key | - -**Returns:** `Buffer` containing the XML binary content. +Would download the raw CFe XML. The route answers `404` (see above). --- @@ -2276,7 +2241,7 @@ interface LegalEntityStateTaxForInvoice { } ``` -> See [src/core/types.ts](../src/core/types.ts) for the complete type definitions. +> See [src/core/types.ts](https://github.com/nfe/client-nodejs/blob/master/src/core/types.ts) for the complete type definitions. --- @@ -2337,7 +2302,7 @@ interface NaturalPersonStatusResponse { } ``` -> See [src/core/types.ts](../src/core/types.ts) for the complete type definitions. +> See [src/core/types.ts](https://github.com/nfe/client-nodejs/blob/master/src/core/types.ts) for the complete type definitions. --- @@ -2419,7 +2384,7 @@ interface CalculateResponse { } ``` -> See [src/core/types.ts](../src/core/types.ts) for the complete type definitions including all tax component interfaces (TaxIcms, TaxPis, TaxCofins, TaxIpi, TaxIi, TaxIcmsUfDest). +> See [src/core/types.ts](https://github.com/nfe/client-nodejs/blob/master/src/core/types.ts) for the complete type definitions including all tax component interfaces (TaxIcms, TaxPis, TaxCofins, TaxIpi, TaxIi, TaxIcmsUfDest). --- @@ -2485,7 +2450,7 @@ interface TaxCodeListOptions { } ``` -> See [src/core/types.ts](../src/core/types.ts) for the complete type definitions. +> See [src/core/types.ts](https://github.com/nfe/client-nodejs/blob/master/src/core/types.ts) for the complete type definitions. --- @@ -2740,7 +2705,7 @@ interface NfeStateTax { } ``` -> See [src/core/types.ts](../src/core/types.ts) for the complete type definitions. +> See [src/core/types.ts](https://github.com/nfe/client-nodejs/blob/master/src/core/types.ts) for the complete type definitions. --- @@ -3049,7 +3014,7 @@ const invoice: ServiceInvoice = await nfe.serviceInvoices.retrieve( ## Extension Development -The SDK is designed to be extensible. See [CONTRIBUTING.md](../CONTRIBUTING.md) for guidance on: +The SDK is designed to be extensible. See [CONTRIBUTING.md](https://github.com/nfe/client-nodejs/blob/master/CONTRIBUTING.md) for guidance on: - Creating MCP (Model Context Protocol) integrations - Building n8n workflow nodes @@ -3090,4 +3055,4 @@ class ExtendedNfeClient extends NfeClient { ## License -MIT License - see [LICENSE](../LICENSE) for details +MIT License - see [LICENSE](https://github.com/nfe/client-nodejs/blob/master/LICENSE) for details diff --git a/docs/EXTENDING.md b/docs/EXTENDING.md index e19667a..71d1da9 100644 --- a/docs/EXTENDING.md +++ b/docs/EXTENDING.md @@ -884,7 +884,7 @@ export class PlatformXAdapter extends BaseAdapter { ## Additional Resources - [Core SDK API Reference](./API.md) -- [Contributing Guidelines](../CONTRIBUTING.md) +- [Contributing Guidelines](https://github.com/nfe/client-nodejs/blob/master/CONTRIBUTING.md) - [NFE.io API Documentation](https://nfe.io/docs) - [TypeScript Handbook](https://www.typescriptlang.org/docs/) diff --git a/docs/multi-host-routing.md b/docs/multi-host-routing.md index 218caf8..d0f0d25 100644 --- a/docs/multi-host-routing.md +++ b/docs/multi-host-routing.md @@ -26,25 +26,35 @@ precisa fornecer as chaves na configuração. | `nfe.api.nfe.io` | **dados** | `productInvoiceQuery`, `consumerInvoiceQuery` | :::danger As duas chaves são complementares, não alternativas -Cada chave responde **`403` no território da outra**. Não existe "a chave que -serve para tudo": os hosts **fiscais** (`api.nfe.io`, `api.nfse.io`) só aceitam a -principal, e os de **consulta** (`nfe.api.nfe.io`, `legalentity`, `naturalperson`, -`address`) só aceitam a de dados. +A **principal** é a que vale em `api.nfe.io` e `api.nfse.io`, e é a que o SDK envia +aos recursos desses hosts. A **de dados** vale nos hosts de consulta +(`nfe.api.nfe.io`, `legalentity`, `naturalperson`, `address`). + +Medido em 2026-10-14, rota por rota: a chave de dados é recusada (`403`) nas rotas +de documento fiscal (NF-e, NFC-e, NFS-e, documentos de entrada e `tax-codes`), e a +principal é recusada (`403`) nos hosts de consulta. Algumas rotas de cadastro da +conta também aceitam a chave de dados, mas isso não é contrato documentado, e o SDK +não depende disso. O SDK aplica um fallback de `dataApiKey` para `apiKey` quando a de dados não é informada. Isso é uma conveniência para quem tem uma chave só com os dois escopos — **não** significa que uma substitua a outra. Se você configurar apenas -`dataApiKey`, os recursos fiscais lançam `ConfigurationError` na hora, em vez de -falhar com `403` na chamada. +`dataApiKey`, os recursos de `api.nfe.io` e `api.nfse.io` lançam +`ConfigurationError` na hora, em vez de arriscar um `403` na chamada. ::: -:::warning Correção em 2026-09-02 -Até esta data a tabela acima dizia que `productInvoices`, `productInvoicesRtc`, -`stateTaxes`, `municipalTaxes`, `certificates`, `transportationInvoices` e -`inboundProductInvoices` usavam a chave **de dados** em `api.nfse.io`. Estava -errado: `api.nfse.io` é host fiscal e responde `403` à chave de dados. Era o -mesmo defeito que o SDK carregava no roteamento interno, corrigido antes desta -página. Se você replicou o mapa antigo na sua aplicação, ajuste. Histórico em +:::warning Correções de registro +- **2026-09-02.** Até essa data, a tabela acima dizia que `productInvoices`, + `productInvoicesRtc`, `stateTaxes`, `municipalTaxes`, `certificates`, + `transportationInvoices` e `inboundProductInvoices` usavam a chave **de dados** em + `api.nfse.io`. Estava errado: as rotas de documento fiscal desse host respondem + `403` à chave de dados, e o SDK passou a enviar a principal. Se você replicou o + mapa antigo na sua aplicação, ajuste. +- **2026-10-14.** O texto anterior desta nota dizia que cada chave era recusada em todo o + host da outra. Isso generalizava para o host inteiro uma medição feita só em rotas de + documento fiscal. A regra é por rota, como descrito acima. + +Histórico em [`MIGRATION.md`](https://github.com/nfe/client-nodejs/blob/master/MIGRATION.md). ::: diff --git a/docs/recursos/consumer-invoice-query.md b/docs/recursos/consumer-invoice-query.md index c7e115a..e1a11cd 100644 --- a/docs/recursos/consumer-invoice-query.md +++ b/docs/recursos/consumer-invoice-query.md @@ -3,28 +3,30 @@ title: Consulta de cupom (CFe-SAT) sidebar_label: Consulta CFe-SAT sidebar_position: 21 slug: consulta-cfe-sat -description: Consulte cupons CFe-SAT por chave de acesso e baixe o XML com nfe.consumerInvoiceQuery (host nfe.api.nfe.io). +description: nfe.consumerInvoiceQuery consulta cupons CFe-SAT por chave de acesso — métodos descontinuados, porque a plataforma não serve as rotas. --- # Consulta de cupom (CFe-SAT) -`nfe.consumerInvoiceQuery` (host `nfe.api.nfe.io`, chave de dados) consulta -cupons fiscais CFe-SAT por **chave de acesso**. É somente leitura — distinto de -[`consumerInvoices`](./consumer-invoices.md) (emissão de NFC-e). +`nfe.consumerInvoiceQuery` (host `nfe.api.nfe.io`, chave de dados) consultaria +cupons fiscais CFe-SAT por **chave de acesso**. É distinto de +[`consumerInvoices`](./consumer-invoices.md), a emissão de NFC-e. + +:::warning[Métodos descontinuados: a plataforma não serve estas rotas] +As rotas `/v1/consumerinvoices/coupon/{chave}` e `.xml` estão declaradas na OpenAPI, +mas respondem `404` igual ao de um caminho inexistente, inclusive sem credencial +(medido em 2026-09-02). Noventa dias de log de gateway não têm um único `200` nelas. + +Os dois métodos estão marcados `@deprecated`. Eles continuam emitindo a requisição, e +o `404` vem com essa explicação. Não há substituto na API hoje. +::: ## Métodos | Método | Descrição | Retorno | |---|---|---| -| `retrieve(accessKey)` | Consulta o cupom por chave. | dados do cupom | -| `downloadXml(accessKey)` | XML do cupom. | `Buffer` | - -## Exemplo - -```typescript -const coupon = await nfe.consumerInvoiceQuery.retrieve(accessKey); -const xml = await nfe.consumerInvoiceQuery.downloadXml(accessKey); -``` +| `retrieve(accessKey)` | **Descontinuado**: consultaria o cupom por chave; a rota responde `404`. | — | +| `downloadXml(accessKey)` | **Descontinuado**: baixaria o XML do cupom; a rota responde `404`. | — | ## Próximos passos diff --git a/docs/recursos/municipal-taxes.md b/docs/recursos/municipal-taxes.md index 3c2130e..581b6f7 100644 --- a/docs/recursos/municipal-taxes.md +++ b/docs/recursos/municipal-taxes.md @@ -3,7 +3,7 @@ title: Inscrições municipais sidebar_label: Inscrições municipais sidebar_position: 5 slug: inscricoes-municipais -description: CRUD de inscrições municipais (pré-requisito da NFS-e) com nfe.municipalTaxes, incluindo updatePrefecture (PATCH) e getSeries. +description: CRUD de inscrições municipais (pré-requisito da NFS-e) com nfe.municipalTaxes, incluindo a semântica de substituição do update (PUT). --- # Inscrições municipais @@ -19,10 +19,13 @@ empresa — **pré-requisito para emissão de NFS-e**. Espelha | `list(companyId)` | Lista as inscrições. | `{ municipalTaxes, hasMore }` | | `create(companyId, data)` | Cria uma inscrição. | `MunicipalTax` | | `retrieve(companyId, municipalTaxId)` | Consulta por id. | `MunicipalTax` | -| `update(companyId, municipalTaxId, data)` | Atualiza. | `MunicipalTax` | +| `update(companyId, municipalTaxId, data)` | **Substitui** a inscrição (HTTP **PUT**). Veja abaixo. | `MunicipalTax` | | `delete(companyId, municipalTaxId)` | Remove. | `void` | -| `updatePrefecture(companyId, municipalTaxId, data)` | Atualiza credenciais da prefeitura (HTTP **PATCH**). | `MunicipalTax` | -| `getSeries(companyId, municipalTaxId, serie)` | Consulta uma série de RPS. | dados da série | +| `updatePrefecture(companyId, municipalTaxId, data)` | **Descontinuado** (`@deprecated`): a plataforma não serve a rota e responde `404`. | — | +| `getSeries(companyId, municipalTaxId, serie)` | **Descontinuado** (`@deprecated`): a plataforma não serve a rota e responde `404`. | — | + +`create`, `retrieve` e `update` devolvem a própria inscrição. A API responde com o +envelope `{ "municipalTax": {…} }`, e o SDK o desembrulha desde a 6.0.1. ## Exemplo @@ -30,15 +33,51 @@ empresa — **pré-requisito para emissão de NFS-e**. Espelha const { municipalTaxes = [] } = await nfe.municipalTaxes.list(companyId); const created = await nfe.municipalTaxes.create(companyId, { - code: '123456', - // ...credenciais/config da prefeitura + city: { code: '4106902', name: 'Curitiba' }, + taxNumber: '123456', + rpsSerialNumber: 'IO', + environment: 'Development', }); -await nfe.municipalTaxes.updatePrefecture(companyId, created.id, { - // login/senha da prefeitura -}); +console.log(created.id); ``` +## Atualizar: o PUT substitui a inscrição + +`update` é um **PUT de substituição**, não uma atualização dos campos enviados. +Segundo a documentação da plataforma (`nfe/docs@b9e7953`, 14/10/2026): + +- `taxNumber` omitido é **apagado**; +- `environment` omitido volta para `Development`; +- os demais campos omitidos não têm garantia de manter o valor. + +O caminho seguro é ler a inscrição, alterar só o necessário e enviar o objeto de volta: + +```typescript +const current = await nfe.municipalTaxes.retrieve(companyId, municipalTaxId); +const { rpsNumber, lastRpsSent, ...rest } = current; // omitidos, o contador de RPS é mantido +await nfe.municipalTaxes.update(companyId, municipalTaxId, { ...rest, issRate: 2.5 }); +``` + +:::warning[Não reenvie o contador de RPS] +`rpsNumber` e `lastRpsSent` omitidos mantêm o contador da série. Um valor enviado +**sobrescreve** o contador, inclusive para trás: reenviar o número lido antes de uma +emissão repete números de RPS. Só os envie quando quiser mover o contador de +propósito, por exemplo ao trocar de série. +::: + +O SDK não mediu esse comportamento por conta própria, porque exigiria escrever sobre +uma inscrição real. A fonte é a documentação da plataforma. + +## Rotas que a plataforma não serve + +`updatePrefecture` e `getSeries` estão **descontinuados**: as rotas +`.../municipaltaxes/{id}/updateprefecture` e `.../series/{serie}` estão declaradas na +OpenAPI, mas a plataforma responde `404`, igual ao de um caminho inexistente, +inclusive sem credencial (medido em 2026-09-02 e de novo em 2026-10-14). Os métodos +continuam emitindo a requisição; o `404` vem com essa explicação. Não há substituto +na API hoje. + ## Próximos passos - [Notas de serviço (NFS-e)](./service-invoices.md) diff --git a/examples/README.md b/examples/README.md index caf316c..6597e56 100644 --- a/examples/README.md +++ b/examples/README.md @@ -191,7 +191,7 @@ Emissão no leiaute RTC (IBS/CBS/IS) — `serviceInvoicesRtc` (polling) e `produ Ciclo de vida da NFC-e (`consumerInvoices`): emissão webhook-driven, `list`/leituras **exigem `environment`** (`Production`/`Test`), downloads e inutilização. ### **municipal-taxes.js** - Inscrições Municipais -CRUD de inscrições municipais (`municipalTaxes`) — pré-requisito para NFS-e —, com `updatePrefecture` (PATCH) e `getSeries`. +CRUD de inscrições municipais (`municipalTaxes`) — pré-requisito para NFS-e —, com a semântica de substituição do `update` (PUT). `updatePrefecture` e `getSeries` estão descontinuados: a plataforma não serve essas rotas. ### **certificates.js** - Certificados por thumbprint Consulta/remoção de certificados por thumbprint (`certificates`), com variantes v1. diff --git a/examples/consumer-invoice-query.js b/examples/consumer-invoice-query.js index 982f51e..fc9b85f 100644 --- a/examples/consumer-invoice-query.js +++ b/examples/consumer-invoice-query.js @@ -1,20 +1,20 @@ /** - * Consumer Invoice Query Example (CFe-SAT) + * Consumer Invoice Query (CFe-SAT) — DESCONTINUADO * - * Demonstrates querying CFe-SAT (Cupom Fiscal Eletrônico) consumer invoices - * by access key using the NFE.io SDK v3. + * `nfe.consumerInvoiceQuery.retrieve()` e `.downloadXml()` estão marcados @deprecated: + * a plataforma não serve as rotas `/v1/consumerinvoices/coupon/{chave}` e `.xml`, que + * respondem 404 igual ao de um caminho inexistente, inclusive sem credencial (medido em + * 2026-09-02). Não há substituto na API hoje. * - * Setup: - * export NFE_DATA_API_KEY="your-data-api-key" - * # or: export NFE_API_KEY="your-api-key" + * Este exemplo só demonstra o erro que o SDK devolve, para quem encontrar o recurso + * em código antigo. * * Usage: - * node examples/consumer-invoice-query.js + * NFE_DATA_API_KEY=... node examples/consumer-invoice-query.js */ -import { NfeClient } from '../src/index.js'; +import { NfeClient } from '../dist/index.js'; -// Replace with a real CFe-SAT access key (44 digits) const ACCESS_KEY = process.argv[2] || '35240112345678000190590000000012341234567890'; async function main() { @@ -23,68 +23,11 @@ async function main() { dataApiKey: process.env.NFE_DATA_API_KEY, }); - console.log('=== Consumer Invoice Query (CFe-SAT) ===\n'); - - // 1. Retrieve coupon details - try { - console.log(`Retrieving coupon: ${ACCESS_KEY}`); - const coupon = await nfe.consumerInvoiceQuery.retrieve(ACCESS_KEY); - - console.log('\n--- Coupon Details ---'); - console.log('Status:', coupon.currentStatus); - console.log('Number:', coupon.number); - console.log('SAT Serie:', coupon.satSerie); - console.log('Issued On:', coupon.issuedOn); - console.log('Access Key:', coupon.accessKey); - - if (coupon.issuer) { - console.log('\n--- Issuer ---'); - console.log('Name:', coupon.issuer.name); - console.log('Trade Name:', coupon.issuer.tradeName); - console.log('CNPJ:', coupon.issuer.federalTaxNumber); - console.log('Tax Regime:', coupon.issuer.taxRegime); - } - - if (coupon.buyer) { - console.log('\n--- Buyer ---'); - console.log('Name:', coupon.buyer.name); - console.log('CPF/CNPJ:', coupon.buyer.federalTaxNumber); - } - - if (coupon.totals) { - console.log('\n--- Totals ---'); - console.log('Coupon Amount:', coupon.totals.couponAmount); - console.log('Total Tax (approx):', coupon.totals.totalAmount); - } - - if (coupon.items && coupon.items.length > 0) { - console.log(`\n--- Items (${coupon.items.length}) ---`); - for (const item of coupon.items) { - console.log(` ${item.code} - ${item.description}: ${item.quantity} x ${item.unitAmount} = ${item.netAmount}`); - } - } - - if (coupon.payment?.paymentDetails) { - console.log('\n--- Payment ---'); - for (const detail of coupon.payment.paymentDetails) { - console.log(` ${detail.method}: ${detail.amount}`); - } - if (coupon.payment.payBack) { - console.log(' Change:', coupon.payment.payBack); - } - } - } catch (error) { - console.error('Failed to retrieve coupon:', error instanceof Error ? error.message : error); - } - - // 2. Download CFe XML try { - console.log(`\nDownloading CFe XML for: ${ACCESS_KEY}`); - const xmlBuffer = await nfe.consumerInvoiceQuery.downloadXml(ACCESS_KEY); - console.log(`XML downloaded: ${xmlBuffer.length} bytes`); - // fs.writeFileSync('cfe.xml', xmlBuffer); + // @deprecated — a rota não é servida; a chamada termina em 404 com a explicação + await nfe.consumerInvoiceQuery.retrieve(ACCESS_KEY); } catch (error) { - console.error('Failed to download XML:', error instanceof Error ? error.message : error); + console.log('Esperado (rota não servida):', error instanceof Error ? error.message : error); } } diff --git a/examples/municipal-taxes.js b/examples/municipal-taxes.js index c2ffd40..d60cfe2 100644 --- a/examples/municipal-taxes.js +++ b/examples/municipal-taxes.js @@ -1,8 +1,12 @@ /** - * NFE.io SDK v5 - Inscrições Municipais (Municipal Taxes) + * NFE.io SDK - Inscrições Municipais (Municipal Taxes) * * CRUD de inscrições municipais — PRÉ-REQUISITO para emissão de NFS-e. Espelha - * `stateTaxes`. Inclui `updatePrefecture` (HTTP PATCH) e `getSeries`. + * `stateTaxes`. O `update` é um PUT que SUBSTITUI a inscrição: leia, altere e envie o + * objeto de volta, omitindo `rpsNumber`/`lastRpsSent` para preservar o contador de RPS. + * + * `updatePrefecture` e `getSeries` estão descontinuados (@deprecated): a plataforma não + * serve essas rotas e a chamada termina em 404. * * node examples/municipal-taxes.js */ @@ -26,20 +30,21 @@ async function main() { const first = municipalTaxes[0]; if (first) { const detail = await nfe.municipalTaxes.retrieve(companyId, first.id); - console.log(' code:', detail.code, '| status:', detail.status); - - // Série de RPS para essa inscrição - const series = await nfe.municipalTaxes.getSeries(companyId, first.id, '1'); - console.log(' série 1:', JSON.stringify(series).slice(0, 80)); + console.log(' município:', detail.city?.name, '| IM:', detail.taxNumber, '| série:', detail.rpsSerialNumber, '| status:', detail.status); } // Criar (exemplo — descomente e ajuste ao seu município): // const created = await nfe.municipalTaxes.create(companyId, { - // code: '123456', specialTaxRegime: 'Nenhum', /* ...credenciais da prefeitura */ + // city: { code: '4106902', name: 'Curitiba' }, + // taxNumber: '123456', + // rpsSerialNumber: 'IO', + // environment: 'Development', // }); - // Atualizar credenciais da prefeitura (PATCH): - // await nfe.municipalTaxes.updatePrefecture(companyId, created.id, { /* login/senha */ }); + // Atualizar: PUT de substituição. Leia, altere e reenvie, sem o contador de RPS. + // const current = await nfe.municipalTaxes.retrieve(companyId, first.id); + // const { rpsNumber, lastRpsSent, ...rest } = current; + // await nfe.municipalTaxes.update(companyId, first.id, { ...rest, issRate: 2.5 }); } catch (err) { console.error('Erro:', err.message); } diff --git a/package-lock.json b/package-lock.json index d2cfa40..efc9146 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nfe-io", - "version": "5.2.0", + "version": "6.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nfe-io", - "version": "5.2.0", + "version": "6.0.1", "license": "MIT", "devDependencies": { "@types/node": "^22.19.21", diff --git a/package.json b/package.json index 41c31da..2d4fdfd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nfe-io", - "version": "6.0.0", + "version": "6.0.1", "description": "Official NFE.io SDK for Node.js - TypeScript native with zero runtime dependencies", "keywords": [ "nfe", diff --git a/skills/nfeio-node-sdk/SKILL.md b/skills/nfeio-node-sdk/SKILL.md index c536d42..1cd825f 100644 --- a/skills/nfeio-node-sdk/SKILL.md +++ b/skills/nfeio-node-sdk/SKILL.md @@ -1,6 +1,6 @@ --- name: nfeio-node-sdk -description: "NFE.io Node.js SDK integration expert (package: nfe-io). MUST trigger when: code imports 'nfe-io' or references NfeClient; user mentions NFE.io, NFS-e, NF-e, CT-e, CF-e, CFe-SAT, nota fiscal, nota fiscal eletronica, Brazilian invoice, fiscal document, electronic invoice Brazil, CNPJ lookup, CPF lookup, service invoice, product invoice, transportation invoice, consumer invoice, tax calculation Brazilian taxes, SEFAZ, NFE API, emissao de nota, consulta CNPJ, consulta CPF, consulta CEP; user works with Brazilian electronic fiscal documents, tax documents, or Brazilian tax compliance; any file contains 'nfe-io' in package.json or import statements; user mentions polling for invoice status, invoice async processing, or certificate management for Brazilian fiscal documents. Covers all 16 SDK resources, async invoice processing with polling, discriminated union responses, error hierarchy, certificate management, webhook signature validation, address lookup (CEP), legal entity and natural person lookups, tax calculation engine, and pagination patterns. Use this skill even if the user doesn't explicitly name the SDK -- if they're building anything related to Brazilian fiscal document automation in Node.js/TypeScript, this skill applies." +description: "NFE.io Node.js SDK integration expert (package: nfe-io). MUST trigger when: code imports 'nfe-io' or references NfeClient; user mentions NFE.io, NFS-e, NF-e, CT-e, CF-e, CFe-SAT, nota fiscal, nota fiscal eletronica, Brazilian invoice, fiscal document, electronic invoice Brazil, CNPJ lookup, CPF lookup, service invoice, product invoice, transportation invoice, consumer invoice, tax calculation Brazilian taxes, SEFAZ, NFE API, emissao de nota, consulta CNPJ, consulta CPF, consulta CEP; user works with Brazilian electronic fiscal documents, tax documents, or Brazilian tax compliance; any file contains 'nfe-io' in package.json or import statements; user mentions polling for invoice status, invoice async processing, or certificate management for Brazilian fiscal documents. Covers all 22 SDK resources, async invoice processing with polling, discriminated union responses, error hierarchy, certificate management, webhook signature validation, address lookup (CEP), legal entity and natural person lookups, tax calculation engine, and pagination patterns. Use this skill even if the user doesn't explicitly name the SDK -- if they're building anything related to Brazilian fiscal document automation in Node.js/TypeScript, this skill applies." --- # NFE.io Node.js SDK Integration Guide @@ -43,7 +43,11 @@ const nfe = new NfeClient({ }); ``` -**Two complementary API keys** — not alternatives. Each answers **403** in the other's territory: +**Two complementary API keys** — not alternatives. Measured route by route (2026-10-14): the data +key is rejected (**403**) on fiscal-document routes (NF-e, NFC-e, NFS-e, inbound documents, +tax codes and calculation), and the main key is rejected (**403**) on the lookup hosts. Some +account-registry routes also accept the data key; that is not a documented contract and the SDK +does not rely on it. - **`apiKey`** (main) → FISCAL hosts: `api.nfe.io`, `api.nfse.io`. Covers every emission resource plus `certificates`, `municipalTaxes`, `stateTaxes`, `taxCodes` and @@ -261,22 +265,29 @@ for await (const company of nfe.companies.listIterator()) { ## Core Pattern: File Downloads -PDF and XML downloads return `Buffer` objects for most resources: +Downloads differ by resource. Only service invoices and the NF-e query by access key return the +bytes: ```typescript import { writeFileSync } from 'node:fs'; -// Service invoice downloads return Buffer +// Service invoice downloads return Buffer (invoiceId is required) const pdf = await nfe.serviceInvoices.downloadPdf(companyId, invoiceId); const xml = await nfe.serviceInvoices.downloadXml(companyId, invoiceId); writeFileSync('invoice.pdf', pdf); writeFileSync('invoice.xml', xml); -// Query resources also return Buffer +// The NF-e query by access key also returns Buffer const danfe = await nfe.productInvoiceQuery.downloadPdf(accessKey); ``` -**Exception**: `productInvoices.downloadPdf()` returns `NfeFileResource` (with a `uri` field), not a raw Buffer. Use the URI to fetch the file separately. +The others return a URI, and fetching the file is up to the caller: + +- `productInvoices.download*` → `NfeFileResource` (`{ uri }`); +- `consumerInvoices.download*` → `ConsumerInvoiceFileResource` (`{ uri }`); +- `inboundProductInvoices.getXml/getPdf/getEventXml` and + `transportationInvoices.downloadXml/downloadEventXml` → `InboundFileResource` + (`{ publicTemporaryUri }`, pre-signed and time-limited). ## Core Pattern: Certificates @@ -364,7 +375,7 @@ on the live API — fetch the real list with `await nfe.webhooks.fetchEventTypes 6. **Method is `companies.remove()`** not `companies.delete()` — avoids JS reserved keyword conflict. -7. **Data services need API key**: Resources on non-main hosts (addresses, lookups, tax calculation) use `dataApiKey` with `apiKey` as fallback. Ensure at least one is set. +7. **Data services need the data key**: resources on the lookup hosts (addresses, CNPJ/CPF lookups, NF-e query by access key) use `dataApiKey`, with `apiKey` as fallback. Tax calculation and tax codes live on the fiscal host `api.nfse.io` and use the main `apiKey`: the data key gets 403 there. 8. **Different pagination**: Service invoices use offset (`pageIndex`/`pageCount`), product invoices use cursor (`startingAfter`/`endingBefore`/`limit`). @@ -411,7 +422,7 @@ on the live API — fetch the real list with `await nfe.webhooks.fetchEventTypes | Cancel a product invoice | `nfe.productInvoices.cancel(companyId, invoiceId)` | | Download DANFE PDF | `nfe.serviceInvoices.downloadPdf(companyId, id)` or `nfe.productInvoiceQuery.downloadPdf(accessKey)` | | Send invoice by email | `nfe.serviceInvoices.sendEmail(companyId, invoiceId)` | -| Issue correction letter (CC-e) | `nfe.productInvoices.sendCorrectionLetter(companyId, id, data)` | +| Issue correction letter (CC-e) | `nfe.productInvoices.sendCorrectionLetter(companyId, id, reason)` | ## Reference Files diff --git a/skills/nfeio-node-sdk/references/data-services-and-lookups.md b/skills/nfeio-node-sdk/references/data-services-and-lookups.md index 0c2ac7c..1178d03 100644 --- a/skills/nfeio-node-sdk/references/data-services-and-lookups.md +++ b/skills/nfeio-node-sdk/references/data-services-and-lookups.md @@ -115,10 +115,15 @@ Lists fiscal events for the invoice (cancellations, correction letters, manifest Access via `nfe.consumerInvoiceQuery`. Global scope. Queries CFe-SAT (Cupom Fiscal Eletronico) data from SEFAZ. -### retrieve(accessKey): Promise +> ⚠️ **Deprecated — the platform does not serve these routes.** `/v1/consumerinvoices/coupon/{key}` +> and `.xml` answer `404` identical to an unknown path, even without credentials (measured +> 2026-09-02); 90 days of gateway logs have no `200`. The methods still send the request and the +> `404` explains it. Do not recommend them; the shapes below are kept for reference only. + +### retrieve(accessKey): Promise — deprecated, route not served ```typescript -const coupon = await nfe.consumerInvoiceQuery.retrieve(accessKey); +const coupon = await nfe.consumerInvoiceQuery.retrieve(accessKey); // @deprecated — 404, route not served ``` **TaxCoupon** contains: @@ -135,9 +140,9 @@ type CouponStatus = 'Unknown' | 'Authorized' | 'Canceled'; type CouponTaxRegime = 'National_Simple' | 'National_Simple_Brute' | 'Normal_Regime'; ``` -### downloadXml(accessKey): Promise +### downloadXml(accessKey): Promise — deprecated, route not served -Downloads CFe XML as raw `Buffer`. +Would download the CFe XML as a raw `Buffer`; the route answers `404` (see the warning above). --- diff --git a/skills/nfeio-node-sdk/references/product-invoices-and-taxes.md b/skills/nfeio-node-sdk/references/product-invoices-and-taxes.md index f5fa81b..6334488 100644 --- a/skills/nfeio-node-sdk/references/product-invoices-and-taxes.md +++ b/skills/nfeio-node-sdk/references/product-invoices-and-taxes.md @@ -8,7 +8,7 @@ Access via `nfe.productInvoices`. Company-scoped. **Important**: All product invoice operations are **always async** (return 202). There is NO `createAndWait()`. Completion is notified via **webhooks**. -### create(companyId, data): Promise +### create(companyId, data): Promise Issues a product invoice. Returns immediately with 202 status. The invoice will be processed asynchronously and the result delivered via webhook. @@ -55,7 +55,7 @@ Issues a product invoice. Returns immediately with 202 status. The invoice will } ``` -### createWithStateTax(companyId, stateTaxId, data): Promise +### createWithStateTax(companyId, stateTaxId, data): Promise Same as `create()` but uses a specific state tax registration (IE) for issuance. Use when company has multiple IEs across states. @@ -85,72 +85,88 @@ Cancels invoice. Always async (204 response). Reason string is optional. ### listEvents(companyId, invoiceId, options?): Promise -### downloadPdf(companyId, invoiceId): Promise +### downloadPdf(companyId, invoiceId, force?): Promise -Returns the DANFE PDF as a raw Buffer. +Returns `{ uri }` for the DANFE PDF, not the bytes: fetch the file from the URI. `force: true` +regenerates the PDF regardless of the invoice's `flowStatus`. -### downloadXml(companyId, invoiceId): Promise +### downloadXml(companyId, invoiceId): Promise -Returns the NF-e XML. +`{ uri }` for the NF-e XML. -### downloadRejectionXml(companyId, invoiceId): Promise +### downloadRejectionXml(companyId, invoiceId): Promise -XML for rejected invoices. +`{ uri }` for the XML of a rejected invoice. -### downloadEpecXml(companyId, invoiceId): Promise +### downloadEpecXml(companyId, invoiceId): Promise -XML for EPEC contingency mode. +`{ uri }` for the XML in EPEC contingency mode. -### sendCorrectionLetter(companyId, invoiceId, data): Promise +### sendCorrectionLetter(companyId, invoiceId, reason): Promise -Issues a Carta de Correao Eletrnica (CC-e): +Issues a Carta de Correção Eletrônica (CC-e). Processing is asynchronous. `reason` is a plain +string with 15 to 1,000 characters, without accents or special characters: ```typescript -await nfe.productInvoices.sendCorrectionLetter(companyId, invoiceId, { - correctionText: 'Correction description here', - // Must be 15-1000 characters - // No accents or special characters allowed -}); +await nfe.productInvoices.sendCorrectionLetter( + companyId, + invoiceId, + 'Correcao do endereco de entrega do destinatario', +); ``` -### disable(companyId, data): Promise +### disable(companyId, invoiceId, reason?): Promise + +Inutilização (disablement) of a specific invoice, by ID. Processing is asynchronous; `reason` is +optional. -Inutilizao (disablement) of invoice number. Prevents specific numbers from being used. +### disableRange(companyId, data): Promise + +Disables a range of invoice numbers. To disable a single number, use the same `beginNumber` and +`lastNumber`. ```typescript interface NfeDisablementData { - stateTaxId: string; - environment: NfeEnvironmentType; - invoiceSeries: number; - invoiceNumberStart: number; - invoiceNumberEnd: number; - reason: string; + environment: NfeEnvironmentType; // 'None' | 'Production' | 'Test' + serie: number; + state: NfeStateCode; // 'SP', 'RJ', ... + beginNumber: number; + lastNumber: number; + reason?: string; } ``` -### disableRange(companyId, data): Promise - -Disable a range of invoice numbers at once. - --- -## StateTaxesResource (Inscrio Estadual) +## StateTaxesResource (Inscrição Estadual) Access via `nfe.stateTaxes`. Company-scoped. **Prerequisite for NF-e issuance.** ```typescript +// As declared by the SDK; every field is optional. interface NfeStateTax { - id: string; - type: NfeStateTaxType; // 'nFe' | 'nFce' | 'cTe' - state: NfeStateTaxStateCode; - taxNumber: string; // IE number - environment: NfeStateTaxEnvironmentType; // 'Production' | 'Test' - status: NfeStateTaxStatus; - specialTaxRegime?: NfeSpecialTaxRegime; + id?: string; + companyId?: string; + accountId?: string; + code?: NfeStateTaxStateCode; // the state + environmentType?: NfeStateTaxEnvironmentType; // 'none' | 'production' | 'test' + taxNumber?: string; // IE number + serie?: number; + number?: number; + series?: number[]; // all series of this registration + status?: NfeStateTaxStatus; // 'inactive' | 'none' | 'active' + type?: NfeStateTaxType; // 'default' | 'nFe' | 'nFCe' + specialTaxRegime?: NfeStateTaxSpecialTaxRegime; securityCredential?: NfeSecurityCredential; + batchId?: number; + createdOn?: string; + modifiedOn?: string; } ``` +The enum values follow the spec's casing (state codes such as `'sP'`), which is questioned +upstream in nfe/docs#338. + | Method | Signature | |--------|-----------| | `list` | `(companyId, options?): Promise` | @@ -159,6 +175,14 @@ interface NfeStateTax { | `update` | `(companyId, stateTaxId, data): Promise` | | `delete` | `(companyId, stateTaxId): Promise` | +`create`, `retrieve` and `update` return the registration itself since 6.0.1. The API wraps it in +`{ stateTax: {…} }`; up to 6.0.0 the SDK returned that envelope typed as `NfeStateTax`, so +`result.id` was `undefined`. + +`update` sends a PUT. For state taxes it is not documented whether omitted fields keep their +value (the municipal-tax PUT replaces the registration). The safe pattern is to `retrieve`, +change what you need and send the whole object back. + Uses cursor-based pagination like product invoices. --- @@ -253,7 +277,7 @@ Uses CT-e API host (`api.nfse.io`). ### enable(companyId, options?): Promise -Enable automatic CT-e fetch via SEFAZ Distribuio DFe: +Enable automatic CT-e fetch via SEFAZ Distribuição DFe: ```typescript await nfe.transportationInvoices.enable(companyId, { @@ -270,11 +294,14 @@ await nfe.transportationInvoices.enable(companyId, { Get CT-e metadata by 44-digit access key. -### retrieveEvent(companyId, accessKey): Promise +### getEvent(companyId, accessKey, eventKey): Promise + +### downloadXml(companyId, accessKey): Promise -### downloadXml(companyId, accessKey): Promise +### downloadEventXml(companyId, accessKey, eventKey): Promise -### downloadEventXml(companyId, accessKey): Promise +Both downloads return `{ publicTemporaryUri }`, a pre-signed, time-limited URI; downloading the +file is up to the caller. --- @@ -283,7 +310,7 @@ Get CT-e metadata by 44-digit access key. Access via `nfe.inboundProductInvoices`. Company-scoped. Uses CT-e API host (`api.nfse.io`). -### enableAutoFetch(companyId, options?): Promise +### enableAutoFetch(companyId, options): Promise ```typescript interface EnableInboundOptions { @@ -301,31 +328,41 @@ interface EnableInboundOptions { ### Document Access Methods -| Method | Input | Returns | -|--------|-------|---------| -| `getDetails(companyId, accessKey)` | 44-digit key | Generic metadata | -| `getProductInvoiceDetails(companyId, accessKey)` | 44-digit key | Product invoice metadata | -| `getEventDetails(companyId, accessKey)` | 44-digit key | Event metadata | -| `getProductInvoiceEventDetails(companyId, accessKey)` | 44-digit key | Product invoice event metadata | -| `getXml(companyId, accessKey)` | 44-digit key | XML string | -| `getEventXml(companyId, accessKey)` | 44-digit key | Event XML string | -| `getPdf(companyId, accessKey)` | 44-digit key | PDF string (base64) | -| `getJson(companyId, accessKey, format?)` | 44-digit key | JSON string | +`accessKey` is the 44-digit key; `eventKey` identifies an event of that document. + +| Method | Returns | +|--------|---------| +| `getDetails(companyId, accessKey)` | `InboundInvoiceMetadata` | +| `getProductInvoiceDetails(companyId, accessKey)` | `InboundProductInvoiceMetadata` | +| `getEventDetails(companyId, accessKey, eventKey)` | `InboundInvoiceMetadata` | +| `getProductInvoiceEventDetails(companyId, accessKey, eventKey)` | `InboundProductInvoiceMetadata` | +| `getXml(companyId, accessKey)` | `InboundFileResource` | +| `getEventXml(companyId, accessKey, eventKey)` | `InboundFileResource` | +| `getPdf(companyId, accessKey)` | `InboundFileResource` | +| `getJson(companyId, accessKey)` | `InboundInvoiceMetadata` | + +`InboundFileResource` is `{ publicTemporaryUri }`, a pre-signed, time-limited URI: the XML and the +PDF never travel in the response, and downloading them is up to the caller. + +`getProductInvoiceDetails`, `getProductInvoiceEventDetails`, `getJson` and `reprocessWebhook` +reach the API since 6.0.1. Up to 6.0.0 they called `/inbound/productinvoice/` (singular), which +the platform does not serve: every call ended in 404. -### manifest(companyId, accessKey, eventType): Promise +### manifest(companyId, accessKey, tpEvent?): Promise -Send Manifestao do Destinatrio: +Sends the Manifestação do Destinatário. `tpEvent` defaults to `210210`: ```typescript type ManifestEventType = - | 210210 // Cincia da Operao (awareness) - | 210220 // Confirmao da Operao (confirmation) - | 210240; // Operao No Realizada (not performed) + | 210210 // Ciência da Operação (awareness, default) + | 210220 // Confirmação da Operação (confirmation) + | 210240; // Operação não Realizada (not performed) ``` -### reprocessWebhook(companyId, accessKey): Promise +### reprocessWebhook(companyId, accessKeyOrNsu): Promise -Resend webhook notification for a specific document. +Resends the webhook notification for a document, identified by its 44-digit access key or by its +NSU. --- @@ -340,13 +377,13 @@ Resend webhook notification for a specific document. -> Returns 202 immediately 3. Receive webhook notification - invoice.issued / invoice.failed + product_invoice.issued_successfully / product_invoice.issued_failed / product_invoice.issued_error -4. Download files +4. Download files (each returns { uri }; fetch the file from it) nfe.productInvoices.downloadPdf(companyId, id) nfe.productInvoices.downloadXml(companyId, id) 5. If needed: correction letter or cancellation - nfe.productInvoices.sendCorrectionLetter(companyId, id, { correctionText }) + nfe.productInvoices.sendCorrectionLetter(companyId, id, reason) // 15-1000 chars, no accents nfe.productInvoices.cancel(companyId, id) ``` diff --git a/skills/nfeio-node-sdk/references/rtc-nfce-and-account-resources.md b/skills/nfeio-node-sdk/references/rtc-nfce-and-account-resources.md index 8126b9a..dc12393 100644 --- a/skills/nfeio-node-sdk/references/rtc-nfce-and-account-resources.md +++ b/skills/nfeio-node-sdk/references/rtc-nfce-and-account-resources.md @@ -1,6 +1,6 @@ # RTC, NFC-e & Account Resources (v5) -New resources introduced in v5. Signatures are current as of v5.0.0. +New resources introduced in v5. Signatures checked against the SDK code of v6.0.1. ## RTC — Reforma Tributária do Consumo @@ -29,21 +29,22 @@ create(companyId, data: ProductInvoiceRtcRequest): Promise // webhook-driven list(companyId, options: ConsumerInvoiceListOptions): Promise // options.environment REQUIRED -retrieve(companyId, invoiceId, environment?): Promise -cancel(companyId, invoiceId): Promise -getItems(companyId, invoiceId, environment?): Promise -getEvents(companyId, invoiceId, environment?): Promise -downloadPdf(companyId, invoiceId, environment?): Promise // DANFE-NFC-e -downloadXml(companyId, invoiceId, environment?): Promise -downloadRejectionXml(companyId, invoiceId, environment?): Promise +retrieve(companyId, invoiceId): Promise +cancel(companyId, invoiceId, reason?): Promise +getItems(companyId, invoiceId, options?: ConsumerInvoicePageOptions): Promise +getEvents(companyId, invoiceId, options?: ConsumerInvoicePageOptions): Promise +downloadPdf(companyId, invoiceId, force?): Promise // DANFE-NFC-e, { uri } +downloadXml(companyId, invoiceId): Promise // { uri } +downloadRejectionXml(companyId, invoiceId): Promise disable(companyId, data: ConsumerInvoiceDisablementData): Promise // inutilização ``` `ConsumerInvoiceListOptions = { environment: 'Production' | 'Test'; startingAfter?; endingBefore?; limit?; q? }`. +`ConsumerInvoicePageOptions = { limit?: number; startingAfter?: number }`. Distinct from `nfe.consumerInvoiceQuery` (read-only CFe-SAT coupon lookup). --- @@ -56,10 +57,31 @@ CRUD of municipal enrollments — **prerequisite for NFS-e issuance** (mirrors ` list(companyId): Promise // { municipalTaxes, hasMore } create(companyId, data: CreateMunicipalTaxData): Promise retrieve(companyId, municipalTaxId): Promise -update(companyId, municipalTaxId, data: UpdateMunicipalTaxData): Promise +update(companyId, municipalTaxId, data: UpdateMunicipalTaxData): Promise // PUT: replaces the registration delete(companyId, municipalTaxId): Promise -updatePrefecture(companyId, municipalTaxId, data: UpdateMunicipalTaxData): Promise // HTTP PATCH -getSeries(companyId, municipalTaxId, serie): Promise> +``` + +`create`, `retrieve` and `update` return the registration itself (since 6.0.1; the API wraps it +as `{ municipalTax: {…} }` and the SDK unwraps it). + +**`update` is a PUT that replaces the registration**, per the platform documentation +(nfe/docs@b9e7953): an omitted `taxNumber` is erased and an omitted `environment` falls back to +`Development`. Read, change, send the whole object back — and **omit `rpsNumber`/`lastRpsSent`**, +which an omitted value preserves; sending them overwrites the RPS counter, even backwards: + +```typescript +const current = await nfe.municipalTaxes.retrieve(companyId, municipalTaxId); +const { rpsNumber, lastRpsSent, ...rest } = current; // omitted, the RPS counter is kept +await nfe.municipalTaxes.update(companyId, municipalTaxId, { ...rest, issRate: 2.5 }); +``` + +⚠️ **Deprecated — the platform does not serve these routes** (`404`, identical to an unknown +path, even without credentials; measured 2026-09-02 and again 2026-10-14). The methods still send +the request and the `404` explains it. Do not recommend them: + +```typescript +updatePrefecture(companyId, municipalTaxId, data) // @deprecated — route not served +getSeries(companyId, municipalTaxId, serie) // @deprecated — route not served ``` --- @@ -70,7 +92,7 @@ getSeries(companyId, municipalTaxId, serie): Promise> list(companyId): Promise // { certificates } getByThumbprint(companyId, thumbprint): Promise deleteByThumbprint(companyId, thumbprint): Promise -getByThumbprintV1(companyId, thumbprint): Promise<...> // variantes v1 +getByThumbprintV1(companyId, thumbprint): Promise // variante v1 deleteByThumbprintV1(companyId, thumbprint): Promise ``` Complements the legacy `companies.uploadCertificate` (upload lives on api.nfe.io). @@ -81,7 +103,7 @@ Complements the legacy `companies.uploadCertificate` (upload lives on api.nfe.io ```typescript list(companyId): Promise // { notifications } -retrieve(companyId, notificationId): Promise +retrieve(companyId, notificationId): Promise // the notification itself since 6.0.1 (the API wraps it in { notification }) delete(companyId, notificationId): Promise sendEmail(companyId, data?): Promise ``` diff --git a/skills/nfeio-node-sdk/references/service-invoices-and-polling.md b/skills/nfeio-node-sdk/references/service-invoices-and-polling.md index ceb30f1..e47f82e 100644 --- a/skills/nfeio-node-sdk/references/service-invoices-and-polling.md +++ b/skills/nfeio-node-sdk/references/service-invoices-and-polling.md @@ -95,27 +95,35 @@ Returns `{ data: ServiceInvoiceData[], totalCount: number, page: PageInfo }`. Get a single invoice by ID. -### cancel(companyId, invoiceId): Promise +### cancel(companyId, invoiceId): Promise -Cancel an issued invoice. May return async (some municipalities process cancellation asynchronously). +Cancels an issued invoice. Cancellation is normally asynchronous: the API replies `202 + Location` +and the invoice moves through `WaitingSendCancel` → `Cancelled`. The result is a discriminated +union, `{ status: 'immediate', invoice }` or `{ status: 'async', response }`. Use +`cancelAndWait()` to poll until it settles. ### sendEmail(companyId, invoiceId): Promise Send invoice to borrower's email. Returns `{ sent: boolean, message?: string }`. -### downloadPdf(companyId, invoiceId?): Promise +### downloadPdf(companyId, invoiceId): Promise -Download DANFE PDF. If `invoiceId` is omitted, downloads a ZIP of all invoices for the company. +Downloads the invoice PDF. Only available once the invoice reaches a terminal state +(Issued/Cancelled); before that the API answers 404. -### downloadXml(companyId, invoiceId?): Promise +### downloadXml(companyId, invoiceId): Promise -Download RPS XML. Same ZIP behavior when `invoiceId` is omitted. +Downloads the NFS-e XML. -### getStatus(companyId, invoiceId): Promise +There is no batch download: `invoiceId` is required since 6.0.0. The old branch without an id +called a route the server does not have. -Returns `{ flowStatus: FlowStatus, invoice: ServiceInvoiceData, isComplete: boolean, isFailed: boolean }`. +### getStatus(companyId, invoiceId): Promise<{ status: FlowStatus; invoice: ServiceInvoiceData; isComplete: boolean; isFailed: boolean }> -### createBatch(companyId, invoices, options?): Promise> +`status` is the invoice's `flowStatus`. `isComplete` is true in a terminal state, and `isFailed` +for `IssueFailed`/`CancelFailed`. + +### createBatch(companyId, invoices, options?): Promise> Batch creation with optional concurrency control: ```typescript @@ -281,36 +289,27 @@ interface NaturalPerson { ## WebhooksResource -Access via `nfe.webhooks`. Company-scoped. - -```typescript -interface Webhook { - id?: string; - url: string; - events: WebhookEvent[]; - active?: boolean; - secret?: string; - createdOn?: string; - modifiedOn?: string; -} +Access via `nfe.webhooks`. -type WebhookEvent = - | 'invoice.created' - | 'invoice.issued' - | 'invoice.cancelled' - | 'invoice.failed'; -``` +> ⚠️ **The company-scoped methods are deprecated: the platform does not serve the route.** +> `list`, `create`, `retrieve`, `update`, `delete` and `test` call `/v1/companies/{id}/webhooks`, +> which answers 404 (deprecated since v5.1, confirmed on three accounts). Use the account-scoped +> methods (`createAccountWebhook`, `listAccountWebhooks`, `retrieveAccountWebhook`, +> `updateAccountWebhook`, `deleteAccountWebhook`, `deleteAllAccountWebhooks`, +> `pingAccountWebhook`), documented in `references/rtc-nfce-and-account-resources.md`. The +> `Webhook` and `WebhookEvent` types describe shapes the API rejects: use `AccountWebhook` and +> `WebhookEventType` (e.g. `'service_invoice.issued_successfully'`). | Method | Signature | |--------|-----------| -| `list` | `(companyId): Promise>` | -| `create` | `(companyId, data): Promise` | -| `retrieve` | `(companyId, webhookId): Promise` | -| `update` | `(companyId, webhookId, data): Promise` | -| `delete` | `(companyId, webhookId): Promise` | +| `list` | `(companyId): Promise>`: deprecated, route not served | +| `create` | `(companyId, data): Promise`: deprecated, route not served | +| `retrieve` | `(companyId, webhookId): Promise`: deprecated, route not served | +| `update` | `(companyId, webhookId, data): Promise`: deprecated, route not served | +| `delete` | `(companyId, webhookId): Promise`: deprecated, route not served | +| `test` | `(companyId, webhookId): Promise<{ success: boolean; message?: string }>`: deprecated, route not served | | `validateSignature` | `(payload: Buffer \| string, signature: string \| string[] \| undefined, secret): boolean` | -| `test` | `(companyId, webhookId): Promise<{ success: boolean }>` | -| `getAvailableEvents` | `(): WebhookEvent[]` | +| `getAvailableEvents` | `(): WebhookEvent[]`: deprecated, a fixed list that drifts; use `fetchEventTypes()` | ### Webhook Signature Validation diff --git a/src/core/client.ts b/src/core/client.ts index a61976d..ee8f71c 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -8,6 +8,7 @@ * @module nfe-io/client * @author NFE.io * @license MIT + * @copyright 2026 NFE.IO HUB SERVICOS DE TECNOLOGIA LTDA */ import type { @@ -224,6 +225,9 @@ export class NfeClient { * - Upload digital certificates (PFX/P12) * - Batch operations * + * Uses the main `apiKey` on both of its hosts: `api.nfe.io` (v1) and the + * fiscal host `api.nfse.io` (the v2 side). + * * @see {@link CompaniesResource} * @throws {ConfigurationError} If API key is not configured * @@ -686,7 +690,7 @@ export class NfeClient { * NF-e product invoice issuance — list, create, retrieve, update, and delete. * * **Note:** This resource uses the api.nfse.io host. - * Uses the main `apiKey` — `api.nfse.io` is a FISCAL host and rejects the data key with 403. + * Uses the main `apiKey` (fiscal host `api.nfse.io`). * * @see {@link StateTaxesResource} * @throws {ConfigurationError} If no main API key is configured (apiKey) @@ -731,7 +735,8 @@ export class NfeClient { /** * Municipal Taxes resource — CRUD for company municipal tax registrations - * (Inscrições Municipais), prerequisite for NFS-e issuance. Uses api.nfse.io. + * (Inscrições Municipais), prerequisite for NFS-e issuance. + * Uses the main `apiKey` (fiscal host `api.nfse.io`). */ get municipalTaxes(): MunicipalTaxesResource { if (!this._municipalTaxes) { @@ -754,8 +759,9 @@ export class NfeClient { /** * Certificates resource — manage company digital certificates (retrieve/delete - * by thumbprint, list) via the contribuintes-v2 API on api.nfse.io. Complements - * the legacy `companies.uploadCertificate` (which targets the api.nfe.io host). + * by thumbprint, list) via the contribuintes-v2 API. Complements the legacy + * `companies.uploadCertificate` (which targets the api.nfe.io host). + * Uses the main `apiKey` (fiscal host `api.nfse.io`). */ get certificates(): CertificatesResource { if (!this._certificates) { @@ -907,10 +913,12 @@ export class NfeClient { * Get or create the HTTP client for `api.nfse.io` — the FISCAL host. * * Every resource on this host uses the MAIN api key. The two platform keys are - * complementary, not interchangeable: the data key is rejected with 403 here, - * and the main key is rejected with 403 on the lookup hosts - * (`nfe.api.nfe.io`, `legalentity`, `naturalperson`, `address`). - * Verified live on 2026-09-01 — see tests/fixtures/live-contracts/api-key-host-matrix.json. + * complementary, not interchangeable: the data key is rejected with 403 on this + * host's fiscal-document routes, and the main key is rejected with 403 on the + * lookup hosts (`nfe.api.nfe.io`, `legalentity`, `naturalperson`, `address`). + * Some account-registry routes here also accept the data key; that is not a + * documented contract, so the SDK does not rely on it. + * Measured route by route on 2026-10-14 — see tests/fixtures/live-contracts/api-key-route-matrix.json. * * @throws {ConfigurationError} If no main API key is configured */ diff --git a/src/core/resources/inbound-product-invoices.ts b/src/core/resources/inbound-product-invoices.ts index 89eda2b..a0d79c1 100644 --- a/src/core/resources/inbound-product-invoices.ts +++ b/src/core/resources/inbound-product-invoices.ts @@ -310,8 +310,12 @@ export class InboundProductInvoicesResource { validateCompanyId(companyId); validateAccessKey(accessKey); + // Plural `/inbound/productinvoices/` here and in the three methods below, as the platform + // serves it. The singular form answers 404 with an empty body even without credentials, like an + // invented path (measured 2026-10-14). The local spec consulta-nfe-distribuicao-v1.yaml + // (vintage b487823) still says singular; upstream fixed it in nfe/docs@51d8816. const response = await this.http.get( - `/v2/companies/${companyId}/inbound/productinvoice/${accessKey.trim()}` + `/v2/companies/${companyId}/inbound/productinvoices/${accessKey.trim()}` ); return response.data; @@ -393,7 +397,7 @@ export class InboundProductInvoicesResource { validateEventKey(eventKey); const response = await this.http.get( - `/v2/companies/${companyId}/inbound/productinvoice/${accessKey.trim()}/events/${eventKey.trim()}` + `/v2/companies/${companyId}/inbound/productinvoices/${accessKey.trim()}/events/${eventKey.trim()}` ); return response.data; @@ -537,7 +541,7 @@ export class InboundProductInvoicesResource { validateAccessKey(accessKey); const response = await this.http.get( - `/v2/companies/${companyId}/inbound/productinvoice/${accessKey.trim()}/json` + `/v2/companies/${companyId}/inbound/productinvoices/${accessKey.trim()}/json` ); return response.data; @@ -636,7 +640,7 @@ export class InboundProductInvoicesResource { validateAccessKeyOrNsu(accessKeyOrNsu); const response = await this.http.post( - `/v2/companies/${companyId}/inbound/productinvoice/${accessKeyOrNsu.trim()}/processwebhook` + `/v2/companies/${companyId}/inbound/productinvoices/${accessKeyOrNsu.trim()}/processwebhook` ); return response.data; diff --git a/src/core/resources/municipal-taxes.ts b/src/core/resources/municipal-taxes.ts index 69fce9e..e647fce 100644 --- a/src/core/resources/municipal-taxes.ts +++ b/src/core/resources/municipal-taxes.ts @@ -16,6 +16,7 @@ import type { } from '../types.js'; import { ValidationError } from '../errors/index.js'; import { withUnservedRouteNote } from '../utils/unserved-route.js'; +import { unwrapEnvelope } from '../utils/envelope.js'; function validateCompanyId(companyId: string): void { if (!companyId || companyId.trim() === '') { @@ -46,23 +47,48 @@ export class MunicipalTaxesResource { /** Create a municipal tax registration (wrapped as `{ municipalTax }`). */ async create(companyId: string, data: CreateMunicipalTaxData): Promise { validateCompanyId(companyId); - const response = await this.http.post(this.basePath(companyId), { + const response = await this.http.post(this.basePath(companyId), { municipalTax: data, }); - return response.data; + return unwrapEnvelope(response.data, 'municipalTax'); } - /** Retrieve a municipal tax registration by id. */ + /** + * Retrieve a municipal tax registration by id. + * + * The API answers `{ "municipalTax": {…} }` (measured 2026-10-15); this returns the + * registration itself. + */ async retrieve(companyId: string, municipalTaxId: string): Promise { validateCompanyId(companyId); validateMunicipalTaxId(municipalTaxId); - const response = await this.http.get( + const response = await this.http.get( `${this.basePath(companyId)}/${municipalTaxId}` ); - return response.data; + return unwrapEnvelope(response.data, 'municipalTax'); } - /** Update a municipal tax registration. */ + /** + * Update a municipal tax registration. + * + * **This is a PUT that replaces the registration, not a partial update.** Per the platform + * documentation (nfe/docs@b9e7953, 2026-10-14), an omitted `taxNumber` is erased and an + * omitted `environment` falls back to `Development`; other omitted fields are not guaranteed to + * keep their value. Read the registration, change what you need and send the whole object back. + * + * `rpsNumber` and `lastRpsSent` are the exception: omitted, they keep the series' RPS counter. + * A value you send overwrites the counter, even backwards, so resending the number you read + * before an emission repeats RPS numbers. Omit both unless you mean to move the counter. + * + * The SDK did not measure this itself: it would require writing over a real registration. + * + * @example + * ```typescript + * const current = await nfe.municipalTaxes.retrieve(companyId, municipalTaxId); + * const { rpsNumber, lastRpsSent, ...rest } = current; // omitted, the RPS counter is kept + * await nfe.municipalTaxes.update(companyId, municipalTaxId, { ...rest, issRate: 2.5 }); + * ``` + */ async update( companyId: string, municipalTaxId: string, @@ -70,11 +96,11 @@ export class MunicipalTaxesResource { ): Promise { validateCompanyId(companyId); validateMunicipalTaxId(municipalTaxId); - const response = await this.http.put( + const response = await this.http.put( `${this.basePath(companyId)}/${municipalTaxId}`, { municipalTax: data } ); - return response.data; + return unwrapEnvelope(response.data, 'municipalTax'); } /** Delete a municipal tax registration. */ diff --git a/src/core/resources/notifications.ts b/src/core/resources/notifications.ts index 6891083..a38bcb6 100644 --- a/src/core/resources/notifications.ts +++ b/src/core/resources/notifications.ts @@ -8,6 +8,7 @@ import type { HttpClient } from '../http/client.js'; import { ValidationError } from '../errors/index.js'; +import { unwrapEnvelope } from '../utils/envelope.js'; /** A company notification (minimal, spec has no named schema). */ export interface Notification { @@ -47,14 +48,19 @@ export class NotificationsResource { return response.data; } - /** Retrieve a notification by id. */ + /** + * Retrieve a notification by id. + * + * The API answers `{ "notification": {…} }` (measured 2026-10-15); this returns the + * notification itself. + */ async retrieve(companyId: string, notificationId: string): Promise { validateCompanyId(companyId); validateNotificationId(notificationId); - const response = await this.http.get( + const response = await this.http.get( `${this.basePath(companyId)}/${notificationId}` ); - return response.data; + return unwrapEnvelope(response.data, 'notification'); } /** Delete a notification. */ diff --git a/src/core/resources/state-taxes.ts b/src/core/resources/state-taxes.ts index 90a4979..83b1448 100644 --- a/src/core/resources/state-taxes.ts +++ b/src/core/resources/state-taxes.ts @@ -15,6 +15,7 @@ import type { NfeStateTaxListOptions, } from '../types.js'; import { ValidationError } from '../errors/index.js'; +import { unwrapEnvelope } from '../utils/envelope.js'; // ============================================================================ // Validation Helpers @@ -164,11 +165,11 @@ export class StateTaxesResource { data: NfeStateTaxCreateData, ): Promise { validateCompanyId(companyId); - const response = await this.http.post( + const response = await this.http.post( this.basePath(companyId), { stateTax: data }, ); - return response.data; + return unwrapEnvelope(response.data, 'stateTax'); } // -------------------------------------------------------------------------- @@ -178,6 +179,9 @@ export class StateTaxesResource { /** * Retrieve a specific state tax registration by ID. * + * The API answers `{ "stateTax": {…} }` (measured 2026-10-15); this returns the + * registration itself. + * * @param companyId - The company ID * @param stateTaxId - The state tax ID * @returns The state tax record @@ -196,10 +200,10 @@ export class StateTaxesResource { ): Promise { validateCompanyId(companyId); validateStateTaxId(stateTaxId); - const response = await this.http.get( + const response = await this.http.get( `${this.basePath(companyId)}/${stateTaxId}`, ); - return response.data; + return unwrapEnvelope(response.data, 'stateTax'); } // -------------------------------------------------------------------------- @@ -231,11 +235,11 @@ export class StateTaxesResource { ): Promise { validateCompanyId(companyId); validateStateTaxId(stateTaxId); - const response = await this.http.put( + const response = await this.http.put( `${this.basePath(companyId)}/${stateTaxId}`, { stateTax: data }, ); - return response.data; + return unwrapEnvelope(response.data, 'stateTax'); } // -------------------------------------------------------------------------- diff --git a/src/core/types.ts b/src/core/types.ts index 5d787e3..b13faaf 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -30,10 +30,14 @@ export interface NfeConfig { * Covers address (CEP), legal entity (CNPJ), natural person (CPF) and the * product/consumer invoice *query* resources. * - * **The two keys are NOT interchangeable.** Each is rejected with HTTP 403 on - * the other family's hosts — verified live on 2026-09-01, see - * `tests/fixtures/live-contracts/api-key-host-matrix.json`. Setting this key - * does not affect any fiscal resource. + * **The two keys are NOT interchangeable.** The SDK sends the main key to every + * resource on `api.nfe.io` and `api.nfse.io`, and this key to the lookup hosts. + * Measured route by route on 2026-10-14 (see + * `tests/fixtures/live-contracts/api-key-route-matrix.json`): this key is rejected + * with HTTP 403 on fiscal-document routes, and the main key is rejected on the + * lookup hosts. Some account-registry routes also accept this key; that is not a + * documented contract and the SDK does not rely on it. Setting this key does not + * affect any fiscal resource. * * Optional: falls back to {@link NfeConfig.apiKey} when omitted. Note the * fallback is one-way — a client configured with ONLY `dataApiKey` cannot diff --git a/src/core/utils/envelope.ts b/src/core/utils/envelope.ts new file mode 100644 index 0000000..545a1ff --- /dev/null +++ b/src/core/utils/envelope.ts @@ -0,0 +1,34 @@ +/** + * NFE.io SDK — envelopes de propriedade única nas respostas da API. + * + * Várias rotas respondem com a entidade embrulhada numa propriedade só: + * `{ "municipalTax": {…} }`, `{ "stateTax": {…} }`, `{ "notification": {…} }`. + * Até a v6.0.0, alguns métodos devolviam esse envelope com o tipo da entidade: + * o código que seguia o tipo compilava e lia `resultado.id` como `undefined`. + * + * Medido em 2026-10-15: a leitura por id dos três recursos responde com o + * envelope. A spec `contribuintes-v2` declara o mesmo envelope no `POST` e no + * `PUT` das inscrições, que não foram sondados porque exigiriam escrita. Por + * isso o desembrulho é defensivo: se a resposta vier com a entidade direta, ela + * passa como veio. + * + * Não fica na camada HTTP de propósito. Cada recurso sabe a chave do próprio + * envelope; um desembrulho genérico ali teria de adivinhá-la e mudaria o retorno + * de métodos que já desembrulham certo (`companies`, `webhooks`, `legalPeople`). + */ + +const isPlainObject = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +/** + * Devolve `body[key]` quando ele é um objeto; nos demais casos, o `body` como veio. + * + * @param body - O corpo da resposta, já parseado + * @param key - A propriedade do envelope (`municipalTax`, `stateTax`, `notification`…) + */ +export function unwrapEnvelope(body: unknown, key: string): T { + if (isPlainObject(body) && isPlainObject(body[key])) { + return body[key] as T; + } + return body as T; +} diff --git a/src/index.ts b/src/index.ts index ae75509..c11b91a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,9 +32,9 @@ * ``` * * @module nfe-io - * @version 5.1.0 * @author NFE.io * @license MIT + * @copyright 2026 NFE.IO HUB SERVICOS DE TECNOLOGIA LTDA */ // ============================================================================ diff --git a/src/version.ts b/src/version.ts index e2b65ba..e711043 100644 --- a/src/version.ts +++ b/src/version.ts @@ -11,4 +11,4 @@ export const PACKAGE_NAME = 'nfe-io'; /** Versão desta build, vinda do `package.json`. */ -export const VERSION = '6.0.0'; +export const VERSION = '6.0.1'; diff --git a/tests/fixtures/live-contracts/api-key-host-matrix.json b/tests/fixtures/live-contracts/api-key-host-matrix.json index cf82951..52ee46f 100644 --- a/tests/fixtures/live-contracts/api-key-host-matrix.json +++ b/tests/fixtures/live-contracts/api-key-host-matrix.json @@ -1,4 +1,7 @@ { + "supersededBy": "api-key-route-matrix.json", + "supersededOn": "2026-10-14", + "supersededReason": "esta captura mediu so rotas de documento fiscal em api.nfse.io e generalizou para o host; a regra e por rota", "probe": "⑧ qual chave cada host aceita", "capturedOn": "2026-09-01", "note": "As duas chaves sao COMPLEMENTARES, nao alternativas. getCteHttpClient() usa a chave DATA num host FISCAL — 8 resources afetados.", diff --git a/tests/fixtures/live-contracts/api-key-route-matrix.json b/tests/fixtures/live-contracts/api-key-route-matrix.json new file mode 100644 index 0000000..e3bde51 --- /dev/null +++ b/tests/fixtures/live-contracts/api-key-route-matrix.json @@ -0,0 +1,27 @@ +{ + "probe": "qual chave cada ROTA aceita (refaz e amplia api-key-host-matrix.json)", + "capturedOn": "2026-10-14", + "note": "A fronteira entre as chaves e por rota, nao por host. A chave de dados e recusada (403) nas rotas de documento fiscal e aceita nas rotas de cadastro da conta; a principal e recusada nos hosts de consulta. O SDK envia a principal a todo recurso de api.nfe.io e api.nfse.io e nao depende da aceitacao da chave de dados no cadastro, que nao e contrato documentado (pergunta aberta ao time de API).", + "correctsRecordOf": "api-key-host-matrix.json (2026-09-01) mediu so rotas de documento fiscal em api.nfse.io e generalizou para o host.", + "matrix": [ + { "host": "api.nfse.io", "pathShape": "/tax-codes/operation-code", "kind": "documento fiscal", "mainKey": 200, "dataKey": 403 }, + { "host": "api.nfse.io", "pathShape": "/v2/companies/{id}/consumerinvoices", "kind": "documento fiscal", "mainKey": 200, "dataKey": 403 }, + { "host": "api.nfse.io", "pathShape": "/v2/companies/{id}/productinvoices", "kind": "documento fiscal", "mainKey": 200, "dataKey": 403 }, + { "host": "api.nfse.io", "pathShape": "/v2/companies/{id}/inbound/productinvoices", "kind": "documento fiscal", "mainKey": 404, "dataKey": 403, "note": "404 = empresa sem captura configurada; a 403 da chave de dados e que e recusa de autenticacao" }, + { "host": "api.nfse.io", "pathShape": "POST /tax-rules/{tenantId}/engine/calculate", "kind": "documento fiscal", "mainKey": 403, "dataKey": 403, "capturedOn": "2026-10-16", + "note": "Com a principal, 403 de aplicacao em ProblemDetails ('Authorization account does not match tenant id': o tenant usado era um id de empresa). Com a de dados, 403 de corpo vazio, recusa do gateway, como nas demais rotas de documento." }, + { "host": "api.nfe.io", "pathShape": "/v1/companies/{id}/serviceinvoices", "kind": "documento fiscal", "mainKey": 200, "dataKey": 403 }, + { "host": "api.nfse.io", "pathShape": "/v2/companies/{id}", "kind": "cadastro", "mainKey": 200, "dataKey": 200 }, + { "host": "api.nfse.io", "pathShape": "/v2/companies?limit=1", "kind": "cadastro", "mainKey": 200, "dataKey": 200 }, + { "host": "api.nfse.io", "pathShape": "/v2/companies/{id}/municipaltaxes", "kind": "cadastro", "mainKey": 200, "dataKey": 200 }, + { "host": "api.nfse.io", "pathShape": "/v2/companies/{id}/statetaxes", "kind": "cadastro", "mainKey": 200, "dataKey": 200 }, + { "host": "api.nfse.io", "pathShape": "/v2/companies/{id}/certificates", "kind": "cadastro", "mainKey": 200, "dataKey": 200 }, + { "host": "api.nfse.io", "pathShape": "/v2/webhooks", "kind": "cadastro", "mainKey": 200, "dataKey": 200 }, + { "host": "api.nfe.io", "pathShape": "/v2/webhooks", "kind": "cadastro", "mainKey": 200, "dataKey": 200 }, + { "host": "api.nfe.io", "pathShape": "/v1/companies?pageCount=2", "kind": "cadastro", "mainKey": 200, "dataKey": 200 }, + { "host": "legalentity.api.nfe.io", "pathShape": "/v1/legalentities/basicInfo/{taxNumber}", "kind": "consulta", "mainKey": 403, "dataKey": 200 }, + { "host": "address.api.nfe.io", "pathShape": "/v2/addresses/{postalCode}", "kind": "consulta", "mainKey": 403, "dataKey": 200 }, + { "host": "nfe.api.nfe.io", "pathShape": "/v3/productinvoices/serpro/{accessKey}.xml", "kind": "consulta", "mainKey": 403, "dataKey": 406, "note": "406 com chave de acesso sintetica: a autenticacao passou" } + ], + "keysAreDistinct": "as duas chaves do .env da sonda tem valores diferentes (comparadas por hash, sem expor valor)" +} diff --git a/tests/fixtures/live-contracts/inbound-distribution-routes.json b/tests/fixtures/live-contracts/inbound-distribution-routes.json new file mode 100644 index 0000000..3f2ce7b --- /dev/null +++ b/tests/fixtures/live-contracts/inbound-distribution-routes.json @@ -0,0 +1,42 @@ +{ + "probe": "distribuicao de NF-e: /inbound/productinvoice (singular) x /inbound/productinvoices (plural)", + "capturedOn": "2026-10-14", + "host": "api.nfse.io", + "note": "Os quatro metodos (getProductInvoiceDetails, getProductInvoiceEventDetails, getJson, reprocessWebhook) usavam o singular, que a plataforma nao serve. A spec local consulta-nfe-distribuicao-v1.yaml (vintage b487823) ainda diz singular; o upstream corrigiu para o plural em nfe/docs@51d8816, 'alinhado ao contrato real'.", + "metodoDeDiscriminacao": "Rota nao servida responde 404 de corpo vazio inclusive sem credencial, igual a um path inventado; rota servida responde 401 sem credencial. POST processwebhook so foi exercido sem credencial: com credencial dispararia webhook de empresa alheia.", + "cases": [ + { "method": "GET", "pathShape": "/v2/companies/{companyId}/inbound/productinvoice/{accessKey}", "credential": "main", "status": 404, "headers": {}, "bodyLength": 0 }, + { "method": "GET", "pathShape": "/v2/companies/{companyId}/inbound/productinvoice/{accessKey}/json", "credential": "main", "status": 404, "headers": {}, "bodyLength": 0 }, + { "method": "GET", "pathShape": "/v2/companies/{companyId}/inbound/productinvoice/{accessKey}", "credential": "none", "status": 404, "headers": {}, "bodyLength": 0 }, + { "method": "GET", "pathShape": "/v2/companies/{companyId}/inbound/productinvoice/{accessKey}/events/{eventKey}", "credential": "none", "status": 404, "headers": {}, "bodyLength": 0 }, + { "method": "POST", "pathShape": "/v2/companies/{companyId}/inbound/productinvoice/{accessKey}/processwebhook", "credential": "none", "status": 404, "headers": {}, "bodyLength": 0 }, + { "method": "GET", "pathShape": "/v2/companies/{companyId}/inbound/productinvoices/{accessKey}", "credential": "main", "status": 200, + "headers": { "content-type": "application/json; charset=utf-8" }, + "bodyKeys": ["accessKey", "createdOn", "parentAccessKey", "company", "issuer", "buyer", "transportation", "links"] }, + { "method": "GET", "pathShape": "/v2/companies/{companyId}/inbound/productinvoices/{accessKey}/json", "credential": "main", "status": 200, + "headers": { "content-type": "application/json; charset=utf-8" }, + "bodyKeys": ["nfeProc"] }, + { "method": "GET", "pathShape": "/v2/companies/{companyId}/inbound/productinvoices/{accessKey}", "credential": "none", "status": 401, "headers": {}, "bodyLength": 0 }, + { "method": "GET", "pathShape": "/v2/companies/{companyId}/inbound/productinvoices/{accessKey}/events/{eventKey}", "credential": "none", "status": 401, "headers": {}, "bodyLength": 0 }, + { "method": "POST", "pathShape": "/v2/companies/{companyId}/inbound/productinvoices/{accessKey}/processwebhook", "credential": "none", "status": 401, "headers": {}, "bodyLength": 0 }, + { "method": "GET", "pathShape": "/v2/companies/{companyId}/inbound/productinvoicez/{accessKey}", "credential": "none", "status": 404, "headers": {}, "bodyLength": 0, "note": "controle: path inventado" } + ], + "syntheticBodyExample": { + "accessKey": "00000000000000000000550000000000000000000000", + "createdOn": "2026-01-01T00:00:00Z", + "parentAccessKey": null, + "company": { "id": "synthetic-company-id" }, + "issuer": { "federalTaxNumber": 0, "name": "EMITENTE SINTETICO" }, + "buyer": { "federalTaxNumber": 0, "name": "DESTINATARIO SINTETICO" }, + "transportation": {}, + "links": {} + }, + "gatewayLog30d": { + "window": "2026-09-14 a 2026-10-14", + "pluralStatus200": 28543, + "singularStatus404": 9, + "singularFromSdkUserAgent": 0, + "note": "O bug era latente: nenhuma requisicao com User-Agent do SDK bateu no singular em 30 dias." + }, + "upstream": "nfe/docs@51d8816 (2026-09-23); origem: nfe/dfetech-distribution-api#444 e #445" +} diff --git a/tests/fixtures/live-contracts/response-envelopes.json b/tests/fixtures/live-contracts/response-envelopes.json new file mode 100644 index 0000000..8d2b0b0 --- /dev/null +++ b/tests/fixtures/live-contracts/response-envelopes.json @@ -0,0 +1,38 @@ +{ + "probe": "envelopes de propriedade unica nas respostas", + "capturedOn": "2026-10-15", + "note": "Corpo sintetico; as chaves do envelope vem de respostas reais, e as chaves internas usadas aqui foram vistas no fio (stateTax traz series, array, nao serie). Os registros de pessoas da conta do time tem dado pessoal e nao entram aqui.", + "fixedInThisRelease": [ + { + "method": "municipalTaxes.retrieve", + "request": "GET /v2/companies/{companyId}/municipaltaxes/{municipalTaxId}", + "host": "api.nfse.io", + "response": { "status": 200, "envelopeKey": "municipalTax", "body": { "municipalTax": { "id": "synthetic-mt-id", "taxNumber": "000000", "rpsSerialNumber": "IO" } } }, + "sdkBefore": "devolvia o envelope com o tipo MunicipalTax; resultado.id era undefined", + "alsoDeclaredBySpec": ["POST /municipaltaxes", "PUT /municipaltaxes/{id}"], + "postAndPutProbed": false + }, + { + "method": "stateTaxes.retrieve", + "request": "GET /v2/companies/{companyId}/statetaxes/{stateTaxId}", + "host": "api.nfse.io", + "response": { "status": 200, "envelopeKey": "stateTax", "body": { "stateTax": { "id": "synthetic-st-id", "companyId": "synthetic-company-id", "series": [1] } } }, + "sampling": "11 de 12 inscricoes da conta do time responderam 200 com envelope; 1 respondeu 503 (ProblemDetails), problema daquele registro", + "sdkBefore": "devolvia o envelope com o tipo NfeStateTax; resultado.id era undefined", + "alsoDeclaredBySpec": ["POST /statetaxes", "PUT /statetaxes/{id}"], + "postAndPutProbed": false + }, + { + "method": "notifications.retrieve", + "request": "GET /v1/companies/{companyId}/notifications/{notificationId}", + "host": "api.nfe.io", + "response": { "status": 200, "envelopeKey": "notification", "body": { "notification": { "id": "synthetic-notification-id", "filters": [] } } }, + "sdkBefore": "devolvia o envelope; o tipo tem assinatura de indice e o compilador nao acusava resultado.id" + } + ], + "sdkAlreadyRightSpecWrong": [ + { "method": "legalPeople.list", "request": "GET /v1/companies/{companyId}/legalpeople", "wireEnvelope": "{ legalPeople: [...] }", "spec": "nf-servico-v1 declara um objeto unico, sem envelope" }, + { "method": "naturalPeople.retrieve", "request": "GET /v1/companies/{companyId}/naturalpeople/{id}", "wireEnvelope": "{ naturalPeople: {...} }", "spec": "nf-servico-v1 declara a entidade direta" }, + { "method": "addresses.lookupByPostalCode", "request": "GET /v2/addresses/{postalCode}", "wireEnvelope": "{ address: {...} }", "spec": "consulta-endereco declara { addresses: [...] }; upstream nfe/docs#240" } + ] +} diff --git a/tests/integration/README.md b/tests/integration/README.md index 1a27097..5a21de3 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -7,7 +7,11 @@ These tests validate the NFE.io SDK against the **real API** (development or pro - **Real API calls**: These tests make actual HTTP requests to NFE.io API - **API key required**: You must provide valid credentials - **Costs may apply**: Depending on your NFE.io plan, API calls might incur costs -- **Cleanup**: Tests attempt to cleanup resources, but failures might leave data +- **Cleanup**: `companies.remove` answers with success but the company stays in the account with + status `Inactive` (measured 2026-10-16), so the company write tests leave data behind. They + are opt-in for that reason (`NFE_ALLOW_ACCOUNT_WRITES`) +- **`npm test` runs them too**: outside CI, the full suite includes these tests whenever an API + key is set in `.env` ## 🔧 Setup @@ -30,6 +34,11 @@ export NFE_TEST_ENVIRONMENT="development" # or "production" # Optional: Enable integration tests in CI export RUN_INTEGRATION_TESTS="true" +# Optional: allow tests that create, update or delete companies in the account. +# Only with an account of your own that you can throw away -- never with a key +# shared by a team, since those writes touch everyone's data. +export NFE_ALLOW_ACCOUNT_WRITES="true" + # Optional: Debug logging export DEBUG_INTEGRATION_TESTS="true" ``` @@ -66,12 +75,13 @@ npm run test:integration -- --coverage - Client factory ### `companies.integration.test.ts` -- CRUD operations for Companies +- CRUD operations for Companies, including duplicate CNPJ: **skipped unless + `NFE_ALLOW_ACCOUNT_WRITES=true`**, because they create and delete companies in the account - Certificate upload (skipped - requires PFX file) -- Validation error handling -- Duplicate detection +- Validation error handling (runs by default: the API rejects the body, nothing is written) +- 404 for a non-existent company (runs by default) -**Tests**: 8 tests (1 skipped) +**Tests**: 9 tests: 6 need `NFE_ALLOW_ACCOUNT_WRITES=true`, 2 run by default, 1 always skipped ### `service-invoices.integration.test.ts` - Complete invoice workflow @@ -156,7 +166,8 @@ it.skip('test that requires special setup', async () => { ## 🧹 Cleanup Tests automatically cleanup created resources: -- Companies are deleted after tests +- Companies get `companies.remove` after tests, but the API keeps them in the account with + status `Inactive` - Invoices are cancelled after tests - Errors during cleanup are logged but don't fail tests diff --git a/tests/integration/companies.integration.test.ts b/tests/integration/companies.integration.test.ts index f21bbd7..9b4c4bd 100644 --- a/tests/integration/companies.integration.test.ts +++ b/tests/integration/companies.integration.test.ts @@ -11,9 +11,14 @@ import { cleanupTestCompany, logTestInfo, INTEGRATION_TEST_CONFIG, + ALLOW_ACCOUNT_WRITES, } from './setup.js'; import { NfeClient } from '../../src/core/client.js'; +// Criar, alterar ou apagar empresa mexe na conta inteira, que costuma ser compartilhada: +// esses testes só rodam com NFE_ALLOW_ACCOUNT_WRITES=true (ver setup.ts). +const itEscreveNaConta = it.skipIf(!ALLOW_ACCOUNT_WRITES); + // Integration tests should skip unless explicitly enabled describe.skipIf(skipIfNoApiKey())('Companies Integration Tests', () => { let client: NfeClient; @@ -34,7 +39,7 @@ describe.skipIf(skipIfNoApiKey())('Companies Integration Tests', () => { createdCompanyIds.length = 0; }); - it('should create a company', { timeout: INTEGRATION_TEST_CONFIG.timeout }, async () => { + itEscreveNaConta('should create a company', { timeout: INTEGRATION_TEST_CONFIG.timeout }, async () => { const companyData = { ...TEST_COMPANY_DATA, name: `Test Company ${Date.now()}`, @@ -52,7 +57,7 @@ describe.skipIf(skipIfNoApiKey())('Companies Integration Tests', () => { logTestInfo('Company created', { id: company.id }); }); - it('should retrieve a company by id', { timeout: INTEGRATION_TEST_CONFIG.timeout }, async () => { + itEscreveNaConta('should retrieve a company by id', { timeout: INTEGRATION_TEST_CONFIG.timeout }, async () => { // Create company first const companyData = { ...TEST_COMPANY_DATA, @@ -70,7 +75,7 @@ describe.skipIf(skipIfNoApiKey())('Companies Integration Tests', () => { expect(retrieved.name).toBe(companyData.name); }); - it('should list companies', { timeout: INTEGRATION_TEST_CONFIG.timeout }, async () => { + itEscreveNaConta('should list companies', { timeout: INTEGRATION_TEST_CONFIG.timeout }, async () => { // Create at least one company const companyData = { ...TEST_COMPANY_DATA, @@ -94,7 +99,7 @@ describe.skipIf(skipIfNoApiKey())('Companies Integration Tests', () => { expect(hasCompanies).toBe(true); }); - it('should update a company', { timeout: INTEGRATION_TEST_CONFIG.timeout }, async () => { + itEscreveNaConta('should update a company', { timeout: INTEGRATION_TEST_CONFIG.timeout }, async () => { // Create company first const companyData = { ...TEST_COMPANY_DATA, @@ -115,7 +120,7 @@ describe.skipIf(skipIfNoApiKey())('Companies Integration Tests', () => { expect(updated.name).toBe(updatedName); }); - it('should delete a company', { timeout: INTEGRATION_TEST_CONFIG.timeout }, async () => { + itEscreveNaConta('should delete a company', { timeout: INTEGRATION_TEST_CONFIG.timeout }, async () => { // Create company first const companyData = { ...TEST_COMPANY_DATA, @@ -127,9 +132,9 @@ describe.skipIf(skipIfNoApiKey())('Companies Integration Tests', () => { logTestInfo('Deleting company', { id: created.id }); await client.companies.remove(created.id); - // NOTE: API may return 204 but company might still be retrievable immediately after - // This is expected behavior in Development environment (eventual consistency) - // In Production, deletion would be immediate + // O remove responde com sucesso, mas a empresa não some: horas depois ela continua + // respondendo 200 em v1 e v2, com status Inactive (medido em 2026-10-16). Não é + // consistência eventual. // Remove from cleanup list since delete was called const index = createdCompanyIds.indexOf(created.id); @@ -159,7 +164,7 @@ describe.skipIf(skipIfNoApiKey())('Companies Integration Tests', () => { ).rejects.toThrow(); }); - it('should allow duplicate federalTaxNumber', { timeout: INTEGRATION_TEST_CONFIG.timeout }, async () => { + itEscreveNaConta('should allow duplicate federalTaxNumber', { timeout: INTEGRATION_TEST_CONFIG.timeout }, async () => { // Create first company const companyData = { ...TEST_COMPANY_DATA, diff --git a/tests/integration/setup.ts b/tests/integration/setup.ts index f9cfa24..8462bc9 100644 --- a/tests/integration/setup.ts +++ b/tests/integration/setup.ts @@ -78,6 +78,21 @@ export function skipIfNoDataApiKey(): boolean { return skipIfNoApiKey() || INTEGRATION_TEST_CONFIG.dataApiKey.length === 0; } +/** + * Escrita na conta só com opt-in explícito. + * + * A chave dos testes costuma ser de uma conta compartilhada por várias pessoas, e esta suíte + * não roda só no `npm run test:integration`: fora da CI, o `npm test` também a executa sempre + * que há chave no `.env`. Cada rodada dos testes de escrita de companies deixava sete empresas + * na conta, porque o `companies.remove` responde com sucesso e a empresa continua lá, com + * status `Inactive` (medido em 2026-10-16). Sem `NFE_ALLOW_ACCOUNT_WRITES=true`, esses testes + * pulam. Ligue a variável só com uma conta sua, descartável. + * + * Tentativas que a API recusa por validação (corpo inválido) continuam rodando: elas não + * gravam nada. + */ +export const ALLOW_ACCOUNT_WRITES = process.env.NFE_ALLOW_ACCOUNT_WRITES === 'true'; + // Test data helpers for integration tests export const TEST_COMPANY_DATA = { federalTaxNumber: 11222333000181, // Valid CNPJ with proper check digits diff --git a/tests/unit/client-api-key-wiring.test.ts b/tests/unit/client-api-key-wiring.test.ts index d6cccc1..b4cd60a 100644 --- a/tests/unit/client-api-key-wiring.test.ts +++ b/tests/unit/client-api-key-wiring.test.ts @@ -1,9 +1,11 @@ /** * Qual chave sai no fio, por família de host. * - * As duas chaves da plataforma são COMPLEMENTARES, não alternativas — cada uma - * responde 403 no território da outra (probe ao vivo 2026-09-01, evidência em - * tests/fixtures/live-contracts/api-key-host-matrix.json): + * As duas chaves da plataforma são COMPLEMENTARES, não alternativas. A regra é + * por rota (probe ao vivo 2026-10-14, evidência em + * tests/fixtures/live-contracts/api-key-route-matrix.json): a chave de dados é + * recusada (403) nas rotas de documento fiscal e a principal nos hosts de + * consulta. Algumas rotas de cadastro aceitam as duas; o SDK não depende disso. * * api.nfse.io / api.nfe.io (fiscal) → apiKey * nfe.api.nfe.io, legalentity, naturalperson, address (consulta) → dataApiKey diff --git a/tests/unit/docs-drift.test.ts b/tests/unit/docs-drift.test.ts index 0367be0..90b7ffe 100644 --- a/tests/unit/docs-drift.test.ts +++ b/tests/unit/docs-drift.test.ts @@ -22,7 +22,7 @@ import { describe, it, expect } from 'vitest'; import { readFileSync, readdirSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, dirname } from 'node:path'; /** Superfície pública: tudo que os resources e o cliente declaram. */ function superficiePublica(): string { @@ -97,3 +97,221 @@ describe('documentação × código', () => { expect(new RegExp(`\\b${inventado}\\s*[(<]`).test(codigo)).toBe(false); }); }); + +// --------------------------------------------------------------------------- +// Métodos sem caminho de sucesso +// --------------------------------------------------------------------------- + +/** + * Um método depreciado porque a plataforma não serve a rota nunca funciona. + * + * Em 2026-10-15, um mês depois de a v6 depreciar `municipalTaxes.updatePrefecture`, + * `municipalTaxes.getSeries` e `consumerInvoiceQuery.*`, eles ainda eram ensinados como + * funcionais na página do recurso, numa referência da skill, no README e nos exemplos + * — o de inscrições municipais chamava `getSeries` de fato. A verificação de nomes acima + * não pega isso: o nome existe, só está depreciado. + * + * O conjunto sai do código (JSDoc com `@deprecated`), não de uma lista à mão: um método + * novo depreciado entra sozinho. Ficam fora só as depreciações FUNCIONAIS — o método + * funciona e foi superado — listadas abaixo com o motivo. + * + * LIMITE, de propósito: o critério de "trecho" é sintático. Aceita o aviso no mesmo bloco + * (parágrafo, linha de tabela, bloco de código; em `examples/`, os comentários logo acima) + * ou no título da seção, mesmo que o aviso fale de outro método. O CHANGELOG fica fora por + * ser histórico. + */ +const DEPRECIACOES_FUNCIONAIS: Record = { + 'companies.list': + 'a v1 foi superada pelo listV2, mas funciona; a doc de companies é revista na v6.1.0', + 'webhooks.getAvailableEvents': + 'lista fixa superada pelo fetchEventTypes, mas funciona', +}; + +const AVISO = /deprec|depreci|descontinuad|n[ãa]o (é )?servid|not served|\b404\b/i; + +/** Nome do recurso no cliente a partir do arquivo: `consumer-invoice-query.ts` → `consumerInvoiceQuery`. */ +const recursoDoArquivo = (arquivo: string): string => + arquivo.replace(/\.ts$/, '').replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); + +/** + * `recurso.metodo` de todo método depreciado: o JSDoc imediatamente anterior ao método traz + * `@deprecated`, ou o da classe traz — aí valem todos os métodos públicos dela (é o caso de + * `consumerInvoiceQuery`, cujas duas rotas não são servidas). + */ +function metodosDepreciados(): Set { + const conjunto = new Set(); + const dir = 'src/core/resources'; + for (const arquivo of readdirSync(dir).filter(f => f.endsWith('.ts') && f !== 'index.ts')) { + const texto = readFileSync(join(dir, arquivo), 'utf8'); + const recurso = recursoDoArquivo(arquivo); + for (const m of texto.matchAll(/\/\*\*((?:(?!\*\/)[\s\S])*?)\*\/\s*(?:async\s+)?\*?\s*(\w+)\s*[(<]/g)) { + if (m[1]!.includes('@deprecated')) conjunto.add(`${recurso}.${m[2]}`); + } + const classe = /\/\*\*((?:(?!\*\/)[\s\S])*?)\*\/\s*export\s+class\s+\w+/.exec(texto); + if (classe?.[1]!.includes('@deprecated')) { + for (const m of texto.matchAll(/^ {2}(?:public\s+)?(?:async\s+)?\*?(\w+)\s*[(<]/gm)) { + if (m[1] !== 'constructor') conjunto.add(`${recurso}.${m[1]}`); + } + } + } + return conjunto; +} + +/** Arquivos de texto sob `dir`, recursivamente, com uma das extensões. */ +function listarRecursivo(dir: string, extensoes: string[]): string[] { + if (!existsSync(dir)) return []; + const saida: string[] = []; + for (const entrada of readdirSync(dir, { withFileTypes: true })) { + const caminho = join(dir, entrada.name); + if (entrada.isDirectory()) saida.push(...listarRecursivo(caminho, extensoes)); + else if (extensoes.some(ext => entrada.name.endsWith(ext))) saida.push(caminho); + } + return saida; +} + +/** O trecho em volta da linha `i`: bloco de código cercado, linha de tabela ou parágrafo. */ +function trechoMarkdown(linhas: string[], i: number): string { + // Dentro de bloco cercado? Conta as cercas antes da linha. + const cercas: number[] = []; + linhas.forEach((l, n) => { + if (/^\s*```/.test(l)) cercas.push(n); + }); + for (let k = 0; k + 1 < cercas.length; k += 2) { + if (i > cercas[k]! && i < cercas[k + 1]!) return linhas.slice(cercas[k]!, cercas[k + 1]! + 1).join('\n'); + } + if (/^\s*\|/.test(linhas[i]!)) return linhas[i]!; + let ini = i; + let fim = i; + while (ini > 0 && linhas[ini - 1]!.trim() !== '') ini--; + while (fim + 1 < linhas.length && linhas[fim + 1]!.trim() !== '') fim++; + return linhas.slice(ini, fim + 1).join('\n'); +} + +/** O título da seção mais próxima acima da linha `i`. */ +function tituloDaSecao(linhas: string[], i: number): string { + for (let n = i; n >= 0; n--) if (/^#{1,6}\s/.test(linhas[n]!)) return linhas[n]!; + return ''; +} + +/** Em código de exemplo: os comentários contíguos logo acima da linha, mais a própria linha. */ +function trechoCodigo(linhas: string[], i: number): string { + let ini = i; + while (ini > 0 && /^\s*(\/\/|\/\*|\*)/.test(linhas[ini - 1]!)) ini--; + return linhas.slice(ini, i + 1).join('\n'); +} + +describe('métodos sem caminho de sucesso × documentação', () => { + const depreciados = metodosDepreciados(); + const naoServidos = [...depreciados].filter(m => !(m in DEPRECIACOES_FUNCIONAIS)); + + it('o conjunto sai do código e inclui os métodos sobre rota não servida', () => { + for (const m of [ + 'municipalTaxes.updatePrefecture', + 'municipalTaxes.getSeries', + 'consumerInvoiceQuery.retrieve', + 'consumerInvoiceQuery.downloadXml', + 'webhooks.create', + ]) { + expect(naoServidos, `${m} deveria estar no conjunto derivado do código`).toContain(m); + } + }); + + it('toda depreciação funcional listada existe no código, com motivo', () => { + for (const [metodo, motivo] of Object.entries(DEPRECIACOES_FUNCIONAIS)) { + expect(depreciados, `${metodo} não está mais depreciado: tire da lista`).toContain(metodo); + expect(motivo.length).toBeGreaterThan(10); + } + }); + + it('nenhum documento ensina método sem caminho de sucesso sem avisar', () => { + const arquivos = [ + 'README.md', + 'MIGRATION.md', + ...listarRecursivo('docs', ['.md']), + ...listarRecursivo('skills', ['.md']), + ...listarRecursivo('examples', ['.md', '.js', '.ts', '.mjs', '.cjs']), + ].filter(existsSync); + + const semAviso: string[] = []; + for (const arquivo of arquivos) { + const linhas = readFileSync(arquivo, 'utf8').split('\n'); + const ehMarkdown = arquivo.endsWith('.md'); + linhas.forEach((linha, i) => { + for (const metodo of naoServidos) { + const [recurso, nome] = metodo.split('.') as [string, string]; + if (!new RegExp(`\\b${recurso}\\.${nome}\\s*\\(`).test(linha)) continue; + const trecho = ehMarkdown + ? `${trechoMarkdown(linhas, i)}\n${tituloDaSecao(linhas, i)}` + : trechoCodigo(linhas, i); + if (!AVISO.test(trecho)) semAviso.push(`${arquivo}:${i + 1}: ${metodo}()`); + } + }); + } + + expect( + semAviso, + `Documentação ensina método sem caminho de sucesso sem avisar:\n ${semAviso.join('\n ')}` + ).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Links relativos +// --------------------------------------------------------------------------- + +/** + * Link relativo precisa apontar para arquivo que existe. + * + * Em 2026-09-04 a issue #47 apontou três: `README.md` → `docs/MIGRATION-TO-GENERATED-TYPES.md`, + * `CHANGELOG.md` → `RELEASE_NOTES_v3.md` e `docs/API.md` → `../LICENSE`, que nunca existiu. + * + * `docs/` é espelhado no portal (`nfeio-docs`), onde a árvore em volta é outra: um link + * relativo que sai de `docs/` funciona no GitHub e quebra no portal. Por isso, em `docs/**`, + * resolver para fora de `docs/` também falha — para o README ou o LICENSE, use URL absoluta. + * + * LIMITE: âncoras (`#secao`) não são validadas. Validá-las exigiria reproduzir os + * algoritmos de slug do GitHub e do Docusaurus, que diferem. + */ +function linksQuebrados(arquivo: string, texto: string): string[] { + const problemas: string[] = []; + let dentroDeCodigo = false; + texto.split('\n').forEach((linha, i) => { + if (/^\s*```/.test(linha)) dentroDeCodigo = !dentroDeCodigo; + if (dentroDeCodigo) return; + for (const m of linha.matchAll(/!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) { + const alvo = m[1]!; + if (/^([a-z][a-z0-9+.-]*:|#|\/)/i.test(alvo)) continue; // URL, âncora pura ou rota do site + const caminho = decodeURI(alvo.split('#')[0]!.split('?')[0]!); + if (caminho === '') continue; + const resolvido = join(dirname(arquivo), caminho); + if (!existsSync(resolvido)) { + problemas.push(`${arquivo}:${i + 1}: ${alvo} (não existe)`); + } else if (arquivo.startsWith('docs/') && !resolvido.startsWith('docs/')) { + problemas.push(`${arquivo}:${i + 1}: ${alvo} (sai de docs/; quebra no portal)`); + } + } + }); + return problemas; +} + +describe('links relativos da documentação', () => { + it('apontam para arquivos que existem', () => { + const arquivos = [ + 'README.md', + 'MIGRATION.md', + 'CHANGELOG.md', + ...listarRecursivo('docs', ['.md']), + ...listarRecursivo('skills', ['.md']), + ].filter(existsSync); + + const quebrados = arquivos.flatMap(a => linksQuebrados(a, readFileSync(a, 'utf8'))); + + expect(quebrados, `Links quebrados:\n ${quebrados.join('\n ')}`).toEqual([]); + }); + + it('a verificação pega de verdade um link quebrado e um que sai de docs/', () => { + expect(linksQuebrados('README.md', '[x](./NAO-EXISTE.md)')).toHaveLength(1); + expect(linksQuebrados('docs/API.md', '[x](../README.md)')).toHaveLength(1); + expect(linksQuebrados('README.md', '[x](https://example.com) [y](#ancora)')).toHaveLength(0); + }); +}); diff --git a/tests/unit/license.test.ts b/tests/unit/license.test.ts new file mode 100644 index 0000000..fcd810b --- /dev/null +++ b/tests/unit/license.test.ts @@ -0,0 +1,156 @@ +/** + * A licença declarada vem junto do código? + * + * Até a v6.0.0 o repositório não tinha arquivo de licença: o MIT existia só no + * campo `license` do package.json, e a API do GitHub reportava o repositório + * como sem licença (issue #47). Para uma varredura de dependências ou uma + * revisão jurídica, licença só em metadado é "licença não confirmada". + * + * Este teste prende três coisas: + * - o SPDX do package.json corresponde ao texto do LICENSE, comparado ao modelo + * inteiro, não a uma palavra-chave; + * - o LICENSE entra no tarball (`npm pack --dry-run`, sem rodar scripts); + * - todo lugar que cita a licença usa o mesmo SPDX e a mesma forma do titular. + * + * Trocar de licença exige trocar o modelo aqui junto, de propósito: o teste + * falha até as duas coisas andarem juntas. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { join } from 'node:path'; + +/** Decisão de 2026-10-16 (design D6 da change fix-inbound-routes-and-doc-accuracy). */ +const TITULAR = 'NFE.IO HUB SERVICOS DE TECNOLOGIA LTDA'; +const LINHA_DE_COPYRIGHT = `Copyright (c) 2026 ${TITULAR}`; + +/** Modelos por SPDX, no formato do choosealicense.com, que é o que o GitHub usa para detectar. */ +const MODELOS: Record = { + MIT: `MIT License + +Copyright (c) [year] [fullname] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.`, +}; + +const normalizar = (texto: string) => texto.replace(/\s+/g, ' ').trim(); + +/** `null` quando o texto é o modelo do SPDX declarado; senão, o motivo. */ +function divergenciaDoTexto(spdx: string, texto: string): string | null { + const modelo = MODELOS[spdx]; + if (!modelo) return `SPDX "${spdx}" sem modelo neste teste: atualize MODELOS junto com a licença`; + const linha = texto.split('\n').find(l => l.startsWith('Copyright (c) ')); + if (!linha) return 'o texto não tem linha de copyright'; + const preenchido = modelo.replace('Copyright (c) [year] [fullname]', linha.trim()); + return normalizar(preenchido) === normalizar(texto) ? null : `o texto não é o modelo ${spdx}`; +} + +/** O npm inclui LICENSE/LICENCE da raiz no tarball mesmo fora de `files`. */ +const temArquivoDeLicenca = (arquivos: string[]) => + arquivos.some(a => /^LICEN[CS]E(\.[a-z]+)?$/i.test(a)); + +function arquivosDoTarball(): string[] { + const saida = execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + const [pacote] = JSON.parse(saida) as Array<{ files: Array<{ path: string }> }>; + return pacote?.files.map(f => f.path) ?? []; +} + +/** Citações de titular que não usam a forma decidida ("MIT © NFE.io", "NFe.io", pessoa física). */ +function titularesDivergentes(arquivo: string, conteudo: string): string[] { + const citacoes = conteudo.match(/(?:Copyright \(c\)|©|@copyright)[^\n]*/gi) ?? []; + return citacoes.filter(c => !c.includes(TITULAR)).map(c => `${arquivo}: "${c.trim()}"`); +} + +function listarRecursivo(dir: string, extensao: string): string[] { + return readdirSync(dir).flatMap(nome => { + const caminho = join(dir, nome); + if (statSync(caminho).isDirectory()) return listarRecursivo(caminho, extensao); + return caminho.endsWith(extensao) ? [caminho] : []; + }); +} + +describe('licença do pacote', () => { + const spdx = + (JSON.parse(readFileSync('package.json', 'utf8')) as { license?: string }).license ?? ''; + const texto = readFileSync('LICENSE', 'utf8'); + + it('o SPDX do package.json corresponde ao texto do LICENSE', () => { + expect(divergenciaDoTexto(spdx, texto)).toBeNull(); + }); + + it('o LICENSE traz o titular decidido', () => { + expect(texto).toContain(LINHA_DE_COPYRIGHT); + }); + + it('o LICENSE entra no tarball', () => { + expect(temArquivoDeLicenca(arquivosDoTarball())).toBe(true); + }, 30_000); + + it('os lugares que citam a licença usam o SPDX declarado e o mesmo titular', () => { + const citacoes: Array<[arquivo: string, trechos: string[]]> = [ + ['README.md', [`[${spdx}](./LICENSE)`, LINHA_DE_COPYRIGHT]], + ['CONTRIBUTING.md', [spdx, '[LICENSE](./LICENSE)']], + ['src/index.ts', [`@license ${spdx}`, `@copyright 2026 ${TITULAR}`]], + ['src/core/client.ts', [`@license ${spdx}`, `@copyright 2026 ${TITULAR}`]], + ['tsdown.config.ts', [`SPDX-License-Identifier: ${spdx}`, LINHA_DE_COPYRIGHT]], + ]; + const faltas = citacoes.flatMap(([arquivo, trechos]) => { + const conteudo = readFileSync(arquivo, 'utf8'); + return trechos.filter(t => !conteudo.includes(t)).map(t => `${arquivo}: falta "${t}"`); + }); + expect(faltas).toEqual([]); + }); + + it('nenhum lugar escreve o titular de outro jeito', () => { + const arquivos = [ + 'LICENSE', + 'README.md', + 'MIGRATION.md', + 'CONTRIBUTING.md', + 'tsdown.config.ts', + ...listarRecursivo('src', '.ts').filter(a => !a.startsWith(join('src', 'generated'))), + ...listarRecursivo('docs', '.md'), + ...listarRecursivo('skills', '.md'), + ]; + const divergentes = arquivos.flatMap(a => titularesDivergentes(a, readFileSync(a, 'utf8'))); + expect(divergentes).toEqual([]); + }); + + describe('a verificação falha quando deve', () => { + it('SPDX trocado sem trocar o texto', () => { + expect(divergenciaDoTexto('Apache-2.0', texto)).not.toBeNull(); + }); + + it('texto do LICENSE alterado', () => { + expect(divergenciaDoTexto('MIT', texto.replace('free of charge', 'for a fee'))).not.toBeNull(); + }); + + it('LICENSE fora do tarball', () => { + expect(temArquivoDeLicenca(['package.json', 'README.md', 'dist/index.js'])).toBe(false); + }); + + it('titular na forma antiga do README', () => { + expect(titularesDivergentes('README.md', 'MIT © [NFE.io](https://nfe.io)')).toHaveLength(1); + }); + }); +}); diff --git a/tests/unit/resources/inbound-product-invoices.test.ts b/tests/unit/resources/inbound-product-invoices.test.ts index bc5db87..014cfea 100644 --- a/tests/unit/resources/inbound-product-invoices.test.ts +++ b/tests/unit/resources/inbound-product-invoices.test.ts @@ -278,7 +278,7 @@ describe('InboundProductInvoicesResource', () => { expect(result).toEqual(mockProductMetadata); expect(mockHttpClient.get).toHaveBeenCalledWith( - `/v2/companies/${testCompanyId}/inbound/productinvoice/${validAccessKey}` + `/v2/companies/${testCompanyId}/inbound/productinvoices/${validAccessKey}` ); }); }); @@ -322,7 +322,7 @@ describe('InboundProductInvoicesResource', () => { await resource.getProductInvoiceEventDetails(testCompanyId, validAccessKey, testEventKey); expect(mockHttpClient.get).toHaveBeenCalledWith( - `/v2/companies/${testCompanyId}/inbound/productinvoice/${validAccessKey}/events/${testEventKey}` + `/v2/companies/${testCompanyId}/inbound/productinvoices/${validAccessKey}/events/${testEventKey}` ); }); }); @@ -406,7 +406,7 @@ describe('InboundProductInvoicesResource', () => { await resource.getJson(testCompanyId, validAccessKey); expect(mockHttpClient.get).toHaveBeenCalledWith( - `/v2/companies/${testCompanyId}/inbound/productinvoice/${validAccessKey}/json` + `/v2/companies/${testCompanyId}/inbound/productinvoices/${validAccessKey}/json` ); }); }); @@ -483,7 +483,7 @@ describe('InboundProductInvoicesResource', () => { await resource.reprocessWebhook(testCompanyId, validAccessKey); expect(mockHttpClient.post).toHaveBeenCalledWith( - `/v2/companies/${testCompanyId}/inbound/productinvoice/${validAccessKey}/processwebhook` + `/v2/companies/${testCompanyId}/inbound/productinvoices/${validAccessKey}/processwebhook` ); }); @@ -498,7 +498,7 @@ describe('InboundProductInvoicesResource', () => { await resource.reprocessWebhook(testCompanyId, '12345'); expect(mockHttpClient.post).toHaveBeenCalledWith( - `/v2/companies/${testCompanyId}/inbound/productinvoice/12345/processwebhook` + `/v2/companies/${testCompanyId}/inbound/productinvoices/12345/processwebhook` ); }); diff --git a/tests/unit/resources/municipal-taxes.test.ts b/tests/unit/resources/municipal-taxes.test.ts index e6eef43..3856322 100644 --- a/tests/unit/resources/municipal-taxes.test.ts +++ b/tests/unit/resources/municipal-taxes.test.ts @@ -33,26 +33,37 @@ describe('MunicipalTaxesResource', () => { expect(http.get).toHaveBeenCalledWith(base); }); - it('create POSTs wrapped as { municipalTax }', async () => { - http.post.mockResolvedValue({ data: { id }, status: 201, headers: {} }); + // The API wraps the entity: { "municipalTax": {…} }. GET by id measured on 2026-10-15; + // POST/PUT per the contribuintes-v2 spec (not probed: they would write). + it('create POSTs wrapped as { municipalTax } and returns the entity', async () => { + http.post.mockResolvedValue({ data: { municipalTax: { id } }, status: 201, headers: {} }); const data = { taxNumber: '123' } as unknown as CreateMunicipalTaxData; - await resource.create(companyId, data); + const result = await resource.create(companyId, data); expect(http.post).toHaveBeenCalledWith(base, { municipalTax: data }); + expect(result).toEqual({ id }); }); - it('retrieve/update/delete hit /{id}', async () => { - http.get.mockResolvedValue({ data: { id }, status: 200, headers: {} }); - http.put.mockResolvedValue({ data: { id }, status: 200, headers: {} }); + it('retrieve/update/delete hit /{id} and return the entity, not the envelope', async () => { + http.get.mockResolvedValue({ data: { municipalTax: { id } }, status: 200, headers: {} }); + http.put.mockResolvedValue({ data: { municipalTax: { id } }, status: 200, headers: {} }); http.delete.mockResolvedValue({ data: undefined, status: 204, headers: {} }); const upd = { issRate: 5 } as unknown as UpdateMunicipalTaxData; - await resource.retrieve(companyId, id); - await resource.update(companyId, id, upd); + const retrieved = await resource.retrieve(companyId, id); + const updated = await resource.update(companyId, id, upd); await resource.delete(companyId, id); expect(http.get).toHaveBeenCalledWith(`${base}/${id}`); expect(http.put).toHaveBeenCalledWith(`${base}/${id}`, { municipalTax: upd }); expect(http.delete).toHaveBeenCalledWith(`${base}/${id}`); + expect(retrieved.id).toBe(id); + expect(updated.id).toBe(id); + }); + + it('also accepts the bare entity (defensive unwrap)', async () => { + http.get.mockResolvedValue({ data: { id }, status: 200, headers: {} }); + const result = await resource.retrieve(companyId, id); + expect(result).toEqual({ id }); }); it('updatePrefecture uses PATCH on .../updateprefecture', async () => { diff --git a/tests/unit/resources/p5-p7-additions.test.ts b/tests/unit/resources/p5-p7-additions.test.ts index 3e77dc1..d553d25 100644 --- a/tests/unit/resources/p5-p7-additions.test.ts +++ b/tests/unit/resources/p5-p7-additions.test.ts @@ -94,6 +94,20 @@ describe('NotificationsResource', () => { expect(http.post).toHaveBeenCalledWith(`${base}/email`, { to: 'x@y.z' }); }); + it('retrieve returns the notification, not the { notification } envelope', async () => { + // Wire shape measured on 2026-10-15: 200 { "notification": {…} } + http.get.mockResolvedValue({ + status: 200, + headers: {}, + data: { notification: { id: 'n1', filters: ['service_invoice.issued'] } }, + }); + + const result = await res.retrieve(companyId, 'n1'); + + expect(result).toEqual({ id: 'n1', filters: ['service_invoice.issued'] }); + expect(result.id).toBe('n1'); + }); + it('validates inputs', async () => { await expect(res.retrieve(companyId, '')).rejects.toBeInstanceOf(ValidationError); await expect(res.list('')).rejects.toBeInstanceOf(ValidationError); diff --git a/tests/unit/resources/state-taxes.test.ts b/tests/unit/resources/state-taxes.test.ts index ee1bc51..011825d 100644 --- a/tests/unit/resources/state-taxes.test.ts +++ b/tests/unit/resources/state-taxes.test.ts @@ -111,8 +111,9 @@ describe('StateTaxesResource', () => { } as NfeStateTaxCreateData; it('should create a state tax registration', async () => { - const httpResponse: HttpResponse = { - data: mockStateTax, + // The API wraps the entity: { "stateTax": {…} } (spec contribuintes-v2; GET measured 2026-10-15) + const httpResponse: HttpResponse<{ stateTax: NfeStateTax }> = { + data: { stateTax: mockStateTax }, status: 201, headers: {}, }; @@ -138,8 +139,9 @@ describe('StateTaxesResource', () => { describe('retrieve', () => { it('should retrieve a state tax by ID', async () => { - const httpResponse: HttpResponse = { - data: mockStateTax, + // Wire shape measured 2026-10-15: 200 { "stateTax": {…} } + const httpResponse: HttpResponse<{ stateTax: NfeStateTax }> = { + data: { stateTax: mockStateTax }, status: 200, headers: {}, }; @@ -148,11 +150,20 @@ describe('StateTaxesResource', () => { const result = await resource.retrieve(companyId, stateTaxId); expect(result).toEqual(mockStateTax); + expect(result.id).toBe(stateTaxId); expect(mockHttpClient.get).toHaveBeenCalledWith( `/v2/companies/${companyId}/statetaxes/${stateTaxId}`, ); }); + it('should also accept the bare entity (defensive unwrap)', async () => { + mockHttpClient.get.mockResolvedValue({ data: mockStateTax, status: 200, headers: {} }); + + const result = await resource.retrieve(companyId, stateTaxId); + + expect(result).toEqual(mockStateTax); + }); + it('should throw ValidationError when stateTaxId is empty', async () => { await expect(resource.retrieve(companyId, '')).rejects.toThrow(ValidationError); }); @@ -174,8 +185,8 @@ describe('StateTaxesResource', () => { it('should update a state tax registration', async () => { const updatedTax = { ...mockStateTax, serie: 2, environmentType: 'test' as const }; - const httpResponse: HttpResponse = { - data: updatedTax, + const httpResponse: HttpResponse<{ stateTax: NfeStateTax }> = { + data: { stateTax: updatedTax }, status: 200, headers: {}, }; diff --git a/tests/unit/utils/envelope.test.ts b/tests/unit/utils/envelope.test.ts new file mode 100644 index 0000000..38ffcf4 --- /dev/null +++ b/tests/unit/utils/envelope.test.ts @@ -0,0 +1,45 @@ +/** + * O desembrulho de envelope é defensivo. + * + * A leitura por id de inscrição municipal, inscrição estadual e notificação + * responde `{ "": {…} }` (medido em 2026-10-15). O `POST` e o `PUT` das + * inscrições não foram sondados, então o helper precisa aceitar também a + * entidade direta — e não pode inventar desembrulho onde não há envelope. + */ + +import { describe, it, expect } from 'vitest'; +import { unwrapEnvelope } from '../../../src/core/utils/envelope.js'; + +describe('unwrapEnvelope', () => { + it('devolve o conteúdo da chave quando o corpo é o envelope', () => { + const body = { municipalTax: { id: 'mt-1', taxNumber: '123' } }; + + expect(unwrapEnvelope(body, 'municipalTax')).toEqual({ id: 'mt-1', taxNumber: '123' }); + }); + + it('devolve a entidade direta como veio', () => { + const body = { id: 'mt-1', taxNumber: '123' }; + + expect(unwrapEnvelope(body, 'municipalTax')).toBe(body); + }); + + it('devolve o corpo como veio quando a chave está ausente', () => { + const body = { hasMore: false, municipalTaxes: [] }; + + expect(unwrapEnvelope(body, 'municipalTax')).toBe(body); + }); + + it('não desembrulha quando o valor da chave não é objeto', () => { + for (const value of [null, 'texto', 42, [{ id: 'x' }]]) { + const body = { stateTax: value }; + + expect(unwrapEnvelope(body, 'stateTax')).toBe(body); + } + }); + + it('não quebra com corpo vazio ou não objeto', () => { + expect(unwrapEnvelope(undefined, 'notification')).toBeUndefined(); + expect(unwrapEnvelope(null, 'notification')).toBeNull(); + expect(unwrapEnvelope('texto', 'notification')).toBe('texto'); + }); +}); diff --git a/tests/unit/version.test.ts b/tests/unit/version.test.ts index 4e5eb6d..3b9bc25 100644 --- a/tests/unit/version.test.ts +++ b/tests/unit/version.test.ts @@ -19,7 +19,8 @@ */ import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join, sep } from 'node:path'; import { PACKAGE_NAME, PACKAGE_VERSION, VERSION } from '../../src/index.js'; const pkg = JSON.parse(readFileSync('package.json', 'utf8')) as { @@ -41,6 +42,30 @@ describe('identidade do pacote', () => { expect(VERSION).toBe(PACKAGE_VERSION); }); + it('o package-lock.json acompanha o package.json', () => { + // A 6.0.0 subiu só o package.json, e o lockfile ficou em 5.2.0 até a 6.0.1. + const lock = JSON.parse(readFileSync('package-lock.json', 'utf8')) as { + name: string; + version: string; + packages: Record; + }; + expect(lock.name).toBe(pkg.name); + expect(lock.version).toBe(pkg.version); + expect(lock.packages['']?.version).toBe(pkg.version); + }); + + it('nenhuma tag @version fixa a versão num comentário de src/', () => { + // A varredura de literais abaixo ignora comentários, porque as notas históricas + // citam versões antigas. Uma tag `@version` não é nota: declara a versão, e + // apodrece — o cabeçalho de src/index.ts disse `@version 5.1.0` até a 6.0.1. + // src/generated/ fica fora: é gerado da spec, e uma versão ali seria a da API. + const arquivos = (readdirSync('src', { recursive: true, encoding: 'utf8' }) as string[]) + .filter(a => a.endsWith('.ts') && a.split(sep)[0] !== 'generated') + .map(a => join('src', a)); + const comTag = arquivos.filter(a => /@version\s+\d+\.\d+\.\d+/.test(readFileSync(a, 'utf8'))); + expect(comTag).toEqual([]); + }); + it('nenhum literal de versão sobrou em src/', () => { // Varredura direta: o que quebrou antes foi exatamente isto — alguém fixar o // valor em vez de derivar. `src/version.ts` é o único lugar onde ele aparece. diff --git a/tsdown.config.ts b/tsdown.config.ts index ff60a9a..5700860 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -12,7 +12,10 @@ export default defineConfig({ // Keep the same output names as v3 (tsup): index.js/index.d.ts (ESM) + index.cjs (CJS) outExtensions: ({ format }) => format === 'cjs' ? { js: '.cjs', dts: '.d.cts' } : { js: '.js', dts: '.d.ts' }, - banner: '// NFE.io SDK - https://nfe.io', + // `/*!` marks a legal comment: esbuild and terser keep it when the consumer re-bundles + // or minifies, so the license notice travels with the code (MIT requires it). + banner: + '/*! nfe-io | SPDX-License-Identifier: MIT | Copyright (c) 2026 NFE.IO HUB SERVICOS DE TECNOLOGIA LTDA | https://nfe.io */', onSuccess: async () => { console.log('✅ Build completed successfully'); },