From 5b949c11b447137f5172c678a96a78d2e8085f25 Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Fri, 16 Oct 2026 15:58:15 -0300 Subject: [PATCH 01/13] fix(inbound): rotas de distribuicao no plural, a forma que a plataforma serve getProductInvoiceDetails, getProductInvoiceEventDetails, getJson e reprocessWebhook montavam /inbound/productinvoice/ (singular). A plataforma serve so o plural, /inbound/productinvoices/. Medido em 2026-10-14: singular, com ou sem credencial 404 de corpo vazio, igual a um path inventado plural, sem credencial 401 plural, com credencial 200 (detalhes e JSON) A spec local consulta-nfe-distribuicao-v1.yaml (safra b487823) ainda diz singular; o upstream corrigiu em nfe/docs@51d8816. Os cinco testes que afirmavam o path errado agora afirmam o plural, e falham com o codigo antigo. O bug era latente: em 30 dias de gateway o singular teve 9 respostas 404, nenhuma com User-Agent do SDK. Evidencia em tests/fixtures/live-contracts/inbound-distribution-routes.json, com corpo sintetico. --- .../resources/inbound-product-invoices.ts | 12 ++++-- .../inbound-distribution-routes.json | 42 +++++++++++++++++++ .../inbound-product-invoices.test.ts | 10 ++--- 3 files changed, 55 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/live-contracts/inbound-distribution-routes.json 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/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/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` ); }); From c3c23b0bee498f33b2f556c412ff9b84bca9f97d Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Fri, 16 Oct 2026 15:58:15 -0300 Subject: [PATCH 02/13] fix(resources): municipalTaxes, stateTaxes e notifications devolvem a 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 compilacao. Medido no retrieve dos tres em 2026-10-15; em stateTaxes, 11 de 12 inscricoes da conta do time responderam com o envelope (a outra respondeu 503, problema daquele registro). src/core/utils/envelope.ts devolve o conteudo da chave quando o corpo a traz como objeto, e o corpo como veio nos demais casos. Passam a usa-lo o create, o retrieve e o update de municipalTaxes e stateTaxes, e o notifications.retrieve. O create e o update nao foram sondados, porque exigiriam escrever num registro real: seguem a spec contribuintes-v2, que declara o mesmo envelope, e o desembrulho defensivo deixa passar a entidade direta se a resposta vier assim. O helper fica nos recursos, nao na camada HTTP: cada recurso sabe a chave do proprio envelope, e um desembrulho generico teria de adivinha-la e mudaria o retorno de metodos que ja desembrulham certo (companies, webhooks, legalPeople). Os mocks dos testes passam a ter o formato do fio, e cada recurso ganha um caso de entidade direta. Com o codigo antigo, 6 testes falham. Evidencia em tests/fixtures/live-contracts/response-envelopes.json, que registra tambem tres casos em que o SDK ja estava certo e a spec erra. --- src/core/resources/municipal-taxes.ts | 20 ++++++--- src/core/resources/notifications.ts | 12 +++-- src/core/resources/state-taxes.ts | 16 ++++--- src/core/utils/envelope.ts | 34 ++++++++++++++ .../live-contracts/response-envelopes.json | 38 ++++++++++++++++ tests/unit/resources/municipal-taxes.test.ts | 27 +++++++---- tests/unit/resources/p5-p7-additions.test.ts | 14 ++++++ tests/unit/resources/state-taxes.test.ts | 23 +++++++--- tests/unit/utils/envelope.test.ts | 45 +++++++++++++++++++ 9 files changed, 199 insertions(+), 30 deletions(-) create mode 100644 src/core/utils/envelope.ts create mode 100644 tests/fixtures/live-contracts/response-envelopes.json create mode 100644 tests/unit/utils/envelope.test.ts diff --git a/src/core/resources/municipal-taxes.ts b/src/core/resources/municipal-taxes.ts index 69fce9e..d11f58d 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,20 +47,25 @@ 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. */ @@ -70,11 +76,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/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/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/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'); + }); +}); From b4e227cb913e0c726df396db7f6d3618226ca8fd Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Fri, 16 Oct 2026 15:58:15 -0300 Subject: [PATCH 03/13] docs(municipal-taxes): update e PUT de substituicao; rotas nao servidas Pela documentacao da plataforma (nfe/docs@b9e7953), o update de inscricao municipal e um PUT que substitui o registro: omitir taxNumber apaga o numero da inscricao, e omitir environment volta para Development. rpsNumber e lastRpsSent sao a excecao: omitidos, preservam o contador de RPS; enviados, sobrescrevem o contador, inclusive para tras. O JSDoc dizia so "Update a municipal tax registration", e a pagina do recurso, "Atualiza". O JSDoc, docs/recursos/municipal-taxes.md, a referencia da skill e o exemplo passam a mostrar o roteiro: ler com retrieve, alterar e reenviar sem os dois campos do contador. O exemplo do JSDoc foi checado em modo estrito com exactOptionalPropertyTypes. O SDK nao sondou isso, porque exigiria escrever sobre uma inscricao real. Na mesma pagina, o exemplo de create nao compilava (usava `code` no primeiro nivel, que nao e campo da inscricao) e chamava updatePrefecture. Ele e getSeries estao @deprecated desde a 6.0.0, porque a plataforma nao serve as rotas, e o exemplo de examples/ chamava getSeries de fato. Agora os dois aparecem como descontinuados, e os exemplos usam os campos reais (city, taxNumber, rpsSerialNumber, environment). --- docs/recursos/municipal-taxes.md | 57 ++++++++++++++++--- examples/README.md | 2 +- examples/municipal-taxes.js | 25 ++++---- .../rtc-nfce-and-account-resources.md | 27 ++++++++- src/core/resources/municipal-taxes.ts | 22 ++++++- 5 files changed, 109 insertions(+), 24 deletions(-) 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/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/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..313a4a2 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 @@ -56,10 +56,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 ``` --- diff --git a/src/core/resources/municipal-taxes.ts b/src/core/resources/municipal-taxes.ts index d11f58d..e647fce 100644 --- a/src/core/resources/municipal-taxes.ts +++ b/src/core/resources/municipal-taxes.ts @@ -68,7 +68,27 @@ export class MunicipalTaxesResource { 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, From 29f737622f54cc3cde72170f68ca7c9a98f8a9ad Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Fri, 16 Oct 2026 15:58:25 -0300 Subject: [PATCH 04/13] docs: consulta de cupom CFe-SAT e webhook por empresa deixam de ser ensinados consumerInvoiceQuery.retrieve e downloadXml estao @deprecated desde a 6.0.0: a plataforma nao serve /v1/consumerinvoices/coupon/{chave}, que responde 404 igual a um path inventado, inclusive sem credencial. Mesmo assim o README, docs/API.md, docs/recursos/consumer-invoice-query.md, a referencia da skill e o exemplo ensinavam os dois como caminho de uso. Agora aparecem como descontinuados, e o exemplo so demonstra o erro. O fluxo completo do README criava webhook por empresa (webhooks.create(empresaId, ...)), descontinuado desde a v5.1 porque a rota responde 404 (confirmado em tres contas em 2026-07). Passa ao webhook de conta (createAccountWebhook), com validacao de assinatura sobre o corpo cru. A secao v2 -> v3 do MIGRATION ganha o mesmo aviso no topo do bloco de webhooks. --- MIGRATION.md | 5 ++ README.md | 68 ++++++---------- docs/API.md | 55 +++---------- docs/recursos/consumer-invoice-query.md | 28 ++++--- examples/consumer-invoice-query.js | 81 +++---------------- .../references/data-services-and-lookups.md | 13 ++- 6 files changed, 77 insertions(+), 173 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index e6e1ea3..edf2878 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -593,6 +593,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..926fb00 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 diff --git a/docs/API.md b/docs/API.md index a666d7c..23cebb5 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). --- 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/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/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). --- From fa2c027b55edc3d6cc9d087ab44bd7ec1f15947c Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Fri, 16 Oct 2026 15:58:25 -0300 Subject: [PATCH 05/13] test(docs): docs-drift falha quando um doc ensina metodo sem caminho de sucesso A verificacao existente casa nome de metodo. getSeries existe no codigo, entao uma pagina que o ensinava como funcional passava, e foi assim que os metodos de rota nao servida continuaram ensinados um mes depois de a v6 os depreciar. A nova verificacao procura, em README, MIGRATION, docs/**, skills/** e examples/**, cada citacao .( de um metodo sem caminho de sucesso, e exige aviso no mesmo bloco (paragrafo, linha de tabela, bloco de codigo; em examples/, os comentarios logo acima) ou no titulo da secao. O CHANGELOG fica de fora, por ser historico. O conjunto sai do codigo, nao de uma lista mantida a mao: entram os metodos com @deprecated no JSDoc, inclusive o @deprecated de classe (consumerInvoiceQuery), menos as depreciacoes funcionais declaradas com motivo (companies.list, webhooks.getAvailableEvents), que funcionam. Um teste de guarda confere que o conjunto inclui os metodos de rota nao servida, para a verificacao nao passar por estar vazia. --- tests/unit/docs-drift.test.ts | 157 ++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/tests/unit/docs-drift.test.ts b/tests/unit/docs-drift.test.ts index 0367be0..33142f2 100644 --- a/tests/unit/docs-drift.test.ts +++ b/tests/unit/docs-drift.test.ts @@ -97,3 +97,160 @@ 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([]); + }); +}); From 8c54f7005a188ed9a53bbdc1c66ef79a06e2ccc8 Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Fri, 16 Oct 2026 15:58:44 -0300 Subject: [PATCH 06/13] docs: links quebrados da #47 e verificacao de links relativos Tres links apontavam para arquivos que nao existem: README.md docs/MIGRATION-TO-GENERATED-TYPES.md -> MIGRATION.md CHANGELOG.md RELEASE_NOTES_v3.md (entrada 3.0) -> secao v2 -> v3 do MIGRATION.md docs/API.md ../LICENSE -> URL do repositorio docs/ e espelhado sozinho no portal de documentacao, entao link que sai dele quebra la, mesmo que o arquivo exista aqui. Os outros links de docs/API.md que saiam de docs/ (../src/core/types.ts cinco vezes, ../CONTRIBUTING.md) e o de docs/EXTENDING.md passam a URLs absolutas do repositorio. docs-drift.test.ts ganha a verificacao: em README, MIGRATION, CHANGELOG, docs/** e skills/**, todo link relativo precisa resolver para um arquivo que existe, e em docs/** nao pode sair de docs/. Ela falhava nos tres links antes da correcao. Um teste de guarda confere que ela pega link inexistente e link que sai de docs/. Refs #47 --- CHANGELOG.md | 2 +- README.md | 2 +- docs/API.md | 14 ++++---- docs/EXTENDING.md | 2 +- tests/unit/docs-drift.test.ts | 63 ++++++++++++++++++++++++++++++++++- 5 files changed, 72 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fb6d16..5dc26e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1168,7 +1168,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 diff --git a/README.md b/README.md index 926fb00..f387b1d 100644 --- a/README.md +++ b/README.md @@ -1081,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 diff --git a/docs/API.md b/docs/API.md index 23cebb5..a10c86a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2241,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. --- @@ -2302,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. --- @@ -2384,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). --- @@ -2450,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. --- @@ -2705,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. --- @@ -3014,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 @@ -3055,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/tests/unit/docs-drift.test.ts b/tests/unit/docs-drift.test.ts index 33142f2..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 { @@ -254,3 +254,64 @@ describe('métodos sem caminho de sucesso × documentação', () => { ).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); + }); +}); From 31bd3bd4173a9d1f9b2df297f959004217f93112 Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Fri, 16 Oct 2026 15:58:56 -0300 Subject: [PATCH 07/13] docs(keys): a regra de chaves e por rota, nao por host A 6.0.0 documentou que as duas chaves "respondem 403 no territorio da outra", generalizando uma sonda de 2026-09-01 que so testou rotas de documento fiscal em api.nfse.io. A matriz refeita em 2026-10-14, sobre 16 rotas, mostra outra coisa: principal dados documento fiscal (tax-codes, NF-e, aceita 403 NFC-e, distribuicao, NFS-e v1) cadastro da conta, na leitura aceita aceita (empresas, inscricoes, certificados, webhooks) hosts de consulta (CNPJ, CEP, NF-e 403 aceita por chave de acesso) O calculo de tributos, medido em 2026-10-16, se comporta como documento fiscal. A escrita com a chave de dados nao foi testada. O SDK nao tinha bug: ele envia a principal a todo recurso de api.nfe.io e api.nfse.io. A aceitacao da chave de dados no cadastro nao e contrato documentado, e o SDK nao depende dela. docs/multi-host-routing.md, a secao v5 -> v6 do MIGRATION, a skill, o JSDoc de NfeConfig.dataApiKey e o comentario do cliente fiscal passam a descrever a regra por rota. Os getters de cadastro (companies, stateTaxes, municipalTaxes, certificates) dizem so que usam a chave principal; os de documento fiscal mantem a frase do 403, que e verdadeira para eles. O README ja estava correto, e a entrada 6.0.0 do CHANGELOG nao e reescrita: a correcao entra na 6.0.1 como correcao de registro. Evidencia em tests/fixtures/live-contracts/api-key-route-matrix.json. O fixture de 2026-09-01 fica como registro historico, com supersededBy apontando para o novo. --- MIGRATION.md | 9 +++-- docs/multi-host-routing.md | 36 ++++++++++++------- skills/nfeio-node-sdk/SKILL.md | 6 +++- src/core/client.ts | 23 +++++++----- src/core/types.ts | 12 ++++--- .../live-contracts/api-key-host-matrix.json | 3 ++ .../live-contracts/api-key-route-matrix.json | 27 ++++++++++++++ tests/unit/client-api-key-wiring.test.ts | 8 +++-- 8 files changed, 92 insertions(+), 32 deletions(-) create mode 100644 tests/fixtures/live-contracts/api-key-route-matrix.json diff --git a/MIGRATION.md b/MIGRATION.md index edf2878..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 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/skills/nfeio-node-sdk/SKILL.md b/skills/nfeio-node-sdk/SKILL.md index c536d42..83297ae 100644 --- a/skills/nfeio-node-sdk/SKILL.md +++ b/skills/nfeio-node-sdk/SKILL.md @@ -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 diff --git a/src/core/client.ts b/src/core/client.ts index a61976d..f02b0fb 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -224,6 +224,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 +689,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 +734,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 +758,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 +912,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/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/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/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 From 7ceff1fa42ee72afed39786cd6f634105a4a150d Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Fri, 16 Oct 2026 15:58:56 -0300 Subject: [PATCH 08/13] chore(license): arquivo LICENSE e um so titular onde a licenca e citada O repositorio nunca teve arquivo de licenca: o MIT existia so no campo license do package.json, e a API do GitHub reportava o repositorio como sem licenca. Para varredura de dependencias e revisao juridica, licenca so em metadado e licenca nao confirmada. Decisao de 2026-10-16: MIT, com a linha "Copyright (c) 2026 NFE.IO HUB SERVICOS DE TECNOLOGIA LTDA", e contribuicoes sob a mesma licenca, sem CLA. - LICENSE com o texto MIT identico ao modelo do choosealicense, que e o que o GitHub usa para detectar; - README, cabecalhos de src/index.ts e src/core/client.ts e CONTRIBUTING citam o mesmo SPDX e o mesmo titular. O README diz que a licenca cobre o SDK e que o uso da API segue o contrato da conta; - o banner do bundle passa a /*! ... */ com o SPDX e o titular. Os minificadores preservam esse formato quando a aplicacao re-empacota o SDK, e a MIT exige que o aviso acompanhe o codigo; - tests/unit/license.test.ts compara o SPDX declarado com o texto inteiro do LICENSE, confere o arquivo no tarball (npm pack --dry-run, sem scripts) e procura titular escrito de outro jeito. Verificado por mutacao: falha com o arquivo removido e com o SPDX trocado. Refs #47 --- CONTRIBUTING.md | 2 +- LICENSE | 21 +++++ README.md | 6 +- src/core/client.ts | 1 + src/index.ts | 1 + tests/unit/license.test.ts | 156 +++++++++++++++++++++++++++++++++++++ tsdown.config.ts | 5 +- 7 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 LICENSE create mode 100644 tests/unit/license.test.ts 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/README.md b/README.md index f387b1d..91ae9f8 100644 --- a/README.md +++ b/README.md @@ -1101,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/src/core/client.ts b/src/core/client.ts index f02b0fb..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 { diff --git a/src/index.ts b/src/index.ts index ae75509..7425cba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,7 @@ * @version 5.1.0 * @author NFE.io * @license MIT + * @copyright 2026 NFE.IO HUB SERVICOS DE TECNOLOGIA LTDA */ // ============================================================================ 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/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'); }, From ecd0805d7f30fbb0299b74e2243956187068da46 Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Fri, 16 Oct 2026 15:58:56 -0300 Subject: [PATCH 09/13] chore(release): v6.0.1 Versao 6.0.1 no package.json e src/version.ts regenerado. Entrada 6.0.1 no CHANGELOG: rotas de distribuicao e envelopes (corrigido); PUT da inscricao municipal, metodos sem caminho de sucesso, links e licenca (documentacao); e a regra de chaves por rota (correcao de registro). A entrada 6.0.0 nao muda. --- CHANGELOG.md | 109 +++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- src/version.ts | 2 +- 3 files changed, 111 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dc26e2..2cf9cab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,115 @@ 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. + +### 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. + ## [6.0.0] - 2026-09-03 > **Major de correção de contrato.** Nada aqui é funcionalidade nova: são bugs provados por 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/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'; From b94e5360e3a36f5cc4794e6767682c07a25f6dd4 Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Fri, 16 Oct 2026 19:42:21 -0300 Subject: [PATCH 10/13] chore(version): package-lock e cabecalho deixam de divergir da versao Dois lugares ainda carregavam versao antiga: package-lock.json 5.2.0 na raiz e em packages[""]: a 6.0.0 subiu so o package.json src/index.ts @version 5.1.0 no cabecalho do modulo O lockfile foi editado nos dois campos, sem npm install, para nao re-resolver dependencias. A tag @version saiu em vez de ser atualizada: a versao vive so em src/version.ts, e um numero num comentario apodrece de novo. Ela nao chegava ao .d.ts publicado. version.test.ts ganha duas guardas: o lockfile precisa ter o nome e a versao do package.json, e nenhum arquivo de src/ (fora src/generated/) pode ter tag @version X.Y.Z. A varredura de literais continua ignorando comentarios, que citam versoes antigas como historico; uma tag nao e historico, declara a versao. Verificado por mutacao: as duas falham com o lockfile em 5.2.0 e com uma tag @version num comentario. --- CHANGELOG.md | 9 +++++++++ package-lock.json | 4 ++-- src/index.ts | 1 - tests/unit/version.test.ts | 27 ++++++++++++++++++++++++++- 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cf9cab..f2092df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,6 +116,15 @@ Evidência em `api-key-route-matrix.json`. O fixture de 2026-09-01 continua como 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. + ## [6.0.0] - 2026-09-03 > **Major de correção de contrato.** Nada aqui é funcionalidade nova: são bugs provados por 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/src/index.ts b/src/index.ts index 7425cba..c11b91a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,7 +32,6 @@ * ``` * * @module nfe-io - * @version 5.1.0 * @author NFE.io * @license MIT * @copyright 2026 NFE.IO HUB SERVICOS DE TECNOLOGIA LTDA 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. From cdec7428263798bf61ecfd212be961b0279330ce Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Fri, 16 Oct 2026 19:43:32 -0300 Subject: [PATCH 11/13] test(integration): escrita na conta so com opt-in explicito companies.integration.test.ts criava e apagava empresas na conta da chave configurada, que costuma ser compartilhada por varias pessoas. E nao rodava so no test:integration: fora da CI, o setup carrega o .env e o npm test executa a integracao sempre que ha chave. A limpeza tambem nao limpava. O companies.remove responde com sucesso (o teste de delete, que o chama sem try/catch, passa), mas a empresa continua na conta com status Inactive, em v1 e v2, horas depois. Cada rodada deixava sete empresas. Medido em 2026-10-16: ha 42 com o nome gerado pelo teste, todas inativas, 14 delas de duas rodadas locais desta release. O comentario que atribuia a sobra a consistencia eventual saiu. Os seis testes que criam, alteram ou apagam empresa (create, retrieve, list, update, delete e CNPJ duplicado) passam a exigir NFE_ALLOW_ACCOUNT_WRITES=true, declarado em setup.ts com o motivo. Continuam rodando por padrao o 404 de empresa inexistente e a recusa de corpo invalido, que nao gravam nada. O README de integracao documenta a variavel e corrige a secao de limpeza. Verificado ao vivo sem a variavel: 2 passam, os 6 de escrita e o de certificado pulam. Com a variavel nao foi exercido, porque escreveria na conta compartilhada. --- CHANGELOG.md | 6 +++++ tests/integration/README.md | 23 ++++++++++++++----- .../integration/companies.integration.test.ts | 23 +++++++++++-------- tests/integration/setup.ts | 15 ++++++++++++ 4 files changed, 52 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2092df..e33f203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -124,6 +124,12 @@ reescrita. 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 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 From 8650e6e3f6513037e506ba90e71d7432b17703d0 Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Fri, 16 Oct 2026 20:52:47 -0300 Subject: [PATCH 12/13] docs(skill): assinaturas conferidas contra o codigo da 6.0.1 A skill publicada, que vai no pacote npm, ensinava assinaturas de antes da 6.0.0. Comparada ao codigo, ela tinha 32 metodos com parametro, retorno ou nome errado: productInvoices 9 downloads devolvem NfeFileResource ({ uri }), nao Buffer; sendCorrectionLetter recebe reason: string; disable(companyId, invoiceId, reason?); disableRange devolve NfeDisablementResource; create devolve NfeProductInvoiceIssueData inboundProductInvoices 9 XML e PDF devolvem InboundFileResource; os metodos de evento exigem eventKey; getJson nao tem format; manifest e reprocessWebhook tem outro retorno consumerInvoices 7 leituras sem environment; downloads devolvem ConsumerInvoiceFileResource; cancel aceita reason e devolve ConsumerInvoiceCancellationResponse serviceInvoices 4 cancel devolve CancelInvoiceResponse; downloads exigem invoiceId (nao ha ZIP em lote); getStatus citava um tipo inexistente transportationInvoices 3 retrieveEvent nao existe (o metodo e getEvent, com eventKey); downloads devolvem InboundFileResource Tambem estavam errados: o CRUD de webhook por empresa aparecia como funcional numa tabela, que a guarda de drift nao le; o pitfall 7 dizia que o calculo de tributos usa a chave de dados (usa a principal, e a de dados toma 403); NfeStateTax nao batia com o tipo; a descricao contava 16 recursos (sao 22); e sete linhas tinham perdido os acentos. Entram as notas da 6.0.1 que faltavam: os quatro metodos de distribuicao so alcancam a API a partir dela, e stateTaxes e notifications devolvem a entidade. Verificado: a comparacao mecanica das assinaturas fica so com diferencas de nome de parametro; um arquivo com os usos que a skill ensina compila em modo estrito, e tres usos da versao anterior nao compilam. --- CHANGELOG.md | 17 ++ skills/nfeio-node-sdk/SKILL.md | 21 ++- .../references/product-invoices-and-taxes.md | 161 +++++++++++------- .../rtc-nfce-and-account-resources.md | 23 +-- .../service-invoices-and-polling.md | 67 ++++---- 5 files changed, 175 insertions(+), 114 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e33f203..d7ad9d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,23 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR 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 diff --git a/skills/nfeio-node-sdk/SKILL.md b/skills/nfeio-node-sdk/SKILL.md index 83297ae..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 @@ -265,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 @@ -368,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`). @@ -415,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/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 313a4a2..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). --- @@ -91,7 +92,7 @@ getSeries(companyId, municipalTaxId, serie) // @deprecated — route not 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). @@ -102,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 From 7aebfacd31a55e2a5ca89b38c31c4d93ff90f7a2 Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Fri, 16 Oct 2026 21:37:05 -0300 Subject: [PATCH 13/13] docs(changelog): links de comparacao de todas as versoes, pelas tags reais O rodape do CHANGELOG parou na 5.2.0: [Unreleased] ainda comparava a partir dela, e oito versoes nao tinham link (6.0.1, 6.0.0, 4.0.0, 3.2.1, 3.2.0, 3.1.0, 3.0.2, 3.0.1). A referencia [Unreleased] tambem nao casava com o titulo da secao, que e "Nao lancado". Dois links existentes estavam quebrados: [3.0.0] apontava para a tag v3.0.0, mas no remoto ela se chama 3.0.0, sem o v; e [2.0.0] apontava para uma tag v2.0.0 que nao existe no repositorio. Esta passa a apontar para a versao no npm, onde ela esta publicada. Verificado: os links do GitHub respondem 200; os dois da 6.0.1 so existem depois da release. A 2.0.0 confere no registro do npm. --- CHANGELOG.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7ad9d4..be065b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1356,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