diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index 4c3cb3d7..dd178e17 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -634,6 +634,83 @@ 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 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, 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, 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. +- 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('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) +``` + +### parseNfseKey + +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`. + +```javascript +import { parseNfseKey } from '@brazilian-utils/brazilian-utils'; + +parseNfseKey('NFS35503082258716523000119000000000001226011357924683'); +// '35503082258716523000119000000000001226011357924683' + +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 + +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 caracteres, numérico ou alfanumérico, em maiúsculas. +- 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('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, 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 ### isValidSuframa diff --git a/docs/utilities.md b/docs/utilities.md index 842e477c..33281df4 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -634,6 +634,83 @@ 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 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, 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, 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 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('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) +``` + +### parseNfseKey + +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`. + +```javascript +import { parseNfseKey } from '@brazilian-utils/brazilian-utils'; + +parseNfseKey('NFS35503082258716523000119000000000001226011357924683'); +// '35503082258716523000119000000000001226011357924683' + +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 + +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 character CNPJ, numeric or alphanumeric, in upper case. +- 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('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, [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 ### isValidSuframa diff --git a/jsr.json b/jsr.json index 67174cf9..a1cf2943 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 1c86f12e..766bd049 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/_internals/constants/nfse-key.ts b/src/_internals/constants/nfse-key.ts new file mode 100644 index 00000000..046f6b60 --- /dev/null +++ b/src/_internals/constants/nfse-key.ts @@ -0,0 +1,67 @@ +/** + * 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{9}[\dA-Z]{14}\d{27})$/i; + +/** + * 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; + +/** + * The `ambGer` (ambiente gerador) codes of `TSAmbGeradorNFSe`: 1 for the system of the + * 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; + +/** + * 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 character key. */ +export const GENERATOR_ENVIRONMENT_INDEX = 7; + +/** 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 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". */ +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 00000000..b427be2f --- /dev/null +++ b/src/get-nfse-key-info/get-nfse-key-info.test.ts @@ -0,0 +1,368 @@ +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 { isValidNfseKey } from "../is-valid-nfse-key/is-valid-nfse-key"; +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" }, + { type: "2", registration: "12ABC34501DE35", taxIdType: "cnpj", taxId: "12ABC34501DE35" }, +]; + +const expectedCheckDigit = (body: string): number => { + let sum = 0; + let weight = 2; + + for (let index = body.length - 1; index >= 0; index -= 1) { + sum += (body.charCodeAt(index) - 48) * weight; + weight = weight === 9 ? 2 : weight + 1; + } + + const remainder = sum % 11; + + return remainder < 2 ? 0 : 11 - remainder; +}; + +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 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(); + }); + }); + + 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("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); + 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 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]); + 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(checkDigit); + }), + ); + }); + + 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 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) => { + 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 00000000..65c6516f --- /dev/null +++ b/src/get-nfse-key-info/get-nfse-key-info.ts @@ -0,0 +1,146 @@ +import { IBGE_UF_CODES } from "../_internals/constants/ibge-uf-codes"; +import { + CHECK_DIGIT_INDEX, + CODE_START, + CPF_PADDING, + 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 "../_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"; + +/** + * 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 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; + /** 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; +}; + +/** + * 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 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". 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. + * + * 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, 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 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. + * + * 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. + * + * @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("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 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: 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: 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/index.test.ts b/src/index.test.ts index 63934009..2bfdffa3 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 d865a49a..dfd10232 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 00000000..fb31cfe1 --- /dev/null +++ b/src/is-valid-nfse-key/is-valid-nfse-key.test.ts @@ -0,0 +1,142 @@ +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"; +// 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", () => { + 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); + }); + + 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", () => { + 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 and whose issuer CNPJ 51235800000112 is not valid either", () => { + 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); + 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); + }); + }); + + 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 00000000..e04dd31d --- /dev/null +++ b/src/is-valid-nfse-key/is-valid-nfse-key.ts @@ -0,0 +1,125 @@ +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, + * 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". + * @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, { version: 2 }); + 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 + * eletrônica of the Sistema Nacional NFS-e. + * + * 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 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, + * 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. + * + * 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. + * + * @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". + * @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) + * ``` + */ +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 key = match[1].toUpperCase(); + const ambGer = Number(key[GENERATOR_ENVIRONMENT_INDEX]); + const month = Number(key.slice(MONTH_START, CODE_START)); + + return ( + IBGE_UF_CODES[key.slice(0, 2)] !== undefined && + GENERATOR_ENVIRONMENTS.some((candidate) => candidate === ambGer) && + 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(key.slice(0, CHECK_DIGIT_INDEX), { variant: "arrecadacao" }) === + Number(key[CHECK_DIGIT_INDEX]) + ); +}; 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 00000000..e04314ef --- /dev/null +++ b/src/parse-nfse-key/parse-nfse-key.test.ts @@ -0,0 +1,96 @@ +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"; +const KEY_ALPHANUMERIC = "35503082212ABC34501DE35000000000001226091357924682"; + +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 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); + }); + + 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 characters of an access key, opening with a digit", () => { + expectMatchesPattern(parseNfseKey, /^(?:\d[\dA-Z]{0,49})?$/, anyText); + }); + + test("should be idempotent", () => { + expectIdempotent(parseNfseKey, anyText); + }); + + test("should never throw and always return a string", () => { + 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", () => { + 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 00000000..b87f4e71 --- /dev/null +++ b/src/parse-nfse-key/parse-nfse-key.ts @@ -0,0 +1,59 @@ +import { NFSE_KEY_LENGTH } from "../_internals/constants/nfse-key"; +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; +import { sanitizeToAlphanumeric } from "../_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric"; + +/** + * 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. + * + * 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 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 and upper case letters, 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" + * + * 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 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. + */ +export const parseNfseKey = (value: string | number): string => + isLookupCode(value) + ? sanitizeToAlphanumeric(value).replace(LEADING_LETTERS_REGEX, "").slice(0, NFSE_KEY_LENGTH) + : "";