From 94b4d3a5ddce6d36c8560860f3a8fb292bb1a1a9 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:01:00 -0300 Subject: [PATCH 1/8] feat(nfse-key): add isValidNfseKey, parseNfseKey and getNfseKeyInfo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The national NFS-e (Sistema Nacional NFS-e) identifies a service invoice by a 50 digit access key, which the package did not cover next to the 44 digit DF-e key of isValidNfeKey. The layout comes from the official schema package and ANEXO I: Cód.Mun.(7), Amb.Ger.(1), Tipo de Inscrição Federal(1), Inscrição Federal(14), nNFSe(13), AAMM(4), Cód.Num.(9) and DV(1), optionally behind the NFS literal of the XML Id attribute. The check digit is the modulus 11 the official manual names, with the weights and remainder rule of the DF-e key, confirmed against more than a hundred keys found in public repositories. The CPF or CNPJ of the issuer is checked too, as rules E1280 and E1284 do. There is no formatNfseKey: the DANFSe prints the key as a single block of 50 digits (NT SE/CGNFS-e 008), so there is no official mask to produce. Keys with an alphanumeric CNPJ are rejected for now, since no official document states how a letter enters the check digit of the key. --- docs/pt-br/utilities.md | 67 ++++ docs/utilities.md | 67 ++++ jsr.json | 3 + reports/api/brazilian-utils.api.md | 29 ++ src/get-nfse-key-info/constants.ts | 53 ++++ .../get-nfse-key-info.test.ts | 286 ++++++++++++++++++ src/get-nfse-key-info/get-nfse-key-info.ts | 165 ++++++++++ src/index.test.ts | 9 + src/index.ts | 8 + .../is-valid-nfse-key.test.ts | 110 +++++++ src/is-valid-nfse-key/is-valid-nfse-key.ts | 45 +++ src/parse-nfse-key/constants.ts | 2 + src/parse-nfse-key/parse-nfse-key.test.ts | 71 +++++ src/parse-nfse-key/parse-nfse-key.ts | 36 +++ 14 files changed, 951 insertions(+) create mode 100644 src/get-nfse-key-info/constants.ts create mode 100644 src/get-nfse-key-info/get-nfse-key-info.test.ts create mode 100644 src/get-nfse-key-info/get-nfse-key-info.ts create mode 100644 src/is-valid-nfse-key/is-valid-nfse-key.test.ts create mode 100644 src/is-valid-nfse-key/is-valid-nfse-key.ts create mode 100644 src/parse-nfse-key/constants.ts create mode 100644 src/parse-nfse-key/parse-nfse-key.test.ts create mode 100644 src/parse-nfse-key/parse-nfse-key.ts diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index 4c3cb3d7d..cf3cbdccb 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -634,6 +634,73 @@ getNfeKeyInfo('35170458716523000119620010000000121000123450'); getNfeKeyInfo('invalid'); // null ``` +## Chave de NFS-e + +### isValidNfseKey + +Verifica se a chave de acesso de uma NFS-e nacional, a Nota Fiscal de Serviço eletrônica do Sistema Nacional NFS-e, é válida. + +- A chave é um bloco único de 50 dígitos, `Cód.Mun.(7) Amb.Ger.(1) Tipo de Inscrição Federal(1) Inscrição Federal(14) nNFSe(13) AAMM(4) Cód.Num.(9) DV(1)`. +- O literal `NFS` que o atributo `Id` de `infNFSe` coloca antes da chave é retirado, junto com os espaços nas extremidades. +- A chave não tem máscara impressa, já que o DANFSe a imprime em um único bloco de 50 dígitos, então, diferente do `isValidNfeKey`, um separador em qualquer ponto dela é rejeitado em vez de removido. +- O código do município precisa começar com um código IBGE de UF; ele não é consultado na tabela do IBGE. +- O `ambGer` precisa ser `1` (o sistema do município) ou `2` (o Sistema Nacional NFS-e), e o tipo de inscrição `1` (um CPF, preenchido com `000` à esquerda) ou `2` (um CNPJ), com um CPF ou CNPJ cujos próprios dígitos verificadores sejam válidos. +- O `nNFSe` não pode ser todo de zeros e o mês precisa estar entre 01 e 12. +- O dígito verificador é um módulo 11 sobre os 49 primeiros dígitos, pesos de 2 a 9 ciclando a partir da direita, em que resto 0 ou 1 dá 0. +- Chaves com CNPJ alfanumérico ainda não são aceitas: nenhum documento oficial diz como uma letra entra no dígito verificador da chave. +- Os modelos municipais de NFS-e que não são o padrão nacional estão fora do escopo. + +```javascript +import { isValidNfseKey } from '@brazilian-utils/brazilian-utils'; + +isValidNfseKey('35503082258716523000119000000000001226011357924683'); // true (emitente com CNPJ, SP) +isValidNfseKey('NFS35503082258716523000119000000000001226011357924683'); // true (prefixo Id do XML) +isValidNfseKey('43149021100040364478829000000000105725120484407255'); // true (emitente com CPF, RS) +isValidNfseKey('35503082258716523000119000000000001226011357924684'); // false (dígito verificador) +isValidNfseKey('3550308 2 2 58716523000119 0000000000012 2601 135792468 3'); // false (a chave não tem máscara) +``` + +### parseNfseKey + +Remove tudo o que não é dígito da chave de acesso de uma NFS-e nacional, inclusive o prefixo `NFS` do atributo `Id` do XML, e limita o resultado a 50 dígitos. + +- Essa é a forma em que o leiaute guarda a chave e a que o DANFSe imprime, um bloco único, e por isso não existe `formatNfseKey`. + +```javascript +import { parseNfseKey } from '@brazilian-utils/brazilian-utils'; + +parseNfseKey('NFS35503082258716523000119000000000001226011357924683'); +// '35503082258716523000119000000000001226011357924683' + +parseNfseKey('3550308 2 2 58716523000119 0000000000012 2601 135792468 3'); +// '35503082258716523000119000000000001226011357924683' +``` + +### getNfseKeyInfo + +Interpreta a chave de acesso de uma NFS-e nacional e retorna seus campos, como um `NfseKeyInfo`. Aceita as mesmas formas de entrada do `isValidNfseKey`. + +- Retorna `municipalityCode`, `stateCode`, `generatorEnvironment`, `taxIdType`, `taxId`, `number`, `year`, `month`, `code` e `checkDigit`. +- O `generatorEnvironment` é um `NfseKeyGeneratorEnvironment`: `1` o sistema do município, `2` o Sistema Nacional NFS-e. +- O `taxIdType` é um `NfseKeyTaxIdType`, `'cpf'` ou `'cnpj'`, e o `taxId` é o CPF de 11 dígitos, sem o `000` que o preenche na chave, ou o CNPJ de 14 dígitos. +- Retorna `null` quando a chave não é válida. + +```javascript +import { getNfseKeyInfo } from '@brazilian-utils/brazilian-utils'; + +getNfseKeyInfo('35503082258716523000119000000000001226011357924683'); +// { municipalityCode: '3550308', stateCode: 'SP', generatorEnvironment: 2, taxIdType: 'cnpj', +// taxId: '58716523000119', number: 12, year: 2026, month: 1, code: '135792468', checkDigit: 3 } + +getNfseKeyInfo('43149021100040364478829000000000105725120484407255'); +// { municipalityCode: '4314902', stateCode: 'RS', generatorEnvironment: 1, taxIdType: 'cpf', +// taxId: '40364478829', number: 1057, year: 2025, month: 12, code: '048440725', checkDigit: 5 } + +getNfseKeyInfo('invalid'); // null +``` + +Fonte: a [documentação técnica do Sistema Nacional NFS-e](https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual), cujos tipos de esquema `TSIdNFSe` e `TSChaveNFSe` e o campo `NFSe/infNFSe/id` do ANEXO I definem o leiaute e as regras E1280 e E1284, o [manual da emissão por decisão administrativa ou judicial](https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual/manual-contribuintes-emissor-publico-api-emissao-decisao-administrativa-e-judicial.pdf), que nomeia o dígito verificador de módulo 11, e a [Nota Técnica SE/CGNFS-e 008](https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/rtc/nt-008-se-cgnfse-danfse-20260714-v1-02.pdf) (item 2.1.1), que imprime a chave em bloco único. + ## SUFRAMA ### isValidSuframa diff --git a/docs/utilities.md b/docs/utilities.md index 842e477c0..1d2bb1b2e 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -634,6 +634,73 @@ getNfeKeyInfo('35170458716523000119620010000000121000123450'); getNfeKeyInfo('invalid'); // null ``` +## NFS-e key + +### isValidNfseKey + +Check if the access key (chave de acesso) of a national NFS-e, the Nota Fiscal de Serviço eletrônica of the Sistema Nacional NFS-e, is valid. + +- The key is one block of 50 digits, `Cód.Mun.(7) Amb.Ger.(1) Tipo de Inscrição Federal(1) Inscrição Federal(14) nNFSe(13) AAMM(4) Cód.Num.(9) DV(1)`. +- The `NFS` literal the `Id` attribute of `infNFSe` puts in front of the key is stripped, with surrounding whitespace. +- The key has no printed mask, since the DANFSe prints it as a single block of 50 digits, so, unlike `isValidNfeKey`, a separator anywhere in it is rejected instead of being stripped. +- The municipality code must start with an IBGE UF code; it is not looked up in the IBGE table. +- `ambGer` must be `1` (the system of the municipality) or `2` (the Sistema Nacional NFS-e), and the registration type `1` (a CPF, left padded with `000`) or `2` (a CNPJ), with a CPF or CNPJ whose own check digits are valid. +- `nNFSe` must not be all zeros and the month must be 01 to 12. +- The check digit is a modulus 11 over the first 49 digits, weights 2 to 9 cycling from the right, where a remainder of 0 or 1 gives 0. +- Keys carrying an alphanumeric CNPJ are not accepted yet: no official document states how a letter enters the check digit of the key. +- The municipal NFS-e models that are not the national standard are out of scope. + +```javascript +import { isValidNfseKey } from '@brazilian-utils/brazilian-utils'; + +isValidNfseKey('35503082258716523000119000000000001226011357924683'); // true (CNPJ issuer, SP) +isValidNfseKey('NFS35503082258716523000119000000000001226011357924683'); // true (XML Id prefix) +isValidNfseKey('43149021100040364478829000000000105725120484407255'); // true (CPF issuer, RS) +isValidNfseKey('35503082258716523000119000000000001226011357924684'); // false (check digit) +isValidNfseKey('3550308 2 2 58716523000119 0000000000012 2601 135792468 3'); // false (the key has no mask) +``` + +### parseNfseKey + +Remove everything but the digits from the access key of a national NFS-e, the `NFS` prefix of the XML `Id` attribute included, and cap the result to 50 digits. + +- That is the form the leiaute stores the key in and the one the DANFSe prints, a single block, which is why there is no `formatNfseKey`. + +```javascript +import { parseNfseKey } from '@brazilian-utils/brazilian-utils'; + +parseNfseKey('NFS35503082258716523000119000000000001226011357924683'); +// '35503082258716523000119000000000001226011357924683' + +parseNfseKey('3550308 2 2 58716523000119 0000000000012 2601 135792468 3'); +// '35503082258716523000119000000000001226011357924683' +``` + +### getNfseKeyInfo + +Parse the access key of a national NFS-e into its fields, as an `NfseKeyInfo`. Accepts the same input forms as `isValidNfseKey`. + +- Returns `municipalityCode`, `stateCode`, `generatorEnvironment`, `taxIdType`, `taxId`, `number`, `year`, `month`, `code` and `checkDigit`. +- `generatorEnvironment` is an `NfseKeyGeneratorEnvironment`: `1` the system of the municipality, `2` the Sistema Nacional NFS-e. +- `taxIdType` is an `NfseKeyTaxIdType`, `'cpf'` or `'cnpj'`, and `taxId` is the 11 digit CPF, without the `000` that pads it in the key, or the 14 digit CNPJ. +- Returns `null` when the key is not valid. + +```javascript +import { getNfseKeyInfo } from '@brazilian-utils/brazilian-utils'; + +getNfseKeyInfo('35503082258716523000119000000000001226011357924683'); +// { municipalityCode: '3550308', stateCode: 'SP', generatorEnvironment: 2, taxIdType: 'cnpj', +// taxId: '58716523000119', number: 12, year: 2026, month: 1, code: '135792468', checkDigit: 3 } + +getNfseKeyInfo('43149021100040364478829000000000105725120484407255'); +// { municipalityCode: '4314902', stateCode: 'RS', generatorEnvironment: 1, taxIdType: 'cpf', +// taxId: '40364478829', number: 1057, year: 2025, month: 12, code: '048440725', checkDigit: 5 } + +getNfseKeyInfo('invalid'); // null +``` + +Source: the [technical documentation of the Sistema Nacional NFS-e](https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual), whose schema types `TSIdNFSe` and `TSChaveNFSe` and ANEXO I field `NFSe/infNFSe/id` define the layout and the rules E1280 and E1284, the [manual da emissão por decisão administrativa ou judicial](https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual/manual-contribuintes-emissor-publico-api-emissao-decisao-administrativa-e-judicial.pdf), which names the modulus 11 check digit, and [Nota Técnica SE/CGNFS-e 008](https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/rtc/nt-008-se-cgnfse-danfse-20260714-v1-02.pdf) (item 2.1.1), which prints the key as a single block. + ## SUFRAMA ### isValidSuframa diff --git a/jsr.json b/jsr.json index 67174cf9c..a1cf29431 100644 --- a/jsr.json +++ b/jsr.json @@ -75,6 +75,7 @@ "./get-municipality": "./src/get-municipality/get-municipality.ts", "./get-municipality-by-code": "./src/get-municipality-by-code/get-municipality-by-code.ts", "./get-nfe-key-info": "./src/get-nfe-key-info/get-nfe-key-info.ts", + "./get-nfse-key-info": "./src/get-nfse-key-info/get-nfse-key-info.ts", "./get-pix-key-info": "./src/get-pix-key-info/get-pix-key-info.ts", "./get-pix-payload-info": "./src/get-pix-payload-info/get-pix-payload-info.ts", "./get-state-by-cep": "./src/get-state-by-cep/get-state-by-cep.ts", @@ -112,6 +113,7 @@ "./is-valid-mobile-phone": "./src/is-valid-mobile-phone/is-valid-mobile-phone.ts", "./is-valid-ncm": "./src/is-valid-ncm/is-valid-ncm.ts", "./is-valid-nfe-key": "./src/is-valid-nfe-key/is-valid-nfe-key.ts", + "./is-valid-nfse-key": "./src/is-valid-nfse-key/is-valid-nfse-key.ts", "./is-valid-passport": "./src/is-valid-passport/is-valid-passport.ts", "./is-valid-phone": "./src/is-valid-phone/is-valid-phone.ts", "./is-valid-pis": "./src/is-valid-pis/is-valid-pis.ts", @@ -143,6 +145,7 @@ "./parse-license-plate": "./src/parse-license-plate/parse-license-plate.ts", "./parse-ncm": "./src/parse-ncm/parse-ncm.ts", "./parse-nfe-key": "./src/parse-nfe-key/parse-nfe-key.ts", + "./parse-nfse-key": "./src/parse-nfse-key/parse-nfse-key.ts", "./parse-passport": "./src/parse-passport/parse-passport.ts", "./parse-phone": "./src/parse-phone/parse-phone.ts", "./parse-pis": "./src/parse-pis/parse-pis.ts", diff --git a/reports/api/brazilian-utils.api.md b/reports/api/brazilian-utils.api.md index 1c86f12e4..766bd0494 100644 --- a/reports/api/brazilian-utils.api.md +++ b/reports/api/brazilian-utils.api.md @@ -617,6 +617,9 @@ export type GetMunicipalityParams = GetMunicipalityByCodeParams | GetMunicipalit // @public export const getNfeKeyInfo: (value: string) => NfeKeyInfo | null; +// @public +export const getNfseKeyInfo: (value: string) => NfseKeyInfo | null; + // @public export const getPixKeyInfo: (value: string) => PixKeyInfo | null; @@ -831,6 +834,9 @@ export const isValidNcm: (value: string | number) => boolean; // @public export const isValidNfeKey: (value: string) => boolean; +// @public +export const isValidNfseKey: (value: string) => boolean; + // @public export const isValidPassport: (passport: string | number) => boolean; @@ -934,6 +940,26 @@ export type NfeKeyInfo = { // @public export type NfeKeyModel = "55" | "57" | "58" | "62" | "63" | "64" | "65" | "66" | "67"; +// @public +export type NfseKeyGeneratorEnvironment = 1 | 2; + +// @public +export type NfseKeyInfo = { + municipalityCode: string; + stateCode: StateCode; + generatorEnvironment: NfseKeyGeneratorEnvironment; + taxIdType: NfseKeyTaxIdType; + taxId: string; + number: number; + year: number; + month: number; + code: string; + checkDigit: number; +}; + +// @public +export type NfseKeyTaxIdType = "cpf" | "cnpj"; + // @public export type NumberToWordsGender = "masculine" | "feminine"; @@ -1002,6 +1028,9 @@ export const parseNcm: (value: string | number) => string; // @public export const parseNfeKey: (value: string | number) => string; +// @public +export const parseNfseKey: (value: string | number) => string; + // @public export const parsePassport: (passport: string) => string; diff --git a/src/get-nfse-key-info/constants.ts b/src/get-nfse-key-info/constants.ts new file mode 100644 index 000000000..96075c86e --- /dev/null +++ b/src/get-nfse-key-info/constants.ts @@ -0,0 +1,53 @@ +/** + * Shape of the key: one block of 50 digits, the pattern of `TSChaveNFSe` in + * `tiposSimples_v1.01.xsd`, optionally behind the `NFS` literal the `Id` attribute of `infNFSe` + * puts in front of it (`TSIdNFSe`). The DANFSe prints the key the same way ("em único bloco + * contendo 50 dígitos", Nota Técnica SE/CGNFS-e 008, item 2.1.1), so there is no mask to accept. + * The digits are the first capture group. + */ +export const FORMAT_REGEX = /^(?:nfs)?(\d{50})$/i; + +/** + * The `ambGer` (ambiente gerador) codes of `TSAmbGeradorNFSe`: 1 for the system of the + * municipality (Prefeitura), 2 for the Sistema Nacional NFS-e (Sefin Nacional). + */ +export const GENERATOR_ENVIRONMENTS = [1, 2] as const; + +/** + * The "Tipo de Inscrição Federal" codes of the key, as rule E1263 of the ANEXO I states them: + * 1 for a CPF and 2 for a CNPJ. + */ +export const TAX_ID_TYPES: Readonly> = { "1": "cpf", "2": "cnpj" }; + +/** The zeros that pad an 11 digit CPF to the 14 positions of the "Inscrição Federal" field. */ +export const CPF_PADDING = "000"; + +/** + * A number of all zeros is not a valid `nNFSe`: the leiaute types it `TSNNFSe`, whose pattern is + * `[1-9]{1}[0-9]{0,12}`. + */ +export const ABSENT_NUMBER = "0000000000000"; + +/** Position of `ambGer` inside the 50 digit key. */ +export const GENERATOR_ENVIRONMENT_INDEX = 7; + +/** Position of the "Tipo de Inscrição Federal" inside the 50 digit key. */ +export const TAX_ID_TYPE_INDEX = 8; + +/** Start of the "Inscrição Federal" inside the 50 digit key. */ +export const TAX_ID_START = 9; + +/** Start of the NFS-e number (`nNFSe`), which is also the end of the "Inscrição Federal". */ +export const NUMBER_START = 23; + +/** Start of the issue year and month (AAMM), which is also the end of the NFS-e number. */ +export const YEAR_START = 36; + +/** Start of the issue month, which is also the end of the issue year. */ +export const MONTH_START = 38; + +/** Start of the numeric code (Cód.Num.), which is also the end of the issue month. */ +export const CODE_START = 40; + +/** Position of the check digit (DV), which is also the end of the numeric code. */ +export const CHECK_DIGIT_INDEX = 49; diff --git a/src/get-nfse-key-info/get-nfse-key-info.test.ts b/src/get-nfse-key-info/get-nfse-key-info.test.ts new file mode 100644 index 000000000..c0666eabb --- /dev/null +++ b/src/get-nfse-key-info/get-nfse-key-info.test.ts @@ -0,0 +1,286 @@ +import * as fc from "fast-check"; + +import { IBGE_UF_CODES } from "../_internals/constants/ibge-uf-codes"; +import { type StateCode } from "../_internals/constants/states"; +import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; +import { type GENERATOR_ENVIRONMENTS } from "./constants"; +import { + getNfseKeyInfo, + type NfseKeyGeneratorEnvironment, + type NfseKeyInfo, + type NfseKeyTaxIdType, +} from "./get-nfse-key-info"; + +const KEY_SP = "35503082258716523000119000000000001226011357924683"; +const KEY_RS = "43149021100040364478829000000000105725120484407255"; + +const CHECK_DIGITS = Array.from({ length: 10 }, (_, digit) => String(digit)); + +const ISSUERS: { type: string; registration: string; taxIdType: string; taxId: string }[] = [ + { type: "1", registration: "00040364478829", taxIdType: "cpf", taxId: "40364478829" }, + { type: "2", registration: "58716523000119", taxIdType: "cnpj", taxId: "58716523000119" }, + { type: "2", registration: "00000000000191", taxIdType: "cnpj", taxId: "00000000000191" }, +]; + +const buildNfseKey = (base: string): string => + CHECK_DIGITS.map((digit) => `${base}${digit}`).find((key) => getNfseKeyInfo(key) !== null) ?? ""; + +describe("getNfseKeyInfo", () => { + describe("should return null", () => { + test("when it is null", () => { + // @ts-expect-error: intentionally invalid input + expect(getNfseKeyInfo(null)).toBeNull(); + }); + + test("when it is undefined", () => { + // @ts-expect-error: intentionally invalid input + expect(getNfseKeyInfo()).toBeNull(); + }); + + test("when it is a number", () => { + // @ts-expect-error: intentionally invalid input + expect(getNfseKeyInfo(123)).toBeNull(); + }); + + test("when it is an empty string", () => { + expect(getNfseKeyInfo("")).toBeNull(); + }); + + test("when it is otherwise not a key", () => { + expect(getNfseKeyInfo("not-a-key")).toBeNull(); + }); + + test("when it does not have 50 digits", () => { + expect(getNfseKeyInfo(KEY_SP.slice(0, 49))).toBeNull(); + expect(getNfseKeyInfo(`${KEY_SP}3`)).toBeNull(); + }); + + test("when there is anything before or after the 50 digits", () => { + expect(getNfseKeyInfo(`x${KEY_SP}`)).toBeNull(); + expect(getNfseKeyInfo(`${KEY_SP}x`)).toBeNull(); + expect(getNfseKeyInfo(`NFSe${KEY_SP}`)).toBeNull(); + }); + + test("when it is split by separators, since the key has no mask", () => { + expect( + getNfseKeyInfo("3550308 2 2 58716523000119 0000000000012 2601 135792468 3"), + ).toBeNull(); + expect(getNfseKeyInfo(`NFS ${KEY_SP}`)).toBeNull(); + }); + + test("when the check digit does not match", () => { + expect(getNfseKeyInfo("35503082258716523000119000000000001226011357924684")).toBeNull(); + }); + + test("when the municipality code does not start with an IBGE UF code, even with a matching check digit", () => { + expect(getNfseKeyInfo("99503082258716523000119000000000001226011357924680")).toBeNull(); + expect(getNfseKeyInfo("00503082258716523000119000000000001226011357924680")).toBeNull(); + }); + + test("when ambGer is 0 or 3, even with a matching check digit", () => { + expect(getNfseKeyInfo("35503080258716523000119000000000001226011357924689")).toBeNull(); + expect(getNfseKeyInfo("35503083258716523000119000000000001226011357924680")).toBeNull(); + }); + + test("when the registration type is 0 or 3, even with a matching check digit", () => { + expect(getNfseKeyInfo("35503082058716523000119000000000001226011357924687")).toBeNull(); + expect(getNfseKeyInfo("35503082358716523000119000000000001226011357924681")).toBeNull(); + }); + + test("when the registration type is 0 or 3 and the registration is a well padded CPF", () => { + expect(getNfseKeyInfo("35503082000040364478829000000000001226011357924689")).toBeNull(); + expect(getNfseKeyInfo("35503082300040364478829000000000001226011357924683")).toBeNull(); + }); + + test("when the registration type says CPF and the registration is a CNPJ", () => { + expect(getNfseKeyInfo("35503082158716523000119000000000001226011357924685")).toBeNull(); + }); + + test("when the registration type says CNPJ and the registration is a padded CPF", () => { + expect(getNfseKeyInfo("35503082200040364478829000000000001226011357924685")).toBeNull(); + }); + + test("when a valid CPF is not left padded with 000", () => { + expect(getNfseKeyInfo("35503082110040364478829000000000001226011357924689")).toBeNull(); + expect(getNfseKeyInfo("35503082101040364478829000000000001226011357924680")).toBeNull(); + expect(getNfseKeyInfo("35503082100140364478829000000000001226011357924680")).toBeNull(); + }); + + test("when the check digits of the CPF or of the CNPJ do not match, even with a matching key check digit", () => { + expect(getNfseKeyInfo("43149021100040364478820000000000105725120484407258")).toBeNull(); + expect(getNfseKeyInfo("35503082258716523000110000000000001226011357924686")).toBeNull(); + }); + + test("when nNFSe is all zeros, even with a matching check digit", () => { + expect(getNfseKeyInfo("35503082258716523000119000000000000026011357924683")).toBeNull(); + }); + + test("when the month is 00 or 13, even with a matching check digit", () => { + expect(getNfseKeyInfo("35503082258716523000119000000000001226001357924686")).toBeNull(); + expect(getNfseKeyInfo("35503082258716523000119000000000001226131357924684")).toBeNull(); + }); + + test("when the CNPJ is alphanumeric, which no official document gives a check digit rule for", () => { + expect(getNfseKeyInfo("355030822AB716523000119000000000001226011357924683")).toBeNull(); + }); + }); + + describe("should return the parsed access key", () => { + test("for a synthetic key of a CNPJ issuer generated by the Sistema Nacional NFS-e (São Paulo)", () => { + expect(getNfseKeyInfo(KEY_SP)).toEqual({ + municipalityCode: "3550308", + stateCode: "SP", + generatorEnvironment: 2, + taxIdType: "cnpj", + taxId: "58716523000119", + number: 12, + year: 2026, + month: 1, + code: "135792468", + checkDigit: 3, + }); + }); + + test("for a synthetic key of a CPF issuer generated by the municipality (Porto Alegre), dropping the 000 padding", () => { + expect(getNfseKeyInfo(KEY_RS)).toEqual({ + municipalityCode: "4314902", + stateCode: "RS", + generatorEnvironment: 1, + taxIdType: "cpf", + taxId: "40364478829", + number: 1057, + year: 2025, + month: 12, + code: "048440725", + checkDigit: 5, + }); + }); + + test("accepting the NFS prefix of the XML Id attribute, in any case, and surrounding whitespace", () => { + expect(getNfseKeyInfo(`NFS${KEY_SP}`)?.number).toBe(12); + expect(getNfseKeyInfo(`nfs${KEY_SP}`)?.number).toBe(12); + expect(getNfseKeyInfo(` NFS${KEY_SP}\n`)?.number).toBe(12); + expect(getNfseKeyInfo(` ${KEY_SP} `)?.number).toBe(12); + }); + + test("for ambGer 1 on the São Paulo key, with the check digit recalculated", () => { + expect( + getNfseKeyInfo("35503081258716523000119000000000001226011357924686")?.generatorEnvironment, + ).toBe(1); + }); + + test("for month 12, the upper boundary", () => { + expect(getNfseKeyInfo("35503082258716523000119000000000001226121357924687")?.month).toBe(12); + }); + + test("for the largest nNFSe, which a JavaScript number still holds exactly", () => { + expect(getNfseKeyInfo("35503082258716523000119999999999999926011357924686")?.number).toBe( + 9_999_999_999_999, + ); + }); + + test("for years 00 and 99 of the two digit year", () => { + expect(getNfseKeyInfo("35503082258716523000119000000000001200011357924681")?.year).toBe(2000); + expect(getNfseKeyInfo("35503082258716523000119000000000001299011357924681")?.year).toBe(2099); + }); + + test("keeping the leading zeros of a numeric code of all zeros", () => { + expect(getNfseKeyInfo("35503082258716523000119000000000001226010000000004")?.code).toBe( + "000000000", + ); + }); + + test("for check digit 0 out of remainders 0 and 1, and check digit 1 out of remainder 10", () => { + expect(getNfseKeyInfo("35503082258716523000119000000000001226011234567890")?.checkDigit).toBe( + 0, + ); + expect(getNfseKeyInfo("35503082258716523000119000000000001226011234567840")?.checkDigit).toBe( + 0, + ); + expect(getNfseKeyInfo("35503082258716523000119000000000001226011234567831")?.checkDigit).toBe( + 1, + ); + }); + }); + + describe("properties", () => { + const parts = fc.tuple( + fc.constantFrom(...Object.keys(IBGE_UF_CODES)), + fc.stringMatching(/^[0-9]{5}$/), + fc.constantFrom(1, 2), + fc.constantFrom(...ISSUERS), + fc.integer({ min: 1, max: 9_999_999_999_999 }), + fc.stringMatching(/^[0-9]{2}$/), + fc.integer({ min: 1, max: 12 }), + fc.stringMatching(/^[0-9]{9}$/), + ); + + test("should give back every field of a well-formed access key", () => { + fc.assert( + fc.property(parts, (fields) => { + const [uf, municipality, ambGer, issuer, number, year, month, code] = fields; + const head = `${uf}${municipality}${ambGer}${issuer.type}${issuer.registration}`; + const issue = `${year}${String(month).padStart(2, "0")}`; + const key = buildNfseKey(`${head}${String(number).padStart(13, "0")}${issue}${code}`); + const parsed = getNfseKeyInfo(key); + + expect(parsed?.municipalityCode).toBe(`${uf}${municipality}`); + expect(parsed?.stateCode).toBe(IBGE_UF_CODES[uf]); + expect(parsed?.generatorEnvironment).toBe(ambGer); + expect(parsed?.taxIdType).toBe(issuer.taxIdType); + expect(parsed?.taxId).toBe(issuer.taxId); + expect(parsed?.number).toBe(number); + expect(parsed?.year).toBe(2000 + Number(year)); + expect(parsed?.month).toBe(month); + expect(parsed?.code).toBe(code); + expect(parsed?.checkDigit).toBe(Number(key.charAt(49))); + }), + ); + }); + + test("should accept exactly one check digit for a well-formed body", () => { + fc.assert( + fc.property(fc.stringMatching(/^[0-9]{9}$/), (code) => { + const base = `${KEY_SP.slice(0, 40)}${code}`; + const accepted = CHECK_DIGITS.filter((digit) => getNfseKeyInfo(`${base}${digit}`)); + + expect(accepted).toHaveLength(1); + }), + ); + }); + + test("should never throw and always return an access key or null", () => { + fc.assert( + fc.property(fc.anything(), (value) => { + const parsed = getNfseKeyInfo(value as string); + + expect(parsed === null || typeof parsed.taxId === "string").toBe(true); + }), + ); + }); + }); +}); + +describe("getNfseKeyInfo types", () => { + test("should take a string and return an NfseKeyInfo or null", () => { + expectTypeOf(getNfseKeyInfo).parameter(0).toEqualTypeOf(); + expectTypeOf(getNfseKeyInfo).returns.toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<{ + municipalityCode: string; + stateCode: StateCode; + generatorEnvironment: NfseKeyGeneratorEnvironment; + taxIdType: NfseKeyTaxIdType; + taxId: string; + number: number; + year: number; + month: number; + code: string; + checkDigit: number; + }>(); + expectTypeOf().toEqualTypeOf<1 | 2>(); + expectTypeOf().toEqualTypeOf< + (typeof GENERATOR_ENVIRONMENTS)[number] + >(); + expectTypeOf().toEqualTypeOf<"cpf" | "cnpj">(); + }); +}); diff --git a/src/get-nfse-key-info/get-nfse-key-info.ts b/src/get-nfse-key-info/get-nfse-key-info.ts new file mode 100644 index 000000000..7e2cc6f24 --- /dev/null +++ b/src/get-nfse-key-info/get-nfse-key-info.ts @@ -0,0 +1,165 @@ +import { IBGE_UF_CODES } from "../_internals/constants/ibge-uf-codes"; +import { type StateCode } from "../_internals/constants/states"; +import { mod11 } from "../_internals/mod11/mod11"; +import { isValidCnpj } from "../is-valid-cnpj/is-valid-cnpj"; +import { isValidCpf } from "../is-valid-cpf/is-valid-cpf"; +import { + ABSENT_NUMBER, + CHECK_DIGIT_INDEX, + CODE_START, + CPF_PADDING, + FORMAT_REGEX, + GENERATOR_ENVIRONMENTS, + GENERATOR_ENVIRONMENT_INDEX, + MONTH_START, + NUMBER_START, + TAX_ID_START, + TAX_ID_TYPES, + TAX_ID_TYPE_INDEX, + YEAR_START, +} from "./constants"; + +export type { StateCode } from "../_internals/constants/states"; + +/** + * The system that generated the NFS-e (`ambGer`): `1` the system of the municipality + * (Prefeitura), `2` the Sistema Nacional NFS-e (Sefin Nacional). Spelled out instead of derived + * from `GENERATOR_ENVIRONMENTS` because the allowlist is internal and API Extractor cannot name + * it in the public report; the type test of `get-nfse-key-info.test.ts` pins the two together. + */ +export type NfseKeyGeneratorEnvironment = 1 | 2; + +/** The kind of federal registration ("Tipo de Inscrição Federal") the key carries. */ +export type NfseKeyTaxIdType = "cpf" | "cnpj"; + +/** The fields `getNfseKeyInfo` reads out of a national NFS-e access key (chave de acesso). */ +export type NfseKeyInfo = { + /** Seven digit IBGE code of the municipality of the issuer's address. */ + municipalityCode: string; + /** Two letter code of the state (UF) of that municipality, read from its first two digits. */ + stateCode: StateCode; + /** The system that generated the NFS-e (`ambGer`): 1 municipality, 2 Sistema Nacional NFS-e. */ + generatorEnvironment: NfseKeyGeneratorEnvironment; + /** Whether the issuer is identified by a CPF or by a CNPJ. */ + taxIdType: NfseKeyTaxIdType; + /** The 11 digit CPF (without the `000` padding of the key) or the 14 digit CNPJ of the issuer. */ + taxId: string; + /** NFS-e number (`nNFSe`), sequential by issuer, 1 to 9999999999999. */ + number: number; + /** Four digit issue year. */ + year: number; + /** Issue month, 1 to 12. */ + month: number; + /** The 9 digit random numeric code drawn by the system that generated the NFS-e. */ + code: string; + /** The modulo 11 check digit of the key. */ + checkDigit: number; +}; + +const readTaxId = (taxIdType: NfseKeyTaxIdType, registration: string): string | null => { + if (taxIdType === "cnpj") return isValidCnpj(registration) ? registration : null; + + const cpf = registration.slice(CPF_PADDING.length); + + return registration.startsWith(CPF_PADDING) && isValidCpf(cpf) ? cpf : null; +}; + +/** + * Parses the access key (chave de acesso) of a national NFS-e, the Nota Fiscal de Serviço + * eletrônica of the Sistema Nacional NFS-e, into its fields. + * + * The key is one block of 50 digits: + * `Cód.Mun.(7) Amb.Ger.(1) Tipo de Inscrição Federal(1) Inscrição Federal(14) nNFSe(13) AAMM(4) + * Cód.Num.(9) DV(1)`. It is accepted as it is written in the documents (`chNFSe`, `chSubstda`, + * the DANFSe, which prints it as a single block) or with the `NFS` literal the `Id` attribute + * of `infNFSe` puts in front of it, surrounding whitespace aside. The key has no printed mask, + * so a separator anywhere in it is rejected instead of being stripped. The keys of the + * municipal NFS-e models that are not the national standard are out of scope. + * + * What is checked: the first two digits of the municipality code are an IBGE UF code, `ambGer` + * is 1 or 2, the registration type is 1 (CPF, the 11 digits left padded with `000`) or 2 + * (CNPJ) and the CPF or CNPJ has valid check digits of its own (rules E1280 and E1284 of the + * ANEXO I reject an NFS-e whose issuer fails them), `nNFSe` is not all zeros, the month is 01 + * to 12 and the check digit matches. The municipality code is not looked up in the IBGE table. + * + * The check digit is a modulus 11 over the first 49 digits, weights 2 to 9 cycling from the + * right, where a remainder of 0 or 1 gives 0. The official text only says "algoritmo do módulo + * 11"; the weights and the remainder rule are the ones of the DF-e access key and were + * confirmed against more than a hundred NFS-e keys found in public repositories, generated by + * both environments, remainders 0, 1 and 10 included. + * + * Keys carrying an alphanumeric CNPJ are not accepted yet: the schema package published for the + * restricted production environment on 2026-07-27 widens `TSIdNFSe` to letters in the + * registration, but no official document states how a letter enters the check digit of the key. + * + * @param {string} value - The access key value to be parsed. + * @returns {NfseKeyInfo | null} The parsed access key, or `null` when it is not valid. + * + * @see Official: https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual + * Sistema Nacional NFS-e, current technical documentation: `NFSe-ESQUEMAS_XSD-v1.01` + * (`tiposSimples_v1.01.xsd`: `TSIdNFSe`, `TSChaveNFSe`, `TSAmbGeradorNFSe`, `TSNNFSe`) and + * `ANEXO_I-SEFIN_ADN-DPS_NFSe-SNNFSe` v1.01 (field `NFSe/infNFSe/id`, rules E1263, E1280, E1284 + * and E0042). + * @see Official: https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual/manual-contribuintes-emissor-publico-api-emissao-decisao-administrativa-e-judicial.pdf + * Manual de Contribuintes, Emissão por Decisão Administrativa ou Judicial, field `id`: "O dígito + * verificador deve ser calculado segundo o algoritmo do módulo 11". + * @see Official: https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/rtc/nt-008-se-cgnfse-danfse-20260714-v1-02.pdf + * Nota Técnica SE/CGNFS-e 008 (DANFSe), item 2.1.1: the key is printed as a single block of 50 + * digits. + * @see Based on: https://github.com/nfse-nacional/nfse-php + * Test fixtures with NFS-e keys used to confirm the check digit rule; none of them is reproduced + * in the tests of this package, whose keys are synthetic. + * @see Based on: https://github.com/Unimake/DFe + * Example and test XMLs of the NACIONAL standard, second source of keys for the same check. + * + * @example + * ```typescript + * getNfseKeyInfo("35503082258716523000119000000000001226011357924683"); + * // { municipalityCode: "3550308", stateCode: "SP", generatorEnvironment: 2, taxIdType: "cnpj", + * // taxId: "58716523000119", number: 12, year: 2026, month: 1, code: "135792468", checkDigit: 3 } + * + * getNfseKeyInfo("invalid"); // null + * ``` + */ +export const getNfseKeyInfo = (value: string): NfseKeyInfo | null => { + if (typeof value !== "string") return null; + + const match = FORMAT_REGEX.exec(value.trim()); + + if (match === null) return null; + + const [, digits] = match; + const stateCode = IBGE_UF_CODES[digits.slice(0, 2)]; + const ambGer = Number(digits[GENERATOR_ENVIRONMENT_INDEX]); + const generatorEnvironment = GENERATOR_ENVIRONMENTS.find((candidate) => candidate === ambGer); + const taxIdType = TAX_ID_TYPES[digits[TAX_ID_TYPE_INDEX]]; + + if (stateCode === undefined || generatorEnvironment === undefined || taxIdType === undefined) { + return null; + } + + const taxId = readTaxId(taxIdType, digits.slice(TAX_ID_START, NUMBER_START)); + const numberDigits = digits.slice(NUMBER_START, YEAR_START); + const month = Number(digits.slice(MONTH_START, CODE_START)); + + if (taxId === null || numberDigits === ABSENT_NUMBER || month < 1 || month > 12) return null; + + const checkDigit = Number(digits[CHECK_DIGIT_INDEX]); + + if (mod11(digits.slice(0, CHECK_DIGIT_INDEX), { variant: "arrecadacao" }) !== checkDigit) { + return null; + } + + return { + municipalityCode: digits.slice(0, GENERATOR_ENVIRONMENT_INDEX), + stateCode, + generatorEnvironment, + taxIdType, + taxId, + number: Number(numberDigits), + year: 2000 + Number(digits.slice(YEAR_START, MONTH_START)), + month, + code: digits.slice(CODE_START, CHECK_DIGIT_INDEX), + checkDigit, + }; +}; diff --git a/src/index.test.ts b/src/index.test.ts index 63934009b..2bfdffa30 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -84,6 +84,9 @@ import { type Municipality, type NfeKeyInfo, type NfeKeyModel, + type NfseKeyGeneratorEnvironment, + type NfseKeyInfo, + type NfseKeyTaxIdType, type NumberToWordsGender, type ParseCnpjOptions, type ParseCurrencyOptions, @@ -195,6 +198,7 @@ const PUBLIC = [ "getMunicipality", "getMunicipalityByCode", "getNfeKeyInfo", + "getNfseKeyInfo", "getPixKeyInfo", "getPixPayloadInfo", "getStateByCep", @@ -236,6 +240,7 @@ const PUBLIC = [ "isValidMobilePhone", "isValidNcm", "isValidNfeKey", + "isValidNfseKey", "isValidPIS", "isValidPassport", "isValidPhone", @@ -268,6 +273,7 @@ const PUBLIC = [ "parseLicensePlate", "parseNcm", "parseNfeKey", + "parseNfseKey", "parsePassport", "parsePhone", "parsePis", @@ -390,6 +396,9 @@ describe("Public API", () => { Municipality: Municipality; NfeKeyInfo: NfeKeyInfo; NfeKeyModel: NfeKeyModel; + NfseKeyGeneratorEnvironment: NfseKeyGeneratorEnvironment; + NfseKeyInfo: NfseKeyInfo; + NfseKeyTaxIdType: NfseKeyTaxIdType; NumberToWordsGender: NumberToWordsGender; ParseCnpjOptions: ParseCnpjOptions; ParseCurrencyOptions: ParseCurrencyOptions; diff --git a/src/index.ts b/src/index.ts index d865a49ae..dfd10232d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -158,6 +158,12 @@ export { type NfeKeyModel, getNfeKeyInfo, } from "./get-nfe-key-info/get-nfe-key-info"; +export { + type NfseKeyGeneratorEnvironment, + type NfseKeyInfo, + type NfseKeyTaxIdType, + getNfseKeyInfo, +} from "./get-nfse-key-info/get-nfse-key-info"; export { type PixKeyInfo, type PixKeyType, @@ -212,6 +218,7 @@ export { } from "./is-valid-mobile-phone/is-valid-mobile-phone"; export { isValidNcm } from "./is-valid-ncm/is-valid-ncm"; export { isValidNfeKey } from "./is-valid-nfe-key/is-valid-nfe-key"; +export { isValidNfseKey } from "./is-valid-nfse-key/is-valid-nfse-key"; export { isValidPassport } from "./is-valid-passport/is-valid-passport"; export { type IsValidPhoneOptions, @@ -252,6 +259,7 @@ export { parseLegalNature } from "./parse-legal-nature/parse-legal-nature"; export { parseLicensePlate } from "./parse-license-plate/parse-license-plate"; export { parseNcm } from "./parse-ncm/parse-ncm"; export { parseNfeKey } from "./parse-nfe-key/parse-nfe-key"; +export { parseNfseKey } from "./parse-nfse-key/parse-nfse-key"; export { parsePassport } from "./parse-passport/parse-passport"; export { parsePhone } from "./parse-phone/parse-phone"; export { parsePis } from "./parse-pis/parse-pis"; diff --git a/src/is-valid-nfse-key/is-valid-nfse-key.test.ts b/src/is-valid-nfse-key/is-valid-nfse-key.test.ts new file mode 100644 index 000000000..82f30b006 --- /dev/null +++ b/src/is-valid-nfse-key/is-valid-nfse-key.test.ts @@ -0,0 +1,110 @@ +import { anyText, anyValue, digits } from "../_internals/test/arbitraries"; +import { expectAlwaysReturnsType, expectRejected } from "../_internals/test/properties"; +import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; +import { isValidNfseKey } from "./is-valid-nfse-key"; + +const KEY_CNPJ = "35503082258716523000119000000000001226011357924683"; +const KEY_CPF = "43149021100040364478829000000000105725120484407255"; + +describe("isValidNfseKey", () => { + describe("should return true", () => { + test("for a synthetic key of a CNPJ issuer generated by the Sistema Nacional NFS-e", () => { + expect(isValidNfseKey(KEY_CNPJ)).toBe(true); + }); + + test("for a synthetic key of a CPF issuer generated by the municipality", () => { + expect(isValidNfseKey(KEY_CPF)).toBe(true); + }); + + test("when it has the NFS prefix found in the XML Id attribute, in any case", () => { + expect(isValidNfseKey(`NFS${KEY_CNPJ}`)).toBe(true); + expect(isValidNfseKey(`nfs${KEY_CPF}`)).toBe(true); + }); + + test("when it has surrounding whitespace", () => { + expect(isValidNfseKey(` ${KEY_CNPJ}\n`)).toBe(true); + }); + }); + + describe("should return false", () => { + test("when it is null", () => { + // @ts-expect-error: intentionally invalid input + expect(isValidNfseKey(null)).toBe(false); + }); + + test("when it is undefined", () => { + // @ts-expect-error: intentionally invalid input + expect(isValidNfseKey()).toBe(false); + }); + + test("when it is a number, a boolean, an object or an array", () => { + // @ts-expect-error: intentionally invalid input + expect(isValidNfseKey(123)).toBe(false); + // @ts-expect-error: intentionally invalid input + expect(isValidNfseKey(true)).toBe(false); + // @ts-expect-error: intentionally invalid input + expect(isValidNfseKey({})).toBe(false); + // @ts-expect-error: intentionally invalid input + expect(isValidNfseKey([KEY_CNPJ])).toBe(false); + }); + + test("when it is an empty string", () => { + expect(isValidNfseKey("")).toBe(false); + }); + + test("when the check digit does not match", () => { + expect(isValidNfseKey("35503082258716523000119000000000001226011357924684")).toBe(false); + }); + + test("when it does not have 50 digits", () => { + expect(isValidNfseKey(KEY_CNPJ.slice(0, 49))).toBe(false); + expect(isValidNfseKey(`${KEY_CNPJ}3`)).toBe(false); + }); + + test("when it is split by separators, since the key has no mask", () => { + expect(isValidNfseKey("3550308 2 2 58716523000119 0000000000012 2601 135792468 3")).toBe( + false, + ); + expect(isValidNfseKey("3550308.2.2.58716523000119.0000000000012.2601.135792468-3")).toBe( + false, + ); + }); + + test("for the 44 digit key of an NF-e", () => { + expect(isValidNfseKey("35170458716523000119550010000000121000123458")).toBe(false); + }); + + test("for the example of the Guia do Emissor Público Nacional Web v1.2, item 9.1, an illustrative key whose check digit does not match", () => { + expect(isValidNfseKey("31062001251235800000112230000000173023019580208160")).toBe(false); + }); + + test("when the month is 13, even with a matching check digit", () => { + expect(isValidNfseKey("35503082258716523000119000000000001226131357924684")).toBe(false); + }); + + test("when the issuer CNPJ has wrong check digits, even with a matching key check digit", () => { + expect(isValidNfseKey("35503082258716523000110000000000001226011357924686")).toBe(false); + }); + }); + + describe("properties", () => { + test("should reject any text that is not a key", () => { + expectRejected(isValidNfseKey, anyText); + }); + + test("should reject digits of the NF-e key length", () => { + expectRejected(isValidNfseKey, digits(44)); + }); + + test("should never throw and always return a boolean", () => { + expectAlwaysReturnsType(isValidNfseKey, "boolean", anyValue); + }); + }); +}); + +describe("isValidNfseKey types", () => { + test("should take a string and return a boolean", () => { + expectTypeOf(isValidNfseKey).parameter(0).toEqualTypeOf(); + expectTypeOf(isValidNfseKey).returns.toEqualTypeOf(); + }); +}); diff --git a/src/is-valid-nfse-key/is-valid-nfse-key.ts b/src/is-valid-nfse-key/is-valid-nfse-key.ts new file mode 100644 index 000000000..eb7c5e423 --- /dev/null +++ b/src/is-valid-nfse-key/is-valid-nfse-key.ts @@ -0,0 +1,45 @@ +import { getNfseKeyInfo } from "../get-nfse-key-info/get-nfse-key-info"; + +/** + * Validates the access key (chave de acesso) of a national NFS-e, the Nota Fiscal de Serviço + * eletrônica of the Sistema Nacional NFS-e. + * + * The key is one block of 50 digits: + * `Cód.Mun.(7) Amb.Ger.(1) Tipo de Inscrição Federal(1) Inscrição Federal(14) nNFSe(13) AAMM(4) + * Cód.Num.(9) DV(1)`. The `NFS` literal the `Id` attribute of `infNFSe` puts in front of it is + * stripped, with surrounding whitespace. The key has no printed mask (the DANFSe prints it as a + * single block), so a separator anywhere in it is rejected instead of being stripped. The keys + * of the municipal NFS-e models that are not the national standard are out of scope, and so is + * the 44 digit DF-e key, which `isValidNfeKey` covers. + * + * The municipality code must start with an IBGE UF code, `ambGer` must be 1 (municipality) or 2 + * (Sistema Nacional NFS-e), the registration type 1 (CPF, left padded with `000`) or 2 (CNPJ) + * with a CPF or CNPJ whose own check digits are valid, `nNFSe` must not be all zeros and the + * month must be 01 to 12. The check digit (DV) is a modulus 11 over the first 49 digits, weights + * 2 to 9 cycling from the right, where a remainder of 0 or 1 gives 0. `getNfseKeyInfo` states + * what each rule is taken from. + * + * Keys carrying an alphanumeric CNPJ are not accepted yet, since no official document states how + * a letter enters the check digit of the key. + * + * @param {string} value - The access key value to be validated. + * @returns {boolean} True if the access key is valid, false otherwise. + * + * @see Official: https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual + * Sistema Nacional NFS-e, current technical documentation: `NFSe-ESQUEMAS_XSD-v1.01` + * (`tiposSimples_v1.01.xsd`: `TSIdNFSe`, `TSChaveNFSe`) and `ANEXO_I-SEFIN_ADN-DPS_NFSe-SNNFSe` + * v1.01 (field `NFSe/infNFSe/id`, rules E1263, E1280, E1284 and E0042). + * @see Official: https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual/manual-contribuintes-emissor-publico-api-emissao-decisao-administrativa-e-judicial.pdf + * Manual de Contribuintes, Emissão por Decisão Administrativa ou Judicial, field `id`: "O dígito + * verificador deve ser calculado segundo o algoritmo do módulo 11". + * + * @example + * ```typescript + * isValidNfseKey("35503082258716523000119000000000001226011357924683"); // true (CNPJ issuer, SP) + * isValidNfseKey("NFS35503082258716523000119000000000001226011357924683"); // true (XML Id prefix) + * isValidNfseKey("43149021100040364478829000000000105725120484407255"); // true (CPF issuer, RS) + * isValidNfseKey("35503082258716523000119000000000001226011357924684"); // false (check digit) + * isValidNfseKey("3550308 2 2 58716523000119 0000000000012 2601 135792468 3"); // false (no mask) + * ``` + */ +export const isValidNfseKey = (value: string): boolean => getNfseKeyInfo(value) !== null; diff --git a/src/parse-nfse-key/constants.ts b/src/parse-nfse-key/constants.ts new file mode 100644 index 000000000..c1e4ac265 --- /dev/null +++ b/src/parse-nfse-key/constants.ts @@ -0,0 +1,2 @@ +/** Digits of a national NFS-e access key (chave de acesso), type `TSChaveNFSe` of the leiaute. */ +export const LENGTH = 50; diff --git a/src/parse-nfse-key/parse-nfse-key.test.ts b/src/parse-nfse-key/parse-nfse-key.test.ts new file mode 100644 index 000000000..6fef0845b --- /dev/null +++ b/src/parse-nfse-key/parse-nfse-key.test.ts @@ -0,0 +1,71 @@ +import { anyText, anyValue } from "../_internals/test/arbitraries"; +import { + expectAlwaysReturnsType, + expectIdempotent, + expectMatchesPattern, +} from "../_internals/test/properties"; +import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; +import { parseNfseKey } from "./parse-nfse-key"; + +const KEY = "35503082258716523000119000000000001226011357924683"; + +describe("parseNfseKey", () => { + it("should keep a bare access key as it is", () => { + expect(parseNfseKey(KEY)).toBe(KEY); + }); + + it("should strip the NFS prefix of the XML Id attribute", () => { + expect(parseNfseKey(`NFS${KEY}`)).toBe(KEY); + expect(parseNfseKey(` nfs${KEY}`)).toBe(KEY); + }); + + it("should remove non numeric characters", () => { + expect(parseNfseKey("3550308 2 2 58716523000119 0000000000012 2601 135792468 3")).toBe(KEY); + expect(parseNfseKey("3550308.2.2.58716523000119/0000000000012-2601-135792468-3")).toBe(KEY); + }); + + it("should ignore digits after the 50 of an access key", () => { + expect(parseNfseKey(`${KEY}999`)).toBe(KEY); + }); + + it("should keep a partial access key as written", () => { + expect(parseNfseKey("3550308 2 2")).toBe("355030822"); + }); + + it("should read a number as the string of its digits", () => { + expect(parseNfseKey(355_030_822)).toBe("355030822"); + }); + + it("should return an empty string when there is no digit", () => { + expect(parseNfseKey("NFS")).toBe(""); + expect(parseNfseKey("")).toBe(""); + }); + + it("should return an empty string for null and undefined", () => { + // @ts-expect-error not a string or number + expect(parseNfseKey(null)).toBe(""); + // @ts-expect-error not a string or number + expect(parseNfseKey()).toBe(""); + }); + + describe("properties", () => { + test("should return at most the digits of an access key", () => { + expectMatchesPattern(parseNfseKey, /^\d{0,50}$/, anyText); + }); + + test("should be idempotent", () => { + expectIdempotent(parseNfseKey, anyText); + }); + + test("should never throw and always return a string", () => { + expectAlwaysReturnsType(parseNfseKey, "string", anyValue); + }); + }); +}); + +describe("parseNfseKey types", () => { + test("should take a string or number value and return a string", () => { + expectTypeOf(parseNfseKey).parameter(0).toEqualTypeOf(); + expectTypeOf(parseNfseKey).returns.toEqualTypeOf(); + }); +}); diff --git a/src/parse-nfse-key/parse-nfse-key.ts b/src/parse-nfse-key/parse-nfse-key.ts new file mode 100644 index 000000000..262b46d74 --- /dev/null +++ b/src/parse-nfse-key/parse-nfse-key.ts @@ -0,0 +1,36 @@ +import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { LENGTH } from "./constants"; + +/** + * Removes everything but the digits from the access key (chave de acesso) of a national NFS-e. + * + * The `NFS` literal the `Id` attribute of `infNFSe` puts in front of the key goes away with + * every other character that is not a digit. The result is + * the form the leiaute stores the key in (`TSChaveNFSe`) and the one the DANFSe prints, a + * single block of digits, so this package has no `formatNfseKey`. + * + * The result is capped at the 50 digits of an access key; a shorter value passes through as far + * as it goes. Use `isValidNfseKey` to check the key and `getNfseKeyInfo` to read its fields. + * + * @param {string|number} value - The access key value to be parsed. + * @returns {string} Up to 50 digits, or an empty string when there is no digit at all. + * + * @example + * ```typescript + * parseNfseKey("NFS35503082258716523000119000000000001226011357924683"); + * // "35503082258716523000119000000000001226011357924683" + * + * parseNfseKey("3550308 2 2 58716523000119 0000000000012 2601 135792468 3"); + * // "35503082258716523000119000000000001226011357924683" + * ``` + * + * @see Official: https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual + * Sistema Nacional NFS-e, current technical documentation: `NFSe-ESQUEMAS_XSD-v1.01` + * (`tiposSimples_v1.01.xsd`), types `TSChaveNFSe` (50 digits) and `TSIdNFSe` (the `NFS` prefix). + * @see Official: https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/rtc/nt-008-se-cgnfse-danfse-20260714-v1-02.pdf + * Nota Técnica SE/CGNFS-e 008 (DANFSe), item 2.1.1: the key is printed as a single block of 50 + * digits. + */ +export const parseNfseKey = (value: string | number): string => + isNullish(value) ? "" : sanitizeToDigits(value).slice(0, LENGTH); From 793cd992ff1bc6498856be80773132fbf7320ce6 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:42:34 -0300 Subject: [PATCH 2/8] test(nfse-key): derive the property test key from an independent check digit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The property built its key by asking getNfseKeyInfo which check digit it accepts, so the assertion on checkDigit could not fail and the property was blind to a wrong weight sequence or a wrong remainder rule. The expected digit is now computed in the test by a plain modulus 11 loop that touches neither mod11 nor the function under test, and the key is built from it. The Guia do Emissor Público Nacional Web example is pinned as invalid for a second reason as well: its "Inscrição Federal" 51235800000112 is not a valid CNPJ, the first check digit should be 2. --- .../get-nfse-key-info.test.ts | 22 ++++++++++++++----- .../is-valid-nfse-key.test.ts | 2 +- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/get-nfse-key-info/get-nfse-key-info.test.ts b/src/get-nfse-key-info/get-nfse-key-info.test.ts index c0666eabb..f613d93c4 100644 --- a/src/get-nfse-key-info/get-nfse-key-info.test.ts +++ b/src/get-nfse-key-info/get-nfse-key-info.test.ts @@ -22,8 +22,19 @@ const ISSUERS: { type: string; registration: string; taxIdType: string; taxId: s { type: "2", registration: "00000000000191", taxIdType: "cnpj", taxId: "00000000000191" }, ]; -const buildNfseKey = (base: string): string => - CHECK_DIGITS.map((digit) => `${base}${digit}`).find((key) => getNfseKeyInfo(key) !== null) ?? ""; +const expectedCheckDigit = (body: string): number => { + let sum = 0; + let weight = 2; + + for (let index = body.length - 1; index >= 0; index -= 1) { + sum += Number(body.charAt(index)) * weight; + weight = weight === 9 ? 2 : weight + 1; + } + + const remainder = sum % 11; + + return remainder < 2 ? 0 : 11 - remainder; +}; describe("getNfseKeyInfo", () => { describe("should return null", () => { @@ -221,8 +232,9 @@ describe("getNfseKeyInfo", () => { const [uf, municipality, ambGer, issuer, number, year, month, code] = fields; const head = `${uf}${municipality}${ambGer}${issuer.type}${issuer.registration}`; const issue = `${year}${String(month).padStart(2, "0")}`; - const key = buildNfseKey(`${head}${String(number).padStart(13, "0")}${issue}${code}`); - const parsed = getNfseKeyInfo(key); + const body = `${head}${String(number).padStart(13, "0")}${issue}${code}`; + const checkDigit = expectedCheckDigit(body); + const parsed = getNfseKeyInfo(`${body}${checkDigit}`); expect(parsed?.municipalityCode).toBe(`${uf}${municipality}`); expect(parsed?.stateCode).toBe(IBGE_UF_CODES[uf]); @@ -233,7 +245,7 @@ describe("getNfseKeyInfo", () => { expect(parsed?.year).toBe(2000 + Number(year)); expect(parsed?.month).toBe(month); expect(parsed?.code).toBe(code); - expect(parsed?.checkDigit).toBe(Number(key.charAt(49))); + expect(parsed?.checkDigit).toBe(checkDigit); }), ); }); diff --git a/src/is-valid-nfse-key/is-valid-nfse-key.test.ts b/src/is-valid-nfse-key/is-valid-nfse-key.test.ts index 82f30b006..94c6c3fc6 100644 --- a/src/is-valid-nfse-key/is-valid-nfse-key.test.ts +++ b/src/is-valid-nfse-key/is-valid-nfse-key.test.ts @@ -74,7 +74,7 @@ describe("isValidNfseKey", () => { expect(isValidNfseKey("35170458716523000119550010000000121000123458")).toBe(false); }); - test("for the example of the Guia do Emissor Público Nacional Web v1.2, item 9.1, an illustrative key whose check digit does not match", () => { + test("for the example of the Guia do Emissor Público Nacional Web v1.2, item 9.1, an illustrative key whose check digit does not match and whose issuer CNPJ 51235800000112 is not valid either", () => { expect(isValidNfseKey("31062001251235800000112230000000173023019580208160")).toBe(false); }); From 703176cbddd4dbd54f52f89a2119b74e937ecd4c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 18:08:33 +0000 Subject: [PATCH 3/8] refactor(nfse): isValidNfseKey checks the key on its own and getNfseKeyInfo builds on it isValidNfseKey answered by parsing the whole key with getNfseKeyInfo and comparing the result to null, so a caller that only wanted a boolean pulled in the object builder as well. The checks now live in isValidNfseKey: the format, the IBGE UF code, ambGer, the registration type with its CPF or CNPJ, a nonzero nNFSe, the month and the modulus 11 check digit. getNfseKeyInfo returns null when isValidNfseKey rejects the value and otherwise only reads the fields out of the last 50 digits, without repeating any check. The layout constants move from get-nfse-key-info/constants.ts to _internals/constants/nfse-key.ts, since both functions need them, with a new NFSE_KEY_LENGTH for the slice. Results are the same for every input; a property pins getNfseKeyInfo(v) === null to !isValidNfseKey(v) over keys with one digit changed and over arbitrary values. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RLkm9YrtAifc6XCLFVEsdH --- .../constants/nfse-key.ts} | 9 ++- .../get-nfse-key-info.test.ts | 46 ++++++++++++- src/get-nfse-key-info/get-nfse-key-info.ts | 64 +++++-------------- src/is-valid-nfse-key/is-valid-nfse-key.ts | 61 +++++++++++++++++- 4 files changed, 128 insertions(+), 52 deletions(-) rename src/{get-nfse-key-info/constants.ts => _internals/constants/nfse-key.ts} (87%) diff --git a/src/get-nfse-key-info/constants.ts b/src/_internals/constants/nfse-key.ts similarity index 87% rename from src/get-nfse-key-info/constants.ts rename to src/_internals/constants/nfse-key.ts index 96075c86e..dae479331 100644 --- a/src/get-nfse-key-info/constants.ts +++ b/src/_internals/constants/nfse-key.ts @@ -7,9 +7,16 @@ */ export const FORMAT_REGEX = /^(?:nfs)?(\d{50})$/i; +/** + * Digits of the key, type `TSChaveNFSe` of the leiaute. Once `isValidNfseKey` accepts a value, + * they are the last 50 characters of the trimmed value, after the optional `NFS` prefix. + */ +export const NFSE_KEY_LENGTH = 50; + /** * The `ambGer` (ambiente gerador) codes of `TSAmbGeradorNFSe`: 1 for the system of the - * municipality (Prefeitura), 2 for the Sistema Nacional NFS-e (Sefin Nacional). + * municipality (Prefeitura), 2 for the Sistema Nacional NFS-e (Sefin Nacional). In code order, + * so code `n` sits at index `n - 1`. */ export const GENERATOR_ENVIRONMENTS = [1, 2] as const; diff --git a/src/get-nfse-key-info/get-nfse-key-info.test.ts b/src/get-nfse-key-info/get-nfse-key-info.test.ts index f613d93c4..059e5a8b7 100644 --- a/src/get-nfse-key-info/get-nfse-key-info.test.ts +++ b/src/get-nfse-key-info/get-nfse-key-info.test.ts @@ -1,9 +1,11 @@ import * as fc from "fast-check"; import { IBGE_UF_CODES } from "../_internals/constants/ibge-uf-codes"; +import { type GENERATOR_ENVIRONMENTS } from "../_internals/constants/nfse-key"; import { type StateCode } from "../_internals/constants/states"; +import { anyText } from "../_internals/test/arbitraries"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; -import { type GENERATOR_ENVIRONMENTS } from "./constants"; +import { isValidNfseKey } from "../is-valid-nfse-key/is-valid-nfse-key"; import { getNfseKeyInfo, type NfseKeyGeneratorEnvironment, @@ -261,6 +263,48 @@ describe("getNfseKeyInfo", () => { ); }); + test("should return null exactly when isValidNfseKey rejects a well-formed key with one digit changed", () => { + fc.assert( + fc.property( + parts, + fc.integer({ min: 0, max: 49 }), + fc.constantFrom(...CHECK_DIGITS), + fc.constantFrom("", "NFS", "nfs", " "), + ( + [uf, municipality, ambGer, issuer, number, year, month, code], + position, + digit, + prefix, + ) => { + const body = [ + uf, + municipality, + ambGer, + issuer.type, + issuer.registration, + String(number).padStart(13, "0"), + year, + String(month).padStart(2, "0"), + code, + ].join(""); + const key = `${body}${expectedCheckDigit(body)}`; + const changed = `${prefix}${key.slice(0, position)}${digit}${key.slice(position + 1)}`; + expect(getNfseKeyInfo(changed) === null).toBe(!isValidNfseKey(changed)); + }, + ), + ); + }); + + const textOrAnything = fc.oneof(anyText, fc.anything()); + + test("should return null exactly when isValidNfseKey rejects any text or any value", () => { + fc.assert( + fc.property(textOrAnything, (value) => { + expect(getNfseKeyInfo(value as string) === null).toBe(!isValidNfseKey(value as string)); + }), + ); + }); + test("should never throw and always return an access key or null", () => { fc.assert( fc.property(fc.anything(), (value) => { diff --git a/src/get-nfse-key-info/get-nfse-key-info.ts b/src/get-nfse-key-info/get-nfse-key-info.ts index 7e2cc6f24..05f3906a4 100644 --- a/src/get-nfse-key-info/get-nfse-key-info.ts +++ b/src/get-nfse-key-info/get-nfse-key-info.ts @@ -1,23 +1,20 @@ import { IBGE_UF_CODES } from "../_internals/constants/ibge-uf-codes"; -import { type StateCode } from "../_internals/constants/states"; -import { mod11 } from "../_internals/mod11/mod11"; -import { isValidCnpj } from "../is-valid-cnpj/is-valid-cnpj"; -import { isValidCpf } from "../is-valid-cpf/is-valid-cpf"; import { - ABSENT_NUMBER, CHECK_DIGIT_INDEX, CODE_START, CPF_PADDING, - FORMAT_REGEX, GENERATOR_ENVIRONMENTS, GENERATOR_ENVIRONMENT_INDEX, MONTH_START, + NFSE_KEY_LENGTH, NUMBER_START, TAX_ID_START, TAX_ID_TYPES, TAX_ID_TYPE_INDEX, YEAR_START, -} from "./constants"; +} from "../_internals/constants/nfse-key"; +import { type StateCode } from "../_internals/constants/states"; +import { isValidNfseKey } from "../is-valid-nfse-key/is-valid-nfse-key"; export type { StateCode } from "../_internals/constants/states"; @@ -56,14 +53,6 @@ export type NfseKeyInfo = { checkDigit: number; }; -const readTaxId = (taxIdType: NfseKeyTaxIdType, registration: string): string | null => { - if (taxIdType === "cnpj") return isValidCnpj(registration) ? registration : null; - - const cpf = registration.slice(CPF_PADDING.length); - - return registration.startsWith(CPF_PADDING) && isValidCpf(cpf) ? cpf : null; -}; - /** * Parses the access key (chave de acesso) of a national NFS-e, the Nota Fiscal de Serviço * eletrônica of the Sistema Nacional NFS-e, into its fields. @@ -76,7 +65,8 @@ const readTaxId = (taxIdType: NfseKeyTaxIdType, registration: string): string | * so a separator anywhere in it is rejected instead of being stripped. The keys of the * municipal NFS-e models that are not the national standard are out of scope. * - * What is checked: the first two digits of the municipality code are an IBGE UF code, `ambGer` + * The key is checked by `isValidNfseKey`, and `null` comes back exactly when it returns false. + * What it checks: the first two digits of the municipality code are an IBGE UF code, `ambGer` * is 1 or 2, the registration type is 1 (CPF, the 11 digits left padded with `000`) or 2 * (CNPJ) and the CPF or CNPJ has valid check digits of its own (rules E1280 and E1284 of the * ANEXO I reject an NFS-e whose issuer fails them), `nNFSe` is not all zeros, the month is 01 @@ -93,7 +83,7 @@ const readTaxId = (taxIdType: NfseKeyTaxIdType, registration: string): string | * registration, but no official document states how a letter enters the check digit of the key. * * @param {string} value - The access key value to be parsed. - * @returns {NfseKeyInfo | null} The parsed access key, or `null` when it is not valid. + * @returns {NfseKeyInfo | null} The parsed access key, or `null` when `isValidNfseKey` rejects it. * * @see Official: https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual * Sistema Nacional NFS-e, current technical documentation: `NFSe-ESQUEMAS_XSD-v1.01` @@ -122,44 +112,22 @@ const readTaxId = (taxIdType: NfseKeyTaxIdType, registration: string): string | * ``` */ export const getNfseKeyInfo = (value: string): NfseKeyInfo | null => { - if (typeof value !== "string") return null; - - const match = FORMAT_REGEX.exec(value.trim()); + if (!isValidNfseKey(value)) return null; - if (match === null) return null; - - const [, digits] = match; - const stateCode = IBGE_UF_CODES[digits.slice(0, 2)]; - const ambGer = Number(digits[GENERATOR_ENVIRONMENT_INDEX]); - const generatorEnvironment = GENERATOR_ENVIRONMENTS.find((candidate) => candidate === ambGer); + const digits = value.trim().slice(-NFSE_KEY_LENGTH); const taxIdType = TAX_ID_TYPES[digits[TAX_ID_TYPE_INDEX]]; - - if (stateCode === undefined || generatorEnvironment === undefined || taxIdType === undefined) { - return null; - } - - const taxId = readTaxId(taxIdType, digits.slice(TAX_ID_START, NUMBER_START)); - const numberDigits = digits.slice(NUMBER_START, YEAR_START); - const month = Number(digits.slice(MONTH_START, CODE_START)); - - if (taxId === null || numberDigits === ABSENT_NUMBER || month < 1 || month > 12) return null; - - const checkDigit = Number(digits[CHECK_DIGIT_INDEX]); - - if (mod11(digits.slice(0, CHECK_DIGIT_INDEX), { variant: "arrecadacao" }) !== checkDigit) { - return null; - } + const taxIdStart = taxIdType === "cpf" ? TAX_ID_START + CPF_PADDING.length : TAX_ID_START; return { municipalityCode: digits.slice(0, GENERATOR_ENVIRONMENT_INDEX), - stateCode, - generatorEnvironment, + stateCode: IBGE_UF_CODES[digits.slice(0, 2)], + generatorEnvironment: GENERATOR_ENVIRONMENTS[Number(digits[GENERATOR_ENVIRONMENT_INDEX]) - 1], taxIdType, - taxId, - number: Number(numberDigits), + taxId: digits.slice(taxIdStart, NUMBER_START), + number: Number(digits.slice(NUMBER_START, YEAR_START)), year: 2000 + Number(digits.slice(YEAR_START, MONTH_START)), - month, + month: Number(digits.slice(MONTH_START, CODE_START)), code: digits.slice(CODE_START, CHECK_DIGIT_INDEX), - checkDigit, + checkDigit: Number(digits[CHECK_DIGIT_INDEX]), }; }; diff --git a/src/is-valid-nfse-key/is-valid-nfse-key.ts b/src/is-valid-nfse-key/is-valid-nfse-key.ts index eb7c5e423..ffcfe2216 100644 --- a/src/is-valid-nfse-key/is-valid-nfse-key.ts +++ b/src/is-valid-nfse-key/is-valid-nfse-key.ts @@ -1,4 +1,40 @@ -import { getNfseKeyInfo } from "../get-nfse-key-info/get-nfse-key-info"; +import { IBGE_UF_CODES } from "../_internals/constants/ibge-uf-codes"; +import { + ABSENT_NUMBER, + CHECK_DIGIT_INDEX, + CODE_START, + CPF_PADDING, + FORMAT_REGEX, + GENERATOR_ENVIRONMENTS, + GENERATOR_ENVIRONMENT_INDEX, + MONTH_START, + NUMBER_START, + TAX_ID_START, + TAX_ID_TYPES, + TAX_ID_TYPE_INDEX, + YEAR_START, +} from "../_internals/constants/nfse-key"; +import { mod11 } from "../_internals/mod11/mod11"; +import { isValidCnpj } from "../is-valid-cnpj/is-valid-cnpj"; +import { isValidCpf } from "../is-valid-cpf/is-valid-cpf"; + +/** + * Checks the "Inscrição Federal" against its "Tipo de Inscrição Federal" digit: a CNPJ as it is, + * a CPF behind its `000` padding, and anything else (an unknown type) rejected. + * + * @param {string} typeDigit - The "Tipo de Inscrição Federal" digit of the key. + * @param {string} registration - The 14 positions of the "Inscrição Federal". + * @returns {boolean} True if the registration is a valid CPF or CNPJ of that type. + */ +const isValidTaxId = (typeDigit: string, registration: string): boolean => { + const taxIdType = TAX_ID_TYPES[typeDigit]; + if (taxIdType === "cnpj") return isValidCnpj(registration); + return ( + taxIdType === "cpf" && + registration.startsWith(CPF_PADDING) && + isValidCpf(registration.slice(CPF_PADDING.length)) + ); +}; /** * Validates the access key (chave de acesso) of a national NFS-e, the Nota Fiscal de Serviço @@ -42,4 +78,25 @@ import { getNfseKeyInfo } from "../get-nfse-key-info/get-nfse-key-info"; * isValidNfseKey("3550308 2 2 58716523000119 0000000000012 2601 135792468 3"); // false (no mask) * ``` */ -export const isValidNfseKey = (value: string): boolean => getNfseKeyInfo(value) !== null; +export const isValidNfseKey = (value: string): boolean => { + if (typeof value !== "string") return false; + + const match = FORMAT_REGEX.exec(value.trim()); + + if (match === null) return false; + + const [, digits] = match; + const ambGer = Number(digits[GENERATOR_ENVIRONMENT_INDEX]); + const month = Number(digits.slice(MONTH_START, CODE_START)); + + return ( + IBGE_UF_CODES[digits.slice(0, 2)] !== undefined && + GENERATOR_ENVIRONMENTS.some((candidate) => candidate === ambGer) && + isValidTaxId(digits[TAX_ID_TYPE_INDEX], digits.slice(TAX_ID_START, NUMBER_START)) && + digits.slice(NUMBER_START, YEAR_START) !== ABSENT_NUMBER && + month >= 1 && + month <= 12 && + mod11(digits.slice(0, CHECK_DIGIT_INDEX), { variant: "arrecadacao" }) === + Number(digits[CHECK_DIGIT_INDEX]) + ); +}; From 2d0472be50178330f7b27e753e7c54b4d36f13ed Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 18:49:21 +0000 Subject: [PATCH 4/8] refactor(nfse): parseNfseKey reads the key length from the shared constants src/parse-nfse-key/constants.ts kept its own LENGTH = 50 after the key constants moved to src/_internals/constants/nfse-key.ts, which already has NFSE_KEY_LENGTH. parseNfseKey now imports that one, as parseNfeKey does with NFE_KEY_LENGTH; its single-import size is unchanged (1003 B). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RLkm9YrtAifc6XCLFVEsdH --- src/parse-nfse-key/constants.ts | 2 -- src/parse-nfse-key/parse-nfse-key.ts | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) delete mode 100644 src/parse-nfse-key/constants.ts diff --git a/src/parse-nfse-key/constants.ts b/src/parse-nfse-key/constants.ts deleted file mode 100644 index c1e4ac265..000000000 --- a/src/parse-nfse-key/constants.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Digits of a national NFS-e access key (chave de acesso), type `TSChaveNFSe` of the leiaute. */ -export const LENGTH = 50; diff --git a/src/parse-nfse-key/parse-nfse-key.ts b/src/parse-nfse-key/parse-nfse-key.ts index 262b46d74..17659d4e4 100644 --- a/src/parse-nfse-key/parse-nfse-key.ts +++ b/src/parse-nfse-key/parse-nfse-key.ts @@ -1,6 +1,6 @@ +import { NFSE_KEY_LENGTH } from "../_internals/constants/nfse-key"; import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -import { LENGTH } from "./constants"; /** * Removes everything but the digits from the access key (chave de acesso) of a national NFS-e. @@ -33,4 +33,4 @@ import { LENGTH } from "./constants"; * digits. */ export const parseNfseKey = (value: string | number): string => - isNullish(value) ? "" : sanitizeToDigits(value).slice(0, LENGTH); + isNullish(value) ? "" : sanitizeToDigits(value).slice(0, NFSE_KEY_LENGTH); From fc2bf15d6cbec9d40cba5cb2e9b40babf5a593ef Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 23:10:32 +0000 Subject: [PATCH 5/8] fix(nfse-key): read a number only when it is a non-negative safe integer parseNfseKey accepted `string | number` and read a number as the string of its digits, sign and decimal point included: the sign and the decimal point were dropped like mask characters and what was left was read as a code the number never was. `parseNfseKey(-1)` gave "1" and `parseNfseKey(1e21)` gave "121". The first guard is now `isLookupCode`, as in the validators and lookups, so a number is only read when it is a non-negative safe integer. Any other number (negative, fractional, not finite or past `Number.MAX_SAFE_INTEGER`) gives the empty string this function already returns for null, so each example above now gives "". In a string, "-" and "." are still mask characters: every string and every non-negative safe integer is read as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RLkm9YrtAifc6XCLFVEsdH --- src/parse-nfse-key/parse-nfse-key.test.ts | 11 +++++++++++ src/parse-nfse-key/parse-nfse-key.ts | 8 ++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/parse-nfse-key/parse-nfse-key.test.ts b/src/parse-nfse-key/parse-nfse-key.test.ts index 6fef0845b..ea99bba39 100644 --- a/src/parse-nfse-key/parse-nfse-key.test.ts +++ b/src/parse-nfse-key/parse-nfse-key.test.ts @@ -61,6 +61,17 @@ describe("parseNfseKey", () => { expectAlwaysReturnsType(parseNfseKey, "string", anyValue); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(parseNfseKey(-1)).toBe(""); + expect(parseNfseKey(1.5)).toBe(""); + expect(parseNfseKey(2 ** 53)).toBe(""); + expect(parseNfseKey(Number.MAX_VALUE)).toBe(""); + expect(parseNfseKey(1e21)).toBe(""); + expect(parseNfseKey(Number.NaN)).toBe(""); + expect(parseNfseKey(Number.POSITIVE_INFINITY)).toBe(""); + expect(parseNfseKey(Number.NEGATIVE_INFINITY)).toBe(""); + }); }); describe("parseNfseKey types", () => { diff --git a/src/parse-nfse-key/parse-nfse-key.ts b/src/parse-nfse-key/parse-nfse-key.ts index 17659d4e4..e8ac18b85 100644 --- a/src/parse-nfse-key/parse-nfse-key.ts +++ b/src/parse-nfse-key/parse-nfse-key.ts @@ -1,5 +1,5 @@ import { NFSE_KEY_LENGTH } from "../_internals/constants/nfse-key"; -import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; /** @@ -13,6 +13,9 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d * The result is capped at the 50 digits of an access key; a shorter value passes through as far * as it goes. Use `isValidNfseKey` to check the key and `getNfseKeyInfo` to read its fields. * + * A number is only read when it is a non-negative safe integer; any other number (negative, + * fractional, not finite or past `Number.MAX_SAFE_INTEGER`) gives an empty string. + * * @param {string|number} value - The access key value to be parsed. * @returns {string} Up to 50 digits, or an empty string when there is no digit at all. * @@ -23,6 +26,7 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d * * parseNfseKey("3550308 2 2 58716523000119 0000000000012 2601 135792468 3"); * // "35503082258716523000119000000000001226011357924683" + * parseNfseKey(-1); // "" (not a non-negative safe integer) * ``` * * @see Official: https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual @@ -33,4 +37,4 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d * digits. */ export const parseNfseKey = (value: string | number): string => - isNullish(value) ? "" : sanitizeToDigits(value).slice(0, NFSE_KEY_LENGTH); + isLookupCode(value) ? sanitizeToDigits(value).slice(0, NFSE_KEY_LENGTH) : ""; From 50777d62ae24051965298f992c160e0522becbf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 01:01:48 +0000 Subject: [PATCH 6/8] fix(nfse-key): accept the alphanumeric CNPJ in the national NFS-e key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isValidNfseKey and getNfseKeyInfo rejected every key whose issuer has an alphanumeric CNPJ, on the ground that no official document said how a letter enters the key. The CNPJ alfanumérico is in production in the Sistema Nacional NFS-e since 10/08/2026, so every such issuer's real keys were rejected. The official schema bundle NFSe-ESQUEMAS_XSD v1.01-20260727, published for the restricted production environment ("os novos schemas XML atualizados para o CNPJ Alfanumérico"), types the key with letters in the registration: TSIdNFSe NFS[0-9]{9}[0-9A-Z]{14}[0-9]{27} TSIdDPS DPS[0-9]{7}(1[0-9]{14}|2[0-9A-Z]{14})[0-9]{20} TSIdPedRegEvt PRE[0-9]{8}(1[0-9]{14}|2[0-9A-Z]{14})[0-9]{33} TSChaveNFSe [0-9]{6}([0-9A-Z]{14})[0-9]{30} The key is now read with `[0-9A-Z]` in the 14 positions of the Inscrição Federal (10 to 23), as TSIdNFSe puts them. TSChaveNFSe puts its letter window at positions 7 to 20, which contradicts the key structure (cMun 7, ambGer 1, tpInsc 1, inscrição 14, nNFSe 13, AAMM 4, cód 9, DV 1), TSIdNFSe, and the DPS and event ids, and would reject a CNPJ with a letter in its 12th position; that official inconsistency is documented and not followed. Letters are accepted only when the registration type is 2 (CNPJ), which is checked with isValidCnpj(value, { version: 2 }), as TSIdDPS and TSIdPedRegEvt tie them to type 2; a CPF (type 1) stays digits only. Lower case input is read in upper case, as isValidCnpj with version 2 reads it, and getNfseKeyInfo returns `taxId` upper cased. No NFS-e document states how a letter enters the modulus 11 check digit. By analogy with Nota Técnica Conjunta 2025.001 for the DF-e key ("O cálculo do DV da chave de acesso deverá aplicar a mesma lógica da validação do CNPJ Alfa, trocando todos os caracteres [...] pelos números correspondentes da tabela ASCII subtraindo 48") and with the Receita Federal rule for the CNPJ's own check digits, each character counts as its ASCII code minus 48 (A = 17). The JSDoc and both docs say it is by analogy. Test vector, built by hand from the Receita Federal example CNPJ 12.ABC.345/01DE-35 (its own DVs 3 and 5 recomputed), cMun 3550308, ambGer 2, nNFSe 12, 2026-09, code 135792468: weighted sum 1164, remainder 9, DV 2. 35503082212ABC34501DE35000000000001226091357924682 before: isValidNfseKey false, getNfseKeyInfo null after: true, { taxIdType: "cnpj", taxId: "12ABC34501DE35", ... } Still rejected: the same key with the DV that reads A as 10 (DV 0), a CNPJ with wrong DVs of its own (…DE36…), letters under registration type 1, and letters outside the registration (nNFSe, cMun), each with a matching key check digit. Changed test: the getNfseKeyInfo case "when the CNPJ is alphanumeric, which no official document gives a check digit rule for" is replaced by cases with a stated reason (wrong CNPJ DVs, letters under type 1); the property test's check digit oracle now uses charCode - 48 and its issuers include the alphanumeric CNPJ. These utils are not in 2.4.0; they are unreleased (PR #565). Sources: the schemas were read directly through the mirror github.com/fm-s/open-nfse (schemas/1.01, byte-pinned to the official Produção Restrita zip), whose standards log quotes the official "Atualizações e Implantações" page ("CNPJ alfanumérico em produção desde 10/08/2026"). gov.br and nfe.fazenda.gov.br are blocked by the proxy: the Portal NFS-e news of 2026-07-27 and the text of NT Conjunta 2025.001 were read through search result snippets only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RLkm9YrtAifc6XCLFVEsdH --- docs/pt-br/utilities.md | 19 ++++-- docs/utilities.md | 19 ++++-- src/_internals/constants/nfse-key.ts | 29 +++++---- .../get-nfse-key-info.test.ts | 32 ++++++++- src/get-nfse-key-info/get-nfse-key-info.ts | 51 +++++++++------ .../is-valid-nfse-key.test.ts | 32 +++++++++ src/is-valid-nfse-key/is-valid-nfse-key.ts | 65 +++++++++++++------ 7 files changed, 179 insertions(+), 68 deletions(-) diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index cf3cbdccb..5576a2c89 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -640,14 +640,14 @@ getNfeKeyInfo('invalid'); // null Verifica se a chave de acesso de uma NFS-e nacional, a Nota Fiscal de Serviço eletrônica do Sistema Nacional NFS-e, é válida. -- A chave é um bloco único de 50 dígitos, `Cód.Mun.(7) Amb.Ger.(1) Tipo de Inscrição Federal(1) Inscrição Federal(14) nNFSe(13) AAMM(4) Cód.Num.(9) DV(1)`. +- A chave é um bloco único de 50 caracteres, `Cód.Mun.(7) Amb.Ger.(1) Tipo de Inscrição Federal(1) Inscrição Federal(14) nNFSe(13) AAMM(4) Cód.Num.(9) DV(1)`, todos dígitos exceto um CNPJ alfanumérico na Inscrição Federal. - O literal `NFS` que o atributo `Id` de `infNFSe` coloca antes da chave é retirado, junto com os espaços nas extremidades. -- A chave não tem máscara impressa, já que o DANFSe a imprime em um único bloco de 50 dígitos, então, diferente do `isValidNfeKey`, um separador em qualquer ponto dela é rejeitado em vez de removido. +- A chave não tem máscara impressa, já que o DANFSe a imprime em um único bloco, então, diferente do `isValidNfeKey`, um separador em qualquer ponto dela é rejeitado em vez de removido. - O código do município precisa começar com um código IBGE de UF; ele não é consultado na tabela do IBGE. -- O `ambGer` precisa ser `1` (o sistema do município) ou `2` (o Sistema Nacional NFS-e), e o tipo de inscrição `1` (um CPF, preenchido com `000` à esquerda) ou `2` (um CNPJ), com um CPF ou CNPJ cujos próprios dígitos verificadores sejam válidos. +- O `ambGer` precisa ser `1` (o sistema do município) ou `2` (o Sistema Nacional NFS-e), e o tipo de inscrição `1` (um CPF, preenchido com `000` à esquerda) ou `2` (um CNPJ, numérico ou alfanumérico), com um CPF ou CNPJ cujos próprios dígitos verificadores sejam válidos. Letras só são aceitas em um CNPJ, e minúsculas são lidas como maiúsculas, como o `isValidCnpj` com `{ version: 2 }` as lê. - O `nNFSe` não pode ser todo de zeros e o mês precisa estar entre 01 e 12. -- O dígito verificador é um módulo 11 sobre os 49 primeiros dígitos, pesos de 2 a 9 ciclando a partir da direita, em que resto 0 ou 1 dá 0. -- Chaves com CNPJ alfanumérico ainda não são aceitas: nenhum documento oficial diz como uma letra entra no dígito verificador da chave. +- O dígito verificador é um módulo 11 sobre os 49 primeiros caracteres, pesos de 2 a 9 ciclando a partir da direita, em que resto 0 ou 1 dá 0. Uma letra vale o seu código ASCII menos 48 (`A` vale 17): nenhum documento da NFS-e diz isso, então a regra vem por analogia com a chave da NF-e da Nota Técnica Conjunta 2025.001 e com os próprios dígitos verificadores do CNPJ. +- As letras seguem o `TSIdNFSe` do pacote de esquemas de 27/07/2026, nas posições da Inscrição Federal (10 a 23). O `TSChaveNFSe` do mesmo pacote as coloca nas posições 7 a 20, o que contradiz a estrutura da chave, e não é seguido. - Os modelos municipais de NFS-e que não são o padrão nacional estão fora do escopo. ```javascript @@ -656,6 +656,7 @@ import { isValidNfseKey } from '@brazilian-utils/brazilian-utils'; isValidNfseKey('35503082258716523000119000000000001226011357924683'); // true (emitente com CNPJ, SP) isValidNfseKey('NFS35503082258716523000119000000000001226011357924683'); // true (prefixo Id do XML) isValidNfseKey('43149021100040364478829000000000105725120484407255'); // true (emitente com CPF, RS) +isValidNfseKey('35503082212ABC34501DE35000000000001226091357924682'); // true (emitente com CNPJ alfanumérico) isValidNfseKey('35503082258716523000119000000000001226011357924684'); // false (dígito verificador) isValidNfseKey('3550308 2 2 58716523000119 0000000000012 2601 135792468 3'); // false (a chave não tem máscara) ``` @@ -682,7 +683,7 @@ Interpreta a chave de acesso de uma NFS-e nacional e retorna seus campos, como u - Retorna `municipalityCode`, `stateCode`, `generatorEnvironment`, `taxIdType`, `taxId`, `number`, `year`, `month`, `code` e `checkDigit`. - O `generatorEnvironment` é um `NfseKeyGeneratorEnvironment`: `1` o sistema do município, `2` o Sistema Nacional NFS-e. -- O `taxIdType` é um `NfseKeyTaxIdType`, `'cpf'` ou `'cnpj'`, e o `taxId` é o CPF de 11 dígitos, sem o `000` que o preenche na chave, ou o CNPJ de 14 dígitos. +- O `taxIdType` é um `NfseKeyTaxIdType`, `'cpf'` ou `'cnpj'`, e o `taxId` é o CPF de 11 dígitos, sem o `000` que o preenche na chave, ou o CNPJ de 14 caracteres, numérico ou alfanumérico, em maiúsculas. - Retorna `null` quando a chave não é válida. ```javascript @@ -696,10 +697,14 @@ getNfseKeyInfo('43149021100040364478829000000000105725120484407255'); // { municipalityCode: '4314902', stateCode: 'RS', generatorEnvironment: 1, taxIdType: 'cpf', // taxId: '40364478829', number: 1057, year: 2025, month: 12, code: '048440725', checkDigit: 5 } +getNfseKeyInfo('35503082212ABC34501DE35000000000001226091357924682'); +// { municipalityCode: '3550308', stateCode: 'SP', generatorEnvironment: 2, taxIdType: 'cnpj', +// taxId: '12ABC34501DE35', number: 12, year: 2026, month: 9, code: '135792468', checkDigit: 2 } + getNfseKeyInfo('invalid'); // null ``` -Fonte: a [documentação técnica do Sistema Nacional NFS-e](https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual), cujos tipos de esquema `TSIdNFSe` e `TSChaveNFSe` e o campo `NFSe/infNFSe/id` do ANEXO I definem o leiaute e as regras E1280 e E1284, o [manual da emissão por decisão administrativa ou judicial](https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual/manual-contribuintes-emissor-publico-api-emissao-decisao-administrativa-e-judicial.pdf), que nomeia o dígito verificador de módulo 11, e a [Nota Técnica SE/CGNFS-e 008](https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/rtc/nt-008-se-cgnfse-danfse-20260714-v1-02.pdf) (item 2.1.1), que imprime a chave em bloco único. +Fonte: a [documentação técnica do Sistema Nacional NFS-e](https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual), cujos tipos de esquema `TSIdNFSe` e `TSChaveNFSe` e o campo `NFSe/infNFSe/id` do ANEXO I definem o leiaute e as regras E1280 e E1284, o [manual da emissão por decisão administrativa ou judicial](https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual/manual-contribuintes-emissor-publico-api-emissao-decisao-administrativa-e-judicial.pdf), que nomeia o dígito verificador de módulo 11, a [Nota Técnica SE/CGNFS-e 008](https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/rtc/nt-008-se-cgnfse-danfse-20260714-v1-02.pdf) (item 2.1.1), que imprime a chave em bloco único, os [esquemas atualizados para o CNPJ alfanumérico](https://www.gov.br/nfse/pt-br/noticias/plataforma-nfs-e-disponibiliza-novas-evolucoes-em-producao-restrita-e-divulga-cronograma-de-implantacao) (pacote v1.01-20260727, em produção desde 10/08/2026) e a [Nota Técnica Conjunta 2025.001](https://www.nfe.fazenda.gov.br/portal/exibirArquivo.aspx?conteudo=5ZkvIZt10mQ=), cuja regra de ASCII menos 48 da chave da NF-e o dígito verificador empresta. ## SUFRAMA diff --git a/docs/utilities.md b/docs/utilities.md index 1d2bb1b2e..2fe690e51 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -640,14 +640,14 @@ getNfeKeyInfo('invalid'); // null Check if the access key (chave de acesso) of a national NFS-e, the Nota Fiscal de Serviço eletrônica of the Sistema Nacional NFS-e, is valid. -- The key is one block of 50 digits, `Cód.Mun.(7) Amb.Ger.(1) Tipo de Inscrição Federal(1) Inscrição Federal(14) nNFSe(13) AAMM(4) Cód.Num.(9) DV(1)`. +- The key is one block of 50 characters, `Cód.Mun.(7) Amb.Ger.(1) Tipo de Inscrição Federal(1) Inscrição Federal(14) nNFSe(13) AAMM(4) Cód.Num.(9) DV(1)`, all digits except an alphanumeric CNPJ in the Inscrição Federal. - The `NFS` literal the `Id` attribute of `infNFSe` puts in front of the key is stripped, with surrounding whitespace. -- The key has no printed mask, since the DANFSe prints it as a single block of 50 digits, so, unlike `isValidNfeKey`, a separator anywhere in it is rejected instead of being stripped. +- The key has no printed mask, since the DANFSe prints it as a single block, so, unlike `isValidNfeKey`, a separator anywhere in it is rejected instead of being stripped. - The municipality code must start with an IBGE UF code; it is not looked up in the IBGE table. -- `ambGer` must be `1` (the system of the municipality) or `2` (the Sistema Nacional NFS-e), and the registration type `1` (a CPF, left padded with `000`) or `2` (a CNPJ), with a CPF or CNPJ whose own check digits are valid. +- `ambGer` must be `1` (the system of the municipality) or `2` (the Sistema Nacional NFS-e), and the registration type `1` (a CPF, left padded with `000`) or `2` (a CNPJ, numeric or alphanumeric), with a CPF or CNPJ whose own check digits are valid. Letters are accepted in a CNPJ only, and lower case is read as upper case, as `isValidCnpj` with `{ version: 2 }` reads it. - `nNFSe` must not be all zeros and the month must be 01 to 12. -- The check digit is a modulus 11 over the first 49 digits, weights 2 to 9 cycling from the right, where a remainder of 0 or 1 gives 0. -- Keys carrying an alphanumeric CNPJ are not accepted yet: no official document states how a letter enters the check digit of the key. +- The check digit is a modulus 11 over the first 49 characters, weights 2 to 9 cycling from the right, where a remainder of 0 or 1 gives 0. A letter counts as its ASCII code minus 48 (`A` is 17): no NFS-e document states it, so it is taken by analogy with the NF-e key of Nota Técnica Conjunta 2025.001 and the CNPJ's own check digits. +- The letters follow `TSIdNFSe` of the schema bundle of 2026-07-27, in the positions of the Inscrição Federal (10 to 23). `TSChaveNFSe` of the same bundle puts them at positions 7 to 20, which contradicts the structure of the key, and is not followed. - The municipal NFS-e models that are not the national standard are out of scope. ```javascript @@ -656,6 +656,7 @@ import { isValidNfseKey } from '@brazilian-utils/brazilian-utils'; isValidNfseKey('35503082258716523000119000000000001226011357924683'); // true (CNPJ issuer, SP) isValidNfseKey('NFS35503082258716523000119000000000001226011357924683'); // true (XML Id prefix) isValidNfseKey('43149021100040364478829000000000105725120484407255'); // true (CPF issuer, RS) +isValidNfseKey('35503082212ABC34501DE35000000000001226091357924682'); // true (alphanumeric CNPJ issuer) isValidNfseKey('35503082258716523000119000000000001226011357924684'); // false (check digit) isValidNfseKey('3550308 2 2 58716523000119 0000000000012 2601 135792468 3'); // false (the key has no mask) ``` @@ -682,7 +683,7 @@ Parse the access key of a national NFS-e into its fields, as an `NfseKeyInfo`. A - Returns `municipalityCode`, `stateCode`, `generatorEnvironment`, `taxIdType`, `taxId`, `number`, `year`, `month`, `code` and `checkDigit`. - `generatorEnvironment` is an `NfseKeyGeneratorEnvironment`: `1` the system of the municipality, `2` the Sistema Nacional NFS-e. -- `taxIdType` is an `NfseKeyTaxIdType`, `'cpf'` or `'cnpj'`, and `taxId` is the 11 digit CPF, without the `000` that pads it in the key, or the 14 digit CNPJ. +- `taxIdType` is an `NfseKeyTaxIdType`, `'cpf'` or `'cnpj'`, and `taxId` is the 11 digit CPF, without the `000` that pads it in the key, or the 14 character CNPJ, numeric or alphanumeric, in upper case. - Returns `null` when the key is not valid. ```javascript @@ -696,10 +697,14 @@ getNfseKeyInfo('43149021100040364478829000000000105725120484407255'); // { municipalityCode: '4314902', stateCode: 'RS', generatorEnvironment: 1, taxIdType: 'cpf', // taxId: '40364478829', number: 1057, year: 2025, month: 12, code: '048440725', checkDigit: 5 } +getNfseKeyInfo('35503082212ABC34501DE35000000000001226091357924682'); +// { municipalityCode: '3550308', stateCode: 'SP', generatorEnvironment: 2, taxIdType: 'cnpj', +// taxId: '12ABC34501DE35', number: 12, year: 2026, month: 9, code: '135792468', checkDigit: 2 } + getNfseKeyInfo('invalid'); // null ``` -Source: the [technical documentation of the Sistema Nacional NFS-e](https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual), whose schema types `TSIdNFSe` and `TSChaveNFSe` and ANEXO I field `NFSe/infNFSe/id` define the layout and the rules E1280 and E1284, the [manual da emissão por decisão administrativa ou judicial](https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual/manual-contribuintes-emissor-publico-api-emissao-decisao-administrativa-e-judicial.pdf), which names the modulus 11 check digit, and [Nota Técnica SE/CGNFS-e 008](https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/rtc/nt-008-se-cgnfse-danfse-20260714-v1-02.pdf) (item 2.1.1), which prints the key as a single block. +Source: the [technical documentation of the Sistema Nacional NFS-e](https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual), whose schema types `TSIdNFSe` and `TSChaveNFSe` and ANEXO I field `NFSe/infNFSe/id` define the layout and the rules E1280 and E1284, the [manual da emissão por decisão administrativa ou judicial](https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual/manual-contribuintes-emissor-publico-api-emissao-decisao-administrativa-e-judicial.pdf), which names the modulus 11 check digit, [Nota Técnica SE/CGNFS-e 008](https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/rtc/nt-008-se-cgnfse-danfse-20260714-v1-02.pdf) (item 2.1.1), which prints the key as a single block, the [schemas updated for the alphanumeric CNPJ](https://www.gov.br/nfse/pt-br/noticias/plataforma-nfs-e-disponibiliza-novas-evolucoes-em-producao-restrita-e-divulga-cronograma-de-implantacao) (bundle v1.01-20260727, in production since 2026-08-10) and [Nota Técnica Conjunta 2025.001](https://www.nfe.fazenda.gov.br/portal/exibirArquivo.aspx?conteudo=5ZkvIZt10mQ=), whose ASCII minus 48 rule for the NF-e key the check digit borrows. ## SUFRAMA diff --git a/src/_internals/constants/nfse-key.ts b/src/_internals/constants/nfse-key.ts index dae479331..046f6b603 100644 --- a/src/_internals/constants/nfse-key.ts +++ b/src/_internals/constants/nfse-key.ts @@ -1,15 +1,22 @@ /** - * Shape of the key: one block of 50 digits, the pattern of `TSChaveNFSe` in - * `tiposSimples_v1.01.xsd`, optionally behind the `NFS` literal the `Id` attribute of `infNFSe` - * puts in front of it (`TSIdNFSe`). The DANFSe prints the key the same way ("em único bloco - * contendo 50 dígitos", Nota Técnica SE/CGNFS-e 008, item 2.1.1), so there is no mask to accept. - * The digits are the first capture group. + * Shape of the key: 50 characters, all digits but the 14 of the "Inscrição Federal", which may + * also be the upper case letters of an alphanumeric CNPJ, optionally behind the `NFS` literal the + * `Id` attribute of `infNFSe` puts in front of it. It follows `TSIdNFSe` of + * `tiposSimples_v1.01.xsd` (bundle 20260727), `NFS[0-9]{9}[0-9A-Z]{14}[0-9]{27}`, whose letter + * window is the registration of the key structure. `TSChaveNFSe` of the same file, + * `[0-9]{6}([0-9A-Z]{14})[0-9]{30}`, puts the window at positions 7 to 20 instead, which + * contradicts that structure (the registration is at 10 to 23), `TSIdNFSe`, `TSIdDPS` and + * `TSIdPedRegEvt`, so it is not followed. The key has no printed mask (the DANFSe prints it "em + * único bloco", Nota Técnica SE/CGNFS-e 008, item 2.1.1), so there is no separator to accept. + * Case-insensitive, as `isValidCnpj` with version 2 is: the callers upper case the key, which is + * the first capture group. Whether a letter may stand in the registration at all is left to the + * registration type, since only a CNPJ (type 2) can carry one. */ -export const FORMAT_REGEX = /^(?:nfs)?(\d{50})$/i; +export const FORMAT_REGEX = /^(?:nfs)?(\d{9}[\dA-Z]{14}\d{27})$/i; /** - * Digits of the key, type `TSChaveNFSe` of the leiaute. Once `isValidNfseKey` accepts a value, - * they are the last 50 characters of the trimmed value, after the optional `NFS` prefix. + * Characters of the key, type `TSChaveNFSe` of the leiaute. Once `isValidNfseKey` accepts a + * value, they are the last 50 characters of the trimmed value, after the optional `NFS` prefix. */ export const NFSE_KEY_LENGTH = 50; @@ -35,13 +42,13 @@ export const CPF_PADDING = "000"; */ export const ABSENT_NUMBER = "0000000000000"; -/** Position of `ambGer` inside the 50 digit key. */ +/** Position of `ambGer` inside the 50 character key. */ export const GENERATOR_ENVIRONMENT_INDEX = 7; -/** Position of the "Tipo de Inscrição Federal" inside the 50 digit key. */ +/** Position of the "Tipo de Inscrição Federal" inside the 50 character key. */ export const TAX_ID_TYPE_INDEX = 8; -/** Start of the "Inscrição Federal" inside the 50 digit key. */ +/** Start of the "Inscrição Federal" inside the 50 character key. */ export const TAX_ID_START = 9; /** Start of the NFS-e number (`nNFSe`), which is also the end of the "Inscrição Federal". */ diff --git a/src/get-nfse-key-info/get-nfse-key-info.test.ts b/src/get-nfse-key-info/get-nfse-key-info.test.ts index 059e5a8b7..b427be2f8 100644 --- a/src/get-nfse-key-info/get-nfse-key-info.test.ts +++ b/src/get-nfse-key-info/get-nfse-key-info.test.ts @@ -22,6 +22,7 @@ const ISSUERS: { type: string; registration: string; taxIdType: string; taxId: s { type: "1", registration: "00040364478829", taxIdType: "cpf", taxId: "40364478829" }, { type: "2", registration: "58716523000119", taxIdType: "cnpj", taxId: "58716523000119" }, { type: "2", registration: "00000000000191", taxIdType: "cnpj", taxId: "00000000000191" }, + { type: "2", registration: "12ABC34501DE35", taxIdType: "cnpj", taxId: "12ABC34501DE35" }, ]; const expectedCheckDigit = (body: string): number => { @@ -29,7 +30,7 @@ const expectedCheckDigit = (body: string): number => { let weight = 2; for (let index = body.length - 1; index >= 0; index -= 1) { - sum += Number(body.charAt(index)) * weight; + sum += (body.charCodeAt(index) - 48) * weight; weight = weight === 9 ? 2 : weight + 1; } @@ -133,8 +134,12 @@ describe("getNfseKeyInfo", () => { expect(getNfseKeyInfo("35503082258716523000119000000000001226131357924684")).toBeNull(); }); - test("when the CNPJ is alphanumeric, which no official document gives a check digit rule for", () => { - expect(getNfseKeyInfo("355030822AB716523000119000000000001226011357924683")).toBeNull(); + test("when an alphanumeric CNPJ has wrong check digits of its own, even with a matching key check digit", () => { + expect(getNfseKeyInfo("35503082212ABC34501DE36000000000001226091357924689")).toBeNull(); + }); + + test("when the registration type says CPF and the registration carries letters, even with a matching key check digit", () => { + expect(getNfseKeyInfo("355030821000ABC4478829000000000001226091357924685")).toBeNull(); }); }); @@ -169,6 +174,27 @@ describe("getNfseKeyInfo", () => { }); }); + test("for a key of an alphanumeric CNPJ issuer (the Receita Federal example 12.ABC.345/01DE-35)", () => { + expect(getNfseKeyInfo("35503082212ABC34501DE35000000000001226091357924682")).toEqual({ + municipalityCode: "3550308", + stateCode: "SP", + generatorEnvironment: 2, + taxIdType: "cnpj", + taxId: "12ABC34501DE35", + number: 12, + year: 2026, + month: 9, + code: "135792468", + checkDigit: 2, + }); + }); + + test("upper casing the CNPJ of a key written in lower case", () => { + expect(getNfseKeyInfo("nfs35503082212abc34501de35000000000001226091357924682")?.taxId).toBe( + "12ABC34501DE35", + ); + }); + test("accepting the NFS prefix of the XML Id attribute, in any case, and surrounding whitespace", () => { expect(getNfseKeyInfo(`NFS${KEY_SP}`)?.number).toBe(12); expect(getNfseKeyInfo(`nfs${KEY_SP}`)?.number).toBe(12); diff --git a/src/get-nfse-key-info/get-nfse-key-info.ts b/src/get-nfse-key-info/get-nfse-key-info.ts index 05f3906a4..65c6516f8 100644 --- a/src/get-nfse-key-info/get-nfse-key-info.ts +++ b/src/get-nfse-key-info/get-nfse-key-info.ts @@ -39,7 +39,10 @@ export type NfseKeyInfo = { generatorEnvironment: NfseKeyGeneratorEnvironment; /** Whether the issuer is identified by a CPF or by a CNPJ. */ taxIdType: NfseKeyTaxIdType; - /** The 11 digit CPF (without the `000` padding of the key) or the 14 digit CNPJ of the issuer. */ + /** + * The 11 digit CPF (without the `000` padding of the key) or the 14 character CNPJ of the + * issuer, numeric or alphanumeric, its letters in upper case. + */ taxId: string; /** NFS-e number (`nNFSe`), sequential by issuer, 1 to 9999999999999. */ number: number; @@ -57,9 +60,10 @@ export type NfseKeyInfo = { * Parses the access key (chave de acesso) of a national NFS-e, the Nota Fiscal de Serviço * eletrônica of the Sistema Nacional NFS-e, into its fields. * - * The key is one block of 50 digits: + * The key is one block of 50 characters: * `Cód.Mun.(7) Amb.Ger.(1) Tipo de Inscrição Federal(1) Inscrição Federal(14) nNFSe(13) AAMM(4) - * Cód.Num.(9) DV(1)`. It is accepted as it is written in the documents (`chNFSe`, `chSubstda`, + * Cód.Num.(9) DV(1)`, all digits except an alphanumeric CNPJ in the "Inscrição Federal". It is + * accepted as it is written in the documents (`chNFSe`, `chSubstda`, * the DANFSe, which prints it as a single block) or with the `NFS` literal the `Id` attribute * of `infNFSe` puts in front of it, surrounding whitespace aside. The key has no printed mask, * so a separator anywhere in it is rejected instead of being stripped. The keys of the @@ -68,19 +72,23 @@ export type NfseKeyInfo = { * The key is checked by `isValidNfseKey`, and `null` comes back exactly when it returns false. * What it checks: the first two digits of the municipality code are an IBGE UF code, `ambGer` * is 1 or 2, the registration type is 1 (CPF, the 11 digits left padded with `000`) or 2 - * (CNPJ) and the CPF or CNPJ has valid check digits of its own (rules E1280 and E1284 of the + * (CNPJ, numeric or alphanumeric, as `isValidCnpj` with version 2 reads it) and the CPF or CNPJ has valid check digits of its own (rules E1280 and E1284 of the * ANEXO I reject an NFS-e whose issuer fails them), `nNFSe` is not all zeros, the month is 01 * to 12 and the check digit matches. The municipality code is not looked up in the IBGE table. * - * The check digit is a modulus 11 over the first 49 digits, weights 2 to 9 cycling from the + * The check digit is a modulus 11 over the first 49 characters, weights 2 to 9 cycling from the * right, where a remainder of 0 or 1 gives 0. The official text only says "algoritmo do módulo * 11"; the weights and the remainder rule are the ones of the DF-e access key and were * confirmed against more than a hundred NFS-e keys found in public repositories, generated by * both environments, remainders 0, 1 and 10 included. * - * Keys carrying an alphanumeric CNPJ are not accepted yet: the schema package published for the - * restricted production environment on 2026-07-27 widens `TSIdNFSe` to letters in the - * registration, but no official document states how a letter enters the check digit of the key. + * An alphanumeric CNPJ is accepted since the schema bundle of 2026-07-27 (in production since + * 2026-08-10) widened `TSIdNFSe` to letters in the registration. Lower case input is read in + * upper case, as `isValidCnpj` with version 2 reads it, and `taxId` comes back upper cased. No + * NFS-e document states how a letter enters the check digit of the key; by analogy with the + * NF-e key (Nota Técnica Conjunta 2025.001) and the CNPJ's own check digits, each character + * counts as its ASCII code minus 48 (`A` is 17). `isValidNfseKey` cites the sources, and why + * `TSChaveNFSe`, which puts the letter window at positions 7 to 20, is not followed. * * @param {string} value - The access key value to be parsed. * @returns {NfseKeyInfo | null} The parsed access key, or `null` when `isValidNfseKey` rejects it. @@ -108,26 +116,31 @@ export type NfseKeyInfo = { * // { municipalityCode: "3550308", stateCode: "SP", generatorEnvironment: 2, taxIdType: "cnpj", * // taxId: "58716523000119", number: 12, year: 2026, month: 1, code: "135792468", checkDigit: 3 } * + * getNfseKeyInfo("35503082212abc34501de35000000000001226091357924682"); + * // { municipalityCode: "3550308", stateCode: "SP", generatorEnvironment: 2, taxIdType: "cnpj", + * // taxId: "12ABC34501DE35", number: 12, year: 2026, month: 9, code: "135792468", + * // checkDigit: 2 } + * * getNfseKeyInfo("invalid"); // null * ``` */ export const getNfseKeyInfo = (value: string): NfseKeyInfo | null => { if (!isValidNfseKey(value)) return null; - const digits = value.trim().slice(-NFSE_KEY_LENGTH); - const taxIdType = TAX_ID_TYPES[digits[TAX_ID_TYPE_INDEX]]; + const key = value.trim().slice(-NFSE_KEY_LENGTH).toUpperCase(); + const taxIdType = TAX_ID_TYPES[key[TAX_ID_TYPE_INDEX]]; const taxIdStart = taxIdType === "cpf" ? TAX_ID_START + CPF_PADDING.length : TAX_ID_START; return { - municipalityCode: digits.slice(0, GENERATOR_ENVIRONMENT_INDEX), - stateCode: IBGE_UF_CODES[digits.slice(0, 2)], - generatorEnvironment: GENERATOR_ENVIRONMENTS[Number(digits[GENERATOR_ENVIRONMENT_INDEX]) - 1], + municipalityCode: key.slice(0, GENERATOR_ENVIRONMENT_INDEX), + stateCode: IBGE_UF_CODES[key.slice(0, 2)], + generatorEnvironment: GENERATOR_ENVIRONMENTS[Number(key[GENERATOR_ENVIRONMENT_INDEX]) - 1], taxIdType, - taxId: digits.slice(taxIdStart, NUMBER_START), - number: Number(digits.slice(NUMBER_START, YEAR_START)), - year: 2000 + Number(digits.slice(YEAR_START, MONTH_START)), - month: Number(digits.slice(MONTH_START, CODE_START)), - code: digits.slice(CODE_START, CHECK_DIGIT_INDEX), - checkDigit: Number(digits[CHECK_DIGIT_INDEX]), + taxId: key.slice(taxIdStart, NUMBER_START), + number: Number(key.slice(NUMBER_START, YEAR_START)), + year: 2000 + Number(key.slice(YEAR_START, MONTH_START)), + month: Number(key.slice(MONTH_START, CODE_START)), + code: key.slice(CODE_START, CHECK_DIGIT_INDEX), + checkDigit: Number(key[CHECK_DIGIT_INDEX]), }; }; diff --git a/src/is-valid-nfse-key/is-valid-nfse-key.test.ts b/src/is-valid-nfse-key/is-valid-nfse-key.test.ts index 94c6c3fc6..fb31cfe14 100644 --- a/src/is-valid-nfse-key/is-valid-nfse-key.test.ts +++ b/src/is-valid-nfse-key/is-valid-nfse-key.test.ts @@ -5,6 +5,10 @@ import { isValidNfseKey } from "./is-valid-nfse-key"; const KEY_CNPJ = "35503082258716523000119000000000001226011357924683"; const KEY_CPF = "43149021100040364478829000000000105725120484407255"; +// Receita Federal's example alphanumeric CNPJ 12.ABC.345/01DE-35, September 2026; the key check +// digit worked out by hand with every character worth its ASCII code minus 48 (sum 1164, +// remainder 9, DV 2). +const KEY_ALPHANUMERIC = "35503082212ABC34501DE35000000000001226091357924682"; describe("isValidNfseKey", () => { describe("should return true", () => { @@ -24,6 +28,19 @@ describe("isValidNfseKey", () => { test("when it has surrounding whitespace", () => { expect(isValidNfseKey(` ${KEY_CNPJ}\n`)).toBe(true); }); + + test("for a key of an alphanumeric CNPJ issuer, the letters worth their ASCII code minus 48", () => { + expect(isValidNfseKey(KEY_ALPHANUMERIC)).toBe(true); + expect(isValidNfseKey(`NFS${KEY_ALPHANUMERIC}`)).toBe(true); + }); + + test("for a key of an alphanumeric CNPJ written in lower case, read in upper case as isValidCnpj reads it", () => { + expect(isValidNfseKey(KEY_ALPHANUMERIC.toLowerCase())).toBe(true); + }); + + test("for an alphanumeric CNPJ key generated by the municipality, with the check digit recalculated", () => { + expect(isValidNfseKey("35503081212ABC34501DE35000000000001226091357924685")).toBe(true); + }); }); describe("should return false", () => { @@ -84,6 +101,21 @@ describe("isValidNfseKey", () => { test("when the issuer CNPJ has wrong check digits, even with a matching key check digit", () => { expect(isValidNfseKey("35503082258716523000110000000000001226011357924686")).toBe(false); + expect(isValidNfseKey("35503082212ABC34501DE36000000000001226091357924689")).toBe(false); + }); + + test("when the check digit of an alphanumeric key reads a letter as its base 36 value (A = 10) instead of its ASCII code minus 48", () => { + expect(isValidNfseKey("35503082212ABC34501DE35000000000001226091357924680")).toBe(false); + }); + + test("when the registration type says CPF and the registration carries letters, even with a matching key check digit", () => { + expect(isValidNfseKey("35503082112ABC34501DE35000000000001226091357924684")).toBe(false); + expect(isValidNfseKey("355030821000ABC4478829000000000001226091357924685")).toBe(false); + }); + + test("when a letter stands outside the registration, even with a matching key check digit", () => { + expect(isValidNfseKey("35503082212ABC34501DE3500000000000A226091357924686")).toBe(false); + expect(isValidNfseKey("355030A2258716523000119000000000001226011357924680")).toBe(false); }); }); diff --git a/src/is-valid-nfse-key/is-valid-nfse-key.ts b/src/is-valid-nfse-key/is-valid-nfse-key.ts index ffcfe2216..e04dd31d6 100644 --- a/src/is-valid-nfse-key/is-valid-nfse-key.ts +++ b/src/is-valid-nfse-key/is-valid-nfse-key.ts @@ -20,7 +20,9 @@ import { isValidCpf } from "../is-valid-cpf/is-valid-cpf"; /** * Checks the "Inscrição Federal" against its "Tipo de Inscrição Federal" digit: a CNPJ as it is, - * a CPF behind its `000` padding, and anything else (an unknown type) rejected. + * numeric or alphanumeric, a CPF behind its `000` padding, digits only, and anything else (an + * unknown type) rejected. A letter is only accepted in a CNPJ, as `TSIdDPS` and `TSIdPedRegEvt` + * state (`1[0-9]{14}|2[0-9A-Z]{14}`), since `isValidCpf` rejects any letter. * * @param {string} typeDigit - The "Tipo de Inscrição Federal" digit of the key. * @param {string} registration - The 14 positions of the "Inscrição Federal". @@ -28,7 +30,7 @@ import { isValidCpf } from "../is-valid-cpf/is-valid-cpf"; */ const isValidTaxId = (typeDigit: string, registration: string): boolean => { const taxIdType = TAX_ID_TYPES[typeDigit]; - if (taxIdType === "cnpj") return isValidCnpj(registration); + if (taxIdType === "cnpj") return isValidCnpj(registration, { version: 2 }); return ( taxIdType === "cpf" && registration.startsWith(CPF_PADDING) && @@ -40,23 +42,26 @@ const isValidTaxId = (typeDigit: string, registration: string): boolean => { * Validates the access key (chave de acesso) of a national NFS-e, the Nota Fiscal de Serviço * eletrônica of the Sistema Nacional NFS-e. * - * The key is one block of 50 digits: + * The key is one block of 50 characters: * `Cód.Mun.(7) Amb.Ger.(1) Tipo de Inscrição Federal(1) Inscrição Federal(14) nNFSe(13) AAMM(4) - * Cód.Num.(9) DV(1)`. The `NFS` literal the `Id` attribute of `infNFSe` puts in front of it is - * stripped, with surrounding whitespace. The key has no printed mask (the DANFSe prints it as a - * single block), so a separator anywhere in it is rejected instead of being stripped. The keys + * Cód.Num.(9) DV(1)`, all digits except an alphanumeric CNPJ in the "Inscrição Federal". The + * `NFS` literal the `Id` attribute of `infNFSe` puts in front of it is stripped, with + * surrounding whitespace. The key has no printed mask (the DANFSe prints it as a single block), so a separator anywhere in it is rejected instead of being stripped. The keys * of the municipal NFS-e models that are not the national standard are out of scope, and so is * the 44 digit DF-e key, which `isValidNfeKey` covers. * * The municipality code must start with an IBGE UF code, `ambGer` must be 1 (municipality) or 2 - * (Sistema Nacional NFS-e), the registration type 1 (CPF, left padded with `000`) or 2 (CNPJ) - * with a CPF or CNPJ whose own check digits are valid, `nNFSe` must not be all zeros and the - * month must be 01 to 12. The check digit (DV) is a modulus 11 over the first 49 digits, weights - * 2 to 9 cycling from the right, where a remainder of 0 or 1 gives 0. `getNfseKeyInfo` states - * what each rule is taken from. + * (Sistema Nacional NFS-e), the registration type 1 (CPF, left padded with `000`) or 2 (CNPJ, + * numeric or alphanumeric) with a CPF or CNPJ whose own check digits are valid, `nNFSe` must not + * be all zeros and the month must be 01 to 12. The check digit (DV) is a modulus 11 over the + * first 49 characters, weights 2 to 9 cycling from the right, where a remainder of 0 or 1 gives + * 0. `getNfseKeyInfo` states what each rule is taken from. * - * Keys carrying an alphanumeric CNPJ are not accepted yet, since no official document states how - * a letter enters the check digit of the key. + * An alphanumeric CNPJ is accepted since the schema bundle of 2026-07-27 widened the key to it + * (in production since 2026-08-10). Letters are read in upper case, lower case input included, + * as `isValidCnpj` with version 2 reads them. No NFS-e document states how a letter enters the + * check digit of the key, so it is taken by analogy with the NF-e key (NT Conjunta 2025.001) and + * the CNPJ's own check digits: each character counts as its ASCII code minus 48, `A` as 17. * * @param {string} value - The access key value to be validated. * @returns {boolean} True if the access key is valid, false otherwise. @@ -68,12 +73,30 @@ const isValidTaxId = (typeDigit: string, registration: string): boolean => { * @see Official: https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual/manual-contribuintes-emissor-publico-api-emissao-decisao-administrativa-e-judicial.pdf * Manual de Contribuintes, Emissão por Decisão Administrativa ou Judicial, field `id`: "O dígito * verificador deve ser calculado segundo o algoritmo do módulo 11". + * @see Official: https://www.gov.br/nfse/pt-br/noticias/plataforma-nfs-e-disponibiliza-novas-evolucoes-em-producao-restrita-e-divulga-cronograma-de-implantacao + * Portal NFS-e, 2026-07-27: the restricted production documentation "passa a disponibilizar [...] + * os novos schemas XML atualizados para o CNPJ Alfanumérico". That bundle, `NFSe-ESQUEMAS_XSD` + * v1.01-20260727, `tiposSimples_v1.01.xsd`: `TSIdNFSe` "NFS[0-9]{9}[0-9A-Z]{14}[0-9]{27}", `TSIdDPS` + * "DPS[0-9]{7}(1[0-9]{14}|2[0-9A-Z]{14})[0-9]{20}" and `TSIdPedRegEvt` + * "PRE[0-9]{8}(1[0-9]{14}|2[0-9A-Z]{14})[0-9]{33}". `TSChaveNFSe`, + * "[0-9]{6}([0-9A-Z]{14})[0-9]{30}", misplaces the letter window (positions 7 to 20 instead of + * the registration's 10 to 23) and is not followed. Read through the byte-pinned mirror + * https://github.com/fm-s/open-nfse (`schemas/1.01`), whose log of the official "Atualizações e + * Implantações" page reads "CNPJ alfanumérico em produção desde 10/08/2026". + * @see Official: https://www.gov.br/receitafederal/pt-br/centrais-de-conteudo/publicacoes/documentos-tecnicos/cnpj/manual-dv-cnpj.pdf + * Receita Federal, cálculo do DV do CNPJ alfanumérico: each character is worth its ASCII code + * minus 48, the value this key's check digit gives a letter by analogy. + * @see Official: https://www.nfe.fazenda.gov.br/portal/exibirArquivo.aspx?conteudo=5ZkvIZt10mQ= + * Nota Técnica Conjunta 2025.001 (CNPJ alfanumérico), for the 44 character DF-e key: "O cálculo + * do DV da chave de acesso deverá aplicar a mesma lógica da validação do CNPJ Alfa, trocando + * todos os caracteres [...] pelos números correspondentes da tabela ASCII subtraindo 48". * * @example * ```typescript * isValidNfseKey("35503082258716523000119000000000001226011357924683"); // true (CNPJ issuer, SP) * isValidNfseKey("NFS35503082258716523000119000000000001226011357924683"); // true (XML Id prefix) * isValidNfseKey("43149021100040364478829000000000105725120484407255"); // true (CPF issuer, RS) + * isValidNfseKey("35503082212ABC34501DE35000000000001226091357924682"); // true (alphanumeric CNPJ) * isValidNfseKey("35503082258716523000119000000000001226011357924684"); // false (check digit) * isValidNfseKey("3550308 2 2 58716523000119 0000000000012 2601 135792468 3"); // false (no mask) * ``` @@ -85,18 +108,18 @@ export const isValidNfseKey = (value: string): boolean => { if (match === null) return false; - const [, digits] = match; - const ambGer = Number(digits[GENERATOR_ENVIRONMENT_INDEX]); - const month = Number(digits.slice(MONTH_START, CODE_START)); + const key = match[1].toUpperCase(); + const ambGer = Number(key[GENERATOR_ENVIRONMENT_INDEX]); + const month = Number(key.slice(MONTH_START, CODE_START)); return ( - IBGE_UF_CODES[digits.slice(0, 2)] !== undefined && + IBGE_UF_CODES[key.slice(0, 2)] !== undefined && GENERATOR_ENVIRONMENTS.some((candidate) => candidate === ambGer) && - isValidTaxId(digits[TAX_ID_TYPE_INDEX], digits.slice(TAX_ID_START, NUMBER_START)) && - digits.slice(NUMBER_START, YEAR_START) !== ABSENT_NUMBER && + isValidTaxId(key[TAX_ID_TYPE_INDEX], key.slice(TAX_ID_START, NUMBER_START)) && + key.slice(NUMBER_START, YEAR_START) !== ABSENT_NUMBER && month >= 1 && month <= 12 && - mod11(digits.slice(0, CHECK_DIGIT_INDEX), { variant: "arrecadacao" }) === - Number(digits[CHECK_DIGIT_INDEX]) + mod11(key.slice(0, CHECK_DIGIT_INDEX), { variant: "arrecadacao" }) === + Number(key[CHECK_DIGIT_INDEX]) ); }; From 93cf0c8acd013982ace8922d54237356f30b946e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 01:03:02 +0000 Subject: [PATCH 7/8] fix(nfse-key): keep the letters of an alphanumeric CNPJ in parseNfseKey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseNfseKey kept digits only, so the key of an issuer with an alphanumeric CNPJ, in production in the Sistema Nacional NFS-e since 10/08/2026, lost the letters of its registration and came back as a string that is no longer the key. The official schema bundle NFSe-ESQUEMAS_XSD v1.01-20260727 types the key with letters in its registration, in TSIdNFSe "NFS[0-9]{9}[0-9A-Z]{14}[0-9]{27}". parseNfseKey now keeps [0-9A-Z], upper casing lower case letters as parseCnpj with version 2 does. Since TSIdNFSe opens the key with nine digits, no letter in front of the first digit belongs to it, so those are dropped: that is how the `NFS` prefix still goes away, in any case. Letters after the first digit are kept wherever they stand; checking that they stand in a CNPJ is isValidNfseKey's job. parseNfseKey("nfs3550308 2 2 12.abc.345/01de-35 0000000000012 2609 135792468 2") before: "355030822123450135000000000001226091357924682" (45 digits, the five letters lost: not a key) after: "35503082212ABC34501DE35000000000001226091357924682" Every numeric key is parsed as before; a letter after the first digit is now kept instead of dropped ("12ab34" gave "1234", now "12AB34"). Changed test: the property "should return at most the digits of an access key", /^\d{0,50}$/, is now "at most the characters of an access key, opening with a digit", /^(?:\d[\dA-Z]{0,49})?$/; the case "should remove non numeric characters" is renamed "should remove the characters that are neither digits nor letters" with the same expectations. These utils are not in 2.4.0; they are unreleased (PR #565). The schema was read directly through the mirror github.com/fm-s/open-nfse (schemas/1.01, byte-pinned to the official Produção Restrita zip); the Portal NFS-e news of 2026-07-27 announcing it was read through a search result snippet, gov.br being blocked by the proxy. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RLkm9YrtAifc6XCLFVEsdH --- docs/pt-br/utilities.md | 7 +++- docs/utilities.md | 7 +++- src/parse-nfse-key/parse-nfse-key.test.ts | 20 ++++++++-- src/parse-nfse-key/parse-nfse-key.ts | 45 ++++++++++++++++------- 4 files changed, 61 insertions(+), 18 deletions(-) diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index 5576a2c89..dd178e170 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -663,7 +663,9 @@ isValidNfseKey('3550308 2 2 58716523000119 0000000000012 2601 135792468 3'); // ### parseNfseKey -Remove tudo o que não é dígito da chave de acesso de uma NFS-e nacional, inclusive o prefixo `NFS` do atributo `Id` do XML, e limita o resultado a 50 dígitos. +Remove tudo o que não é dígito ou letra de um CNPJ alfanumérico da chave de acesso de uma NFS-e nacional e limita o resultado a 50 caracteres. + +- As letras ficam em maiúsculas, como faz o `parseCnpj` com `{ version: 2 }`, e as letras antes do primeiro dígito são descartadas, inclusive o prefixo `NFS` do atributo `Id` do XML, já que a chave começa com dígitos. O `isValidNfseKey` verifica se as letras que restam estão em um CNPJ. - Essa é a forma em que o leiaute guarda a chave e a que o DANFSe imprime, um bloco único, e por isso não existe `formatNfseKey`. @@ -675,6 +677,9 @@ parseNfseKey('NFS35503082258716523000119000000000001226011357924683'); parseNfseKey('3550308 2 2 58716523000119 0000000000012 2601 135792468 3'); // '35503082258716523000119000000000001226011357924683' + +parseNfseKey('nfs3550308 2 2 12.abc.345/01de-35 0000000000012 2609 135792468 2'); +// '35503082212ABC34501DE35000000000001226091357924682' ``` ### getNfseKeyInfo diff --git a/docs/utilities.md b/docs/utilities.md index 2fe690e51..33281df41 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -663,7 +663,9 @@ isValidNfseKey('3550308 2 2 58716523000119 0000000000012 2601 135792468 3'); // ### parseNfseKey -Remove everything but the digits from the access key of a national NFS-e, the `NFS` prefix of the XML `Id` attribute included, and cap the result to 50 digits. +Remove everything but the digits and the letters of an alphanumeric CNPJ from the access key of a national NFS-e, and cap the result to 50 characters. + +- Letters are upper cased, as `parseCnpj` with `{ version: 2 }` does, and the letters in front of the first digit are dropped, the `NFS` prefix of the XML `Id` attribute included, since the key opens with digits. `isValidNfseKey` checks that the letters left stand in a CNPJ. - That is the form the leiaute stores the key in and the one the DANFSe prints, a single block, which is why there is no `formatNfseKey`. @@ -675,6 +677,9 @@ parseNfseKey('NFS35503082258716523000119000000000001226011357924683'); parseNfseKey('3550308 2 2 58716523000119 0000000000012 2601 135792468 3'); // '35503082258716523000119000000000001226011357924683' + +parseNfseKey('nfs3550308 2 2 12.abc.345/01de-35 0000000000012 2609 135792468 2'); +// '35503082212ABC34501DE35000000000001226091357924682' ``` ### getNfseKeyInfo diff --git a/src/parse-nfse-key/parse-nfse-key.test.ts b/src/parse-nfse-key/parse-nfse-key.test.ts index ea99bba39..e04314ef6 100644 --- a/src/parse-nfse-key/parse-nfse-key.test.ts +++ b/src/parse-nfse-key/parse-nfse-key.test.ts @@ -8,6 +8,7 @@ import { describe, expect, expectTypeOf, it, test } from "../_internals/test/run import { parseNfseKey } from "./parse-nfse-key"; const KEY = "35503082258716523000119000000000001226011357924683"; +const KEY_ALPHANUMERIC = "35503082212ABC34501DE35000000000001226091357924682"; describe("parseNfseKey", () => { it("should keep a bare access key as it is", () => { @@ -19,7 +20,20 @@ describe("parseNfseKey", () => { expect(parseNfseKey(` nfs${KEY}`)).toBe(KEY); }); - it("should remove non numeric characters", () => { + it("should keep the letters of an alphanumeric CNPJ, upper cased", () => { + expect(parseNfseKey(KEY_ALPHANUMERIC)).toBe(KEY_ALPHANUMERIC); + expect(parseNfseKey(`NFS${KEY_ALPHANUMERIC}`)).toBe(KEY_ALPHANUMERIC); + expect(parseNfseKey("nfs3550308 2 2 12.abc.345/01de-35 0000000000012 2609 135792468 2")).toBe( + KEY_ALPHANUMERIC, + ); + }); + + it("should drop every letter in front of the first digit, since the key opens with digits", () => { + expect(parseNfseKey(`chave ${KEY}`)).toBe(KEY); + expect(parseNfseKey("NF")).toBe(""); + }); + + it("should remove the characters that are neither digits nor letters", () => { expect(parseNfseKey("3550308 2 2 58716523000119 0000000000012 2601 135792468 3")).toBe(KEY); expect(parseNfseKey("3550308.2.2.58716523000119/0000000000012-2601-135792468-3")).toBe(KEY); }); @@ -49,8 +63,8 @@ describe("parseNfseKey", () => { }); describe("properties", () => { - test("should return at most the digits of an access key", () => { - expectMatchesPattern(parseNfseKey, /^\d{0,50}$/, anyText); + test("should return at most the characters of an access key, opening with a digit", () => { + expectMatchesPattern(parseNfseKey, /^(?:\d[\dA-Z]{0,49})?$/, anyText); }); test("should be idempotent", () => { diff --git a/src/parse-nfse-key/parse-nfse-key.ts b/src/parse-nfse-key/parse-nfse-key.ts index e8ac18b85..b87f4e717 100644 --- a/src/parse-nfse-key/parse-nfse-key.ts +++ b/src/parse-nfse-key/parse-nfse-key.ts @@ -1,23 +1,33 @@ import { NFSE_KEY_LENGTH } from "../_internals/constants/nfse-key"; import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; -import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { sanitizeToAlphanumeric } from "../_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric"; /** - * Removes everything but the digits from the access key (chave de acesso) of a national NFS-e. + * The letters in front of the first digit. The key opens with nine digits (`TSIdNFSe`, + * `NFS[0-9]{9}[0-9A-Z]{14}[0-9]{27}`), so none of them is part of it: the `NFS` prefix is one. + */ +const LEADING_LETTERS_REGEX = /^[A-Z]+/; + +/** + * Removes everything but the digits and the letters of an alphanumeric CNPJ from the access key + * (chave de acesso) of a national NFS-e. * - * The `NFS` literal the `Id` attribute of `infNFSe` puts in front of the key goes away with - * every other character that is not a digit. The result is - * the form the leiaute stores the key in (`TSChaveNFSe`) and the one the DANFSe prints, a - * single block of digits, so this package has no `formatNfseKey`. + * Every character that is not `0-9` or `A-Z` goes away, lower case letters are upper cased (as + * `parseCnpj` with version 2 reads them) and the letters in front of the first digit are dropped + * with the `NFS` literal the `Id` attribute of `infNFSe` puts there, since the key opens with + * digits. The result is the form the leiaute stores the key in and the one the DANFSe prints, a + * single block, so this package has no `formatNfseKey`. Letters are kept wherever they stand + * after the first digit; `isValidNfseKey` checks that they only stand in a CNPJ. * - * The result is capped at the 50 digits of an access key; a shorter value passes through as far - * as it goes. Use `isValidNfseKey` to check the key and `getNfseKeyInfo` to read its fields. + * The result is capped at the 50 characters of an access key; a shorter value passes through as + * far as it goes. Use `isValidNfseKey` to check the key and `getNfseKeyInfo` to read its fields. * * A number is only read when it is a non-negative safe integer; any other number (negative, * fractional, not finite or past `Number.MAX_SAFE_INTEGER`) gives an empty string. * * @param {string|number} value - The access key value to be parsed. - * @returns {string} Up to 50 digits, or an empty string when there is no digit at all. + * @returns {string} Up to 50 digits and upper case letters, or an empty string when there is no + * digit at all. * * @example * ```typescript @@ -26,15 +36,24 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d * * parseNfseKey("3550308 2 2 58716523000119 0000000000012 2601 135792468 3"); * // "35503082258716523000119000000000001226011357924683" + * + * parseNfseKey("nfs3550308 2 2 12.abc.345/01de-35 0000000000012 2609 135792468 2"); + * // "35503082212ABC34501DE35000000000001226091357924682" * parseNfseKey(-1); // "" (not a non-negative safe integer) * ``` * * @see Official: https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual * Sistema Nacional NFS-e, current technical documentation: `NFSe-ESQUEMAS_XSD-v1.01` - * (`tiposSimples_v1.01.xsd`), types `TSChaveNFSe` (50 digits) and `TSIdNFSe` (the `NFS` prefix). + * (`tiposSimples_v1.01.xsd`), types `TSChaveNFSe` (50 characters) and `TSIdNFSe` (the `NFS` + * prefix). + * @see Official: https://www.gov.br/nfse/pt-br/noticias/plataforma-nfs-e-disponibiliza-novas-evolucoes-em-producao-restrita-e-divulga-cronograma-de-implantacao + * Portal NFS-e, 2026-07-27: "os novos schemas XML atualizados para o CNPJ Alfanumérico". In that + * bundle (v1.01-20260727, read through the byte-pinned mirror https://github.com/fm-s/open-nfse), + * `TSIdNFSe` is "NFS[0-9]{9}[0-9A-Z]{14}[0-9]{27}". * @see Official: https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/rtc/nt-008-se-cgnfse-danfse-20260714-v1-02.pdf - * Nota Técnica SE/CGNFS-e 008 (DANFSe), item 2.1.1: the key is printed as a single block of 50 - * digits. + * Nota Técnica SE/CGNFS-e 008 (DANFSe), item 2.1.1: the key is printed as a single block. */ export const parseNfseKey = (value: string | number): string => - isLookupCode(value) ? sanitizeToDigits(value).slice(0, NFSE_KEY_LENGTH) : ""; + isLookupCode(value) + ? sanitizeToAlphanumeric(value).replace(LEADING_LETTERS_REGEX, "").slice(0, NFSE_KEY_LENGTH) + : ""; From 3d9f1a80e0c0bb9820ab2acfc2040310509bbb16 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 16:08:41 +0000 Subject: [PATCH 8/8] docs(nfse-key): leave out the TSChaveNFSe letter window note Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RLkm9YrtAifc6XCLFVEsdH --- docs/pt-br/utilities.md | 2 +- docs/utilities.md | 2 +- src/_internals/constants/nfse-key.ts | 5 +---- src/get-nfse-key-info/get-nfse-key-info.ts | 3 +-- src/is-valid-nfse-key/is-valid-nfse-key.ts | 6 ++---- 5 files changed, 6 insertions(+), 12 deletions(-) diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index dd178e170..d0869889b 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -647,7 +647,7 @@ Verifica se a chave de acesso de uma NFS-e nacional, a Nota Fiscal de Serviço e - O `ambGer` precisa ser `1` (o sistema do município) ou `2` (o Sistema Nacional NFS-e), e o tipo de inscrição `1` (um CPF, preenchido com `000` à esquerda) ou `2` (um CNPJ, numérico ou alfanumérico), com um CPF ou CNPJ cujos próprios dígitos verificadores sejam válidos. Letras só são aceitas em um CNPJ, e minúsculas são lidas como maiúsculas, como o `isValidCnpj` com `{ version: 2 }` as lê. - O `nNFSe` não pode ser todo de zeros e o mês precisa estar entre 01 e 12. - O dígito verificador é um módulo 11 sobre os 49 primeiros caracteres, pesos de 2 a 9 ciclando a partir da direita, em que resto 0 ou 1 dá 0. Uma letra vale o seu código ASCII menos 48 (`A` vale 17): nenhum documento da NFS-e diz isso, então a regra vem por analogia com a chave da NF-e da Nota Técnica Conjunta 2025.001 e com os próprios dígitos verificadores do CNPJ. -- As letras seguem o `TSIdNFSe` do pacote de esquemas de 27/07/2026, nas posições da Inscrição Federal (10 a 23). O `TSChaveNFSe` do mesmo pacote as coloca nas posições 7 a 20, o que contradiz a estrutura da chave, e não é seguido. +- As letras seguem o `TSIdNFSe` do pacote de esquemas de 27/07/2026, nas posições da Inscrição Federal (10 a 23). - Os modelos municipais de NFS-e que não são o padrão nacional estão fora do escopo. ```javascript diff --git a/docs/utilities.md b/docs/utilities.md index 33281df41..34c02b41d 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -647,7 +647,7 @@ Check if the access key (chave de acesso) of a national NFS-e, the Nota Fiscal d - `ambGer` must be `1` (the system of the municipality) or `2` (the Sistema Nacional NFS-e), and the registration type `1` (a CPF, left padded with `000`) or `2` (a CNPJ, numeric or alphanumeric), with a CPF or CNPJ whose own check digits are valid. Letters are accepted in a CNPJ only, and lower case is read as upper case, as `isValidCnpj` with `{ version: 2 }` reads it. - `nNFSe` must not be all zeros and the month must be 01 to 12. - The check digit is a modulus 11 over the first 49 characters, weights 2 to 9 cycling from the right, where a remainder of 0 or 1 gives 0. A letter counts as its ASCII code minus 48 (`A` is 17): no NFS-e document states it, so it is taken by analogy with the NF-e key of Nota Técnica Conjunta 2025.001 and the CNPJ's own check digits. -- The letters follow `TSIdNFSe` of the schema bundle of 2026-07-27, in the positions of the Inscrição Federal (10 to 23). `TSChaveNFSe` of the same bundle puts them at positions 7 to 20, which contradicts the structure of the key, and is not followed. +- The letters follow `TSIdNFSe` of the schema bundle of 2026-07-27, in the positions of the Inscrição Federal (10 to 23). - The municipal NFS-e models that are not the national standard are out of scope. ```javascript diff --git a/src/_internals/constants/nfse-key.ts b/src/_internals/constants/nfse-key.ts index 046f6b603..6305506b6 100644 --- a/src/_internals/constants/nfse-key.ts +++ b/src/_internals/constants/nfse-key.ts @@ -3,10 +3,7 @@ * also be the upper case letters of an alphanumeric CNPJ, optionally behind the `NFS` literal the * `Id` attribute of `infNFSe` puts in front of it. It follows `TSIdNFSe` of * `tiposSimples_v1.01.xsd` (bundle 20260727), `NFS[0-9]{9}[0-9A-Z]{14}[0-9]{27}`, whose letter - * window is the registration of the key structure. `TSChaveNFSe` of the same file, - * `[0-9]{6}([0-9A-Z]{14})[0-9]{30}`, puts the window at positions 7 to 20 instead, which - * contradicts that structure (the registration is at 10 to 23), `TSIdNFSe`, `TSIdDPS` and - * `TSIdPedRegEvt`, so it is not followed. The key has no printed mask (the DANFSe prints it "em + * window is the registration of the key structure. The key has no printed mask (the DANFSe prints it "em * único bloco", Nota Técnica SE/CGNFS-e 008, item 2.1.1), so there is no separator to accept. * Case-insensitive, as `isValidCnpj` with version 2 is: the callers upper case the key, which is * the first capture group. Whether a letter may stand in the registration at all is left to the diff --git a/src/get-nfse-key-info/get-nfse-key-info.ts b/src/get-nfse-key-info/get-nfse-key-info.ts index 65c6516f8..54f40fd05 100644 --- a/src/get-nfse-key-info/get-nfse-key-info.ts +++ b/src/get-nfse-key-info/get-nfse-key-info.ts @@ -87,8 +87,7 @@ export type NfseKeyInfo = { * upper case, as `isValidCnpj` with version 2 reads it, and `taxId` comes back upper cased. No * NFS-e document states how a letter enters the check digit of the key; by analogy with the * NF-e key (Nota Técnica Conjunta 2025.001) and the CNPJ's own check digits, each character - * counts as its ASCII code minus 48 (`A` is 17). `isValidNfseKey` cites the sources, and why - * `TSChaveNFSe`, which puts the letter window at positions 7 to 20, is not followed. + * counts as its ASCII code minus 48 (`A` is 17). `isValidNfseKey` cites the sources. * * @param {string} value - The access key value to be parsed. * @returns {NfseKeyInfo | null} The parsed access key, or `null` when `isValidNfseKey` rejects it. diff --git a/src/is-valid-nfse-key/is-valid-nfse-key.ts b/src/is-valid-nfse-key/is-valid-nfse-key.ts index e04dd31d6..cb2fc1b0b 100644 --- a/src/is-valid-nfse-key/is-valid-nfse-key.ts +++ b/src/is-valid-nfse-key/is-valid-nfse-key.ts @@ -68,7 +68,7 @@ const isValidTaxId = (typeDigit: string, registration: string): boolean => { * * @see Official: https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual * Sistema Nacional NFS-e, current technical documentation: `NFSe-ESQUEMAS_XSD-v1.01` - * (`tiposSimples_v1.01.xsd`: `TSIdNFSe`, `TSChaveNFSe`) and `ANEXO_I-SEFIN_ADN-DPS_NFSe-SNNFSe` + * (`tiposSimples_v1.01.xsd`: `TSIdNFSe`) and `ANEXO_I-SEFIN_ADN-DPS_NFSe-SNNFSe` * v1.01 (field `NFSe/infNFSe/id`, rules E1263, E1280, E1284 and E0042). * @see Official: https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual/manual-contribuintes-emissor-publico-api-emissao-decisao-administrativa-e-judicial.pdf * Manual de Contribuintes, Emissão por Decisão Administrativa ou Judicial, field `id`: "O dígito @@ -78,9 +78,7 @@ const isValidTaxId = (typeDigit: string, registration: string): boolean => { * os novos schemas XML atualizados para o CNPJ Alfanumérico". That bundle, `NFSe-ESQUEMAS_XSD` * v1.01-20260727, `tiposSimples_v1.01.xsd`: `TSIdNFSe` "NFS[0-9]{9}[0-9A-Z]{14}[0-9]{27}", `TSIdDPS` * "DPS[0-9]{7}(1[0-9]{14}|2[0-9A-Z]{14})[0-9]{20}" and `TSIdPedRegEvt` - * "PRE[0-9]{8}(1[0-9]{14}|2[0-9A-Z]{14})[0-9]{33}". `TSChaveNFSe`, - * "[0-9]{6}([0-9A-Z]{14})[0-9]{30}", misplaces the letter window (positions 7 to 20 instead of - * the registration's 10 to 23) and is not followed. Read through the byte-pinned mirror + * "PRE[0-9]{8}(1[0-9]{14}|2[0-9A-Z]{14})[0-9]{33}". Read through the byte-pinned mirror * https://github.com/fm-s/open-nfse (`schemas/1.01`), whose log of the official "Atualizações e * Implantações" page reads "CNPJ alfanumérico em produção desde 10/08/2026". * @see Official: https://www.gov.br/receitafederal/pt-br/centrais-de-conteudo/publicacoes/documentos-tecnicos/cnpj/manual-dv-cnpj.pdf