diff --git a/docs/getting-started.md b/docs/getting-started.md index 7a1f70224..c880958c1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -68,19 +68,19 @@ A few utils embed an official dataset and weigh far more than everything else co | Util | Dataset | Minified | Gzipped | | --- | --- | --- | --- | -| `getCid10` | CID-10 V2008 categories and subcategories, with the DATASUS descriptions | 1030.4 KB | 146.9 KB | +| `getCid10` | CID-10 V2008 categories and subcategories plus the SIM `U07` codes, with the DATASUS descriptions | 1030.6 KB | 147.0 KB | | `getMunicipalities` · `getMunicipalityByCode` · `getMunicipality` | 5571 IBGE municipalities, with names and codes | 154.9 - 156.5 KB | 50.3 - 50.4 KB | | `getCities` | 5571 IBGE municipality names | 154.2 KB | 49.8 KB | | `isValidNcm` | NCM (Nomenclatura Comum do Mercosul) codes | 114.2 KB | 24.6 KB | -| `isValidCbo` · `getCbo` | CBO 2002 occupation titles | 119.1 KB | 30.6 KB | +| `isValidCbo` · `getCbo` | CBO 2002 occupation titles | 119.1 - 119.2 KB | 30.6 - 30.7 KB | | `isValidCest` · `getCest` | CEST descriptions and segments (Convênio ICMS 142/18) | 116.6 - 117.8 KB | 26.4 - 26.9 KB | -| `isValidCnae` · `getCnae` | CNAE-Subclasses 2.3 | 93.9 KB | 21.2 KB | +| `isValidCnae` · `getCnae` | CNAE-Subclasses 2.3 | 93.9 - 94.0 KB | 21.1 - 21.2 KB | | `isValidNbs` · `getNbs` | NBS 2.0 (Nomenclatura Brasileira de Serviços) descriptions | 81.8 KB | 13.8 KB | -| `isValidCfop` · `getCfop` | CFOP operation descriptions | 68.9 KB | 6.9 KB | +| `isValidCfop` · `getCfop` | CFOP operation descriptions | 68.9 - 69.0 KB | 6.9 KB | | `getClassTrib` | cClassTrib (IBS/CBS) names and descriptions | 50.8 KB | 9.6 KB | | `getBanks` · `getBankByCode` · `getBankByIspb` | Banco Central STR participants (COMPE + ISPB) | 38.3 - 38.6 KB | 9.5 - 9.7 KB | | `isValidServiceItem` · `getServiceItem` | Service list of the Lei Complementar 116/2003 | 27.1 - 27.2 KB | 8.8 - 8.9 KB | -| `isValidCid10` | CID-10 V2008 category and subcategory codes, without the descriptions | 27.0 KB | 7.4 KB | +| `isValidCid10` | CID-10 V2008 category and subcategory codes plus the SIM `U07` codes, without the descriptions | 27.0 KB | 7.4 KB | The root of the package is a single ESM module, so a bundler cannot split one of these datasets out of it: importing a heavy util from the root puts its whole dataset in your main bundle, and a dynamic `import()` of the root does not help. To lazy-load one, import it from its own subpath: diff --git a/docs/pt-br/getting-started.md b/docs/pt-br/getting-started.md index 38cda5a32..34f320b30 100644 --- a/docs/pt-br/getting-started.md +++ b/docs/pt-br/getting-started.md @@ -68,19 +68,19 @@ Alguns utilitários embutem uma base de dados oficial e pesam muito mais que tod | Utilitário | Base de dados | Minificado | Gzip | | --- | --- | --- | --- | -| `getCid10` | categorias e subcategorias da CID-10 V2008, com as descrições do DATASUS | 1030,4 KB | 146,9 KB | +| `getCid10` | categorias e subcategorias da CID-10 V2008 mais os códigos `U07` do SIM, com as descrições do DATASUS | 1030,6 KB | 147,0 KB | | `getMunicipalities` · `getMunicipalityByCode` · `getMunicipality` | 5571 municípios do IBGE, com nomes e códigos | 154,9 - 156,5 KB | 50,3 - 50,4 KB | | `getCities` | nomes dos 5571 municípios do IBGE | 154,2 KB | 49,8 KB | | `isValidNcm` | códigos NCM (Nomenclatura Comum do Mercosul) | 114,2 KB | 24,6 KB | -| `isValidCbo` · `getCbo` | títulos das ocupações da CBO 2002 | 119,1 KB | 30,6 KB | +| `isValidCbo` · `getCbo` | títulos das ocupações da CBO 2002 | 119,1 - 119,2 KB | 30,6 - 30,7 KB | | `isValidCest` · `getCest` | descrições e segmentos do CEST (Convênio ICMS 142/18) | 116,6 - 117,8 KB | 26,4 - 26,9 KB | -| `isValidCnae` · `getCnae` | CNAE-Subclasses 2.3 | 93,9 KB | 21,2 KB | +| `isValidCnae` · `getCnae` | CNAE-Subclasses 2.3 | 93,9 - 94,0 KB | 21,1 - 21,2 KB | | `isValidNbs` · `getNbs` | descrições da NBS 2.0 (Nomenclatura Brasileira de Serviços) | 81,8 KB | 13,8 KB | -| `isValidCfop` · `getCfop` | descrições das operações do CFOP | 68,9 KB | 6,9 KB | +| `isValidCfop` · `getCfop` | descrições das operações do CFOP | 68,9 - 69,0 KB | 6,9 KB | | `getClassTrib` | nomes e descrições do cClassTrib (IBS/CBS) | 50,8 KB | 9,6 KB | | `getBanks` · `getBankByCode` · `getBankByIspb` | participantes do STR do Banco Central (COMPE + ISPB) | 38,3 - 38,6 KB | 9,5 - 9,7 KB | | `isValidServiceItem` · `getServiceItem` | lista de serviços da Lei Complementar 116/2003 | 27,1 - 27,2 KB | 8,8 - 8,9 KB | -| `isValidCid10` | códigos das categorias e subcategorias da CID-10 V2008, sem as descrições | 27,0 KB | 7,4 KB | +| `isValidCid10` | códigos das categorias e subcategorias da CID-10 V2008 mais os códigos `U07` do SIM, sem as descrições | 27,0 KB | 7,4 KB | A raiz do pacote é um único módulo ESM, então o bundler não consegue separar uma dessas bases de dados dele: importar um utilitário pesado da raiz coloca a base inteira no seu bundle principal, e um `import()` dinâmico da raiz não ajuda. Para carregar sob demanda, importe do subpath próprio: diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index 58a1e8d5a..b4ebcbf27 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -12,6 +12,7 @@ Estas regras valem para todas as funções, a não ser que a seção diga o cont - **Nada lança erro com entrada inválida** (`null`, `undefined`, tipo errado): `isValid*` retornam `false`, `format*` e `parse*` retornam `''`, `get*` de um item retornam `null`, `get*` de lista retornam `[]`. As únicas exceções são as assíncronas `getAddressInfoByCep` e `getCepInfoByAddress`, que rejeitam com erros tipados. - **Validadores aceitam o valor com ou sem máscara**: os caracteres de máscara usuais (`.`, `-`, `/`) e espaços entre ou ao redor dos grupos são ignorados, então não é preciso limpar a formatação antes. +- **Um número só é lido quando é um inteiro seguro não negativo**: as funções que recebem `string | number` tratam um número negativo, fracionário, não finito ou inseguro como entrada inválida (`isValidCep(-20040020)` é `false`, `formatCpf(-1)` e `parseCpf(1.5)` são `''`), já que num número `-` e `.` não são caracteres de máscara. - **Formatadores aplicam a máscara até onde o valor vai**, então também servem como máscara de digitação. As funções `parse*` fazem o inverso e mantêm só os caracteres que importam. - **Geradores usam `Math.random()`**, então servem para testes e dados de exemplo e nunca para nada relacionado a segurança. - **Getters retornam um array ou objeto novo a cada chamada**, então alterar um resultado nunca afeta a chamada seguinte. @@ -25,6 +26,7 @@ Estas regras valem para todas as funções, a não ser que a seção diga o cont Valida um CPF. - Retorna `false` para um número reservado (todos os dígitos iguais, como `00000000000`) e para um dígito verificador errado. +- Os números reservados são os que o [leiaute DJE](http://normas.receita.fazenda.gov.br/sijut2consulta/anexoOutros.action?idArquivoBinario=36307) da Receita Federal lista como inválidos. A norma do CPF, a [IN RFB nº 2.172/2024](https://normas.receita.fazenda.gov.br/sijut2consulta/link.action?idAto=135611), não traz regra de dígito verificador; a regra é a do manual da e-Financeira da Receita Federal (Anexo II, `REGRA_VALIDA_CPF`, aprovado pelo Ato Declaratório Executivo Cofis nº 10/2026). ```javascript import { isValidCpf } from '@brazilian-utils/brazilian-utils'; @@ -38,7 +40,7 @@ isValidCpf('111 444 777 35'); // true (máscara com espaços) Formata um CPF. - **Opções** (`FormatCpfOptions`): `pad` preenche o valor com zeros à esquerda até 11 dígitos antes de aplicar a máscara (padrão `false`); `obfuscate` esconde os 3 primeiros dígitos e os 2 dígitos verificadores. -- `obfuscate` é aplicada após o `pad`. +- `obfuscate` é aplicada após o `pad`. Segue a regra que as Leis de Diretrizes Orçamentárias definem para a divulgação do CPF: "ocultar os três primeiros dígitos e os dois dígitos verificadores" ([Lei nº 14.194/2021, art. 149](https://www.planalto.gov.br/ccivil_03/_ato2019-2022/2021/lei/L14194.htm), regra criada pela Lei nº 12.309/2010, art. 87, § 5º; a LDO de 2026, [Lei nº 15.321/2025, art. 163](https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2025/lei/L15321.htm#art163), a repete). ```javascript import { formatCpf } from '@brazilian-utils/brazilian-utils'; @@ -114,23 +116,25 @@ Valida um CNPJ. - **Opções** (`IsValidCnpjOptions`): `version` escolhe o formato aceito: `1` (padrão) apenas numérico, `2` numérico e alfanumérico. Qualquer outro valor é lido como `1`. - Um número reservado (todos os dígitos iguais) é rejeitado nas duas versões; a versão `2` não tem lista de reservados para letras. +- O conjunto oficial de caracteres do CNPJ alfanumérico são as letras maiúsculas de `A` a `Z` e os algarismos (os 2 dígitos verificadores são sempre algarismos). Uma letra minúscula só é aceita como normalização da entrada, como um caractere de máscara: a entrada é convertida para maiúsculas antes. +- O Ex1 da pergunta 23 do perguntas e respostas da Receita Federal sobre o CNPJ alfanumérico, `AA345678/0003-29`, tem erro de impressão: os dígitos verificadores dele são `86`, então ele é rejeitado. ```javascript import { isValidCnpj } from '@brazilian-utils/brazilian-utils'; isValidCnpj('15515147234255'); // false -isValidCnpj('q0slfmbd7vx439', { version: 2 }); // true (alfanumérico minúsculo) +isValidCnpj('q0slfmbd7vx439', { version: 2 }); // true (lido como Q0SLFMBD7VX439) ``` -Fonte: [Receita Federal, Manual do DV do CNPJ](https://www.gov.br/receitafederal/pt-br/centrais-de-conteudo/publicacoes/documentos-tecnicos/cnpj/manual-dv-cnpj.pdf), [CNPJ alfanumérico](https://www.gov.br/receitafederal/pt-br/acesso-a-informacao/acoes-e-programas/programas-e-atividades/cnpj-alfanumerico). +Fonte: [Instrução Normativa RFB nº 2.229/2024](http://normas.receita.fazenda.gov.br/sijut2consulta/link.action?idAto=141102) (Anexo XV da IN RFB nº 2.119/2022, pesos "da direita para esquerda" conforme a retificação no DOU de 25/10/2024), [Receita Federal, Manual do DV do CNPJ](https://www.gov.br/receitafederal/pt-br/centrais-de-conteudo/publicacoes/documentos-tecnicos/cnpj/manual-dv-cnpj.pdf), [CNPJ alfanumérico](https://www.gov.br/receitafederal/pt-br/acesso-a-informacao/acoes-e-programas/programas-e-atividades/cnpj-alfanumerico). ### formatCnpj Formata um CNPJ. - **Opções** (`FormatCnpjOptions`): `pad` preenche o valor com zeros à esquerda até 14 caracteres antes de aplicar a máscara (padrão `false`); `version` escolhe o formato, `1` (padrão) apenas numérico, `2` alfanumérico; `obfuscate` esconde os 2 primeiros dígitos e os 2 dígitos verificadores. -- A versão `2` mantém letras (em maiúsculas) e dígitos; a versão `1` mantém apenas dígitos. -- `obfuscate` vale para as duas versões e é aplicada após o `pad`. +- A versão `2` mantém letras e dígitos, com uma letra minúscula convertida para maiúscula antes, já que o conjunto oficial é de `A` a `Z`; a versão `1` mantém apenas dígitos. +- `obfuscate` vale para as duas versões e é aplicada após o `pad`. É uma convenção desta biblioteca, não uma regra oficial: nenhuma lei ou ato da Receita Federal define mascaramento para o CNPJ, cujos dados são públicos, a [ANPD](https://www.gov.br/anpd/pt-br/centrais-de-conteudo/documentos-tecnicos-orientativos/estudo_tecnico_sobre_anonimizacao_de_dados_na_lgpd_uma_visao_de_processo_baseado_em_risco_e_tecnicas_computacionais.pdf) diz que "não há um padrão para o mascaramento" e as [regras do Pix do Banco Central](https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/IV_RequisitosMinimosparaExperienciadoUsuario.pdf) mostram o CNPJ inteiro onde mascaram o CPF; ela esconde os 2 primeiros caracteres e os 2 dígitos verificadores, à semelhança da regra do CPF. ```javascript import { formatCnpj } from '@brazilian-utils/brazilian-utils'; @@ -145,7 +149,7 @@ formatCnpj('12345678000195', { obfuscate: true }); // **.345.678/0001-** Remove a formatação do CNPJ, retorna um valor normalizado e limita o resultado a 14 caracteres. -- **Opções** (`ParseCnpjOptions`): `version` escolhe o formato: `1` (padrão) mantém apenas dígitos, `2` mantém letras e dígitos, em maiúsculas. +- **Opções** (`ParseCnpjOptions`): `version` escolhe o formato: `1` (padrão) mantém apenas dígitos, `2` mantém letras e dígitos, com uma letra minúscula convertida para maiúscula, já que o conjunto oficial é de `A` a `Z` (`parseCnpj('12.abc.345/01de-35', { version: 2 })` retorna `'12ABC34501DE35'`). ```javascript import { parseCnpj } from '@brazilian-utils/brazilian-utils'; @@ -160,6 +164,7 @@ Gera um CNPJ válido aleatório. - O primeiro argumento é a versão, `1` (padrão) numérico ou `2` alfanumérico, ou um objeto `GenerateCnpjParams` com `version` mais `branch`. - `branch` é o bloco do "número de ordem" (filial), um inteiro de 1 a 9999 (aleatório por padrão). Um `branch` inválido é ignorado. O bloco continua numérico nas duas versões. +- Um bloco de ordem aleatório nunca é `0000`: os estabelecimentos de uma raiz são numerados a partir de `0001`, a matriz, então esse bloco nunca é atribuído. ```javascript import { generateCnpj } from '@brazilian-utils/brazilian-utils' @@ -210,7 +215,7 @@ Fonte: [Instrução Normativa RFB nº 2.229/2024](http://normas.receita.fazenda. Valida um CEP ([código de endereçamento postal](https://pt.wikipedia.org/wiki/C%C3%B3digo_de_Endere%C3%A7amento_Postal)). -- Aceita `string` ou `number`. Um CEP que começa com `0` precisa ser string, já que um número não preserva o zero à esquerda. +- Aceita `string` ou `number`. Um CEP que começa com `0` precisa ser string, já que um número não preserva o zero à esquerda, e um número só é lido quando é um inteiro seguro não negativo. - Espaços, pontos e hífens são ignorados. Qualquer outro caractere invalida o valor. ```javascript @@ -221,6 +226,7 @@ isValidCep('92500-000'); // true (hífen entre os grupos) isValidCep('92.500-000'); // true (ponto e hífen) isValidCep('013 10 100'); // true (espaços em qualquer posição entre os dígitos) isValidCep(20040020); // true (entrada numérica) +isValidCep(-20040020); // false (não é um inteiro seguro não negativo) isValidCep('9250000A'); // false (letras são rejeitadas) isValidCep('12345'); // false (tamanho inválido) ``` @@ -230,13 +236,14 @@ isValidCep('12345'); // false (tamanho inválido) Formata um CEP ([código de endereçamento postal](https://pt.wikipedia.org/wiki/C%C3%B3digo_de_Endere%C3%A7amento_Postal)). - **Opções** (`FormatCepOptions`): `pad` preenche o valor com zeros à esquerda até 8 dígitos antes de aplicar a máscara (padrão `false`). -- Um CEP que começa com `0` passado como número perde esse zero: passe uma string ou use `pad`. +- Um CEP que começa com `0` passado como número perde esse zero: passe uma string ou use `pad`. Um número só é lido quando é um inteiro seguro não negativo; qualquer outro número retorna `''`. ```javascript import { formatCep } from '@brazilian-utils/brazilian-utils'; formatCep('92500000'); // 92500-000 formatCep('9250000', { pad: true }); // 09250-000 +formatCep(-92500000); // '' (não é um inteiro seguro não negativo) ``` ### parseCep @@ -264,7 +271,7 @@ generateCep(); // '92500000' Busca o endereço de um CEP em vários provedores ao mesmo tempo e resolve com a primeira resposta bem-sucedida. O resultado é um `AddressInfo`: `cep`, `state`, `city`, `neighborhood` e `street`. - **Opções** (`GetAddressInfoByCepOptions`): `providers` (`CepProvider[]`) lista os provedores a disputar (padrão `['viacep', 'brasilapi']`). `'widenet'` está descontinuado e fica fora da lista padrão. -- Aceita string ou número. Um número é preenchido com zeros à esquerda até 8 dígitos. +- Aceita string ou número. Um número é preenchido com zeros à esquerda até 8 dígitos; um negativo ou fracionário é rejeitado com `GetAddressInfoByCepValidationError` antes de qualquer requisição. - Repete falhas transitórias de rede por provedor. - Rejeita com `GetAddressInfoByCepValidationError` quando o CEP é inválido ou `providers` não nomeia nenhum provedor conhecido, com `GetAddressInfoByCepNotFoundError` quando todos os provedores falharam e pelo menos um informou que o CEP é desconhecido, e com `GetAddressInfoByCepServiceError` quando todos os provedores falharam por outro motivo. - Os três estendem `GetAddressInfoByCepError`, então um único `catch` cobre todos. @@ -329,16 +336,18 @@ const ceps = await getCepInfoByAddress({ Valida um boleto ([meio de pagamento brasileiro](https://pt.wikipedia.org/wiki/Boleto_banc%C3%A1rio)). - Aceita a linha digitável de 47 dígitos da "cobrança bancária" e, do "boleto de arrecadação", seja a linha digitável de 48 dígitos, seja o código de barras de 44 dígitos. -- O código de moeda (posição 4 do código de barras da cobrança bancária) não é verificado. +- O código de moeda (posição 4 do código de barras e da linha digitável da cobrança bancária) precisa ser `9` (real), o único código que a Carta-Circular BCB nº 2.926/2000 atribui. A única exceção é o boleto da "Situação 2" da Convenção da Cobrança da FEBRABAN, emitido por instituição identificada apenas pelo ISPB: código de banco `988`, código de moeda `0`, fator de vencimento `0000` e o ISPB, completado com zeros, no lugar do valor. Qualquer outro dígito é rejeitado. ```javascript import { isValidBoleto } from '@brazilian-utils/brazilian-utils'; isValidBoleto('00190000090114971860168524522114675860000102656'); // true isValidBoleto('846100000005246100291102005460339004695895061080'); // true (boleto de arrecadação) +isValidBoleto('00170000010114971860168524522114275860000102656'); // false (código de moeda 7) +isValidBoleto('98800000060114971860168524522114100000018236120'); // true (Situação 2: banco 988, moeda 0, ISPB) ``` -Fonte: [Carta-Circular BCB nº 2.926/2000](https://www.bcb.gov.br/pre/normativos/c_circ/2000/pdf/c_circ_2926_v1_O.pdf), [FEBRABAN, Layout Padrão de Arrecadação](https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf). +Fonte: [Carta-Circular BCB nº 2.926/2000](https://www.bcb.gov.br/pre/normativos/c_circ/2000/pdf/c_circ_2926_v1_O.pdf), [FEBRABAN, Layout Padrão de Arrecadação, Versão 08](https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf) (vigente desde 01/06/2026), [FEBRABAN, Convenção da Cobrança](https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Conven%C3%A7%C3%A3o%20da%20Cobran%C3%A7a%20-%2005_02_2021_f.pdf). ### formatBoleto @@ -356,7 +365,7 @@ formatBoleto('846100000005246100291102005460339004695895061080'); // 84610000000 formatBoleto('84610000000246100291100054603390069589506108'); // 84610.00000 02461.002911 00054.603390 0 69589506108 (código de barras de arrecadação de 44 dígitos mantém a máscara bancária) ``` -Fonte: [FEBRABAN, Layout Padrão de Arrecadação](https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf). +Fonte: [FEBRABAN, Layout Padrão de Arrecadação, Versão 08](https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf) (vigente desde 01/06/2026). ### parseBoleto @@ -373,6 +382,7 @@ parseBoleto('00190.00009 01149.718601 68524.522114 6 75860000102656'); // 001900 Gera um boleto válido aleatório. - Informe `{ type: 'arrecadacao' }` (`GenerateBoletoParams`) para um boleto de arrecadação de 48 dígitos em vez do tipo padrão `'bancario'` (cobrança bancária, 47 dígitos). +- Um boleto de cobrança bancária traz o código de moeda `9` e um fator de vencimento `0000` (sem vencimento) ou de `1000` a `9999`; de `0001` a `0999` o fator não indica data. ```javascript import { generateBoleto } from '@brazilian-utils/brazilian-utils'; @@ -387,8 +397,9 @@ Extrai informações de um boleto (valor, data de vencimento, código do banco). - **Opções** (`GetBoletoInfoOptions`): `referenceDate` resolve o ciclo do "fator de vencimento" a partir dessa data em vez de agora. - Retorna um `BoletoInfo`: `amount` em centavos, `expirationDate` e o `bankCode` de três dígitos. `expirationDate` é `null` quando o boleto não traz fator de vencimento (um fator abaixo de `1000`). -- O ciclo do fator de vencimento reiniciou em 22/02/2025, então um fator pode significar uma de duas datas separadas por 9000 dias. `referenceDate` escolhe entre elas; informe-a sempre que a resposta precisar ser estável. +- O ciclo do fator de vencimento reiniciou em 22/02/2025, então um fator pode significar uma de duas datas separadas por 9000 dias. Não há comunicado da FEBRABAN publicado sobre o reinício; a regra está em manuais de banco, como o [do Bradesco](https://banco.bradesco/assets/pessoajuridica/pdf/4008-524-0121-layout-cobranca-versao-portugues.pdf) (Versão 17). `referenceDate` escolhe entre elas; informe-a sempre que a resposta precisar ser estável. - Um boleto de arrecadação tem `bankCode: ''` e `expirationDate: null`, mais `type: 'arrecadacao'`, `segment`, `value` (o valor em reais) e `hasEffectiveValue`. +- O boleto da "Situação 2" da Convenção da Cobrança da FEBRABAN (código de banco `988`, código de moeda `0`) traz o ISPB do emissor no lugar do valor: ele volta como `ispb`, com `amount: 0`. ```javascript import { getBoletoInfo } from '@brazilian-utils/brazilian-utils'; @@ -401,13 +412,16 @@ getBoletoInfo('00190000090114971860168524522114675860000102656', { }); // Resolve o ciclo do fator de vencimento a partir de 01/07/2018 +getBoletoInfo('98800000060114971860168524522114100000018236120'); +// { amount: 0, expirationDate: null, bankCode: '988', ispb: '18236120' } + getBoletoInfo('846100000005246100291102005460339004695895061080'); // { amount: 2461, expirationDate: null, bankCode: '', type: 'arrecadacao', segment: 4, value: 24.61, hasEffectiveValue: true } getBoletoInfo('invalid'); // null ``` -Fonte: [Carta-Circular BCB nº 2.926/2000](https://www.bcb.gov.br/pre/normativos/c_circ/2000/pdf/c_circ_2926_v1_O.pdf), [FEBRABAN, Layout Padrão de Arrecadação](https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf). +Fonte: [Carta-Circular BCB nº 2.926/2000](https://www.bcb.gov.br/pre/normativos/c_circ/2000/pdf/c_circ_2926_v1_O.pdf), [FEBRABAN, Layout Padrão de Arrecadação, Versão 08](https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf) (vigente desde 01/06/2026), [FEBRABAN, Convenção da Cobrança](https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Conven%C3%A7%C3%A3o%20da%20Cobran%C3%A7a%20-%2005_02_2021_f.pdf). ## Pix @@ -430,7 +444,7 @@ isValidPixKey('123.456.789-09', { accept: ['email', 'evp'] }); // false isValidPixKey('not a key'); // false ``` -Fonte: [Manual de Padrões para Iniciação do Pix](https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/II_ManualdePadroesparaIniciacaodoPix.pdf), [API do DICT](https://www.bcb.gov.br/content/estabilidadefinanceira/pix/API-DICT.html), [pix-api](https://github.com/bacen/pix-api). +Fonte: [Manual de Padrões para Iniciação do Pix](https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/II_ManualdePadroesparaIniciacaodoPix.pdf), [API do DICT 2.12.1](https://www.bcb.gov.br/content/estabilidadefinanceira/pix/API-DICT.html) e seu [changelog](https://www.bcb.gov.br/content/estabilidadefinanceira/pix/changelog.html), [pix-api](https://github.com/bacen/pix-api). ### getPixKeyInfo @@ -439,13 +453,15 @@ Identifica uma chave Pix e a normaliza para a forma canônica que o DICT espera - Retorna um `PixKeyInfo` com o `type` (`PixKeyType`) e o `value`. - O `value` canônico é só dígitos para CPF ou CNPJ (letras maiúsculas), e-mail minúsculo, celular em E.164 ou UUID minúsculo. - Um valor de 11 dígitos válido como CPF e celular é lido como CPF, salvo se escrito como telefone (prefixo `+55` ou DDD entre parênteses). -- Um e-mail com mais de 77 caracteres é rejeitado. +- Um e-mail é conferido, já em minúsculas, contra a expressão regular que a API do DICT registra e o limite de 77 caracteres, não contra `isValidEmail`: a parte local pode ter qualquer um de ``.!#$'*+/=?^_`{|}~-``, pontos em qualquer posição inclusive, e o domínio pode ter um só rótulo (`a@localhost`). A expressão é a da API do DICT 2.12.1, que não tem `&` desde a versão 2.6.0 (27/09/2025). ```javascript import { getPixKeyInfo } from '@brazilian-utils/brazilian-utils'; getPixKeyInfo('123.456.789-09'); // { type: 'cpf', value: '12345678909' } getPixKeyInfo('Fulano@Example.COM '); // { type: 'email', value: 'fulano@example.com' } +getPixKeyInfo('a{b}@example.com'); // { type: 'email', value: 'a{b}@example.com' } (expressão do DICT, isValidEmail o rejeita) +getPixKeyInfo('a&b@example.com'); // null (sem & desde a API do DICT 2.6.0) getPixKeyInfo('(11) 98765-4321'); // { type: 'phone', value: '+5511987654321' } getPixKeyInfo('71C7D9BE-4B85-4E43-9F1C-1F3B8B4E9A2D'); // { type: 'evp', value: '71c7d9be-4b85-4e43-9f1c-1f3b8b4e9a2d' } @@ -454,16 +470,21 @@ getPixKeyInfo('51998259765'); // { type: 'cpf', value: '51998259765' } (também getPixKeyInfo('+5551998259765'); // { type: 'phone', value: '+5551998259765' } ``` -Fonte: [Manual de Padrões para Iniciação do Pix](https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/II_ManualdePadroesparaIniciacaodoPix.pdf), [API do DICT](https://www.bcb.gov.br/content/estabilidadefinanceira/pix/API-DICT.html). +Fonte: [Manual de Padrões para Iniciação do Pix](https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/II_ManualdePadroesparaIniciacaodoPix.pdf), [API do DICT 2.12.1](https://www.bcb.gov.br/content/estabilidadefinanceira/pix/API-DICT.html) e seu [changelog](https://www.bcb.gov.br/content/estabilidadefinanceira/pix/changelog.html). ### isValidPixPayload -Valida um payload de BR Code Pix (a string por trás de um QR Code Pix e do "Pix copia e cola"). A chave em si não é conferida; use `isValidPixKey`. +Valida um payload de BR Code Pix (a string por trás de um QR Code Pix e do "Pix copia e cola") pelo Manual de Padrões para Iniciação do Pix e, onde ele é omisso, pela especificação EMV de QR Code em que ele se apoia. -- A estrutura TLV, o CRC-16 e os objetos obrigatórios (format indicator, category code, moeda, país, nome e cidade do recebedor) são verificados. +- O payload precisa começar pelo format indicator `000201`. +- A estrutura TLV, o CRC-16 e os objetos obrigatórios (format indicator, category code de 4 dígitos, moeda, país, nome e cidade do recebedor) são verificados. - Um template "Merchant Account Information" (IDs 26 a 51) precisa trazer o GUI `br.gov.bcb.pix` com uma chave (estático) ou a URL do PSP (dinâmico), nunca os dois. -- Os objetos `01` (Point of Initiation Method) e `62` (Additional Data Field) são opcionais; `01` precisa ser `11` ou `12` quando presente. -- Um valor (`54`) precisa ser maior que zero, exceto num BR Code de Pix Saque (`fss` de 8 dígitos no subobjeto 26-03). +- A chave vem na forma do DICT (§2.5.1): a que `getPixKeyInfo` devolve sem mudar, então `12345678909` passa e `123.456.789-09` não. Se ela está registrada não dá para saber pelo payload. A URL do PSP tem no máximo 77 caracteres (§2.5.2). +- O nome do recebedor tem no máximo 25 caracteres e a cidade no máximo 15; o país é `BR` em maiúsculas. +- Nenhum manual do BCB diz se o CRC ou o `BR` podem estar em minúsculas: a única regra de caixa que eles dão é a do GUI, e todos os exemplos oficiais escrevem os dois em maiúsculas. Aceitar CRC em minúsculas (`1d3d`) e rejeitar `br` são escolhas desta biblioteca, como na 2.4.0. +- O objeto `01` (Point of Initiation Method) é opcional e precisa ser `11` ou `12` quando presente. +- O objeto `62` (Additional Data Field) é obrigatório e traz o `txid` (62-05), "sempre presente em um BR Code": `***` ou de 1 a 25 letras e dígitos (§2.6.2); com URL do PSP qualquer valor vale, já que o §2.7 manda o pagador ignorá-lo. O `-` do exemplo `RP12345678-2019` do Manual do BR Code está fora do conjunto de caracteres do Pix do §2.6.2, então esse exemplo estático é rejeitado. +- Um valor (`54`) é feito de dígitos com um `.` opcional e no máximo duas casas decimais (`98.73`, `98` e `98.` são os exemplos do EMV), com no máximo 13 caracteres, e maior que zero, exceto num BR Code de Pix Saque (`fss` de 8 dígitos no subobjeto 26-03) e junto de uma URL do PSP, em que a API Pix lhe dá `0.00` (o Manual do BR Code traz `"0"` entre os exemplos). - Os Unreserved Templates (IDs 80 a 99) são ignorados. ```javascript @@ -485,8 +506,8 @@ Interpreta um payload de BR Code Pix e retorna seus campos. Aceita o que `isVali - Retorna um `PixPayloadInfo`: `merchantName`, `merchantCity`, `pointOfInitiation` e `key` (estático) ou `url` (dinâmico). - `amount`, `txid`, `description` e `withdrawalFacilitator` (o `fss` do Pix Saque) só aparecem quando o payload os traz; `txid` fica ausente para o marcador `***`. -- `pointOfInitiation` (`PixPointOfInitiation`) é `"dynamic"` quando o payload traz uma localização de PSP ou o objeto `01` é `"12"`; senão, `"static"`. -- Com localização de PSP, `amount` e `txid` são ignorados, como manda o manual. +- `pointOfInitiation` (`PixPointOfInitiation`) é `"dynamic"` quando o payload traz uma localização de PSP (o QR Code dinâmico do manual do Pix, §2.4.2) ou se marca como de uso único com o objeto `01` = `"12"` (§2.7.2); senão, `"static"`. Um payload com chave e `01` = `"12"` é, portanto, `"dynamic"`; `url` e `key` distinguem os dois tipos de QR Code do manual. +- Com localização de PSP, `amount` e `txid` são ignorados e ficam de fora, como manda o §2.7 do manual ("Se preenchidos, seu conteúdo deve ser ignorado"). ```javascript import { getPixPayloadInfo } from '@brazilian-utils/brazilian-utils'; @@ -543,23 +564,26 @@ Fonte: [Manual do BR Code](https://www.bcb.gov.br/content/estabilidadefinanceira ### isValidNfeKey -Valida uma chave de acesso de DF-e. Cobre todo DF-e com chave de acesso de 44 dígitos; o CF-e-SAT (59) fica de fora. +Valida uma chave de acesso de DF-e. Cobre todo DF-e com chave de acesso de 44 caracteres; o CF-e-SAT (59) fica de fora. - Modelos: NF-e (55), NFC-e (65), CT-e (57), MDF-e (58), CT-e OS (67), GTV-e (64), BP-e (63), NF3e (66) e NFCom (62). -- Os 44 dígitos podem ser agrupados de 4 em 4 por espaço, `.`, `-` ou `/`. Os prefixos `Id` do XML (`NFe`, `CTe`, `MDFe`, `BPe`, `NF3e`, `NFCom`) são removidos antes. +- Todo caractere é dígito, exceto as posições 7 a 18, a raiz e a ordem do CNPJ do emitente, que podem trazer as letras de um CNPJ alfanumérico: os schemas atuais (NF-e PL_010 `TChNFe`, CT-e PL_CTe_400_RTC, MDF-e 3.00b, NFCom) tipam a chave como `[0-9]{6}[0-9A-Z]{12}[0-9]{26}`, em produção na NF-e a partir de 01/07/2026 (NT 2026.004). Uma letra em qualquer outra posição, incluídos os dígitos verificadores do CNPJ nas posições 19 e 20, é rejeitada. O schema só admite maiúsculas; minúsculas são lidas como maiúsculas, como `isValidCnpj` faz com `{ version: 2 }`. +- Os 44 caracteres podem ser agrupados de 4 em 4 por espaço, `.`, `-` ou `/`. Os prefixos `Id` do XML (`NFe`, `CTe`, `MDFe`, `BPe`, `NF3e`, `NFCom`) são removidos antes. - `tpEmis` precisa ser um dos que o MOC do modelo atribui (tabela abaixo). - Para NF-e e NFC-e o `cNF` precisa passar na regra B03-10 do MOC (sem valores repetidos ou sequenciais, diferente do número do documento). -- Um número de documento todo zerado é rejeitado. O dígito verificador é um módulo 11 sobre os 43 primeiros dígitos. +- Um número de documento todo zerado é rejeitado. O dígito verificador é um módulo 11 sobre os 43 primeiros caracteres, cada um valendo seu código ASCII menos 48 (`A` = 17 ... `Z` = 42), como a NT Conjunta 2025.001 define. | Modelo | `tpEmis` aceitos | | --- | --- | | NF-e (55), NFC-e (65) | 1 a 7 e 9 | | CT-e (57) | 1, 3, 4, 5, 7, 8 | | CT-e OS (67) | 1, 5, 7, 8 | -| GTV-e (64) | 1, 2, 7, 8 | +| GTV-e (64) | 1, 2, 7, 8 (veja abaixo) | | MDF-e (58) | 1, 2, 3 | | BP-e (63), NF3e (66), NFCom (62) | 1, 2 | +Para a GTV-e, o pacote de schemas atual do CT-e (PL_CTe_400_RTC) enumera apenas os `tpEmis` 1 (normal) e 2 (contingência off-line); o PL_CTe_400 anterior também tinha 7 e 8 (autorização pela SVC-RS e pela SVC-SP). A chave não traz a versão do schema, então a biblioteca aceita os quatro, e as chaves de GTV-e autorizadas sob o pacote anterior continuam válidas. + ```javascript import { isValidNfeKey } from '@brazilian-utils/brazilian-utils'; @@ -568,6 +592,8 @@ isValidNfeKey('NFe35170458716523000119550010000000121000123458'); // true (prefi isValidNfeKey('CTe35170458716523000119570010000000128000123452'); // true (CT-e autorizado pela SVC-SP) isValidNfeKey('3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458'); // true (com máscara) isValidNfeKey('3517.0458.7165.2300.0119.5500.1000.0000.1210.0012.3458'); // true (qualquer um dos caracteres de máscara) +isValidNfeKey('35260712ABC34501DE35550010000001231102030403'); // true (CNPJ alfanumérico 12ABC34501DE35) +isValidNfeKey('35260712ABC34501DEA5550010000001231102030408'); // false (letra na posição 19, dígito verificador do CNPJ) isValidNfeKey('351 70458716523000119550010000000121000123458'); // false (separador dentro de um grupo de 4) isValidNfeKey('99170458716523000119550010000000121000123458'); // false (cUF inválido) isValidNfeKey('35170458716523000119010010000000121000123450'); // false (modelo inválido) @@ -575,14 +601,15 @@ isValidNfeKey('35170458716523000119550010000000128000123455'); // false (o MOC d isValidNfeKey('35170458716523000119550010000000121000000003'); // false (cNF 00000000, regra B03-10) ``` -Fonte: [MOC da NF-e](https://www.confaz.fazenda.gov.br/legislacao/arquivo-manuais/moc7-visao-geral.pdf), [schemas da NF-e](https://dfe-portal.svrs.rs.gov.br/NFE/Documentos) e os MOCs citados em `src/is-valid-nfe-key/is-valid-nfe-key.ts`. +Fonte: [MOC da NF-e](https://www.confaz.fazenda.gov.br/legislacao/arquivo-manuais/moc7-visao-geral.pdf), [schemas da NF-e](https://dfe-portal.svrs.rs.gov.br/NFE/Documentos), [NT Conjunta 2025.001](https://www.nfe.fazenda.gov.br/portal/exibirArquivo.aspx?conteudo=5ZkvIZt10mQ%3D) (CNPJ alfanumérico) e os MOCs citados em `src/is-valid-nfe-key/is-valid-nfe-key.ts`. ### formatNfeKey -Formata uma chave de acesso de DF-e (Documento Fiscal eletrônico) em grupos de 4 dígitos separados por espaço. É a forma em que o DANFE, o DACTE, o DAMDFE, o DABPE, o DANF3E e o DANFE-COM a imprimem. +Formata uma chave de acesso de DF-e (Documento Fiscal eletrônico) em grupos de 4 caracteres separados por espaço. É a forma em que o DANFE, o DACTE, o DAMDFE, o DABPE, o DANF3E e o DANFE-COM a imprimem. -- **Opções** (`FormatNfeKeyOptions`): `pad` preenche o valor com zeros à esquerda até os 44 dígitos de uma chave de acesso completa (padrão `false`). -- Uma chave com máscara ou parcial é agrupada até onde os dígitos vão. +- **Opções** (`FormatNfeKeyOptions`): `pad` preenche o valor com zeros à esquerda até os 44 caracteres de uma chave de acesso completa (padrão `false`). +- Uma chave com máscara ou parcial é agrupada até onde os caracteres vão. +- As letras de um CNPJ alfanumérico são mantidas, em maiúsculas, nas posições 7 a 18; uma letra em qualquer outra posição é descartada. - Use `isValidNfeKey` para verificar uma chave. ```javascript @@ -591,6 +618,9 @@ import { formatNfeKey } from '@brazilian-utils/brazilian-utils'; formatNfeKey('35170458716523000119550010000000121000123458'); // '3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458' +formatNfeKey('35260712abc34501de35550010000001231102030403'); +// '3526 0712 ABC3 4501 DE35 5500 1000 0001 2311 0203 0403' (CNPJ alfanumérico) + formatNfeKey('12345'); // '1234 5' formatNfeKey('12345', { pad: true }); @@ -599,7 +629,9 @@ formatNfeKey('12345', { pad: true }); ### parseNfeKey -Remove a formatação de uma chave de acesso de DF-e (chave de acesso), mantém apenas os dígitos e limita o resultado a 44 dígitos. +Remove a formatação de uma chave de acesso de DF-e (chave de acesso), mantém apenas os seus caracteres e limita o resultado a 44 caracteres. + +- Os caracteres mantidos são os dígitos e, nas posições 7 a 18, as letras de um CNPJ alfanumérico, em maiúsculas; uma letra em qualquer outra posição é descartada. - Os prefixos `Id` do XML (`NFe`, `CTe`, `MDFe`, `BPe`, `NF3e`, `NFCom`) são removidos antes. @@ -611,6 +643,9 @@ parseNfeKey('3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458'); parseNfeKey('NFe35170458716523000119550010000000121000123458'); // '35170458716523000119550010000000121000123458' + +parseNfeKey('3526 0712 abc3 4501 de35 5500 1000 0001 2311 0203 0403'); +// '35260712ABC34501DE35550010000001231102030403' (CNPJ alfanumérico) ``` ### getNfeKeyInfo @@ -619,6 +654,7 @@ Interpreta uma chave de acesso de DF-e e retorna seus campos. Aceita as mesmas f - Retorna um `NfeKeyInfo`: `stateCode`, `year`, `month`, `taxId`, `model` (`NfeKeyModel`), `series`, `number`, `emissionType`, `code` e `checkDigit`. - Para NFCom e NF3e (modelos `'62'` e `'66'`) o resultado também traz `authorizationSite` e o `code` tem 7 dígitos em vez de 8. +- `taxId` são os 14 caracteres das posições 7 a 20 como escritos: um CNPJ numérico, um CNPJ alfanumérico (em maiúsculas) ou um CPF completado com zeros à esquerda. Um `taxId` com letra é sempre um CNPJ alfanumérico; um CPF completado e um CNPJ que começa com `000` se parecem, então verifique com `isValidCpf` ou `isValidCnpj` quando o tipo importar. ```javascript import { getNfeKeyInfo } from '@brazilian-utils/brazilian-utils'; @@ -631,6 +667,10 @@ getNfeKeyInfo('35170458716523000119620010000000121000123450'); // { stateCode: 'SP', year: 2017, month: 4, taxId: '58716523000119', model: '62', // series: 1, number: 12, emissionType: 1, code: '0012345', checkDigit: 0, authorizationSite: 0 } +getNfeKeyInfo('35260712ABC34501DE35550010000001231102030403'); +// { stateCode: 'SP', year: 2026, month: 7, taxId: '12ABC34501DE35', model: '55', +// series: 1, number: 123, emissionType: 1, code: '10203040', checkDigit: 3 } + getNfeKeyInfo('invalid'); // null ``` @@ -646,8 +686,8 @@ Verifica se a chave de acesso de uma NFS-e nacional, a Nota Fiscal de Serviço e - 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 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 oficial diz isso: as notas técnicas 001 a 009 da NFS-e, o Anexo I e as Perguntas e Respostas de 08/09/2026 são omissos, e a Nota Técnica Conjunta 2025.001, cuja regra do ASCII menos 48 vale para a chave dos DF-e, lista os documentos que abrange (NF-e, NFC-e, CT-e, CT-e OS, GTV-e, MDF-e, BP-e, BP-e TM, NF3e e NFCom) sem a NFS-e. A regra vem por analogia com essa NT 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 pacote de produção de 09/02/2026 ainda tipa a chave como `[0-9]{50}`. - Os modelos municipais de NFS-e que não são o padrão nacional estão fora do escopo. ```javascript @@ -709,7 +749,7 @@ getNfseKeyInfo('35503082212ABC34501DE35000000000001226091357924682'); 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. +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) (o pacote de produção restrita v1.01-20260727; o serviço trata o CNPJ alfanumérico em produção desde 10/08/2026, enquanto o pacote de produção de 09/02/2026 ainda só tem dígitos) 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 @@ -718,9 +758,10 @@ Fonte: a [documentação técnica do Sistema Nacional NFS-e](https://www.gov.br/ Valida uma Inscrição SUFRAMA. É o número de registro que a Superintendência da Zona Franca de Manaus dá às empresas com incentivo fiscal, informado no campo `ISUF` do destinatário da NF-e. - O número tem a forma `SS.NNNN.LLD`: setor de atividade, número sequencial, localidade da unidade da SUFRAMA e dígito verificador. -- Aceita 8 ou 9 dígitos: um valor de 8 dígitos é um número cujo código de setor perdeu o zero à esquerda. +- Aceita 8 ou 9 dígitos. O MOC só diz que o código de setor "pode começar por '0'"; ler um valor de 8 dígitos como um cujo código de setor perdeu esse zero é uma inferência desta biblioteca. - Retorna `false` para um código de setor `00` e para um dígito verificador módulo 11 errado. - Os códigos de setor e de localidade não são conferidos com uma tabela, pois o manual os lista apenas como exemplos. +- A regra vem do Manual de Orientação do Contribuinte da NF-e (CONFAZ/ENCAT), não da SUFRAMA, cuja Resolução CAS nº 64/2021, art. 5º, só chama a inscrição de "um número de identificação e controle" e não traz layout nem dígito verificador. - Além dos caracteres de máscara usuais, `(`, `)`, `,` e `*` também são ignorados. ```javascript @@ -779,6 +820,7 @@ Fonte: [Manual de Orientação do Contribuinte da NF-e 7.0, Visão Geral](https: Valida um número de telefone (celular ou fixo). Um código de país brasileiro (`+55`, `0055` ou um `55` isolado) é aceito e removido antes, como em `parsePhone`. - **Opções** (`IsValidPhoneOptions`): `accept` (`PhoneType[]`, padrão `['mobile', 'landline']`) define quais tipos de número são aceitos; inclua `'service'` para os números que `isValidServicePhone` reconhece. `version` (`PhoneVersion`, padrão `1`) é repassado a `isValidMobilePhone`. +- Um celular precisa começar com 7, 8 ou 9 nas duas versões (Resolução Anatel 749/2022, art. 12, I, "a"), então um 6 inicial é rejeitado; até a 2.4.0 a versão padrão o aceitava. ```javascript import { isValidPhone } from '@brazilian-utils/brazilian-utils'; @@ -786,6 +828,7 @@ import { isValidPhone } from '@brazilian-utils/brazilian-utils'; isValidPhone('11900000000'); // true isValidPhone('11712345678', { version: 2 }); // true (7, 8 e 9 são todos SMP) isValidPhone('11700123456', { version: 2 }); // false (a série 700 é de satélite) +isValidPhone('11612345678'); // false (6 não é SMP) isValidPhone('+55 11 98765-4321'); // true (código de país aceito) isValidPhone('08001234567'); // false (números de serviço não são aceitos por padrão) isValidPhone('08001234567', { accept: ['service'] }); // true @@ -803,9 +846,10 @@ Formata um número de telefone de acordo com os padrões brasileiros. Se `value` - `"e164"` e `"international"` removem antes o código de país, como `parsePhone`, e recaem para `"service"` para um número de serviço. - `"service"`: os Códigos Não Geográficos (`0800 123 4567`) e os números abreviados `300X`/`400X` (`4004-1234`). - `"auto"`: `"service"` para um número de serviço, `"international"` quando `value` traz código de país, senão `"nanp"` para mais de 9 dígitos, ou `"sn"`. +- O `obfuscate` é uma convenção desta biblioteca, não uma regra oficial: nenhuma lei, ato da Anatel ou orientação da [ANPD](https://www.gov.br/anpd/pt-br/centrais-de-conteudo/documentos-tecnicos-orientativos/estudo_tecnico_sobre_anonimizacao_de_dados_na_lgpd_uma_visao_de_processo_baseado_em_risco_e_tecnicas_computacionais.pdf) define quais dígitos de um telefone mostrar ("não há um padrão para o mascaramento"), e o [Banco Central](https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/IV_RequisitosMinimosparaExperienciadoUsuario.pdf) proíbe mascarar a chave Pix, inclusive telefone, no retorno da consulta ao DICT. - O `obfuscate` mantém 2 dígitos, a contagem que a conta gov.br usa para o celular cadastrado, e mantém o prefixo que indica uma região ou um serviço, e não um assinante: o DDD, o código do tipo `0800` e a raiz `300X`/`400X`. - Os 2 dígitos são os últimos que cabem na própria máscara, então na máscara padrão `"sn"` um valor com DDD é truncado antes, igual ao que acontece sem `obfuscate`, e o par visível é o 8º e o 9º dígito, e não os 2 últimos de `value`. -- Um código de utilidade pública de 3 dígitos (`190`) não identifica ninguém e é devolvido como está; num valor que a máscara `"service"` não reconhece cada dígito vira um `*`, o que esconde os dígitos, mas não quantos eram. Os padrões ofuscados têm um número fixo de posições, então em `"e164"` o que passa do 11º dígito nacional é descartado. +- Nas máscaras `"service"`, `"auto"`, `"e164"` e `"international"` um código de utilidade pública de 3 dígitos (`190`) não identifica ninguém e é devolvido como está (as outras máscaras o leem como um número curto qualquer); num valor que a máscara `"service"` não reconhece cada dígito vira um `*`, o que esconde os dígitos, mas não quantos eram. Os padrões ofuscados têm um número fixo de posições, então em `"e164"` o que passa do 11º dígito nacional é descartado. ```javascript import { formatPhone } from '@brazilian-utils/brazilian-utils'; @@ -869,24 +913,30 @@ generatePhone('service'); // '08001234567' ou '40041234' Valida um número de telefone celular. Um código de país brasileiro (`+55`, `0055` ou um `55` isolado) é aceito e removido antes, como em `parsePhone`. -- **Opções** (`IsValidMobilePhoneOptions`): `version` (`PhoneVersion`, padrão `1`) escolhe a regra de numeração: `1` aceita 6, 7, 8 ou 9 como primeiro dígito; `2` segue a Resolução Anatel 749/2022, aceita só 7, 8 ou 9 e rejeita a série `700`. +- **Opções** (`IsValidMobilePhoneOptions`): `version` (`PhoneVersion`, padrão `1`) escolhe a regra de numeração. As duas seguem a Resolução Anatel 749/2022, art. 12, I, "a" (`"7", "8" e "9": Serviço Móvel Pessoal (SMP)`) e aceitam só 7, 8 ou 9 como primeiro dígito; `1` também aceita a série `700`, `2` a rejeita por ser de satélite (art. 12, II, "a"). +- Até a 2.4.0 a versão `1` também aceitava 6 como primeiro dígito, que não é SMP. +- Mudança agendada, ainda não aplicada: a Resolução Anatel 777/2025, art. 22, reescreve o art. 12 a partir de 1º de março de 2027. O primeiro dígito `6` passa a ser SCM (não é celular), só `8` e `9` continuam SMP, a série `700` passa a ser "SMGS e SMP por Satélite" e qualquer outro número com `7` vira reserva técnica. A partir dessa data, uma `version: 2` que siga essa regra terá de aceitar só `8` e `9`, além da série `700` como SMP por satélite. ```javascript import { isValidMobilePhone } from '@brazilian-utils/brazilian-utils'; isValidMobilePhone('11900000000'); // true -isValidMobilePhone('11712345678', { version: 1 }); // true (formato antigo) +isValidMobilePhone('11712345678', { version: 1 }); // true isValidMobilePhone('11712345678', { version: 2 }); // true (7 também é SMP) -isValidMobilePhone('11612345678', { version: 2 }); // false (6 é Reserva Técnica) +isValidMobilePhone('11612345678'); // false (6 não é SMP, em nenhuma das versões) +isValidMobilePhone('11700123456'); // true (a versão 1 mantém a série 700) isValidMobilePhone('11700123456', { version: 2 }); // false (a série 700 é de satélite) ``` -Fonte: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749). +Fonte: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749) e [Resolução Anatel nº 777/2025](https://informacoes.anatel.gov.br/legislacao/resolucoes/2025/2022-resolucao-777), art. 22. ### isValidLandlinePhone Valida um número de telefone fixo. Um código de país brasileiro (`+55`, `0055` ou um `55` isolado) é aceito e removido antes, como em `parsePhone`. +- O número é o DDD mais 8 dígitos começando com `2` a `6`, a faixa de STFC e SCM da Resolução Anatel 749/2022, art. 11, I, "a". +- Mudança agendada, ainda não aplicada: a partir de 1º de março de 2027 a Resolução Anatel 777/2025, art. 21, deixa só `2` a `5` para o STFC, e o SCM passa para números de 9 dígitos começando com `6`. A partir dessa data, um fixo começando com `6` terá de ser rejeitado. + ```javascript import { isValidLandlinePhone } from '@brazilian-utils/brazilian-utils'; @@ -894,13 +944,15 @@ isValidLandlinePhone('1130000000'); // true isValidLandlinePhone('+55 11 3000-0000'); // true (código de país aceito) ``` +Fonte: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749), art. 11, e [Resolução Anatel nº 777/2025](https://informacoes.anatel.gov.br/legislacao/resolucoes/2025/2022-resolucao-777), art. 21. + ### isValidServicePhone Valida um número de serviço brasileiro, discado sem DDD. Apenas a estrutura é verificada: o número não precisa estar atribuído a ninguém. -- Os Códigos Não Geográficos `0300`, `0303`, `0500`, `0800` e `0900` seguidos de 7 dígitos (11 no total). +- Os Códigos Não Geográficos `0300`, `0303`, `0500`, `0800` e `0900` seguidos de 7 dígitos (11 no total): as séries de 10 dígitos da Resolução Anatel 749/2022, art. 18, discadas atrás do prefixo `0` (art. 28). - Os números abreviados `300X`/`400X`, com 8 dígitos. Outros prefixos de operadora, como `4020` e `4062`, são rejeitados. -- Os códigos de utilidade pública de 3 dígitos designados pela Anatel (ex.: `190`, `192`). O `112` e o `911` não estão entre eles e são rejeitados. +- Os códigos de utilidade pública de 3 dígitos designados pela Anatel (ex.: `190`, `192`), conforme a [página de SUP da Anatel](https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais/servicos-de-utilidade-publica-e-de-emergencia) (modificada em 22/06/2023) e o Anexo do Ato 43.151/2004, o último ato consolidado. A página lista `112/911` para a Polícia Militar no celular: o `112` é aceito; o `911` é rejeitado, porque a Resolução 749/2022, art. 13, deixa toda série fora de `1XX` em reserva técnica. Até a 2.4.0 o `112` também era rejeitado. ```javascript import { isValidServicePhone } from '@brazilian-utils/brazilian-utils'; @@ -911,7 +963,7 @@ isValidServicePhone('190'); // true isValidServicePhone('11987654321'); // false (número geográfico) ``` -Fonte: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749), [Ato Anatel nº 43.151/2004](https://informacoes.anatel.gov.br/legislacao/atos-de-numeracao/2004/1648-ato-43151), [Resolução nº 86/1998](https://informacoes.anatel.gov.br/legislacao/resolucoes/1998/336-resolucao-86). +Fonte: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749), [página de SUP da Anatel](https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais/servicos-de-utilidade-publica-e-de-emergencia), [Ato Anatel nº 43.151/2004](https://informacoes.anatel.gov.br/legislacao/atos-de-numeracao/2004/1648-ato-43151), [Resolução nº 86/1998](https://informacoes.anatel.gov.br/legislacao/resolucoes/1998/336-resolucao-86). ### getAreaCodeInfo @@ -937,7 +989,7 @@ getAreaCodeInfo(-11); // null getAreaCodeInfo(1.1); // null ``` -Fonte: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749), [Códigos Nacionais da Anatel](https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais). +Fonte: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749), [Códigos Nacionais da Anatel](https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais), [tabela da Anatel dos Códigos Nacionais por município (21/09/2026)](https://informacoes.anatel.gov.br/paineis/areas-tarifarias/codigos-nacionais). ### getAreaCodesByState @@ -957,7 +1009,7 @@ getAreaCodesByState('SC'); // [42, 47, 48, 49] getAreaCodesByState('XX'); // [] ``` -Fonte: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749), [Códigos Nacionais da Anatel](https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais). +Fonte: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749), [Códigos Nacionais da Anatel](https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais), [tabela da Anatel dos Códigos Nacionais por município (21/09/2026)](https://informacoes.anatel.gov.br/paineis/areas-tarifarias/codigos-nacionais). ## Placa de veículo @@ -1057,6 +1109,7 @@ Fonte: [Resolução CONTRAN nº 969/2022](https://www.gov.br/transportes/pt-br/a Valida um RENAVAM (Registro Nacional de Veículos Automotores). Aceita o formato antigo (9 dígitos) e o formato novo (11 dígitos). - Espaços, pontos e hífens são ignorados; qualquer outro caractere invalida o valor. +- O dígito verificador é o da Portaria DENATRAN nº 27/2013, art. 1º: "10 dígitos e um dígito verificador, calculado através do módulo 11, peso 9", lido como os pesos 3, 2, 9, 8, 7, 6, 5, 4, 3 e 2. A portaria não escreve os pesos um a um nem diz o que fazer com resto 0, 1 ou 10, o que segue o [validation-br](https://github.com/klawdyo/validation-br/blob/main/src/renavam.ts) e o [brutils](https://github.com/brazilian-utils/python/blob/main/brutils/renavam.py); completar com zeros um código de 9 dígitos até 11 é prática de mercado. ```javascript import { isValidRenavam } from '@brazilian-utils/brazilian-utils'; @@ -1069,6 +1122,8 @@ isValidRenavam('00000000000'); // false (dígitos repetidos) isValidRenavam('ab00639884962'); // false (letras são rejeitadas) ``` +Fonte: [Portaria DENATRAN nº 27/2013](https://www.gov.br/transportes/pt-br/assuntos/transito/arquivos-senatran/portarias/2013/portaria0272013.pdf). + ### generateRenavam Gera um RENAVAM válido aleatório na forma de 11 dígitos: dez dígitos de base mais o dígito verificador. @@ -1086,6 +1141,7 @@ generateRenavam(); // '12345678900' Valida um PIS. Aceita o valor com ou sem máscara. - Um valor com todos os dígitos iguais é rejeitado. +- O dígito verificador usa os pesos 3, 2, 9, 8, 7, 6, 5, 4, 3 e 2 e módulo 11. Nenhum documento oficial encontrado publica esses pesos: os manuais do eSocial e do SIRC dizem só que o número tem 11 dígitos e dígito verificador módulo 11, e os leiautes da Caixa pedem um "Número de PIS/PASEP válido" sem dizer como ele é calculado. Os pesos seguem o [brutils](https://github.com/brazilian-utils/python/blob/main/brutils/pis.py). ```javascript import { isValidPis } from '@brazilian-utils/brazilian-utils'; @@ -1100,7 +1156,7 @@ Formata um PIS. - **Opções** (`FormatPisOptions`): `pad` completa o valor com zeros à esquerda até 11 dígitos antes de aplicar a máscara (padrão `false`); `obfuscate` esconde os 3 primeiros dígitos e o dígito verificador. - O `obfuscate` é aplicado depois do `pad`. -- Nenhuma autoridade publica uma regra de mascaramento para o PIS, então o `obfuscate` usa a que a Lei nº 12.309/2010, art. 87, § 5º define para o CPF ("ocultar os três primeiros dígitos e os dois dígitos verificadores"), um número com a mesma estrutura. +- Nenhuma autoridade publica uma regra de mascaramento para o PIS, então o `obfuscate` usa a que as Leis de Diretrizes Orçamentárias definem para a divulgação do CPF ("ocultar os três primeiros dígitos e os dois dígitos verificadores", Lei nº 14.194/2021, art. 149, regra criada pela Lei nº 12.309/2010, art. 87, § 5º), um número com a mesma estrutura. ```javascript import { formatPis } from '@brazilian-utils/brazilian-utils'; @@ -1130,7 +1186,7 @@ import { generatePis } from '@brazilian-utils/brazilian-utils'; generatePis(); // '91077906857' ``` -Fonte: [Lei nº 12.309/2010, art. 87, § 5º](https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm), a regra de mascaramento do CPF que o `obfuscate` toma emprestada. +Fonte: [Lei nº 14.194/2021, art. 149](https://www.planalto.gov.br/ccivil_03/_ato2019-2022/2021/lei/L14194.htm), a regra de mascaramento do CPF que o `obfuscate` toma emprestada, criada pela [Lei nº 12.309/2010, art. 87, § 5º](https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm) e repetida pelas LDOs seguintes (a de 2026, [Lei nº 15.321/2025, art. 163](https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2025/lei/L15321.htm#art163), a repete). ## Processo jurídico @@ -1151,7 +1207,7 @@ isValidProcessoJuridico('0000100-23.2008.8.28.0000'); // false (não existe 28º isValidProcessoJuridico('ab00020802520125150049'); // false (letras são rejeitadas) ``` -Fonte: [Resolução CNJ nº 65/2008](https://atos.cnj.jus.br/atos/detalhar/119). +Fonte: [Resolução CNJ nº 65/2008](https://atos.cnj.jus.br/atos/detalhar/119), art. 1º (layout, `J` e `TR`) e Anexo VIII ("CÁLCULO DO DÍGITO VERIFICADOR"); o TRF da 6ª Região (`4.06`) conforme a [Resolução CNJ nº 477/2022](https://atos.cnj.jus.br/atos/detalhar/4781). ### formatProcessoJuridico @@ -1204,9 +1260,11 @@ Fonte: [Resolução CNJ nº 65/2008](https://atos.cnj.jus.br/atos/detalhar/119). Valida uma conta bancária brasileira. O `bankCode` precisa ser um participante do STR do Banco Central (a lista que `getBankByCode` usa). - **Parâmetros** (`IsValidBankAccountParams`, todos strings): `bankCode` (3 dígitos), `agency` (1-5 dígitos), `account` (1-13 dígitos) e `digit` (1-2 caracteres, ou `X` para o Banco do Brasil e `P` para o Bradesco). -- Um banco da lista é validado de uma de três formas: pelo algoritmo de dígito verificador publicado, apenas pela estrutura ou por um fallback genérico mod10/mod11. +- Um banco da lista é validado de uma de três formas: por uma regra de dígito verificador, apenas pela estrutura ou por um fallback genérico mod10/mod11. +- Nenhum ato do Banco Central, de outro órgão de governo ou da Febraban define essas regras de dígito verificador. As dos bancos 001, 033, 041, 104, 237, 341, 399 e 745 vêm do compêndio "Regras de Validação de dígito verificador de agência e conta corrente" da Icatu Seguros, uma compilação privada da regra de cada banco. O Nubank não publica regra: o dígito de Verhoeff é o que validadores de código aberto deduziram de contas reais. +- Os únicos textos oficiais sobre esses dígitos dizem que não há regra comum: o [Layout Padrão CNAB 240 v11.0](https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20padrao%20CNAB240%20V%2011_0%20-%202026_09_11.pdf) da FEBRABAN (11/09/2026), notas G009, G011 e G012, chama cada um deles de "código adotado pelo Banco" e não dá algoritmo, e a API do DICT do Banco Central recebe a conta com o dígito e não calcula nada. -Bancos validados pelo algoritmo de dígito verificador publicado: +Bancos validados por uma regra de dígito verificador: | Banco | Código | Agência | Conta | Observações | | --- | --- | --- | --- | --- | @@ -1215,12 +1273,12 @@ Bancos validados pelo algoritmo de dígito verificador publicado: | Banrisul | `041` | 4 dígitos | 9 dígitos | pesos `3,2,4,7,6,5,4,3,2`; resto 0 gera `0` e resto 1 gera `6`; `account` é tipo (2 dígitos) + conta (7 dígitos) | | Caixa Econômica Federal | `104` | 4 dígitos | 11 dígitos | mod11 sobre agência + conta; `account` é operação (3 dígitos) + conta (8 dígitos) | | Bradesco | `237` | 4 dígitos | 7 dígitos | mod11 com pesos 2..7 ciclando da direita para a esquerda; resto 0 gera `0` e resto 1 gera `"P"` | -| Nubank | `260` | 4 dígitos | 5-13 dígitos | dígito de Verhoeff sobre a conta, ignorando zeros à esquerda | +| Nubank | `260` | 4 dígitos | 5-13 dígitos | dígito de Verhoeff sobre a conta, ignorando zeros à esquerda (sem regra publicada; veja acima) | | Itaú Unibanco | `341` | 4 dígitos | 5 dígitos | mod10 sobre agência + conta | | HSBC / Kirton Bank | `399` | 4 dígitos | 6 dígitos | pesos `8,9,2,3,4,5,6,7,8,9` sobre agência + conta; resto 10 gera `0` | | Citibank | `745` | 4 dígitos | 10 dígitos | pesos `11..2` sobre a conta; resto 0 ou 1 gera `0` | -Bancos validados apenas pela estrutura (um único `digit` numérico basta): +Bancos validados apenas pela estrutura, já que não se conhece regra de dígito verificador deles (um único `digit` numérico basta): | Banco | Código | | Banco | Código | | --- | --- | --- | --- | --- | @@ -1303,7 +1361,7 @@ isValidBankAccount({ }); // true (Banco ABC Brasil, fallback genérico mod10) ``` -Fonte: [lista de participantes do STR](https://www.bcb.gov.br/content/estabilidadefinanceira/str1/ParticipantesSTR.csv), [Regras de Validação de dígito verificador](https://github.com/eduardokum/laravel-boleto/blob/master/manuais/Regras%20Validacao%20Conta%20Corrente%20VI_EPS.pdf). +Fonte: [lista de participantes do STR](https://www.bcb.gov.br/content/estabilidadefinanceira/str1/ParticipantesSTR.csv) (oficial). Baseado em: o compêndio da Icatu Seguros [Regras de Validação de dígito verificador de agência e conta corrente](https://github.com/eduardokum/laravel-boleto/blob/master/manuais/Regras%20Validacao%20Conta%20Corrente%20VI_EPS.pdf) e, para o Nubank, o [bran_checker](https://github.com/Xerpa/bran_checker/tree/master/lib/banks). ### getBanks @@ -1363,7 +1421,8 @@ Fonte: [lista de participantes do STR](https://www.bcb.gov.br/content/estabilida Valida um IBAN (International Bank Account Number) brasileiro. Somente IBANs brasileiros (código de país `BR`) são reconhecidos; qualquer outro país retorna `false`. -- Layout, 29 caracteres: `BR`, 2 dígitos verificadores (ISO 7064 MOD 97-10), ISPB de 8 dígitos, agência de 5, conta de 10, 1 letra de tipo de conta, 1 indicador de titularidade. +- Layout, 29 caracteres (Resolução BCB 585/2026, art. 2º, que revogou a Circular BCB 3.625/2013 e manteve o layout): `BR`, 2 dígitos verificadores (ISO 7064 MOD 97-10), ISPB de 8 caracteres, agência de 5, conta de 10, 1 letra de tipo de conta, 1 indicador de titularidade. +- O ISPB pode ter letras: a Resolução o define como "oito caracteres alfanuméricos", onde a Circular dizia "numéricos". Até a 2.4.0 só dígitos eram aceitos. - Tipo de conta: qualquer letra, normalmente `C` ou `P`. Titularidade: `1` a `9`, depois `A` a `Z`. - Aceita a forma compacta ou grupos de 4 separados por um espaço, `.`, `-` ou `/`, em maiúsculas ou minúsculas. @@ -1373,12 +1432,13 @@ import { isValidIban } from '@brazilian-utils/brazilian-utils'; isValidIban('BR1500000000000010932840814P2'); // true isValidIban('BR15 0000 0000 0000 1093 2840 814P 2'); // true (espaços de agrupamento) isValidIban('BR15-0000-0000-0000-1093-2840-814P-2'); // true (qualquer um dos caracteres de máscara) +isValidIban('BR1012AB34CD000010932840814P2'); // true (ISPB alfanumérico) isValidIban('BR1500000000000010932840814P3'); // false (dígitos verificadores inválidos) isValidIban('BR15 000 00000 0000 1093 2840 814P 2'); // false (separador dentro de um grupo) isValidIban('DE89370400440532013000'); // false (IBAN não brasileiro) ``` -Fonte: [Diretrizes de Implementação do IBAN no Brasil](https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf), [Circular BCB nº 3.625/2013](https://www.bcb.gov.br/pre/normativos/circ/2013/pdf/circ_3625_v1_O.pdf), [ISO 13616-1:2020](https://www.iso.org/standard/81090.html). +Fonte: [Diretrizes de Implementação do IBAN no Brasil](https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf), [Resolução BCB nº 585/2026](https://www.bcb.gov.br/estabilidadefinanceira/exibenormativo?tipo=Resolu%C3%A7%C3%A3o%20BCB&numero=585), que revogou a [Circular BCB nº 3.625/2013](https://www.bcb.gov.br/pre/normativos/circ/2013/pdf/circ_3625_v1_O.pdf), [ISO 13616-1:2020](https://www.iso.org/standard/81090.html). ### formatIban @@ -1431,7 +1491,7 @@ getIbanInfo('DE89370400440532013000'); // null (IBAN não brasileiro) getIbanInfo('BR15 000 00000 0000 1093 2840 814P 2'); // null (separador dentro de um grupo) ``` -Fonte: [Diretrizes de Implementação do IBAN no Brasil](https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf), [Circular BCB nº 3.625/2013](https://www.bcb.gov.br/pre/normativos/circ/2013/pdf/circ_3625_v1_O.pdf), [ISO 13616-1:2020](https://www.iso.org/standard/81090.html). +Fonte: [Diretrizes de Implementação do IBAN no Brasil](https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf), [Resolução BCB nº 585/2026](https://www.bcb.gov.br/estabilidadefinanceira/exibenormativo?tipo=Resolu%C3%A7%C3%A3o%20BCB&numero=585), que revogou a [Circular BCB nº 3.625/2013](https://www.bcb.gov.br/pre/normativos/circ/2013/pdf/circ_3625_v1_O.pdf), [ISO 13616-1:2020](https://www.iso.org/standard/81090.html). ## Moeda, números e datas por extenso @@ -1695,7 +1755,7 @@ Retorna os municípios brasileiros publicados pelo IBGE: todos os municípios, o - Cada município (`Municipality`) é `{ code, name, stateCode }`, onde `code` é o código IBGE de 7 dígitos. Ordenados por nome no locale "pt-BR". - Só um `stateCode` omitido (ou `undefined`) pede a lista completa: `null` e `''` retornam `[]`. - `stateCode` diferencia maiúsculas de minúsculas: `'sp'`, como um código desconhecido, retorna `[]`. -- Embute todos os 5571 municípios. Veja [Tamanho do bundle](pt-br/getting-started.md#tamanho-do-bundle) para carregá-lo sob demanda via `@brazilian-utils/brazilian-utils/get-municipalities`. +- Embute todos os 5571 municípios, os mesmos códigos da [Divisão Territorial Brasileira 2025](https://geoftp.ibge.gov.br/organizacao_do_territorio/estrutura_territorial/divisao_territorial/2025/DTB_2025.zip) do IBGE (data base 31/12/2025). Veja [Tamanho do bundle](pt-br/getting-started.md#tamanho-do-bundle) para carregá-lo sob demanda via `@brazilian-utils/brazilian-utils/get-municipalities`. ```javascript import { getMunicipalities } from '@brazilian-utils/brazilian-utils'; @@ -1855,7 +1915,13 @@ Retorna os feriados brasileiros de um ano: os nacionais e, com um `stateCode`, t - Cada feriado é um `Holiday` cujo `type` (`HolidayType`) é `"national"`, `"state"`, `"optional"` ou `"religious"`. Os feriados vêm ordenados por data. - O "Dia da Consciência Negra", 20/11, é nacional a partir de 2024. -- As regras por estado (o deslocamento para domingo em SC, o Corpus Christi no DF, datas que deixaram de ser feriado) seguem a lei de cada estado; veja a fonte para a lista. +- O primeiro turno das eleições, "Eleições (primeiro turno)", é feriado nacional nos anos pares a partir de 1998 (Código Eleitoral, art. 380): o primeiro domingo de outubro, ou 15/11 em 2020 (EC nº 107/2020). O segundo turno fica de fora, porque só acontece onde é necessário. Por cair num domingo, nunca muda uma contagem de dias úteis. +- Cada feriado nacional de data fixa só é listado nos anos em que uma norma federal o declarava (a Sexta-feira Santa, que as portarias do calendário federal listam como feriado nacional todo ano, é listada em todos os anos): Nossa Senhora Aparecida a partir de 1980, Natal a partir de 1922, Dia do trabalhador a partir de 1925, Tiradentes até 1930, de 1933 a 1948 e a partir de 1951, e Finados até 1948 e a partir de 2003. A Lei nº 662/1949 deixou Finados fora dos feriados nacionais que o Decreto-lei nº 486/1938 listava, e só a Lei nº 10.607/2002 o recolocou (o parecer da Câmara sobre o projeto: "Só inova ao sugerir o dia de finados"); até a 2.4.0 ele era listado em todos os anos. +- As entradas `"optional"` são os pontos facultativos de dia inteiro do calendário federal (Portarias MGI nº 8.617/2023, 9.783/2024 e 11.460/2025, de 2024 a 2026, que listam as duas datas de Carnaval como ponto facultativo, nunca como feriado nacional): a segunda e a terça-feira de Carnaval e o Corpus Christi, os mesmos três dias que o mercado financeiro não conta como úteis (Resolução CMN nº 4.880/2020), mais os estaduais que uma norma estadual declara (o 16/09 de AL, de 2020 a 2023, o 08/12 do AM, que o estado declara para as suas repartições por decreto, e o 06/03 de PE em 2008 e 2009). O `includeOptional` liga e desliga exatamente esses. Os parciais ficam de fora: a Quarta-feira de Cinzas (até as 14h), 28/10 (Dia do Servidor Público) e as tardes de 24/12 e 31/12. +- As regras por estado (o deslocamento para domingo em SC da data que cai de segunda a sexta, ficando na data a que cai no sábado, já que o "dia útil da semana" da lei não resolve o sábado; o 30/11 de AL antecipado para segunda quando cai na terça e adiado para sexta quando cai na quinta, o Corpus Christi no DF e, desde 2024, no MA, e a terça-feira de Carnaval no RJ com tipo `"state"`, datas que deixaram de ser feriado) seguem a lei de cada estado; veja a fonte para a lista. +- Outros deslocamentos não são aplicados e a data da lei é a retornada: a lei do AC adia para a sexta-feira os feriados que caem de terça a quinta (Lei AC nº 2.126/2009), mas os próprios decretos anuais do estado a aplicam de forma desigual (em 2026 o 20/1 é adiado e o 17/11, uma terça, fica na data). +- As três datas de GO (26/7, 24/10, 28/10) são os "feriados estaduais" do estatuto dos servidores do estado (Lei GO nº 20.756/2020, art. 269, II); não foi achada lei goiana que fixe uma data magna como feriado civil. O governador transfere o 26/7 por decreto todo ano (2025: 28/7; 2026: 20/7), então o 26/7 da lei, que é o retornado aqui, em geral não é o dia observado. +- Cada feriado estadual só é listado a partir do primeiro ano em que a sua lei estadual se aplicava (o 9 de julho de SP a partir de 1997, o São Jorge do RJ a partir de 2008, o 11 de agosto de SC a partir de 2004), então um ano mais antigo tem menos feriados estaduais. - Um `stateCode` desconhecido ou que não é string é ignorado e só os feriados nacionais são retornados. - Retorna `[]` quando o ano não é um inteiro de 1900 a 2099, ou quando o argumento não é nem número nem objeto. @@ -1866,9 +1932,11 @@ import { getHolidays } from '@brazilian-utils/brazilian-utils'; getHolidays(2024); // [ // { name: 'Ano novo', date: Date('2024-01-01'), type: 'national' }, +// { name: 'Carnaval (segunda-feira)', date: Date('2024-02-12'), type: 'optional' }, // { name: 'Carnaval (terça-feira)', date: Date('2024-02-13'), type: 'optional' }, // { name: 'Sexta-feira Santa', date: Date('2024-03-29'), type: 'national' }, // { name: 'Páscoa', date: Date('2024-03-31'), type: 'religious' }, +// { name: 'Eleições (primeiro turno)', date: Date('2024-10-06'), type: 'national' }, // { name: 'Dia da Consciência Negra', date: Date('2024-11-20'), type: 'national' }, // // ... mais feriados // ] @@ -1900,8 +1968,8 @@ isHoliday(); // false Verifica se uma data é dia útil no Brasil: não é sábado, domingo nem um feriado que `getHolidays` lista para o seu dia de calendário local. -- **Opções** (`BusinessDayOptions`, as mesmas de todos os utilitários de dias úteis): `includeOptional` (padrão `true`) também conta os feriados `"optional"`, Carnaval e Corpus Christi, como dias não úteis; `includeSaturday` (padrão `false`) conta o sábado como dia útil; `stateCode` também conta os feriados daquele estado. -- Com `includeSaturday` desligado, é a contagem de segunda a sexta usada por bancos e tribunais. Ligado, é a contagem trabalhista do prazo de pagamento do salário do art. 459, § 1º, da CLT, a que a fiscalização do trabalho lê pela Instrução Normativa MTP nº 2/2021, art. 14, I: "na contagem dos dias será incluído o sábado, excluindo-se o domingo e o feriado, inclusive o municipal". +- **Opções** (`BusinessDayOptions`, as mesmas de todos os utilitários de dias úteis): `includeOptional` (padrão `true`) também conta os feriados `"optional"`, a segunda e a terça-feira de Carnaval e o Corpus Christi, como dias não úteis; `includeSaturday` (padrão `false`) conta o sábado como dia útil; `stateCode` também conta os feriados daquele estado. +- Com `includeSaturday` desligado, é uma contagem de segunda a sexta. Ela não é, por si só, o calendário de bancos ou tribunais: o mercado financeiro também não conta a segunda e a terça-feira de Carnaval e o Corpus Christi (Resolução CMN nº 4.880/2020, art. 6º), que o `includeOptional` padrão cobre, e os bancos fecham nos feriados locais; a Justiça Federal também fecha de 20/12 a 6/1, de quarta-feira santa ao domingo de Páscoa, na segunda e na terça-feira de Carnaval, em 11/8, 1º e 2/11 e 8/12 (Lei nº 5.010/1966, art. 62), e os prazos processuais seguem o calendário de cada tribunal (CPC, art. 216). Ligado, é a contagem trabalhista do prazo de pagamento do salário do art. 459, § 1º, da CLT, a que a fiscalização do trabalho lê pela Instrução Normativa MTP nº 2/2021, art. 14, I: "na contagem dos dias será incluído o sábado, excluindo-se o domingo e o feriado, inclusive o municipal". - Com `includeSaturday` ligado, o domingo e os feriados continuam excluídos, então um feriado que cai em um sábado continua não sendo dia útil. - O trecho "inclusive o municipal" dessa regra não é coberto: `getHolidays` tem apenas feriados nacionais e estaduais, então um feriado municipal é contado aqui como dia útil comum. Retire os feriados municipais por conta própria quando a contagem precisar ser exata para um município. - Retorna `false` quando `value` não é um `Date` válido ou o seu ano está fora de 1900 a 2099, ou quando `stateCode` está presente e não é string. @@ -1915,7 +1983,8 @@ isBusinessDay(new Date(2024, 0, 6)); // false (sábado) isBusinessDay(new Date(2024, 0, 6), { includeSaturday: true }); // true (contagem trabalhista) isBusinessDay(new Date(2024, 8, 7), { includeSaturday: true }); // false (Independência, feriado em um sábado) isBusinessDay(new Date(2024, 0, 7), { includeSaturday: true }); // false (o domingo nunca é incluído) -isBusinessDay(new Date(2024, 1, 13)); // false (Carnaval, feriado facultativo, conta por padrão) +isBusinessDay(new Date(2024, 1, 12)); // false (segunda-feira de Carnaval, feriado facultativo, conta por padrão) +isBusinessDay(new Date(2024, 1, 13)); // false (terça-feira de Carnaval, feriado facultativo, conta por padrão) isBusinessDay(new Date(2024, 1, 13), { includeOptional: false }); // true isBusinessDay(new Date(2024, 6, 9), { stateCode: 'SP' }); // false (Revolução Constitucionalista) isBusinessDay(new Date(2024, 6, 9)); // true (feriado estadual ignorado sem stateCode) @@ -1926,7 +1995,7 @@ isBusinessDay(new Date('not a date')); // false Soma dias úteis a uma data, pulando sábados, domingos e os feriados que `isBusinessDay` considera. Assinatura: `addBusinessDays(date, amount, options?)`, a mesma do date-fns. -- **Opções** (`BusinessDayOptions`, as mesmas de `isBusinessDay`): `includeOptional` (padrão `true`) também pula Carnaval e Corpus Christi; `includeSaturday` (padrão `false`) conta o sábado como dia útil; `stateCode` também pula os feriados daquele estado. +- **Opções** (`BusinessDayOptions`, as mesmas de `isBusinessDay`): `includeOptional` (padrão `true`) também pula a segunda e a terça-feira de Carnaval e o Corpus Christi; `includeSaturday` (padrão `false`) conta o sábado como dia útil; `stateCode` também pula os feriados daquele estado. - Retorna um novo `Date`, com o horário preservado; `date` não é alterado. - `amount` igual a `0` retorna a mesma data, mesmo em fim de semana ou feriado. Um `amount` negativo anda para trás. - Retorna `null` quando `date` é inválido, `amount` não é um inteiro finito, `stateCode` não é string ou o resultado sai dos anos de 1900 a 2099. @@ -1973,14 +2042,14 @@ import { addBusinessDays, subBusinessDays } from '@brazilian-utils/brazilian-uti // n-ésimo dia útil do mês: some n a partir do último dia do mês anterior addBusinessDays(new Date(2024, 0, 0), 5); // Date, 2024-01-08 00:00 (5º dia útil de janeiro de 2024) -addBusinessDays(new Date(2024, 1, 0), 10); // Date, 2024-02-15 00:00 (10º de fevereiro de 2024, Carnaval pulado) +addBusinessDays(new Date(2024, 1, 0), 10); // Date, 2024-02-16 00:00 (10º de fevereiro de 2024, segunda e terça-feira de Carnaval puladas) // último dia útil do mês: subtraia 1 a partir do primeiro dia do mês seguinte subBusinessDays(new Date(2024, 3, 1), 1); // Date, 2024-03-28 00:00 (2024-03-29 é Sexta-feira Santa, seguida de um fim de semana) subBusinessDays(new Date(2024, 1, 1), 2); // Date, 2024-01-30 00:00 (penúltimo de janeiro de 2024) // prazo do salário do art. 459, § 1º, da CLT: o 5º dia útil na contagem trabalhista -addBusinessDays(new Date(2024, 2, 0), 5, { includeSaturday: true }); // Date, 2024-03-06 00:00 (2024-03-02, um sábado, conta; a contagem bancária dá 2024-03-07) +addBusinessDays(new Date(2024, 2, 0), 5, { includeSaturday: true }); // Date, 2024-03-06 00:00 (2024-03-02, um sábado, conta; a contagem de segunda a sexta dá 2024-03-07) addBusinessDays(new Date(2024, 10, 0), 5, { includeSaturday: true }); // Date, 2024-11-07 00:00 (2024-11-02 é Finados, um feriado num sábado) subBusinessDays(new Date(2024, 8, 1), 1, { includeSaturday: true }); // Date, 2024-08-31 00:00 (último dia útil de agosto de 2024, um sábado) ``` @@ -1993,7 +2062,7 @@ subBusinessDays(new Date(2024, 8, 1), 1, { includeSaturday: true }); // Date, 20 Conta os dias úteis entre duas datas. Assinatura: `differenceInBusinessDays(laterDate, earlierDate, options?)`, a mesma do date-fns. -- **Opções** (`BusinessDayOptions`, as mesmas de `isBusinessDay`): `includeOptional` (padrão `true`) também pula Carnaval e Corpus Christi; `includeSaturday` (padrão `false`) conta o sábado como dia útil; `stateCode` também pula os feriados daquele estado. +- **Opções** (`BusinessDayOptions`, as mesmas de `isBusinessDay`): `includeOptional` (padrão `true`) também pula a segunda e a terça-feira de Carnaval e o Corpus Christi; `includeSaturday` (padrão `false`) conta o sábado como dia útil; `stateCode` também pula os feriados daquele estado. - Conta `earlierDate` quando é dia útil e cada dia útil estritamente entre as duas datas; `laterDate` nunca é contado. O horário é ignorado. - O resultado é negativo quando `laterDate` é anterior a `earlierDate`, e `0` no mesmo dia de calendário. - Retorna `null` quando uma das datas não é um `Date` válido ou está fora dos anos de 1900 a 2099, ou quando `stateCode` não é string. @@ -2005,7 +2074,7 @@ differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 1)); // 0 (01/0 differenceInBusinessDays(new Date(2024, 0, 3), new Date(2024, 0, 2)); // 1 (02/01 contado, uma terça-feira; 03/01 não) differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 3)); // -1 (a data posterior vem primeiro, então a contagem é negativa) differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 2)); // 0 (mesmo dia) -differenceInBusinessDays(new Date(2024, 0, 8), new Date(2024, 0, 1)); // 4 (contagem bancária, de 2024-01-02 a 2024-01-05) +differenceInBusinessDays(new Date(2024, 0, 8), new Date(2024, 0, 1)); // 4 (contagem de segunda a sexta, de 2024-01-02 a 2024-01-05) differenceInBusinessDays(new Date(2024, 0, 8), new Date(2024, 0, 1), { includeSaturday: true }); // 5 (2024-01-06, um sábado, também conta) differenceInBusinessDays(new Date(2024, 10, 4), new Date(2024, 10, 1), { includeSaturday: true }); // 1 (2024-11-02 é Finados, feriado em um sábado) differenceInBusinessDays(new Date(2024, 6, 10), new Date(2024, 6, 8), { stateCode: 'SP' }); // 1 (09/07/2024 é feriado estadual em SP) @@ -2019,6 +2088,7 @@ differenceInBusinessDays(new Date(), new Date('not a date')); // null Valida um número de passaporte brasileiro: 2 letras seguidas de 6 dígitos. - Não há dígito verificador, então um número bem formado não é necessariamente um passaporte real. +- As 2 letras (a "série") e os 6 dígitos vêm do FAQ da Polícia Federal; nenhuma norma define o número (nem o Decreto nº 5.978/2006 nem a IN nº 173-DG/PF/2020, alterada até a IN nº 283/2024), e o FAQ não lista letra proibida. ```javascript import { isValidPassport } from '@brazilian-utils/brazilian-utils'; @@ -2070,7 +2140,7 @@ generatePassport(); // 'RY393097' Valida uma CNH. Espaços, pontos e hífens são ignorados; qualquer outro caractere invalida o valor. - Um valor cujos 11 dígitos são todos iguais é rejeitado, então `'11111111111'` é inválido. -- O primeiro dígito verificador mantém o resto 1 como `1`, como nos números reais de registro. A Resolução CONTRAN nº 886/2021 diz `0`. +- O primeiro dígito verificador mantém o resto 1 como `1`, como nos números reais de registro. O art. 4º, § 1º, da Resolução CONTRAN nº 886/2021, em que o resto 0 ou 1 dá `0`, fala em "O dígito verificador" sem dizer de qual número: escrito no singular logo depois do Número do Espelho da CNH (o único número do artigo com um só dígito verificador), ele se lê melhor como a regra desse dígito, mas a redação é genérica e não traz pesos, então não serve de fonte para os 2 dígitos verificadores do número de registro; nenhum texto oficial publica os pesos deles. As Resoluções CONTRAN nº 976/2022, nº 998/2023 e nº 1.006/2024 alteram a Resolução nº 886/2021, nenhuma delas no art. 4º. A Resolução CONTRAN nº 1.020/2025, a norma de habilitação mais nova, repete o layout no art. 10 ("nove caracteres e dois dígitos verificadores") sem regra de dígito verificador e não revoga a 886 (art. 140). ```javascript import { isValidCnh } from '@brazilian-utils/brazilian-utils'; @@ -2080,7 +2150,7 @@ isValidCnh('000000001-19'); // true (hífen antes dos dígitos verificadores) isValidCnh('ab00000000119'); // false (letras são rejeitadas) ``` -Fonte: [Resolução CONTRAN nº 886/2021, art. 4º](https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/Resolucao8862021F.pdf); pesos conforme o [siga0984](https://siga0984.wordpress.com/2019/05/01/algoritmos-validacao-de-cnh/). +Fonte: [Resolução CONTRAN nº 886/2021, art. 4º](https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/Resolucao8862021F.pdf), [Resolução CONTRAN nº 1.020/2025, art. 10](https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/Resolucao10202025.pdf); pesos conforme o [siga0984](https://siga0984.wordpress.com/2019/05/01/algoritmos-validacao-de-cnh/). ### formatCnh @@ -2088,7 +2158,7 @@ Formata uma CNH. - **Opções** (`FormatCnhOptions`): `pad` completa o valor com zeros à esquerda até os 11 dígitos antes de aplicar a máscara (padrão `false`); `obfuscate` esconde os 3 primeiros dígitos e os 2 dígitos verificadores. - O `obfuscate` é aplicado depois do `pad`. -- Nenhuma autoridade publica uma regra de mascaramento para a CNH, então o `obfuscate` usa a que a Lei nº 12.309/2010, art. 87, § 5º define para o CPF ("ocultar os três primeiros dígitos e os dois dígitos verificadores"), um número com a mesma estrutura. +- Nenhuma autoridade publica uma regra de mascaramento para a CNH, então o `obfuscate` usa a que as Leis de Diretrizes Orçamentárias definem para a divulgação do CPF ("ocultar os três primeiros dígitos e os dois dígitos verificadores", Lei nº 14.194/2021, art. 149, regra criada pela Lei nº 12.309/2010, art. 87, § 5º), um número com a mesma estrutura. ```javascript import { formatCnh } from '@brazilian-utils/brazilian-utils'; @@ -2118,7 +2188,7 @@ import { generateCnh } from '@brazilian-utils/brazilian-utils'; generateCnh(); // '02650306461' ``` -Fonte: [Lei nº 12.309/2010, art. 87, § 5º](https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm), a regra de mascaramento do CPF que o `obfuscate` toma emprestada. +Fonte: [Lei nº 14.194/2021, art. 149](https://www.planalto.gov.br/ccivil_03/_ato2019-2022/2021/lei/L14194.htm), a regra de mascaramento do CPF que o `obfuscate` toma emprestada, criada pela [Lei nº 12.309/2010, art. 87, § 5º](https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm) e repetida pelas LDOs seguintes (a de 2026, [Lei nº 15.321/2025, art. 163](https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2025/lei/L15321.htm#art163), a repete). ## Natureza jurídica @@ -2211,7 +2281,8 @@ getLegalNature('2208'); // } getLegalNature('3123')?.currentCode; // null (extinto sem sucessor) getLegalNature('206-2')?.code; // '2062' -getLegalNature(206.2)?.category.description; // 'Entidades Empresariais' +getLegalNature('206.2')?.category.description; // 'Entidades Empresariais' +getLegalNature(206.2); // null (um número só é lido quando é um inteiro seguro não negativo: escreva a forma com ponto como string) getLegalNature('0000'); // null ``` @@ -2262,10 +2333,12 @@ getLegalNaturesByCategory('9'); // [] ### isValidVoterId -Valida um título de eleitor. Aceita o título padrão de 12 dígitos e o título de 13 dígitos expedido por São Paulo (UF `01`) e Minas Gerais (UF `02`). +Valida um título de eleitor. Um título tem no máximo 12 dígitos, então um valor de 13 dígitos é rejeitado. - Um título é um número sequencial de 8 dígitos, um código de unidade federativa de 2 dígitos (`01` a `28`) e 2 dígitos verificadores. +- O TSE despreza os zeros à esquerda do número sequencial na emissão, então um valor mais curto é lido como o título sem eles e completado com zeros à esquerda até 12 dígitos antes da validação (`123450159` é validado como `000123450159`). É preciso ao menos um dígito sequencial: o menor valor aceito tem 5 dígitos. - Espaços e pontos são aceitos ao redor e entre os grupos. Qualquer outro caractere, inclusive um hífen, invalida o valor. +- A Resolução TSE nº 23.659/2021, art. 36, que revogou a Resolução TSE nº 21.538/2003 (art. 140), fixa o layout, a tabela das unidades federativas e dois dígitos verificadores "determinados com base no 'Módulo 11'". Ela não traz pesos nem regra por estado: os pesos e a regra que troca o resto 0 por 1 para São Paulo (`01`) e Minas Gerais (`02`) não têm fonte oficial e seguem as referências da comunidade abaixo. ```javascript import { generateVoterId, isValidVoterId } from '@brazilian-utils/brazilian-utils'; @@ -2274,38 +2347,40 @@ const voterId = generateVoterId('SP'); isValidVoterId(voterId); // true isValidVoterId('102385010671'); // true (12 dígitos) -isValidVoterId('1234567880191'); // true (13 dígitos, São Paulo) +isValidVoterId('123450159'); // true (000123450159 emitido sem os zeros à esquerda) +isValidVoterId('1234567880191'); // false (13 dígitos, mais que os 12 que o TSE permite) isValidVoterId('123456780124'); // false (dígitos verificadores inválidos) ``` -Fonte: [Resolução TSE nº 23.659/2021, art. 36](https://www.tse.jus.br/legislacao/compilada/res/2021/resolucao-no-23-659-de-26-de-outubro-de-2021), [brutils](https://github.com/brazilian-utils/python/blob/main/brutils/voter_id.py) e [siga0984](https://siga0984.wordpress.com/2019/05/01/algoritmos-validacao-de-titulo-de-eleitor/). +Fonte: [Resolução TSE nº 23.659/2021, art. 36](https://www.tse.jus.br/legislacao/compilada/res/2021/resolucao-no-23-659-de-26-de-outubro-de-2021) ("composto por até 12 algarismos", "os oito primeiros algarismos serão sequenciados, desprezando-se, na emissão, os zeros à esquerda"), [brutils](https://github.com/brazilian-utils/python/blob/main/brutils/voter_id.py) e [siga0984](https://siga0984.wordpress.com/2019/05/01/algoritmos-validacao-de-titulo-de-eleitor/). ### formatVoterId Formata um título de eleitor com o agrupamento de 12 dígitos `0000 0000 00 00`. -- **Opções** (`FormatVoterIdOptions`): `obfuscate` esconde os 3 primeiros dígitos e os 2 dígitos verificadores, deixando visível o código da unidade federativa. -- O agrupamento de 13 dígitos `0000 0000 0 00 00` só é usado quando o valor tem mais de 12 dígitos e o código da UF (o 10º e o 11º dígitos) é `01` ou `02`. -- Os dígitos além da última posição do padrão são descartados. -- Nenhuma autoridade publica uma regra de mascaramento para o título de eleitor, então o `obfuscate` usa a que a Lei nº 12.309/2010, art. 87, § 5º define para o CPF ("ocultar os três primeiros dígitos e os dois dígitos verificadores"), um número com a mesma estrutura. +- **Opções** (`FormatVoterIdOptions`): `pad` completa o valor com zeros à esquerda até 12 dígitos, restaurando os zeros de um título emitido sem eles; `obfuscate` esconde os 3 primeiros dígitos e os 2 dígitos verificadores, deixando visível o código da unidade federativa. A máscara esconde por posição, então use `pad` junto com `obfuscate` para um título passado como número, que perdeu os zeros à esquerda: sem ele a máscara cai sobre os dígitos verificadores. +- Sem `pad`, um valor mais curto é formatado a partir da esquerda, como um título digitado pela metade. +- Os dígitos além do 12º são descartados. +- Nenhuma autoridade publica uma regra de mascaramento para o título de eleitor, então o `obfuscate` usa a que as Leis de Diretrizes Orçamentárias definem para a divulgação do CPF ("ocultar os três primeiros dígitos e os dois dígitos verificadores", Lei nº 14.194/2021, art. 149, regra criada pela Lei nº 12.309/2010, art. 87, § 5º), um número com a mesma estrutura. ```javascript import { formatVoterId } from '@brazilian-utils/brazilian-utils'; formatVoterId('123456780175'); // '1234 5678 01 75' formatVoterId('123456780175', { obfuscate: true }); // '***4 5678 01 **' -formatVoterId('1234567880191'); // '1234 5678 8 01 91' (título de 13 dígitos SP/MG) +formatVoterId('123450159', { pad: true }); // '0001 2345 01 59' +formatVoterId('123450159'); // '1234 5015 9' (lido como um título digitado pela metade) ``` ### parseVoterId -Remove a formatação do título de eleitor, mantém apenas os dígitos e limita o resultado a 12 dígitos (13 quando os dígitos da UF identificam São Paulo ou Minas Gerais). +Remove a formatação do título de eleitor, mantém apenas os dígitos e limita o resultado a 12 dígitos. Um valor mais curto é mantido como está, sem acrescentar zeros à esquerda. ```javascript import { parseVoterId } from '@brazilian-utils/brazilian-utils'; parseVoterId('1234 5678 01 75'); // '123456780175' -parseVoterId('1234 5678 8 01 91'); // '1234567880191' (título de 13 dígitos SP/MG) +parseVoterId('12345 01 59'); // '123450159' ``` ### generateVoterId @@ -2313,7 +2388,7 @@ parseVoterId('1234 5678 8 01 91'); // '1234567880191' (título de 13 dígitos SP Gera um título de eleitor válido aleatório. O argumento opcional `state` (`StateCode`, ou `"ZZ"` para um título expedido no exterior) define o código de unidade federativa. - Uma UF desconhecida, ou um valor que não seja string, usa `"ZZ"` (UF `28`). -- O resultado sempre tem 12 dígitos, nunca a forma de 13 dígitos de São Paulo ou Minas Gerais. +- O resultado sempre tem 12 dígitos, com os zeros à esquerda do número sequencial; o mesmo título sem eles também é válido. ```javascript import { generateVoterId } from '@brazilian-utils/brazilian-utils'; @@ -2323,7 +2398,7 @@ generateVoterId('SP'); // título de eleitor aleatório válido de São Paulo generateVoterId('XX'); // usa "ZZ" em vez de lançar erro ``` -Fonte: [Lei nº 12.309/2010, art. 87, § 5º](https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm), a regra de mascaramento do CPF que o `obfuscate` toma emprestada. +Fonte: [Lei nº 14.194/2021, art. 149](https://www.planalto.gov.br/ccivil_03/_ato2019-2022/2021/lei/L14194.htm), a regra de mascaramento do CPF que o `obfuscate` toma emprestada, criada pela [Lei nº 12.309/2010, art. 87, § 5º](https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm) e repetida pelas LDOs seguintes (a de 2026, [Lei nº 15.321/2025, art. 163](https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2025/lei/L15321.htm#art163), a repete). ## CNS @@ -2332,7 +2407,7 @@ Fonte: [Lei nº 12.309/2010, art. 87, § 5º](https://www.planalto.gov.br/ccivil Valida um número de CNS (Cartão Nacional de Saúde), o identificador do SUS (Sistema Único de Saúde) de um usuário, profissional ou estabelecimento de saúde. O valor precisa ser os 15 dígitos, opcionalmente separados nos grupos impressos de 3-4-4-4 por espaço, `.`, `-` ou `/`. - Cartões definitivos começam com 1 ou 2, provisórios com 7, 8 ou 9; cada um tem sua própria regra de módulo 11. -- Um número iniciado em 5 é rejeitado, seguindo a ANVISA. +- Um número iniciado em 5 é rejeitado. As rotinas de validação do DATASUS ([cópia no Wayback Machine](https://web.archive.org/web/20190106003442/http://cartaonet.datasus.gov.br/Rotina_JavaScript.doc) do arquivo que o site cartaonet.datasus.gov.br publicava) cobrem só os números iniciados em 1 ou 2 (definitivo) e em 7, 8 ou 9 (provisório), como a ANVISA; nenhum documento oficial cita o prefixo 5, que a página do e-SUS APS aceita. ```javascript import { isValidCns } from '@brazilian-utils/brazilian-utils'; @@ -2341,12 +2416,13 @@ isValidCns('123456789010000'); // true (definitivo) isValidCns('100000000060018'); // true (definitivo, dígito bruto 10, sufixo 001) isValidCns('700000000000005'); // true (provisório) isValidCns('123.4567-8901/0000'); // true (qualquer um dos caracteres de máscara) +isValidCns(-123456789010000); // false (não é um inteiro seguro não negativo) isValidCns('123456789010001'); // false (dígito verificador inválido) isValidCns('12345678901'); // false (tamanho inválido) isValidCns('abc123456789010000'); // false (não escrito como um CNS) ``` -Fonte: [página de validação de CNS da ANVISA](https://rni-docs.anvisa.gov.br/docs/regras_gerais/validacoes/validacaoCNS/) e a [página do e-SUS APS](https://integracao.esusab.ufsc.br/ledi/documentacao/regras/algoritmo_CNS.html). +Fonte: [rotinas de validação do DATASUS](https://web.archive.org/web/20190106003442/http://cartaonet.datasus.gov.br/Rotina_JavaScript.doc) (cópia no Wayback Machine), [página de validação de CNS da ANVISA](https://rni-docs.anvisa.gov.br/docs/regras_gerais/validacoes/validacaoCNS/) e a [página do e-SUS APS](https://integracao.esusab.ufsc.br/ledi/documentacao/regras/algoritmo_CNS.html). ### formatCns @@ -2393,8 +2469,9 @@ A matrícula tem 32 dígitos, impressos como `000000 00 00 0000 0 00000 000 0000 | 2 | dígitos verificadores | - **Opções** (`IsValidCertidaoOptions`): `accept` restringe os tipos de livro válidos (`CertidaoType`) aos listados (padrão: todos os tipos). -- O serviço precisa ser `55`, e o dígito do tipo de livro precisa ser um dos nove livros (`0` é rejeitado). +- O serviço precisa ser `55`, e o dígito do tipo de livro um dos códigos de 1 a 9: 1 a 7 são os livros do art. 473, V (Provimento CNJ nº 149/2023, redação do Provimento CN nº 182/2024); 8 (`"emancipation"`, Livro E desdobrado para emancipações) e 9 (`"interdiction"`, Livro E desdobrado para interdições) vêm do Provimento CNJ nº 3/2009, art. 7º, revogado pelo Provimento CNJ nº 63/2017, e são mantidos para que as certidões emitidas sob ele a partir de 2010 continuem válidas. `0` é rejeitado. - Aceita o valor com ou sem máscara, com espaços entre e ao redor dos grupos. +- Nenhum documento oficial publica o algoritmo dos dígitos verificadores: o art. 473, IX só nomeia os dois dígitos, o revogado Provimento CNJ nº 3/2009 mandava calculá-los com um programa que o CNJ entregava aos registradores e o leiaute do Cadastro NIS da Caixa diz só "módulo 11". Os pesos e a regra do resto seguem as referências da comunidade abaixo. ```javascript import { isValidCertidao } from '@brazilian-utils/brazilian-utils'; @@ -2403,6 +2480,8 @@ isValidCertidao('104539 01 55 2013 1 00012 021 0000123 21'); // true isValidCertidao('09430001552010100020112000012087'); // true isValidCertidao('104539 01 55 2013 1 00012 021 0000123 22'); // false (dígitos verificadores inválidos) isValidCertidao('09400301542011100110002005191744'); // false (serviço diferente de 55) +isValidCertidao('10453901552013900012021000012398'); // true (código de livro 9, Provimento CNJ nº 3/2009) +isValidCertidao('10453901552013000012021000012387'); // false (o código de livro 0 não nomeia livro) isValidCertidao('123456'); // false (tamanho inválido) isValidCertidao('104539 01 55 2013 1 00012 021 0000123 21', { accept: ['birth'] }); // true isValidCertidao('104539 01 55 2013 1 00012 021 0000123 21', { accept: ['death'] }); // false @@ -2415,7 +2494,7 @@ Fonte: [art. 473 do Código Nacional de Normas da Corregedoria Nacional de Justi Formata a matrícula de uma certidão de registro civil na máscara impressa do art. 473. Os 32 dígitos são agrupados em 6 2 2 4 1 5 3 7 2 e separados por espaços. - **Opções** (`FormatCertidaoOptions`): `pad` completa o valor com zeros à esquerda até 32 dígitos (padrão `false`). -- Um número é aceito, mas uma matrícula completa de 32 dígitos precisa ser uma string. +- Um número é aceito quando é um inteiro seguro não negativo, então uma matrícula completa de 32 dígitos precisa ser uma string. Qualquer outro número retorna `''`. ```javascript import { formatCertidao } from '@brazilian-utils/brazilian-utils'; @@ -2424,6 +2503,7 @@ formatCertidao('10453901552013100012021000012321'); // 104539 01 55 2013 1 00012 formatCertidao('104539.01.55.2013.1.00012.021.0000123-21'); // 104539 01 55 2013 1 00012 021 0000123 21 formatCertidao('1552010100020112000012087', { pad: true }); // 000000 01 55 2010 1 00020 112 0000120 87 formatCertidao(104539015520); // 104539 01 55 20 (um número é lido como a string dos seus dígitos) +formatCertidao(1045390155.2); // '' (não é um inteiro seguro não negativo) ``` Fonte: [art. 473 do Código Nacional de Normas](https://atos.cnj.jus.br/atos/detalhar/5243). @@ -2443,8 +2523,8 @@ parseCertidao('104539 01 55 2013 1 00012 021 0000123 21'); Extrai os campos da matrícula de uma certidão de registro civil. Aceita as mesmas formas de entrada de `isValidCertidao` e retorna `null` quando a matrícula é inválida. -- Retorna `null` também para um serviço diferente de `55` e para um código de livro fora de 1 a 9. -- O art. 473, V lista apenas os códigos de livro de 1 a 7. Os códigos 8 (emancipação) e 9 (interdição) também são aceitos. +- Retorna `null` também para um serviço diferente de `55` e para o código de livro `0`. +- O art. 473, V lista os códigos de livro de 1 a 7, de "1: Livro A (Nascimento)" a "7: Livro E (Demais atos relativos ao registro civil)". Os códigos 8 (emancipação) e 9 (interdição) do Provimento CNJ nº 3/2009, art. 7º, revogado pelo Provimento CNJ nº 63/2017, não estão nele, mas continuam sendo lidos, como `"emancipation"` e `"interdiction"`, já que as certidões emitidas sob ele a partir de 2010 os trazem e continuam sendo documentos válidos. O resultado `CertidaoInfo` traz: @@ -2454,7 +2534,7 @@ O resultado `CertidaoInfo` traz: | `acervo` | Acervo a que o livro pertence: `"01"` acervo próprio, `"02"` em diante um por acervo incorporado. O art. 473, §§ 3º a 5º separa os incorporados pela data em que a serventia de origem foi extinta ou desativada. Até 31/12/2009: o CNS da unidade incorporadora e um código de acervo a partir de `"02"`, um por incorporação. A partir de 01/01/2010: o CNS da própria unidade incorporada e o código `"01"`, considerado acervo próprio dessa unidade. Um acervo fracionado entre duas ou mais serventias sucessoras leva o CNS próprio de cada sucessora com o código `"02"`. | | `service` | Serviço prestado pela serventia, sempre `"55"`, o registro civil das pessoas naturais. | | `year` | Ano do registro, com 4 dígitos. | -| `type` | Livro a que o ato pertence: `"birth"`, `"marriage"`, `"religious-marriage"`, `"death"`, `"stillbirth"`, `"banns"`, `"other"`, `"emancipation"` ou `"interdiction"`. | +| `type` | Livro a que o ato pertence: `"birth"`, `"marriage"`, `"religious-marriage"`, `"death"`, `"stillbirth"`, `"banns"`, `"other"`, ou, para os códigos 8 e 9 do Provimento CNJ nº 3/2009, `"emancipation"` e `"interdiction"`. | | `typeCode` | Código bruto do livro, de 1 a 9, como impresso na décima quinta posição da matrícula. | | `book` | Número do livro, com 5 dígitos e zeros à esquerda. | | `page` | Número da folha, com 3 dígitos e zeros à esquerda. | @@ -2481,7 +2561,7 @@ getCertidaoInfo('104539 01 55 2013 1 00012 021 0000123 21'); getCertidaoInfo('invalid'); // null ``` -Fonte: [art. 473 do Código Nacional de Normas da Corregedoria Nacional de Justiça](https://atos.cnj.jus.br/atos/detalhar/5243); códigos de livro 8 e 9 conforme o [ghiorzi.org](http://ghiorzi.org/DVnew.htm) e o [validation-br](https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts). +Fonte: [art. 473 do Código Nacional de Normas da Corregedoria Nacional de Justiça](https://atos.cnj.jus.br/atos/detalhar/5243); códigos de livro 8 e 9 conforme o revogado [Provimento CNJ nº 3/2009, art. 7º](https://atos.cnj.jus.br/atos/detalhar/1310), ainda listados pelo [ghiorzi.org](http://ghiorzi.org/DVnew.htm) e o [validation-br](https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts). ## CEI, CNO e CAEPF @@ -2490,6 +2570,7 @@ Fonte: [art. 473 do Código Nacional de Normas da Corregedoria Nacional de Justi Valida um número de CEI (Cadastro Específico do INSS). O CEI identifica o empregador sem CNPJ, como uma obra ou um produtor rural. - Layout: 12 dígitos impressos como `00.000.00000/00`, 11 dígitos de base e um dígito verificador. +- Só os 12 dígitos são oficiais: nenhuma norma, leiaute ou manual da Receita Federal publica o dígito verificador, que segue as referências da comunidade abaixo e confere com a base aberta do CNO e com o exemplo `000000336854` do SERPRO. ```javascript import { isValidCei } from '@brazilian-utils/brazilian-utils'; @@ -2497,11 +2578,12 @@ import { isValidCei } from '@brazilian-utils/brazilian-utils'; isValidCei('11.583.00249/85'); // true isValidCei('277297118187'); // true isValidCei(249859674386); // true +isValidCei(-249859674386); // false (não é um inteiro seguro não negativo) isValidCei('24.985.96743/68'); // false (dígito verificador inválido) isValidCei('000000000000'); // false (dígitos repetidos) ``` -Fonte: [yii2-br-validator](https://github.com/yiibr/yii2-br-validator/blob/master/src/CeiValidator.php), [Bigai.Documentos.Brasil](https://github.com/marcos-cruz/Documento/blob/master/src/Bigai.Documentos.Brasil/Cei/Cei.cs) e a [base de dados aberta do CNO](https://dados.gov.br/dados/conjuntos-dados/cadastro-nacional-de-obras-cno). +Fonte: [SERPRO, cadastro CNO](https://bcadastros.serpro.gov.br/documentacao/cadastro_cno/) (12 posições), [MOS do eSocial S-1.3, item 9.1](https://www.gov.br/esocial/pt-br/documentacao-tecnica/manuais/mos-s-1-3-consolidada-ate-a-no-s-1-3-07-2026.pdf) (o CNO mantém o número do CEI); dígito verificador conforme o [yii2-br-validator](https://github.com/yiibr/yii2-br-validator/blob/master/src/CeiValidator.php), [Bigai.Documentos.Brasil](https://github.com/marcos-cruz/Documento/blob/master/src/Bigai.Documentos.Brasil/Cei/Cei.cs) e a [base de dados aberta do CNO](https://dados.gov.br/dados/conjuntos-dados/cadastro-nacional-de-obras-cno). ### formatCei @@ -2539,11 +2621,12 @@ import { isValidCno } from '@brazilian-utils/brazilian-utils'; isValidCno('11.084.01680/62'); // true isValidCno('111130137368'); // true isValidCno(401800097960); // true +isValidCno(-401800097960); // false (não é um inteiro seguro não negativo) isValidCno('110840168063'); // false (dígito verificador inválido) isValidCno('000000000000'); // false (dígitos repetidos) ``` -Fonte: [página do CNO da Receita Federal](https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/cno) e a [base de dados aberta do CNO](https://dados.gov.br/dados/conjuntos-dados/cadastro-nacional-de-obras-cno). +Fonte: [página do CNO da Receita Federal](https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/cno), [SERPRO, cadastro CNO](https://bcadastros.serpro.gov.br/documentacao/cadastro_cno/), [MOS do eSocial S-1.3, item 9.1](https://www.gov.br/esocial/pt-br/documentacao-tecnica/manuais/mos-s-1-3-consolidada-ate-a-no-s-1-3-07-2026.pdf) e a [base de dados aberta do CNO](https://dados.gov.br/dados/conjuntos-dados/cadastro-nacional-de-obras-cno). ### formatCno @@ -2575,6 +2658,7 @@ Valida um número de CAEPF (Cadastro de Atividade Econômica da Pessoa Física). - Layout: 14 dígitos impressos como `000.000.000/000-00`: a base de 9 dígitos do CPF do titular, um número de ordem de 3 dígitos e 2 dígitos verificadores. - Os dois dígitos verificadores seguem o módulo 11 do CNPJ; o par é então somado a 12, com retorno a zero acima de 99. +- Só as 14 posições e a base do CPF são oficiais (SERPRO: "9 primeiros números do CPF + número de inscrição resumido" de 5 posições). A divisão dessas 5 em número de ordem e 2 dígitos verificadores, a regra do dígito e a soma de 12 vêm das referências da comunidade abaixo; elas conferem com o exemplo `00000002500171` do SERPRO. ```javascript import { isValidCaepf } from '@brazilian-utils/brazilian-utils'; @@ -2582,12 +2666,13 @@ import { isValidCaepf } from '@brazilian-utils/brazilian-utils'; isValidCaepf('293.118.610/001-84'); // true isValidCaepf('41142260000101'); // true isValidCaepf(29311861000184); // true +isValidCaepf(-29311861000184); // false (não é um inteiro seguro não negativo) isValidCaepf('29311861000185'); // false (dígitos verificadores inválidos) isValidCaepf('00000000000000'); // false (dígitos da base repetidos) isValidCaepf('00000000000012'); // false (dígitos da base repetidos) ``` -Fonte: [ghiorzi.org](http://ghiorzi.org/DVnew.htm) e [brazilian-values](https://github.com/VitorLuizC/brazilian-values/blob/master/src/validators/isCAEPF.ts). +Fonte: [SERPRO, cadastro CAEPF](https://bcadastros.serpro.gov.br/documentacao/cadastro_caepf/) (14 posições); dígitos verificadores conforme o [ghiorzi.org](http://ghiorzi.org/DVnew.htm) e o [brazilian-values](https://github.com/VitorLuizC/brazilian-values/blob/master/src/validators/isCAEPF.ts). ### formatCaepf @@ -2693,7 +2778,7 @@ Fonte: [CNAE-Subclasses 2.3 na CONCLA/IBGE](https://concla.ibge.gov.br/busca-onl Formata um código de subclasse CNAE (Classificação Nacional de Atividades Econômicas). Só a estrutura muda; use `isValidCnae` para conferir um código com a tabela. - **Opções** (`FormatCnaeOptions`): `pad` (padrão `false`) completa antes o valor com zeros à esquerda até os 7 dígitos de um código completo. Sem ele a máscara é aplicada até onde o valor vai. -- Caracteres fora da máscara são descartados, e um número é lido como a string dos seus dígitos. Retorna `''` quando não há dígito algum. +- Caracteres fora da máscara são descartados, e um número só é lido como a string dos seus dígitos quando é um inteiro seguro não negativo: um número negativo, fracionário ou inseguro retorna `''`, já que o sinal e o ponto decimal não são caracteres da máscara. Retorna `''` quando não há dígito algum. ```javascript import { formatCnae } from '@brazilian-utils/brazilian-utils'; @@ -2704,7 +2789,7 @@ formatCnae('62015'); // 6201-5 formatCnae('62', { pad: true }); // 0000-0/62 (completado até 7 dígitos antes) formatCnae(111301, { pad: true }); // 0111-3/01 formatCnae('abc6201501'); // 6201-5/01 (só os dígitos são lidos) -formatCnae(-6201501); // 6201-5/01 +formatCnae(-6201501); // '' (não é um inteiro seguro não negativo) ``` ### parseCnae @@ -2758,7 +2843,7 @@ isValidNcm('abc01012100'); // false (não é uma forma documentada) isValidNcm(-84713012); // false (não é um inteiro seguro não negativo) ``` -Fonte: [nomenclatura NCM publicada pelo Portal Único Siscomex](https://portalunico.siscomex.gov.br/classif/api/publico/nomenclatura/download/json). +Fonte: [nomenclatura NCM publicada pelo Portal Único Siscomex](https://portalunico.siscomex.gov.br/classif/api/publico/nomenclatura/download/json); os 10.515 códigos embutidos são os do arquivo "Vigente em 26/09/2026" (Resolução Gecex nº 926/2026). ### formatNcm @@ -2775,7 +2860,7 @@ formatNcm('8471'); // 8471 (máscara aplicada até onde o valor vai) formatNcm('847130'); // 8471.30 formatNcm('8471', { pad: true }); // 0000.84.71 (completado até 8 dígitos antes) formatNcm('abc8471'); // 8471 (só os dígitos são lidos) -formatNcm(-84713012); // 8471.30.12 +formatNcm(-84713012); // '' (não é um inteiro seguro não negativo) ``` ### parseNcm @@ -2884,7 +2969,7 @@ getServiceItem('40.01'); // { code: '40.01', description: 'Obras de arte sob enc getServiceItem('3.01'); // null (vetado) ``` -Fonte: [Lei Complementar 116/2003](https://www.planalto.gov.br/ccivil_03/leis/lcp/lcp116.htm) e a planilha `LISTA.SERV.NAC.` do [ANEXO B do Sistema Nacional NFS-e](https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual), a lista em vigor em formato legível por máquina. +Fonte: [Lei Complementar 116/2003](https://www.planalto.gov.br/ccivil_03/leis/lcp/lcp116.htm), cuja lista foi alterada pela última vez pela Lei Complementar 183/2021, e a planilha `LISTA.SERV.NAC.` do [ANEXO B do Sistema Nacional NFS-e](https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual), a lista em vigor em formato legível por máquina. ### isValidCfop @@ -3159,6 +3244,7 @@ Verifica se um GTIN (Global Trade Item Number, o número sob um código de barra - O valor deve ser uma string de 8, 12, 13 ou 14 dígitos, fora os espaços em volta, cujo último dígito é o dígito verificador módulo 10 da GS1: pesos 3 e 1 alternados a partir da direita, e a soma subtraída do múltiplo de dez igual ou imediatamente superior. É o que as regras I03-10 e I12-10 da Nota Técnica 2021.003 da SEFAZ verificam (rejeições 611 e 612). - Zeros à esquerda contam, então um número nunca é aceito, e um valor com máscara (`'7 890000 000017'`) é rejeitado em vez de ter seus dígitos extraídos. - O literal `'SEM GTIN'`, que a NF-e usa para produto sem GTIN, não é um GTIN e portanto não é válido aqui: teste por ele antes de chamar. +- Um valor só com zeros é rejeitado, embora o dígito verificador seja válido. Essa é uma regra desta biblioteca, não da NF-e nem da GS1: a rejeição 611 trata só do dígito verificador, e as GS1 General Specifications (release 26.0, tabela 1-4) reservam o GS1 Prefix `0000000` para Números de Circulação Restrita dentro de uma empresa, sem proibi-lo. Os zeros são rejeitados por serem o preenchimento usual de um GTIN ausente. - O prefixo não muda o veredito. Os Números de Circulação Restrita (prefixos 02, 04 e 20 a 29, os códigos que a loja imprime nas etiquetas da própria balança) e as faixas de ISSN, ISBN e cupons têm a mesma estrutura e o mesmo dígito verificador, e a "Tabela Prefixo GS1", contra a qual a SEFAZ valida o `cEAN`, lista essas faixas como válidas; use `getGtinInfo` para distingui-las. - O prefixo também não é conferido contra a lista de Organizações Membro da GS1: a GS1 segue atribuindo faixas, e uma cópia dessa lista passaria a recusar números válidos conforme envelhecesse. Se o número está cadastrado (a consulta ao Cadastro Centralizado de GTIN que a SEFAZ faz para os prefixos 789 e 790) não dá para verificar offline. @@ -3174,7 +3260,7 @@ isValidGtin('7890000000018'); // false (dígito verificador errado) isValidGtin('17890000000014', { lengths: [8, 12, 13] }); // false (GTIN-14 não aceito) isValidGtin('7 890000 000017'); // false (somente dígitos) isValidGtin('SEM GTIN'); // false -isValidGtin('0000000000000'); // false (só zeros, nunca alocado pelo GS1) +isValidGtin('0000000000000'); // false (só zeros, regra desta biblioteca) ``` ### getGtinInfo @@ -3182,13 +3268,13 @@ isValidGtin('0000000000000'); // false (só zeros, nunca alocado pelo GS1) Extrai os campos de um GTIN, como um `GtinInfo`. - Retorna `null` quando o valor não é um GTIN válido, sob as mesmas regras de `isValidGtin`. -- O prefixo é lido como a "Tabela Prefixo GS1" do Portal da NF-e orienta: o valor é preenchido com zeros à esquerda até 14 dígitos, e o prefixo são as posições 7 a 9 quando as posições 2 a 6 são zeros (um GTIN-8, ou um GTIN-14 que agrupa um) e as posições 2 a 4 caso contrário. O primeiro dígito, o zero de preenchimento ou o dígito indicador, nunca faz parte do prefixo, então um GTIN-12 tem um prefixo que começa com `0`, e um GTIN-14 tem o prefixo do GTIN que ele agrupa. +- O prefixo é lido como as GS1 General Specifications (tabelas 1-4, 1-5 e 1-9) organizam os números: o valor é preenchido com zeros à esquerda até 14 dígitos, e o prefixo são as posições 7 a 9 quando as posições 2 a 6 são zeros (um GTIN-8, ou um GTIN-14 que agrupa um) e as posições 2 a 4 caso contrário. O primeiro dígito, o zero de preenchimento ou o dígito indicador, nunca faz parte do prefixo, então um GTIN-12 tem um prefixo que começa com `0`, e um GTIN-14 tem o prefixo do GTIN que ele agrupa. | Campo | Descrição | | --- | --- | | `type` | `'GTIN-8'`, `'GTIN-12'`, `'GTIN-13'` ou `'GTIN-14'` (`GtinType`), conforme o tamanho com que o valor foi escrito | | `length` | `8`, `12`, `13` ou `14` (`GtinLength`) | -| `prefix` | O Prefixo GS1 de três dígitos, ou um Prefixo GS1-8 quando os seis primeiros dígitos da forma de 14 dígitos são zeros, o que cobre todo GTIN-8 e o Prefixo GS1 `0000000`. Identifica a Organização Membro da GS1 que licenciou o número, não o país de origem | +| `prefix` | O Prefixo GS1 de três dígitos, ou um Prefixo GS1-8 quando as posições 2 a 6 da forma de 14 dígitos são zeros, o que cobre todo GTIN-8, um GTIN-14 que agrupa um e o Prefixo GS1 `0000000`. Identifica a Organização Membro da GS1 que licenciou o número, não o país de origem | | `isBrazilian` | `true` quando o prefixo é um dos da GS1 Brasil, `789` ou `790`, o que a NT 2021.003 chama de "prefixo do Brasil" | | `isRestrictedCirculation` | `true` quando o prefixo está em uma faixa que a GS1 reserva para Números de Circulação Restrita (Prefixos GS1 02, 04 e 20 a 29; Prefixos GS1-8 000 a 099 e 200 a 299, faixa em que também cai o Prefixo GS1 `0000000`, já que sua forma de 14 dígitos começa com seis zeros), ou seja, o número só é único dentro de uma empresa ou região | | `checkDigit` | O dígito verificador módulo 10, o último dígito | @@ -3222,7 +3308,7 @@ Valida um código CID-10 contra as tabelas que o DATASUS publica, a edição bra - Os dois níveis da classificação são válidos: as categorias de 3 caracteres (`A00`) e as subcategorias de 4 caracteres, escritas com o ponto (`A00.0`) ou sem ele (`A000`). - Maiúsculas, minúsculas e espaços em volta são ignorados. Qualquer outra coisa (outro separador, um quinto caractere, um sufixo de cruz ou asterisco, um valor que não é string) é rejeitada. -- As tabelas da V2008 são a única fonte: um código que não está nelas, como `U07.1` (COVID-19), não é encontrado. +- As tabelas são as V2008 do DATASUS, mais a categoria `U07` da tabela da CID-10 que o DATASUS mantém para o SIM (`U07`, `U07.0`, `U07.1` COVID-19 com vírus identificado e `U07.2` vírus não identificado), que as V2008 não têm. Um código que não está em nenhuma delas não é encontrado, como `U09.9` (condição pós-COVID-19) e `U10.9` (síndrome inflamatória multissistêmica associada à COVID-19). Até a 2.4.0 os códigos `U07` também não eram encontrados. - Só uma tabela de códigos é lida (cerca de 27 KB minificada), não as descrições que `getCid10` carrega. ```javascript @@ -3272,7 +3358,8 @@ parseCid10('A00'); // 'A00' Busca um código CID-10 e retorna a sua descrição oficial em português. O resultado é um registro `Cid10`: `{ code, description }`. - Mesmas regras de entrada de `isValidCid10`. O `code` vem em maiúsculas e sem o ponto. Retorna `null` quando o código é desconhecido ou o valor não está em uma forma documentada. -- Este é o utilitário mais pesado do pacote: ele embute as 2045 categorias e 12188 subcategorias com suas descrições, cerca de 1 MB minificado (147 KB com gzip). Carregue-o sob demanda pelo seu subpath, como mostrado em [Tamanho do bundle](pt-br/getting-started.md#tamanho-do-bundle), e use `isValidCid10` quando a descrição não for necessária. +- Mesma tabela de `isValidCid10`: a V2008 do DATASUS mais os códigos `U07` da tabela do SIM (`getCid10('U07.1')` é `{ code: 'U071', description: 'Infecção pelo novo Coronavírus (COVID-19)' }`); `U09.9` e `U10.9` não são encontrados. +- Este é o utilitário mais pesado do pacote: ele embute as 2046 categorias e 12191 subcategorias com suas descrições, cerca de 1 MB minificado (147 KB com gzip). Carregue-o sob demanda pelo seu subpath, como mostrado em [Tamanho do bundle](pt-br/getting-started.md#tamanho-do-bundle), e use `isValidCid10` quando a descrição não for necessária. ```javascript import { getCid10 } from '@brazilian-utils/brazilian-utils'; @@ -3284,7 +3371,7 @@ getCid10('A00.5'); // null getCid10('A00-0'); // null (não é uma forma documentada) ``` -Fonte: [tabelas da CID-10 V2008 que o DATASUS publica em CSV](http://www2.datasus.gov.br/cid10/V2008/descrcsv.htm). +Fonte: [tabelas da CID-10 V2008 que o DATASUS publica em CSV](http://www2.datasus.gov.br/cid10/V2008/descrcsv.htm) e a [tabela da CID-10 do SIM](ftp://ftp.datasus.gov.br/dissemin/publicos/SIM/CID10/TABELAS/CID10.DBF) para os códigos `U07`. ## Texto @@ -3295,7 +3382,7 @@ Transforma em maiúscula a primeira letra de cada palavra, do jeito que se escre - **Opções** (`CapitalizeOptions`): `lowerCaseWords`, palavras mantidas em minúsculas entre duas palavras, por padrão preposições e artigos como `de`, `da`, `do`, `e`; `upperCaseWords`, palavras sempre em maiúsculas, por padrão designações societárias e abreviações como `LTDA`, `S.A.`, `ME`, `CNPJ` e algarismos romanos. Uma lista substitui a padrão. - Palavras se separam em espaços, `-`, `/`, apóstrofos e pontuação colada; espaços repetidos viram um só. - Palavra minúscula que é a primeira, a última ou precede pontuação é designativo e mantém a maiúscula. -- `ME` só vira maiúsculas como designação (última palavra ou antes de outra); `SA` sem pontos fica como está (o sobrenome Sá). Sigla de estado após `/` vira maiúsculas mesmo com `upperCaseWords` informado. +- `ME` só vira maiúsculas como designação (última palavra ou antes de outra); `SA` sem pontos fica como está (o sobrenome Sá). Sigla de estado após `/` vira maiúsculas mesmo com `upperCaseWords` informado, assim como a que termina o valor depois de `-` ou `–` entre espaços ou de `, `, o "Cidade – UF" dos Correios. ```javascript import { capitalize } from '@brazilian-utils/brazilian-utils'; @@ -3315,9 +3402,11 @@ capitalize('(empresa) ltda'); // (Empresa) LTDA capitalize('luiz von schmidt'); // Luiz von Schmidt capitalize('santana/rs'); // Santana/RS ("RS" é sigla de estado logo depois de uma "/") capitalize('porto alegre/rs'); // Porto Alegre/RS +capitalize('brasília - df'); // Brasília - DF (sigla de estado como última palavra depois de " - ", " – " ou ", ") capitalize('santana rs'); // Santana Rs (sem "/", "rs" é só uma palavra) capitalize('rua xv de novembro'); // Rua XV de Novembro (algarismo romano, "de" fica em minúsculas) capitalize('joão paulo ii'); // João Paulo II +capitalize('rua xxiv de maio'); // Rua XXIV de Maio (algarismos romanos de II a XXXIX, exceto VI, o verbo "vi") capitalize('de'); // De (uma preposição mantém a maiúscula quando é a primeira palavra) capitalize('empresa ltda', { upperCaseWords: [] }); // Empresa Ltda (a lista informada substitui a padrão) capitalize('josé Ama MARIA', { lowerCaseWords: ['ama'] }); // José ama Maria @@ -3350,8 +3439,19 @@ removeAccents(''); // '' Valida uma inscrição estadual para um estado. **Descontinuada:** a forma posicional `isValidIe(stateCode, ie)` continua funcionando, mas está descontinuada; use a forma com objeto `isValidIe({ value, stateCode })`. - Recebe um único objeto (`IsValidIeParams`): `value` é a inscrição e `stateCode` o estado ao qual ela pertence (um `StateCode`, sem diferenciar maiúsculas de minúsculas). -- GO, PA, MS, SP, TO, DF, PE, AL e RJ têm casos especiais (prefixos ou formatos extras, ou um desvio da página do SINTEGRA); veja o JSDoc em `src/is-valid-ie` para os detalhes. -- Uma inscrição só de zeros é aceita em todo estado cuja fórmula publicada produz dígito verificador 0 para ela: AM, CE, ES, MG, MT, PB, PE, PI, PR, RJ, RS, SC, SE e SP, mais BA com 8 ou 9 dígitos e TO com 9 dígitos. +- Alguns estados têm casos especiais, um prefixo ou formato que a página do SINTEGRA não traz ou um desvio proposital dela (detalhes e fontes no JSDoc em `src/is-valid-ie`): + - GO: os prefixos 10, 11, 15 e 20 a 29, a união de fontes que divergem: a norma (IN nº 946/09-GSF, art. 39, I, na redação da IN nº 1.535/22-GSE) traz 10, 20 e 11, a página do SINTEGRA 10, 11 e 20 a 29, o roteiro de crítica de 2012 10, 11 e 15. O dígito verificador segue o roteiro, como na 2.4.0: resto 1 dá 1 na faixa 10103105 a 10119997, e 11094402 aceita os dois dígitos, casos especiais que a página do SINTEGRA (2022) não tem. + - MT: 11 dígitos, ou os 9 dígitos que a Portaria SEFAZ-MT nº 59/2025 (art. 8º, § 1º) prevê, lidos como a forma de 11 dígitos com dois zeros à esquerda (nenhum texto oficial traz a regra do dígito verificador da forma de 9 dígitos). + - PA: os prefixos 15 e 75 a 79 que a página do SINTEGRA lista (a SEFA-PA atribui 75 desde 07/10/2024). MS: os prefixos 28 e 50 que a página do SINTEGRA traz (o 50 também está em um comunicado da SEFAZ-MS sobre o e-CCE; a única norma, a Resolução/SEF nº 1.344/1999, traz só o 28). + - DF: a regra de 13 dígitos do AC com os prefixos 07 e 08. A página do SINTEGRA não diz que o 07 é fixo; a folha de regras da SEFAZ-DF o chama de "campo fixo". O DF passou a 08 quando os números iniciados por 07 acabaram. + - RR: o prefixo 24, que a página do SINTEGRA não escreve como regra: ele vem dos dez exemplos da página. + - SP: o formato de produtor rural `P0MMMSSSSD000`, com o zero depois do `P`; o `P` pode ser minúsculo. + - TO: os 9 dígitos em vigor (Portaria SEFAZ-TO nº 676/2002, art. 3º; RICMS-TO, Decreto nº 2.912/2006, art. 90), ou os antigos 11 dígitos que a página do SINTEGRA documenta, com os dígitos de tipo 01, 02, 03 ou 99. + - AM: a regra do dígito verificador da página tem dois ramos e não define "Resto"; a biblioteca o lê como a soma módulo 11 e dá 0 a uma soma 0 ou 1, a regra comum de módulo 11. + - MG: um primeiro dígito verificador 10, de uma soma que já é múltiplo de dez, é lido como 0. + - PE: o formato eFisco de 9 dígitos e o antigo formato CACEPE de 14 dígitos, ambos na página do SINTEGRA (a Portaria SF nº 087/2007 converteu os números antigos, mas não fixou data a partir da qual deixam de valer). + - AL: o terceiro dígito, o tipo de empresa, deve ser 0, 3, 5, 7 ou 8, os valores que a página do SINTEGRA lista. +- Uma inscrição só de zeros é aceita em todo estado cuja fórmula publicada produz dígito verificador 0 para ela: AM, CE, ES, MG, PB, PE (9 dígitos), PI, PR, RJ, RS, SC, SE e SP, mais BA com 8 ou 9 dígitos, MT com 9 ou 11 dígitos e TO com 9 dígitos. ```javascript import { isValidIe } from '@brazilian-utils/brazilian-utils'; @@ -3360,9 +3460,11 @@ isValidIe({ value: '110042490114', stateCode: 'SP' }); // true isValidIe({ value: 'P011004243002', stateCode: 'SP' }); // true (produtor rural) isValidIe({ value: '0187634580933', stateCode: 'AC' }); // false isValidIe({ value: '109161793', stateCode: 'go' }); // true (não diferencia maiúsculas de minúsculas) +isValidIe({ value: '200000004', stateCode: 'GO' }); // true (prefixo 20) +isValidIe({ value: '130000019', stateCode: 'MT' }); // true (9 dígitos) ``` -Fonte: [páginas dos estados no SINTEGRA](http://www.sintegra.gov.br/insc_est.html) e o [roteiro de crítica da SEFAZ-GO](https://goias.gov.br/economia/roteiro-de-critica-da-inscricao-estadual-de-goias/). +Fonte: [páginas dos estados no SINTEGRA](http://www.sintegra.gov.br/insc_est.html), o [roteiro de crítica de Goiás](https://goias.gov.br/economia/roteiro-de-critica-da-inscricao-estadual-de-goias/) e a [IN nº 946/09-GSF](https://appasp.economia.go.gov.br/Legislacao/arquivos/secretario/in/IN_0946_2009.htm). ## E-mail @@ -3414,10 +3516,10 @@ Fonte: [ISO/IEC 7812-1](https://www.iso.org/standard/70484.html). Verifica a estrutura de um número de registro em conselho profissional (registro/inscrição profissional). Só a quantidade de dígitos e a UF são conferidas, nunca o dígito verificador, nem no CRC. - Recebe um objeto (`IsValidRegistroProfissionalParams`): `value`, `council` (`RegistroProfissionalCouncil`: `"OAB"`, `"CRM"`, `"CRO"`, `"CRP"` ou `"CRC"`) e `stateCode` opcional (UF esperada). -- `"OAB"` e `"CRM"`: 4 a 6 dígitos mais a UF (`123456/SP`, `123456-SP`); `"CRO"`: 3 a 6 dígitos (`12345/SP`). -- `"CRP"`: código regional de 2 dígitos (`01` a `24`) mais 4 a 6 dígitos (`06/12345`); `stateCode` é ignorado. -- `"CRC"`: UF, 6 dígitos, tipo de registro (`O` ou `P`) e dígito verificador (`SP-123456/O-3`); transferência acrescenta `T` ou `S` e a UF destino (`SP-123456/O-3 T-MG`). `stateCode` confere a UF de origem. -- Formatos de OAB, CRM, CRO e CRP são convencionais (nenhum é publicado); CREA não é coberto. +- `"OAB"` e `"CRM"`: 4 a 6 dígitos mais a UF (`123456/SP`, `123456-SP`); `"CRO"`: 3 a 6 dígitos (`12345/SP`), ou a forma da Consolidação das Normas do CFO (Resolução CFO-63/2005), art. 115, § 1º: a sigla do Conselho Regional antes, ligada por hífen à categoria (`TPD`, `TSB`, `ASB`, `APD`, `CLM`/`CLF`, `LPM`/`LPF`, `PV`, `T`) quando houver, depois o número, seguido de `-IS` na secundária ou `-R` na remida (`CRO-SP 12345`, `CRO-SP-TPD 1234`, `CRO-SP 12345-IS`). Até a 2.4.0 essa forma era rejeitada. +- `"CRP"`: código regional de 2 dígitos (`01` a `24`) mais 4 a 6 dígitos (`06/12345`); `stateCode` é ignorado. O sistema CFP tem 24 regionais; o CRP-25 (Amapá) é só uma proposta. +- `"CRC"`: UF, 6 dígitos, tipo de registro (`O` ou `P`) e dígito verificador (`SP-123456/O-3`); transferência acrescenta `T` ou `S` e a UF destino (`SP-123456/O-3 T-MG`). `stateCode` confere a UF de origem. Essa forma e os registros `P`/`S` vêm do Manual de Registro de 2009; a Resolução CFC nº 1.707/2023, em vigor, só fixa uma numeração "única e sequencial em cada CRC" e o `T` da transferência, e o algoritmo do dígito verificador não é publicado. +- As quantidades de dígitos de OAB, CRM, CRO e CRP são convencionais: a OAB e o CFM não publicam formato, e nem o art. 115 do CFO nem o CFP fixam quantidade de dígitos. CREA não é coberto. ```javascript import { isValidRegistroProfissional } from '@brazilian-utils/brazilian-utils'; @@ -3425,30 +3527,35 @@ import { isValidRegistroProfissional } from '@brazilian-utils/brazilian-utils'; isValidRegistroProfissional({ value: '123456/SP', council: 'OAB' }); // true isValidRegistroProfissional({ value: '123456-RJ', council: 'OAB', stateCode: 'SP' }); // false (UF divergente) isValidRegistroProfissional({ value: '123456', council: 'OAB' }); // false (sem UF) +isValidRegistroProfissional({ value: 'CRO-SP-TPD 1234', council: 'CRO' }); // true (art. 115 das normas do CFO) isValidRegistroProfissional({ value: '06/12345', council: 'CRP' }); // true isValidRegistroProfissional({ value: 'SP-123456/O-3', council: 'CRC' }); // true isValidRegistroProfissional({ value: 'SP-123456/O-3 T-MG', council: 'CRC' }); // true (registro transferido) isValidRegistroProfissional({ value: 'SP-123456/T-3', council: 'CRC' }); // false ("T" não é tipo de registro) ``` -Fonte: [Manual de Registro do Sistema CFC/CRCs](https://cfc.org.br/wp-content/uploads/2018/04/1_manual_registro.pdf), [Resolução CFC nº 1.707/2023](https://www1.cfc.org.br/sisweb/SRE/docs/Res_1707.pdf), [regionais do CFP](https://site.cfp.org.br/cfp/sistema-conselhos/conselhos-pelo-brasil/). +Fonte: [Consolidação das Normas do CFO, art. 115](https://transparencia.cfo.org.br/wp-content/uploads/2023/09/Consolida%C3%A7%C3%A3o-das-Normas-Atualizado-emsetembro-de-2023.pdf), [Manual de Registro do Sistema CFC/CRCs](https://cfc.org.br/wp-content/uploads/2018/04/1_manual_registro.pdf), [Resolução CFC nº 1.707/2023](https://www1.cfc.org.br/sisweb/SRE/docs/Res_1707.pdf), [regionais do CFP](https://site.cfp.org.br/cfp/sistema-conselhos/conselhos-pelo-brasil/). ## VIN ### isValidVin -Valida um VIN (Vehicle Identification Number / chassi). É uma verificação estrutural no padrão norte-americano, não um validador universal de VINs brasileiros. +Valida um VIN (Vehicle Identification Number / chassi). Por padrão confere 17 caracteres nas três seções da Resolução CONTRAN nº 968/2022, art. 3º (o WMI, o VDS e o VIS), cada um algarismo ou letra maiúscula exceto `I`, `O` e `Q`. -- Confere o tamanho de 17 caracteres, as letras excluídas `I`, `O` e `Q` e o dígito verificador na 9ª posição. -- As normas brasileiras não exigem o dígito verificador, então muitos VINs fabricados no Brasil não passam nele. +- A exclusão de `I`, `O` e `Q` vem da ISO 3779, não da resolução: ela não lista caractere proibido e remete a gravação à ABNT NBR 6066:2022 (art. 5º), norma paga sem cópia oficial gratuita. Os VINs de regularização do Anexo II dela (WMI `XXX`) são escritos sem essas letras, então passam. +- **Opções** (`IsValidVinOptions`): `checkDigit: true` também exige as regras norte-americanas do 49 CFR 565.15, o dígito verificador na 9ª posição e um código de ano-modelo diferente de `U`, `Z` e `0` na 10ª. Use para o VIN de um veículo fabricado para os Estados Unidos ou o Canadá. +- As normas brasileiras não exigem o dígito verificador, e muitos VINs fabricados no Brasil não o têm. Até a 2.4.0 ele era sempre exigido; passe `{ checkDigit: true }` para manter esse comportamento. +- Não diferencia maiúsculas de minúsculas e ignora espaços ao redor; um valor de um único caractere repetido é rejeitado. ```javascript import { isValidVin } from '@brazilian-utils/brazilian-utils'; -isValidVin('1HGCM82633A004352'); // true -isValidVin('1m8gdm9axkp042788'); // true (dígito verificador X, minúsculo) -isValidVin('1HGCM82633A004353'); // false (dígito verificador inválido) -isValidVin('00000000000000000'); // false (todos os caracteres iguais, ainda que o dígito feche) +isValidVin('9BWZZZ377VT004251'); // true (VIN brasileiro, sem dígito verificador) +isValidVin('9BWZZZ377VT004251', { checkDigit: true }); // false (o 9º caractere não é o dígito verificador) +isValidVin('1HGCM82633A004352', { checkDigit: true }); // true +isValidVin('1m8gdm9axkp042788', { checkDigit: true }); // true (dígito verificador X, minúsculo) +isValidVin('1HGCM82633A004353', { checkDigit: true }); // false (dígito verificador inválido) +isValidVin('00000000000000000'); // false (todos os caracteres iguais) isValidVin('1HGCM8263IA004352'); // false (contém a letra excluída I) isValidVin('1HGCM82633A00435'); // false (16 caracteres) ``` diff --git a/docs/snippets/document-field/templates/schema/arktype.ts.tmpl b/docs/snippets/document-field/templates/schema/arktype.ts.tmpl index 4aa40d738..f743c3b56 100644 --- a/docs/snippets/document-field/templates/schema/arktype.ts.tmpl +++ b/docs/snippets/document-field/templates/schema/arktype.ts.tmpl @@ -1,9 +1,9 @@ -import { @@validatorFn@@ } from "@brazilian-utils/brazilian-utils"; +import { @@validatorName@@ } from "@brazilian-utils/brazilian-utils"; import { type } from "arktype"; /** A @@label@@, reusable wherever a schema needs one. */ export const @@kind@@Schema = type("string").narrow( - (@@kind@@, ctx) => @@validatorCtx@@ || ctx.mustBe("a valid @@label@@"), + (@@kind@@, ctx) => @@validatorContext@@ || ctx.mustBe("a valid @@label@@"), ); export const signupSchema = type({ diff --git a/docs/snippets/document-field/templates/schema/standard.ts.tmpl b/docs/snippets/document-field/templates/schema/standard.ts.tmpl index 3668ebaaf..6aec15946 100644 --- a/docs/snippets/document-field/templates/schema/standard.ts.tmpl +++ b/docs/snippets/document-field/templates/schema/standard.ts.tmpl @@ -1,4 +1,4 @@ -import { toStandardSchema, @@validatorFn@@ } from "@brazilian-utils/brazilian-utils"; +import { toStandardSchema, @@validatorName@@ } from "@brazilian-utils/brazilian-utils"; import { sValidator } from "@hono/standard-validator"; import { FieldApi, FormApi } from "@tanstack/form-core"; import { initTRPC } from "@trpc/server"; diff --git a/docs/snippets/document-field/templates/schema/valibot.ts.tmpl b/docs/snippets/document-field/templates/schema/valibot.ts.tmpl index 2fb883368..9fcee8d66 100644 --- a/docs/snippets/document-field/templates/schema/valibot.ts.tmpl +++ b/docs/snippets/document-field/templates/schema/valibot.ts.tmpl @@ -1,4 +1,4 @@ -import { @@validatorFn@@ } from "@brazilian-utils/brazilian-utils"; +import { @@validatorName@@ } from "@brazilian-utils/brazilian-utils"; import * as v from "valibot"; /** A @@label@@, reusable wherever a schema needs one. */ diff --git a/docs/snippets/document-field/templates/schema/zod.ts.tmpl b/docs/snippets/document-field/templates/schema/zod.ts.tmpl index 7b3d37d1d..2b2ae3b27 100644 --- a/docs/snippets/document-field/templates/schema/zod.ts.tmpl +++ b/docs/snippets/document-field/templates/schema/zod.ts.tmpl @@ -1,4 +1,4 @@ -import { @@validatorFn@@ } from "@brazilian-utils/brazilian-utils"; +import { @@validatorName@@ } from "@brazilian-utils/brazilian-utils"; import { z } from "zod"; /** A @@label@@, reusable wherever a schema needs one. */ diff --git a/docs/utilities.md b/docs/utilities.md index 258e739ee..ce69692fb 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -12,6 +12,7 @@ These rules hold for every function unless its section says otherwise. - **Nothing throws on bad input** (`null`, `undefined`, the wrong type): `isValid*` return `false`, `format*` and `parse*` return `''`, single-item `get*` return `null`, list `get*` return `[]`. The only exceptions are the async `getAddressInfoByCep` and `getCepInfoByAddress`, which reject with typed errors. - **Validators accept the value masked or not**: the usual mask characters (`.`, `-`, `/`) and spaces between or around the groups are ignored, so there is no need to strip formatting first. +- **A number is read only when it is a non-negative safe integer**: the functions that take `string | number` treat a negative, fractional, non-finite or unsafe number as bad input (`isValidCep(-20040020)` is `false`, `formatCpf(-1)` and `parseCpf(1.5)` are `''`), since in a number `-` and `.` are not mask characters. - **Formatters mask as far as the value goes**, so they also work as input masks while the user types. `parse*` functions do the reverse and keep only the meaningful characters. - **Generators use `Math.random()`**, so they are fine for tests and fixtures and never for anything security-related. - **Getters return a new array or object on every call**, so mutating a result never affects the next call. @@ -25,6 +26,7 @@ These rules hold for every function unless its section says otherwise. Check if a CPF is valid. - Returns `false` for a reserved number (all digits the same, such as `00000000000`) and for a wrong check digit. +- The reserved numbers are the ones the Receita Federal's [DJE layout](http://normas.receita.fazenda.gov.br/sijut2consulta/anexoOutros.action?idArquivoBinario=36307) lists as not valid. The CPF's own norm, [IN RFB nº 2.172/2024](https://normas.receita.fazenda.gov.br/sijut2consulta/link.action?idAto=135611), has no check digit rule; the rule is the one of the Receita Federal's e-Financeira manual (Anexo II, `REGRA_VALIDA_CPF`, approved by the Ato Declaratório Executivo Cofis nº 10/2026). ```javascript import { isValidCpf } from '@brazilian-utils/brazilian-utils'; @@ -38,7 +40,7 @@ isValidCpf('111 444 777 35'); // true (whitespace mask) Format a CPF. - **Options** (`FormatCpfOptions`): `pad` left-pads the value with zeros to 11 digits before masking (default `false`); `obfuscate` hides the first 3 digits and the 2 check digits. -- `obfuscate` is applied after `pad`. +- `obfuscate` is applied after `pad`. It follows the rule the Leis de Diretrizes Orçamentárias set for publishing a CPF: "ocultar os três primeiros dígitos e os dois dígitos verificadores" ([Lei nº 14.194/2021, art. 149](https://www.planalto.gov.br/ccivil_03/_ato2019-2022/2021/lei/L14194.htm), first set by Lei nº 12.309/2010, art. 87, § 5º; [Lei nº 15.321/2025, art. 163](https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2025/lei/L15321.htm#art163), the LDO for 2026, repeats it). ```javascript import { formatCpf } from '@brazilian-utils/brazilian-utils'; @@ -114,23 +116,25 @@ Check if a CNPJ is valid. - **Options** (`IsValidCnpjOptions`): `version` picks the accepted format: `1` (default) numeric only, `2` numeric and alphanumeric. Any other value is read as `1`. - A reserved number (all digits the same) is rejected under both versions; version `2` has no reserved list for letters. +- The official character set of the alphanumeric CNPJ is the capital letters `A` to `Z` and the digits (the 2 check digits are always digits). A lower case letter is accepted only as input normalization, like a mask character: the input is upper-cased first. +- The Ex1 of question 23 of the Receita Federal's Q&A on the alphanumeric CNPJ, `AA345678/0003-29`, is a misprint: its check digits are `86`, so it is rejected. ```javascript import { isValidCnpj } from '@brazilian-utils/brazilian-utils'; isValidCnpj('15515147234255'); // false -isValidCnpj('q0slfmbd7vx439', { version: 2 }); // true (lowercase alphanumeric) +isValidCnpj('q0slfmbd7vx439', { version: 2 }); // true (read as Q0SLFMBD7VX439) ``` -Source: [Receita Federal, Manual do DV do CNPJ](https://www.gov.br/receitafederal/pt-br/centrais-de-conteudo/publicacoes/documentos-tecnicos/cnpj/manual-dv-cnpj.pdf), [CNPJ alfanumérico](https://www.gov.br/receitafederal/pt-br/acesso-a-informacao/acoes-e-programas/programas-e-atividades/cnpj-alfanumerico). +Source: [Instrução Normativa RFB nº 2.229/2024](http://normas.receita.fazenda.gov.br/sijut2consulta/link.action?idAto=141102) (Anexo XV of IN RFB nº 2.119/2022, weights "da direita para esquerda" as corrected by the retificação in the DOU of 25/10/2024), [Receita Federal, Manual do DV do CNPJ](https://www.gov.br/receitafederal/pt-br/centrais-de-conteudo/publicacoes/documentos-tecnicos/cnpj/manual-dv-cnpj.pdf), [CNPJ alfanumérico](https://www.gov.br/receitafederal/pt-br/acesso-a-informacao/acoes-e-programas/programas-e-atividades/cnpj-alfanumerico). ### formatCnpj Format a CNPJ. - **Options** (`FormatCnpjOptions`): `pad` left-pads the value with zeros to 14 characters before masking (default `false`); `version` picks the format, `1` (default) numeric only, `2` alphanumeric; `obfuscate` hides the first 2 digits and the 2 check digits. -- Version `2` keeps letters (upper-cased) and digits; version `1` keeps digits only. -- `obfuscate` works in both versions and is applied after `pad`. +- Version `2` keeps letters and digits, a lower case letter upper-cased first since the official set is `A` to `Z`; version `1` keeps digits only. +- `obfuscate` works in both versions and is applied after `pad`. It is a convention of this library, not an official rule: no law or Receita Federal act sets a masking rule for the CNPJ, whose data are public, the [ANPD](https://www.gov.br/anpd/pt-br/centrais-de-conteudo/documentos-tecnicos-orientativos/estudo_tecnico_sobre_anonimizacao_de_dados_na_lgpd_uma_visao_de_processo_baseado_em_risco_e_tecnicas_computacionais.pdf) says "não há um padrão para o mascaramento", and the [Banco Central's Pix rules](https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/IV_RequisitosMinimosparaExperienciadoUsuario.pdf) show the CNPJ in full where they mask the CPF; it hides the first 2 characters and the 2 check digits, after the CPF rule. ```javascript import { formatCnpj } from '@brazilian-utils/brazilian-utils'; @@ -145,7 +149,7 @@ formatCnpj('12345678000195', { obfuscate: true }); // **.345.678/0001-** Remove CNPJ formatting, return a normalized value, and cap the result to 14 characters. -- **Options** (`ParseCnpjOptions`): `version` picks the format: `1` (default) keeps digits only, `2` keeps letters and digits, upper-cased. +- **Options** (`ParseCnpjOptions`): `version` picks the format: `1` (default) keeps digits only, `2` keeps letters and digits, a lower case letter upper-cased since the official set is `A` to `Z` (`parseCnpj('12.abc.345/01de-35', { version: 2 })` returns `'12ABC34501DE35'`). ```javascript import { parseCnpj } from '@brazilian-utils/brazilian-utils'; @@ -160,6 +164,7 @@ Generate a valid random CNPJ. - The first argument is either the version, `1` (default) numeric or `2` alphanumeric, or a `GenerateCnpjParams` object with `version` plus `branch`. - `branch` is the "número de ordem" (filial) block, an integer from 1 to 9999 (random by default). An invalid `branch` is ignored. The block stays numeric in both versions. +- A random ordem block is never `0000`: the establishments of a root are numbered from `0001`, the matriz, on, so that block is never assigned. ```javascript import { generateCnpj } from '@brazilian-utils/brazilian-utils' @@ -210,7 +215,7 @@ Source: [Instrução Normativa RFB nº 2.229/2024](http://normas.receita.fazenda Check if a CEP ([brazilian postal code](https://en.wikipedia.org/wiki/C%C3%B3digo_de_Endere%C3%A7amento_Postal)) is valid. -- Accepts a `string` or a `number`. A CEP that starts with `0` has to be a string, since a number cannot keep the leading zero. +- Accepts a `string` or a `number`. A CEP that starts with `0` has to be a string, since a number cannot keep the leading zero, and a number is only read when it is a non-negative safe integer. - Spaces, dots and hyphens are ignored. Any other character makes the value invalid. ```javascript @@ -221,6 +226,7 @@ isValidCep('92500-000'); // true (hyphen between groups) isValidCep('92.500-000'); // true (dot and hyphen) isValidCep('013 10 100'); // true (spaces anywhere between the digits) isValidCep(20040020); // true (number input) +isValidCep(-20040020); // false (not a non-negative safe integer) isValidCep('9250000A'); // false (letters are rejected) isValidCep('12345'); // false (invalid length) ``` @@ -230,13 +236,14 @@ isValidCep('12345'); // false (invalid length) Format a CEP ([brazilian postal code](https://en.wikipedia.org/wiki/C%C3%B3digo_de_Endere%C3%A7amento_Postal)). - **Options** (`FormatCepOptions`): `pad` left-pads the value with zeros to 8 digits before masking (default `false`). -- A CEP that starts with `0` given as a number loses that zero: pass a string or use `pad`. +- A CEP that starts with `0` given as a number loses that zero: pass a string or use `pad`. A number is only read when it is a non-negative safe integer; any other number returns `''`. ```javascript import { formatCep } from '@brazilian-utils/brazilian-utils'; formatCep('92500000'); // 92500-000 formatCep('9250000', { pad: true }); // 09250-000 +formatCep(-92500000); // '' (not a non-negative safe integer) ``` ### parseCep @@ -264,7 +271,7 @@ generateCep(); // '92500000' Fetch the address of a CEP from several providers at once and resolve to the first successful answer. The result is an `AddressInfo`: `cep`, `state`, `city`, `neighborhood` and `street`. - **Options** (`GetAddressInfoByCepOptions`): `providers` (`CepProvider[]`) lists the providers to race (default `['viacep', 'brasilapi']`). `'widenet'` is deprecated and left out of the default list. -- Accepts a string or a number. A number is left-padded with zeros to 8 digits. +- Accepts a string or a number. A number is left-padded with zeros to 8 digits; a negative or fractional one is rejected with `GetAddressInfoByCepValidationError` before any request is made. - Retries transient network failures per provider. - Rejects with `GetAddressInfoByCepValidationError` when the CEP is invalid or `providers` names no known provider, with `GetAddressInfoByCepNotFoundError` when every provider failed and at least one reported the CEP as unknown, and with `GetAddressInfoByCepServiceError` when every provider failed for another reason. - All three extend `GetAddressInfoByCepError`, so one `catch` covers them. @@ -329,16 +336,18 @@ const ceps = await getCepInfoByAddress({ Check if a boleto ([brazilian payment method](https://en.wikipedia.org/wiki/Boleto)) is valid. - Accepts the 47 digit "cobrança bancária" linha digitável and, for the "boleto de arrecadação", either its 48 digit linha digitável or its 44 digit barcode. -- The código de moeda (position 4 of the cobrança bancária barcode) is not checked. +- The código de moeda (position 4 of the cobrança bancária barcode and linha digitável) must be `9` (real), the only code Carta-Circular BCB nº 2.926/2000 assigns. The one exception is the "Situação 2" slip of the FEBRABAN Convenção da Cobrança, issued by an institution identified only by its ISPB: bank code `988`, código de moeda `0`, fator de vencimento `0000` and the ISPB, padded with zeros, where the amount would be. Any other digit is rejected. ```javascript import { isValidBoleto } from '@brazilian-utils/brazilian-utils'; isValidBoleto('00190000090114971860168524522114675860000102656'); // true isValidBoleto('846100000005246100291102005460339004695895061080'); // true (boleto de arrecadação) +isValidBoleto('00170000010114971860168524522114275860000102656'); // false (código de moeda 7) +isValidBoleto('98800000060114971860168524522114100000018236120'); // true (Situação 2: bank 988, moeda 0, ISPB) ``` -Source: [Carta-Circular BCB nº 2.926/2000](https://www.bcb.gov.br/pre/normativos/c_circ/2000/pdf/c_circ_2926_v1_O.pdf), [FEBRABAN, Layout Padrão de Arrecadação](https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf). +Source: [Carta-Circular BCB nº 2.926/2000](https://www.bcb.gov.br/pre/normativos/c_circ/2000/pdf/c_circ_2926_v1_O.pdf), [FEBRABAN, Layout Padrão de Arrecadação, Versão 08](https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf) (in force from 01/06/2026), [FEBRABAN, Convenção da Cobrança](https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Conven%C3%A7%C3%A3o%20da%20Cobran%C3%A7a%20-%2005_02_2021_f.pdf). ### formatBoleto @@ -356,7 +365,7 @@ formatBoleto('846100000005246100291102005460339004695895061080'); // 84610000000 formatBoleto('84610000000246100291100054603390069589506108'); // 84610.00000 02461.002911 00054.603390 0 69589506108 (44 digit arrecadação barcode keeps the bancária mask) ``` -Source: [FEBRABAN, Layout Padrão de Arrecadação](https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf). +Source: [FEBRABAN, Layout Padrão de Arrecadação, Versão 08](https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf) (in force from 01/06/2026). ### parseBoleto @@ -373,6 +382,7 @@ parseBoleto('00190.00009 01149.718601 68524.522114 6 75860000102656'); // 001900 Generate a valid random boleto. - Pass `{ type: 'arrecadacao' }` (`GenerateBoletoParams`) for a 48 digit boleto de arrecadação instead of the default `'bancario'` (cobrança bancária, 47 digits). +- A cobrança bancária slip carries the código de moeda `9` and a fator de vencimento of `0000` (no due date) or `1000` to `9999`; `0001` to `0999` denote no date. ```javascript import { generateBoleto } from '@brazilian-utils/brazilian-utils'; @@ -387,8 +397,9 @@ Extract information from a boleto (amount, expiration date, bank code). Returns - **Options** (`GetBoletoInfoOptions`): `referenceDate` resolves the "fator de vencimento" cycle as of that date instead of now. - Returns a `BoletoInfo`: `amount` in cents, `expirationDate` and the three digit `bankCode`. `expirationDate` is `null` when the slip carries no fator de vencimento (a factor below `1000`). -- The fator de vencimento cycle reset on 22/02/2025, so a factor can mean either of two dates 9000 days apart. `referenceDate` picks between them; pass it whenever the answer has to stay stable. +- The fator de vencimento cycle reset on 22/02/2025, so a factor can mean either of two dates 9000 days apart. No FEBRABAN communiqué on the reset is published; the rule is in bank manuals, such as [Bradesco's](https://banco.bradesco/assets/pessoajuridica/pdf/4008-524-0121-layout-cobranca-versao-portugues.pdf) (Versão 17). `referenceDate` picks between them; pass it whenever the answer has to stay stable. - A boleto de arrecadação has `bankCode: ''` and `expirationDate: null`, plus `type: 'arrecadacao'`, `segment`, `value` (the amount in reais) and `hasEffectiveValue`. +- A FEBRABAN Convenção da Cobrança "Situação 2" slip (bank code `988`, código de moeda `0`) carries the issuer's ISPB where the amount would be: it comes back as `ispb`, with `amount: 0`. ```javascript import { getBoletoInfo } from '@brazilian-utils/brazilian-utils'; @@ -401,13 +412,16 @@ getBoletoInfo('00190000090114971860168524522114675860000102656', { }); // Resolves the fator de vencimento cycle as of 2018-07-01 +getBoletoInfo('98800000060114971860168524522114100000018236120'); +// { amount: 0, expirationDate: null, bankCode: '988', ispb: '18236120' } + getBoletoInfo('846100000005246100291102005460339004695895061080'); // { amount: 2461, expirationDate: null, bankCode: '', type: 'arrecadacao', segment: 4, value: 24.61, hasEffectiveValue: true } getBoletoInfo('invalid'); // null ``` -Source: [Carta-Circular BCB nº 2.926/2000](https://www.bcb.gov.br/pre/normativos/c_circ/2000/pdf/c_circ_2926_v1_O.pdf), [FEBRABAN, Layout Padrão de Arrecadação](https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf). +Source: [Carta-Circular BCB nº 2.926/2000](https://www.bcb.gov.br/pre/normativos/c_circ/2000/pdf/c_circ_2926_v1_O.pdf), [FEBRABAN, Layout Padrão de Arrecadação, Versão 08](https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf) (in force from 01/06/2026), [FEBRABAN, Convenção da Cobrança](https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Conven%C3%A7%C3%A3o%20da%20Cobran%C3%A7a%20-%2005_02_2021_f.pdf). ## Pix @@ -430,7 +444,7 @@ isValidPixKey('123.456.789-09', { accept: ['email', 'evp'] }); // false isValidPixKey('not a key'); // false ``` -Source: [Manual de Padrões para Iniciação do Pix](https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/II_ManualdePadroesparaIniciacaodoPix.pdf), [DICT API](https://www.bcb.gov.br/content/estabilidadefinanceira/pix/API-DICT.html), [pix-api](https://github.com/bacen/pix-api). +Source: [Manual de Padrões para Iniciação do Pix](https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/II_ManualdePadroesparaIniciacaodoPix.pdf), [DICT API 2.12.1](https://www.bcb.gov.br/content/estabilidadefinanceira/pix/API-DICT.html) and its [changelog](https://www.bcb.gov.br/content/estabilidadefinanceira/pix/changelog.html), [pix-api](https://github.com/bacen/pix-api). ### getPixKeyInfo @@ -439,13 +453,15 @@ Identify a Pix key and normalize it to the canonical form the DICT expects insid - Returns a `PixKeyInfo` with the `type` (`PixKeyType`) and the `value`. - The canonical `value` is digits for a CPF or CNPJ (letters upper-cased), a lowercase e-mail, an E.164 phone or a lowercase UUID. - An 11 digit value valid as both CPF and mobile phone is read as a CPF, unless written as a phone (`+55` prefix or DDD in parentheses). -- An e-mail longer than 77 characters is rejected. +- An e-mail is checked, once lowercased, against the pattern the DICT API registers and its 77 character limit, not against `isValidEmail`: the local part may carry any of ``.!#$'*+/=?^_`{|}~-``, dots included anywhere, and the domain may be a single label (`a@localhost`). The pattern is the one of DICT API 2.12.1, which has had no `&` since version 2.6.0 (27/09/2025). ```javascript import { getPixKeyInfo } from '@brazilian-utils/brazilian-utils'; getPixKeyInfo('123.456.789-09'); // { type: 'cpf', value: '12345678909' } getPixKeyInfo('Fulano@Example.COM '); // { type: 'email', value: 'fulano@example.com' } +getPixKeyInfo('a{b}@example.com'); // { type: 'email', value: 'a{b}@example.com' } (DICT pattern, isValidEmail rejects it) +getPixKeyInfo('a&b@example.com'); // null (no & since DICT API 2.6.0) getPixKeyInfo('(11) 98765-4321'); // { type: 'phone', value: '+5511987654321' } getPixKeyInfo('71C7D9BE-4B85-4E43-9F1C-1F3B8B4E9A2D'); // { type: 'evp', value: '71c7d9be-4b85-4e43-9f1c-1f3b8b4e9a2d' } @@ -454,16 +470,21 @@ getPixKeyInfo('51998259765'); // { type: 'cpf', value: '51998259765' } (also a v getPixKeyInfo('+5551998259765'); // { type: 'phone', value: '+5551998259765' } ``` -Source: [Manual de Padrões para Iniciação do Pix](https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/II_ManualdePadroesparaIniciacaodoPix.pdf), [DICT API](https://www.bcb.gov.br/content/estabilidadefinanceira/pix/API-DICT.html). +Source: [Manual de Padrões para Iniciação do Pix](https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/II_ManualdePadroesparaIniciacaodoPix.pdf), [DICT API 2.12.1](https://www.bcb.gov.br/content/estabilidadefinanceira/pix/API-DICT.html) and its [changelog](https://www.bcb.gov.br/content/estabilidadefinanceira/pix/changelog.html). ### isValidPixPayload -Check if a Pix BR Code payload (the string behind a Pix QR Code and behind "Pix copia e cola") is valid. The key itself is not checked; use `isValidPixKey`. +Check if a Pix BR Code payload (the string behind a Pix QR Code and behind "Pix copia e cola") is valid under the Manual de Padrões para Iniciação do Pix and, where it is silent, the EMV QR Code specification it builds on. -- The TLV structure, the CRC-16 and the mandatory objects (format indicator, category code, currency, country, merchant name and city) are checked. +- The payload must start with the format indicator `000201`. +- The TLV structure, the CRC-16 and the mandatory objects (format indicator, a 4 digit category code, currency, country, merchant name and city) are checked. - One "Merchant Account Information" template (IDs 26 to 51) must carry the `br.gov.bcb.pix` GUI with a key (static) or a PSP URL (dynamic), never both. -- Objects `01` (Point of Initiation Method) and `62` (Additional Data Field) are optional; `01` must be `11` or `12` when present. -- An amount (`54`) must be greater than zero, except in a Pix Saque BR Code (8 digit `fss` in sub-object 26-03). +- The key is written in the DICT form (§2.5.1): the one `getPixKeyInfo` returns unchanged, so `12345678909` passes and `123.456.789-09` does not. Whether it is registered cannot be told from the payload. The PSP URL has at most 77 characters (§2.5.2). +- The merchant name has at most 25 characters and the city at most 15; the country is `BR` in uppercase. +- No BCB manual states the case of the CRC or of `BR`: the only case rule they give is for the GUI, and every official example writes both in uppercase. Accepting a lowercase CRC (`1d3d`) and rejecting `br` are choices of this library, as in 2.4.0. +- Object `01` (Point of Initiation Method) is optional and must be `11` or `12` when present. +- Object `62` (Additional Data Field) is mandatory and carries the `txid` (62-05), "sempre presente em um BR Code": `***` or 1 to 25 letters and digits (§2.6.2); with a PSP URL any value stands, since §2.7 has the payer ignore it. The `-` of the Manual do BR Code example `RP12345678-2019` is outside the Pix character set of §2.6.2, so that static example is rejected. +- An amount (`54`) is digits with an optional `.` and at most two decimals (`98.73`, `98` and `98.` are the EMV examples), at most 13 characters, and greater than zero, except in a Pix Saque BR Code (8 digit `fss` in sub-object 26-03) and next to a PSP location, where the Pix API gives it `0.00` (the Manual do BR Code lists `"0"` among its examples). - Unreserved Templates (IDs 80 to 99) are ignored. ```javascript @@ -485,8 +506,8 @@ Parse a Pix BR Code payload into its fields. Accepts what `isValidPixPayload` ac - Returns a `PixPayloadInfo`: `merchantName`, `merchantCity`, `pointOfInitiation` and either `key` (static) or `url` (dynamic). - `amount`, `txid`, `description` and `withdrawalFacilitator` (the `fss` of a Pix Saque) are present only when the payload carries them. `txid` is absent for the `***` marker. -- `pointOfInitiation` (`PixPointOfInitiation`) is `"dynamic"` when the payload carries a PSP location or object `01` is `"12"`, `"static"` otherwise. -- With a PSP location, `amount` and `txid` are ignored, as the manual mandates. +- `pointOfInitiation` (`PixPointOfInitiation`) is `"dynamic"` when the payload carries a PSP location (a dynamic QR Code in the Pix manual, §2.4.2) or marks itself single use with object `01` = `"12"` (§2.7.2), `"static"` otherwise. A key payload with `01` = `"12"` is therefore `"dynamic"`; `url` and `key` tell the two kinds of QR Code of the manual apart. +- With a PSP location, `amount` and `txid` are ignored and left out, as §2.7 of the manual mandates ("Se preenchidos, seu conteúdo deve ser ignorado"). ```javascript import { getPixPayloadInfo } from '@brazilian-utils/brazilian-utils'; @@ -543,23 +564,26 @@ Source: [Manual do BR Code](https://www.bcb.gov.br/content/estabilidadefinanceir ### isValidNfeKey -Check if a DF-e access key (chave de acesso) is valid. Covers every DF-e with a 44 digit access key; the CF-e-SAT (59) is out. +Check if a DF-e access key (chave de acesso) is valid. Covers every DF-e with a 44 character access key; the CF-e-SAT (59) is out. - Models: NF-e (55), NFC-e (65), CT-e (57), MDF-e (58), CT-e OS (67), GTV-e (64), BP-e (63), NF3e (66) and NFCom (62). -- The 44 digits may be grouped in 4 by whitespace, `.`, `-` or `/`. The XML `Id` prefixes (`NFe`, `CTe`, `MDFe`, `BPe`, `NF3e`, `NFCom`) are stripped first. +- Every character is a digit except positions 7 to 18, the root and order of the issuer's CNPJ, which may hold the letters of an alphanumeric CNPJ: the current schemas (NF-e PL_010 `TChNFe`, CT-e PL_CTe_400_RTC, MDF-e 3.00b, NFCom) type the key as `[0-9]{6}[0-9A-Z]{12}[0-9]{26}`, in production for the NF-e from 01/07/2026 (NT 2026.004). A letter anywhere else, the CNPJ check digits in positions 19 and 20 included, is rejected. The schema admits upper case only; lower case is read as upper case, as `isValidCnpj` does with `{ version: 2 }`. +- The 44 characters may be grouped in 4 by whitespace, `.`, `-` or `/`. The XML `Id` prefixes (`NFe`, `CTe`, `MDFe`, `BPe`, `NF3e`, `NFCom`) are stripped first. - `tpEmis` must be one the MOC of that model assigns (table below). - For NF-e and NFC-e the `cNF` must pass rule B03-10 of the MOC (no repeated or sequential values, not the document number). -- A document number of all zeros is rejected. The check digit is a modulus 11 over the first 43 digits. +- A document number of all zeros is rejected. The check digit is a modulus 11 over the first 43 characters, each valued at its ASCII code minus 48 (`A` = 17 ... `Z` = 42), as NT Conjunta 2025.001 sets it. | Model | `tpEmis` accepted | | --- | --- | | NF-e (55), NFC-e (65) | 1 to 7 and 9 | | CT-e (57) | 1, 3, 4, 5, 7, 8 | | CT-e OS (67) | 1, 5, 7, 8 | -| GTV-e (64) | 1, 2, 7, 8 | +| GTV-e (64) | 1, 2, 7, 8 (see below) | | MDF-e (58) | 1, 2, 3 | | BP-e (63), NF3e (66), NFCom (62) | 1, 2 | +For the GTV-e, the current CT-e schema package (PL_CTe_400_RTC) enumerates `tpEmis` 1 (normal) and 2 (contingência off-line) only; the earlier PL_CTe_400 also had 7 and 8 (autorização pela SVC-RS and SVC-SP). The key carries no schema version, so the library accepts all four, and the keys of GTV-e authorised under the earlier package keep validating. + ```javascript import { isValidNfeKey } from '@brazilian-utils/brazilian-utils'; @@ -568,6 +592,8 @@ isValidNfeKey('NFe35170458716523000119550010000000121000123458'); // true (XML I isValidNfeKey('CTe35170458716523000119570010000000128000123452'); // true (CT-e authorised by the SVC-SP) isValidNfeKey('3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458'); // true (masked) isValidNfeKey('3517.0458.7165.2300.0119.5500.1000.0000.1210.0012.3458'); // true (any of the mask characters) +isValidNfeKey('35260712ABC34501DE35550010000001231102030403'); // true (alphanumeric CNPJ 12ABC34501DE35) +isValidNfeKey('35260712ABC34501DEA5550010000001231102030408'); // false (a letter in position 19, a CNPJ check digit) isValidNfeKey('351 70458716523000119550010000000121000123458'); // false (a separator inside a group of 4) isValidNfeKey('99170458716523000119550010000000121000123458'); // false (invalid cUF) isValidNfeKey('35170458716523000119010010000000121000123450'); // false (invalid mod) @@ -575,14 +601,15 @@ isValidNfeKey('35170458716523000119550010000000128000123455'); // false (the NF- isValidNfeKey('35170458716523000119550010000000121000000003'); // false (cNF 00000000, rule B03-10) ``` -Source: [MOC NF-e](https://www.confaz.fazenda.gov.br/legislacao/arquivo-manuais/moc7-visao-geral.pdf), [NF-e schemas](https://dfe-portal.svrs.rs.gov.br/NFE/Documentos) and the MOCs cited in `src/is-valid-nfe-key/is-valid-nfe-key.ts`. +Source: [MOC NF-e](https://www.confaz.fazenda.gov.br/legislacao/arquivo-manuais/moc7-visao-geral.pdf), [NF-e schemas](https://dfe-portal.svrs.rs.gov.br/NFE/Documentos), [NT Conjunta 2025.001](https://www.nfe.fazenda.gov.br/portal/exibirArquivo.aspx?conteudo=5ZkvIZt10mQ%3D) (CNPJ alfanumérico) and the MOCs cited in `src/is-valid-nfe-key/is-valid-nfe-key.ts`. ### formatNfeKey -Format a DF-e (Documento Fiscal eletrônico) access key into groups of 4 digits separated by spaces, the form the DANFE, DACTE, DAMDFE, DABPE, DANF3E and DANFE-COM print it in. +Format a DF-e (Documento Fiscal eletrônico) access key into groups of 4 characters separated by spaces, the form the DANFE, DACTE, DAMDFE, DABPE, DANF3E and DANFE-COM print it in. -- **Options** (`FormatNfeKeyOptions`): `pad` left pads the value with zeros up to the 44 digits of a complete access key (default `false`). -- A masked or partial key is grouped as far as its digits go. +- **Options** (`FormatNfeKeyOptions`): `pad` left pads the value with zeros up to the 44 characters of a complete access key (default `false`). +- A masked or partial key is grouped as far as its characters go. +- The letters of an alphanumeric CNPJ are kept, upper cased, in positions 7 to 18; a letter anywhere else is dropped. - Use `isValidNfeKey` to check a key. ```javascript @@ -591,6 +618,9 @@ import { formatNfeKey } from '@brazilian-utils/brazilian-utils'; formatNfeKey('35170458716523000119550010000000121000123458'); // '3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458' +formatNfeKey('35260712abc34501de35550010000001231102030403'); +// '3526 0712 ABC3 4501 DE35 5500 1000 0001 2311 0203 0403' (alphanumeric CNPJ) + formatNfeKey('12345'); // '1234 5' formatNfeKey('12345', { pad: true }); @@ -599,7 +629,9 @@ formatNfeKey('12345', { pad: true }); ### parseNfeKey -Remove the formatting of a DF-e access key (chave de acesso), keep only digits, and cap the result to 44 digits. +Remove the formatting of a DF-e access key (chave de acesso), keep only its characters, and cap the result to 44 characters. + +- The characters kept are the digits and, in positions 7 to 18, the letters of an alphanumeric CNPJ, upper cased; a letter anywhere else is dropped. - The XML `Id` prefixes (`NFe`, `CTe`, `MDFe`, `BPe`, `NF3e`, `NFCom`) are stripped first. @@ -611,6 +643,9 @@ parseNfeKey('3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458'); parseNfeKey('NFe35170458716523000119550010000000121000123458'); // '35170458716523000119550010000000121000123458' + +parseNfeKey('3526 0712 abc3 4501 de35 5500 1000 0001 2311 0203 0403'); +// '35260712ABC34501DE35550010000001231102030403' (alphanumeric CNPJ) ``` ### getNfeKeyInfo @@ -619,6 +654,7 @@ Parse a DF-e access key into its fields. Accepts the same input forms as `isVali - Returns an `NfeKeyInfo`: `stateCode`, `year`, `month`, `taxId`, `model` (`NfeKeyModel`), `series`, `number`, `emissionType`, `code` and `checkDigit`. - For NFCom and NF3e (models `'62'` and `'66'`) the result also carries `authorizationSite` and `code` is 7 digits instead of 8. +- `taxId` is the 14 characters of positions 7 to 20 as written: a numeric CNPJ, an alphanumeric CNPJ (upper cased), or a CPF left padded with zeros. A `taxId` with a letter is always an alphanumeric CNPJ; a padded CPF and a CNPJ starting with `000` look alike, so check it with `isValidCpf` or `isValidCnpj` when the type matters. ```javascript import { getNfeKeyInfo } from '@brazilian-utils/brazilian-utils'; @@ -631,6 +667,10 @@ getNfeKeyInfo('35170458716523000119620010000000121000123450'); // { stateCode: 'SP', year: 2017, month: 4, taxId: '58716523000119', model: '62', // series: 1, number: 12, emissionType: 1, code: '0012345', checkDigit: 0, authorizationSite: 0 } +getNfeKeyInfo('35260712ABC34501DE35550010000001231102030403'); +// { stateCode: 'SP', year: 2026, month: 7, taxId: '12ABC34501DE35', model: '55', +// series: 1, number: 123, emissionType: 1, code: '10203040', checkDigit: 3 } + getNfeKeyInfo('invalid'); // null ``` @@ -646,8 +686,8 @@ Check if the access key (chave de acesso) of a national NFS-e, the Nota Fiscal d - 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). +- 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 official document states it: the NFS-e technical notes 001 to 009, the Anexo I and the Perguntas e Respostas of 08/09/2026 are silent, and Nota Técnica Conjunta 2025.001, whose ASCII minus 48 rule covers the DF-e key, lists the documents it covers (NF-e, NFC-e, CT-e, CT-e OS, GTV-e, MDF-e, BP-e, BP-e TM, NF3e and NFCom) without the NFS-e. The rule is taken by analogy with that NT 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). The production bundle of 09/02/2026 still types the key `[0-9]{50}`. - The municipal NFS-e models that are not the national standard are out of scope. ```javascript @@ -709,7 +749,7 @@ getNfseKeyInfo('35503082212ABC34501DE35000000000001226091357924682'); 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. +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) (the restricted-production bundle v1.01-20260727; the service handles the alphanumeric CNPJ in production since 2026-08-10, while the production bundle of 2026-02-09 is still digits only) 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 @@ -718,9 +758,10 @@ Source: the [technical documentation of the Sistema Nacional NFS-e](https://www. Check if an Inscrição SUFRAMA is valid. It is the registration number the Superintendência da Zona Franca de Manaus gives to companies with tax incentives, carried by the `ISUF` field of the NF-e recipient. - The number is `SS.NNNN.LLD`: sector of activity, sequential number, locality of the SUFRAMA unit and check digit. -- Accepts 8 or 9 digits: an 8 digit value is a number whose sector code lost its leading zero. +- Accepts 8 or 9 digits. The MOC only says the sector code "pode começar por '0'"; reading an 8 digit value as one whose sector code lost that zero is this library's inference. - Returns `false` for a sector code of `00` and for a wrong módulo 11 check digit. - The sector and locality codes are not checked against a table, since the manual lists them only as examples. +- The rule comes from the NF-e Manual de Orientação do Contribuinte (CONFAZ/ENCAT), not from the SUFRAMA, whose Resolução CAS nº 64/2021, art. 5º, only calls the inscrição "um número de identificação e controle" and gives no layout or check digit. - Besides the usual mask characters, `(`, `)`, `,` and `*` are also ignored. ```javascript @@ -779,6 +820,7 @@ Source: [NF-e Manual de Orientação do Contribuinte 7.0, Visão Geral](https:// Check if a phone number (mobile or landline) is valid. A Brazilian country code (`+55`, `0055` or a bare `55`) is accepted and removed first, as in `parsePhone`. - **Options** (`IsValidPhoneOptions`): `accept` (`PhoneType[]`, default `['mobile', 'landline']`) picks which kinds of number count as valid; add `'service'` for the numbers `isValidServicePhone` recognizes. `version` (`PhoneVersion`, default `1`) is forwarded to `isValidMobilePhone`. +- A mobile number must start with 7, 8 or 9 under both versions (Resolução Anatel 749/2022, art. 12, I, "a"), so a leading 6 is rejected; up to 2.4.0 the default version accepted it. ```javascript import { isValidPhone } from '@brazilian-utils/brazilian-utils'; @@ -786,6 +828,7 @@ import { isValidPhone } from '@brazilian-utils/brazilian-utils'; isValidPhone('11900000000'); // true isValidPhone('11712345678', { version: 2 }); // true (7, 8 and 9 are all SMP) isValidPhone('11700123456', { version: 2 }); // false (the 700 series is satellite) +isValidPhone('11612345678'); // false (6 is not SMP) isValidPhone('+55 11 98765-4321'); // true (country code accepted) isValidPhone('08001234567'); // false (service numbers rejected by default) isValidPhone('08001234567', { accept: ['service'] }); // true @@ -803,9 +846,10 @@ Format a phone number according to Brazilian patterns. If `value` includes a DDD - `"e164"` and `"international"` drop the country code first, as `parsePhone` does, and fall back to `"service"` for a service number. - `"service"`: the Códigos Não Geográficos (`0800 123 4567`) and the abbreviated `300X`/`400X` numbers (`4004-1234`). - `"auto"`: `"service"` for a service number, `"international"` when `value` carries a country code, otherwise `"nanp"` for more than 9 digits, else `"sn"`. +- `obfuscate` is a convention of this library, not an official rule: no law, Anatel act or [ANPD guidance](https://www.gov.br/anpd/pt-br/centrais-de-conteudo/documentos-tecnicos-orientativos/estudo_tecnico_sobre_anonimizacao_de_dados_na_lgpd_uma_visao_de_processo_baseado_em_risco_e_tecnicas_computacionais.pdf) sets which digits of a phone number to show ("não há um padrão para o mascaramento"), and the [Banco Central](https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/IV_RequisitosMinimosparaExperienciadoUsuario.pdf) forbids masking a Pix key, a phone number included, when the DICT lookup returns it. - `obfuscate` keeps 2 digits, the count the gov.br account shows for a registered mobile, and keeps the prefix that names a region or a service instead of a subscriber: the DDD, the `0800`-like code and the `300X`/`400X` root. - The 2 digits are the last ones the mask itself has room for, so under the default `"sn"` a DDD-prefixed value is truncated first, exactly as it is without `obfuscate`, and the visible pair is the 8th and 9th digit rather than the last 2 of `value`. -- A 3 digit public utility code (`190`) identifies no one and is returned as it is; a value the `"service"` mask does not recognize has every digit replaced by a `*`, which hides the digits but not how many there were. The obfuscated patterns have a fixed number of slots, so under `"e164"` anything past the 11th national digit is dropped. +- Under the `"service"`, `"auto"`, `"e164"` and `"international"` masks a 3 digit public utility code (`190`) identifies no one and is returned as it is (the other masks read it as an ordinary short number); a value the `"service"` mask does not recognize has every digit replaced by a `*`, which hides the digits but not how many there were. The obfuscated patterns have a fixed number of slots, so under `"e164"` anything past the 11th national digit is dropped. ```javascript import { formatPhone } from '@brazilian-utils/brazilian-utils'; @@ -869,24 +913,30 @@ generatePhone('service'); // '08001234567' or '40041234' Check if a mobile phone number is valid. A Brazilian country code (`+55`, `0055` or a bare `55`) is accepted and removed first, as in `parsePhone`. -- **Options** (`IsValidMobilePhoneOptions`): `version` (`PhoneVersion`, default `1`) picks the numbering rule: `1` accepts a first digit of 6, 7, 8 or 9; `2` follows Resolução Anatel 749/2022, accepts only 7, 8 or 9 and rejects the `700` series. +- **Options** (`IsValidMobilePhoneOptions`): `version` (`PhoneVersion`, default `1`) picks the numbering rule. Both follow Resolução Anatel 749/2022, art. 12, I, "a" (`"7", "8" e "9": Serviço Móvel Pessoal (SMP)`) and accept only a first digit of 7, 8 or 9; `1` also accepts the `700` series, `2` rejects it as satellite (art. 12, II, "a"). +- Up to 2.4.0 version `1` also accepted a first digit of 6, which is not SMP. +- Scheduled change, not applied yet: Resolução Anatel 777/2025, art. 22, rewrites art. 12 with effect from 1 March 2027. A first digit of `6` becomes SCM (not a mobile), only `8` and `9` stay SMP, the `700` series becomes "SMGS e SMP por Satélite" and any other `7` number becomes reserva técnica. From that date a `version: 2` that follows it will have to accept only `8` and `9`, plus the `700` series as satellite SMP. ```javascript import { isValidMobilePhone } from '@brazilian-utils/brazilian-utils'; isValidMobilePhone('11900000000'); // true -isValidMobilePhone('11712345678', { version: 1 }); // true (legacy format) +isValidMobilePhone('11712345678', { version: 1 }); // true isValidMobilePhone('11712345678', { version: 2 }); // true (7 is SMP as well) -isValidMobilePhone('11612345678', { version: 2 }); // false (6 is Reserva Técnica) +isValidMobilePhone('11612345678'); // false (6 is not SMP, under either version) +isValidMobilePhone('11700123456'); // true (version 1 keeps the 700 series) isValidMobilePhone('11700123456', { version: 2 }); // false (the 700 series is satellite) ``` -Source: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749). +Source: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749) and [Resolução Anatel nº 777/2025](https://informacoes.anatel.gov.br/legislacao/resolucoes/2025/2022-resolucao-777), art. 22. ### isValidLandlinePhone Check if a landline phone number is valid. A Brazilian country code (`+55`, `0055` or a bare `55`) is accepted and removed first, as in `parsePhone`. +- The number is the DDD plus 8 digits starting with `2` to `6`, the STFC and SCM range of Resolução Anatel 749/2022, art. 11, I, "a". +- Scheduled change, not applied yet: from 1 March 2027 Resolução Anatel 777/2025, art. 21, leaves only `2` to `5` to the STFC, and the SCM moves to 9 digit numbers starting with `6`. From that date a landline starting with `6` will have to be rejected. + ```javascript import { isValidLandlinePhone } from '@brazilian-utils/brazilian-utils'; @@ -894,13 +944,15 @@ isValidLandlinePhone('1130000000'); // true isValidLandlinePhone('+55 11 3000-0000'); // true (country code accepted) ``` +Source: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749), art. 11, and [Resolução Anatel nº 777/2025](https://informacoes.anatel.gov.br/legislacao/resolucoes/2025/2022-resolucao-777), art. 21. + ### isValidServicePhone Check if a phone number is a valid Brazilian service number, dialed without a DDD. Only the structure is checked: the number does not have to be assigned to anyone. -- The Códigos Não Geográficos `0300`, `0303`, `0500`, `0800` and `0900` followed by 7 digits (11 in total). +- The Códigos Não Geográficos `0300`, `0303`, `0500`, `0800` and `0900` followed by 7 digits (11 in total): the 10 digit series of Resolução Anatel 749/2022, art. 18, dialed behind the `0` prefix (art. 28). - The abbreviated `300X`/`400X` numbers, 8 digits. Other carrier prefixes such as `4020` and `4062` are rejected. -- The 3 digit public utility codes Anatel has designated (e.g. `190`, `192`). `112` and `911` are not among them and are rejected. +- The 3 digit public utility codes Anatel has designated (e.g. `190`, `192`), as listed on the [Anatel SUP page](https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais/servicos-de-utilidade-publica-e-de-emergencia) (modified on 22/06/2023) and in the Anexo of Ato 43.151/2004, the last consolidated act. The page lists `112/911` for the Polícia Militar on handsets: `112` is accepted; `911` is rejected, since Resolução 749/2022, art. 13, holds every series outside `1XX` in reserva técnica. Up to 2.4.0 `112` was rejected too. ```javascript import { isValidServicePhone } from '@brazilian-utils/brazilian-utils'; @@ -911,7 +963,7 @@ isValidServicePhone('190'); // true isValidServicePhone('11987654321'); // false (geographic number) ``` -Source: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749), [Ato Anatel nº 43.151/2004](https://informacoes.anatel.gov.br/legislacao/atos-de-numeracao/2004/1648-ato-43151), [Resolução nº 86/1998](https://informacoes.anatel.gov.br/legislacao/resolucoes/1998/336-resolucao-86). +Source: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749), [Anatel SUP page](https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais/servicos-de-utilidade-publica-e-de-emergencia), [Ato Anatel nº 43.151/2004](https://informacoes.anatel.gov.br/legislacao/atos-de-numeracao/2004/1648-ato-43151), [Resolução nº 86/1998](https://informacoes.anatel.gov.br/legislacao/resolucoes/1998/336-resolucao-86). ### getAreaCodeInfo @@ -937,7 +989,7 @@ getAreaCodeInfo(-11); // null getAreaCodeInfo(1.1); // null ``` -Source: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749), [Anatel Códigos Nacionais](https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais). +Source: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749), [Anatel Códigos Nacionais](https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais), [Anatel table of the Códigos Nacionais by municipality (21/09/2026)](https://informacoes.anatel.gov.br/paineis/areas-tarifarias/codigos-nacionais). ### getAreaCodesByState @@ -957,7 +1009,7 @@ getAreaCodesByState('SC'); // [42, 47, 48, 49] getAreaCodesByState('XX'); // [] ``` -Source: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749), [Anatel Códigos Nacionais](https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais). +Source: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749), [Anatel Códigos Nacionais](https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais), [Anatel table of the Códigos Nacionais by municipality (21/09/2026)](https://informacoes.anatel.gov.br/paineis/areas-tarifarias/codigos-nacionais). ## License plate @@ -1057,6 +1109,7 @@ Source: [Resolução CONTRAN nº 969/2022](https://www.gov.br/transportes/pt-br/ Check if a RENAVAM (Registro Nacional de Veículos Automotores) is valid. Accepts the old format (9 digits) and the new format (11 digits). - Spaces, dots and hyphens are ignored; any other character makes the value invalid. +- The check digit is the one of Portaria DENATRAN nº 27/2013, art. 1º: "10 dígitos e um dígito verificador, calculado através do módulo 11, peso 9", read as the weights 3, 2, 9, 8, 7, 6, 5, 4, 3 and 2. The portaria gives neither the weights one by one nor the handling of a remainder of 0, 1 or 10, which follow [validation-br](https://github.com/klawdyo/validation-br/blob/main/src/renavam.ts) and [brutils](https://github.com/brazilian-utils/python/blob/main/brutils/renavam.py); padding a 9 digit code to 11 with zeros is market practice. ```javascript import { isValidRenavam } from '@brazilian-utils/brazilian-utils'; @@ -1069,6 +1122,8 @@ isValidRenavam('00000000000'); // false (repeated digits) isValidRenavam('ab00639884962'); // false (letters are rejected) ``` +Source: [Portaria DENATRAN nº 27/2013](https://www.gov.br/transportes/pt-br/assuntos/transito/arquivos-senatran/portarias/2013/portaria0272013.pdf). + ### generateRenavam Generate a valid random RENAVAM in the 11 digit form: ten base digits plus the check digit. @@ -1086,6 +1141,7 @@ generateRenavam(); // '12345678900' Check if a PIS is valid. Accepts the value masked or not. - A value whose digits are all the same is rejected. +- The check digit uses the weights 3, 2, 9, 8, 7, 6, 5, 4, 3 and 2 and módulo 11. No official document found publishes them: the eSocial and SIRC manuals say only that the number has 11 digits and a módulo 11 check digit, and the Caixa layouts ask for a "Número de PIS/PASEP válido" without saying how it is computed. The weights follow [brutils](https://github.com/brazilian-utils/python/blob/main/brutils/pis.py). ```javascript import { isValidPis } from '@brazilian-utils/brazilian-utils'; @@ -1100,7 +1156,7 @@ Format a PIS. - **Options** (`FormatPisOptions`): `pad` left-pads the value with zeros to 11 digits before masking (default `false`); `obfuscate` hides the first 3 digits and the check digit. - `obfuscate` is applied after `pad`. -- No authority publishes a masking rule for the PIS, so `obfuscate` applies the one Lei nº 12.309/2010, art. 87, § 5º sets for the CPF ("ocultar os três primeiros dígitos e os dois dígitos verificadores"), a number with the same structure. +- No authority publishes a masking rule for the PIS, so `obfuscate` applies the one the Leis de Diretrizes Orçamentárias set for publishing a CPF ("ocultar os três primeiros dígitos e os dois dígitos verificadores", Lei nº 14.194/2021, art. 149, first set by Lei nº 12.309/2010, art. 87, § 5º), a number with the same structure. ```javascript import { formatPis } from '@brazilian-utils/brazilian-utils'; @@ -1130,7 +1186,7 @@ import { generatePis } from '@brazilian-utils/brazilian-utils'; generatePis(); // '91077906857' ``` -Source: [Lei nº 12.309/2010, art. 87, § 5º](https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm), the CPF masking rule `obfuscate` borrows. +Source: [Lei nº 14.194/2021, art. 149](https://www.planalto.gov.br/ccivil_03/_ato2019-2022/2021/lei/L14194.htm), the CPF masking rule `obfuscate` borrows, first set by [Lei nº 12.309/2010, art. 87, § 5º](https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm) and repeated by the later LDOs ([Lei nº 15.321/2025, art. 163](https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2025/lei/L15321.htm#art163), the one for 2026, repeats it). ## Processo jurídico @@ -1151,7 +1207,7 @@ isValidProcessoJuridico('0000100-23.2008.8.28.0000'); // false (no 28th Tribunal isValidProcessoJuridico('ab00020802520125150049'); // false (letters are rejected) ``` -Source: [Resolução CNJ nº 65/2008](https://atos.cnj.jus.br/atos/detalhar/119). +Source: [Resolução CNJ nº 65/2008](https://atos.cnj.jus.br/atos/detalhar/119), art. 1º (layout, `J` and `TR`) and Anexo VIII ("CÁLCULO DO DÍGITO VERIFICADOR"); the TRF da 6ª Região (`4.06`) per [Resolução CNJ nº 477/2022](https://atos.cnj.jus.br/atos/detalhar/4781). ### formatProcessoJuridico @@ -1204,9 +1260,11 @@ Source: [Resolução CNJ nº 65/2008](https://atos.cnj.jus.br/atos/detalhar/119) Check if a Brazilian bank account is valid. The `bankCode` must be a Banco Central STR participant (the list `getBankByCode` uses). - **Params** (`IsValidBankAccountParams`, all strings): `bankCode` (3 digits), `agency` (1-5 digits), `account` (1-13 digits) and `digit` (1-2 characters, or `X` for Banco do Brasil and `P` for Bradesco). -- A listed bank is validated in one of three ways: by its published check digit algorithm, by structure only, or by a generic mod10/mod11 fallback. +- A listed bank is validated in one of three ways: by a check digit rule, by structure only, or by a generic mod10/mod11 fallback. +- No act of the Banco Central, of another government body or of Febraban sets these check digit rules. Those of banks 001, 033, 041, 104, 237, 341, 399 and 745 come from the "Regras de Validação de dígito verificador de agência e conta corrente" compendium of Icatu Seguros, a private compilation of each bank's rule. Nubank publishes no rule: its Verhoeff digit is the one open source validators derived from real accounts. +- The only official texts on these digits say there is no common rule: the FEBRABAN [Layout Padrão CNAB 240 v11.0](https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20padrao%20CNAB240%20V%2011_0%20-%202026_09_11.pdf) (11/09/2026), notes G009, G011 and G012, calls each of them a "código adotado pelo Banco" and gives no algorithm, and the DICT API of the Banco Central takes the account with its digit and computes nothing. -Banks validated by their published check digit algorithm: +Banks validated by a check digit rule: | Bank | Code | Agency | Account | Notes | | --- | --- | --- | --- | --- | @@ -1215,12 +1273,12 @@ Banks validated by their published check digit algorithm: | Banrisul | `041` | 4 digits | 9 digits | weights `3,2,4,7,6,5,4,3,2`; remainder 0 gives `0` and remainder 1 gives `6`; `account` is tipo (2 digits) + conta (7 digits) | | Caixa Econômica Federal | `104` | 4 digits | 11 digits | mod11 over agency + account; `account` is operação (3 digits) + conta (8 digits) | | Bradesco | `237` | 4 digits | 7 digits | mod11 with weights 2..7 cycling from the right; remainder 0 gives `0` and remainder 1 gives `"P"` | -| Nubank | `260` | 4 digits | 5-13 digits | Verhoeff check digit over the account, leading zeros dropped | +| Nubank | `260` | 4 digits | 5-13 digits | Verhoeff check digit over the account, leading zeros dropped (no published rule; see above) | | Itaú Unibanco | `341` | 4 digits | 5 digits | mod10 over agency + account | | HSBC / Kirton Bank | `399` | 4 digits | 6 digits | weights `8,9,2,3,4,5,6,7,8,9` over agency + account; remainder 10 gives `0` | | Citibank | `745` | 4 digits | 10 digits | weights `11..2` over the account; remainder 0 or 1 gives `0` | -Banks validated by structure only (a single numeric `digit` is enough): +Banks validated by structure only, since no check digit rule of theirs is known (a single numeric `digit` is enough): | Bank | Code | | Bank | Code | | --- | --- | --- | --- | --- | @@ -1303,7 +1361,7 @@ isValidBankAccount({ }); // true (Banco ABC Brasil, generic mod10 fallback) ``` -Source: [STR participants list](https://www.bcb.gov.br/content/estabilidadefinanceira/str1/ParticipantesSTR.csv), [Regras de Validação de dígito verificador](https://github.com/eduardokum/laravel-boleto/blob/master/manuais/Regras%20Validacao%20Conta%20Corrente%20VI_EPS.pdf). +Source: [STR participants list](https://www.bcb.gov.br/content/estabilidadefinanceira/str1/ParticipantesSTR.csv) (official). Based on: the Icatu Seguros compendium [Regras de Validação de dígito verificador de agência e conta corrente](https://github.com/eduardokum/laravel-boleto/blob/master/manuais/Regras%20Validacao%20Conta%20Corrente%20VI_EPS.pdf) and, for Nubank, [bran_checker](https://github.com/Xerpa/bran_checker/tree/master/lib/banks). ### getBanks @@ -1363,7 +1421,8 @@ Source: [STR participants list](https://www.bcb.gov.br/content/estabilidadefinan Check if a Brazilian IBAN (International Bank Account Number) is valid. Only Brazilian IBANs (country code `BR`) are recognized; any other country returns `false`. -- Layout, 29 characters: `BR`, 2 check digits (ISO 7064 MOD 97-10), 8 digit ISPB, 5 digit branch, 10 digit account, 1 letter account type, 1 owner indicator. +- Layout, 29 characters (Resolução BCB 585/2026, art. 2º, which revoked Circular BCB 3.625/2013 and kept its layout): `BR`, 2 check digits (ISO 7064 MOD 97-10), 8 character ISPB, 5 digit branch, 10 digit account, 1 letter account type, 1 owner indicator. +- The ISPB may hold letters: the Resolução makes it "oito caracteres alfanuméricos", where the Circular said "numéricos". Up to 2.4.0 only digits were accepted. - Account type: any letter, usually `C` or `P`. Owner: `1` to `9`, then `A` to `Z`. - Accepts the compact form or groups of 4 split by one whitespace, `.`, `-` or `/`, in any case. @@ -1373,12 +1432,13 @@ import { isValidIban } from '@brazilian-utils/brazilian-utils'; isValidIban('BR1500000000000010932840814P2'); // true isValidIban('BR15 0000 0000 0000 1093 2840 814P 2'); // true (grouping spaces) isValidIban('BR15-0000-0000-0000-1093-2840-814P-2'); // true (any of the mask characters) +isValidIban('BR1012AB34CD000010932840814P2'); // true (alphanumeric ISPB) isValidIban('BR1500000000000010932840814P3'); // false (bad check digits) isValidIban('BR15 000 00000 0000 1093 2840 814P 2'); // false (a separator inside a group) isValidIban('DE89370400440532013000'); // false (non Brazilian IBAN) ``` -Source: [Diretrizes de Implementação do IBAN no Brasil](https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf), [Circular BCB nº 3.625/2013](https://www.bcb.gov.br/pre/normativos/circ/2013/pdf/circ_3625_v1_O.pdf), [ISO 13616-1:2020](https://www.iso.org/standard/81090.html). +Source: [Diretrizes de Implementação do IBAN no Brasil](https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf), [Resolução BCB nº 585/2026](https://www.bcb.gov.br/estabilidadefinanceira/exibenormativo?tipo=Resolu%C3%A7%C3%A3o%20BCB&numero=585), which revoked [Circular BCB nº 3.625/2013](https://www.bcb.gov.br/pre/normativos/circ/2013/pdf/circ_3625_v1_O.pdf), [ISO 13616-1:2020](https://www.iso.org/standard/81090.html). ### formatIban @@ -1431,7 +1491,7 @@ getIbanInfo('DE89370400440532013000'); // null (non Brazilian IBAN) getIbanInfo('BR15 000 00000 0000 1093 2840 814P 2'); // null (a separator inside a group) ``` -Source: [Diretrizes de Implementação do IBAN no Brasil](https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf), [Circular BCB nº 3.625/2013](https://www.bcb.gov.br/pre/normativos/circ/2013/pdf/circ_3625_v1_O.pdf), [ISO 13616-1:2020](https://www.iso.org/standard/81090.html). +Source: [Diretrizes de Implementação do IBAN no Brasil](https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf), [Resolução BCB nº 585/2026](https://www.bcb.gov.br/estabilidadefinanceira/exibenormativo?tipo=Resolu%C3%A7%C3%A3o%20BCB&numero=585), which revoked [Circular BCB nº 3.625/2013](https://www.bcb.gov.br/pre/normativos/circ/2013/pdf/circ_3625_v1_O.pdf), [ISO 13616-1:2020](https://www.iso.org/standard/81090.html). ## Currency, numbers and dates in words @@ -1695,7 +1755,7 @@ Get the Brazilian municipalities published by the IBGE: every municipality, or o - Each municipality (`Municipality`) is `{ code, name, stateCode }`, where `code` is the 7-digit IBGE code. Sorted by name in the "pt-BR" locale. - Only an omitted (or `undefined`) `stateCode` asks for the full list: `null` and `''` return `[]`. - `stateCode` is case-sensitive: `'sp'`, like an unknown code, returns `[]`. -- Embeds all 5571 municipalities. See [Bundle size](getting-started.md#bundle-size) to lazy-load it via `@brazilian-utils/brazilian-utils/get-municipalities`. +- Embeds all 5571 municipalities, the same codes as the IBGE [Divisão Territorial Brasileira 2025](https://geoftp.ibge.gov.br/organizacao_do_territorio/estrutura_territorial/divisao_territorial/2025/DTB_2025.zip) (data base 31/12/2025). See [Bundle size](getting-started.md#bundle-size) to lazy-load it via `@brazilian-utils/brazilian-utils/get-municipalities`. ```javascript import { getMunicipalities } from '@brazilian-utils/brazilian-utils'; @@ -1855,7 +1915,13 @@ Get the Brazilian holidays of a year: the national ones and, with a `stateCode`, - Each holiday is a `Holiday` whose `type` (`HolidayType`) is `"national"`, `"state"`, `"optional"` or `"religious"`. Holidays are sorted by date. - "Dia da Consciência Negra", Nov 20, is national from 2024 on. -- Per-state rules (SC's Sunday shift, DF's Corpus Christi, dates that stopped being holidays) follow each state's law; see the source for the list. +- The first round of the elections, "Eleições (primeiro turno)", is a national holiday in the even years from 1998 on (Código Eleitoral, art. 380): the first Sunday of October, or Nov 15 in 2020 (EC nº 107/2020). The second round is left out, since it is held only where one is needed. Being a Sunday, it never changes a business day count. +- Each fixed-date national holiday is listed only for the years a federal norm declared it (Sexta-feira Santa, which the federal calendar portarias list as a feriado nacional every year, is listed every year): Nossa Senhora Aparecida from 1980, Natal from 1922, Dia do trabalhador from 1925, Tiradentes up to 1930, from 1933 to 1948 and from 1951, and Finados up to 1948 and from 2003. Lei nº 662/1949 left Finados out of the feriados nacionais that Decreto-lei nº 486/1938 listed, and only Lei nº 10.607/2002 put it back (the Câmara report on its bill: "Só inova ao sugerir o dia de finados"); up to 2.4.0 it was listed every year. +- The `"optional"` entries are the whole-day pontos facultativos of the federal calendar (Portarias MGI nº 8.617/2023, 9.783/2024 and 11.460/2025, for 2024 to 2026, all of which list both Carnaval days as ponto facultativo, never as feriado nacional): Carnaval Monday and Tuesday and Corpus Christi, the same three days the financial market skips (Resolução CMN nº 4.880/2020), plus the state ones a state norm declares (AL's Sep 16 from 2020 to 2023, AM's Dec 8, which the state declares for its offices by decree, and PE's Mar 6 in 2008 and 2009). `includeOptional` switches exactly these. The partial ones are left out: Quarta-feira de Cinzas (until 14h), Oct 28 (Dia do Servidor Público) and the Dec 24 and Dec 31 afternoons. +- Per-state rules (SC's Sunday shift of a date falling Monday to Friday, a Saturday date staying, since the law's "dia útil da semana" does not settle Saturday; AL's Nov 30 moved to Monday from a Tuesday and to Friday from a Thursday, DF's and, from 2024, MA's Corpus Christi and RJ's Carnaval Tuesday typed `"state"`, dates that stopped being holidays) follow each state's law; see the source for the list. +- Other shifts are not applied and the statutory date is returned: AC's law moves the feriados falling Tuesday to Thursday to the Friday (Lei AC nº 2.126/2009), but the state's own yearly decrees apply it unevenly (2026 moves Jan 20 and leaves Nov 17, a Tuesday, in place). +- GO's three dates (Jul 26, Oct 24, Oct 28) are the "feriados estaduais" of the state servants' statute (Lei GO nº 20.756/2020, art. 269, II); no Goiás law fixing a data magna as a civil holiday was found. The governor moves Jul 26 by decree every year (2025: Jul 28; 2026: Jul 20), so the statutory Jul 26 returned here is usually not the day observed. +- Each state holiday is listed only from the first year its state law applied (SP's Jul 9 from 1997, RJ's São Jorge from 2008, SC's Aug 11 from 2004), so an older year has fewer state holidays. - An unknown or non-string `stateCode` is ignored and only national holidays are returned. - Returns `[]` when the year is not an integer from 1900 to 2099, or when the argument is neither a number nor an object. @@ -1866,9 +1932,11 @@ import { getHolidays } from '@brazilian-utils/brazilian-utils'; getHolidays(2024); // [ // { name: 'Ano novo', date: Date('2024-01-01'), type: 'national' }, +// { name: 'Carnaval (segunda-feira)', date: Date('2024-02-12'), type: 'optional' }, // { name: 'Carnaval (terça-feira)', date: Date('2024-02-13'), type: 'optional' }, // { name: 'Sexta-feira Santa', date: Date('2024-03-29'), type: 'national' }, // { name: 'Páscoa', date: Date('2024-03-31'), type: 'religious' }, +// { name: 'Eleições (primeiro turno)', date: Date('2024-10-06'), type: 'national' }, // { name: 'Dia da Consciência Negra', date: Date('2024-11-20'), type: 'national' }, // // ... more holidays // ] @@ -1900,8 +1968,8 @@ isHoliday(); // false Check if a date is a Brazilian business day (dia útil): not a Saturday, a Sunday or a holiday `getHolidays` lists for its local calendar day. -- **Options** (`BusinessDayOptions`, shared by every business day util): `includeOptional` (default `true`) also counts the `"optional"` holidays, Carnaval and Corpus Christi, as non-business days; `includeSaturday` (default `false`) counts Saturday as a business day; `stateCode` also counts that state's holidays. -- `includeSaturday` off is the Monday to Friday count banks and courts use. On, it is the labour law count of the payroll deadline of CLT art. 459 § 1º, the one labour inspection reads through Instrução Normativa MTP nº 2/2021, art. 14, I: "na contagem dos dias será incluído o sábado, excluindo-se o domingo e o feriado, inclusive o municipal". +- **Options** (`BusinessDayOptions`, shared by every business day util): `includeOptional` (default `true`) also counts the `"optional"` holidays, Carnaval Monday and Tuesday and Corpus Christi, as non-business days; `includeSaturday` (default `false`) counts Saturday as a business day; `stateCode` also counts that state's holidays. +- `includeSaturday` off is a Monday to Friday count. It is not by itself the calendar of banks or courts: the financial market also skips Carnaval Monday and Tuesday and Corpus Christi (Resolução CMN nº 4.880/2020, art. 6º), which the default `includeOptional` covers, and banks close on local holidays; the federal courts also close from Dec 20 to Jan 6, from Holy Wednesday to Easter, on Carnaval Monday and Tuesday, Aug 11, Nov 1 and 2 and Dec 8 (Lei nº 5.010/1966, art. 62), and procedural deadlines follow each court's calendar (CPC art. 216). On, it is the labour law count of the payroll deadline of CLT art. 459 § 1º, the one labour inspection reads through Instrução Normativa MTP nº 2/2021, art. 14, I: "na contagem dos dias será incluído o sábado, excluindo-se o domingo e o feriado, inclusive o municipal". - Sunday and holidays are still excluded with `includeSaturday` on, so a holiday that falls on a Saturday is still not a business day. - The "inclusive o municipal" part of that rule is not covered: `getHolidays` carries national and state holidays only, so a municipal holiday counts here as an ordinary business day. Remove the municipal holidays yourself when a count has to be exact for one municipality. - Returns `false` when `value` is not a valid `Date` or its year is outside 1900 to 2099, or when `stateCode` is present and not a string. @@ -1915,7 +1983,8 @@ isBusinessDay(new Date(2024, 0, 6)); // false (Saturday) isBusinessDay(new Date(2024, 0, 6), { includeSaturday: true }); // true (labour law count) isBusinessDay(new Date(2024, 8, 7), { includeSaturday: true }); // false (Independência, a holiday on a Saturday) isBusinessDay(new Date(2024, 0, 7), { includeSaturday: true }); // false (Sunday is never included) -isBusinessDay(new Date(2024, 1, 13)); // false (Carnaval, optional holiday, counts by default) +isBusinessDay(new Date(2024, 1, 12)); // false (Carnaval Monday, optional holiday, counts by default) +isBusinessDay(new Date(2024, 1, 13)); // false (Carnaval Tuesday, optional holiday, counts by default) isBusinessDay(new Date(2024, 1, 13), { includeOptional: false }); // true isBusinessDay(new Date(2024, 6, 9), { stateCode: 'SP' }); // false (Revolução Constitucionalista) isBusinessDay(new Date(2024, 6, 9)); // true (state holiday ignored without stateCode) @@ -1926,7 +1995,7 @@ isBusinessDay(new Date('not a date')); // false Add a number of Brazilian business days (dias úteis) to a date, skipping Saturdays, Sundays and the holidays `isBusinessDay` skips. Signature: `addBusinessDays(date, amount, options?)`, the same as date-fns. -- **Options** (`BusinessDayOptions`, shared with `isBusinessDay`): `includeOptional` (default `true`) also skips Carnaval and Corpus Christi; `includeSaturday` (default `false`) counts Saturday as a business day; `stateCode` also skips that state's holidays. +- **Options** (`BusinessDayOptions`, shared with `isBusinessDay`): `includeOptional` (default `true`) also skips Carnaval Monday and Tuesday and Corpus Christi; `includeSaturday` (default `false`) counts Saturday as a business day; `stateCode` also skips that state's holidays. - Returns a new `Date`, time of day preserved; `date` is never mutated. - An `amount` of `0` returns the same date, even on a weekend or holiday. A negative `amount` walks backwards. - Returns `null` when `date` is invalid, `amount` is not a finite integer, `stateCode` is not a string, or the result leaves the years 1900 to 2099. @@ -1973,14 +2042,14 @@ import { addBusinessDays, subBusinessDays } from '@brazilian-utils/brazilian-uti // n-th business day of the month: add n from the last day of the month before addBusinessDays(new Date(2024, 0, 0), 5); // Date, 2024-01-08 00:00 (5th business day of January 2024) -addBusinessDays(new Date(2024, 1, 0), 10); // Date, 2024-02-15 00:00 (10th of February 2024, Carnaval skipped) +addBusinessDays(new Date(2024, 1, 0), 10); // Date, 2024-02-16 00:00 (10th of February 2024, Carnaval Monday and Tuesday skipped) // last business day of the month: subtract 1 from the first day of the month after subBusinessDays(new Date(2024, 3, 1), 1); // Date, 2024-03-28 00:00 (2024-03-29 is Sexta-feira Santa, then a weekend) subBusinessDays(new Date(2024, 1, 1), 2); // Date, 2024-01-30 00:00 (2nd to last of January 2024) // payroll deadline of CLT art. 459 § 1º: the 5th business day in the labour law count -addBusinessDays(new Date(2024, 2, 0), 5, { includeSaturday: true }); // Date, 2024-03-06 00:00 (2024-03-02, a Saturday, counts; the banking count gives 2024-03-07) +addBusinessDays(new Date(2024, 2, 0), 5, { includeSaturday: true }); // Date, 2024-03-06 00:00 (2024-03-02, a Saturday, counts; the Monday to Friday count gives 2024-03-07) addBusinessDays(new Date(2024, 10, 0), 5, { includeSaturday: true }); // Date, 2024-11-07 00:00 (2024-11-02 is Finados, a holiday on a Saturday) subBusinessDays(new Date(2024, 8, 1), 1, { includeSaturday: true }); // Date, 2024-08-31 00:00 (last business day of August 2024, a Saturday) ``` @@ -1993,7 +2062,7 @@ subBusinessDays(new Date(2024, 8, 1), 1, { includeSaturday: true }); // Date, 20 Count the Brazilian business days (dias úteis) between two dates. Signature: `differenceInBusinessDays(laterDate, earlierDate, options?)`, the same as date-fns. -- **Options** (`BusinessDayOptions`, shared with `isBusinessDay`): `includeOptional` (default `true`) also skips Carnaval and Corpus Christi; `includeSaturday` (default `false`) counts Saturday as a business day; `stateCode` also skips that state's holidays. +- **Options** (`BusinessDayOptions`, shared with `isBusinessDay`): `includeOptional` (default `true`) also skips Carnaval Monday and Tuesday and Corpus Christi; `includeSaturday` (default `false`) counts Saturday as a business day; `stateCode` also skips that state's holidays. - Counts `earlierDate` when it is a business day and every business day strictly between the two dates; `laterDate` is never counted. The time of day is ignored. - The result is negative when `laterDate` is before `earlierDate`, and `0` on the same calendar day. - Returns `null` when either date is not a valid `Date` or is outside the years 1900 to 2099, or `stateCode` is not a string. @@ -2005,7 +2074,7 @@ differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 1)); // 0 (Jan differenceInBusinessDays(new Date(2024, 0, 3), new Date(2024, 0, 2)); // 1 (Jan 2 counted, a Tuesday; Jan 3 is not) differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 3)); // -1 (the later date comes first, so the count is negative) differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 2)); // 0 (same day) -differenceInBusinessDays(new Date(2024, 0, 8), new Date(2024, 0, 1)); // 4 (banking count, 2024-01-02 to 2024-01-05) +differenceInBusinessDays(new Date(2024, 0, 8), new Date(2024, 0, 1)); // 4 (Monday to Friday count, 2024-01-02 to 2024-01-05) differenceInBusinessDays(new Date(2024, 0, 8), new Date(2024, 0, 1), { includeSaturday: true }); // 5 (2024-01-06, a Saturday, also counts) differenceInBusinessDays(new Date(2024, 10, 4), new Date(2024, 10, 1), { includeSaturday: true }); // 1 (2024-11-02 is Finados, a holiday on a Saturday) differenceInBusinessDays(new Date(2024, 6, 10), new Date(2024, 6, 8), { stateCode: 'SP' }); // 1 (2024-07-09 is a state holiday in SP) @@ -2019,6 +2088,7 @@ differenceInBusinessDays(new Date(), new Date('not a date')); // null Check if a Brazilian passport number is valid: 2 letters followed by 6 digits. - There is no check digit, so a well-formed number is not necessarily a real passport. +- The 2 letters (the "série") and 6 digits come from the Polícia Federal's FAQ; no norm defines the number (neither the Decreto nº 5.978/2006 nor the IN nº 173-DG/PF/2020, as amended up to the IN nº 283/2024), and the FAQ lists no forbidden letter. ```javascript import { isValidPassport } from '@brazilian-utils/brazilian-utils'; @@ -2070,7 +2140,7 @@ generatePassport(); // 'RY393097' Check if a CNH is valid. Spaces, dots and hyphens are ignored; any other character makes the value invalid. - A value whose 11 digits are all the same is rejected, so `'11111111111'` is invalid. -- The first check digit keeps a remainder of 1 as `1`, as real registry numbers do. Resolução CONTRAN nº 886/2021 says `0`. +- The first check digit keeps a remainder of 1 as `1`, as real registry numbers do. Resolução CONTRAN nº 886/2021, art. 4º § 1º, whose remainder of 0 or 1 gives `0`, speaks of "O dígito verificador" without naming the number: written in the singular right after the Número do Espelho da CNH (the only number of the article with a single check digit), it reads best as that digit's rule, but it is worded generically and gives no weights, so it is no source for the 2 check digits of the registry number; no official text publishes their weights. Resoluções CONTRAN nº 976/2022, nº 998/2023 and nº 1.006/2024 amend Resolução nº 886/2021, none of them in art. 4º. Resolução CONTRAN nº 1.020/2025, the newer habilitação norm, repeats the layout in its art. 10 ("nove caracteres e dois dígitos verificadores") with no check digit rule and does not revoke the 886 (art. 140). ```javascript import { isValidCnh } from '@brazilian-utils/brazilian-utils'; @@ -2080,7 +2150,7 @@ isValidCnh('000000001-19'); // true (hyphen before the check digits) isValidCnh('ab00000000119'); // false (letters are rejected) ``` -Source: [Resolução CONTRAN nº 886/2021, art. 4º](https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/Resolucao8862021F.pdf); weights per [siga0984](https://siga0984.wordpress.com/2019/05/01/algoritmos-validacao-de-cnh/). +Source: [Resolução CONTRAN nº 886/2021, art. 4º](https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/Resolucao8862021F.pdf), [Resolução CONTRAN nº 1.020/2025, art. 10](https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/Resolucao10202025.pdf); weights per [siga0984](https://siga0984.wordpress.com/2019/05/01/algoritmos-validacao-de-cnh/). ### formatCnh @@ -2088,7 +2158,7 @@ Format a CNH. - **Options** (`FormatCnhOptions`): `pad` left-pads the value with zeros to the full 11 digits before masking (default `false`); `obfuscate` hides the first 3 digits and the 2 check digits. - `obfuscate` is applied after `pad`. -- No authority publishes a masking rule for the CNH, so `obfuscate` applies the one Lei nº 12.309/2010, art. 87, § 5º sets for the CPF ("ocultar os três primeiros dígitos e os dois dígitos verificadores"), a number with the same structure. +- No authority publishes a masking rule for the CNH, so `obfuscate` applies the one the Leis de Diretrizes Orçamentárias set for publishing a CPF ("ocultar os três primeiros dígitos e os dois dígitos verificadores", Lei nº 14.194/2021, art. 149, first set by Lei nº 12.309/2010, art. 87, § 5º), a number with the same structure. ```javascript import { formatCnh } from '@brazilian-utils/brazilian-utils'; @@ -2118,7 +2188,7 @@ import { generateCnh } from '@brazilian-utils/brazilian-utils'; generateCnh(); // '02650306461' ``` -Source: [Lei nº 12.309/2010, art. 87, § 5º](https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm), the CPF masking rule `obfuscate` borrows. +Source: [Lei nº 14.194/2021, art. 149](https://www.planalto.gov.br/ccivil_03/_ato2019-2022/2021/lei/L14194.htm), the CPF masking rule `obfuscate` borrows, first set by [Lei nº 12.309/2010, art. 87, § 5º](https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm) and repeated by the later LDOs ([Lei nº 15.321/2025, art. 163](https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2025/lei/L15321.htm#art163), the one for 2026, repeats it). ## Legal nature @@ -2211,7 +2281,8 @@ getLegalNature('2208'); // } getLegalNature('3123')?.currentCode; // null (retired without a successor) getLegalNature('206-2')?.code; // '2062' -getLegalNature(206.2)?.category.description; // 'Entidades Empresariais' +getLegalNature('206.2')?.category.description; // 'Entidades Empresariais' +getLegalNature(206.2); // null (a number is only read when it is a non-negative safe integer: write the dotted form as a string) getLegalNature('0000'); // null ``` @@ -2262,10 +2333,12 @@ getLegalNaturesByCategory('9'); // [] ### isValidVoterId -Check if a voter ID number is valid. Accepts the standard 12-digit id and the 13-digit id issued by São Paulo (UF `01`) and Minas Gerais (UF `02`). +Check if a voter ID number is valid. A voter ID has at most 12 digits, so a 13-digit value is rejected. - A voter ID is an 8-digit sequential number, a 2-digit federative union code (`01` to `28`) and 2 check digits. +- The TSE drops the leading zeros of the sequential number when it issues the ID, so a shorter value is read as the ID without them and left padded with zeros to 12 digits before it is checked (`123450159` is checked as `000123450159`). At least one sequential digit is required: the shortest accepted value has 5 digits. - Whitespace and dots are accepted around and between the groups. Any other character, a hyphen included, makes the value invalid. +- Resolução TSE nº 23.659/2021, art. 36, which revoked Resolução TSE nº 21.538/2003 (art. 140), fixes the layout, the federative union table and two check digits "determinados com base no 'Módulo 11'". It gives no weights and no rule per state: the weights, and the rule that turns a remainder of 0 into 1 for São Paulo (`01`) and Minas Gerais (`02`), have no official source and follow the community references below. ```javascript import { generateVoterId, isValidVoterId } from '@brazilian-utils/brazilian-utils'; @@ -2274,38 +2347,40 @@ const voterId = generateVoterId('SP'); isValidVoterId(voterId); // true isValidVoterId('102385010671'); // true (12 digits) -isValidVoterId('1234567880191'); // true (13 digits, São Paulo) +isValidVoterId('123450159'); // true (000123450159 issued without its leading zeros) +isValidVoterId('1234567880191'); // false (13 digits, more than the 12 the TSE allows) isValidVoterId('123456780124'); // false (invalid check digits) ``` -Source: [Resolução TSE nº 23.659/2021, art. 36](https://www.tse.jus.br/legislacao/compilada/res/2021/resolucao-no-23-659-de-26-de-outubro-de-2021), [brutils](https://github.com/brazilian-utils/python/blob/main/brutils/voter_id.py) and [siga0984](https://siga0984.wordpress.com/2019/05/01/algoritmos-validacao-de-titulo-de-eleitor/). +Source: [Resolução TSE nº 23.659/2021, art. 36](https://www.tse.jus.br/legislacao/compilada/res/2021/resolucao-no-23-659-de-26-de-outubro-de-2021) ("composto por até 12 algarismos", "os oito primeiros algarismos serão sequenciados, desprezando-se, na emissão, os zeros à esquerda"), [brutils](https://github.com/brazilian-utils/python/blob/main/brutils/voter_id.py) and [siga0984](https://siga0984.wordpress.com/2019/05/01/algoritmos-validacao-de-titulo-de-eleitor/). ### formatVoterId Format a voter ID number with the 12-digit grouping `0000 0000 00 00`. -- **Options** (`FormatVoterIdOptions`): `obfuscate` hides the first 3 digits and the 2 check digits, leaving the federative union code visible. -- The 13-digit grouping `0000 0000 0 00 00` is used only when the value has more than 12 digits and its UF code (the 10th and 11th digits) is `01` or `02`. -- Digits past the last slot of the pattern are dropped. -- No authority publishes a masking rule for the voter ID, so `obfuscate` applies the one Lei nº 12.309/2010, art. 87, § 5º sets for the CPF ("ocultar os três primeiros dígitos e os dois dígitos verificadores"), a number with the same structure. +- **Options** (`FormatVoterIdOptions`): `pad` left pads the value with zeros up to 12 digits, restoring the leading zeros of a voter ID issued without them; `obfuscate` hides the first 3 digits and the 2 check digits, leaving the federative union code visible. The mask hides by position, so pass `pad` with `obfuscate` for a voter ID given as a number, which has lost its leading zeros: without it the mask shifts onto the check digits. +- Without `pad`, a shorter value is formatted from the left, as a partially typed ID. +- Digits past the 12th are dropped. +- No authority publishes a masking rule for the voter ID, so `obfuscate` applies the one the Leis de Diretrizes Orçamentárias set for publishing a CPF ("ocultar os três primeiros dígitos e os dois dígitos verificadores", Lei nº 14.194/2021, art. 149, first set by Lei nº 12.309/2010, art. 87, § 5º), a number with the same structure. ```javascript import { formatVoterId } from '@brazilian-utils/brazilian-utils'; formatVoterId('123456780175'); // '1234 5678 01 75' formatVoterId('123456780175', { obfuscate: true }); // '***4 5678 01 **' -formatVoterId('1234567880191'); // '1234 5678 8 01 91' (13-digit SP/MG voter id) +formatVoterId('123450159', { pad: true }); // '0001 2345 01 59' +formatVoterId('123450159'); // '1234 5015 9' (read as a partially typed ID) ``` ### parseVoterId -Remove voter ID formatting, keep only digits, and cap the result to 12 digits (13 when the UF digits identify São Paulo or Minas Gerais). +Remove voter ID formatting, keep only digits, and cap the result to 12 digits. A shorter value is kept as it is, without adding leading zeros. ```javascript import { parseVoterId } from '@brazilian-utils/brazilian-utils'; parseVoterId('1234 5678 01 75'); // '123456780175' -parseVoterId('1234 5678 8 01 91'); // '1234567880191' (13-digit SP/MG voter id) +parseVoterId('12345 01 59'); // '123450159' ``` ### generateVoterId @@ -2313,7 +2388,7 @@ parseVoterId('1234 5678 8 01 91'); // '1234567880191' (13-digit SP/MG voter id) Generate a valid random voter ID number. The optional `state` argument (`StateCode`, or `"ZZ"` for a voter ID issued abroad) sets the federative union code. - An unknown state, or a value that is not a string, falls back to `"ZZ"` (UF `28`). -- The result always has 12 digits, never the 13-digit São Paulo or Minas Gerais form. +- The result always has 12 digits, the leading zeros of the sequential number included; the same ID without them is valid too. ```javascript import { generateVoterId } from '@brazilian-utils/brazilian-utils'; @@ -2323,7 +2398,7 @@ generateVoterId('SP'); // valid random voter ID for Sao Paulo generateVoterId('XX'); // falls back to "ZZ" instead of throwing ``` -Source: [Lei nº 12.309/2010, art. 87, § 5º](https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm), the CPF masking rule `obfuscate` borrows. +Source: [Lei nº 14.194/2021, art. 149](https://www.planalto.gov.br/ccivil_03/_ato2019-2022/2021/lei/L14194.htm), the CPF masking rule `obfuscate` borrows, first set by [Lei nº 12.309/2010, art. 87, § 5º](https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm) and repeated by the later LDOs ([Lei nº 15.321/2025, art. 163](https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2025/lei/L15321.htm#art163), the one for 2026, repeats it). ## CNS @@ -2332,7 +2407,7 @@ Source: [Lei nº 12.309/2010, art. 87, § 5º](https://www.planalto.gov.br/ccivi Check if a CNS (Cartão Nacional de Saúde) number is valid, the SUS (Sistema Único de Saúde) identifier of a user, health professional or health facility. The value must be the 15 digits, optionally split into the printed groups of 3-4-4-4 by whitespace, `.`, `-` or `/`. - Definitive cards start with 1 or 2, provisional ones with 7, 8 or 9; each has its own modulus 11 rule. -- A number starting with 5 is rejected, following ANVISA. +- A number starting with 5 is rejected. The DATASUS validation routines ([Wayback Machine copy](https://web.archive.org/web/20190106003442/http://cartaonet.datasus.gov.br/Rotina_JavaScript.doc) of the file the cartaonet.datasus.gov.br site published) cover only the numbers that start with 1 or 2 (definitive) and with 7, 8 or 9 (provisional), as ANVISA does; no official document names the prefix 5, which the e-SUS APS page accepts. ```javascript import { isValidCns } from '@brazilian-utils/brazilian-utils'; @@ -2341,12 +2416,13 @@ isValidCns('123456789010000'); // true (definitive) isValidCns('100000000060018'); // true (definitive, raw check digit 10, suffix 001) isValidCns('700000000000005'); // true (provisional) isValidCns('123.4567-8901/0000'); // true (any of the mask characters) +isValidCns(-123456789010000); // false (not a non-negative safe integer) isValidCns('123456789010001'); // false (wrong check digit) isValidCns('12345678901'); // false (wrong length) isValidCns('abc123456789010000'); // false (not written as a CNS) ``` -Source: [ANVISA CNS validation page](https://rni-docs.anvisa.gov.br/docs/regras_gerais/validacoes/validacaoCNS/) and the [e-SUS APS page](https://integracao.esusab.ufsc.br/ledi/documentacao/regras/algoritmo_CNS.html). +Source: [DATASUS validation routines](https://web.archive.org/web/20190106003442/http://cartaonet.datasus.gov.br/Rotina_JavaScript.doc) (Wayback Machine copy), [ANVISA CNS validation page](https://rni-docs.anvisa.gov.br/docs/regras_gerais/validacoes/validacaoCNS/) and the [e-SUS APS page](https://integracao.esusab.ufsc.br/ledi/documentacao/regras/algoritmo_CNS.html). ### formatCns @@ -2393,8 +2469,9 @@ The matrícula has 32 digits, printed as `000000 00 00 0000 0 00000 000 0000000 | 2 | dígitos verificadores | - **Options** (`IsValidCertidaoOptions`): `accept` narrows the valid book types (`CertidaoType`) to the listed ones (default: every type). -- The serviço must be `55`, and the book-type digit must be one of the nine books (`0` is rejected). +- The serviço must be `55`, and the book-type digit one of the codes 1 to 9: 1 to 7 are the books of art. 473, V (Provimento CNJ nº 149/2023, redação of the Provimento CN nº 182/2024); 8 (`"emancipation"`, Livro E desdobrado for emancipações) and 9 (`"interdiction"`, Livro E desdobrado for interdições) come from the Provimento CNJ nº 3/2009, art. 7º, revoked by the Provimento CNJ nº 63/2017, and are kept so the certidões issued under it from 2010 on still validate. `0` is rejected. - Accepts the value masked or not, with whitespace between and around the groups. +- No official document publishes the check digit algorithm: art. 473, IX only names the two digits, the revoked Provimento CNJ nº 3/2009 had them computed by a program the CNJ handed to the registrars, and the Caixa's Cadastro NIS layout says only "módulo 11". The weights and the remainder rule follow the community references below. ```javascript import { isValidCertidao } from '@brazilian-utils/brazilian-utils'; @@ -2403,6 +2480,8 @@ isValidCertidao('104539 01 55 2013 1 00012 021 0000123 21'); // true isValidCertidao('09430001552010100020112000012087'); // true isValidCertidao('104539 01 55 2013 1 00012 021 0000123 22'); // false (invalid check digits) isValidCertidao('09400301542011100110002005191744'); // false (serviço is not 55) +isValidCertidao('10453901552013900012021000012398'); // true (book code 9, Provimento CNJ nº 3/2009) +isValidCertidao('10453901552013000012021000012387'); // false (book code 0 names no book) isValidCertidao('123456'); // false (wrong length) isValidCertidao('104539 01 55 2013 1 00012 021 0000123 21', { accept: ['birth'] }); // true isValidCertidao('104539 01 55 2013 1 00012 021 0000123 21', { accept: ['death'] }); // false @@ -2415,7 +2494,7 @@ Source: [art. 473 of the Código Nacional de Normas da Corregedoria Nacional de Format the matrícula of a certidão de registro civil into the printed mask of art. 473. The 32 digits are grouped as 6 2 2 4 1 5 3 7 2 and separated by spaces. - **Options** (`FormatCertidaoOptions`): `pad` left-pads the value with zeros up to 32 digits (default `false`). -- A number is accepted, but a full 32-digit matrícula has to be a string. +- A number is accepted when it is a non-negative safe integer, so a full 32-digit matrícula has to be a string. Any other number returns `''`. ```javascript import { formatCertidao } from '@brazilian-utils/brazilian-utils'; @@ -2424,6 +2503,7 @@ formatCertidao('10453901552013100012021000012321'); // 104539 01 55 2013 1 00012 formatCertidao('104539.01.55.2013.1.00012.021.0000123-21'); // 104539 01 55 2013 1 00012 021 0000123 21 formatCertidao('1552010100020112000012087', { pad: true }); // 000000 01 55 2010 1 00020 112 0000120 87 formatCertidao(104539015520); // 104539 01 55 20 (a number is read as the string of its digits) +formatCertidao(1045390155.2); // '' (not a non-negative safe integer) ``` Source: [art. 473 of the Código Nacional de Normas](https://atos.cnj.jus.br/atos/detalhar/5243). @@ -2443,8 +2523,8 @@ parseCertidao('104539 01 55 2013 1 00012 021 0000123 21'); Parse the matrícula of a certidão de registro civil into its fields. Accepts the same input forms as `isValidCertidao` and returns `null` when the matrícula is not valid. -- Returns `null` also for a serviço other than `55` and for a book code outside 1 to 9. -- Art. 473, V lists only the book codes 1 to 7. The codes 8 (emancipação) and 9 (interdição) are also accepted. +- Returns `null` also for a serviço other than `55` and for the book code `0`. +- Art. 473, V lists the book codes 1 to 7, from "1: Livro A (Nascimento)" to "7: Livro E (Demais atos relativos ao registro civil)". The codes 8 (emancipação) and 9 (interdição) of the Provimento CNJ nº 3/2009, art. 7º, revoked by the Provimento CNJ nº 63/2017, are not in it but are still read, as `"emancipation"` and `"interdiction"`, since the certidões issued under it from 2010 on carry them and remain valid documents. The `CertidaoInfo` result carries: @@ -2454,7 +2534,7 @@ The `CertidaoInfo` result carries: | `acervo` | Acervo the book belongs to: `"01"` the serventia's own, `"02"` and up one per acervo it absorbed. Art. 473, §§ 3º to 5º splits the absorbed ones by the date the origin serventia was extinguished or deactivated. Up to 31/12/2009: the CNS of the incorporating unit and an acervo code from `"02"` up, one per incorporation. From 01/01/2010 on: the CNS of the incorporated unit itself and the code `"01"`, counted as that unit's own acervo. An acervo split between two or more successor serventias gets each successor's own CNS with the code `"02"`. | | `service` | Service rendered by the serventia, always `"55"`, the registro civil das pessoas naturais. | | `year` | Four digit year the act was recorded. | -| `type` | The book the act belongs to: `"birth"`, `"marriage"`, `"religious-marriage"`, `"death"`, `"stillbirth"`, `"banns"`, `"other"`, `"emancipation"` or `"interdiction"`. | +| `type` | The book the act belongs to: `"birth"`, `"marriage"`, `"religious-marriage"`, `"death"`, `"stillbirth"`, `"banns"`, `"other"`, or, for the codes 8 and 9 of the Provimento CNJ nº 3/2009, `"emancipation"` and `"interdiction"`. | | `typeCode` | Raw book code, 1 to 9, as printed in the fifteenth position of the matrícula. | | `book` | The 5 digit book (livro) number, zero padded. | | `page` | The 3 digit page (folha) number, zero padded. | @@ -2481,7 +2561,7 @@ getCertidaoInfo('104539 01 55 2013 1 00012 021 0000123 21'); getCertidaoInfo('invalid'); // null ``` -Source: [art. 473 of the Código Nacional de Normas da Corregedoria Nacional de Justiça](https://atos.cnj.jus.br/atos/detalhar/5243); book codes 8 and 9 per [ghiorzi.org](http://ghiorzi.org/DVnew.htm) and [validation-br](https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts). +Source: [art. 473 of the Código Nacional de Normas da Corregedoria Nacional de Justiça](https://atos.cnj.jus.br/atos/detalhar/5243); book codes 8 and 9 per the revoked [Provimento CNJ nº 3/2009, art. 7º](https://atos.cnj.jus.br/atos/detalhar/1310), still listed by [ghiorzi.org](http://ghiorzi.org/DVnew.htm) and [validation-br](https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts). ## CEI, CNO and CAEPF @@ -2490,6 +2570,7 @@ Source: [art. 473 of the Código Nacional de Normas da Corregedoria Nacional de Check if a CEI (Cadastro Específico do INSS) number is valid. The CEI identifies an employer with no CNPJ, such as a construction work or a rural producer. - Layout: 12 digits printed as `00.000.00000/00`, 11 base digits and one check digit. +- Only the 12 digits are official: no norm, layout or manual of the Receita Federal publishes the check digit, which follows the community references below and agrees with the CNO open dataset and with SERPRO's example `000000336854`. ```javascript import { isValidCei } from '@brazilian-utils/brazilian-utils'; @@ -2497,11 +2578,12 @@ import { isValidCei } from '@brazilian-utils/brazilian-utils'; isValidCei('11.583.00249/85'); // true isValidCei('277297118187'); // true isValidCei(249859674386); // true +isValidCei(-249859674386); // false (not a non-negative safe integer) isValidCei('24.985.96743/68'); // false (invalid check digit) isValidCei('000000000000'); // false (repeated digits) ``` -Source: [yii2-br-validator](https://github.com/yiibr/yii2-br-validator/blob/master/src/CeiValidator.php), [Bigai.Documentos.Brasil](https://github.com/marcos-cruz/Documento/blob/master/src/Bigai.Documentos.Brasil/Cei/Cei.cs) and the [CNO open dataset](https://dados.gov.br/dados/conjuntos-dados/cadastro-nacional-de-obras-cno). +Source: [SERPRO, CNO cadastro](https://bcadastros.serpro.gov.br/documentacao/cadastro_cno/) (12 positions), [eSocial MOS S-1.3, item 9.1](https://www.gov.br/esocial/pt-br/documentacao-tecnica/manuais/mos-s-1-3-consolidada-ate-a-no-s-1-3-07-2026.pdf) (the CNO keeps the CEI number); check digit per [yii2-br-validator](https://github.com/yiibr/yii2-br-validator/blob/master/src/CeiValidator.php), [Bigai.Documentos.Brasil](https://github.com/marcos-cruz/Documento/blob/master/src/Bigai.Documentos.Brasil/Cei/Cei.cs) and the [CNO open dataset](https://dados.gov.br/dados/conjuntos-dados/cadastro-nacional-de-obras-cno). ### formatCei @@ -2539,11 +2621,12 @@ import { isValidCno } from '@brazilian-utils/brazilian-utils'; isValidCno('11.084.01680/62'); // true isValidCno('111130137368'); // true isValidCno(401800097960); // true +isValidCno(-401800097960); // false (not a non-negative safe integer) isValidCno('110840168063'); // false (invalid check digit) isValidCno('000000000000'); // false (repeated digits) ``` -Source: [CNO page of the Receita Federal](https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/cno) and the [CNO open dataset](https://dados.gov.br/dados/conjuntos-dados/cadastro-nacional-de-obras-cno). +Source: [CNO page of the Receita Federal](https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/cno), [SERPRO, CNO cadastro](https://bcadastros.serpro.gov.br/documentacao/cadastro_cno/), [eSocial MOS S-1.3, item 9.1](https://www.gov.br/esocial/pt-br/documentacao-tecnica/manuais/mos-s-1-3-consolidada-ate-a-no-s-1-3-07-2026.pdf) and the [CNO open dataset](https://dados.gov.br/dados/conjuntos-dados/cadastro-nacional-de-obras-cno). ### formatCno @@ -2575,6 +2658,7 @@ Check if a CAEPF (Cadastro de Atividade Econômica da Pessoa Física) number is - Layout: 14 digits printed as `000.000.000/000-00`: the 9-digit CPF base of the holder, a 3-digit sequence and 2 check digits. - Both check digits follow the CNPJ's modulus 11; the pair is then shifted by 12, wrapping around 100. +- Only the 14 positions and the CPF base are official (SERPRO: "9 primeiros números do CPF + número de inscrição resumido" of 5 positions). The split of those 5 into a sequence and 2 check digits, the check digit rule and the shift of 12 come from the community references below; they agree with SERPRO's example `00000002500171`. ```javascript import { isValidCaepf } from '@brazilian-utils/brazilian-utils'; @@ -2582,12 +2666,13 @@ import { isValidCaepf } from '@brazilian-utils/brazilian-utils'; isValidCaepf('293.118.610/001-84'); // true isValidCaepf('41142260000101'); // true isValidCaepf(29311861000184); // true +isValidCaepf(-29311861000184); // false (not a non-negative safe integer) isValidCaepf('29311861000185'); // false (invalid check digits) isValidCaepf('00000000000000'); // false (repeated base digits) isValidCaepf('00000000000012'); // false (repeated base digits) ``` -Source: [ghiorzi.org](http://ghiorzi.org/DVnew.htm) and [brazilian-values](https://github.com/VitorLuizC/brazilian-values/blob/master/src/validators/isCAEPF.ts). +Source: [SERPRO, CAEPF cadastro](https://bcadastros.serpro.gov.br/documentacao/cadastro_caepf/) (14 positions); check digits per [ghiorzi.org](http://ghiorzi.org/DVnew.htm) and [brazilian-values](https://github.com/VitorLuizC/brazilian-values/blob/master/src/validators/isCAEPF.ts). ### formatCaepf @@ -2693,7 +2778,7 @@ Source: [CNAE-Subclasses 2.3 at CONCLA/IBGE](https://concla.ibge.gov.br/busca-on Format a CNAE (Classificação Nacional de Atividades Econômicas) subclass code. Only the structure changes; use `isValidCnae` to check a code against the table. - **Options** (`FormatCnaeOptions`): `pad` (default `false`) first left pads the value with zeros to the 7 digits of a complete code. Without it the mask is applied as far as the value goes. -- Characters outside the mask are dropped, and a number is read as the string of its digits. Returns `''` when there is no digit at all. +- Characters outside the mask are dropped, and a number is read as the string of its digits only when it is a non-negative safe integer: a negative, fractional or unsafe number returns `''`, since its sign and decimal point are not mask characters. Returns `''` when there is no digit at all. ```javascript import { formatCnae } from '@brazilian-utils/brazilian-utils'; @@ -2704,7 +2789,7 @@ formatCnae('62015'); // 6201-5 formatCnae('62', { pad: true }); // 0000-0/62 (padded to 7 digits first) formatCnae(111301, { pad: true }); // 0111-3/01 formatCnae('abc6201501'); // 6201-5/01 (only the digits are read) -formatCnae(-6201501); // 6201-5/01 +formatCnae(-6201501); // '' (not a non-negative safe integer) ``` ### parseCnae @@ -2758,7 +2843,7 @@ isValidNcm('abc01012100'); // false (not a documented form) isValidNcm(-84713012); // false (not a non-negative safe integer) ``` -Source: [NCM nomenclature published by the Portal Único Siscomex](https://portalunico.siscomex.gov.br/classif/api/publico/nomenclatura/download/json). +Source: [NCM nomenclature published by the Portal Único Siscomex](https://portalunico.siscomex.gov.br/classif/api/publico/nomenclatura/download/json); the bundled 10,515 codes are those of the file "Vigente em 26/09/2026" (Resolução Gecex nº 926/2026). ### formatNcm @@ -2775,7 +2860,7 @@ formatNcm('8471'); // 8471 (masked as far as it goes) formatNcm('847130'); // 8471.30 formatNcm('8471', { pad: true }); // 0000.84.71 (padded to 8 digits first) formatNcm('abc8471'); // 8471 (only the digits are read) -formatNcm(-84713012); // 8471.30.12 +formatNcm(-84713012); // '' (not a non-negative safe integer) ``` ### parseNcm @@ -2884,7 +2969,7 @@ getServiceItem('40.01'); // { code: '40.01', description: 'Obras de arte sob enc getServiceItem('3.01'); // null (vetoed) ``` -Source: [Lei Complementar 116/2003](https://www.planalto.gov.br/ccivil_03/leis/lcp/lcp116.htm) and the sheet `LISTA.SERV.NAC.` of the [ANEXO B of the Sistema Nacional NFS-e](https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual), the list in force in machine readable form. +Source: [Lei Complementar 116/2003](https://www.planalto.gov.br/ccivil_03/leis/lcp/lcp116.htm), whose list was last amended by Lei Complementar 183/2021, and the sheet `LISTA.SERV.NAC.` of the [ANEXO B of the Sistema Nacional NFS-e](https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual), the list in force in machine readable form. ### isValidCfop @@ -3159,6 +3244,7 @@ Check if a GTIN (Global Trade Item Number, the number under an EAN/UPC barcode) - The value must be a string of 8, 12, 13 or 14 digits, surrounding whitespace aside, whose last digit is the GS1 modulo 10 check digit: weights 3 and 1 alternating from the right, the sum subtracted from the nearest equal or higher multiple of ten. That is what rules I03-10 and I12-10 of SEFAZ Nota Técnica 2021.003 check (rejections 611 and 612). - Leading zeros count, so a number is never accepted, and a masked value (`'7 890000 000017'`) is rejected instead of having its digits picked out. - The `'SEM GTIN'` literal the NF-e uses for a product without a GTIN is not a GTIN, so it is not valid here: test for it before calling. +- A value of zeros only is rejected, although its check digit is valid. That is a rule of this library, not of the NF-e or GS1: rejection 611 is only the check digit, and the GS1 General Specifications (release 26.0, table 1-4) reserve the GS1 Prefix `0000000` for Restricted Circulation Numbers within a company rather than forbid it. Zeros are rejected as the usual placeholder for a missing GTIN. - The prefix does not change the verdict. Restricted Circulation Numbers (prefixes 02, 04 and 20 to 29, the codes a shop prints on its own scale labels) and the ISSN, ISBN and coupon ranges share the structure and the check digit, and the "Tabela Prefixo GS1" SEFAZ validates `cEAN` against lists them as valid; use `getGtinInfo` to tell them apart. - The prefix is not checked against the list of GS1 Member Organisations either: GS1 keeps assigning ranges, so a copy of that list would turn down valid numbers as it ages. Whether the number is registered (the Cadastro Centralizado de GTIN lookup SEFAZ runs for the 789 and 790 prefixes) cannot be checked offline. @@ -3174,7 +3260,7 @@ isValidGtin('7890000000018'); // false (wrong check digit) isValidGtin('17890000000014', { lengths: [8, 12, 13] }); // false (GTIN-14 not accepted) isValidGtin('7 890000 000017'); // false (digits only) isValidGtin('SEM GTIN'); // false -isValidGtin('0000000000000'); // false (zeros only, never allocated by GS1) +isValidGtin('0000000000000'); // false (zeros only, a rule of this library) ``` ### getGtinInfo @@ -3182,13 +3268,13 @@ isValidGtin('0000000000000'); // false (zeros only, never allocated by GS1) Parse a GTIN into its fields, as a `GtinInfo`. - Returns `null` when the value is not a valid GTIN, under the same rules as `isValidGtin`. -- The prefix is read the way the "Tabela Prefixo GS1" of the Portal da NF-e tells: the value is left padded with zeros to 14 digits, and the prefix is positions 7 to 9 when positions 2 to 6 are zeros (a GTIN-8, or a GTIN-14 that packs one) and positions 2 to 4 otherwise. The first digit, the padding zero or the indicator digit, is never part of the prefix, so a GTIN-12 has a prefix that starts with `0`, and a GTIN-14 has the prefix of the GTIN it packs. +- The prefix is read as the GS1 General Specifications (tables 1-4, 1-5 and 1-9) lay the numbers out: the value is left padded with zeros to 14 digits, and the prefix is positions 7 to 9 when positions 2 to 6 are zeros (a GTIN-8, or a GTIN-14 that packs one) and positions 2 to 4 otherwise. The first digit, the padding zero or the indicator digit, is never part of the prefix, so a GTIN-12 has a prefix that starts with `0`, and a GTIN-14 has the prefix of the GTIN it packs. | Field | Description | | --- | --- | | `type` | `'GTIN-8'`, `'GTIN-12'`, `'GTIN-13'` or `'GTIN-14'` (`GtinType`), from the length the value was written with | | `length` | `8`, `12`, `13` or `14` (`GtinLength`) | -| `prefix` | The three digit GS1 Prefix, or a GS1-8 Prefix when the first six digits of the 14 digit form are zeros, which covers every GTIN-8 and the GS1 Prefix `0000000`. It names the GS1 Member Organisation that licensed the number, not the country of origin | +| `prefix` | The three digit GS1 Prefix, or a GS1-8 Prefix when positions 2 to 6 of the 14 digit form are zeros, which covers every GTIN-8, a GTIN-14 that packs one and the GS1 Prefix `0000000`. It names the GS1 Member Organisation that licensed the number, not the country of origin | | `isBrazilian` | `true` when the prefix is one of GS1 Brasil, `789` or `790`, what NT 2021.003 calls "prefixo do Brasil" | | `isRestrictedCirculation` | `true` when the prefix is in a range GS1 sets aside for Restricted Circulation Numbers (GS1 Prefixes 02, 04 and 20 to 29; GS1-8 Prefixes 000 to 099 and 200 to 299, which is also where the GS1 Prefix `0000000` lands, since its 14 digit form starts with six zeros), so the number is only unique inside a company or region | | `checkDigit` | The modulo 10 check digit, the last digit | @@ -3222,7 +3308,7 @@ Check if a CID-10 code exists in the tables DATASUS publishes, the Brazilian Por - Both levels of the classification are valid: the 3 character categories (`A00`) and the 4 character subcategories, written with the dot (`A00.0`) or without it (`A000`). - Letter case and surrounding whitespace are ignored. Anything else (another separator, a fifth character, a dagger or asterisk suffix, a value that is not a string) is rejected. -- The V2008 tables are the only source: a code that is not in them, such as `U07.1` (COVID-19), is not found. +- The tables are the DATASUS V2008 ones, plus the `U07` category of the CID-10 table DATASUS keeps for the SIM (`U07`, `U07.0`, `U07.1` COVID-19 virus identified and `U07.2` virus not identified), which the V2008 files predate. A code in neither is not found, such as `U09.9` (post COVID-19 condition) and `U10.9` (multisystem inflammatory syndrome associated with COVID-19). Up to 2.4.0 the `U07` codes were not found either. - Only a table of codes is read (about 27 KB minified), not the descriptions `getCid10` carries. ```javascript @@ -3272,7 +3358,8 @@ parseCid10('A00'); // 'A00' Look a CID-10 code up and get its official Brazilian Portuguese description. The result is a `Cid10` record: `{ code, description }`. - Same input rules as `isValidCid10`. `code` is upper case and has no dot. Returns `null` when the code is unknown or the value is not in a documented form. -- This is the heaviest util of the package: it embeds the 2045 categories and 12188 subcategories with their descriptions, about 1 MB minified (147 KB gzipped). Load it lazily through its subpath, as shown in [Bundle size](getting-started.md#bundle-size), and use `isValidCid10` when the description is not needed. +- Same table as `isValidCid10`: the DATASUS V2008 one plus the `U07` codes of the SIM table (`getCid10('U07.1')` is `{ code: 'U071', description: 'Infecção pelo novo Coronavírus (COVID-19)' }`); `U09.9` and `U10.9` are not found. +- This is the heaviest util of the package: it embeds the 2046 categories and 12191 subcategories with their descriptions, about 1 MB minified (147 KB gzipped). Load it lazily through its subpath, as shown in [Bundle size](getting-started.md#bundle-size), and use `isValidCid10` when the description is not needed. ```javascript import { getCid10 } from '@brazilian-utils/brazilian-utils'; @@ -3284,7 +3371,7 @@ getCid10('A00.5'); // null getCid10('A00-0'); // null (not a documented form) ``` -Source: [CID-10 V2008 tables DATASUS publishes as CSV](http://www2.datasus.gov.br/cid10/V2008/descrcsv.htm). +Source: [CID-10 V2008 tables DATASUS publishes as CSV](http://www2.datasus.gov.br/cid10/V2008/descrcsv.htm) and the [CID-10 table of the SIM](ftp://ftp.datasus.gov.br/dissemin/publicos/SIM/CID10/TABELAS/CID10.DBF) for the `U07` codes. ## Text @@ -3295,7 +3382,7 @@ Capitalize the first letter of each word, the way a Brazilian name, company name - **Options** (`CapitalizeOptions`): `lowerCaseWords`, words kept in lower case between two words, by default prepositions and articles such as `de`, `da`, `do`, `e`; `upperCaseWords`, words always in upper case, by default company designations and abbreviations such as `LTDA`, `S.A.`, `ME`, `CNPJ` and roman numerals. A list replaces its default. - Words split at whitespace, `-`, `/`, apostrophes and adjoining punctuation; whitespace runs collapse into one space. - A lower-case word that is first, last or followed by punctuation is a designator and keeps its capital. -- `ME` is upper-cased only as a designation (last word, or before another designation); `SA` without dots is left alone (the surname Sá). A state code after a `/` is upper-cased even with `upperCaseWords` given. +- `ME` is upper-cased only as a designation (last word, or before another designation); `SA` without dots is left alone (the surname Sá). A state code after a `/` is upper-cased even with `upperCaseWords` given, and so is one that ends the value after a spaced `-`, a spaced `–` or a `, `, the Correios' "Cidade – UF". ```javascript import { capitalize } from '@brazilian-utils/brazilian-utils'; @@ -3315,9 +3402,11 @@ capitalize('(empresa) ltda'); // (Empresa) LTDA capitalize('luiz von schmidt'); // Luiz von Schmidt capitalize('santana/rs'); // Santana/RS ("RS" is a state code right after a "/") capitalize('porto alegre/rs'); // Porto Alegre/RS +capitalize('brasília - df'); // Brasília - DF (a state code as the last word after " - ", " – " or ", ") capitalize('santana rs'); // Santana Rs (no "/", so "rs" is just a word) capitalize('rua xv de novembro'); // Rua XV de Novembro (roman numeral, "de" stays lower case) capitalize('joão paulo ii'); // João Paulo II +capitalize('rua xxiv de maio'); // Rua XXIV de Maio (roman numerals from II to XXXIX, except VI, the verb "vi") capitalize('de'); // De (a preposition keeps its capital when it is the first word) capitalize('empresa ltda', { upperCaseWords: [] }); // Empresa Ltda (the list given replaces the default one) capitalize('josé Ama MARIA', { lowerCaseWords: ['ama'] }); // José ama Maria @@ -3350,8 +3439,19 @@ removeAccents(''); // '' Check if an inscrição estadual (state registration) is valid for a state. **Deprecated:** the positional form `isValidIe(stateCode, ie)` still works but is deprecated; use the object form `isValidIe({ value, stateCode })`. - Takes a single object (`IsValidIeParams`): `value` is the registration and `stateCode` the state it belongs to (a `StateCode`, case-insensitive). -- GO, PA, MS, SP, TO, DF, PE, AL and RJ have special cases (extra prefixes or formats, or a deviation from the SINTEGRA page); see the JSDoc in `src/is-valid-ie` for the details. -- An all-zero registration is accepted wherever the published formula yields a check digit of 0 for it: AM, CE, ES, MG, MT, PB, PE, PI, PR, RJ, RS, SC, SE and SP, plus BA with 8 or 9 digits and TO with 9 digits. +- Some states have special cases, a prefix or format the SINTEGRA page does not print or a deliberate deviation from it (details and sources in the JSDoc in `src/is-valid-ie`): + - GO: the prefixes 10, 11, 15 and 20 to 29, the union of sources that disagree: the norm (IN nº 946/09-GSF, art. 39, I, as worded by IN nº 1.535/22-GSE) gives 10, 20 and 11, the SINTEGRA page 10, 11 and 20 to 29, the 2012 roteiro de crítica 10, 11 and 15. The check digit follows the roteiro, as in 2.4.0: a remainder of 1 gives 1 in the range 10103105 to 10119997, and 11094402 takes either digit, special cases the SINTEGRA page (2022) does not have. + - MT: 11 digits, or the 9 digits Portaria SEFAZ-MT nº 59/2025 (art. 8º, § 1º) prescribes, read as the 11 digit form padded with two zeros (no official text gives the check digit rule of the 9 digit form). + - PA: the prefixes 15 and 75 to 79 the SINTEGRA page lists (SEFA-PA has issued 75 since 07/10/2024). MS: the prefixes 28 and 50 the SINTEGRA page gives (50 is also in a SEFAZ-MS e-CCE communiqué; the only norm, Resolução/SEF nº 1.344/1999, gives 28 alone). + - DF: the 13 digit AC rule under the prefixes 07 and 08. The SINTEGRA page does not call 07 fixed; SEFAZ-DF's rule sheet calls it a "campo fixo". DF moved to 08 when the numbers starting with 07 ran out. + - RR: the prefix 24, which the SINTEGRA page does not write as a rule: it is read from the page's ten examples. + - SP: the produtor rural form `P0MMMSSSSD000`, the zero after the `P` included; the `P` may be written in lower case. + - TO: the 9 digits in force (Portaria SEFAZ-TO nº 676/2002, art. 3º; RICMS-TO, Decreto nº 2.912/2006, art. 90), or the old 11 digits the SINTEGRA page documents, with the tipo digits 01, 02, 03 or 99. + - AM: the page's check digit rule has two branches and leaves "Resto" undefined; the library reads it as the sum modulo 11 and gives 0 to a sum of 0 or 1, the shared modulus 11 rule. + - MG: a first check digit of 10, from a sum that is already a multiple of ten, is read as 0. + - PE: the 9 digit eFisco form and the old 14 digit CACEPE form, both on the SINTEGRA page (Portaria SF nº 087/2007 converted the old numbers but set no date after which they are void). + - AL: the third digit, the tipo de empresa, must be 0, 3, 5, 7 or 8, the values the SINTEGRA page lists. +- An all-zero registration is accepted wherever the published formula yields a check digit of 0 for it: AM, CE, ES, MG, PB, PE (9 digits), PI, PR, RJ, RS, SC, SE and SP, plus BA with 8 or 9 digits, MT with 9 or 11 digits and TO with 9 digits. ```javascript import { isValidIe } from '@brazilian-utils/brazilian-utils'; @@ -3360,9 +3460,11 @@ isValidIe({ value: '110042490114', stateCode: 'SP' }); // true isValidIe({ value: 'P011004243002', stateCode: 'SP' }); // true (produtor rural) isValidIe({ value: '0187634580933', stateCode: 'AC' }); // false isValidIe({ value: '109161793', stateCode: 'go' }); // true (case-insensitive) +isValidIe({ value: '200000004', stateCode: 'GO' }); // true (prefix 20) +isValidIe({ value: '130000019', stateCode: 'MT' }); // true (9 digits) ``` -Source: [SINTEGRA state pages](http://www.sintegra.gov.br/insc_est.html) and the [SEFAZ-GO roteiro de crítica](https://goias.gov.br/economia/roteiro-de-critica-da-inscricao-estadual-de-goias/). +Source: [SINTEGRA state pages](http://www.sintegra.gov.br/insc_est.html), the [Goiás roteiro de crítica](https://goias.gov.br/economia/roteiro-de-critica-da-inscricao-estadual-de-goias/) and [IN nº 946/09-GSF](https://appasp.economia.go.gov.br/Legislacao/arquivos/secretario/in/IN_0946_2009.htm). ## Email @@ -3414,10 +3516,10 @@ Source: [ISO/IEC 7812-1](https://www.iso.org/standard/70484.html). Check the structure of a professional council registration number (registro/inscrição profissional). Only the digit count and the UF are checked, never a check digit, even for CRC. - Takes an object (`IsValidRegistroProfissionalParams`): `value`, `council` (`"OAB"`, `"CRM"`, `"CRO"`, `"CRP"` or `"CRC"`, a `RegistroProfissionalCouncil`) and an optional `stateCode` (expected UF). -- `"OAB"` and `"CRM"`: 4 to 6 digits plus the UF (`123456/SP`, `123456-SP`); `"CRO"`: 3 to 6 digits (`12345/SP`). -- `"CRP"`: a 2-digit regional code (`01` to `24`) plus 4 to 6 digits (`06/12345`); `stateCode` is ignored. -- `"CRC"`: UF, 6 digits, tipo de registro (`O` or `P`) and check digit (`SP-123456/O-3`); a transfer appends `T` or `S` and the destination UF (`SP-123456/O-3 T-MG`). `stateCode` matches the originating UF. -- The OAB, CRM, CRO and CRP shapes are conventional (no published format). CREA is not covered. +- `"OAB"` and `"CRM"`: 4 to 6 digits plus the UF (`123456/SP`, `123456-SP`); `"CRO"`: 3 to 6 digits (`12345/SP`), or the form of the Consolidação das Normas do CFO (Resolução CFO-63/2005), art. 115, § 1º: the sigla of the Conselho Regional first, joined by a hyphen to the category (`TPD`, `TSB`, `ASB`, `APD`, `CLM`/`CLF`, `LPM`/`LPF`, `PV`, `T`) when there is one, then the number, followed by `-IS` for a secundária or `-R` for a remida (`CRO-SP 12345`, `CRO-SP-TPD 1234`, `CRO-SP 12345-IS`). Up to 2.4.0 this form was rejected. +- `"CRP"`: a 2-digit regional code (`01` to `24`) plus 4 to 6 digits (`06/12345`); `stateCode` is ignored. The CFP system has 24 regionals; the CRP-25 (Amapá) is only a proposal. +- `"CRC"`: UF, 6 digits, tipo de registro (`O` or `P`) and check digit (`SP-123456/O-3`); a transfer appends `T` or `S` and the destination UF (`SP-123456/O-3 T-MG`). `stateCode` matches the originating UF. This shape and the `P`/`S` registrations come from the Manual de Registro of 2009; Resolução CFC nº 1.707/2023, in force, only sets a numbering "única e sequencial em cada CRC" and the `T` of the transfer, and the check digit algorithm is not published. +- The digit counts of the OAB, CRM, CRO and CRP numbers are conventional: the OAB and the CFM publish no format, and neither the CFO's art. 115 nor the CFP fixes a digit count. CREA is not covered. ```javascript import { isValidRegistroProfissional } from '@brazilian-utils/brazilian-utils'; @@ -3425,30 +3527,35 @@ import { isValidRegistroProfissional } from '@brazilian-utils/brazilian-utils'; isValidRegistroProfissional({ value: '123456/SP', council: 'OAB' }); // true isValidRegistroProfissional({ value: '123456-RJ', council: 'OAB', stateCode: 'SP' }); // false (UF mismatch) isValidRegistroProfissional({ value: '123456', council: 'OAB' }); // false (no UF) +isValidRegistroProfissional({ value: 'CRO-SP-TPD 1234', council: 'CRO' }); // true (art. 115 of the CFO norms) isValidRegistroProfissional({ value: '06/12345', council: 'CRP' }); // true isValidRegistroProfissional({ value: 'SP-123456/O-3', council: 'CRC' }); // true isValidRegistroProfissional({ value: 'SP-123456/O-3 T-MG', council: 'CRC' }); // true (registro transferido) isValidRegistroProfissional({ value: 'SP-123456/T-3', council: 'CRC' }); // false ("T" is not a tipo de registro) ``` -Source: [Manual de Registro do Sistema CFC/CRCs](https://cfc.org.br/wp-content/uploads/2018/04/1_manual_registro.pdf), [Resolução CFC nº 1.707/2023](https://www1.cfc.org.br/sisweb/SRE/docs/Res_1707.pdf), [CFP regional councils](https://site.cfp.org.br/cfp/sistema-conselhos/conselhos-pelo-brasil/). +Source: [Consolidação das Normas do CFO, art. 115](https://transparencia.cfo.org.br/wp-content/uploads/2023/09/Consolida%C3%A7%C3%A3o-das-Normas-Atualizado-emsetembro-de-2023.pdf), [Manual de Registro do Sistema CFC/CRCs](https://cfc.org.br/wp-content/uploads/2018/04/1_manual_registro.pdf), [Resolução CFC nº 1.707/2023](https://www1.cfc.org.br/sisweb/SRE/docs/Res_1707.pdf), [CFP regional councils](https://site.cfp.org.br/cfp/sistema-conselhos/conselhos-pelo-brasil/). ## VIN ### isValidVin -Check if a VIN (Vehicle Identification Number / chassi) is valid. This is a North-American-style structural check, not a universal validator of Brazilian VINs. +Check if a VIN (Vehicle Identification Number / chassi) is valid. By default it checks 17 characters in the three sections of Resolução CONTRAN nº 968/2022, art. 3º (the WMI, the VDS and the VIS), each a digit or a capital letter other than `I`, `O` and `Q`. -- Checks the 17-character length, the excluded letters `I`, `O` and `Q`, and the check digit at position 9. -- Brazilian rules do not mandate the check digit, so many Brazilian-built VINs fail it. +- The `I`, `O` and `Q` exclusion comes from ISO 3779, not from the resolution: it lists no forbidden character and refers the engraving to ABNT NBR 6066:2022 (art. 5º), a paid standard with no official free copy. The regularization VINs of its Anexo II (WMI `XXX`) are written without those letters, so they pass. +- **Options** (`IsValidVinOptions`): `checkDigit: true` also enforces the North-American rules of 49 CFR 565.15, the check digit at position 9 and a model year code other than `U`, `Z` or `0` at position 10. Use it for a VIN of a vehicle built for the United States or Canada. +- Brazilian rules do not mandate the check digit, and many Brazilian-built VINs do not carry one. Up to 2.4.0 it was always enforced; pass `{ checkDigit: true }` to keep that behaviour. +- Case-insensitive and trims surrounding whitespace; a value of one repeated character is rejected. ```javascript import { isValidVin } from '@brazilian-utils/brazilian-utils'; -isValidVin('1HGCM82633A004352'); // true -isValidVin('1m8gdm9axkp042788'); // true (check digit X, lowercase) -isValidVin('1HGCM82633A004353'); // false (bad check digit) -isValidVin('00000000000000000'); // false (every character the same, though the check digit matches) +isValidVin('9BWZZZ377VT004251'); // true (Brazilian VIN, no check digit) +isValidVin('9BWZZZ377VT004251', { checkDigit: true }); // false (its 9th character is not the check digit) +isValidVin('1HGCM82633A004352', { checkDigit: true }); // true +isValidVin('1m8gdm9axkp042788', { checkDigit: true }); // true (check digit X, lowercase) +isValidVin('1HGCM82633A004353', { checkDigit: true }); // false (bad check digit) +isValidVin('00000000000000000'); // false (every character the same) isValidVin('1HGCM8263IA004352'); // false (contains the excluded letter I) isValidVin('1HGCM82633A00435'); // false (16 characters) ``` diff --git a/scripts/banks.ts b/scripts/banks.ts index 8de6a6ce2..29699a60d 100644 --- a/scripts/banks.ts +++ b/scripts/banks.ts @@ -5,7 +5,7 @@ import { resolve } from "node:path"; import { fetchWithRetry } from "../src/_internals/fetch-with-retry/fetch-with-retry.ts"; -const scriptsDir = import.meta.dirname; +const scriptsDirectory = import.meta.dirname; const BACEN_CSV_URL = "https://www.bcb.gov.br/content/estabilidadefinanceira/str1/ParticipantesSTR.csv"; @@ -39,24 +39,24 @@ const parseCsvLine = (line: string): string[] => { let inQuotes = false; for (let i = 0; i < line.length; i++) { - const char = line[i]; + const character = line[i]; if (inQuotes) { - if (char === '"' && line[i + 1] === '"') { + if (character === '"' && line[i + 1] === '"') { current += '"'; i++; - } else if (char === '"') { + } else if (character === '"') { inQuotes = false; } else { - current += char; + current += character; } - } else if (char === '"') { + } else if (character === '"') { inQuotes = true; - } else if (char === ",") { + } else if (character === ",") { fields.push(current); current = ""; } else { - current += char; + current += character; } } @@ -165,7 +165,7 @@ const main = async (): Promise => { throw new Error("Refusing to write an empty bank dataset"); } - const banksPath = resolve(scriptsDir, "..", "./src/_internals/constants/banks.ts"); + const banksPath = resolve(scriptsDirectory, "..", "./src/_internals/constants/banks.ts"); const banksFile = `/** * Brazilian STR (Sistema de Transferência de Reservas) participants that have a compensation * code (commonly known as COMPE), published by Banco Central do Brasil. Generated by @@ -184,7 +184,7 @@ export type Bank = { export const BANKS: Bank[] = ${JSON.stringify(sorted)};`; const compeCodes = sorted.map((bank) => bank.code).join(""); - const constantsPath = resolve(scriptsDir, "..", "./src/is-valid-bank-account/constants.ts"); + const constantsPath = resolve(scriptsDirectory, "..", "./src/is-valid-bank-account/constants.ts"); const constants = await readFile(constantsPath, "utf8"); const literal = (compeCodes.match(/.{1,90}/g) ?? []).map((chunk) => `\t"${chunk}"`).join(" +\n"); const compeCodesPattern = /export const COMPE_CODES =\n(?:\t"\d*" \+\n)*\t"\d*";/; diff --git a/scripts/cbo.ts b/scripts/cbo.ts index 64414d484..b90a7c86f 100644 --- a/scripts/cbo.ts +++ b/scripts/cbo.ts @@ -5,7 +5,7 @@ import { resolve } from "node:path"; import { fetchSortedRecord } from "./fetch-sorted-record.ts"; -const scriptsDir = import.meta.dirname; +const scriptsDirectory = import.meta.dirname; /** The official CSV is published in ISO-8859-1, not UTF-8. */ const CBO_CSV_DECODER = new TextDecoder("iso-8859-1"); @@ -60,7 +60,7 @@ const main = async (): Promise => { ); await writeFile( - resolve(scriptsDir, "..", "./src/_internals/constants/cbo.ts"), + resolve(scriptsDirectory, "..", "./src/_internals/constants/cbo.ts"), `/** * CBO 2002 (Classificação Brasileira de Ocupações) titles, indexed by the raw 6 digit code. * diff --git a/scripts/cest.ts b/scripts/cest.ts index 037381d88..fd3cb0e20 100644 --- a/scripts/cest.ts +++ b/scripts/cest.ts @@ -7,7 +7,7 @@ import { decodeEntities } from "./decode-entities.ts"; import { fetchSortedRecord } from "./fetch-sorted-record.ts"; import { removeUntilStable } from "./remove-until-stable.ts"; -const scriptsDir = import.meta.dirname; +const scriptsDirectory = import.meta.dirname; const SOURCE_URL = "https://www.confaz.fazenda.gov.br/legislacao/convenios/2018/CV142_18"; @@ -296,7 +296,7 @@ const main = async (): Promise => { } await writeFile( - resolve(scriptsDir, "..", "./src/_internals/constants/cest.ts"), + resolve(scriptsDirectory, "..", "./src/_internals/constants/cest.ts"), `/** * CEST (Código Especificador da Substituição Tributária) table, indexed by the 7 digit code. * diff --git a/scripts/cfop.ts b/scripts/cfop.ts index 55ff4b64d..3e01a250e 100644 --- a/scripts/cfop.ts +++ b/scripts/cfop.ts @@ -6,7 +6,7 @@ import { resolve } from "node:path"; import { decodeEntities } from "./decode-entities.ts"; import { fetchSortedRecord } from "./fetch-sorted-record.ts"; -const scriptsDir = import.meta.dirname; +const scriptsDirectory = import.meta.dirname; /** * CONFAZ prints the text in force with the `A5-1TextoAcordo` class and keeps every superseded @@ -98,7 +98,7 @@ const main = async (): Promise => { ); await writeFile( - resolve(scriptsDir, "..", "./src/_internals/constants/cfop.ts"), + resolve(scriptsDirectory, "..", "./src/_internals/constants/cfop.ts"), `/** * CFOP (Código Fiscal de Operações e Prestações) table, indexed by the 4 digit code. * diff --git a/scripts/cid10.ts b/scripts/cid10.ts index 49b5b10e2..baf5529fa 100644 --- a/scripts/cid10.ts +++ b/scripts/cid10.ts @@ -8,7 +8,7 @@ import { inflateRawSync } from "node:zlib"; import { sortRecord } from "./sort-record.ts"; -const scriptsDir = import.meta.dirname; +const scriptsDirectory = import.meta.dirname; /** The official CSVs are published in ISO-8859-1, not UTF-8. */ const CID10_CSV_DECODER = new TextDecoder("iso-8859-1"); @@ -67,6 +67,24 @@ const CID10_TABLES: Cid10Table[] = [ const CID10_DESCRIPTION_COLUMN = "DESCRICAO"; +/** + * The CID-10 codes of the mortality table DATASUS keeps for the SIM (Sistema de Informações sobre + * Mortalidade) that the V2008 CSVs lack: the `U07` category the WHO opened for emergency use and + * its subcategories, among them the COVID-19 codes. They are copied from `CID10.DBF` (FTP + * DATASUS, `dissemin/publicos/SIM/CID10/TABELAS`, listed on 11/05/2020), whose `DESCR` column + * prefixes each description with its printed code, dropped here. That table has no `U08` to `U12`, + * so `U09.9` and `U10.9` stay out. + */ +const SIM_CID10_ADDITIONS: Record = { + U07: "Uso emergencial do U07", + U070: "Doença por cigarro eletrônico", + U071: "Infecção pelo novo Coronavírus (COVID-19)", + U072: "COVID-19, vírus não identificado", +}; + +// oxlint-disable-next-line sonarjs/no-clear-text-protocols -- the FTP address of the SIM table, cited only +const SIM_CID10_URL = "ftp://ftp.datasus.gov.br/dissemin/publicos/SIM/CID10/TABELAS/CID10.DBF"; + /** * Requests the archive, retrying the DATASUS host, which drops connections and answers 503 * often enough that a single attempt fails the weekly run. Written here rather than with @@ -240,11 +258,17 @@ const main = async (): Promise => { } } + for (const [code, description] of Object.entries(SIM_CID10_ADDITIONS)) { + if (Object.hasOwn(data, code)) throw new Error(`CID-10 V2008 tables already have ${code}`); + + data[code] = description; + } + const sorted = sortRecord(data); const subcategories = groupSubcategories(Object.keys(sorted)); await writeFile( - resolve(scriptsDir, "..", "./src/_internals/constants/cid10-descriptions.ts"), + resolve(scriptsDirectory, "..", "./src/_internals/constants/cid10-descriptions.ts"), `/** * CID-10 (Classificação Estatística Internacional de Doenças e Problemas Relacionados à Saúde, * 10th revision) descriptions in Brazilian Portuguese, indexed by the code without the dot: the @@ -255,6 +279,10 @@ const main = async (): Promise => { * the \`DESCRICAO\` column, as published. A category with no subcategory is listed by both files * with the same description and shows up once here. * + * The V2008 files predate the \`U07\` category the WHO opened for emergency use. Its codes, \`U07\`, + * \`U07.0\`, \`U07.1\` and \`U07.2\` (the COVID-19 ones), are added from the CID-10 table of the + * SIM (Sistema de Informações sobre Mortalidade), \`CID10.DBF\`, which DATASUS keeps on its FTP. + * * This table is about 1 MB, so it has a module of its own: \`CID10_SUBCATEGORIES\`, which holds * the same codes without the descriptions, lives in \`cid10.ts\` and never loads this one. * @@ -264,18 +292,21 @@ const main = async (): Promise => { * The CID-10 tables in CSV, as published by DATASUS (Ministério da Saúde). * @see Official: http://www2.datasus.gov.br/cid10/V2008/descrcsv.htm * The DATASUS page that links the archive and documents its files, columns and encoding. + * @see Official: ${SIM_CID10_URL} + * The CID-10 table of the SIM, source of the \`U07\` codes. */ export const CID10_DESCRIPTIONS: Record = ${JSON.stringify(sorted)}; `, ); await writeFile( - resolve(scriptsDir, "..", "./src/_internals/constants/cid10.ts"), + resolve(scriptsDirectory, "..", "./src/_internals/constants/cid10.ts"), `/** * Every CID-10 category, with the fourth characters of its subcategories: \`A00: "019"\` stands * for \`A00.0\`, \`A00.1\` and \`A00.9\`, and an empty string for a category that is not * subdivided. It holds the same codes as \`CID10_DESCRIPTIONS\` without the descriptions, so that - * checking a code does not cost the whole table. + * checking a code does not cost the whole table. The \`U07\` codes come from the SIM table, as in + * \`cid10-descriptions.ts\`. * * Generated by \`node ./scripts/cid10.ts\`. Do not edit by hand. * @@ -283,6 +314,8 @@ export const CID10_DESCRIPTIONS: Record = ${JSON.stringify(sorte * The CID-10 tables in CSV, as published by DATASUS (Ministério da Saúde). * @see Official: http://www2.datasus.gov.br/cid10/V2008/descrcsv.htm * The DATASUS page that links the archive and documents its files, columns and encoding. + * @see Official: ${SIM_CID10_URL} + * The CID-10 table of the SIM, source of the \`U07\` codes. */ export const CID10_SUBCATEGORIES: Record = ${JSON.stringify(subcategories)}; `, diff --git a/scripts/cities.ts b/scripts/cities.ts index 2c09fbd57..6029ba65f 100644 --- a/scripts/cities.ts +++ b/scripts/cities.ts @@ -6,7 +6,7 @@ import { resolve } from "node:path"; import { DATA as STATES } from "../src/_internals/constants/states.ts"; import { fetchWithRetry } from "../src/_internals/fetch-with-retry/fetch-with-retry.ts"; -const scriptsDir = import.meta.dirname; +const scriptsDirectory = import.meta.dirname; const STATE_CODES = STATES.map((state) => state.code); @@ -105,7 +105,7 @@ const main = async (): Promise => { } await writeFile( - resolve(scriptsDir, "..", "./src/_internals/constants/municipalities.ts"), + resolve(scriptsDirectory, "..", "./src/_internals/constants/municipalities.ts"), `import { type StateCode } from "./states"; /** diff --git a/scripts/cnae.ts b/scripts/cnae.ts index ead9e7bc7..f16c29b1f 100644 --- a/scripts/cnae.ts +++ b/scripts/cnae.ts @@ -5,7 +5,7 @@ import { resolve } from "node:path"; import { fetchSortedRecord } from "./fetch-sorted-record.ts"; -const scriptsDir = import.meta.dirname; +const scriptsDirectory = import.meta.dirname; type CnaeSubclass = { id: string; @@ -40,7 +40,7 @@ const main = async (): Promise => { ); await writeFile( - resolve(scriptsDir, "..", "./src/_internals/constants/cnae.ts"), + resolve(scriptsDirectory, "..", "./src/_internals/constants/cnae.ts"), `/** * CNAE-Subclasses 2.3 (Classificação Nacional de Atividades Econômicas) subclasses, indexed by * the raw 7 digit code, mapping to the official subclass description. diff --git a/scripts/data.ts b/scripts/data.ts index 90600ae3b..a37f26844 100644 --- a/scripts/data.ts +++ b/scripts/data.ts @@ -3,7 +3,7 @@ import { spawn } from "node:child_process"; import { resolve } from "node:path"; -const scriptsDir = import.meta.dirname; +const scriptsDirectory = import.meta.dirname; const run = (command: string, args: string[]): Promise => new Promise((_resolve) => { @@ -53,7 +53,7 @@ const generatedFiles = [ ]; const results = await Promise.all( - generators.map((generator) => run("node", [resolve(scriptsDir, generator)])), + generators.map((generator) => run("node", [resolve(scriptsDirectory, generator)])), ); // Lint and format before checking the generators, so a failing generator never leaves diff --git a/scripts/examples.ts b/scripts/examples.ts index 2cdbb56b9..f443dffb2 100644 --- a/scripts/examples.ts +++ b/scripts/examples.ts @@ -16,8 +16,8 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node import { join } from "node:path"; const ROOT = join(import.meta.dirname, ".."); -const EXAMPLE_DIR = join(ROOT, "docs", "snippets", "document-field"); -const TEMPLATE_DIR = join(EXAMPLE_DIR, "templates"); +const EXAMPLE_DIRECTORY = join(ROOT, "docs", "snippets", "document-field"); +const TEMPLATE_DIRECTORY = join(EXAMPLE_DIRECTORY, "templates"); type Document = { /** The kebab-case name of the document, which names its folder and its files. */ @@ -105,21 +105,24 @@ const MASK_BODY_PATTERN = /^([ \t]*)@@maskBody@@$/m; /** * @param {string} source - A function and the arguments after the value, `formatCnpj, { version: 2 }`. - * @returns {{ fn: string; rest: string }} The function on its own, and the remaining arguments. + * @returns {{ functionName: string; rest: string }} The function on its own, and the remaining arguments. */ -function split(source: string): { fn: string; rest: string } { - const [fn = "", ...args] = source.split(", "); +function split(source: string): { functionName: string; rest: string } { + const [functionName = "", ...args] = source.split(", "); - return { fn, rest: args.length === 0 ? "" : `, ${args.join(", ")}` }; + return { functionName, rest: args.length === 0 ? "" : `, ${args.join(", ")}` }; } /** - * @param {{ fn: string; rest: string }} target - A function and its arguments after the value. + * @param {{ functionName: string; rest: string }} target - A function and its arguments after the value. * @param {string} value - What to pass as the value. * @returns {string} The call, `formatCnpj(value, { version: 2 })`. */ -function call({ fn, rest }: { fn: string; rest: string }, value: string): string { - return `${fn}(${value}${rest})`; +function call( + { functionName, rest }: { functionName: string; rest: string }, + value: string, +): string { + return `${functionName}(${value}${rest})`; } /** @@ -132,8 +135,9 @@ function values(document: Document): Record { const parser = split(document.parser); // A function that takes options is wrapped, so that the mask can call it with a value alone. const parserExpression = - parser.rest === "" ? parser.fn : `(value: string) => ${call(parser, "value")}`; - const formatter = format.rest === "" ? format.fn : `(value: string) => ${call(format, "value")}`; + parser.rest === "" ? parser.functionName : `(value: string) => ${call(parser, "value")}`; + const formatter = + format.rest === "" ? format.functionName : `(value: string) => ${call(format, "value")}`; return { kind: document.kind, @@ -143,10 +147,10 @@ function values(document: Document): Record { length: String(document.placeholder.length), inputMode: document.inputMode, autocomplete: document.autocomplete, - names: [format.fn, validator.fn, parser.fn].join(", "), - fieldImports: `import { ${[format.fn, parser.fn].join(", ")} } from "@brazilian-utils/brazilian-utils";`, - imports: `import { ${[format.fn, validator.fn].join(", ")} } from "@brazilian-utils/brazilian-utils";`, - formImports: `import { ${validator.fn} } from "@brazilian-utils/brazilian-utils";`, + names: [format.functionName, validator.functionName, parser.functionName].join(", "), + fieldImports: `import { ${[format.functionName, parser.functionName].join(", ")} } from "@brazilian-utils/brazilian-utils";`, + imports: `import { ${[format.functionName, validator.functionName].join(", ")} } from "@brazilian-utils/brazilian-utils";`, + formImports: `import { ${validator.functionName} } from "@brazilian-utils/brazilian-utils";`, format: formatter, // The plain JavaScript example takes the same formatter without the type of its parameter. formatJs: formatter.replace("(value: string)", "(value)"), @@ -160,18 +164,20 @@ function values(document: Document): Record { validatorText: call(validator, "text.value"), validatorSignal: call(validator, "this.value()"), validatorControl: call(validator, "control.value"), - validatorFn: validator.fn, + validatorName: validator.functionName, // The schema is a schema of strings, so a validator that takes more than that is narrowed. standardValidator: - validator.rest === "" ? validator.fn : `(value: string) => ${call(validator, "value")}`, + validator.rest === "" + ? validator.functionName + : `(value: string) => ${call(validator, "value")}`, // Inside a schema the validator is called on the value alone, wrapped when it takes options. validatorArrow: validator.rest === "" - ? validator.fn + ? validator.functionName : `(${document.kind}) => ${call(validator, document.kind)}`, // Valibot's `check` takes a validator of `string` alone, so the call is always wrapped. validatorLambda: `(${document.kind}) => ${call(validator, document.kind)}`, - validatorCtx: call(validator, document.kind), + validatorContext: call(validator, document.kind), validatorOptions: validator.rest === "" ? "" : `\n options:${validator.rest.slice(1)},`, parseInput: call(parser, "input.value"), parseMaskedEvent: call(parser, "event.currentTarget.value"), @@ -192,7 +198,7 @@ function values(document: Document): Record { * @returns {string} Its contents. */ function readTemplate(name: string): string { - return readFileSync(join(TEMPLATE_DIR, `${name}.tmpl`), "utf8"); + return readFileSync(join(TEMPLATE_DIRECTORY, `${name}.tmpl`), "utf8"); } /** @@ -230,10 +236,10 @@ function fill(template: string, substitutions: Record): string { }); } -rmSync(join(EXAMPLE_DIR, "generated"), { force: true, recursive: true }); +rmSync(join(EXAMPLE_DIRECTORY, "generated"), { force: true, recursive: true }); for (const document of DOCUMENTS) { - const folder = join(EXAMPLE_DIR, "generated", document.kind); + const folder = join(EXAMPLE_DIRECTORY, "generated", document.kind); mkdirSync(folder, { recursive: true }); @@ -290,13 +296,13 @@ for (const document of DOCUMENTS) { // The address guide types a CEP into the very field the document field guide builds, so that // field and its mask are written there too, from the same templates. The guide shows neither: it // says where they come from and gets on with the lookup. -const ADDRESS_DIR = join(ROOT, "docs", "snippets", "address-form"); +const ADDRESS_DIRECTORY = join(ROOT, "docs", "snippets", "address-form"); const cep = DOCUMENTS.find((document) => document.kind === "cep"); if (cep === undefined) throw new Error("The address guide needs the CEP of the documents table"); for (const framework of FRAMEWORKS) { - const folder = join(ADDRESS_DIR, framework.name); + const folder = join(ADDRESS_DIRECTORY, framework.name); writeFileSync( join(folder, framework.mask), diff --git a/scripts/ibs-cbs.ts b/scripts/ibs-cbs.ts index 3773a0a48..e7e6e00b7 100644 --- a/scripts/ibs-cbs.ts +++ b/scripts/ibs-cbs.ts @@ -8,7 +8,7 @@ import { decodeXml } from "./decode-xml.ts"; import { readXlsxSheets } from "./read-xlsx-sheet.ts"; import { serializeRecord } from "./serialize-record.ts"; -const scriptsDir = import.meta.dirname; +const scriptsDirectory = import.meta.dirname; const PORTAL = "https://www.nfe.fazenda.gov.br/portal/"; @@ -267,7 +267,7 @@ const main = async (): Promise => { } await writeFile( - resolve(scriptsDir, "..", "./src/_internals/constants/ibs-cbs.ts"), + resolve(scriptsDirectory, "..", "./src/_internals/constants/ibs-cbs.ts"), `/** * CST-IBS/CBS (Código de Situação Tributária do IBS e da CBS) table, indexed by the 3 digit * code, with the description the official table gives each one. diff --git a/scripts/jsr.ts b/scripts/jsr.ts index ea7dc977e..b5aee6449 100644 --- a/scripts/jsr.ts +++ b/scripts/jsr.ts @@ -15,18 +15,18 @@ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; const ROOT = join(import.meta.dirname, ".."); -const SRC_DIR = join(ROOT, "src"); +const SOURCE_DIRECTORY = join(ROOT, "src"); const JSR_PATH = join(ROOT, "jsr.json"); -const utilNames = readdirSync(SRC_DIR, { withFileTypes: true }) +const utilityNames = readdirSync(SOURCE_DIRECTORY, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && !entry.name.startsWith("_")) .map((entry) => entry.name) - .filter((name) => existsSync(join(SRC_DIR, name, `${name}.ts`))) + .filter((name) => existsSync(join(SOURCE_DIRECTORY, name, `${name}.ts`))) .toSorted(); const exports: Record = { ".": "./src/index.ts" }; -for (const name of utilNames) { +for (const name of utilityNames) { exports[`./${name}`] = `./src/${name}/${name}.ts`; } diff --git a/scripts/legal-natures.ts b/scripts/legal-natures.ts index fafc2a202..10299638e 100644 --- a/scripts/legal-natures.ts +++ b/scripts/legal-natures.ts @@ -6,7 +6,7 @@ import { inflateSync } from "node:zlib"; import { fetchWithRetry } from "../src/_internals/fetch-with-retry/fetch-with-retry.ts"; -const scriptsDir = import.meta.dirname; +const scriptsDirectory = import.meta.dirname; const SOURCE_URL = "https://concla.ibge.gov.br/images/concla/documentacao/CONCLA-TNJ2021-EstruturaDetalhada.pdf"; @@ -87,12 +87,12 @@ const inflateStreams = (pdf: Buffer): string[] => { }; const unescapePdfString = (value: string): string => - value.replaceAll(/\\([0-7]{1,3})|\\(.)/g, (_match, octal?: string, char?: string) => { + value.replaceAll(/\\([0-7]{1,3})|\\(.)/g, (_match, octal?: string, character?: string) => { if (octal !== undefined) return String.fromCharCode(Number.parseInt(octal, 8)); - if (char === "n") return "\n"; - if (char === "r") return "\r"; - if (char === "t") return "\t"; - return char ?? ""; + if (character === "n") return "\n"; + if (character === "r") return "\r"; + if (character === "t") return "\t"; + return character ?? ""; }); /** @@ -238,7 +238,7 @@ const main = async (): Promise => { .map(([code]) => code); await writeFile( - resolve(scriptsDir, "..", OUTPUT_PATH), + resolve(scriptsDirectory, "..", OUTPUT_PATH), `/** * Tabela de Natureza Jurídica 2021 (IBGE/CONCLA), indexed by the four digit code. * diff --git a/scripts/llms.ts b/scripts/llms.ts index 93177d546..69c5aba39 100644 --- a/scripts/llms.ts +++ b/scripts/llms.ts @@ -5,11 +5,11 @@ import { parseFrontMatter } from "./front-matter.ts"; import { removeUntilStable } from "./remove-until-stable.ts"; const ROOT = join(import.meta.dirname, ".."); -const DOCS_DIR = join(ROOT, "docs"); +const DOCS_DIRECTORY = join(ROOT, "docs"); const SITE = "https://brazilian-utils.com.br"; -const REPO = "https://github.com/brazilian-utils/javascript"; +const REPOSITORY = "https://github.com/brazilian-utils/javascript"; -type UtilSection = { +type UtilitySection = { name: string; slug: string; description: string; @@ -76,19 +76,19 @@ function deprecationSentence(paragraph: string): string { return firstSentence(paragraph.slice(markerIndex).replaceAll("**", "")); } -const UTIL_HEADING_PATTERN = /^#{2,3} ([a-z][A-Za-z0-9]*)\n/; +const UTILITY_HEADING_PATTERN = /^#{2,3} ([a-z][A-Za-z0-9]*)\n/; /** * Parses every function section of `utilities.md` into name/slug/description. A function section - * starts with a `## ` or `### ` heading whose text is a bare identifier; the `##` family + * starts with a `## ` or `### ` heading whose text is a bare identifier; the `##` family * headings that group most of them ("CPF", "Pix", ...) are skipped. - * @param {string} utilitiesMd - The full contents of `utilities.md`. - * @returns {UtilSection[]} One entry per function section, in document order. + * @param {string} utilitiesMarkdown - The full contents of `utilities.md`. + * @returns {UtilitySection[]} One entry per function section, in document order. */ -function parseUtilities(utilitiesMd: string): UtilSection[] { - const sections = utilitiesMd +function parseUtilities(utilitiesMarkdown: string): UtilitySection[] { + const sections = utilitiesMarkdown .split(/^(?=#{2,3} )/m) - .filter((section) => UTIL_HEADING_PATTERN.test(section)); + .filter((section) => UTILITY_HEADING_PATTERN.test(section)); return sections.map((section) => { const newlineIndex = section.indexOf("\n"); @@ -139,11 +139,11 @@ function subHeadings(markdown: string): string[] { /** * Reads the util names listed in the "Bundle size" table of `getting-started.md`, so the summary * of the dataset-backed utils cannot drift from the table it summarizes. - * @param {string} gettingStartedMd - The full contents of `getting-started.md`. + * @param {string} gettingStartedMarkdown - The full contents of `getting-started.md`. * @returns {string[]} The util names of the table, in document order. */ -function parseDatasetUtils(gettingStartedMd: string): string[] { - const section = /\n## Bundle size\n([\s\S]*?)(?=\n## |$)/.exec(gettingStartedMd)?.[1] ?? ""; +function parseDatasetUtilities(gettingStartedMarkdown: string): string[] { + const section = /\n## Bundle size\n([\s\S]*?)(?=\n## |$)/.exec(gettingStartedMarkdown)?.[1] ?? ""; return section .split("\n") @@ -173,39 +173,41 @@ const PREFIX_GROUPS: { title: string; test: (name: string) => boolean }[] = [ { title: "Getters (get*)", test: (name) => name.startsWith("get") }, ]; -function groupUtilities(utils: UtilSection[]): { title: string; utils: UtilSection[] }[] { - const groups: { title: string; utils: UtilSection[] }[] = PREFIX_GROUPS.map((group) => ({ +function groupUtilities( + utilities: UtilitySection[], +): { title: string; utilities: UtilitySection[] }[] { + const groups: { title: string; utilities: UtilitySection[] }[] = PREFIX_GROUPS.map((group) => ({ title: group.title, - utils: [], + utilities: [], })); - const other: UtilSection[] = []; + const other: UtilitySection[] = []; - for (const util of utils) { - const groupIndex = PREFIX_GROUPS.findIndex((group) => group.test(util.name)); + for (const utility of utilities) { + const groupIndex = PREFIX_GROUPS.findIndex((group) => group.test(utility.name)); const matchedGroup = groupIndex === -1 ? undefined : groups[groupIndex]; if (matchedGroup === undefined) { - other.push(util); + other.push(utility); } else { - matchedGroup.utils.push(util); + matchedGroup.utilities.push(utility); } } if (other.length > 0) { - groups.push({ title: "Other utilities", utils: other }); + groups.push({ title: "Other utilities", utilities: other }); } - return groups.filter((group) => group.utils.length > 0); + return groups.filter((group) => group.utilities.length > 0); } -function utilLink(util: UtilSection): string { - return `- [${util.name}](${SITE}/utilities.md#${util.slug}): ${util.description}`; +function utilityLink(utility: UtilitySection): string { + return `- [${utility.name}](${SITE}/utilities.md#${utility.slug}): ${utility.description}`; } -function buildLlmsTxt(utils: UtilSection[], datasetUtils: string[]): string { - const groups = groupUtilities(utils); +function buildLlmsTxt(utilities: UtilitySection[], datasetUtilities: string[]): string { + const groups = groupUtilities(utilities); const groupSections = groups - .map((group) => `## ${group.title}\n\n${group.utils.map(utilLink).join("\n")}`) + .map((group) => `## ${group.title}\n\n${group.utilities.map(utilityLink).join("\n")}`) .join("\n\n"); return `# Brazilian Utils @@ -220,7 +222,7 @@ Install with \`npm install --save @brazilian-utils/brazilian-utils\` (also avail import { isValidCpf } from '@brazilian-utils/brazilian-utils'; \`\`\` -Every util is also available as its own subpath for lazy-loading/code-splitting, \`@brazilian-utils/brazilian-utils/\` (kebab-case of the function name, e.g. \`isValidCpf\` maps to \`is-valid-cpf\`) - most useful for the utils that embed an official dataset (${joinNames(datasetUtils)}): +Every util is also available as its own subpath for lazy-loading/code-splitting, \`@brazilian-utils/brazilian-utils/\` (kebab-case of the function name, e.g. \`isValidCpf\` maps to \`is-valid-cpf\`) - most useful for the utils that embed an official dataset (${joinNames(datasetUtilities)}): \`\`\`javascript const { getCities } = await import('@brazilian-utils/brazilian-utils/get-cities'); @@ -239,8 +241,8 @@ ${groupSections} - [Getting started (pt-BR)](${SITE}/pt-br/getting-started.md): Portuguese translation of the getting started guide - [Utilities (pt-BR)](${SITE}/pt-br/utilities.md): Portuguese translation of the utilities reference -- [README on GitHub](${REPO}#readme): project overview and contributor list -- [CHANGELOG](${REPO}/blob/main/CHANGELOG.md): release history +- [README on GitHub](${REPOSITORY}#readme): project overview and contributor list +- [CHANGELOG](${REPOSITORY}/blob/main/CHANGELOG.md): release history - [npm package](https://www.npmjs.com/package/@brazilian-utils/brazilian-utils): published versions and download stats `; } @@ -271,19 +273,21 @@ function demoteHeadings(markdown: string): string { } function buildLlmsFullTxt( - gettingStartedMd: string, - utilitiesMd: string, - utils: UtilSection[], + gettingStartedMarkdown: string, + utilitiesMarkdown: string, + utilities: UtilitySection[], ): string { const toc = [ "- [Getting Started](#getting-started)", - ...subHeadings(gettingStartedMd).map((heading) => ` - [${heading}](#${slugify(heading)})`), + ...subHeadings(gettingStartedMarkdown).map( + (heading) => ` - [${heading}](#${slugify(heading)})`, + ), "- [Utilities](#utilities)", - ...utils.map((util) => ` - [${util.name}](#${util.slug})`), + ...utilities.map((utility) => ` - [${utility.name}](#${utility.slug})`), ].join("\n"); - const gettingStarted = demoteHeadings(stripDocsifySyntax(gettingStartedMd)); - const utilities = demoteHeadings(stripDocsifySyntax(utilitiesMd)); + const gettingStarted = demoteHeadings(stripDocsifySyntax(gettingStartedMarkdown)); + const utilitiesReference = demoteHeadings(stripDocsifySyntax(utilitiesMarkdown)); return `# Brazilian Utils @@ -295,7 +299,7 @@ ${toc} ${gettingStarted} -${utilities} +${utilitiesReference} `; } @@ -314,19 +318,21 @@ function frontMatterToHeading(markdown: string): string { } function main(): void { - const gettingStartedMd = frontMatterToHeading( - readFileSync(join(DOCS_DIR, "getting-started.md"), "utf8"), + const gettingStartedMarkdown = frontMatterToHeading( + readFileSync(join(DOCS_DIRECTORY, "getting-started.md"), "utf8"), ); - const utilitiesMd = frontMatterToHeading(readFileSync(join(DOCS_DIR, "utilities.md"), "utf8")); - const utils = parseUtilities(utilitiesMd); + const utilitiesMarkdown = frontMatterToHeading( + readFileSync(join(DOCS_DIRECTORY, "utilities.md"), "utf8"), + ); + const utilities = parseUtilities(utilitiesMarkdown); writeFileSync( - join(DOCS_DIR, "llms.txt"), - buildLlmsTxt(utils, parseDatasetUtils(gettingStartedMd)), + join(DOCS_DIRECTORY, "llms.txt"), + buildLlmsTxt(utilities, parseDatasetUtilities(gettingStartedMarkdown)), ); writeFileSync( - join(DOCS_DIR, "llms-full.txt"), - buildLlmsFullTxt(gettingStartedMd, utilitiesMd, utils), + join(DOCS_DIRECTORY, "llms-full.txt"), + buildLlmsFullTxt(gettingStartedMarkdown, utilitiesMarkdown, utilities), ); } diff --git a/scripts/nbs.ts b/scripts/nbs.ts index f08773939..999d9e7a0 100644 --- a/scripts/nbs.ts +++ b/scripts/nbs.ts @@ -6,7 +6,7 @@ import { resolve } from "node:path"; import { fetchSortedRecord } from "./fetch-sorted-record.ts"; import { serializeRecord } from "./serialize-record.ts"; -const scriptsDir = import.meta.dirname; +const scriptsDirectory = import.meta.dirname; /** The official CSV is published in ISO-8859-1, not UTF-8. */ const NBS_CSV_DECODER = new TextDecoder("iso-8859-1"); @@ -112,7 +112,7 @@ const main = async (): Promise => { ); await writeFile( - resolve(scriptsDir, "..", "./src/_internals/constants/nbs.ts"), + resolve(scriptsDirectory, "..", "./src/_internals/constants/nbs.ts"), `/** * NBS 2.0 (Nomenclatura Brasileira de Serviços, Intangíveis e Outras Operações que Produzam * Variações no Patrimônio) descriptions, indexed by the raw 9 digit code. diff --git a/scripts/ncm.ts b/scripts/ncm.ts index 26687ad5c..b2a6f981f 100644 --- a/scripts/ncm.ts +++ b/scripts/ncm.ts @@ -5,7 +5,7 @@ import { resolve } from "node:path"; import { fetchWithRetry } from "../src/_internals/fetch-with-retry/fetch-with-retry.ts"; -const scriptsDir = import.meta.dirname; +const scriptsDirectory = import.meta.dirname; type NcmEntry = { Codigo: string; @@ -106,7 +106,7 @@ const main = async (): Promise => { const uniqueSortedCodes = [...new Set(codes)].sort(); await writeFile( - resolve(scriptsDir, "..", "./src/is-valid-ncm/constants.ts"), + resolve(scriptsDirectory, "..", "./src/is-valid-ncm/constants.ts"), `/** * NCM (Nomenclatura Comum do Mercosul) 8 digit codes in force on the generation date, sorted * ascending. A code is included when the generation date falls within its Siscomex diff --git a/scripts/service-items.ts b/scripts/service-items.ts index 43ad6423a..6a494004a 100644 --- a/scripts/service-items.ts +++ b/scripts/service-items.ts @@ -8,7 +8,7 @@ import { decodeXml } from "./decode-xml.ts"; import { readXlsxSheet } from "./read-xlsx-sheet.ts"; import { serializeRecord } from "./serialize-record.ts"; -const scriptsDir = import.meta.dirname; +const scriptsDirectory = import.meta.dirname; const DOCUMENTATION_URL = "https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual"; @@ -92,7 +92,7 @@ const main = async (): Promise => { } await writeFile( - resolve(scriptsDir, "..", "./src/_internals/constants/service-items.ts"), + resolve(scriptsDirectory, "..", "./src/_internals/constants/service-items.ts"), `/** * The subitems in force of the service list annexed to the Lei Complementar 116/2003, indexed by * the raw 4 digits of the subitem (\`0101\` for \`1.01\`), the first four digits of the national diff --git a/scripts/site.ts b/scripts/site.ts index 6d2217f75..b3396ca48 100644 --- a/scripts/site.ts +++ b/scripts/site.ts @@ -16,7 +16,7 @@ import { parseFrontMatter } from "./front-matter.ts"; */ const ROOT = join(import.meta.dirname, ".."); -const DOCS_DIR = join(ROOT, "docs"); +const DOCS_DIRECTORY = join(ROOT, "docs"); const SITE = "https://brazilian-utils.com.br"; const SITE_NAME = "Brazilian Utils"; @@ -60,7 +60,7 @@ type Alternate = { hreflang: string; href: string }; * @returns {string[]} The page paths, in sidebar order. */ function sidebarPaths(folder: string): string[] { - const sidebar = readFileSync(join(DOCS_DIR, folder, "_sidebar.md"), "utf8"); + const sidebar = readFileSync(join(DOCS_DIRECTORY, folder, "_sidebar.md"), "utf8"); return [...sidebar.matchAll(SIDEBAR_LINK_PATTERN)].map(([, target]) => `/${target ?? ""}`); } @@ -71,7 +71,7 @@ function sidebarPaths(folder: string): string[] { * @returns {{ title: string; description: string }} The two fields, empty when absent. */ function frontMatter(markdown: string): { title: string; description: string } { - const { fields } = parseFrontMatter(readFileSync(join(DOCS_DIR, markdown), "utf8")); + const { fields } = parseFrontMatter(readFileSync(join(DOCS_DIRECTORY, markdown), "utf8")); return { title: fields["title"] ?? "", description: fields["description"] ?? "" }; } @@ -253,16 +253,16 @@ ${entries.join("\n")} } function main(): void { - const shell = readFileSync(join(DOCS_DIR, "index.html"), "utf8"); + const shell = readFileSync(join(DOCS_DIRECTORY, "index.html"), "utf8"); const sitePages = pages(); for (const page of sitePages) { - const file = join(DOCS_DIR, shellFile(page.path)); + const file = join(DOCS_DIRECTORY, shellFile(page.path)); mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, pageShell(shell, page)); } - writeFileSync(join(DOCS_DIR, "404.html"), notFoundShell(shell)); + writeFileSync(join(DOCS_DIRECTORY, "404.html"), notFoundShell(shell)); // The home page is `index.html` itself; `/pt-br/` canonicalizes to its entry page, so the sitemap // lists the canonical paths once. @@ -275,7 +275,7 @@ function main(): void { markdown: HOME_MARKDOWN, }; const canonicalPages = sitePages.filter((page) => page.path === page.canonicalPath); - writeFileSync(join(DOCS_DIR, "sitemap.xml"), buildSitemap([home, ...canonicalPages])); + writeFileSync(join(DOCS_DIRECTORY, "sitemap.xml"), buildSitemap([home, ...canonicalPages])); } main(); diff --git a/scripts/states.ts b/scripts/states.ts index aa1fed470..36d0108a7 100644 --- a/scripts/states.ts +++ b/scripts/states.ts @@ -5,7 +5,7 @@ import { resolve } from "node:path"; import { fetchWithRetry } from "../src/_internals/fetch-with-retry/fetch-with-retry.ts"; -const scriptsDir = import.meta.dirname; +const scriptsDirectory = import.meta.dirname; type State = { id: number; @@ -76,7 +76,7 @@ const main = async (): Promise => { })); await writeFile( - resolve(scriptsDir, "..", "./src/_internals/constants/states.ts"), + resolve(scriptsDirectory, "..", "./src/_internals/constants/states.ts"), `/** * One Brazilian state, as returned by \`getStates\`, \`getStateByIbgeCode\` and the other state * utils. Every state is its own member of the union, so the fields of a state are tied to each @@ -108,7 +108,7 @@ export const DATA: readonly State[] = ${JSON.stringify(states)};`, ); await writeFile( - resolve(scriptsDir, "..", "./src/_internals/constants/state-codes.ts"), + resolve(scriptsDirectory, "..", "./src/_internals/constants/state-codes.ts"), `import { type StateCode } from "./states"; /** diff --git a/scripts/tree-shaking.ts b/scripts/tree-shaking.ts index 9e6f87eec..6b30c61b4 100644 --- a/scripts/tree-shaking.ts +++ b/scripts/tree-shaking.ts @@ -44,7 +44,7 @@ import { gzipSync } from "node:zlib"; import { build } from "esbuild"; -const rootDir = resolve(import.meta.dirname, ".."); +const rootDirectory = resolve(import.meta.dirname, ".."); const packageName = "@brazilian-utils/brazilian-utils"; /** Exit code of a comparison that could not be carried out (missing build, unreadable base). */ @@ -124,7 +124,7 @@ const parseArgs = (argv: string[]): Args => { const mapWithConcurrency = async ( items: readonly T[], limit: number, - fn: (item: T) => Promise, + transform: (item: T) => Promise, ): Promise => { const results: R[] = Array.from({ length: items.length }); let cursor = 0; @@ -139,7 +139,7 @@ const mapWithConcurrency = async ( if (item !== undefined) { // Each worker of the pool processes its items one after the other on purpose. // eslint-disable-next-line no-await-in-loop - results[index] = await fn(item); + results[index] = await transform(item); } } }; @@ -153,12 +153,12 @@ const mapWithConcurrency = async ( const bundleSource = async ( source: string, sourcefile: string, - resolveDir: string, + resolveDirectory: string, ): Promise => { const result = await build({ stdin: { contents: source, - resolveDir, + resolveDir: resolveDirectory, sourcefile, loader: "js", }, @@ -180,9 +180,9 @@ const bundleSource = async ( const measure = async ( name: string, source: string, - resolveDir: string, + resolveDirectory: string, ): Promise => { - const contents = await bundleSource(source, `${name}.mjs`, resolveDir); + const contents = await bundleSource(source, `${name}.mjs`, resolveDirectory); return { name, bytes: contents.byteLength, @@ -194,21 +194,21 @@ const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null; const loadExports = async ( - distEntry: string, + distributionEntry: string, ): Promise<{ testable: string[]; aliasOf: Map }> => { - const mod: unknown = await import(pathToFileURL(distEntry).href); + const moduleNamespace: unknown = await import(pathToFileURL(distributionEntry).href); - if (!isRecord(mod)) { - throw new Error(`Unexpected default export shape for ${distEntry}`); + if (!isRecord(moduleNamespace)) { + throw new Error(`Unexpected default export shape for ${distributionEntry}`); } - const functionExports = Object.keys(mod) - .filter((name) => typeof mod[name] === "function") + const functionExports = Object.keys(moduleNamespace) + .filter((name) => typeof moduleNamespace[name] === "function") .sort(); const groups = new Map(); for (const name of functionExports) { - const value = mod[name]; + const value = moduleNamespace[name]; const group = groups.get(value); if (group) group.push(name); else groups.set(value, [name]); @@ -229,13 +229,13 @@ const loadExports = async ( return { testable, aliasOf }; }; -const consumerResolveDir = async (packageRoot: string): Promise => { - if (packageRoot === rootDir) return rootDir; +const consumerResolveDirectory = async (packageRoot: string): Promise => { + if (packageRoot === rootDirectory) return rootDirectory; - const consumerDir = await mkdtemp(join(tmpdir(), "tree-shaking-")); - await mkdir(join(consumerDir, "node_modules", "@brazilian-utils"), { recursive: true }); - await symlink(packageRoot, join(consumerDir, "node_modules", packageName)); - return consumerDir; + const consumerDirectory = await mkdtemp(join(tmpdir(), "tree-shaking-")); + await mkdir(join(consumerDirectory, "node_modules", "@brazilian-utils"), { recursive: true }); + await symlink(packageRoot, join(consumerDirectory, "node_modules", packageName)); + return consumerDirectory; }; const importSource = (names: string[]): string => @@ -247,16 +247,16 @@ const measureExports = async ( full: Measurement; exports: Record; aliasOf: Map; - resolveDir: string; + resolveDirectory: string; }> => { - const distEntry = resolve(packageRoot, "dist/brazilian-utils.js"); - if (!existsSync(distEntry)) { - console.error(`Missing ${distEntry}. Run \`npm run build\` first.`); + const distributionEntry = resolve(packageRoot, "dist/brazilian-utils.js"); + if (!existsSync(distributionEntry)) { + console.error(`Missing ${distributionEntry}. Run \`npm run build\` first.`); process.exit(COMPARISON_ERROR_EXIT_CODE); } - const { testable, aliasOf } = await loadExports(distEntry); - const resolveDir = await consumerResolveDir(packageRoot); + const { testable, aliasOf } = await loadExports(distributionEntry); + const resolveDirectory = await consumerResolveDirectory(packageRoot); const [full, ...measurements] = await mapWithConcurrency( [ @@ -264,7 +264,7 @@ const measureExports = async ( ...testable.map((name) => ({ name, names: [name] })), ], CONCURRENCY, - ({ name, names }) => measure(name, importSource(names), resolveDir), + ({ name, names }) => measure(name, importSource(names), resolveDirectory), ); if (full === undefined) { @@ -279,19 +279,24 @@ const measureExports = async ( if (targetMeasurement !== undefined) exportsMap[alias] = targetMeasurement; } - return { full: { bytes: full.bytes, gzip: full.gzip }, exports: exportsMap, aliasOf, resolveDir }; + return { + full: { bytes: full.bytes, gzip: full.gzip }, + exports: exportsMap, + aliasOf, + resolveDirectory, + }; }; /** * Bundles one named import of every name in `names` at once, the way a consumer using that whole * API surface would. * @param {string[]} names - The export names to bundle together. - * @param {string} resolveDir - The directory esbuild resolves the bundled import from. + * @param {string} resolveDirectory - The directory esbuild resolves the bundled import from. * @returns {Promise} The bundled size, in bytes and gzip bytes. */ -const measureNames = async (names: string[], resolveDir: string): Promise => { +const measureNames = async (names: string[], resolveDirectory: string): Promise => { if (names.length === 0) return { bytes: 0, gzip: 0 }; - const { bytes, gzip } = await measure("__existing__", importSource(names), resolveDir); + const { bytes, gzip } = await measure("__existing__", importSource(names), resolveDirectory); return { bytes, gzip }; }; @@ -583,9 +588,14 @@ const readSnapshot = async (path: string): Promise => { const main = async (): Promise => { const args = parseArgs(process.argv.slice(2)); const packageRoot = - args.dist === undefined || args.dist === "" ? rootDir : resolve(process.cwd(), args.dist); + args.dist === undefined || args.dist === "" ? rootDirectory : resolve(process.cwd(), args.dist); - const { full, exports: exportsMap, aliasOf, resolveDir } = await measureExports(packageRoot); + const { + full, + exports: exportsMap, + aliasOf, + resolveDirectory, + } = await measureExports(packageRoot); if (args.json !== undefined && args.json !== "") { const snapshot: Snapshot = { full, exports: exportsMap }; @@ -595,7 +605,7 @@ const main = async (): Promise => { const survivingNames = Object.keys(exportsMap) .filter((name) => name in other.exports && !aliasOf.has(name)) .sort(); - snapshot.surviving = await measureNames(survivingNames, resolveDir); + snapshot.surviving = await measureNames(survivingNames, resolveDirectory); } await writeFile(resolve(process.cwd(), args.json), `${JSON.stringify(snapshot, null, "\t")}\n`); @@ -607,7 +617,7 @@ const main = async (): Promise => { const existingNames = Object.keys(base.exports) .filter((name) => name in exportsMap && !aliasOf.has(name)) .sort(); - const existing = await measureNames(existingNames, resolveDir); + const existing = await measureNames(existingNames, resolveDirectory); const result = compareSnapshots(base, head, existing); const markdown = renderMarkdown(base, head, existing, result); diff --git a/src/_internals/calculate-pis-check-digit/calculate-pis-check-digit.ts b/src/_internals/calculate-pis-check-digit/calculate-pis-check-digit.ts index 42935f4d9..cbf15b835 100644 --- a/src/_internals/calculate-pis-check-digit/calculate-pis-check-digit.ts +++ b/src/_internals/calculate-pis-check-digit/calculate-pis-check-digit.ts @@ -8,7 +8,9 @@ const MODULUS = 11; * * The ten base digits are multiplied by the weights 3, 2, 9, 8, 7, 6, 5, 4, 3 and 2, from left to * right. The check digit is 11 minus the remainder of the weighted sum by 11, or 0 when that - * difference is 10 or 11. + * difference is 10 or 11. No official document found publishes these weights: the Caixa layouts + * only ask for a "Número de PIS/PASEP válido", so the rule follows the community reference + * `isValidPis` cites as `Based on:`. * * @param {string} base - The ten digits that precede the check digit. * @returns {number} The check digit, 0 to 9. diff --git a/src/_internals/calculate-processo-juridico-check-digits/calculate-processo-juridico-check-digits.test.ts b/src/_internals/calculate-processo-juridico-check-digits/calculate-processo-juridico-check-digits.test.ts index f428acc35..0d9b47cf2 100644 --- a/src/_internals/calculate-processo-juridico-check-digits/calculate-processo-juridico-check-digits.test.ts +++ b/src/_internals/calculate-processo-juridico-check-digits/calculate-processo-juridico-check-digits.test.ts @@ -10,6 +10,16 @@ describe("calculateProcessoJuridicoCheckDigits", () => { expect(calculateProcessoJuridicoCheckDigits("000000000000000000")).toBe(98); }); + test("should return 2, the lowest value, when the product by 100 leaves the highest remainder, 96", () => { + // 32 · 100 = 3200 = 32 · 97 + 96 + expect(calculateProcessoJuridicoCheckDigits("000000000000000032")).toBe(2); + }); + + test("should also check the Anexo II example of Resolução CNJ nº 477/2022 in the J.TR form", () => { + // 0000100-DD.2008.4.06.0000: the Anexo prints DD 15, a fictitious value; the check gives 68 + expect(calculateProcessoJuridicoCheckDigits("000010020084060000")).toBe(68); + }); + test("should return 95 for the number 1, whose product by 100 leaves a remainder of 3", () => { expect(calculateProcessoJuridicoCheckDigits("000000000000000001")).toBe(95); }); diff --git a/src/_internals/calculate-processo-juridico-check-digits/calculate-processo-juridico-check-digits.ts b/src/_internals/calculate-processo-juridico-check-digits/calculate-processo-juridico-check-digits.ts index 1ddad3644..0289ea96a 100644 --- a/src/_internals/calculate-processo-juridico-check-digits/calculate-processo-juridico-check-digits.ts +++ b/src/_internals/calculate-processo-juridico-check-digits/calculate-processo-juridico-check-digits.ts @@ -5,15 +5,18 @@ const HEAD_LENGTH = 11; /** * Calculates the two verifying digits (`DD`) of a processo jurídico number, the ISO 7064 MOD - * 97-10 check of Resolução CNJ nº 65/2008, art. 1º, § 2º: 98 minus the remainder of the other 18 - * digits, read as one number and multiplied by 100, by 97. + * 97-10 check of Resolução CNJ nº 65/2008, art. 1º, § 2º, "nos termos das instruções constantes + * do Anexo VIII": 98 minus the remainder of the other 18 digits, read as one number and + * multiplied by 100, by 97 (Anexo VIII, III, "D1 D0 = 98 – (N6 ... O0 01 00 módulo 97)"). The + * Anexo II is the table of the Justiça Federal, not the algorithm. * * That 20 digit product does not fit a double, so the 18 digits are reduced in two steps: the * remainder of the first 11 is carried into the last 7 (times 10 to the 9th, the 7 digits times - * the 100), which is the same remainder without ever leaving the safe integer range. + * the 100), which is the same remainder without ever leaving the safe integer range, the + * factoring Anexo VIII, V allows "no caso de limitação técnica de precisão computacional". * * @param {string} base - The 18 digits of the number without its verifying digits (`NNNNNNNAAAAJTROOOO`). - * @returns {number} The verifying digits as one number, 1 to 98. + * @returns {number} The verifying digits as one number, 2 to 98: the remainder by 97 is 0 to 96. * * @example * ```typescript diff --git a/src/_internals/calculate-renavam-check-digit/calculate-renavam-check-digit.ts b/src/_internals/calculate-renavam-check-digit/calculate-renavam-check-digit.ts index e4e2f7471..e20a09110 100644 --- a/src/_internals/calculate-renavam-check-digit/calculate-renavam-check-digit.ts +++ b/src/_internals/calculate-renavam-check-digit/calculate-renavam-check-digit.ts @@ -22,8 +22,14 @@ const OVERFLOW_DIGIT = 10; * `mod11` into this module costs `isValidRenavam` and `generateRenavam` around 100 B of bundle * each, which the tree-shaking budget of this package does not spend on eight lines. * - * The Código de Trânsito Brasileiro creates the RENAVAM registry but does not define its check - * digit, so the calculation follows the two community references cited below. + * Portaria DENATRAN nº 27, de 25 de janeiro de 2013, art. 1º, gives the Portaria DENATRAN nº + * 03/86, art. 3º, its wording in force: the Código RENAVAM is "composto de 11 (onze) dígitos", + * and "possui 10 dígitos e um dígito verificador, calculado através do módulo 11, peso 9". Read + * as the usual módulo 11 with weights 2 to 9 from the right, that gives 3, 2, 9, 8, 7, 6, 5, 4, 3 + * and 2 over the ten base digits, the weights used here; the portaria does not write them out + * nor say what a remainder of 0, 1 or 10 turns into, so that part follows the community + * references cited as `Based on:`. Nor does it say how a 9 digit code issued before 1 April 2013 + * is read in 11: padding it with zeros on the left is market practice. * * @param {string} base - The ten digits that precede the check digit. * @returns {number} The check digit, 0 to 9. @@ -35,6 +41,9 @@ const OVERFLOW_DIGIT = 10; * ``` * * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l9503compilado.htm + * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/arquivos-senatran/portarias/2013/portaria0272013.pdf + * Portaria DENATRAN nº 27/2013, art. 1º: "10 dígitos e um dígito verificador, calculado através do + * módulo 11, peso 9"; art. 2º: the 11 digit numbering from 1 April 2013. * @see Based on: https://github.com/klawdyo/validation-br/blob/main/src/renavam.ts * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/renavam.py */ diff --git a/src/_internals/calculate-voter-id-first-digit/calculate-voter-id-first-digit.test.ts b/src/_internals/calculate-voter-id-first-digit/calculate-voter-id-first-digit.test.ts index 6520d4bb3..48be37646 100644 --- a/src/_internals/calculate-voter-id-first-digit/calculate-voter-id-first-digit.test.ts +++ b/src/_internals/calculate-voter-id-first-digit/calculate-voter-id-first-digit.test.ts @@ -8,19 +8,18 @@ describe("calculateVoterIdFirstDigit", () => { ).toBe(7); }); - test("should calculate the first digit for a 9-digit sequential number (SP)", () => { + test("should calculate the first digit over the 8 sequential digits (SP)", () => { + // 1·2 + 2·3 + 3·4 + 4·5 + 5·6 + 6·7 + 7·8 + 8·9 = 240, 240 mod 11 = 9 expect( - calculateVoterIdFirstDigit({ sequentialNumber: "123456788", federativeUnion: "01" }), + calculateVoterIdFirstDigit({ sequentialNumber: "12345678", federativeUnion: "01" }), ).toBe(9); }); - test("should ignore the ninth sequential digit", () => { + test("should weigh the leading zeros of the sequential number as zeros", () => { + // 1·5 + 2·6 + 3·7 + 4·8 + 5·9 = 115, 115 mod 11 = 5 expect( - calculateVoterIdFirstDigit({ sequentialNumber: "123456780", federativeUnion: "01" }), - ).toBe(9); - expect( - calculateVoterIdFirstDigit({ sequentialNumber: "123456783", federativeUnion: "01" }), - ).toBe(9); + calculateVoterIdFirstDigit({ sequentialNumber: "00012345", federativeUnion: "01" }), + ).toBe(5); }); test("should apply the SP/MG rule when the remainder is 0", () => { diff --git a/src/_internals/calculate-voter-id-first-digit/calculate-voter-id-first-digit.ts b/src/_internals/calculate-voter-id-first-digit/calculate-voter-id-first-digit.ts index 2c37e3f6d..bb3f39188 100644 --- a/src/_internals/calculate-voter-id-first-digit/calculate-voter-id-first-digit.ts +++ b/src/_internals/calculate-voter-id-first-digit/calculate-voter-id-first-digit.ts @@ -1,9 +1,9 @@ -import { NINE_DIGIT_FEDERATIVE_UNION_CODES } from "../constants/voter-id"; +import { REMAINDER_ZERO_FEDERATIVE_UNION_CODES } from "../constants/voter-id"; const SEQUENTIAL_LENGTH = 8; export type CalculateVoterIdFirstDigitParams = { - /** The sequential part of the voter ID, 8 digits (9 for some São Paulo/Minas Gerais ids). */ + /** The sequential part of the voter ID, 8 digits with its leading zeros. */ sequentialNumber: string; /** The 2 digit federative unit code of the voter ID. */ federativeUnion: string; @@ -12,13 +12,15 @@ export type CalculateVoterIdFirstDigitParams = { /** * Calculates the first verification digit of a Brazilian voter id (título de eleitor). * - * The first eight sequential digits are weighted 2..9 from left to right and summed modulo - * 11. São Paulo (01) and Minas Gerais (02) issued some ids with a nine digit sequential - * number; the check digits of those ids are still computed from the first eight digits, the - * ninth one is not part of the calculation (brutils does the same). + * Resolução TSE nº 23.659/2021, art. 36, computes it over the sequential number, "o primeiro + * calculado sobre o número sequencial": its eight digits are weighted 2..9 from left to right + * and summed modulo 11. The weights and the remainder rules, the São Paulo and Minas Gerais + * rule that turns a remainder of 0 into 1 included, have no official source: the resolution says + * only "Módulo 11". They follow the community references the voter id functions cite as + * `Based on:`. * * @param {CalculateVoterIdFirstDigitParams} params - The calculation parameters. - * @param {string} params.sequentialNumber - The 8 or 9 digit sequential number; only the first 8 digits count. + * @param {string} params.sequentialNumber - The 8 digit sequential number, leading zeros included. * @param {string} params.federativeUnion - The 2-digit federative union code. * @returns {number} The calculated first verification digit (0-9). * @@ -42,7 +44,7 @@ export const calculateVoterIdFirstDigit = ({ const remainder = sum % 11; - if (remainder === 0 && NINE_DIGIT_FEDERATIVE_UNION_CODES.includes(federativeUnion)) { + if (remainder === 0 && REMAINDER_ZERO_FEDERATIVE_UNION_CODES.includes(federativeUnion)) { return 1; } diff --git a/src/_internals/calculate-voter-id-second-digit/calculate-voter-id-second-digit.ts b/src/_internals/calculate-voter-id-second-digit/calculate-voter-id-second-digit.ts index cc8255765..68cb3210b 100644 --- a/src/_internals/calculate-voter-id-second-digit/calculate-voter-id-second-digit.ts +++ b/src/_internals/calculate-voter-id-second-digit/calculate-voter-id-second-digit.ts @@ -1,4 +1,4 @@ -import { NINE_DIGIT_FEDERATIVE_UNION_CODES } from "../constants/voter-id"; +import { REMAINDER_ZERO_FEDERATIVE_UNION_CODES } from "../constants/voter-id"; export type CalculateVoterIdSecondDigitParams = { /** The 2 digit federative unit code of the voter ID. */ @@ -31,7 +31,7 @@ export const calculateVoterIdSecondDigit = ({ const remainder = sum % 11; - if (remainder === 0 && NINE_DIGIT_FEDERATIVE_UNION_CODES.includes(federativeUnion)) { + if (remainder === 0 && REMAINDER_ZERO_FEDERATIVE_UNION_CODES.includes(federativeUnion)) { return 1; } diff --git a/src/_internals/constants/area-codes.ts b/src/_internals/constants/area-codes.ts index 975025a88..d51cd35de 100644 --- a/src/_internals/constants/area-codes.ts +++ b/src/_internals/constants/area-codes.ts @@ -13,10 +13,17 @@ import { type StateCode } from "./states"; * approved; Anatel publishes it on the gov.br page below, which lists the codes actually * allocated and links, under "POR MUNICÍPIO", to the Anexo of Resolução Anatel nº 263/2001, * giving the Código Nacional of every municipality. That Anexo was parsed to derive both - * tables. + * tables, and both were checked against the table Anatel publishes today, `Codigos_Nacionais.csv` + * of the Painel de Dados de Áreas Tarifárias (21/09/2026): the same 67 codes, each with the same + * state, and the same four cross-border codes, 42, 47, 49 and 61. * * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 * @see Official: https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais + * @see Official: https://informacoes.anatel.gov.br/paineis/areas-tarifarias/codigos-nacionais + * Anatel, Painel de Dados de Áreas Tarifárias, "Códigos Nacionais", the page the Anatel FAQ + * points to for the CN of every municipality ("67 (sessenta e sete) áreas de numeração"). + * @see Official: https://www.anatel.gov.br/dadosabertos/paineis_de_dados/areastarifarias/pgcn.zip + * `Codigos_Nacionais.csv` of 21/09/2026, the CN of all 5,571 municipalities in force. * @see Based on: https://informacoes.anatel.gov.br/legislacao/resolucoes/2001/383-resolucao-263 * Anexo of Resolução nº 263/2001 (revoked; still the table Anatel's Códigos Nacionais page links to). * @see Based on: https://brasilapi.com.br/docs#tag/DDD @@ -113,9 +120,16 @@ export const AREA_CODE_STATES: Record = { * every municipality, as later amended by Resolução nº 580/2012 (Vila Boa, 62 to 61), * Resolução nº 644/2014 (Porto União, 49 to 42) and Resolução nº 701/2018 (Rio Negro, 41 to * 47, and Barracão, 46 to 49). No other Código Nacional in that Anexo covers more than one - * state. + * state, and the current Anatel table (`Codigos_Nacionais.csv`, 21/09/2026) lists the same four + * and no other: 42 PR (56) + SC (1), 47 SC (78) + PR (1), 49 SC (149) + PR (1), 61 DF (1) + GO + * (12). * * @see Official: https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais + * @see Official: https://informacoes.anatel.gov.br/paineis/areas-tarifarias/codigos-nacionais + * Anatel, Painel de Dados de Áreas Tarifárias, "Códigos Nacionais", the page the Anatel FAQ + * points to for the CN of every municipality ("67 (sessenta e sete) áreas de numeração"). + * @see Official: https://www.anatel.gov.br/dadosabertos/paineis_de_dados/areastarifarias/pgcn.zip + * `Codigos_Nacionais.csv` of 21/09/2026, the CN of all 5,571 municipalities in force. * @see Based on: https://informacoes.anatel.gov.br/legislacao/resolucoes/2001/383-resolucao-263 * Anexo of Resolução nº 263/2001 (revoked; still the table Anatel's Códigos Nacionais page links to). */ diff --git a/src/_internals/constants/arrecadacao.ts b/src/_internals/constants/arrecadacao.ts index b845941be..536fccc2b 100644 --- a/src/_internals/constants/arrecadacao.ts +++ b/src/_internals/constants/arrecadacao.ts @@ -5,9 +5,13 @@ * (6, 7, 8 or 9), 04 overall check digit (modulus 10 or 11 per position 03), 05-15 amount, * 16-19 issuing company, 20-44 free field. The linha digitável splits the barcode into * 4 blocks of 11 digits, each one followed by its own check digit (§03-E). There is no - * segment 8 nor 0, and 9 is reserved for the banks themselves. + * segment 8 nor 0, and 9 is reserved for the banks themselves. Versão 08 adds the alphanumeric + * CNPJ: positions 16-23 may carry a CNPJ only when it is numeric, and segment 6 gets no new + * codes; the structure checked here is unchanged. * * @see Official: https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf + * FEBRABAN "Layout Padrão de Arrecadação/Recebimento com Utilização do Código de Barras", + * Versão 08 (file of 11/05/2026), "Vigência: a partir de 01.06.2026". */ export const ARRECADACAO_PRODUCT = "8"; diff --git a/src/_internals/constants/banks.ts b/src/_internals/constants/banks.ts index 9528a21ae..0f91bbc7b 100644 --- a/src/_internals/constants/banks.ts +++ b/src/_internals/constants/banks.ts @@ -604,7 +604,11 @@ export const BANKS: Bank[] = [ }, { code: "513", ispb: "44728700", name: "ATF SOCIEDADE DE CRÉDITO DIRETO S.A." }, { code: "514", ispb: "73302408", name: "EXIM SOCIEDADE CORRETORA DE CÂMBIO LTDA" }, - { code: "516", ispb: "36583700", name: "QISTA S.A. - CRÉDITO, FINANCIAMENTO E INVESTIMENTO" }, + { + code: "516", + ispb: "36583700", + name: "PLACARPAY S.A. - SOCIEDADE DE CRÉDITO, FINANCIAMENTO E INVESTIMENTO", + }, { code: "517", ispb: "03816413", name: "PAGUEVELOZ INSTITUIÇÃO DE PAGAMENTO LTDA." }, { code: "518", @@ -840,7 +844,7 @@ export const BANKS: Bank[] = [ { code: "688", ispb: "43978697", name: "KIKAI SOCIEDADE DE CRÉDITO DIRETO S.A." }, { code: "690", ispb: "16814330", name: "BK INSTITUIÇÃO DE PAGAMENTO S.A." }, { code: "691", ispb: "49686505", name: "WASU - WALLET SUPPORT INSTITUIÇÃO DE PAGAMENTO LTDA" }, - { code: "692", ispb: "56198117", name: "SQUID SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { code: "692", ispb: "56198117", name: "ZYDI SOCIEDADE DE CRÉDITO DIRETO S.A." }, { code: "693", ispb: "32820711", name: "EFEX INSTITUIÇÃO DE PAGAMENTO S.A." }, { code: "694", ispb: "54811417", name: "WOOVI INSTITUICAO DE PAGAMENTO LTDA" }, { code: "695", ispb: "35523352", name: "BEES INSTITUICAO DE PAGAMENTO LTDA." }, diff --git a/src/_internals/constants/boleto.ts b/src/_internals/constants/boleto.ts index 8b0cffd05..5e0945027 100644 --- a/src/_internals/constants/boleto.ts +++ b/src/_internals/constants/boleto.ts @@ -1,2 +1,32 @@ /** Digits of a "cobrança bancária" linha digitável. */ export const BOLETO_LENGTH = 47; + +/** + * The código de moeda of a "cobrança bancária" slip in real, `9`: Carta-Circular BCB nº + * 2.926/2000 sets it in position 4 of the barcode, which is position 4 of the linha digitável + * too, since campo 1 opens with the first 4 barcode digits. `generateBoleto` writes it. + */ +export const REAL_CURRENCY_CODE = "9"; + +/** + * The prefix of a slip of the FEBRABAN Convenção da Cobrança item 2.3.2, "Situação 2", issued by + * an institution identified only by its ISPB: "Número Código 988" in positions 1 to 3, "Zero" as + * the código de moeda in position 4, and "Zeros" as the fator de vencimento in positions 6 to 9. + * Positions 10 to 19 carry the "ISPB com zeros a esquerda" instead of an amount. + */ +export const ISPB_ONLY_PREFIX = "9880"; + +/** + * What an `ISPB_ONLY_PREFIX` slip carries from `FACTOR_INDEX` on: the "Zeros" fator de vencimento + * and the 2 zeros that pad the 8 digit ISPB to the 10 digits of positions 10 to 19. + */ +export const ISPB_ONLY_ZEROS = "000000"; + +/** Index (0 based) of the fator de vencimento in the linha digitável. */ +export const FACTOR_INDEX = 33; + +/** Index (0 based) of the 8 digit ISPB of an `ISPB_ONLY_PREFIX` slip in the linha digitável. */ +export const ISPB_INDEX = 39; + +/** Index (0 based) of the código de moeda in the barcode and in the linha digitável. */ +export const CURRENCY_CODE_INDEX = 3; diff --git a/src/_internals/constants/cei.ts b/src/_internals/constants/cei.ts index 41aac0ebc..459793115 100644 --- a/src/_internals/constants/cei.ts +++ b/src/_internals/constants/cei.ts @@ -2,13 +2,24 @@ * Numbering shared by the CEI (Cadastro Específico do INSS) and by the CNO (Cadastro Nacional * de Obras) that replaced it: 12 digits printed as "00.000.00000/00". * - * The Receita Federal does not publish the check digit rule of the CEI/CNO numbering, so the - * calculation follows the reference implementations cited below, cross-checked against the CNO - * open data of the Receita Federal. + * The Receita Federal does not publish the check digit rule of the CEI/CNO numbering: no norm + * (IN RFB nº 2.061/2021), layout or manual found has it, and the eSocial only checks that the + * number is "constante das bases da RFB". Only the 12 positions and the CNO keeping the CEI + * number are official. The calculation follows the reference implementations cited below, + * cross-checked against the CNO open data of the Receita Federal and SERPRO's example + * `"000000336854"`. * * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/cno * The registry's own page at the Receita Federal, which describes the cadastro but publishes * neither the mask nor the check digit rule. + * @see Official: https://bcadastros.serpro.gov.br/documentacao/cadastro_cno/ + * SERPRO, documentation of the Receita Federal's Cadastro Compartilhado (bCadastros), CNO: "cno — + * Número de inscrição da obra", 12 positions, with the example "000000336854", which the rule + * here accepts. Neither it nor the IN RFB nº 2.061/2021, the norm of the CNO, gives a check digit + * rule. + * @see Official: https://www.gov.br/esocial/pt-br/documentacao-tecnica/manuais/mos-s-1-3-consolidada-ate-a-no-s-1-3-07-2026.pdf + * Manual de Orientação do eSocial S-1.3, item 9.1: "O número de inscrição no CNO permanece o + * mesmo número do CEI". * @see Official: https://dados.gov.br/dados/conjuntos-dados/cadastro-nacional-de-obras-cno * Cadastro Nacional de Obras (CNO), dados abertos da Receita Federal: the catalogue entry for the * dataset this rule was cross-checked against and where the test vectors come from. The check was diff --git a/src/_internals/constants/certidao.ts b/src/_internals/constants/certidao.ts index 544d64066..b4d0e3ce5 100644 --- a/src/_internals/constants/certidao.ts +++ b/src/_internals/constants/certidao.ts @@ -19,7 +19,12 @@ * structure: "a matrícula, de inserção obrigatória nas certidões (primeira e demais vias) emitidas * pelos Cartórios de Registro Civil das Pessoas Naturais a partir de 1º de janeiro de 2010, é * formada pelos seguintes elementos", incisos I to IX fixing the same 6 + 2 + 2 + 4 + 1 + 5 + 3 + - * 7 + 2 positions art. 473 carries today (revoked; historical). + * 7 + 2 positions art. 473 carries today (revoked by the Provimento CNJ nº 63/2017; historical). + * Its inciso V also listed "8: Livro E (Desdobrado para registro específico das Emancipações)" + * and "9: Livro E (Desdobrado para registro específico das Interdições)", which art. 473, V no + * longer has but which are still accepted, the certidões issued under it carrying them, and its + * inciso IX had the check digits "formado automaticamente por meio do programa" the CNJ + * Corregedoria handed to the registrars, whose algorithm was never published. * @see Based on: http://ghiorzi.org/DVnew.htm * Worked example of the two check digits (sums 288 and 309). * @see Based on: https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts diff --git a/src/_internals/constants/cid10-descriptions.ts b/src/_internals/constants/cid10-descriptions.ts index e41d21a82..696b7eb4b 100644 --- a/src/_internals/constants/cid10-descriptions.ts +++ b/src/_internals/constants/cid10-descriptions.ts @@ -8,6 +8,10 @@ * the `DESCRICAO` column, as published. A category with no subcategory is listed by both files * with the same description and shows up once here. * + * The V2008 files predate the `U07` category the WHO opened for emergency use. Its codes, `U07`, + * `U07.0`, `U07.1` and `U07.2` (the COVID-19 ones), are added from the CID-10 table of the + * SIM (Sistema de Informações sobre Mortalidade), `CID10.DBF`, which DATASUS keeps on its FTP. + * * This table is about 1 MB, so it has a module of its own: `CID10_SUBCATEGORIES`, which holds * the same codes without the descriptions, lives in `cid10.ts` and never loads this one. * @@ -17,6 +21,8 @@ * The CID-10 tables in CSV, as published by DATASUS (Ministério da Saúde). * @see Official: http://www2.datasus.gov.br/cid10/V2008/descrcsv.htm * The DATASUS page that links the archive and documents its files, columns and encoding. + * @see Official: ftp://ftp.datasus.gov.br/dissemin/publicos/SIM/CID10/TABELAS/CID10.DBF + * The CID-10 table of the SIM, source of the `U07` codes. */ export const CID10_DESCRIPTIONS: Record = { A00: "Cólera", @@ -9845,6 +9851,10 @@ export const CID10_DESCRIPTIONS: Record = { T983: "Seqüelas de complicações dos cuidados médicos e cirúrgicos não classificados em outra parte", U04: "Síndrome respiratória aguda grave [severe acute respiratory syndrome SARS]", U049: "Síndrome respiratória aguda grave [Severe acute respiratory syndrome) [SARS], não especificada", + U07: "Uso emergencial do U07", + U070: "Doença por cigarro eletrônico", + U071: "Infecção pelo novo Coronavírus (COVID-19)", + U072: "COVID-19, vírus não identificado", U80: "Agente resistente à penicilina e antibióticos relacionados", U800: "Agente resistente à penicilina", U801: "Agente resistente à meticilina", diff --git a/src/_internals/constants/cid10.ts b/src/_internals/constants/cid10.ts index a70be78f8..7c0f569ef 100644 --- a/src/_internals/constants/cid10.ts +++ b/src/_internals/constants/cid10.ts @@ -2,7 +2,8 @@ * Every CID-10 category, with the fourth characters of its subcategories: `A00: "019"` stands * for `A00.0`, `A00.1` and `A00.9`, and an empty string for a category that is not * subdivided. It holds the same codes as `CID10_DESCRIPTIONS` without the descriptions, so that - * checking a code does not cost the whole table. + * checking a code does not cost the whole table. The `U07` codes come from the SIM table, as in + * `cid10-descriptions.ts`. * * Generated by `node ./scripts/cid10.ts`. Do not edit by hand. * @@ -10,6 +11,8 @@ * The CID-10 tables in CSV, as published by DATASUS (Ministério da Saúde). * @see Official: http://www2.datasus.gov.br/cid10/V2008/descrcsv.htm * The DATASUS page that links the archive and documents its files, columns and encoding. + * @see Official: ftp://ftp.datasus.gov.br/dissemin/publicos/SIM/CID10/TABELAS/CID10.DBF + * The CID-10 table of the SIM, source of the `U07` codes. */ export const CID10_SUBCATEGORIES: Record = { A00: "019", @@ -1595,6 +1598,7 @@ export const CID10_SUBCATEGORIES: Record = { T97: "", T98: "0123", U04: "9", + U07: "012", U80: "018", U81: "08", U88: "", diff --git a/src/_internals/constants/cns.ts b/src/_internals/constants/cns.ts index 8756653e3..d7dc9caa3 100644 --- a/src/_internals/constants/cns.ts +++ b/src/_internals/constants/cns.ts @@ -1,14 +1,22 @@ /** * CNS (Cartão Nacional de Saúde) structural constants, shared by `isValidCns` and `formatCns`. * + * @see Official: https://web.archive.org/web/20190106003442/http://cartaonet.datasus.gov.br/Rotina_JavaScript.doc + * DATASUS, "Rotina de validação de CNS e Número Provisório", the routine the Cartão Nacional de + * Saúde site (cartaonet.datasus.gov.br) published for download; the site is gone, so the link is + * the Wayback Machine copy of the official file (last captured on 06/01/2019). It has two + * routines, "Números que iniciam com '1' ou '2'" (the definitive CNS, PIS base + "000" or "001" + + * check digit) and "Números que iniciam com '7', '8' ou '9'" (the provisional number, weights 15 + * to 1 summing to a multiple of 11), and names no other first digit: nothing official covers a + * number starting with 5. Its 2007 version said "O Número Provisório sempre começa com '8'". * @see Official: https://rni-docs.anvisa.gov.br/docs/regras_gerais/validacoes/validacaoCNS/ * ANVISA's two validation routines, the ones implemented here. The page sits behind a bot filter * and answers HTTP 403 to every non-browser client, so it has to be opened in a browser. * @see Based on: https://integracao.esusab.ufsc.br/ledi/documentacao/regras/algoritmo_CNS.html * e-SUS APS documentation of the same DATASUS algorithm, reachable without a browser. It applies * the provisional routine to numbers starting with 5, 7, 8 or 9; this implementation follows the - * ANVISA page, which restricts it to 7, 8 and 9, so a 5 prefixed number is rejected even when its - * weighted sum checks out. + * DATASUS routine and the ANVISA page, which restrict it to 7, 8 and 9, so a 5 prefixed number is + * rejected even when its weighted sum checks out: no official document names the prefix 5. */ /** diff --git a/src/_internals/constants/iban.ts b/src/_internals/constants/iban.ts index be4ea4b3d..a7960aab8 100644 --- a/src/_internals/constants/iban.ts +++ b/src/_internals/constants/iban.ts @@ -1,25 +1,37 @@ /** - * Layout of a Brazilian IBAN: `BR` + 2 ISO 7064 MOD 97-10 check digits + 8 digit ISPB (the - * institution's Identificador do Sistema de Pagamentos Brasileiro, not the 3 digit COMPE code) - * + 5 digit branch (agência) + 10 digit account (conta) + 1 letter account type + 1 - * alphanumeric owner indicator = 29 characters. The registry pattern `BR2!n8!n5!n10!n1!a1!c` - * allows any letter as the account type, drawn from the "Dicionário de Tipos" of the Catálogo - * de Mensagens e de Arquivos do SFN; `C` (conta corrente) and `P` (conta poupança) are the - * usual values. Circular BCB nº 3.625/2013 art. 2º § 1º numbers the owner indicator `1` for - * the first or only holder, `2` for the second and so on up to the ninth, then `A` to `Z` from - * the tenth, so `0` is not a valid owner indicator. - * The two sources disagree on the account type: art. 2º VI of the same Circular calls it "um - * caractere alfanumérico", while the ISO 13616 registry pattern `1!a` makes it a letter, and the - * registry is the form followed here, so a digit in that position is deliberately rejected. + * Layout of a Brazilian IBAN, Resolução BCB nº 585/2026 art. 2º (which revoked Circular BCB nº + * 3.625/2013 and kept its layout): `BR` + 2 ISO 7064 MOD 97-10 check digits + 8 character ISPB + * (the institution's Identificador do Sistema de Pagamentos Brasileiro, not the 3 digit COMPE + * code) + 5 digit branch (agência) + 10 digit account (conta) + 1 letter account type + 1 + * alphanumeric owner indicator = 29 characters. + * + * The ISPB is "oito caracteres alfanuméricos [...] conforme as listas de participantes do [STR] + * e do [SPI]" (art. 2º III), where the revoked Circular said "oito caracteres numéricos", so a + * letter is accepted there; up to 2.4.0 only digits were. The ISO 13616 registry pattern + * `BR2!n8!n5!n10!n1!a1!c` still writes the ISPB as `8!n`, and every ISPB of the STR list of + * 25/09/2026 is still numeric. + * + * The registry pattern allows any letter as the account type, drawn from the "dicionário de + * tipos" of the Catálogo de Serviços do Sistema Financeiro Nacional (art. 2º VI; the Circular + * said the Catálogo de Mensagens e de Arquivos do SFN); `C` (conta corrente) and `P` (conta + * poupança) are the usual values. Art. 2º § 1º numbers the owner indicator `1` for the first or + * only holder, `2` for the second and so on up to the ninth, then `A` to `Z` from the tenth, so + * `0` is not a valid owner indicator. + * The two sources disagree on the account type: art. 2º VI calls it "um caractere alfanumérico", + * while the ISO 13616 registry pattern `1!a` makes it a letter, and the registry is the form + * followed here, so a digit in that position is deliberately rejected. * Only Brazilian IBANs follow this layout; every other ISO 13616 country has its own. + * @see Official: https://www.bcb.gov.br/estabilidadefinanceira/exibenormativo?tipo=Resolu%C3%A7%C3%A3o%20BCB&numero=585 + * Resolução BCB nº 585, de 24/08/2026 (DOU 25/08/2026), art. 2º, which revoked Circular BCB nº + * 3.625/2013 and keeps its layout, with an alphanumeric ISPB. * @see Official: https://www.bcb.gov.br/pre/normativos/circ/2013/pdf/circ_3625_v1_O.pdf - * Circular BCB nº 3.625/2013 + * Circular BCB nº 3.625/2013 (revoked), the original layout. * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf * Diretrizes de Implementação do IBAN no Brasil */ export const BR_IBAN_LENGTH = 29; -export const BR_IBAN_REGEX = /^BR\d{2}\d{8}\d{5}\d{10}[A-Z][A-Z1-9]$/; +export const BR_IBAN_REGEX = /^BR\d{2}[0-9A-Z]{8}\d{5}\d{10}[A-Z][A-Z1-9]$/; /** * Shape an IBAN has to be written in: letters and digits, optionally split into the ISO 13616 diff --git a/src/_internals/constants/nfe-key.ts b/src/_internals/constants/nfe-key.ts index f0bc29bc3..496929d08 100644 --- a/src/_internals/constants/nfe-key.ts +++ b/src/_internals/constants/nfe-key.ts @@ -1,9 +1,28 @@ -/** Digits of a DF-e (NF-e, NFC-e, CT-e or MDF-e) access key (chave de acesso). */ +/** Characters of a DF-e (NF-e, NFC-e, CT-e or MDF-e) access key (chave de acesso). */ export const NFE_KEY_LENGTH = 44; /** - * The prefixes the `Id` attribute of a DF-e XML puts in front of the 44 digits, one per - * document: `NFe`, `CTe`, `MDFe`, `BPe`, `NF3e` and `NFCom`. Stripped before the digits are - * read, since `NF3e` carries a digit of its own. Shared by `getNfeKeyInfo` and `parseNfeKey`. + * Start (0 based) of the 12 characters of the key that may be letters: the root and the order + * of an alphanumeric CNPJ, positions 7 to 18 of the key. + */ +export const NFE_KEY_ALPHANUMERIC_START = 6; + +/** + * End (0 based, exclusive) of the 12 characters of the key that may be letters. The two CNPJ + * check digits that follow (positions 19 and 20) stay digits, like every other field. + */ +export const NFE_KEY_ALPHANUMERIC_END = 18; + +/** + * The access key once its mask is removed and its letters upper cased: `TChNFe` of + * `tiposBasico_v4.00.xsd` (PL_010), `[0-9]{6}[0-9A-Z]{12}[0-9]{26}`, the pattern `TChDFe` of the + * CT-e (PL_CTe_400_RTC) and `TChMDFe` of the MDF-e (PL_MDFe_300b) repeat. + */ +export const NFE_KEY_REGEX = /^\d{6}[0-9A-Z]{12}\d{26}$/; + +/** + * The prefixes the `Id` attribute of a DF-e XML puts in front of the 44 characters, one per + * document: `NFe`, `CTe`, `MDFe`, `BPe`, `NF3e` and `NFCom`. Stripped before the key is read, + * since `NF3e` carries a digit of its own. Shared by `getNfeKeyInfo` and `parseNfeKey`. */ export const XML_ID_PREFIX_REGEX = /^(?:nfe|cte|mdfe|bpe|nf3e|nfcom)/i; diff --git a/src/_internals/constants/pix-key.ts b/src/_internals/constants/pix-key.ts new file mode 100644 index 000000000..c59407ddc --- /dev/null +++ b/src/_internals/constants/pix-key.ts @@ -0,0 +1,52 @@ +/** + * The longest e-mail key the DICT registers: "E-mail deve possuir no máximo 77 caracteres e deve + * ser em minúsculo" (DICT API 2.12.1, tag "Chave"). + */ +export const EMAIL_MAX_LENGTH = 77; + +/** + * The regular expression the DICT API registers for an e-mail key, verbatim (DICT API 2.12.1, + * the version the BCB publishes on its site, tag "Chave", type `EMAIL`), which requires the + * address in lowercase ("deve ser em minúsculo"). It is not the syntax `isValidEmail` checks: the + * local part may carry any of ``.!#$'*+/=?^_`{|}~-``, with dots anywhere (`.ab`, `a..b`, `ab.`), + * and the domain may be a single label (`a@localhost`) or end in a numeric one (`a@example.123`). + * + * The `&` is not among them: the DICT API changelog, version 2.6.0 (2025-09-27), "Removido '&' + * do conjunto de caracteres permitidos para chaves do tipo EMAIL". The GitHub repository + * `bacen/pix-dict-api`, whose last version (1.8.0, 2022) still carries it, is marked "OBSOLETO". + * The `components.schemas.Email` pattern of the same specification, which keeps `%` and `&`, is + * the participant's contact address, not the Pix key. + * + * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/API-DICT.html + * DICT API 2.12.1, tag "Chave", type `EMAIL`. + * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/changelog.html + * DICT API changelog, version 2.6.0. + */ +export const PIX_EMAIL_REGEX = + /^[a-z0-9.!#$'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/; + +/** + * A DICT random key (EVP) is a lowercase UUID written with its punctuation, which the DICT + * itself generates ("é gerado pelo DICT", DICT API, tag "Chave"). Neither the pattern the DICT + * API registers (`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`) nor its example + * (`123e4567-e89b-12d3-a456-426655440000`) nor the one of the Pix manual + * (`123e4567-e12b-12d1-a456-426655440000`), both with the version nibble `1`, constrains the + * UUID version, so the version and variant nibbles are not enforced. + */ +export const EVP_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * The characters a value written as a phone number may carry: digits, the `+` of the + * international prefix and the spaces, dots, hyphens and parentheses of the usual masks. + * Sanitizing to digits alone would read `"abc(11) 98765-4321xyz"` as a phone number, so the + * value is matched against this before it is sanitized. + */ +export const PHONE_SYNTAX_REGEX = /^[\d ()+.-]+$/; + +/** + * The forms a value written as a CPF may take: the bare 11 digits or the documented mask, with + * the dots and the hyphen optional and a space accepted wherever a separator goes. Sanitizing + * to digits alone would read `"abc123.456.789-09"` as a CPF, so the value is matched against + * this before it is sanitized. + */ +export const CPF_SYNTAX_REGEX = /^\d{3}[ .]?\d{3}[ .]?\d{3}[ -]?\d{2}$/; diff --git a/src/_internals/constants/pix.ts b/src/_internals/constants/pix.ts index f6e204e8d..2054eefa2 100644 --- a/src/_internals/constants/pix.ts +++ b/src/_internals/constants/pix.ts @@ -15,6 +15,13 @@ export const PIX_PAYLOAD_FORMAT_INDICATOR_ID = "00"; export const PIX_PAYLOAD_FORMAT_INDICATOR = "01"; +/** + * The Payload Format Indicator object as the EMV® QRCPS-MPM requires it at the very start of + * the payload: "The Payload Format Indicator (ID "00") shall be the first data object in the QR + * Code". + */ +export const PIX_PAYLOAD_FORMAT_INDICATOR_OBJECT = "000201"; + export const PIX_POINT_OF_INITIATION_ID = "01"; export const PIX_STATIC_POINT_OF_INITIATION = "11"; @@ -43,6 +50,12 @@ export const PIX_MERCHANT_CATEGORY_CODE_ID = "52"; export const PIX_MERCHANT_CATEGORY_CODE = "0000"; +/** + * The Merchant Category Code (ID `52`) is a numeric (`N`) object of length `04` in the EMV® + * QRCPS-MPM, a code of ISO 18245; the Pix manual writes `0000` ("não informado") in its examples. + */ +export const PIX_MERCHANT_CATEGORY_CODE_REGEX = /^\d{4}$/; + export const PIX_TRANSACTION_CURRENCY_ID = "53"; export const PIX_TRANSACTION_CURRENCY = "986"; @@ -69,6 +82,12 @@ export const PIX_TXID_ID = "05"; export const PIX_ABSENT_TXID = "***"; +/** + * A `txid` (62-05): "limitado a 25 caracteres", of the letters `a`-`z` and `A`-`Z` and the digits + * `0`-`9` (Manual de Padrões para Iniciação do Pix, §2.6.2). + */ +export const PIX_TXID_REGEX = /^[A-Za-z0-9]{1,25}$/; + export const PIX_CRC_TAG = "6304"; export const PIX_CRC_LENGTH = 4; diff --git a/src/_internals/constants/processo-juridico.ts b/src/_internals/constants/processo-juridico.ts index 09310b2c2..780fb1858 100644 --- a/src/_internals/constants/processo-juridico.ts +++ b/src/_internals/constants/processo-juridico.ts @@ -28,8 +28,11 @@ * @see Official: https://atos.cnj.jus.br/atos/detalhar/4781 * Resolução CNJ nº 477, de 10 de outubro de 2022, art. 1º: "nos processos da Justiça Federal, os * Tribunais Regionais Federais devem ser identificados no campo (TR) pelos números de 01 a 06, - * observadas as respectivas regiões". Its Anexo II prints `0000100-15.2008.406.0000` for the TRF - * da 6ª Região. + * observadas as respectivas regiões". Its Anexo II prints the TRF da 6ª Região example as + * `0000100-15.2008.406.0000`, J and TR run together and its numbers, as the Anexo says, fictitious: + * in the `NNNNNNN-DD.AAAA.J.TR.OOOO` form of Resolução CNJ nº 65/2008 that number is written + * `0000100-DD.2008.4.06.0000`, and its verifying digits are 68, not 15: + * `0000100-68.2008.4.06.0000`. * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2019-2022/2021/lei/l14226.htm * Lei nº 14.226, de 20 de outubro de 2021, art. 1º: "É criado o Tribunal Regional Federal da 6ª * Região, com sede em Belo Horizonte e jurisdição no Estado de Minas Gerais", the court Resolução diff --git a/src/_internals/constants/service-phone.ts b/src/_internals/constants/service-phone.ts index 7047ce8ec..4b60d47d2 100644 --- a/src/_internals/constants/service-phone.ts +++ b/src/_internals/constants/service-phone.ts @@ -10,18 +10,28 @@ * total and never a DDD. `800` is toll-free for the caller, `300` and `303` split the cost * (`303` marks subscribers that generate call bursts, such as telemarketing), `500` is for * donation campaigns by non-profits and `900` for paid value-added services. The 10-digit - * `0800` + 6 form is extinct: Resolução nº 709/2019 art. 2º ordered every CNG migrated to the - * 11-digit format. `900` is currently held in reserva técnica (Ato nº 12.712/2024, item 12.1), + * `0800` + 6 form is extinct: art. 18 gives the CNG 10 digits, [N10…N1], so the dialed form is + * always 11. Resolução nº 709/2019 is not the source of these rules: it approves the + * Regulamento Geral de Numeração, and its art. 2º, which ordered the CNG migrated to the format + * of art. 44 of Resolução nº 86/1998, was revoked by Resolução nº 769/2024. `900` is currently held in reserva técnica (Ato nº 12.712/2024, item 12.1), * and `500` encodes the donation amount in its last two digits (item 10.6), a rule this * library does not enforce, since it validates structure only. * - **Código de Acesso a Serviços de Utilidade Pública (SUP)**, art. 13-14: 3 digits, with the * whole `1N₂N₁` range destined to SUP and every other 3-digit series held in reserva técnica. - * Individual codes are designated one by one by Anatel Ato, the consolidated table being the - * Anexo of Ato nº 43.151/2004, so the codes below are the ones Anatel has designated rather - * than the full `100`-`199` range. `112` and `911` are *not* among them: `911` is not even - * inside the `1N₂N₁` address space art. 13 destines to SUP, and neither code appears in the - * Anexo of Ato nº 43.151/2004 or in Ato nº 12.712/2024. Their routing on Brazilian handsets is - * a GSM convention of the handset, not an Anatel designation, so both are rejected here. + * Individual codes are designated one by one by Anatel Ato (art. 14). The last consolidated + * act is Ato nº 43.151/2004, still marked in force, but no later act consolidates the codes + * designated since (Ato nº 12.712/2024 only sets the procedure and lists none), so the current + * official list is the one Anatel publishes on its gov.br "Serviços de Utilidade Pública e de + * Emergência" page (modified on 22/06/2023) and, older, in the open data set + * `Servico_Utilidade_Publica_Apoio_STFC` (2019). The codes below are the union of that page + * and the Anexo of Ato nº 43.151/2004, not the full `100`-`199` range. `141` is kept although + * the page no longer lists it, because the Ato that designates it is still in force (the + * service it named, the CVV, now answers on `188`). Some codes take an extension the + * structure check does not read (`103+`, `105+`, `106+`, `133+1`, `135+8`, `174+6`), so only + * their 3 digit root is matched. The page lists "Polícia Militar - SOMENTE CELULAR 112/911": + * `112` is inside the `1N₂N₁` range and is accepted; `911` is not, and art. 13 holds every + * series outside `1N₂N₁` in reserva técnica, so the two official texts conflict on it and it + * stays rejected, as up to 2.4.0. * - **The abbreviated `300X`/`400X` numbers** (`3003-1234`, `4004-1234`) are *not* a regulatory * category at all. They are ordinary 8-digit geographic STFC user numbers (art. 11 assigns * `2`-`6` as the first digit of a fixed-line number) whose 4-digit prefix a carrier licenses @@ -37,9 +47,16 @@ * is the grouping used on gov.br, and `4004-1234` the one carriers print. * * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 - * Resolução Anatel nº 749/2022, the Regulamento de Numeração in force. + * Resolução Anatel nº 749/2022, the Regulamento de Numeração in force: art. 18, the CNG series + * `300`, `303`, `500`, `800` and `900` in the format [N10…N1], and art. 28, their dialing as + * ["0"N10…N1]. + * @see Official: https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais/servicos-de-utilidade-publica-e-de-emergencia + * Anatel, "Serviços de Utilidade Pública e de Emergência (SUP)", modified on 22/06/2023: the + * current list of designated codes. + * @see Official: https://www.anatel.gov.br/dadosabertos/PDA/Servico_Utilidade_Publica_Apoio_STFC/Servico_Utilidade_Publica_Apoio_STFC.csv + * Anatel open data, the same list as of 2019. * @see Official: https://informacoes.anatel.gov.br/legislacao/atos-de-numeracao/2004/1648-ato-43151 - * Ato Anatel nº 43.151/2004, whose Anexo is the consolidated SUP designation table. + * Ato Anatel nº 43.151/2004, whose Anexo is the last consolidated SUP designation act. * @see Official: https://informacoes.anatel.gov.br/legislacao/atos-de-numeracao/2140-ato-12712 * Ato Anatel nº 12.712, de 04/09/2024, art. 1º: the Procedimento para a Atribuição e Designação * de Recursos de Numeração (Anexo I), in force since 03/12/2024, whose items 10.6 and 12.1 carry @@ -75,6 +92,7 @@ export const SERVICE_PHONE_UTILITY_CODES: readonly string[] = [ "105", "106", "111", + "112", "115", "116", "117", diff --git a/src/_internals/constants/voter-id.ts b/src/_internals/constants/voter-id.ts index f00e43494..1bb7f32ac 100644 --- a/src/_internals/constants/voter-id.ts +++ b/src/_internals/constants/voter-id.ts @@ -1,6 +1,11 @@ /** - * Federative union codes ("01" for São Paulo and "02" for Minas Gerais) whose - * voter ids may carry a 9-digit sequential number (13 digits total) instead - * of the usual 8-digit sequential number (12 digits total). + * Federative union codes ("01" for São Paulo and "02" for Minas Gerais) whose check digits are + * 1, not 0, when the módulo 11 remainder is 0. This rule has no official source: Resolução TSE + * nº 23.659/2021, art. 36, and the Resolução TSE nº 21.538/2003 it revoked only say the check + * digits are "determinados com base no 'Módulo 11'", with no weights and no rule per state. It + * follows the community references the voter id functions cite as `Based on:`. */ -export const NINE_DIGIT_FEDERATIVE_UNION_CODES: readonly string[] = ["01", "02"]; +export const REMAINDER_ZERO_FEDERATIVE_UNION_CODES: readonly string[] = ["01", "02"]; + +/** Number of digits of a voter id: 8 sequential digits, 2 federative union digits, 2 check digits. */ +export const VOTER_ID_LENGTH = 12; diff --git a/src/_internals/detect-pix-key-type/detect-pix-key-type.test.ts b/src/_internals/detect-pix-key-type/detect-pix-key-type.test.ts new file mode 100644 index 000000000..a7f145db7 --- /dev/null +++ b/src/_internals/detect-pix-key-type/detect-pix-key-type.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "../test/runtime"; +import { detectPixKeyType } from "./detect-pix-key-type"; + +describe("detectPixKeyType", () => { + test("should tell each kind of key, whatever its case and spacing", () => { + expect(detectPixKeyType("123.456.789-09")).toBe("cpf"); + expect(detectPixKeyType(" 11.222.333/0001-81 ")).toBe("cnpj"); + expect(detectPixKeyType("Fulano@Example.COM")).toBe("email"); + expect(detectPixKeyType("(11) 98765-4321")).toBe("phone"); + expect(detectPixKeyType("71C7D9BE-4B85-4E43-9F1C-1F3B8B4E9A2D")).toBe("evp"); + }); + + test("should check an e-mail address once lowercased, which turns the Kelvin sign into a k", () => { + expect(detectPixKeyType("Kelvin@example.com")).toBe("email"); + }); + + test("should check an e-mail address against the DICT pattern, not isValidEmail", () => { + expect(detectPixKeyType("A{B}@Example.123")).toBe("email"); + expect(detectPixKeyType("a b@example.com")).toBeNull(); + }); + + test("should read a value valid both as a CPF and as a mobile number as a CPF", () => { + expect(detectPixKeyType("51998259765")).toBe("cpf"); + expect(detectPixKeyType("+5551998259765")).toBe("phone"); + }); + + test("should return null for a value that is no Pix key", () => { + expect(detectPixKeyType("")).toBeNull(); + expect(detectPixKeyType("(11) 3333-4444")).toBeNull(); + expect(detectPixKeyType("abc123.456.789-09")).toBeNull(); + expect(detectPixKeyType("not an email@")).toBeNull(); + // @ts-expect-error: intentionally invalid input + expect(detectPixKeyType(null)).toBeNull(); + }); +}); diff --git a/src/_internals/detect-pix-key-type/detect-pix-key-type.ts b/src/_internals/detect-pix-key-type/detect-pix-key-type.ts new file mode 100644 index 000000000..da1d0e3ff --- /dev/null +++ b/src/_internals/detect-pix-key-type/detect-pix-key-type.ts @@ -0,0 +1,55 @@ +import { isValidCnpj } from "../../is-valid-cnpj/is-valid-cnpj"; +import { isValidCpf } from "../../is-valid-cpf/is-valid-cpf"; +import { isValidPhone } from "../../is-valid-phone/is-valid-phone"; +import { + CPF_SYNTAX_REGEX, + EMAIL_MAX_LENGTH, + EVP_REGEX, + PHONE_SYNTAX_REGEX, + PIX_EMAIL_REGEX, +} from "../constants/pix-key"; +import { normalizePhone } from "../normalize-phone/normalize-phone"; +import { sanitizeToDigits } from "../sanitize-to-digits/sanitize-to-digits"; + +/** The kinds of Pix key `getPixKeyInfo` recognizes. */ +export type PixKeyType = "cpf" | "cnpj" | "email" | "phone" | "evp"; + +/** + * Tells which kind of Pix key a value is, without normalizing it. Shared by `isValidPixKey`, + * which only needs the kind to apply its `accept` option, and `getPixKeyInfo`, which then + * writes the key in its canonical DICT form. `getPixKeyInfo` documents the rules: a value is + * read as an EVP, an e-mail address, a CNPJ, a CPF and a mobile phone number, in that order, + * and the CPF and the phone number only when written the way those are written. An e-mail key is + * checked, once lowercased, against the pattern and the 77 character limit the DICT API + * registers, not against `isValidEmail`. + * + * @param {string} value - The Pix key to be checked. + * @returns {PixKeyType|null} The kind of Pix key, or `null` when the value is not a valid one. + * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/II_ManualdePadroesparaIniciacaodoPix.pdf + * Manual de Padrões para Iniciação do Pix, §2.5.1: "A regra para formatação das chaves Pix no BR + * Code [...] segue estritamente as regras definidas no Manual Operacional do DICT". + * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/API-DICT.html + * DICT API 2.12.1, tag "Chave", type `EMAIL`: the pattern and "E-mail deve possuir no máximo 77 + * caracteres e deve ser em minúsculo". + */ +export const detectPixKeyType = (value: string): PixKeyType | null => { + if (typeof value !== "string") return null; + + const trimmed = value.trim(); + + if (EVP_REGEX.test(trimmed)) return "evp"; + + if (trimmed.includes("@")) { + const email = trimmed.toLowerCase(); + + return PIX_EMAIL_REGEX.test(email) && email.length <= EMAIL_MAX_LENGTH ? "email" : null; + } + + if (isValidCnpj(trimmed, { version: 2 })) return "cnpj"; + + if (CPF_SYNTAX_REGEX.test(trimmed) && isValidCpf(sanitizeToDigits(trimmed))) return "cpf"; + + if (!PHONE_SYNTAX_REGEX.test(trimmed)) return null; + + return isValidPhone(normalizePhone(trimmed), { accept: ["mobile"] }) ? "phone" : null; +}; diff --git a/src/_internals/find-pix-merchant-account-information/find-pix-merchant-account-information.test.ts b/src/_internals/find-pix-merchant-account-information/find-pix-merchant-account-information.test.ts new file mode 100644 index 000000000..d9fdf2058 --- /dev/null +++ b/src/_internals/find-pix-merchant-account-information/find-pix-merchant-account-information.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "../test/runtime"; +import { findPixMerchantAccountInformation } from "./find-pix-merchant-account-information"; + +describe("findPixMerchantAccountInformation", () => { + test("should return the objects of the first template carrying the Pix GUI", () => { + expect( + findPixMerchantAccountInformation({ + "26": "0004abcd", + "27": "0014BR.GOV.BCB.PIX0103key", + "28": "0014br.gov.bcb.pix0105other", + }), + ).toStrictEqual({ "00": "BR.GOV.BCB.PIX", "01": "key" }); + }); + + test("should read the last template of the range, ID 51", () => { + expect(findPixMerchantAccountInformation({ "51": "0014br.gov.bcb.pix0103key" })).toStrictEqual({ + "00": "br.gov.bcb.pix", + "01": "key", + }); + }); + + test("should skip a template that is not well-formed TLV", () => { + expect( + findPixMerchantAccountInformation({ + "26": "0014br.gov.bcb.pix01", + "30": "0014br.gov.bcb.pix0103key", + }), + ).toStrictEqual({ "00": "br.gov.bcb.pix", "01": "key" }); + }); + + test("should return null when no template in IDs 26 to 51 carries the Pix GUI", () => { + expect( + findPixMerchantAccountInformation({ + "25": "0014br.gov.bcb.pix0103key", + "26": "0004abcd", + "52": "0014br.gov.bcb.pix0103key", + }), + ).toBeNull(); + }); +}); diff --git a/src/_internals/find-pix-merchant-account-information/find-pix-merchant-account-information.ts b/src/_internals/find-pix-merchant-account-information/find-pix-merchant-account-information.ts new file mode 100644 index 000000000..31c9bf25c --- /dev/null +++ b/src/_internals/find-pix-merchant-account-information/find-pix-merchant-account-information.ts @@ -0,0 +1,35 @@ +import { + PIX_GUI, + PIX_GUI_ID, + PIX_MERCHANT_ACCOUNT_INFORMATION_FIRST_ID, + PIX_MERCHANT_ACCOUNT_INFORMATION_LAST_ID, +} from "../constants/pix"; +import { type TlvFields, parseTlv } from "../parse-tlv/parse-tlv"; + +/** + * Finds the Pix "Merchant Account Information" template of a BR Code: the first of the + * templates with IDs 26 to 51 that is well-formed TLV and carries the `br.gov.bcb.pix` GUI in + * its object `00`, compared without regard to letter case. Shared by `isValidPixPayload`, which + * checks the key or URL it holds, and `getPixPayloadInfo`, which reads them. + * + * @param {TlvFields} fields - The root objects of the BR Code. + * @returns {TlvFields|null} The objects of the Pix template, or `null` when there is none. + * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/spb_docs/ManualBRCode.pdf + */ +export const findPixMerchantAccountInformation = (fields: TlvFields): TlvFields | null => { + for ( + let id = PIX_MERCHANT_ACCOUNT_INFORMATION_FIRST_ID; + id <= PIX_MERCHANT_ACCOUNT_INFORMATION_LAST_ID; + id++ + ) { + const template = fields[id.toString()]; + + if (template === undefined) continue; + + const objects = parseTlv(template); + + if (objects?.[PIX_GUI_ID]?.toLowerCase() === PIX_GUI) return objects; + } + + return null; +}; diff --git a/src/_internals/format-tlv/format-tlv.test.ts b/src/_internals/format-tlv/format-tlv.test.ts index 9d410551b..d5e934d89 100644 --- a/src/_internals/format-tlv/format-tlv.test.ts +++ b/src/_internals/format-tlv/format-tlv.test.ts @@ -15,11 +15,15 @@ describe("formatTlv", () => { expect(formatTlv({ id: "62", value: "" })).toBe("6200"); }); - test("should round-trip through parseTlv", () => { - for (let length = 0; length <= 99; length++) { + test("should round-trip through parseTlv for every length from 01 to 99", () => { + for (let length = 1; length <= 99; length++) { const value = "x".repeat(length); expect(parseTlv(formatTlv({ id: "26", value }))).toEqual({ "26": value }); } }); + + test("should not round-trip an empty value, since parseTlv rejects the length 00", () => { + expect(parseTlv(formatTlv({ id: "26", value: "" }))).toBeNull(); + }); }); diff --git a/src/_internals/format/format.ts b/src/_internals/format/format.ts index f1b915423..c5f24552d 100644 --- a/src/_internals/format/format.ts +++ b/src/_internals/format/format.ts @@ -41,13 +41,13 @@ export const format = ({ pad, value, pattern }: FormatParams): string => { paddedValue = value.padStart(pattern.length - separatorsLength, "0"); } - for (const char of pattern) { - if (char === "0" || char === "*") { + for (const character of pattern) { + if (character === "0" || character === "*") { if (valueIndex >= paddedValue.length) break; - formatted += char === "*" ? "*" : paddedValue[valueIndex]; + formatted += character === "*" ? "*" : paddedValue[valueIndex]; valueIndex++; } else if (valueIndex < paddedValue.length) { - formatted += char; + formatted += character; } } diff --git a/src/_internals/generate-checksum/generate-checksum.ts b/src/_internals/generate-checksum/generate-checksum.ts index 273c6d109..1516e01e2 100644 --- a/src/_internals/generate-checksum/generate-checksum.ts +++ b/src/_internals/generate-checksum/generate-checksum.ts @@ -28,16 +28,16 @@ export function generateChecksum({ base, weight }: GenerateChecksumParams): numb let sum = 0; - const len = digits.length; + const length = digits.length; if (typeof weight === "number") { - let w = weight; - for (let i = 0; i < len; i++, w--) { + let currentWeight = weight; + for (let i = 0; i < length; i++, currentWeight--) { const digit = digits.charCodeAt(i) - 48; - sum += digit * w; + sum += digit * currentWeight; } } else { - for (let i = 0; i < len; i++) { + for (let i = 0; i < length; i++) { const digit = digits.charCodeAt(i) - 48; sum += digit * weight[i]; } diff --git a/src/_internals/generate-random-number/generate-random-number.test.ts b/src/_internals/generate-random-number/generate-random-number.test.ts index c18c98d5b..b4b62640e 100644 --- a/src/_internals/generate-random-number/generate-random-number.test.ts +++ b/src/_internals/generate-random-number/generate-random-number.test.ts @@ -37,8 +37,8 @@ describe("generateRandomNumber", () => { test("should generate numbers within valid range (0-9)", () => { for (let i = 0; i < 100; i++) { const result = generateRandomNumber(5); - for (const char of result) { - const digit = Number.parseInt(char, 10); + for (const character of result) { + const digit = Number.parseInt(character, 10); expect(digit).toBeGreaterThanOrEqual(0); expect(digit).toBeLessThanOrEqual(9); } diff --git a/src/_internals/is-valid-cei-cno-number/is-valid-cei-cno-number.ts b/src/_internals/is-valid-cei-cno-number/is-valid-cei-cno-number.ts index e08c44580..4d3bb1da6 100644 --- a/src/_internals/is-valid-cei-cno-number/is-valid-cei-cno-number.ts +++ b/src/_internals/is-valid-cei-cno-number/is-valid-cei-cno-number.ts @@ -1,5 +1,6 @@ import { calculateCeiCheckDigit } from "../calculate-cei-check-digit/calculate-cei-check-digit"; import { CEI_BASE_LENGTH, CEI_FORMAT_REGEX } from "../constants/cei"; +import { isLookupCode } from "../is-lookup-code/is-lookup-code"; import { isRepeatedDigits } from "../is-repeated-digits/is-repeated-digits"; import { sanitizeToDigits } from "../sanitize-to-digits/sanitize-to-digits"; import { toStringSafe } from "../to-string-safe/to-string-safe"; @@ -49,6 +50,8 @@ import { toStringSafe } from "../to-string-safe/to-string-safe"; * Second, independent reference implementation agreeing with the first. */ export const isValidCeiCnoNumber = (value: string | number): boolean => { + if (!isLookupCode(value)) return false; + const digits = sanitizeToDigits(value); if (!CEI_FORMAT_REGEX.test(toStringSafe(value).trim())) return false; diff --git a/src/_internals/mod10/mod10.ts b/src/_internals/mod10/mod10.ts index f2de0fa0a..46fd64764 100644 --- a/src/_internals/mod10/mod10.ts +++ b/src/_internals/mod10/mod10.ts @@ -13,7 +13,7 @@ export type Mod10Options = { * uses the weight 3 and adds the products as they are (the rule of the GS1 General * Specifications, section 7.9.1, for GTIN and the other fixed length GS1 keys). * - * @param {string} str - The string to calculate the check digit for. + * @param {string} value - The string to calculate the check digit for. * @param {Mod10Options} [options] - Optional options. * @param {Mod10Variant} [options.variant] - The weighting rule to apply. Defaults to `"luhn"`. * @returns {number} The calculated check digit (0-9). @@ -24,16 +24,16 @@ export type Mod10Options = { * mod10("37610425002123456", { variant: "gs1" }); // 9 * ``` */ -export const mod10 = (str: string, options?: Mod10Options): number => { +export const mod10 = (value: string, options?: Mod10Options): number => { const isGs1 = options?.variant === "gs1"; const weight = isGs1 ? 3 : 2; let sum = 0; - const len = str.length; - for (let i = 0; i < len; i++) { - const digit = str.charCodeAt(len - 1 - i) - 48; + const length = value.length; + for (let i = 0; i < length; i++) { + const digit = value.charCodeAt(length - 1 - i) - 48; const result = digit * (i % 2 === 0 ? weight : 1); sum += !isGs1 && result > 9 ? result - 9 : result; } - const mod = sum % 10; - return mod > 0 ? 10 - mod : 0; + const remainder = sum % 10; + return remainder > 0 ? 10 - remainder : 0; }; diff --git a/src/_internals/parse-tlv/parse-tlv.test.ts b/src/_internals/parse-tlv/parse-tlv.test.ts index f7ed40edf..d1d82faf1 100644 --- a/src/_internals/parse-tlv/parse-tlv.test.ts +++ b/src/_internals/parse-tlv/parse-tlv.test.ts @@ -15,8 +15,8 @@ describe("parseTlv", () => { }); }); - test("when an object has an empty value", () => { - expect(parseTlv("0000")).toEqual({ "00": "" }); + test("when an object has the shortest value, of length 01", () => { + expect(parseTlv("0001A")).toEqual({ "00": "A" }); }); test("when the string is empty", () => { @@ -41,6 +41,12 @@ describe("parseTlv", () => { expect(parseTlv("00A201")).toBeNull(); }); + test("when an object declares the length 00, below the 01 to 99 of the EMV specification", () => { + expect(parseTlv("0000")).toBeNull(); + expect(parseTlv("0000530398")).toBeNull(); + expect(parseTlv("53039860000")).toBeNull(); + }); + test("when the string is too short to hold an object", () => { expect(parseTlv("00")).toBeNull(); }); diff --git a/src/_internals/parse-tlv/parse-tlv.ts b/src/_internals/parse-tlv/parse-tlv.ts index 288cdebd2..1894b2ec2 100644 --- a/src/_internals/parse-tlv/parse-tlv.ts +++ b/src/_internals/parse-tlv/parse-tlv.ts @@ -5,14 +5,16 @@ const SEGMENT_LENGTH = 2; // Stryker disable next-line Regex: id and length are always sliced to at most SEGMENT_LENGTH (2) characters, so dropping either anchor cannot change whether this matches const SEGMENT_REGEX = /^\d{2}$/; +const EMPTY_LENGTH = "00"; + /** * Parses an EMV® style TLV (tag-length-value) string into its objects. * - * Every object is a 2 digit ID, a 2 digit length and a value of exactly that many characters, - * laid out back to back. Parsing stops with `null` as soon as the string stops being - * well-formed, i.e. when an ID or a length is not made of two digits or when a value runs past - * the end of the string. Repeated IDs are not expected at the root of a BR Code; when they do - * occur, the last one wins. + * Every object is a 2 digit ID, a 2 digit length from `01` to `99` and a value of exactly that + * many characters, laid out back to back. Parsing stops with `null` as soon as the string stops + * being well-formed, i.e. when an ID or a length is not made of two digits, when a length is + * `00` or when a value runs past the end of the string. Repeated IDs are not expected at the + * root of a BR Code; when they do occur, the last one wins. * * @param {string} value - The TLV string to parse. * @returns {TlvFields|null} The objects keyed by ID, or `null` when the string is malformed. @@ -22,7 +24,14 @@ const SEGMENT_REGEX = /^\d{2}$/; * parseTlv("0002015303986"); // { "00": "01", "53": "986" } * parseTlv("00020153039865802BR"); // { "00": "01", "53": "986", "58": "BR" } * parseTlv("0003ab"); // null, the value is shorter than its declared length + * parseTlv("0000"); // null, a value has at least one character * ``` + * + * @see Official: https://www.emvco.com/terms-of-use/?u=/wp-content/uploads/documents/EMVCo-Merchant-Presented-QR-Specification-v1-1.pdf + * EMV® QRCPS Merchant-Presented Mode v1.1, the one the Pix manual cites, "Data Organization": + * "The length is coded as a two-digit + * numeric value, with a value ranging from "01" to "99"" and "The value field has a minimum + * length of one character and maximum length of 99 characters". */ export const parseTlv = (value: string): TlvFields | null => { const fields: TlvFields = {}; @@ -33,7 +42,8 @@ export const parseTlv = (value: string): TlvFields | null => { const id = value.slice(index, index + SEGMENT_LENGTH); const length = value.slice(index + SEGMENT_LENGTH, index + SEGMENT_LENGTH * 2); - if (!SEGMENT_REGEX.test(id) || !SEGMENT_REGEX.test(length)) return null; + if (!SEGMENT_REGEX.test(id) || !SEGMENT_REGEX.test(length) || length === EMPTY_LENGTH) + return null; const start = index + SEGMENT_LENGTH * 2; const end = start + Number(length); diff --git a/src/_internals/resolve-state-holiday-date/resolve-state-holiday-date.test.ts b/src/_internals/resolve-state-holiday-date/resolve-state-holiday-date.test.ts index 3a3bde3d4..6c68f8689 100644 --- a/src/_internals/resolve-state-holiday-date/resolve-state-holiday-date.test.ts +++ b/src/_internals/resolve-state-holiday-date/resolve-state-holiday-date.test.ts @@ -45,6 +45,21 @@ describe("resolveStateHolidayDate", () => { expect(resolveStateHolidayDate(2029, rule)).toEqual(new Date(2029, 10, 25)); }); + test("should move a Tuesday back to the Monday and a Thursday on to the Friday, across a month boundary too", () => { + const rule = { day: 30, month: 11, tuesdayToMondayThursdayToFriday: true }; + + expect(resolveStateHolidayDate(2027, rule)).toEqual(new Date(2027, 10, 29)); + expect(resolveStateHolidayDate(2023, rule)).toEqual(new Date(2023, 11, 1)); + }); + + test("should leave a Monday, Wednesday, Friday, Saturday or Sunday date where it is under the Tuesday and Thursday rule", () => { + const rule = { day: 30, month: 11, tuesdayToMondayThursdayToFriday: true }; + + for (const year of [2015, 2022, 2029, 2024, 2025]) { + expect(resolveStateHolidayDate(year, rule)).toEqual(new Date(year, 10, 30)); + } + }); + test("should move an Easter derived date landing Monday to Friday on to the following Sunday", () => { expect( resolveStateHolidayDate(2024, { easterOffset: 60, nextSundayWhenWeekday: true }), diff --git a/src/_internals/resolve-state-holiday-date/resolve-state-holiday-date.ts b/src/_internals/resolve-state-holiday-date/resolve-state-holiday-date.ts index 460c0397b..970a2c0b1 100644 --- a/src/_internals/resolve-state-holiday-date/resolve-state-holiday-date.ts +++ b/src/_internals/resolve-state-holiday-date/resolve-state-holiday-date.ts @@ -1,7 +1,7 @@ /** How a holiday's date is defined: a fixed day and month, or an offset in days from Easter Sunday. */ export type HolidayDateRule = ( | { - /** Offset in days from Easter Sunday (Carnaval is -47, Corpus Christi is 60); Easter itself is 0. */ + /** Offset in days from Easter Sunday (Carnaval Monday is -48 and Tuesday -47, Corpus Christi is 60); Easter itself is 0. */ easterOffset: number; } | { @@ -16,9 +16,17 @@ export type HolidayDateRule = ( * falls on a weekday (Monday to Friday), as Santa Catarina's two state holidays do. */ nextSundayWhenWeekday?: boolean; + /** + * Whether the holiday is observed on the Monday before when the date the rule resolves to falls + * on a Tuesday, and on the Friday after when it falls on a Thursday, as Alagoas' 30 November + * does. + */ + tuesdayToMondayThursdayToFriday?: boolean; }; const SUNDAY = 0; +const TUESDAY = 2; +const THURSDAY = 4; const SATURDAY = 6; const DAYS_IN_WEEK = 7; @@ -60,10 +68,23 @@ function moveToNextSundayWhenWeekday(date: Date): Date { return observed; } +function moveTuesdayToMondayThursdayToFriday(date: Date): Date { + const weekday = date.getDay(); + + if (weekday !== TUESDAY && weekday !== THURSDAY) return date; + + const observed = new Date(date); + observed.setDate(date.getDate() + (weekday === TUESDAY ? -1 : 1)); + + return observed; +} + /** * Resolves the date of a holiday in a given year: a fixed `day`/`month` pair, or an offset in * days from Easter Sunday, computed with the Meeus/Jones/Butcher algorithm. When the rule sets - * `nextSundayWhenWeekday`, a date landing on a weekday is moved on to the following Sunday. + * `nextSundayWhenWeekday`, a date landing on a weekday is moved on to the following Sunday; when + * it sets `tuesdayToMondayThursdayToFriday`, a Tuesday is moved back to the Monday and a Thursday + * on to the Friday. * * @param {number} year - The four digit year. * @param {HolidayDateRule} rule - The fixed date or the Easter offset of the holiday. @@ -76,6 +97,7 @@ function moveToNextSundayWhenWeekday(date: Date): Date { * resolveStateHolidayDate(2024, { easterOffset: 60 }); // 2024-05-30 (Corpus Christi) * resolveStateHolidayDate(2024, { day: 9, month: 7 }); // 2024-07-09 * resolveStateHolidayDate(2025, { day: 11, month: 8, nextSundayWhenWeekday: true }); // 2025-08-17 + * resolveStateHolidayDate(2027, { day: 30, month: 11, tuesdayToMondayThursdayToFriday: true }); // 2027-11-29 * ``` * * @see Based on: https://en.wikipedia.org/wiki/Date_of_Easter#Anonymous_Gregorian_algorithm @@ -86,5 +108,9 @@ export const resolveStateHolidayDate = (year: number, rule: HolidayDateRule): Da ? calculateHolidayFromEaster(year, rule.easterOffset) : new Date(year, rule.month - 1, rule.day); - return rule.nextSundayWhenWeekday === true ? moveToNextSundayWhenWeekday(date) : date; + if (rule.nextSundayWhenWeekday === true) return moveToNextSundayWhenWeekday(date); + + return rule.tuesdayToMondayThursdayToFriday === true + ? moveTuesdayToMondayThursdayToFriday(date) + : date; }; diff --git a/src/_internals/sanitize-nfe-key/sanitize-nfe-key.test.ts b/src/_internals/sanitize-nfe-key/sanitize-nfe-key.test.ts new file mode 100644 index 000000000..0e4577bb1 --- /dev/null +++ b/src/_internals/sanitize-nfe-key/sanitize-nfe-key.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, expectTypeOf, test } from "../test/runtime"; +import { sanitizeNfeKey } from "./sanitize-nfe-key"; + +describe("sanitizeNfeKey", () => { + test("should keep the digits of a numeric key", () => { + expect(sanitizeNfeKey("3517 0458 7165 2300 0119")).toBe("35170458716523000119"); + }); + + test("should keep the upper cased letters of an alphanumeric CNPJ in positions 7 to 18", () => { + expect(sanitizeNfeKey("3526 0712 abc3 4501 DE35")).toBe("35260712ABC34501DE35"); + }); + + test("should keep a letter in position 7 and in position 18, the two ends of the CNPJ root and order", () => { + expect(sanitizeNfeKey("352607A2ABC34501DZ35")).toBe("352607A2ABC34501DZ35"); + }); + + test("should drop a letter in positions 1 to 6", () => { + expect(sanitizeNfeKey("NFe35260A")).toBe("35260"); + }); + + test("should drop a letter in positions 19 and 20, the CNPJ check digits, and after them", () => { + expect(sanitizeNfeKey("35260712ABC34501DEAB35X55")).toBe("35260712ABC34501DE3555"); + }); + + test("should read a number as its digits", () => { + expect(sanitizeNfeKey(3_526_071_234)).toBe("3526071234"); + }); + + test("types", () => { + expectTypeOf(sanitizeNfeKey).parameter(0).toEqualTypeOf(); + expectTypeOf(sanitizeNfeKey).returns.toEqualTypeOf(); + }); +}); diff --git a/src/_internals/sanitize-nfe-key/sanitize-nfe-key.ts b/src/_internals/sanitize-nfe-key/sanitize-nfe-key.ts new file mode 100644 index 000000000..15946c55d --- /dev/null +++ b/src/_internals/sanitize-nfe-key/sanitize-nfe-key.ts @@ -0,0 +1,35 @@ +import { NFE_KEY_ALPHANUMERIC_END, NFE_KEY_ALPHANUMERIC_START } from "../constants/nfe-key"; +import { sanitizeToAlphanumeric } from "../sanitize-to-alphanumeric/sanitize-to-alphanumeric"; + +/** + * Sanitizes a DF-e access key (chave de acesso) to the characters it is written with: digits + * everywhere, and the upper cased letters of an alphanumeric CNPJ in positions 7 to 18, the only + * ones `TChNFe` (`[0-9]{6}[0-9A-Z]{12}[0-9]{26}`) opens to letters. A letter that falls anywhere + * else is dropped like any other character outside the key, so the positions are counted on the + * characters kept. The result is not capped: the caller cuts it to the 44 characters of a key. + * + * Shared by `formatNfeKey` and `parseNfeKey`, which read a value the very same way. + * + * @param {string|number} value - The access key value to sanitize. + * @returns {string} The sanitized value. + * + * @example + * ```typescript + * sanitizeNfeKey("3526 0712 abc3 4501 DE35"); // "35260712ABC34501DE35" + * sanitizeNfeKey("NFe352607"); // "352607", the letters before position 7 are dropped + * ``` + */ +export const sanitizeNfeKey = (value: string | number): string => { + let key = ""; + + for (const character of sanitizeToAlphanumeric(value)) { + if ( + /\d/.test(character) || + (key.length >= NFE_KEY_ALPHANUMERIC_START && key.length < NFE_KEY_ALPHANUMERIC_END) + ) { + key += character; + } + } + + return key; +}; diff --git a/src/_internals/test/arbitraries.ts b/src/_internals/test/arbitraries.ts index fcbc71d20..c3dcb8e37 100644 --- a/src/_internals/test/arbitraries.ts +++ b/src/_internals/test/arbitraries.ts @@ -101,34 +101,34 @@ export const digitsOfOtherLength = (maxLength: number, lengths: number[]): fc.Ar digitsUpTo(maxLength).filter((value) => !lengths.includes(value.length)); /** - * @param {string[]} maskChars The characters a separator is built from. + * @param {string[]} maskCharacters The characters a separator is built from. * @param {number} count How many separators the generated array holds. * @param {number} maxLength The largest length of a single separator. * @returns {fc.Arbitrary} Arrays of exactly `count` separators. */ export const maskSeparators = ( - maskChars: string[], + maskCharacters: string[], count: number, maxLength: number, ): fc.Arbitrary => - fc.array(fc.string({ unit: fc.constantFrom(...maskChars), maxLength }), { + fc.array(fc.string({ unit: fc.constantFrom(...maskCharacters), maxLength }), { minLength: count, maxLength: count, }); /** * @param {fc.Arbitrary} source The values to spread the mask over. - * @param {string[]} maskChars The characters a separator is built from. + * @param {string[]} maskCharacters The characters a separator is built from. * @param {number} maxLength The largest length of a single separator. * @returns {fc.Arbitrary} Values of `source` with a separator around every character. */ export const maskedValues = ( source: fc.Arbitrary, - maskChars: string[], + maskCharacters: string[], maxLength: number, ): fc.Arbitrary => source.chain((value) => - maskSeparators(maskChars, value.length + 1, maxLength).map((separators) => + maskSeparators(maskCharacters, value.length + 1, maxLength).map((separators) => interleave(value, separators), ), ); @@ -365,7 +365,13 @@ export const boletos = (type?: "bancario" | "arrecadacao"): fc.Arbitrary }) .map((parts) => assembleBoletoArrecadacao(parts)) : fc - .record({ field1: digits(9), field2: digits(10), field3: digits(10), tail: digits(15) }) + .record({ + // Bank code, the código de moeda 9 (real) and the start of the free field. + field1: fc.tuple(digits(3), digits(5)).map(([bank, free]) => `${bank}9${free}`), + field2: digits(10), + field3: digits(10), + tail: digits(15), + }) .map((parts) => assembleBoletoBancario(parts)); /** Arbitraries of valid phone numbers, built the same way as the documents. */ diff --git a/src/_internals/test/properties.ts b/src/_internals/test/properties.ts index e40050024..7dad576f0 100644 --- a/src/_internals/test/properties.ts +++ b/src/_internals/test/properties.ts @@ -6,172 +6,172 @@ type UnknownInputFunction = (value: never) => unknown; /** * Asserts the util never throws, whatever the arbitrary produces. - * @param {UnknownInputFunction} fn The util under test. + * @param {UnknownInputFunction} utility The utility under test. * @param {fc.Arbitrary} arbitrary The values to feed it. * @returns {void} Nothing. */ export const expectNeverThrows = ( - fn: UnknownInputFunction, + utility: UnknownInputFunction, arbitrary: fc.Arbitrary, ): void => { fc.assert( fc.property(arbitrary, (value) => { - expect(() => fn(value as never)).not.toThrow(); + expect(() => utility(value as never)).not.toThrow(); }), ); }; /** * Asserts the util never throws for any pair of value and options the arbitraries produce. - * @param {Function} fn The util under test. + * @param {Function} utility The utility under test. * @param {fc.Arbitrary} values The values to feed it. * @param {fc.Arbitrary} options The options to feed it. * @returns {void} Nothing. */ export const expectNeverThrowsWithOptions = ( - fn: (value: never, options: never) => unknown, + utility: (value: never, options: never) => unknown, values: fc.Arbitrary, options: fc.Arbitrary, ): void => { fc.assert( fc.property(values, options, (value, currentOptions) => { - expect(() => fn(value as never, currentOptions as never)).not.toThrow(); + expect(() => utility(value as never, currentOptions as never)).not.toThrow(); }), ); }; /** * Asserts the util never throws for any argument list the arbitrary produces. - * @param {Function} fn The util under test. + * @param {Function} utility The utility under test. * @param {fc.Arbitrary} argumentLists The argument lists to spread into it. * @returns {void} Nothing. */ export const expectNeverThrowsWithArguments = ( - fn: (...args: never[]) => unknown, + utility: (...args: never[]) => unknown, argumentLists: fc.Arbitrary, ): void => { fc.assert( fc.property(argumentLists, (values) => { - expect(() => fn(...(values as never[]))).not.toThrow(); + expect(() => utility(...(values as never[]))).not.toThrow(); }), ); }; /** * Asserts the util always returns a value of `expectedType`, whatever the arbitrary produces. - * @param {UnknownInputFunction} fn The util under test. + * @param {UnknownInputFunction} utility The utility under test. * @param {string} expectedType The `typeof` the util is expected to return. * @param {fc.Arbitrary} arbitrary The values to feed it. * @returns {void} Nothing. */ export const expectAlwaysReturnsType = ( - fn: UnknownInputFunction, + utility: UnknownInputFunction, expectedType: "boolean" | "number" | "string", arbitrary: fc.Arbitrary, ): void => { fc.assert( fc.property(arbitrary, (value) => { - expect(typeof fn(value as never)).toBe(expectedType); + expect(typeof utility(value as never)).toBe(expectedType); }), ); }; /** * Asserts the validator answers `verdict` for every value the arbitrary produces. - * @param {Function} fn The validator under test. + * @param {Function} utility The validator under test. * @param {fc.Arbitrary} arbitrary The values to feed it. * @param {boolean} verdict The answer it must give. * @returns {void} Nothing. */ const expectVerdict = ( - fn: (value: string) => boolean, + utility: (value: string) => boolean, arbitrary: fc.Arbitrary, verdict: boolean, ): void => { fc.assert( fc.property(arbitrary, (value) => { - expect(fn(value)).toBe(verdict); + expect(utility(value)).toBe(verdict); }), ); }; /** * Asserts the validator accepts every value the arbitrary produces. - * @param {Function} fn The validator under test. + * @param {Function} utility The validator under test. * @param {fc.Arbitrary} arbitrary The values it must accept. * @returns {void} Nothing. */ export const expectAccepted = ( - fn: (value: string) => boolean, + utility: (value: string) => boolean, arbitrary: fc.Arbitrary, ): void => { - expectVerdict(fn, arbitrary, true); + expectVerdict(utility, arbitrary, true); }; /** * Asserts the validator rejects every value the arbitrary produces. - * @param {Function} fn The validator under test. + * @param {Function} utility The validator under test. * @param {fc.Arbitrary} arbitrary The values it must reject. * @returns {void} Nothing. */ export const expectRejected = ( - fn: (value: string) => boolean, + utility: (value: string) => boolean, arbitrary: fc.Arbitrary, ): void => { - expectVerdict(fn, arbitrary, false); + expectVerdict(utility, arbitrary, false); }; /** * Asserts the util's output always matches `pattern`. - * @param {Function} fn The util under test. + * @param {Function} utility The utility under test. * @param {RegExp} pattern The shape the output must have. * @param {fc.Arbitrary} arbitrary The values to feed it. * @returns {void} Nothing. */ export const expectMatchesPattern = ( - fn: (value: string) => string, + utility: (value: string) => string, pattern: RegExp, arbitrary: fc.Arbitrary, ): void => { fc.assert( fc.property(arbitrary, (value) => { - expect(fn(value)).toMatch(pattern); + expect(utility(value)).toMatch(pattern); }), ); }; /** * Asserts applying the util twice gives the same result as applying it once. - * @param {Function} fn The util under test. + * @param {Function} utility The utility under test. * @param {fc.Arbitrary} arbitrary The values to feed it. * @returns {void} Nothing. */ export const expectIdempotent = ( - fn: (value: string) => string, + utility: (value: string) => string, arbitrary: fc.Arbitrary, ): void => { fc.assert( fc.property(arbitrary, (value) => { - const once = fn(value); + const once = utility(value); - expect(fn(once)).toBe(once); + expect(utility(once)).toBe(once); }), ); }; /** * Asserts the util treats an uppercased value exactly like the value it was given. - * @param {Function} fn The util under test. + * @param {Function} utility The utility under test. * @param {fc.Arbitrary} arbitrary The values to feed it. * @returns {void} Nothing. */ export const expectCaseInsensitive = ( - fn: (value: string) => string, + utility: (value: string) => string, arbitrary: fc.Arbitrary, ): void => { fc.assert( fc.property(arbitrary, (value) => { - expect(fn(value.toUpperCase())).toBe(fn(value)); + expect(utility(value.toUpperCase())).toBe(utility(value)); }), ); }; diff --git a/src/_internals/test/runtime-deno.ts b/src/_internals/test/runtime-deno.ts index 33aedb0ef..66f0628da 100644 --- a/src/_internals/test/runtime-deno.ts +++ b/src/_internals/test/runtime-deno.ts @@ -171,7 +171,7 @@ function createMock(implementation?: MockImplementation): MockFunction { const calls: unknown[][] = []; let currentImplementation = implementation; - const baseFn = (...args: unknown[]): unknown => { + const baseFunction = (...args: unknown[]): unknown => { calls.push(args); if (queue.length > 0) { @@ -189,7 +189,7 @@ function createMock(implementation?: MockImplementation): MockFunction { return undefined; }; - const mockFn: MockFunction = Object.assign(baseFn, { + const mockFunction: MockFunction = Object.assign(baseFunction, { mock: { calls }, mockClear: (): void => { calls.length = 0; @@ -202,7 +202,7 @@ function createMock(implementation?: MockImplementation): MockFunction { mockResolvedValueOnce: (value: unknown): MockFunction => { queue.push(() => Promise.resolve(value)); - return mockFn; + return mockFunction; }, mockRejectedValueOnce: (value: unknown): MockFunction => { queue.push(async () => { @@ -211,12 +211,12 @@ function createMock(implementation?: MockImplementation): MockFunction { throw value; }); - return mockFn; + return mockFunction; }, mockResolvedValue: (value: unknown): MockFunction => { currentImplementation = (): Promise => Promise.resolve(value); - return mockFn; + return mockFunction; }, mockRejectedValue: (value: unknown): MockFunction => { currentImplementation = async (): Promise => { @@ -225,18 +225,18 @@ function createMock(implementation?: MockImplementation): MockFunction { throw value; }; - return mockFn; + return mockFunction; }, mockImplementation: (nextImplementation: MockImplementation): MockFunction => { currentImplementation = nextImplementation; - return mockFn; + return mockFunction; }, }); - registeredMocks.add(mockFn); + registeredMocks.add(mockFunction); - return mockFn; + return mockFunction; } type ThrowExpectation = RegExp | string | Error | (new (...args: any[]) => unknown); @@ -634,8 +634,8 @@ export const expect = createExpect; export const vi = { fn: createMock, restoreAllMocks: (): void => { - for (const mockFn of registeredMocks) { - mockFn.mockReset(); + for (const mockFunction of registeredMocks) { + mockFunction.mockReset(); } }, }; diff --git a/src/add-business-days/add-business-days.test.ts b/src/add-business-days/add-business-days.test.ts index 64b387010..655ac9db0 100644 --- a/src/add-business-days/add-business-days.test.ts +++ b/src/add-business-days/add-business-days.test.ts @@ -91,6 +91,13 @@ describe("addBusinessDays", () => { expect(result).toEqual(new Date(2024, 1, 14, 12)); }); + it("should skip Carnaval Monday 2024-02-12 and Tuesday 2024-02-13 by default, walking from Fri 2024-02-09 to Wed 2024-02-14", () => { + expect(addBusinessDays(new Date(2024, 1, 9, 12), 1)).toEqual(new Date(2024, 1, 14, 12)); + expect(addBusinessDays(new Date(2024, 1, 9, 12), 1, { includeOptional: false })).toEqual( + new Date(2024, 1, 12, 12), + ); + }); + it("should count Carnaval 2024-02-13 as a business day when includeOptional is false", () => { const result = addBusinessDays(new Date(2024, 1, 12, 12), 1, { includeOptional: false }); @@ -294,8 +301,8 @@ describe("addBusinessDays", () => { expect(addBusinessDays(new Date(2024, 0, 0), 1)).toEqual(new Date(2024, 0, 2)); }); - it("should skip Carnaval for the 10th business day of February 2024 (Thu 2024-02-15), and count it when includeOptional is false (Wed 2024-02-14)", () => { - expect(addBusinessDays(new Date(2024, 1, 0), 10)).toEqual(new Date(2024, 1, 15)); + it("should skip both Carnaval days for the 10th business day of February 2024 (Fri 2024-02-16), and count them when includeOptional is false (Wed 2024-02-14)", () => { + expect(addBusinessDays(new Date(2024, 1, 0), 10)).toEqual(new Date(2024, 1, 16)); expect(addBusinessDays(new Date(2024, 1, 0), 10, { includeOptional: false })).toEqual( new Date(2024, 1, 14), ); diff --git a/src/add-business-days/add-business-days.ts b/src/add-business-days/add-business-days.ts index 57f9c3a1b..c21b8e020 100644 --- a/src/add-business-days/add-business-days.ts +++ b/src/add-business-days/add-business-days.ts @@ -29,7 +29,7 @@ export type { BusinessDayOptions } from "../is-business-day/is-business-day"; * not have, such as `00:30` on a day whose clocks jump from `00:00` to `01:00`: the result is * then the nearest instant of that day, `01:30`. * - * `options.includeSaturday` defaults to `false`, the Monday to Friday banking count. Pass `true` + * `options.includeSaturday` defaults to `false`, a Monday to Friday count. Pass `true` * for the labour law count of Instrução Normativa MTP nº 2/2021, art. 14, I, which includes * Saturday and still excludes Sunday and holidays, so a holiday that falls on a Saturday is never * counted. See `isBusinessDay` for the law behind it and for what it does not cover: municipal diff --git a/src/capitalize/capitalize.test.ts b/src/capitalize/capitalize.test.ts index e9b83356e..2e03609a3 100644 --- a/src/capitalize/capitalize.test.ts +++ b/src/capitalize/capitalize.test.ts @@ -78,6 +78,24 @@ describe("capitalize", () => { expect(capitalize("são paulo/sp")).toBe("São Paulo/SP"); }); + test("when a state code ends the value after a spaced hyphen, an en dash or a comma, the Correios' 'Cidade – UF'", () => { + expect(capitalize("brasília - df")).toBe("Brasília - DF"); + expect(capitalize("são paulo – sp")).toBe("São Paulo – SP"); + expect(capitalize("curitiba, pr")).toBe("Curitiba, PR"); + expect(capitalize("rua x, 10, centro, recife - pe")).toBe("Rua X, 10, Centro, Recife - PE"); + expect(capitalize("BRASÍLIA - DF", { upperCaseWords: [] })).toBe("Brasília - DF"); + }); + + test("when a state code after those separators is not the last word, or the separator is not spaced, it is an ordinary word", () => { + expect(capitalize("rs - centro")).toBe("Rs - Centro"); + expect(capitalize("brasília - df sul")).toBe("Brasília - Df Sul"); + expect(capitalize("brasília-df")).toBe("Brasília-Df"); + expect(capitalize("brasília -df")).toBe("Brasília -Df"); + expect(capitalize("brasília,df")).toBe("Brasília,Df"); + expect(capitalize("brasília - br")).toBe("Brasília - Br"); + expect(capitalize("- df")).toBe("- Df"); + }); + test("when a word is bound by an apostrophe or by punctuation", () => { expect(capitalize("santa bárbara d'oeste")).toBe("Santa Bárbara d'Oeste"); expect(capitalize("SANTA BÁRBARA D'OESTE")).toBe("Santa Bárbara d'Oeste"); @@ -166,6 +184,17 @@ describe("capitalize", () => { expect(capitalize("joão paulo ii")).toBe("João Paulo II"); expect(capitalize("rua xv de novembro")).toBe("Rua XV de Novembro"); expect(capitalize("avenida papa joão xxiii")).toBe("Avenida Papa João XXIII"); + expect(capitalize("rua xxiv de maio")).toBe("Rua XXIV de Maio"); + expect(capitalize("rua xxv de março")).toBe("Rua XXV de Março"); + expect(capitalize("praça xxix de junho")).toBe("Praça XXIX de Junho"); + expect(capitalize("rua xxxi de março")).toBe("Rua XXXI de Março"); + expect(capitalize("capítulo xxxix")).toBe("Capítulo XXXIX"); + }); + + test("when a roman numeral is past XXXIX, uses a letter other than I, V and X, or is VI, it is an ordinary word", () => { + expect(capitalize("capítulo xl")).toBe("Capítulo Xl"); + expect(capitalize("eu li o livro")).toBe("Eu Li o Livro"); + expect(capitalize("eu vi maria")).toBe("Eu Vi Maria"); }); test("when a word list given in the options replaces the default one", () => { diff --git a/src/capitalize/capitalize.ts b/src/capitalize/capitalize.ts index f0ab58383..b3578b03b 100644 --- a/src/capitalize/capitalize.ts +++ b/src/capitalize/capitalize.ts @@ -9,6 +9,7 @@ import { PUNCTUATION_REGEX, SEPARATOR_REGEX, TRAILING_DESIGNATIONS, + UF_SEPARATORS, UPPER_CASE_WORDS, WHITESPACE_REGEX, WORD_REGEX, @@ -144,6 +145,27 @@ const isUpperCasePosition = ( return ahead.joined && companyDesignationSet.has(designation) && upperCaseSet.has(designation); }; +/** + * Whether a state code stands where it is the Federative Unit of an address: right after a `/` + * wherever it appears (`"Porto Alegre/RS"`), or as the last word of the value right after one of + * `UF_SEPARATORS` (`"Brasília - DF"`, `"São Paulo – SP"`, `"Curitiba, PR"`). + * + * @param {string[]} output - What has been written so far. + * @param {{ joined: boolean; designation: string }} ahead - What follows the word (`designation` is `""` when it is the last one). + * @returns {boolean} `true` when a state code there is the UF. + */ +const isStateCodePosition = ( + output: string[], + ahead: { joined: boolean; designation: string }, +): boolean => { + if (output.at(-1) === "/") return true; + if (ahead.designation !== "") return false; + + const written = output.join(""); + + return UF_SEPARATORS.some((separator) => written.endsWith(separator)); +}; + /** * A word with its first letter in upper case and the rest in lower case, letter by code point. A * first letter whose upper case is more than one character (`ß` becomes `SS`, the `fi` ligature @@ -194,8 +216,9 @@ const capitalizeWord = (word: string): string => { * default list is the company designations and document abbreviations that are written in upper * case in Brazilian usage (`LTDA`, `S.A.`, `S/A`, `S.S.`, `S/S`, `ME`, `EPP`, `MEI`, `EIRELI`, * `CIA`, `SCP`, `CNPJ`, `CPF`, `RG`, `CEP`, `UF`) plus the roman numerals that appear in names - * and addresses (`II` through `XXIII`, except `VI`, so `"joão paulo ii"` becomes - * `"João Paulo II"` and `"rua xv de novembro"` becomes `"Rua XV de Novembro"`). `ME` is also + * and addresses (`II` through `XXXIX`, the numerals written with `I`, `V` and `X` alone, except + * `VI`, so `"joão paulo ii"` becomes `"João Paulo II"`, `"rua xv de novembro"` becomes `"Rua XV + * de Novembro"` and `"rua xxiv de maio"` becomes `"Rua XXIV de Maio"`). `ME` is also * the pt-BR pronoun "me", so it is only upper cased in the designation position, as the last * word of the value (`"fulano comércio me"` becomes `"Fulano Comércio ME"`) or right before * another designation (`"fulano me epp"` becomes `"Fulano ME EPP"`); anywhere else it is an @@ -207,7 +230,12 @@ const capitalizeWord = (word: string): string => { * Brazilian state, the way a municipality and its Federative Unit are written together, so * `"porto alegre/rs"` becomes `"Porto Alegre/RS"` while `"santana/br"` becomes `"Santana/Br"`. * A state code that does not follow a `/` is left alone (`"santana rs"` becomes - * `"Santana Rs"`), and so is any other two letter word. + * `"Santana Rs"`), and so is any other two letter word. As the last word of the value, a state + * code is also converted after a spaced hyphen or en dash or after a comma, the "Cidade – UF" of + * the Correios' addressing guide, so `"brasília - df"` becomes `"Brasília - DF"`, `"são paulo – + * sp"` becomes `"São Paulo – SP"` and `"curitiba, pr"` becomes `"Curitiba, PR"`; anywhere else + * after those separators the two letters are an ordinary word (`"rs - centro"` stays + * `"Rs - Centro"`). * - All other words are capitalized (first letter upper case, rest lower case), letter by letter: * `"İSTANBUL"` becomes `"İstanbul"`, and a first letter whose upper case is two letters (`ß`, * the `fi` ligature) keeps its case, so `"straße"` becomes `"Straße"` and `"ßa"` stays `"ßa"`. @@ -249,7 +277,9 @@ const capitalizeWord = (word: string): string => { * capitalize("casa de carnes s/a"); // "Casa de Carnes S/A" * capitalize("MOGI-GUAÇU"); // "Mogi-Guaçu" * capitalize("santana/rs"); // "Santana/RS" + * capitalize("brasília - df"); // "Brasília - DF" * capitalize("rua xv de novembro"); // "Rua XV de Novembro" + * capitalize("rua xxiv de maio"); // "Rua XXIV de Maio" * capitalize("empresa ltda", { upperCaseWords: [] }); // "Empresa Ltda" * capitalize("joao\tsilva"); // "Joao Silva" * ``` @@ -306,7 +336,7 @@ export const capitalize = (value: string, options?: CapitalizeOptions): string = isUpperCasePosition(upperCaseWord, enclitic, ahead, upperCaseSet) ) { output.push(upperCaseWord); - } else if (output.at(-1) === "/" && stateCodeSet.has(upperCaseWord)) { + } else if (stateCodeSet.has(upperCaseWord) && isStateCodePosition(output, ahead)) { output.push(upperCaseWord); } else { output.push(capitalizeWord(token)); diff --git a/src/capitalize/constants.ts b/src/capitalize/constants.ts index 32b00204c..a673fdc38 100644 --- a/src/capitalize/constants.ts +++ b/src/capitalize/constants.ts @@ -92,9 +92,13 @@ const DOCUMENT_ABBREVIATIONS = ["CEP", "CNPJ", "CPF", "RG", "UF"]; /** * Roman numerals that appear inside Brazilian names and addresses ("João Paulo II", "Rua XV de - * Novembro", "Avenida Papa João XXIII"). The single letter numerals (V, X, L, C, D, M) are left - * out because a single letter is already written in upper case by the default rule, and VI is - * left out because it collides with the pt-BR verb form "vi". + * Novembro", "Avenida Papa João XXIII", "Rua XXIV de Maio", "Rua XXV de Março"): every numeral + * from II to XXXIX, the ones written with I, V and X alone. The bound stops before XL because + * from there the numerals take L, C, D and M, which spell pt-BR words and abbreviations ("li", + * "xl", "cd", "mil", "dc"), and the dates and ordinals of street names stay below 40. The single + * letter numerals (V, X, L, C, D, M) are left out because a single letter is already written in + * upper case by the default rule, and VI is left out because it collides with the pt-BR verb form + * "vi". */ const ROMAN_NUMERALS = [ "II", @@ -116,6 +120,22 @@ const ROMAN_NUMERALS = [ "XXI", "XXII", "XXIII", + "XXIV", + "XXV", + "XXVI", + "XXVII", + "XXVIII", + "XXIX", + "XXX", + "XXXI", + "XXXII", + "XXXIII", + "XXXIV", + "XXXV", + "XXXVI", + "XXXVII", + "XXXVIII", + "XXXIX", ]; /** Words that are written in upper case wherever they appear, the default `upperCaseWords`. */ @@ -153,6 +173,22 @@ export const WORD_REGEX = /[^\s/'’‘(){}[\]"“”:;,-]/; */ export const JOINER_REGEX = /^(?:\s+|[-/'’‘])$/; +/** + * The separators a municipality is written with before its Federative Unit at the end of an + * address line, besides the `/` of `"Porto Alegre/RS"`: the spaced hyphen or en dash of the + * Correios' "Cidade – UF" (`"São Paulo – SP"`) and the comma of `"Curitiba, PR"`. A state code + * right after one of them is upper case only as the last word of the value, where it can only be + * the UF; elsewhere the same two letters may be an ordinary word. + * + * @see Official: https://www.correios.com.br/enviar/correspondencia/arquivos/nacional/guia-tecnico-de-enderecamento-de-correspondencias.pdf + * Correios, Endereçamento de Correspondências, Guia Técnico, versão 1.4 (03/05/2021): the + * locality line carries the name of the locality and the sigla of the UF, separated by a hyphen or + * dash, "São Paulo – SP". + * @see Official: https://www.correios.com.br/enviar/precisa-de-ajuda/guia-de-enderecamento/guia-de-enderecamento + * The Correios page that publishes the guide. + */ +export const UF_SEPARATORS = [" - ", " – ", ", "]; + /** The apostrophe that elides the particle of `d'Oeste` and marks the English possessive of `Bob's`. */ export const APOSTROPHE_REGEX = /^['’‘]$/; diff --git a/src/difference-in-business-days/difference-in-business-days.test.ts b/src/difference-in-business-days/difference-in-business-days.test.ts index c5cd34fe0..af16e8b58 100644 --- a/src/difference-in-business-days/difference-in-business-days.test.ts +++ b/src/difference-in-business-days/difference-in-business-days.test.ts @@ -12,10 +12,10 @@ import { type BusinessDayOptions, isBusinessDay } from "../is-business-day/is-bu import { differenceInBusinessDays } from "./difference-in-business-days"; describe("differenceInBusinessDays", () => { - it("should match the date-fns differenceInBusinessDays example (2014-07-20 minus 2014-01-10 is 136 weekdays, https://date-fns.org/docs/differenceInBusinessDays) minus the 5 Brazilian holidays that fall on a weekday in between (Carnaval, Sexta-feira Santa, Tiradentes, Dia do trabalhador and Corpus Christi)", () => { + it("should match the date-fns differenceInBusinessDays example (2014-07-20 minus 2014-01-10 is 136 weekdays, https://date-fns.org/docs/differenceInBusinessDays) minus the 6 Brazilian holidays that fall on a weekday in between (Carnaval Monday 2014-03-03 and Tuesday 2014-03-04, Sexta-feira Santa, Tiradentes, Dia do trabalhador and Corpus Christi)", () => { const result = differenceInBusinessDays(new Date(2014, 6, 20), new Date(2014, 0, 10)); - expect(result).toBe(131); + expect(result).toBe(130); }); it("should return 0 for the same calendar day", () => { @@ -87,11 +87,12 @@ describe("differenceInBusinessDays", () => { }); describe("includeOptional", () => { - it("should skip Carnaval 2024-02-13 by default (includeOptional defaults to true)", () => { - expect(differenceInBusinessDays(new Date(2024, 1, 14), new Date(2024, 1, 12))).toBe(1); + it("should skip both Carnaval days, Mon 2024-02-12 and Tue 2024-02-13, by default (includeOptional defaults to true)", () => { + expect(differenceInBusinessDays(new Date(2024, 1, 14), new Date(2024, 1, 12))).toBe(0); + expect(differenceInBusinessDays(new Date(2024, 1, 14), new Date(2024, 1, 9))).toBe(1); }); - it("should count Carnaval 2024-02-13 as a business day when includeOptional is false", () => { + it("should count both Carnaval days, Mon 2024-02-12 and Tue 2024-02-13, as business days when includeOptional is false", () => { const result = differenceInBusinessDays(new Date(2024, 1, 14), new Date(2024, 1, 12), { includeOptional: false, }); diff --git a/src/difference-in-business-days/difference-in-business-days.ts b/src/difference-in-business-days/difference-in-business-days.ts index 383c3034e..a42d37f64 100644 --- a/src/difference-in-business-days/difference-in-business-days.ts +++ b/src/difference-in-business-days/difference-in-business-days.ts @@ -25,7 +25,7 @@ const toLocalDayTimestamp = (date: Date): number => * A business day is a day for which `isBusinessDay` returns `true` (not a Saturday, a Sunday, * or a Brazilian holiday; `options.includeSaturday` keeps Saturday), evaluated with the same `options`. * - * `options.includeSaturday` defaults to `false`, the Monday to Friday banking count. Pass `true` + * `options.includeSaturday` defaults to `false`, a Monday to Friday count. Pass `true` * for the labour law count of Instrução Normativa MTP nº 2/2021, art. 14, I, which includes * Saturday and still excludes Sunday and holidays, so a holiday that falls on a Saturday is never * counted. See `isBusinessDay` for the law behind it and for what it does not cover: municipal @@ -55,7 +55,7 @@ const toLocalDayTimestamp = (date: Date): number => * differenceInBusinessDays(new Date(2024, 0, 3), new Date(2024, 0, 2)); // 1 (Jan 2 counted, a Tuesday; Jan 3 is not) * differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 3)); // -1 (the later date comes first, so the count is negative) * differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 2)); // 0 (same day) - * differenceInBusinessDays(new Date(2024, 0, 8), new Date(2024, 0, 1)); // 4 (Jan 2 to Jan 5, banking count) + * differenceInBusinessDays(new Date(2024, 0, 8), new Date(2024, 0, 1)); // 4 (Jan 2 to Jan 5, Monday to Friday count) * differenceInBusinessDays(new Date(2024, 0, 8), new Date(2024, 0, 1), { includeSaturday: true }); // 5 (Jan 6, a Saturday, also counts) * differenceInBusinessDays(new Date(2024, 10, 4), new Date(2024, 10, 1), { includeSaturday: true }); // 1 (Nov 2 is Finados, a holiday on a Saturday) * differenceInBusinessDays(new Date(2024, 6, 10), new Date(2024, 6, 8), { stateCode: "SP" }); // 1 (Jul 9 is a state holiday in SP) diff --git a/src/format-boleto/format-boleto.test.ts b/src/format-boleto/format-boleto.test.ts index b1872d831..2487ca5d3 100644 --- a/src/format-boleto/format-boleto.test.ts +++ b/src/format-boleto/format-boleto.test.ts @@ -211,6 +211,18 @@ describe("formatBoleto", () => { ); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(formatBoleto(1e21)).toBe(""); + expect(formatBoleto(-1)).toBe(""); + expect(formatBoleto(1.5)).toBe(""); + expect(formatBoleto(2 ** 53)).toBe(""); + expect(formatBoleto(Number.MAX_VALUE)).toBe(""); + expect(formatBoleto(Number.NaN)).toBe(""); + expect(formatBoleto(Number.POSITIVE_INFINITY)).toBe(""); + expect(formatBoleto(Number.NEGATIVE_INFINITY)).toBe(""); + expect(formatBoleto(1e21, { pad: true })).toBe(""); + }); }); describe("formatBoleto with a nullish value under pad", () => { diff --git a/src/format-boleto/format-boleto.ts b/src/format-boleto/format-boleto.ts index 3be6ad91b..291ad4777 100644 --- a/src/format-boleto/format-boleto.ts +++ b/src/format-boleto/format-boleto.ts @@ -1,6 +1,6 @@ import { ARRECADACAO_LINE_LENGTH, ARRECADACAO_PRODUCT } from "../_internals/constants/arrecadacao"; import { format } from "../_internals/format/format"; -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"; import { ARRECADACAO_PATTERN, BANCARIO_PATTERN } from "./constants"; @@ -19,6 +19,9 @@ export type FormatBoletoOptions = { * *barcode* has no display grouping defined by FEBRABAN (§04 describes positions, not a * printed form), so it keeps the published "cobrança bancária" grouping. * + * 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 value to be formatted, either as a string or a number. * @param {FormatBoletoOptions} [options] - Optional formatting options. * @param {boolean} options.pad - Whether to pad the value with leading zeros. @@ -31,20 +34,24 @@ export type FormatBoletoOptions = { * * formatBoleto("826300000011098800100702024102024000000205104519"); * // "82630000001-1 09880010070-2 02410202400-0 00020510451-9" + * formatBoleto(1e21); // "" (not a non-negative safe integer) * ``` * - * Carta-Circular BCB nº 2.926/2000 specifies the linha digitável fields and the módulo 11 - * check digit (using 1 for remainders 0, 10 and 1) of the 47 digit cobrança bancária slip, - * including the position of the fator de vencimento field. The FEBRABAN "Layout Padrão de + * Carta-Circular BCB nº 2.926/2000 specifies the linha digitável fields, the código de moeda + * `9` (real) in position 4 of the barcode and the módulo 11 check digit (1 when 11 minus the + * remainder gives 0, 10 or 11, i.e. when the remainder is 0 or 1) of the 47 digit cobrança + * bancária slip, including the position of the fator de vencimento field. The FEBRABAN "Layout Padrão de * Arrecadação/Recebimento com Utilização do Código de Barras" and the FEBRABAN layout index * cover the arrecadação slip. * * @see Official: https://www.bcb.gov.br/pre/normativos/c_circ/2000/pdf/c_circ_2926_v1_O.pdf * @see Official: https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf + * FEBRABAN "Layout Padrão de Arrecadação/Recebimento com Utilização do Código de Barras", + * Versão 08 (file of 11/05/2026), "Vigência: a partir de 01.06.2026". * @see Official: https://portal.febraban.org.br/pagina/3425/33/pt-br/layout-febraban */ export const formatBoleto = (value: string | number, options?: FormatBoletoOptions): string => { - if (isNullish(value)) return ""; + if (!isLookupCode(value)) return ""; const digits = sanitizeToDigits(value); diff --git a/src/format-caepf/format-caepf.test.ts b/src/format-caepf/format-caepf.test.ts index d202767f2..41ac31b3d 100644 --- a/src/format-caepf/format-caepf.test.ts +++ b/src/format-caepf/format-caepf.test.ts @@ -79,6 +79,19 @@ describe("formatCaepf", () => { ); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(formatCaepf(-41_142_260_000_101)).toBe(""); + expect(formatCaepf(-1)).toBe(""); + expect(formatCaepf(1.5)).toBe(""); + expect(formatCaepf(2 ** 53)).toBe(""); + expect(formatCaepf(Number.MAX_VALUE)).toBe(""); + expect(formatCaepf(1e21)).toBe(""); + expect(formatCaepf(Number.NaN)).toBe(""); + expect(formatCaepf(Number.POSITIVE_INFINITY)).toBe(""); + expect(formatCaepf(Number.NEGATIVE_INFINITY)).toBe(""); + expect(formatCaepf(-41_142_260_000_101, { pad: true })).toBe(""); + }); }); describe("formatCaepf with a nullish value under pad", () => { diff --git a/src/format-caepf/format-caepf.ts b/src/format-caepf/format-caepf.ts index acebbe137..6d16a0231 100644 --- a/src/format-caepf/format-caepf.ts +++ b/src/format-caepf/format-caepf.ts @@ -1,5 +1,5 @@ import { format } from "../_internals/format/format"; -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"; import { PATTERN } from "./constants"; @@ -16,6 +16,9 @@ export type FormatCaepfOptions = { * Formats progressively, as far as the digits given go, so it can also be used as an input * mask while the user is still typing. * + * 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 CAEPF value to be formatted. * @param {FormatCaepfOptions} [options] - Optional formatting options. * @param {boolean} [options.pad] - Whether to pad the value with leading zeros up to 14 digits. @@ -28,6 +31,7 @@ export type FormatCaepfOptions = { * formatCaepf(41142260000101); // "411.422.600/001-01" * formatCaepf("184", { pad: true }); // "000.000.000/001-84" * formatCaepf("184"); // "184" + * formatCaepf(-41142260000101); // "" (not a non-negative safe integer) * ``` * * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/caepf @@ -35,7 +39,7 @@ export type FormatCaepfOptions = { * print the mask; the mask below is the one the sources cited by `isValidCaepf` agree on. */ export const formatCaepf = (value: string | number, options?: FormatCaepfOptions): string => { - if (isNullish(value)) return ""; + if (!isLookupCode(value)) return ""; return format({ pad: options?.pad, diff --git a/src/format-cei/format-cei.test.ts b/src/format-cei/format-cei.test.ts index 345a9265c..a09945406 100644 --- a/src/format-cei/format-cei.test.ts +++ b/src/format-cei/format-cei.test.ts @@ -75,6 +75,19 @@ describe("formatCei", () => { ); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(formatCei(-249_859_674_386)).toBe(""); + expect(formatCei(-1)).toBe(""); + expect(formatCei(1.5)).toBe(""); + expect(formatCei(2 ** 53)).toBe(""); + expect(formatCei(Number.MAX_VALUE)).toBe(""); + expect(formatCei(1e21)).toBe(""); + expect(formatCei(Number.NaN)).toBe(""); + expect(formatCei(Number.POSITIVE_INFINITY)).toBe(""); + expect(formatCei(Number.NEGATIVE_INFINITY)).toBe(""); + expect(formatCei(-249_859_674_386, { pad: true })).toBe(""); + }); }); describe("formatCei with a nullish value under pad", () => { diff --git a/src/format-cei/format-cei.ts b/src/format-cei/format-cei.ts index 7df7bbf5a..93d2eee29 100644 --- a/src/format-cei/format-cei.ts +++ b/src/format-cei/format-cei.ts @@ -1,6 +1,6 @@ import { CEI_PATTERN } from "../_internals/constants/cei"; import { format } from "../_internals/format/format"; -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"; /** Options of `formatCei`. */ @@ -17,6 +17,9 @@ export type FormatCeiOptions = { * Formats progressively, as far as the digits given go, so it can also be used as an input * mask while the user is still typing. * + * 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 CEI value to be formatted. * @param {FormatCeiOptions} [options] - Optional formatting options. * @param {boolean} [options.pad] - Whether to pad the value with leading zeros up to 12 digits. @@ -29,6 +32,7 @@ export type FormatCeiOptions = { * formatCei(249859674386); // "24.985.96743/86" * formatCei("249", { pad: true }); // "00.000.00002/49" * formatCei("249"); // "24.9" + * formatCei(-249859674386); // "" (not a non-negative safe integer) * ``` * * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/cno @@ -37,7 +41,7 @@ export type FormatCeiOptions = { * digit cited by `isValidCei` agree on. */ export const formatCei = (value: string | number, options?: FormatCeiOptions): string => { - if (isNullish(value)) return ""; + if (!isLookupCode(value)) return ""; return format({ pad: options?.pad, diff --git a/src/format-cep/format-cep.test.ts b/src/format-cep/format-cep.test.ts index 228c72668..5c7470e1f 100644 --- a/src/format-cep/format-cep.test.ts +++ b/src/format-cep/format-cep.test.ts @@ -57,6 +57,19 @@ describe("formatCep", () => { expectAlwaysReturnsType(formatCep, "string", anyValue); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(formatCep(-20_040_020)).toBe(""); + expect(formatCep(-1)).toBe(""); + expect(formatCep(1.5)).toBe(""); + expect(formatCep(2 ** 53)).toBe(""); + expect(formatCep(Number.MAX_VALUE)).toBe(""); + expect(formatCep(1e21)).toBe(""); + expect(formatCep(Number.NaN)).toBe(""); + expect(formatCep(Number.POSITIVE_INFINITY)).toBe(""); + expect(formatCep(Number.NEGATIVE_INFINITY)).toBe(""); + expect(formatCep(-20_040_020, { pad: true })).toBe(""); + }); }); describe("formatCep types", () => { diff --git a/src/format-cep/format-cep.ts b/src/format-cep/format-cep.ts index 34970d0ce..a1845dce2 100644 --- a/src/format-cep/format-cep.ts +++ b/src/format-cep/format-cep.ts @@ -1,5 +1,5 @@ import { format } from "../_internals/format/format"; -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"; /** Options of `formatCep`. */ @@ -11,6 +11,9 @@ export type FormatCepOptions = { /** * Formats a given value as a Brazilian postal code (CEP). * + * 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 value to be formatted, either as a string or a number. * @param {FormatCepOptions} [options] - Optional formatting options. * @param {boolean} options.pad - Whether to pad the value with leading zeros. @@ -20,16 +23,17 @@ export type FormatCepOptions = { * ```typescript * formatCep("01310930"); // "01310-930" * formatCep("1310930", { pad: true }); // "01310-930" + * formatCep(-20040020); // "" (not a non-negative safe integer) * ``` * * @see Official: https://www.correios.com.br/enviar/precisa-de-ajuda/tudo-sobre-cep * @see Official: https://www.correios.com.br/enviar/precisa-de-ajuda/guia-de-enderecamento/guia-de-enderecamento */ export const formatCep = (value: string | number, options?: FormatCepOptions): string => - isNullish(value) - ? "" - : format({ + isLookupCode(value) + ? format({ pad: options?.pad, value: sanitizeToDigits(value), pattern: "00000-000", - }); + }) + : ""; diff --git a/src/format-certidao/format-certidao.test.ts b/src/format-certidao/format-certidao.test.ts index 8717f11f0..e45749d85 100644 --- a/src/format-certidao/format-certidao.test.ts +++ b/src/format-certidao/format-certidao.test.ts @@ -106,6 +106,19 @@ describe("formatCertidao", () => { ); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(formatCertidao(1_045_390_155.2)).toBe(""); + expect(formatCertidao(-1)).toBe(""); + expect(formatCertidao(1.5)).toBe(""); + expect(formatCertidao(2 ** 53)).toBe(""); + expect(formatCertidao(Number.MAX_VALUE)).toBe(""); + expect(formatCertidao(1e21)).toBe(""); + expect(formatCertidao(Number.NaN)).toBe(""); + expect(formatCertidao(Number.POSITIVE_INFINITY)).toBe(""); + expect(formatCertidao(Number.NEGATIVE_INFINITY)).toBe(""); + expect(formatCertidao(1_045_390_155.2, { pad: true })).toBe(""); + }); }); describe("formatCertidao types", () => { diff --git a/src/format-certidao/format-certidao.ts b/src/format-certidao/format-certidao.ts index cfe354a65..48af99dc9 100644 --- a/src/format-certidao/format-certidao.ts +++ b/src/format-certidao/format-certidao.ts @@ -1,6 +1,6 @@ import { CERTIDAO_PATTERN } from "../_internals/constants/certidao"; import { format } from "../_internals/format/format"; -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"; /** Options of `formatCertidao`. */ @@ -18,6 +18,9 @@ export type FormatCertidaoOptions = { * exactly. At runtime the value is read for its digits and masked as far as they go, like in every * formatter of this package, so a partial matrícula still being typed is masked progressively. * + * 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 matrícula value to be formatted. * @param {FormatCertidaoOptions} [options] - Optional formatting options. * @param {boolean} options.pad - If true, pads the value with leading zeros if necessary. @@ -35,6 +38,7 @@ export type FormatCertidaoOptions = { * // "000000 01 55 2010 1 00020 112 0000120 87" * * formatCertidao(104539015520); // "104539 01 55 20" (a number is read as the string of its digits) + * formatCertidao(1045390155.2); // "" (not a non-negative safe integer) * ``` * * @see Official: https://atos.cnj.jus.br/atos/detalhar/5243 @@ -53,7 +57,12 @@ export type FormatCertidaoOptions = { * structure: "a matrícula, de inserção obrigatória nas certidões (primeira e demais vias) emitidas * pelos Cartórios de Registro Civil das Pessoas Naturais a partir de 1º de janeiro de 2010, é * formada pelos seguintes elementos", incisos I to IX fixing the same 6 + 2 + 2 + 4 + 1 + 5 + 3 + - * 7 + 2 positions art. 473 carries today (revoked; historical). + * 7 + 2 positions art. 473 carries today (revoked by the Provimento CNJ nº 63/2017; historical). + * Its inciso V also listed "8: Livro E (Desdobrado para registro específico das Emancipações)" + * and "9: Livro E (Desdobrado para registro específico das Interdições)", which art. 473, V no + * longer has but which are still accepted, the certidões issued under it carrying them, and its + * inciso IX had the check digits "formado automaticamente por meio do programa" the CNJ + * Corregedoria handed to the registrars, whose algorithm was never published. * @see Based on: http://ghiorzi.org/DVnew.htm * Worked example of the two check digits (sums 288 and 309). * @see Based on: https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts @@ -62,10 +71,10 @@ export type FormatCertidaoOptions = { * Third reference implementation agreeing on the weights and on the remainder of 10 read as 1. */ export const formatCertidao = (value: string | number, options?: FormatCertidaoOptions): string => - isNullish(value) - ? "" - : format({ + isLookupCode(value) + ? format({ pad: options?.pad, value: sanitizeToDigits(value), pattern: CERTIDAO_PATTERN, - }); + }) + : ""; diff --git a/src/format-cnae/format-cnae.test.ts b/src/format-cnae/format-cnae.test.ts index 45add4aba..6cb9400c9 100644 --- a/src/format-cnae/format-cnae.test.ts +++ b/src/format-cnae/format-cnae.test.ts @@ -86,10 +86,16 @@ describe("formatCnae", () => { expect(formatCnae("62.01-5/01")).toBe("6201-5/01"); }); - it("should read a signed or fractional number as the string of its digits, like formatCpf", () => { - expect(formatCnae(-6_201_501)).toBe("6201-5/01"); - expect(formatCnae(620_150.1)).toBe("6201-5/01"); - expect(formatCnae(2 ** 53)).toBe("9007-1/99"); + test("when it is a negative, fractional or unsafe number", () => { + expect(formatCnae(-6_201_501)).toBe(""); + expect(formatCnae(620_150.1)).toBe(""); + expect(formatCnae(2 ** 53)).toBe(""); + expect(formatCnae(Number.MAX_VALUE)).toBe(""); + expect(formatCnae(1e21)).toBe(""); + expect(formatCnae(Number.NaN)).toBe(""); + expect(formatCnae(Number.POSITIVE_INFINITY)).toBe(""); + expect(formatCnae(Number.NEGATIVE_INFINITY)).toBe(""); + expect(formatCnae(-6_201_501, { pad: true })).toBe(""); }); it("should return an empty string for a null-prototype object", () => { diff --git a/src/format-cnae/format-cnae.ts b/src/format-cnae/format-cnae.ts index 72d6b3f5a..e5c9ffc5f 100644 --- a/src/format-cnae/format-cnae.ts +++ b/src/format-cnae/format-cnae.ts @@ -1,5 +1,5 @@ import { format } from "../_internals/format/format"; -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"; /** Options of `formatCnae`. */ @@ -22,11 +22,12 @@ export type FormatCnaeOptions = { * `pad: true`, so `formatCnae(111301)` gives `"1113-0/1"` and `formatCnae(111301, { pad: true })` * gives `"0111-3/01"`. * - * Like every formatter of this package, the value is read for its digits and masked as far as + * Like every formatter of this package, a string is read for its digits and masked as far as * they go: characters outside the mask are dropped (`formatCnae("abc6201501")` gives - * `"6201-5/01"`) and a number is read as the string of its digits, sign and decimal point - * included (`formatCnae(-6201501)` gives `"6201-5/01"`). This is the input-mask contract of - * `formatCpf`; use `isValidCnae` to check a code. + * `"6201-5/01"`), which is the input-mask contract of `formatCpf`. A number is only read when + * it is a non-negative safe integer: its sign and decimal point are not mask characters, so a + * negative, fractional, not finite or unsafe number gives an empty string instead of the digits + * of its string (`formatCnae(-6201501)` gives `""`). Use `isValidCnae` to check a code. * * @param {string|number} value - The CNAE code to be formatted. * @param {FormatCnaeOptions} [options] - Optional formatting options. @@ -42,13 +43,13 @@ export type FormatCnaeOptions = { * formatCnae("62015"); // "6201-5" * formatCnae("62", { pad: true }); // "0000-0/62" (padded to 7 digits first) * formatCnae("abc6201501"); // "6201-5/01" (only the digits are read) - * formatCnae(-6201501); // "6201-5/01" + * formatCnae(-6201501); // "" (not a non-negative safe integer) * ``` * * @see Official: https://servicodados.ibge.gov.br/api/v2/cnae/subclasses */ export const formatCnae = (value: string | number, options?: FormatCnaeOptions): string => { - if (isNullish(value)) return ""; + if (!isLookupCode(value)) return ""; return format({ pad: options?.pad, diff --git a/src/format-cnh/constants.ts b/src/format-cnh/constants.ts index 7bef84033..36143c6b8 100644 --- a/src/format-cnh/constants.ts +++ b/src/format-cnh/constants.ts @@ -1,9 +1,11 @@ export const PATTERN = "000000000-00"; /** - * No authority publishes a masking rule for the CNH registry number, so this applies the rule - * Lei nº 12.309/2010, art. 87, § 5º, sets for the CPF ("ocultar os três primeiros dígitos e os - * dois dígitos verificadores") to a number with the same structure: the first 3 digits and the - * 2 check digits are hidden, e.g. "***456789-**". + * No authority publishes a masking rule for the CNH registry number, so this applies the one the + * Leis de Diretrizes Orçamentárias set for publishing a CPF, "ocultar os três primeiros dígitos e + * os dois dígitos verificadores" (Lei nº 14.194/2021, art. 149, a rule first set by Lei nº + * 12.309/2010, art. 87, § 5º), to a number with the same structure: the first 3 digits and the 2 + * check digits are hidden, e.g. "***456789-**". + * Lei nº 15.321/2025 (LDO 2026), art. 163, repeats the rule. */ export const OBFUSCATED_PATTERN = "***000000-**"; diff --git a/src/format-cnh/format-cnh.test.ts b/src/format-cnh/format-cnh.test.ts index 728495434..f9496a89f 100644 --- a/src/format-cnh/format-cnh.test.ts +++ b/src/format-cnh/format-cnh.test.ts @@ -78,6 +78,18 @@ describe("formatCnh", () => { expectAlwaysReturnsType(formatCnh, "string", anyValue); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(formatCnh(2 ** 53)).toBe(""); + expect(formatCnh(-1)).toBe(""); + expect(formatCnh(1.5)).toBe(""); + expect(formatCnh(Number.MAX_VALUE)).toBe(""); + expect(formatCnh(1e21)).toBe(""); + expect(formatCnh(Number.NaN)).toBe(""); + expect(formatCnh(Number.POSITIVE_INFINITY)).toBe(""); + expect(formatCnh(Number.NEGATIVE_INFINITY)).toBe(""); + expect(formatCnh(2 ** 53, { pad: true })).toBe(""); + }); }); describe("formatCnh types", () => { diff --git a/src/format-cnh/format-cnh.ts b/src/format-cnh/format-cnh.ts index 188cddd6c..b8551f979 100644 --- a/src/format-cnh/format-cnh.ts +++ b/src/format-cnh/format-cnh.ts @@ -1,5 +1,5 @@ import { format } from "../_internals/format/format"; -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"; import { OBFUSCATED_PATTERN, PATTERN } from "./constants"; @@ -14,6 +14,9 @@ export type FormatCnhOptions = { /** * Formats a Brazilian CNH (Carteira Nacional de Habilitação) number. * + * 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 CNH number to be formatted. * @param {FormatCnhOptions} [options] - Optional options. * @param {boolean} [options.pad] - Whether to pad the value with leading zeros. @@ -26,26 +29,38 @@ export type FormatCnhOptions = { * formatCnh("12345678900"); // "123456789-00" * formatCnh("8900", { pad: true }); // "000000089-00" * formatCnh("12345678900", { obfuscate: true }); // "***456789-**" + * formatCnh(2 ** 53); // "" (not a non-negative safe integer) * ``` * * Resolução CONTRAN nº 886/2021, art. 4º I, defines the CNH registry number as 9 characters plus * 2 security check digits, which is the layout this mask reproduces; no official text publishes - * the check-digit weights used to compute them. + * the check-digit weights used to compute them. Resolução CONTRAN nº 1.020/2025, art. 10, repeats + * the same layout ("nove caracteres e dois dígitos verificadores") and does not revoke the 886. * - * No authority publishes a masking rule for the CNH either, so `obfuscate` applies the one Lei nº - * 12.309/2010, art. 87, § 5º, sets for the CPF, a number with the same structure: the first 3 - * digits and the 2 check digits are hidden. + * No authority publishes a masking rule for the CNH either, so `obfuscate` applies the one the + * Leis de Diretrizes Orçamentárias set for publishing a CPF, a number with the same structure: the + * first 3 digits and the 2 check digits are hidden. * * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/Resolucao8862021F.pdf - * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm - * Lei nº 12.309/2010, art. 87, § 5º: "ocultar os três primeiros dígitos e os dois dígitos - * verificadores do CPF", the rule `obfuscate` borrows. + * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/Resolucao10202025.pdf + * Resolução CONTRAN nº 1.020, de 1º de dezembro de 2025 (DOU of 09/12/2025), art. 10: the same + * three numbers, the registro nacional "composto de nove caracteres e dois dígitos verificadores", + * with no "módulo 11" rule and no weights; its art. 140 does not revoke Resolução nº 886/2021. + * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2019-2022/2021/lei/L14194.htm + * Lei nº 14.194/2021 (LDO 2022), art. 149: the CPF of the terceirizados it publishes is disclosed + * so as to "ocultar os três primeiros dígitos e os dois dígitos verificadores", the rule first set + * by Lei nº 12.309/2010 (LDO 2011), art. 87, § 5º, and repeated by the LDOs after it: the CPF + * rule `obfuscate` borrows. + * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2025/lei/L15321.htm#art163 + * Lei nº 15.321/2025, the LDO for 2026, art. 163: the CPF published under its arts. 160 and 162 + * is disclosed so as to "ocultar os três primeiros dígitos e os dois dígitos verificadores do + * número de inscrição no CPF", the same rule. */ export const formatCnh = (value: string | number, options?: FormatCnhOptions): string => - isNullish(value) - ? "" - : format({ + isLookupCode(value) + ? format({ pad: options?.pad, value: sanitizeToDigits(value), pattern: (options?.obfuscate ?? false) ? OBFUSCATED_PATTERN : PATTERN, - }); + }) + : ""; diff --git a/src/format-cno/format-cno.test.ts b/src/format-cno/format-cno.test.ts index 255ae9c7a..5eb782d78 100644 --- a/src/format-cno/format-cno.test.ts +++ b/src/format-cno/format-cno.test.ts @@ -67,6 +67,19 @@ describe("formatCno", () => { ); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(formatCno(-401_800_097_960)).toBe(""); + expect(formatCno(-1)).toBe(""); + expect(formatCno(1.5)).toBe(""); + expect(formatCno(2 ** 53)).toBe(""); + expect(formatCno(Number.MAX_VALUE)).toBe(""); + expect(formatCno(1e21)).toBe(""); + expect(formatCno(Number.NaN)).toBe(""); + expect(formatCno(Number.POSITIVE_INFINITY)).toBe(""); + expect(formatCno(Number.NEGATIVE_INFINITY)).toBe(""); + expect(formatCno(-401_800_097_960, { pad: true })).toBe(""); + }); }); describe("formatCno with a nullish value under pad", () => { diff --git a/src/format-cno/format-cno.ts b/src/format-cno/format-cno.ts index 31bc1e6ba..a96af9645 100644 --- a/src/format-cno/format-cno.ts +++ b/src/format-cno/format-cno.ts @@ -1,6 +1,6 @@ import { CEI_PATTERN } from "../_internals/constants/cei"; import { format } from "../_internals/format/format"; -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"; /** Options of `formatCno`. */ @@ -18,6 +18,9 @@ export type FormatCnoOptions = { * Formats progressively, as far as the digits given go, so it can also be used as an input * mask while the user is still typing. * + * 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 CNO value to be formatted. * @param {FormatCnoOptions} [options] - Optional formatting options. * @param {boolean} [options.pad] - Whether to pad the value with leading zeros up to 12 digits. @@ -30,6 +33,7 @@ export type FormatCnoOptions = { * formatCno(401800097960); // "40.180.00979/60" * formatCno("979", { pad: true }); // "00.000.00009/79" * formatCno("979"); // "97.9" + * formatCno(-401800097960); // "" (not a non-negative safe integer) * ``` * * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/cno @@ -38,7 +42,7 @@ export type FormatCnoOptions = { * digit cited by `isValidCei` agree on. */ export const formatCno = (value: string | number, options?: FormatCnoOptions): string => { - if (isNullish(value)) return ""; + if (!isLookupCode(value)) return ""; return format({ pad: options?.pad, diff --git a/src/format-cnpj/constants.ts b/src/format-cnpj/constants.ts index bfcc8191b..b6f40da0c 100644 --- a/src/format-cnpj/constants.ts +++ b/src/format-cnpj/constants.ts @@ -1,8 +1,11 @@ export const PATTERN = "00.000.000/0000-00"; /** - * gov.br / Receita Federal display convention: hides the first 2 digits and the 2 check - * digits, e.g. "**.345.678/0001-**". Also used for the alphanumeric CNPJ (`version: 2`), - * which shares the same digit/separator positions. + * A convention of this library, with no official source: hides the first 2 characters and the 2 + * check digits, e.g. "**.345.678/0001-**", after the rule the Leis de Diretrizes Orçamentárias + * set for the CPF. No law or Receita Federal act sets a masking rule for the CNPJ, whose data are + * public (the Receita publishes the Cadastro Nacional da Pessoa Jurídica as open data), and the + * ANPD says "não há um padrão para o mascaramento". Also used + * for the alphanumeric CNPJ (`version: 2`), which shares the same positions. */ export const OBFUSCATED_PATTERN = "**.000.000/0000-**"; diff --git a/src/format-cnpj/format-cnpj.test.ts b/src/format-cnpj/format-cnpj.test.ts index ba2f2afa4..0a7786346 100644 --- a/src/format-cnpj/format-cnpj.test.ts +++ b/src/format-cnpj/format-cnpj.test.ts @@ -115,6 +115,11 @@ describe("formatCnpj", () => { expect(formatCnpj("q0SLFMBD7VX439", { version: 2 })).toBe("Q0.SLF.MBD/7VX4-39"); }); + it("should upper-case lower case letters for version 2, the official set being A to Z", () => { + expect(formatCnpj("q0slfmbd7vx439", { version: 2 })).toBe("Q0.SLF.MBD/7VX4-39"); + expect(formatCnpj("12abc34501de35", { version: 2 })).toBe("12.ABC.345/01DE-35"); + }); + it("should remove non-alphanumeric characters for version 2", () => { expect(formatCnpj("46.?ABC843.485/0001-86abc", { version: 2 })).toBe("46.ABC.843/4850-00"); }); @@ -193,6 +198,19 @@ describe("formatCnpj", () => { expectAlwaysReturnsType(formatCnpj, "string", anyValue); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(formatCnpj(-11_222_333_000_181)).toBe(""); + expect(formatCnpj(-1)).toBe(""); + expect(formatCnpj(1.5)).toBe(""); + expect(formatCnpj(2 ** 53)).toBe(""); + expect(formatCnpj(Number.MAX_VALUE)).toBe(""); + expect(formatCnpj(1e21)).toBe(""); + expect(formatCnpj(Number.NaN)).toBe(""); + expect(formatCnpj(Number.POSITIVE_INFINITY)).toBe(""); + expect(formatCnpj(Number.NEGATIVE_INFINITY)).toBe(""); + expect(formatCnpj(-11_222_333_000_181, { pad: true })).toBe(""); + }); }); describe("formatCnpj with a nullish value under pad", () => { diff --git a/src/format-cnpj/format-cnpj.ts b/src/format-cnpj/format-cnpj.ts index 1c9a5d9e0..a732459c6 100644 --- a/src/format-cnpj/format-cnpj.ts +++ b/src/format-cnpj/format-cnpj.ts @@ -1,5 +1,5 @@ import { format } from "../_internals/format/format"; -import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { sanitizeCnpj } from "../_internals/sanitize-cnpj/sanitize-cnpj"; import { OBFUSCATED_PATTERN, PATTERN } from "./constants"; @@ -16,6 +16,9 @@ export type FormatCnpjOptions = { /** * Formats a given CNPJ (Cadastro Nacional da Pessoa Jurídica) value according to the specified options. * + * 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 CNPJ value to be formatted. It can be a string or a number. * @param {FormatCnpjOptions} [options] - Optional configuration for formatting the CNPJ. * @param {boolean} options.pad - If true, the value will be padded with leading zeros if necessary. @@ -32,14 +35,39 @@ export type FormatCnpjOptions = { * formatCnpj("12345678", { pad: true }); // "00.000.012/3456-78" * formatCnpj("q0SLFMBD7VX439", { version: 2 }); // "Q0.SLF.MBD/7VX4-39" * formatCnpj("12345678000195", { obfuscate: true }); // "**.345.678/0001-**" + * formatCnpj(-11222333000181); // "" (not a non-negative safe integer) * ``` * + * The official character set of the alphanumeric CNPJ is the capital letters `A` to `Z` and the + * digits in the 12 base positions, and digits only in the 2 check digits (Receita Federal, CNPJ + * alfanumérico, and its DV manual, which reads a letter by its ASCII code). A lower case letter is + * not part of it: under `version: 2` this function accepts one only as input normalization, the + * way it accepts mask characters, and upper-cases the input before formatting it, so + * `formatCnpj("q0slfmbd7vx439", { version: 2 })` returns `"Q0.SLF.MBD/7VX4-39"`. + * + * `obfuscate` is a convention of this library, not an official rule: no law or Receita Federal + * act sets a masking rule for the CNPJ, whose data are public, the ANPD says "não há um padrão + * para o mascaramento", and the Banco Central's Pix rules show the CNPJ in full where they mask + * the CPF. It hides the first 2 characters and the 2 check digits, after the rule the Leis de + * Diretrizes Orçamentárias set for the CPF. + * * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/cnpj * @see Official: https://www.gov.br/receitafederal/pt-br/centrais-de-conteudo/publicacoes/documentos-tecnicos/cnpj/manual-dv-cnpj.pdf * @see Official: https://www.gov.br/receitafederal/pt-br/acesso-a-informacao/acoes-e-programas/programas-e-atividades/cnpj-alfanumerico + * @see Official: https://www.gov.br/receitafederal/pt-br/centrais-de-conteudo/publicacoes/perguntas-e-respostas/cnpj/cnpj-alfanumerico.pdf + * Receita Federal, CNPJ alfanumérico, perguntas e respostas: the base positions take the digits 0 + * to 9 and the capital letters A to Z, the 2 check digits stay numeric. + * @see Official: https://www.gov.br/anpd/pt-br/centrais-de-conteudo/documentos-tecnicos-orientativos/estudo_tecnico_sobre_anonimizacao_de_dados_na_lgpd_uma_visao_de_processo_baseado_em_risco_e_tecnicas_computacionais.pdf + * ANPD, Estudo Técnico sobre Anonimização de Dados na LGPD (v1.0, November 2023), "Técnica de + * Mascaramento": "como não há um padrão para o mascaramento, é possível que partes distintas dos + * dados estejam visíveis". + * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/IV_RequisitosMinimosparaExperienciadoUsuario.pdf + * Banco Central, Pix, Requisitos Mínimos para a Experiência do Usuário: "CPF mascarado (ex: + * ***.777.888-**) /CNPJ", the CNPJ shown in full, and "Não deverá haver qualquer mascaramento de + * chave Pix no retorno da consulta ao DICT". */ export const formatCnpj = (value: string | number, options?: FormatCnpjOptions): string => { - if (isNullish(value)) return ""; + if (!isLookupCode(value)) return ""; return format({ pad: options?.pad, diff --git a/src/format-cns/format-cns.test.ts b/src/format-cns/format-cns.test.ts index e26280fd5..69a68f655 100644 --- a/src/format-cns/format-cns.test.ts +++ b/src/format-cns/format-cns.test.ts @@ -70,6 +70,19 @@ describe("formatCns", () => { ); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(formatCns(-123_456_789_010_000)).toBe(""); + expect(formatCns(-1)).toBe(""); + expect(formatCns(1.5)).toBe(""); + expect(formatCns(2 ** 53)).toBe(""); + expect(formatCns(Number.MAX_VALUE)).toBe(""); + expect(formatCns(1e21)).toBe(""); + expect(formatCns(Number.NaN)).toBe(""); + expect(formatCns(Number.POSITIVE_INFINITY)).toBe(""); + expect(formatCns(Number.NEGATIVE_INFINITY)).toBe(""); + expect(formatCns(-123_456_789_010_000, { pad: true })).toBe(""); + }); }); describe("formatCns types", () => { diff --git a/src/format-cns/format-cns.ts b/src/format-cns/format-cns.ts index 48931c168..45ac8e5d7 100644 --- a/src/format-cns/format-cns.ts +++ b/src/format-cns/format-cns.ts @@ -1,5 +1,5 @@ import { format } from "../_internals/format/format"; -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"; /** Options of `formatCns`. */ @@ -12,6 +12,9 @@ export type FormatCnsOptions = { * Formats a CNS (Cartão Nacional de Saúde) number into the common display groups of 3-4-4-4 * digits separated by spaces. * + * 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 CNS value to be formatted. It can be a string or a number. * @param {FormatCnsOptions} [options] - Optional formatting options. * @param {boolean} options.pad - If true, pads the value with leading zeros if necessary. @@ -22,22 +25,31 @@ export type FormatCnsOptions = { * formatCns("123456789010000"); // "123 4567 8901 0000" * formatCns(123456789010000); // "123 4567 8901 0000" * formatCns("89010001", { pad: true }); // "000 0000 8901 0001" + * formatCns(-123456789010000); // "" (not a non-negative safe integer) * ``` * + * @see Official: https://web.archive.org/web/20190106003442/http://cartaonet.datasus.gov.br/Rotina_JavaScript.doc + * DATASUS, "Rotina de validação de CNS e Número Provisório", the routine the Cartão Nacional de + * Saúde site (cartaonet.datasus.gov.br) published for download; the site is gone, so the link is + * the Wayback Machine copy of the official file (last captured on 06/01/2019). It has two + * routines, "Números que iniciam com '1' ou '2'" (the definitive CNS, PIS base + "000" or "001" + + * check digit) and "Números que iniciam com '7', '8' ou '9'" (the provisional number, weights 15 + * to 1 summing to a multiple of 11), and names no other first digit: nothing official covers a + * number starting with 5. Its 2007 version said "O Número Provisório sempre começa com '8'". * @see Official: https://rni-docs.anvisa.gov.br/docs/regras_gerais/validacoes/validacaoCNS/ * ANVISA's two validation routines, the ones implemented here. The page sits behind a bot filter * and answers HTTP 403 to every non-browser client, so it has to be opened in a browser. * @see Based on: https://integracao.esusab.ufsc.br/ledi/documentacao/regras/algoritmo_CNS.html * e-SUS APS documentation of the same DATASUS algorithm, reachable without a browser. It applies * the provisional routine to numbers starting with 5, 7, 8 or 9; this implementation follows the - * ANVISA page, which restricts it to 7, 8 and 9, so a 5 prefixed number is rejected even when its - * weighted sum checks out. + * DATASUS routine and the ANVISA page, which restrict it to 7, 8 and 9, so a 5 prefixed number is + * rejected even when its weighted sum checks out: no official document names the prefix 5. */ export const formatCns = (value: string | number, options?: FormatCnsOptions): string => - isNullish(value) - ? "" - : format({ + isLookupCode(value) + ? format({ pad: options?.pad, value: sanitizeToDigits(value), pattern: "000 0000 0000 0000", - }); + }) + : ""; diff --git a/src/format-cpf/constants.ts b/src/format-cpf/constants.ts index fed79edb8..9bd746777 100644 --- a/src/format-cpf/constants.ts +++ b/src/format-cpf/constants.ts @@ -1,7 +1,10 @@ export const PATTERN = "000.000.000-00"; /** - * gov.br / Receita Federal display convention: hides the first 3 digits and the 2 check - * digits, e.g. "***.456.789-**". + * Hides the first 3 digits and the 2 check digits, e.g. "***.456.789-**": the rule the Leis de + * Diretrizes Orçamentárias set for publishing a CPF, "ocultar os três primeiros dígitos e os dois + * dígitos verificadores" (Lei nº 14.194/2021, art. 149, a rule first set by Lei nº 12.309/2010, + * art. 87, § 5º). + * Lei nº 15.321/2025 (LDO 2026), art. 163, repeats the rule. */ export const OBFUSCATED_PATTERN = "***.000.000-**"; diff --git a/src/format-cpf/format-cpf.test.ts b/src/format-cpf/format-cpf.test.ts index 573950eff..d4466dad1 100644 --- a/src/format-cpf/format-cpf.test.ts +++ b/src/format-cpf/format-cpf.test.ts @@ -137,6 +137,19 @@ describe("formatCpf", () => { expectAlwaysReturnsType(formatCpf, "string", anyValue); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(formatCpf(123_456_789.09)).toBe(""); + expect(formatCpf(-1)).toBe(""); + expect(formatCpf(1.5)).toBe(""); + expect(formatCpf(2 ** 53)).toBe(""); + expect(formatCpf(Number.MAX_VALUE)).toBe(""); + expect(formatCpf(1e21)).toBe(""); + expect(formatCpf(Number.NaN)).toBe(""); + expect(formatCpf(Number.POSITIVE_INFINITY)).toBe(""); + expect(formatCpf(Number.NEGATIVE_INFINITY)).toBe(""); + expect(formatCpf(123_456_789.09, { pad: true })).toBe(""); + }); }); describe("formatCpf with a nullish value under pad", () => { diff --git a/src/format-cpf/format-cpf.ts b/src/format-cpf/format-cpf.ts index 31faa6b00..b2817403c 100644 --- a/src/format-cpf/format-cpf.ts +++ b/src/format-cpf/format-cpf.ts @@ -1,5 +1,5 @@ import { format } from "../_internals/format/format"; -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"; import { OBFUSCATED_PATTERN, PATTERN } from "./constants"; @@ -14,6 +14,9 @@ export type FormatCpfOptions = { /** * Formats a given CPF (Cadastro de Pessoas Físicas) value according to the Brazilian standard. * + * 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 CPF value to be formatted. It can be a string or a number. * @param {FormatCpfOptions} [options] - Optional formatting options. * @param {boolean} options.pad - If true, the value will be padded with leading zeros if necessary. @@ -27,13 +30,25 @@ export type FormatCpfOptions = { * formatCpf(12345678909); // "123.456.789-09" * formatCpf("123456789", { pad: true }); // "001.234.567-89" * formatCpf("12345678909", { obfuscate: true }); // "***.456.789-**" + * formatCpf(123456789.09); // "" (not a non-negative safe integer) * ``` * + * `obfuscate` follows the rule the Leis de Diretrizes Orçamentárias set for publishing a CPF: the + * first 3 digits and the 2 check digits are hidden. + * * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/meu-cpf + * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2019-2022/2021/lei/L14194.htm + * Lei nº 14.194/2021 (LDO 2022), art. 149: the CPF of the terceirizados it publishes is disclosed + * so as to "ocultar os três primeiros dígitos e os dois dígitos verificadores", the rule first set + * by Lei nº 12.309/2010 (LDO 2011), art. 87, § 5º, and repeated by the LDOs after it. + * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2025/lei/L15321.htm#art163 + * Lei nº 15.321/2025, the LDO for 2026, art. 163: the CPF published under its arts. 160 and 162 + * is disclosed so as to "ocultar os três primeiros dígitos e os dois dígitos verificadores do + * número de inscrição no CPF", the same rule. * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/cpf.py */ export const formatCpf = (value: string | number, options?: FormatCpfOptions): string => { - if (isNullish(value)) return ""; + if (!isLookupCode(value)) return ""; return format({ pad: options?.pad, diff --git a/src/format-iban/format-iban.ts b/src/format-iban/format-iban.ts index c41fedecb..104367ca0 100644 --- a/src/format-iban/format-iban.ts +++ b/src/format-iban/format-iban.ts @@ -30,8 +30,11 @@ import { GROUP_SIZE } from "./constants"; * formatIban("BR15 0000-0000.0000/1093 2840 814P-2"); // "BR15 0000 0000 0000 1093 2840 814P 2" * ``` * + * @see Official: https://www.bcb.gov.br/estabilidadefinanceira/exibenormativo?tipo=Resolu%C3%A7%C3%A3o%20BCB&numero=585 + * Resolução BCB nº 585, de 24/08/2026 (DOU 25/08/2026), art. 2º, which revoked Circular BCB nº + * 3.625/2013 and keeps its layout, with an alphanumeric ISPB. * @see Official: https://www.bcb.gov.br/pre/normativos/circ/2013/pdf/circ_3625_v1_O.pdf - * Circular BCB nº 3.625/2013 + * Circular BCB nº 3.625/2013 (revoked), the original layout * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf * Diretrizes de Implementação do IBAN no Brasil */ diff --git a/src/format-legal-nature/format-legal-nature.test.ts b/src/format-legal-nature/format-legal-nature.test.ts index 929c8680b..4e837c8af 100644 --- a/src/format-legal-nature/format-legal-nature.test.ts +++ b/src/format-legal-nature/format-legal-nature.test.ts @@ -52,6 +52,19 @@ describe("formatLegalNature", () => { expectAlwaysReturnsType(formatLegalNature, "string", anyValue); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(formatLegalNature(206.2)).toBe(""); + expect(formatLegalNature(-1)).toBe(""); + expect(formatLegalNature(1.5)).toBe(""); + expect(formatLegalNature(2 ** 53)).toBe(""); + expect(formatLegalNature(Number.MAX_VALUE)).toBe(""); + expect(formatLegalNature(1e21)).toBe(""); + expect(formatLegalNature(Number.NaN)).toBe(""); + expect(formatLegalNature(Number.POSITIVE_INFINITY)).toBe(""); + expect(formatLegalNature(Number.NEGATIVE_INFINITY)).toBe(""); + expect(formatLegalNature(206.2, { pad: true })).toBe(""); + }); }); describe("formatLegalNature types", () => { diff --git a/src/format-legal-nature/format-legal-nature.ts b/src/format-legal-nature/format-legal-nature.ts index fd0728112..583a752f5 100644 --- a/src/format-legal-nature/format-legal-nature.ts +++ b/src/format-legal-nature/format-legal-nature.ts @@ -1,5 +1,5 @@ import { format } from "../_internals/format/format"; -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"; /** Options of `formatLegalNature`. */ @@ -15,6 +15,9 @@ export type FormatLegalNatureOptions = { * they go (`"206"` stays `"206"`, `"2062"` becomes `"206-2"`); with `pad: true` it is first left * padded with zeros to the 4 digits of a complete code. Use `isValidLegalNature` to check a code. * + * 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 legal nature code to be formatted. * @param {FormatLegalNatureOptions} [options] - Optional formatting options. * @param {boolean} [options.pad] - Whether to pad the value with leading zeros. Defaults to `false`. @@ -26,6 +29,7 @@ export type FormatLegalNatureOptions = { * formatLegalNature(2062); // "206-2" * formatLegalNature("206"); // "206" (partial values are masked as far as they go) * formatLegalNature("62", { pad: true }); // "006-2" + * formatLegalNature(206.2); // "" (not a non-negative safe integer) * ``` * * The CONCLA table page sits behind a bot filter and answers HTTP 403 to every non-browser @@ -39,10 +43,10 @@ export const formatLegalNature = ( value: string | number, options?: FormatLegalNatureOptions, ): string => - isNullish(value) - ? "" - : format({ + isLookupCode(value) + ? format({ pad: options?.pad, value: sanitizeToDigits(value), pattern: "000-0", - }); + }) + : ""; diff --git a/src/format-ncm/format-ncm.test.ts b/src/format-ncm/format-ncm.test.ts index 47fcbf02e..c7c12b33b 100644 --- a/src/format-ncm/format-ncm.test.ts +++ b/src/format-ncm/format-ncm.test.ts @@ -85,10 +85,16 @@ describe("formatNcm", () => { expect(formatNcm("8471.30-12")).toBe("8471.30.12"); }); - it("should read a signed or fractional number as the string of its digits, like formatCpf", () => { - expect(formatNcm(-84_713_012)).toBe("8471.30.12"); - expect(formatNcm(8_471_301.2)).toBe("8471.30.12"); - expect(formatNcm(2 ** 53)).toBe("9007.19.92"); + test("when it is a negative, fractional or unsafe number", () => { + expect(formatNcm(-84_713_012)).toBe(""); + expect(formatNcm(8_471_301.2)).toBe(""); + expect(formatNcm(2 ** 53)).toBe(""); + expect(formatNcm(Number.MAX_VALUE)).toBe(""); + expect(formatNcm(1e21)).toBe(""); + expect(formatNcm(Number.NaN)).toBe(""); + expect(formatNcm(Number.POSITIVE_INFINITY)).toBe(""); + expect(formatNcm(Number.NEGATIVE_INFINITY)).toBe(""); + expect(formatNcm(-84_713_012, { pad: true })).toBe(""); }); it("should return an empty string for a null-prototype object", () => { diff --git a/src/format-ncm/format-ncm.ts b/src/format-ncm/format-ncm.ts index c78ccbd0c..c68df359c 100644 --- a/src/format-ncm/format-ncm.ts +++ b/src/format-ncm/format-ncm.ts @@ -1,5 +1,5 @@ import { format } from "../_internals/format/format"; -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"; /** Options of `formatNcm`. */ @@ -22,11 +22,12 @@ export type FormatNcmOptions = { * `pad: true`, so `formatNcm(8471)` gives `"8471"` and `formatNcm(8471, { pad: true })` gives * `"0000.84.71"`. * - * Like every formatter of this package, the value is read for its digits and masked as far as - * they go: characters outside the mask are dropped (`formatNcm("abc8471")` gives - * `"8471"`) and a number is read as the string of its digits, sign and decimal point - * included (`formatNcm(-84713012)` gives `"8471.30.12"`). This is the input-mask contract of - * `formatCpf`; use `isValidNcm` to check a code. + * Like every formatter of this package, a string is read for its digits and masked as far as + * they go: characters outside the mask are dropped (`formatNcm("abc8471")` gives `"8471"`), + * which is the input-mask contract of `formatCpf`. A number is only read when it is a + * non-negative safe integer: its sign and decimal point are not mask characters, so a negative, + * fractional, not finite or unsafe number gives an empty string instead of the digits of its + * string (`formatNcm(-84713012)` gives `""`). Use `isValidNcm` to check a code. * * @param {string|number} value - The NCM code to be formatted. * @param {FormatNcmOptions} [options] - Optional formatting options. @@ -42,13 +43,13 @@ export type FormatNcmOptions = { * formatNcm("847130"); // "8471.30" * formatNcm("8471", { pad: true }); // "0000.84.71" (padded to 8 digits first) * formatNcm("abc8471"); // "8471" (only the digits are read) - * formatNcm(-84713012); // "8471.30.12" + * formatNcm(-84713012); // "" (not a non-negative safe integer) * ``` * * @see Official: https://portalunico.siscomex.gov.br/classif/api/publico/nomenclatura/download/json */ export const formatNcm = (value: string | number, options?: FormatNcmOptions): string => { - if (isNullish(value)) return ""; + if (!isLookupCode(value)) return ""; return format({ pad: options?.pad, diff --git a/src/format-nfe-key/constants.ts b/src/format-nfe-key/constants.ts index 2d5f86926..7799e3492 100644 --- a/src/format-nfe-key/constants.ts +++ b/src/format-nfe-key/constants.ts @@ -1,5 +1,5 @@ /** - * 11 groups of 4 digits, the common display form printed on the DANFE. Spelled out + * 11 groups of 4 characters, the common display form printed on the DANFE. Spelled out * instead of built with `Array(11).fill(...).join(...)`: a top-level call cannot be * proven pure by consumer bundlers and would pin this module into their output. */ diff --git a/src/format-nfe-key/format-nfe-key.test.ts b/src/format-nfe-key/format-nfe-key.test.ts index 87e57578a..a54371432 100644 --- a/src/format-nfe-key/format-nfe-key.test.ts +++ b/src/format-nfe-key/format-nfe-key.test.ts @@ -50,6 +50,30 @@ describe("formatNfeKey", () => { }); }); + describe("with the alphanumeric CNPJ of NT Conjunta 2025.001", () => { + test("should keep the letters in positions 7 to 18 instead of dropping them", () => { + expect(formatNfeKey("35260712ABC34501DE35550010000001231102030403")).toBe( + "3526 0712 ABC3 4501 DE35 5500 1000 0001 2311 0203 0403", + ); + }); + + test("should upper case them", () => { + expect(formatNfeKey("35260712abc34501de35550010000001231102030403")).toBe( + "3526 0712 ABC3 4501 DE35 5500 1000 0001 2311 0203 0403", + ); + }); + + test("should keep a letter in position 7 and in position 18", () => { + expect(formatNfeKey("352607A2ABC34501DZ35")).toBe("3526 07A2 ABC3 4501 DZ35"); + }); + + test("should drop a letter in positions 1 to 6 and from position 19 on", () => { + expect(formatNfeKey("35260A")).toBe("3526 0"); + expect(formatNfeKey("35260712ABC34501DEA5")).toBe("3526 0712 ABC3 4501 DE5"); + expect(formatNfeKey("35260712ABC34501DE3A5")).toBe("3526 0712 ABC3 4501 DE35"); + }); + }); + test("should remove all non numeric characters, including the NFe prefix", () => { expect(formatNfeKey(`NFe${KEY}`)).toBe(FORMATTED); expect(formatNfeKey(FORMATTED)).toBe(FORMATTED); @@ -93,9 +117,10 @@ describe("formatNfeKey", () => { ); }); - test("should keep only the digits of the access key it formats", () => { + test("should keep only the digits of the access key it formats when the value has no letter", () => { fc.assert( - fc.property(fc.string({ unit: "grapheme" }), (value) => { + fc.property(fc.string({ unit: "grapheme" }), (text) => { + const value = text.replaceAll(/[A-Za-z]/g, ""); const digits = value.replaceAll(/\D/g, "").slice(0, 44); expect(formatNfeKey(value).replaceAll(/\D/g, "")).toBe(digits); @@ -103,6 +128,18 @@ describe("formatNfeKey", () => { ); }); + test("should keep letters only in positions 7 to 18, the characters TChNFe opens to them", () => { + fc.assert( + fc.property(fc.string({ unit: "grapheme" }), (value) => { + expect( + /^(?:\d{0,6}|\d{6}[0-9A-Z]{1,12}|\d{6}[0-9A-Z]{12}\d{1,26})$/.test( + formatNfeKey(value).replaceAll(" ", ""), + ), + ).toBe(true); + }), + ); + }); + test("should never throw and always return the access key as a string", () => { fc.assert( fc.property( diff --git a/src/format-nfe-key/format-nfe-key.ts b/src/format-nfe-key/format-nfe-key.ts index ade8d888a..394339a8f 100644 --- a/src/format-nfe-key/format-nfe-key.ts +++ b/src/format-nfe-key/format-nfe-key.ts @@ -1,30 +1,34 @@ import { format } from "../_internals/format/format"; import { isNullish } from "../_internals/is-nullish/is-nullish"; -import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { sanitizeNfeKey } from "../_internals/sanitize-nfe-key/sanitize-nfe-key"; import { PATTERN } from "./constants"; /** Options of `formatNfeKey`. */ export type FormatNfeKeyOptions = { - /** Whether to left pad the value with zeros up to the 44 digits of a complete access key (default: `false`). */ + /** Whether to left pad the value with zeros up to the 44 characters of a complete access key (default: `false`). */ pad?: boolean; }; /** * Formats a DF-e (Documento Fiscal eletrônico) access key (chave de acesso) into groups of 4 - * digits separated by spaces, the form every auxiliary document prints it in: the DANFE of the + * characters separated by spaces, the form every auxiliary document prints it in: the DANFE of the * NF-e and the NFC-e, the DACTE of the CT-e, the CT-e OS and the GTV-e, the DAMDFE of the * MDF-e, the DABPE of the BP-e, the DANF3E of the NF3e and the DANFE-COM of the NFCom. * - * Like every formatter of this package, the value is read for its digits and grouped as far as - * they go, so a masked or partial key still being typed is grouped progressively and anything + * The upper cased letters of an alphanumeric CNPJ are kept in positions 7 to 18, the only ones + * the key's schema type (`[0-9]{6}[0-9A-Z]{12}[0-9]{26}`) opens to letters; a letter anywhere + * else is dropped like any other character outside the key, the way `parseNfeKey` reads it. + * + * Like every formatter of this package, the value is read for its characters and grouped as far + * as they go, so a masked or partial key still being typed is grouped progressively and anything * without a digit (an object, `true`, an object with a null prototype) gives `""` instead of * throwing. Use `isValidNfeKey` to check a key. * - * With `pad: true` the value is first left padded with zeros to the 44 digits of a complete + * With `pad: true` the value is first left padded with zeros to the 44 characters of a complete * access key, so it always comes back fully grouped (`"12345"` gives * `"0000 0000 0000 0000 0000 0000 0000 0000 0000 0001 2345"`). * - * The parameter is typed as a string because the 44 digits of an access key are more than a + * The parameter is typed as a string because the 44 characters of an access key are more than a * JavaScript number can hold exactly. At runtime a number is read as the string of its digits, * like in every formatter of this package. * @@ -38,6 +42,9 @@ export type FormatNfeKeyOptions = { * formatNfeKey("35170458716523000119550010000000121000123458"); * // "3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458" * + * formatNfeKey("35260712abc34501de35550010000001231102030403"); + * // "3526 0712 ABC3 4501 DE35 5500 1000 0001 2311 0203 0403" (alphanumeric CNPJ) + * * formatNfeKey("12345"); // "1234 5" (partial values are grouped as far as they go) * * formatNfeKey("12345", { pad: true }); @@ -46,8 +53,11 @@ export type FormatNfeKeyOptions = { * * @see Official: https://www.confaz.fazenda.gov.br/legislacao/arquivo-manuais/moc7-visao-geral.pdf * Manual de Orientação do Contribuinte (MOC) NF-e, "chave de acesso". + * @see Official: https://www.nfe.fazenda.gov.br/portal/exibirArquivo.aspx?conteudo=5ZkvIZt10mQ%3D + * Nota Técnica Conjunta 2025.001 (CNPJ alfanumérico nos DF-e) and the NF-e schema package + * PL_010 (NT 2026.004): `TChNFe`, ``. */ export const formatNfeKey = (value: string, options?: FormatNfeKeyOptions): string => isNullish(value) ? "" - : format({ pad: options?.pad, value: sanitizeToDigits(value), pattern: PATTERN }); + : format({ pad: options?.pad, value: sanitizeNfeKey(value), pattern: PATTERN }); diff --git a/src/format-phone/format-phone.test.ts b/src/format-phone/format-phone.test.ts index dd437c68e..3deebed70 100644 --- a/src/format-phone/format-phone.test.ts +++ b/src/format-phone/format-phone.test.ts @@ -338,6 +338,18 @@ describe("formatPhone", () => { ); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(formatPhone(-11_987_654_321)).toBe(""); + expect(formatPhone(-1)).toBe(""); + expect(formatPhone(1.5)).toBe(""); + expect(formatPhone(2 ** 53)).toBe(""); + expect(formatPhone(Number.MAX_VALUE)).toBe(""); + expect(formatPhone(1e21)).toBe(""); + expect(formatPhone(Number.NaN)).toBe(""); + expect(formatPhone(Number.POSITIVE_INFINITY)).toBe(""); + expect(formatPhone(Number.NEGATIVE_INFINITY)).toBe(""); + }); }); describe("formatPhone types", () => { diff --git a/src/format-phone/format-phone.ts b/src/format-phone/format-phone.ts index 121b7282f..2b044b7f6 100644 --- a/src/format-phone/format-phone.ts +++ b/src/format-phone/format-phone.ts @@ -4,6 +4,7 @@ import { SERVICE_PHONE_NON_GEOGRAPHIC_PREFIXES, } from "../_internals/constants/service-phone"; import { format } from "../_internals/format/format"; +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { normalizePhone } from "../_internals/normalize-phone/normalize-phone"; import { resolveServicePhoneDigits } from "../_internals/resolve-service-phone-digits/resolve-service-phone-digits"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; @@ -132,18 +133,25 @@ const isPhoneMask = (value: unknown): value is PhoneMask => PHONE_MASKS.has(valu * outside the union falls back to the default `"sn"` instead of throwing. * * `options.obfuscate` hides the subscriber number under every mask, for the places where a - * number is shown to someone who should only recognize it (LGPD, art. 6º III, necessidade). The - * gov.br account shows the registered mobile as `"*********00"`, only the last 2 digits, and - * this keeps that count. The 2 digits are the last ones the mask itself has room for, so under + * number is shown to someone who should only recognize it (LGPD, art. 6º III, necessidade). It is + * a convention of this library, not an official rule: no law, Anatel act or ANPD guidance sets + * which digits of a phone number to show (the ANPD says "não há um padrão para o mascaramento"), + * and the Banco Central forbids masking a Pix key, a phone number included, when the DICT lookup + * returns it. The gov.br account shows the registered mobile as `"*********00"`, only the last 2 + * digits, and this keeps that count. The 2 digits are the last ones the mask itself has room for, so under * the default `"sn"` a DDD-prefixed value is truncated first, exactly as it is without * `obfuscate`, and the visible pair is the 8th and 9th digit rather than the last 2 of `value`. * The prefix that names a region or a service instead of a subscriber also stays: the DDD, the - * `0800`-like code and the `300X`/`400X` root. A 3 digit public utility code (`190`) identifies - * no one and is returned as it is, and a value the `"service"` mask does not recognize has + * `0800`-like code and the `300X`/`400X` root. Under the `"service"`, `"auto"`, `"e164"` and + * `"international"` masks a 3 digit public utility code (`190`) identifies no one and is + * returned as it is (the other masks read it as an ordinary short number), and a value the `"service"` mask does not recognize has * every digit replaced by a `*`, which hides the digits but not how many there were. The * patterns have a fixed number of slots, so under `"e164"` anything past the 11th national * digit is dropped. * + * 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 phone number to format, either as a string or a number. * @param {FormatPhoneOptions} [options] - Optional formatting options. * @param {"auto"|"sn"|"nanp"|"e164"|"international"|"service"} options.mask - The mask to apply for formatting the phone number (default: `"sn"`). @@ -172,6 +180,7 @@ const isPhoneMask = (value: unknown): value is PhoneMask => PHONE_MASKS.has(valu * formatPhone("11988887766", { mask: "service", obfuscate: true }); // "***********" (not a service number) * formatPhone("11987654321"); // "11987-6543" (BEWARE: default "sn" truncates a DDD-prefixed number) * formatPhone("11987654321", { obfuscate: true }); // "*****-**43" (BEWARE: truncated too, so "43", not "21") + * formatPhone(-11987654321); // "" (not a non-negative safe integer) * ``` * * @see Official: https://www.itu.int/rec/T-REC-E.164 @@ -179,8 +188,18 @@ const isPhoneMask = (value: unknown): value is PhoneMask => PHONE_MASKS.has(valu * @see Official: https://acesso.gov.br/faq/_perguntasdafaq/formarrecuperarconta.html * The gov.br account FAQ, whose "Recuperar senha com celular" screen shows the registered mobile * as `"*********00"`, the convention `obfuscate` follows for the number of visible digits. + * @see Official: https://www.gov.br/anpd/pt-br/centrais-de-conteudo/documentos-tecnicos-orientativos/estudo_tecnico_sobre_anonimizacao_de_dados_na_lgpd_uma_visao_de_processo_baseado_em_risco_e_tecnicas_computacionais.pdf + * ANPD, Estudo Técnico sobre Anonimização de Dados na LGPD (v1.0, November 2023), "Técnica de + * Mascaramento": "como não há um padrão para o mascaramento, é possível que partes distintas dos + * dados estejam visíveis". + * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/IV_RequisitosMinimosparaExperienciadoUsuario.pdf + * Banco Central, Pix, Requisitos Mínimos para a Experiência do Usuário: "CPF mascarado (ex: + * ***.777.888-**) /CNPJ", the CNPJ shown in full, and "Não deverá haver qualquer mascaramento de + * chave Pix no retorno da consulta ao DICT". */ export const formatPhone = (value: string | number, options?: FormatPhoneOptions): string => { + if (!isLookupCode(value)) return ""; + const enhancedValue = sanitizeToDigits(value); const serviceDigits = resolveServicePhoneDigits(value); diff --git a/src/format-pis/constants.ts b/src/format-pis/constants.ts index 8dcfcca4b..e77c7b865 100644 --- a/src/format-pis/constants.ts +++ b/src/format-pis/constants.ts @@ -1,9 +1,11 @@ export const PATTERN = "000.00000.00-0"; /** - * No authority publishes a masking rule for the PIS/PASEP/NIS, so this applies the rule Lei - * nº 12.309/2010, art. 87, § 5º, sets for the CPF ("ocultar os três primeiros dígitos e os dois - * dígitos verificadores") to a number with the same structure: the first 3 digits and the + * No authority publishes a masking rule for the PIS/PASEP/NIS, so this applies the one the Leis + * de Diretrizes Orçamentárias set for publishing a CPF, "ocultar os três primeiros dígitos e os + * dois dígitos verificadores" (Lei nº 14.194/2021, art. 149, a rule first set by Lei nº + * 12.309/2010, art. 87, § 5º), to a number with the same structure: the first 3 digits and the * single check digit are hidden, e.g. "***.45678.90-*". + * Lei nº 15.321/2025 (LDO 2026), art. 163, repeats the rule. */ export const OBFUSCATED_PATTERN = "***.00000.00-*"; diff --git a/src/format-pis/format-pis.test.ts b/src/format-pis/format-pis.test.ts index 261b7238e..ebf7b74a5 100644 --- a/src/format-pis/format-pis.test.ts +++ b/src/format-pis/format-pis.test.ts @@ -63,19 +63,31 @@ describe("formatPis", () => { expect(formatPis(1_000_000_000_000)).toBe("100.00000.00-0"); }); - it("when it is a float number", () => { + it("when it is a short integer number", () => { expect(formatPis(1)).toBe("1"); expect(formatPis(10)).toBe("10"); expect(formatPis(100)).toBe("100"); - expect(formatPis(100.1)).toBe("100.1"); - expect(formatPis(100.11)).toBe("100.11"); - expect(formatPis(100.101)).toBe("100.101"); - expect(formatPis(100.1001)).toBe("100.1001"); - expect(formatPis(100.10001)).toBe("100.10001"); - expect(formatPis(100.100001)).toBe("100.10000.1"); - expect(formatPis(100.1000001)).toBe("100.10000.01"); - expect(formatPis(100.10000001)).toBe("100.10000.00-1"); - expect(formatPis(100.100000001)).toBe("100.10000.00-0"); + }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(formatPis(100.1)).toBe(""); + expect(formatPis(100.11)).toBe(""); + expect(formatPis(100.101)).toBe(""); + expect(formatPis(100.1001)).toBe(""); + expect(formatPis(100.10001)).toBe(""); + expect(formatPis(100.100001)).toBe(""); + expect(formatPis(100.1000001)).toBe(""); + expect(formatPis(100.10000001)).toBe(""); + expect(formatPis(100.100000001)).toBe(""); + expect(formatPis(-12_345_678_901)).toBe(""); + expect(formatPis(2 ** 53)).toBe(""); + expect(formatPis(Number.MAX_VALUE)).toBe(""); + expect(formatPis(1e21)).toBe(""); + expect(formatPis(Number.NaN)).toBe(""); + expect(formatPis(Number.POSITIVE_INFINITY)).toBe(""); + expect(formatPis(Number.NEGATIVE_INFINITY)).toBe(""); + expect(formatPis(100.1, { pad: true })).toBe(""); + expect(formatPis(100.1, { obfuscate: true })).toBe(""); }); it(`should NOT add digits after the PIS length (${PIS_LENGTH})`, () => { diff --git a/src/format-pis/format-pis.ts b/src/format-pis/format-pis.ts index 1d7026695..188dfbb5f 100644 --- a/src/format-pis/format-pis.ts +++ b/src/format-pis/format-pis.ts @@ -1,5 +1,5 @@ import { format } from "../_internals/format/format"; -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"; import { OBFUSCATED_PATTERN, PATTERN } from "./constants"; @@ -14,6 +14,9 @@ export type FormatPisOptions = { /** * Formats a PIS (Programa de Integração Social) number according to the specified pattern. * + * 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 PIS number to be formatted. It can be a string or a number. * @param {FormatPisOptions} [options] - Optional formatting options. * @param {boolean} options.pad - If true, pads the value with leading zeros if necessary. @@ -27,25 +30,32 @@ export type FormatPisOptions = { * formatPis(12345678901); // "123.45678.90-1" * formatPis("123456789", { pad: true }); // "001.23456.78-9" * formatPis("12345678901", { obfuscate: true }); // "***.45678.90-*" + * formatPis(100.1); // "" (not a non-negative safe integer) * ``` * - * No authority publishes a masking rule for the PIS, so `obfuscate` applies the one Lei nº - * 12.309/2010, art. 87, § 5º, sets for the CPF, a number with the same structure: the first 3 + * No authority publishes a masking rule for the PIS, so `obfuscate` applies the one the Leis de + * Diretrizes Orçamentárias set for publishing a CPF, a number with the same structure: the first 3 * digits and the check digit are hidden. * * @see Official: https://www.gov.br/inss/pt-br/direitos-e-deveres/inscricao-e-contribuicao/inscricao * @see Official: https://www.gov.br/esocial/pt-br/documentacao-tecnica/manuais/mos-manual-de-orientacao-do-esocial-vs-2-4.pdf * @see Official: https://www.sirc.gov.br/wp-content/uploads/manual_sirc_recomendacoes_tecnicas_v7.pdf - * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm - * Lei nº 12.309/2010, art. 87, § 5º: "ocultar os três primeiros dígitos e os dois dígitos - * verificadores do CPF", the rule `obfuscate` borrows. + * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2019-2022/2021/lei/L14194.htm + * Lei nº 14.194/2021 (LDO 2022), art. 149: the CPF of the terceirizados it publishes is disclosed + * so as to "ocultar os três primeiros dígitos e os dois dígitos verificadores", the rule first set + * by Lei nº 12.309/2010 (LDO 2011), art. 87, § 5º, and repeated by the LDOs after it: the CPF + * rule `obfuscate` borrows. + * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2025/lei/L15321.htm#art163 + * Lei nº 15.321/2025, the LDO for 2026, art. 163: the CPF published under its arts. 160 and 162 + * is disclosed so as to "ocultar os três primeiros dígitos e os dois dígitos verificadores do + * número de inscrição no CPF", the same rule. * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/pis.py */ export const formatPis = (value: string | number, options?: FormatPisOptions): string => - isNullish(value) - ? "" - : format({ + isLookupCode(value) + ? format({ pad: options?.pad, value: sanitizeToDigits(value), pattern: (options?.obfuscate ?? false) ? OBFUSCATED_PATTERN : PATTERN, - }); + }) + : ""; diff --git a/src/format-processo-juridico/format-processo-juridico.test.ts b/src/format-processo-juridico/format-processo-juridico.test.ts index 0343729d2..7673ff4c5 100644 --- a/src/format-processo-juridico/format-processo-juridico.test.ts +++ b/src/format-processo-juridico/format-processo-juridico.test.ts @@ -85,6 +85,18 @@ describe("formatProcessoJuridico", () => { expectAlwaysReturnsType(formatProcessoJuridico, "string", anyValue); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(formatProcessoJuridico(-1)).toBe(""); + expect(formatProcessoJuridico(1.5)).toBe(""); + expect(formatProcessoJuridico(2 ** 53)).toBe(""); + expect(formatProcessoJuridico(Number.MAX_VALUE)).toBe(""); + expect(formatProcessoJuridico(1e21)).toBe(""); + expect(formatProcessoJuridico(Number.NaN)).toBe(""); + expect(formatProcessoJuridico(Number.POSITIVE_INFINITY)).toBe(""); + expect(formatProcessoJuridico(Number.NEGATIVE_INFINITY)).toBe(""); + expect(formatProcessoJuridico(-1, { pad: true })).toBe(""); + }); }); describe("formatProcessoJuridico types", () => { diff --git a/src/format-processo-juridico/format-processo-juridico.ts b/src/format-processo-juridico/format-processo-juridico.ts index 270a34229..1a5a28693 100644 --- a/src/format-processo-juridico/format-processo-juridico.ts +++ b/src/format-processo-juridico/format-processo-juridico.ts @@ -1,5 +1,5 @@ import { format } from "../_internals/format/format"; -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"; /** Options of `formatProcessoJuridico`. */ @@ -11,6 +11,9 @@ export type FormatProcessoJuridicoOptions = { /** * Formats a legal process number (processo jurídico) according to a specific pattern. * + * 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 legal process number to be formatted. It can be a string or a number. * @param {FormatProcessoJuridicoOptions} [options] - Optional formatting options. * @param {boolean} options.pad - If true, the value will be padded with leading zeros if necessary. @@ -19,9 +22,11 @@ export type FormatProcessoJuridicoOptions = { * @example * ```typescript * formatProcessoJuridico("00020802520125150049"); // "0002080-25.2012.5.15.0049" + * formatProcessoJuridico(-1); // "" (not a non-negative safe integer) * ``` * - * Resolução CNJ nº 65/2008 defines this Número Único de Processo layout and its check digits. + * Resolução CNJ nº 65/2008 defines this Número Único de Processo layout and its check digits, + * whose algorithm is in its Anexo VIII. * * @see Official: https://atos.cnj.jus.br/atos/detalhar/119 */ @@ -29,10 +34,10 @@ export const formatProcessoJuridico = ( value: string | number, options?: FormatProcessoJuridicoOptions, ): string => - isNullish(value) - ? "" - : format({ + isLookupCode(value) + ? format({ pad: options?.pad, value: sanitizeToDigits(value), pattern: "0000000-00.0000.0.00.0000", - }); + }) + : ""; diff --git a/src/format-voter-id/constants.ts b/src/format-voter-id/constants.ts index 8e3519d0b..cd79b02cc 100644 --- a/src/format-voter-id/constants.ts +++ b/src/format-voter-id/constants.ts @@ -1,16 +1,12 @@ -export const PATTERNS = { - standard: "0000 0000 00 00", - extended: "0000 0000 0 00 00", -}; +export const PATTERN = "0000 0000 00 00"; /** - * No authority publishes a masking rule for the título de eleitor, so this applies the rule Lei - * nº 12.309/2010, art. 87, § 5º, sets for the CPF ("ocultar os três primeiros dígitos e os dois - * dígitos verificadores") to a number with the same structure: the first 3 digits of the + * No authority publishes a masking rule for the título de eleitor, so this applies the one the + * Leis de Diretrizes Orçamentárias set for publishing a CPF, "ocultar os três primeiros dígitos e + * os dois dígitos verificadores" (Lei nº 14.194/2021, art. 149, a rule first set by Lei nº + * 12.309/2010, art. 87, § 5º), to a number with the same structure: the first 3 digits of the * sequential number and the 2 check digits are hidden, e.g. "***4 5678 01 **". The federative * union code stays visible. + * Lei nº 15.321/2025 (LDO 2026), art. 163, repeats the rule. */ -export const OBFUSCATED_PATTERNS = { - standard: "***0 0000 00 **", - extended: "***0 0000 0 00 **", -}; +export const OBFUSCATED_PATTERN = "***0 0000 00 **"; diff --git a/src/format-voter-id/format-voter-id.test.ts b/src/format-voter-id/format-voter-id.test.ts index 11d9a9d45..6d1580c30 100644 --- a/src/format-voter-id/format-voter-id.test.ts +++ b/src/format-voter-id/format-voter-id.test.ts @@ -22,31 +22,31 @@ describe("formatVoterId", () => { expect(formatVoterId("123456780124")).toBe("1234 5678 01 24"); }); - it("should use the 13-digit grouping once the sequential number has 9 digits (São Paulo/Minas Gerais)", () => { - expect(formatVoterId("1234567880191")).toBe("1234 5678 8 01 91"); - expect(formatVoterId("1234567880299")).toBe("1234 5678 8 02 99"); + it("should use the 12-digit grouping for every value, São Paulo (01) and Minas Gerais (02) included", () => { + // 2.4.0 grouped these as "1234 5678 8 01 91" / "1234 5678 8 02 99": a voter id has at most 12 digits + expect(formatVoterId("1234567880191")).toBe("1234 5678 80 19"); + expect(formatVoterId("1234567880299")).toBe("1234 5678 80 29"); }); - it("should drop the digits past the last slot of the pattern", () => { - expect(formatVoterId("1234567880191")).toBe("1234 5678 8 01 91"); - expect(formatVoterId("12345678801912")).toBe("1234 5678 8 01 91"); - expect(formatVoterId("123456788019123")).toBe("1234 5678 8 01 91"); + it("should drop the digits past the 12th", () => { + expect(formatVoterId("12345678801912")).toBe("1234 5678 80 19"); expect(formatVoterId("12345678803991")).toBe("1234 5678 80 39"); }); - it("should keep the 12-digit grouping for a 13-digit value whose UF cannot carry 9 sequential digits", () => { - expect(formatVoterId("1234567880399")).toBe("1234 5678 80 39"); - }); - - it("should keep using the 12-digit grouping for inputs with 12 digits or fewer", () => { - expect(formatVoterId("123456788")).toBe("1234 5678 8"); - expect(formatVoterId("123456788019")).toBe("1234 5678 80 19"); + it("should restore the leading zeros of a voter id issued without them when pad is true", () => { + expect(formatVoterId("123450159", { pad: true })).toBe("0001 2345 01 59"); + expect(formatVoterId(123_450_159, { pad: true })).toBe("0001 2345 01 59"); + expect(formatVoterId("10191", { pad: true })).toBe("0000 0001 01 91"); + expect(formatVoterId("123456780124", { pad: true })).toBe("1234 5678 01 24"); + expect(formatVoterId("123450159", { pad: true, obfuscate: true })).toBe("***1 2345 01 **"); + expect(formatVoterId("123450159", { pad: false })).toBe("1234 5015 9"); + expect(formatVoterId("123450159")).toBe("1234 5015 9"); }); it("should hide the first 3 digits and the 2 check digits when obfuscate is truthy", () => { expect(formatVoterId("123456780124", { obfuscate: true })).toBe("***4 5678 01 **"); expect(formatVoterId(123_456_780_124, { obfuscate: true })).toBe("***4 5678 01 **"); - expect(formatVoterId("1234567880191", { obfuscate: true })).toBe("***4 5678 8 01 **"); + expect(formatVoterId("1234567880191", { obfuscate: true })).toBe("***4 5678 80 **"); expect(formatVoterId("1234567880399", { obfuscate: true })).toBe("***4 5678 80 **"); expect(formatVoterId("12345", { obfuscate: true })).toBe("***4 5"); // @ts-expect-error: intentionally not a boolean @@ -55,9 +55,9 @@ describe("formatVoterId", () => { it("should behave exactly as without the option when obfuscate is falsy", () => { expect(formatVoterId("123456780124", { obfuscate: false })).toBe("1234 5678 01 24"); - expect(formatVoterId("1234567880191", {})).toBe("1234 5678 8 01 91"); + expect(formatVoterId("123456780124", {})).toBe("1234 5678 01 24"); // @ts-expect-error: intentionally not a boolean - expect(formatVoterId("1234567880191", { obfuscate: 0 })).toBe("1234 5678 8 01 91"); + expect(formatVoterId("123456780124", { obfuscate: 0 })).toBe("1234 5678 01 24"); }); it("should return an empty string for null or undefined", () => { @@ -68,7 +68,7 @@ describe("formatVoterId", () => { }); describe("properties", () => { - const upToAVoterId = digitsUpTo(13); + const upToAVoterId = digitsUpTo(12); test("should only add spaces, never change the digits", () => { fc.assert( @@ -78,25 +78,22 @@ describe("formatVoterId", () => { ); }); - test("should use the grouping documented for each of the two lengths", () => { + test("should use the documented grouping for 12 digits", () => { fc.assert( - fc.property(digits(12), digits(9), fc.constantFrom("01", "02"), (short, sequential, uf) => { - const extended = `${sequential}${uf}00`; - - expect(formatVoterId(short)).toMatch(/^\d{4} \d{4} \d{2} \d{2}$/); - expect(formatVoterId(extended)).toMatch(/^\d{4} \d{4} \d \d{2} \d{2}$/); - expect(formatVoterId(short, { obfuscate: true })).toMatch(/^\*{3}\d \d{4} \d{2} \*{2}$/); - expect(formatVoterId(extended, { obfuscate: true })).toMatch( - /^\*{3}\d \d{4} \d \d{2} \*{2}$/, - ); + fc.property(digits(12), (value) => { + expect(formatVoterId(value)).toMatch(/^\d{4} \d{4} \d{2} \d{2}$/); + expect(formatVoterId(value, { obfuscate: true })).toMatch(/^\*{3}\d \d{4} \d{2} \*{2}$/); }), ); }); - test("should keep the 12-digit grouping for 13 digits when the UF is not 01 or 02", () => { + test("should pad any shorter value to the 12-digit grouping, keeping its digits last", () => { fc.assert( - fc.property(digits(9), fc.constantFrom("03", "10", "28", "99"), (sequential, uf) => { - expect(formatVoterId(`${sequential}${uf}00`)).toMatch(/^\d{4} \d{4} \d{2} \d{2}$/); + fc.property(digitsUpTo(12), (value) => { + const formatted = formatVoterId(value, { pad: true }); + + expect(formatted).toMatch(/^\d{4} \d{4} \d{2} \d{2}$/); + expect(formatted.replaceAll(" ", "")).toBe(value.padStart(12, "0")); }), ); }); @@ -105,6 +102,18 @@ describe("formatVoterId", () => { expectAlwaysReturnsType(formatVoterId, "string", anyValue); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(formatVoterId(-123_456_780_124)).toBe(""); + expect(formatVoterId(-1)).toBe(""); + expect(formatVoterId(1.5)).toBe(""); + expect(formatVoterId(2 ** 53)).toBe(""); + expect(formatVoterId(Number.MAX_VALUE)).toBe(""); + expect(formatVoterId(1e21)).toBe(""); + expect(formatVoterId(Number.NaN)).toBe(""); + expect(formatVoterId(Number.POSITIVE_INFINITY)).toBe(""); + expect(formatVoterId(Number.NEGATIVE_INFINITY)).toBe(""); + }); }); describe("formatVoterId types", () => { @@ -114,7 +123,8 @@ describe("formatVoterId types", () => { expectTypeOf(formatVoterId).returns.toEqualTypeOf(); }); - test("should type the obfuscate option as an optional boolean", () => { + test("should type the obfuscate and pad options as optional booleans", () => { expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); }); }); diff --git a/src/format-voter-id/format-voter-id.ts b/src/format-voter-id/format-voter-id.ts index 7ab0ce13a..f9748c468 100644 --- a/src/format-voter-id/format-voter-id.ts +++ b/src/format-voter-id/format-voter-id.ts @@ -1,12 +1,12 @@ -import { NINE_DIGIT_FEDERATIVE_UNION_CODES } from "../_internals/constants/voter-id"; import { format } from "../_internals/format/format"; +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -import { OBFUSCATED_PATTERNS, PATTERNS } from "./constants"; - -const LENGTH = 12; +import { OBFUSCATED_PATTERN, PATTERN } from "./constants"; /** Options of `formatVoterId`. */ export type FormatVoterIdOptions = { + /** Whether to left pad the value with zeros up to the 12 digits of a voter id (default: `false`). */ + pad?: boolean; /** Whether to hide the first 3 digits and the 2 check digits with `*` (default: `false`, read for truthiness). */ obfuscate?: boolean; }; @@ -14,51 +14,64 @@ export type FormatVoterIdOptions = { /** * Formats a Brazilian voter id (título de eleitor) for display. * - * Uses the 12-digit grouping "0000 0000 00 00" by default. The 13-digit grouping - * "0000 0000 0 00 00" is used only when the sanitized value has more than 12 digits and its - * federative union code (the 10th and 11th digits) is "01" (São Paulo) or "02" (Minas Gerais), - * the two states whose voter ids may carry a 9-digit sequential number. + * Uses the 12-digit grouping "0000 0000 00 00". The TSE drops the leading zeros of the + * sequential number when it issues a voter id, so an id can have fewer than 12 digits: by default + * a shorter value is formatted from the left, as a partially typed id, and `pad: true` restores + * the dropped zeros first, so `formatVoterId("123450159", { pad: true })` gives + * "0001 2345 01 59", the grouping `isValidVoterId` checks the value against. Digits past the 12th + * are dropped. + * + * 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 voter id value to be formatted. * @param {FormatVoterIdOptions} [options] - Optional formatting options. + * @param {boolean} [options.pad] - If true, left pads the value with zeros up to 12 digits, + * restoring the leading zeros of a voter id issued without them. * @param {boolean} [options.obfuscate] - If truthy, hides the first 3 digits and the 2 check - * digits. Read for truthiness, so a non-boolean such as `1` obfuscates too. + * digits. Read for truthiness, so a non-boolean such as `1` obfuscates too. The mask hides by + * position, so pass `pad` with it for a voter id given as a number, which has lost its leading + * zeros. * @returns {string} The formatted voter id string. * * @example * ```typescript * formatVoterId("123456780124"); // "1234 5678 01 24" - * formatVoterId("1234567880191"); // "1234 5678 8 01 91" + * formatVoterId("123450159", { pad: true }); // "0001 2345 01 59" * formatVoterId("123456780124", { obfuscate: true }); // "***4 5678 01 **" - * formatVoterId("1234567880191", { obfuscate: true }); // "***4 5678 8 01 **" + * formatVoterId(-123456780124); // "" (not a non-negative safe integer) * ``` * - * No authority publishes a masking rule for the voter id, so `obfuscate` applies the one Lei nº - * 12.309/2010, art. 87, § 5º, sets for the CPF, a number with the same structure: the first 3 - * digits and the 2 check digits are hidden, and the federative union code stays visible. + * No authority publishes a masking rule for the voter id, so `obfuscate` applies the one the Leis + * de Diretrizes Orçamentárias set for publishing a CPF, a number with the same structure: the + * first 3 digits and the 2 check digits are hidden, and the federative union code stays visible. * - * The 13-digit São Paulo/Minas Gerais grouping is brutils parity, not published by the TSE. A - * 14-or-more-digit input is read the same way as a 13-digit one: it is grouped as a São Paulo or - * Minas Gerais id whenever its 10th and 11th digits are "01"/"02". Both patterns have a fixed - * number of slots, 12 and 13, so anything past the last slot is dropped: - * `formatVoterId("12345678801912")` returns "1234 5678 8 01 91", the same string the 13-digit - * value "1234567880191" produces. + * Resolução TSE nº 23.659/2021, art. 36, gives the voter id "até 12 algarismos", its first eight + * "sequenciados, desprezando-se, na emissão, os zeros à esquerda", so there is no 13-digit + * grouping. * * The TSE resolution page sits behind a bot filter and answers HTTP 403 to every non-browser * client, so it has to be opened in a browser. * * @see Official: https://www.tse.jus.br/legislacao/compilada/res/2021/resolucao-no-23-659-de-26-de-outubro-de-2021 - * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm - * Lei nº 12.309/2010, art. 87, § 5º: "ocultar os três primeiros dígitos e os dois dígitos - * verificadores do CPF", the rule `obfuscate` borrows. + * Resolução TSE nº 23.659/2021, art. 36: "composto por até 12 algarismos", "os oito primeiros + * algarismos serão sequenciados, desprezando-se, na emissão, os zeros à esquerda". + * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2019-2022/2021/lei/L14194.htm + * Lei nº 14.194/2021 (LDO 2022), art. 149: the CPF of the terceirizados it publishes is disclosed + * so as to "ocultar os três primeiros dígitos e os dois dígitos verificadores", the rule first set + * by Lei nº 12.309/2010 (LDO 2011), art. 87, § 5º, and repeated by the LDOs after it: the CPF + * rule `obfuscate` borrows. + * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2025/lei/L15321.htm#art163 + * Lei nº 15.321/2025, the LDO for 2026, art. 163: the CPF published under its arts. 160 and 162 + * is disclosed so as to "ocultar os três primeiros dígitos e os dois dígitos verificadores do + * número de inscrição no CPF", the same rule. * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/voter_id.py */ -export const formatVoterId = (value: string | number, options?: FormatVoterIdOptions): string => { - const digits = sanitizeToDigits(value); - const federativeUnion = digits.slice(9, 11); - const isExtended = - digits.length > LENGTH && NINE_DIGIT_FEDERATIVE_UNION_CODES.includes(federativeUnion); - const patterns = (options?.obfuscate ?? false) ? OBFUSCATED_PATTERNS : PATTERNS; - - return format({ value: digits, pattern: isExtended ? patterns.extended : patterns.standard }); -}; +export const formatVoterId = (value: string | number, options?: FormatVoterIdOptions): string => + isLookupCode(value) + ? format({ + pad: options?.pad, + value: sanitizeToDigits(value), + pattern: (options?.obfuscate ?? false) ? OBFUSCATED_PATTERN : PATTERN, + }) + : ""; diff --git a/src/generate-boleto/constants.ts b/src/generate-boleto/constants.ts new file mode 100644 index 000000000..32f822d1a --- /dev/null +++ b/src/generate-boleto/constants.ts @@ -0,0 +1,13 @@ +/** + * The fator de vencimento of a slip with no due date. Carta-Circular BCB nº 2.926/2000 counts the + * factor from the base date 07/10/1997; it reached 1000 on 03/07/2000, and FEBRABAN restarted it + * at 1000, not at 0001, when it passed 9999 on 22/02/2025, so `0000` is the only factor below + * 1000 a slip carries today. + */ +export const NO_DUE_DATE_FACTOR = "0000"; + +/** The lowest fator de vencimento that denotes a date, 03/07/2000 in the first cycle. */ +export const MIN_DUE_DATE_FACTOR = 1000; + +/** The highest fator de vencimento, the last day of a cycle (21/02/2025 in the first one). */ +export const MAX_DUE_DATE_FACTOR = 9999; diff --git a/src/generate-boleto/generate-boleto.test.ts b/src/generate-boleto/generate-boleto.test.ts index d65c9e0ec..be0193ac8 100644 --- a/src/generate-boleto/generate-boleto.test.ts +++ b/src/generate-boleto/generate-boleto.test.ts @@ -31,6 +31,24 @@ const drawArrecadacaoIdentifier = (algorithmDraw: number, valueDraw: number): st } }; +/** + * Generates a bancário slip with every `Math.random` draw fixed at `draw`, so each random digit + * is `floor(draw * 10)` and the fator de vencimento draw is `floor(draw * 9001)`. + * @param {number} draw - The value every draw returns. + * @returns {string} The generated linha digitável. + */ +const drawBancarioWith = (draw: number): string => { + const originalRandom = Math.random; + + try { + Math.random = (): number => draw; + + return generateBoleto(); + } finally { + Math.random = originalRandom; + } +}; + describe("generateBoleto", () => { test("should generate a valid boleto", () => { const boleto = generateBoleto(); @@ -70,6 +88,40 @@ describe("generateBoleto", () => { expect(boletos.size).toBe(100); }); + describe("cobrança bancária", () => { + test("should always write the código de moeda 9 (real) in position 4, Carta-Circular BCB nº 2.926/2000", () => { + for (let i = 0; i < 100; i++) { + expect(generateBoleto()[3]).toBe("9"); + } + expect(drawBancarioWith(0.9999)[3]).toBe("9"); + }); + + test("should draw the fator de vencimento 0000 (no due date) on the lowest draw", () => { + // Every digit 0 and the factor 0000: campo 1 "000900000" + DV 1, DV geral 7. + expect(drawBancarioWith(0)).toBe("00090000010000000000000000000000700000000000000"); + expect(getBoletoInfo(drawBancarioWith(0))?.expirationDate).toBeNull(); + }); + + test("should skip 0001 to 0999 and draw 1000 right after 0000", () => { + // floor(0.0002 * 9001) = 1, the first factor after 0000: 1000, not 0001. DV geral 1. + expect(drawBancarioWith(0.0002)).toBe("00090000010000000000000000000000110000000000000"); + expect(drawBancarioWith(0.0002).slice(33, 37)).toBe("1000"); + }); + + test("should draw 9999 on the highest draw", () => { + // floor(0.9999 * 9001) = 9000, factor 9999; every other digit 9. DV geral 7. + expect(drawBancarioWith(0.9999)).toBe("99999999999999999999099999999990799999999999999"); + }); + + test("should never draw a factor from 0001 to 0999, which maps to no date", () => { + for (let i = 0; i < 200; i++) { + const factor = Number(generateBoleto().slice(33, 37)); + + expect(factor === 0 || (factor >= 1000 && factor <= 9999)).toBe(true); + } + }); + }); + describe("arrecadação", () => { test("should generate a valid arrecadação bank slip", () => { const boleto = generateBoleto({ type: "arrecadacao" }); diff --git a/src/generate-boleto/generate-boleto.ts b/src/generate-boleto/generate-boleto.ts index c1cccb07d..7a4b126d9 100644 --- a/src/generate-boleto/generate-boleto.ts +++ b/src/generate-boleto/generate-boleto.ts @@ -1,7 +1,9 @@ import { assembleBoletoArrecadacao } from "../_internals/assemble-boleto-arrecadacao/assemble-boleto-arrecadacao"; import { assembleBoletoBancario } from "../_internals/assemble-boleto-bancario/assemble-boleto-bancario"; import { ARRECADACAO_SEGMENTS } from "../_internals/constants/arrecadacao"; +import { REAL_CURRENCY_CODE } from "../_internals/constants/boleto"; import { generateRandomNumber } from "../_internals/generate-random-number/generate-random-number"; +import { MAX_DUE_DATE_FACTOR, MIN_DUE_DATE_FACTOR, NO_DUE_DATE_FACTOR } from "./constants"; /** The parameters of `generateBoleto`. */ export type GenerateBoletoParams = { @@ -9,12 +11,27 @@ export type GenerateBoletoParams = { type?: "bancario" | "arrecadacao"; }; +/** + * Draws a fator de vencimento that denotes a due date, or none: `0000` (no due date) or one of + * `1000` to `9999`, each of the 9001 values equally likely. A factor from `0001` to `0999` maps + * to no date: the factor reached 1000 on 03/07/2000, and since the 22/02/2025 reset the cycle + * restarts at 1000 rather than at 0001, which is why `getBoletoInfo` reads those as `null`. + * + * @returns {string} The 4 digit factor. + */ +const drawDueDateFactor = (): string => { + const draw = Math.floor(Math.random() * (MAX_DUE_DATE_FACTOR - MIN_DUE_DATE_FACTOR + 2)); + + return draw === 0 ? NO_DUE_DATE_FACTOR : String(MIN_DUE_DATE_FACTOR - 1 + draw); +}; + const generateBancario = (): string => assembleBoletoBancario({ - field1: generateRandomNumber(9), + field1: generateRandomNumber(3) + REAL_CURRENCY_CODE + generateRandomNumber(5), field2: generateRandomNumber(10), field3: generateRandomNumber(10), - tail: generateRandomNumber(15), + // The first digit is the slot of the DV geral, which assembleBoletoBancario overwrites. + tail: `0${drawDueDateFactor()}${generateRandomNumber(10)}`, }); const generateArrecadacao = (): string => @@ -30,6 +47,12 @@ const generateArrecadacao = (): string => * * Uses `Math.random()` internally, so it is not cryptographically secure, do not use for security purposes. * + * A cobrança bancária slip carries the código de moeda `9` (real) in position 4, the only one + * Carta-Circular BCB nº 2.926/2000 assigns, after a random 3 digit bank code, and a fator de + * vencimento of `0000` (no due date) or `1000` to `9999`, the values that denote a date: + * `0001` to `0999` denote none, the factor having reached 1000 on 03/07/2000 and restarted at + * 1000 on 22/02/2025. + * * An arrecadação slip draws its segment from 1 to 7 (segment 9 is the banks' own) and its value * identifier from all four values, `6` and `8` for an effective amount and `7` and `9` for a * reference quantity, so both `hasEffectiveValue` branches of `getBoletoInfo` are reachable. @@ -44,14 +67,19 @@ const generateArrecadacao = (): string => * generateBoleto({ type: "arrecadacao" }); // "846100000005246100291102005460339004695895061080" * ``` * - * Carta-Circular BCB nº 2.926/2000 specifies the linha digitável fields and the módulo 11 - * check digit (using 1 for remainders 0, 10 and 1) of the 47 digit cobrança bancária slip, - * including the position of the fator de vencimento field. The FEBRABAN "Layout Padrão de + * Carta-Circular BCB nº 2.926/2000 specifies the linha digitável fields, the código de moeda + * `9` (real) in position 4 of the barcode and the módulo 11 check digit (1 when 11 minus the + * remainder gives 0, 10 or 11, i.e. when the remainder is 0 or 1) of the 47 digit cobrança + * bancária slip, including the position of the fator de vencimento field. The FEBRABAN "Layout Padrão de * Arrecadação/Recebimento com Utilização do Código de Barras" and the FEBRABAN layout index * cover the arrecadação slip. * * @see Official: https://www.bcb.gov.br/pre/normativos/c_circ/2000/pdf/c_circ_2926_v1_O.pdf + * Carta-Circular BCB nº 2.926/2000, anexo, layout of the barcode: position 04, "código da + * moeda", 9 (real); positions 06 to 09, the fator de vencimento counted from 07/10/1997. * @see Official: https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf + * FEBRABAN "Layout Padrão de Arrecadação/Recebimento com Utilização do Código de Barras", + * Versão 08 (file of 11/05/2026), "Vigência: a partir de 01.06.2026". * @see Official: https://portal.febraban.org.br/pagina/3425/33/pt-br/layout-febraban */ export const generateBoleto = (params?: GenerateBoletoParams): string => diff --git a/src/generate-cnh/generate-cnh.ts b/src/generate-cnh/generate-cnh.ts index 5e36f2006..c4acec888 100644 --- a/src/generate-cnh/generate-cnh.ts +++ b/src/generate-cnh/generate-cnh.ts @@ -19,13 +19,29 @@ import { isRepeatedDigits } from "../_internals/is-repeated-digits/is-repeated-d * 2 security check digits, but no official text publishes the check-digit weights; the algorithm * below follows the community reference cited as `Based on:`. * - * Art. 4º § 1º of the same resolution states that the check digit is computed by the DSR system - * with a "módulo 11" routine in which a remainder of 0 or 1 yields the digit 0. That rounding is - * not the rule the registry numbers use in practice: the first verifier keeps the remainder - * itself, so a remainder of 1 yields the digit 1 (which is why `"00000000119"` is a valid CNH). - * The implementation follows the cited `Based on:` reference, not § 1º. + * Art. 4º § 1º of the same resolution has the DSR system compute "O dígito verificador" with a + * "módulo 11" routine in which a remainder of 0 or 1 yields the digit 0. It does not say which + * number it applies to: in the singular, right after inciso II, the Número do Espelho da CNH (9 + * characters plus 1 check digit, the only number of the article with a single one), it reads + * best as the rule of that digit, but its wording is generic, and it gives no weights. So it is + * not a source for the 2 check digits of the registry number, whose first verifier keeps the + * remainder itself, so a remainder of 1 yields the digit 1 (which is why `"00000000119"` is + * a valid CNH). Resoluções CONTRAN nº 976/2022, nº 998/2023 and nº 1.006/2024 amend Resolução nº + * 886/2021, but none of them touches art. 4º. + * + * Resolução CONTRAN nº 1.020/2025, the newer habilitação norm, repeats the layout in its art. 10 + * ("nove caracteres e dois dígitos verificadores") without any check digit rule, and its art. 140 + * does not revoke Resolução nº 886/2021, so art. 4º § 1º of the 886 stays the only official text + * on the calculation. * * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/Resolucao8862021F.pdf + * Resolução CONTRAN nº 886/2021, art. 4º, I: the Número do Registro Nacional, of "9 (nove) + * caracteres" and "2 (dois) dígitos verificadores de segurança"; the Número do Espelho da CNH of + * the same article has 9 characters and 1 check digit, the one § 1º most likely computes. + * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/Resolucao10202025.pdf + * Resolução CONTRAN nº 1.020, de 1º de dezembro de 2025 (DOU of 09/12/2025), art. 10: the same + * three numbers, the registro nacional "composto de nove caracteres e dois dígitos verificadores", + * with no "módulo 11" rule and no weights; its art. 140 does not revoke Resolução nº 886/2021. * @see Based on: https://siga0984.wordpress.com/2019/05/01/algoritmos-validacao-de-cnh/ */ export const generateCnh = (): string => { diff --git a/src/generate-cnpj/generate-cnpj.test.ts b/src/generate-cnpj/generate-cnpj.test.ts index 67fa9de19..b1d92cac9 100644 --- a/src/generate-cnpj/generate-cnpj.test.ts +++ b/src/generate-cnpj/generate-cnpj.test.ts @@ -9,6 +9,10 @@ const REMAINDER_TWO_DRAWS = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]; const BRANCH_FALLBACK_DRAWS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2]; +const UNASSIGNED_BRANCH_DRAWS = [ + 1, 2, 3, 4, 5, 6, 7, 8, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 0, 0, 0, 1, +]; + const INVALID_BRANCHES: [string, number][] = [ ["0, below the first ordem", 0], ["10000, past the last ordem", 10_000], @@ -93,6 +97,22 @@ describe("generateCnpj", () => { } }); + test("should draw again when the random ordem block comes out as 0000, which is never assigned", () => { + // 2.4.0 returned "12345678000004" for the first draw, 123456780000 (sums 220 ≡ 0 → 0, 216 ≡ 7 → 4) + const cnpj = generateWithForcedDraws(UNASSIGNED_BRANCH_DRAWS, 10, () => generateCnpj(1)); + + // 123456780001: 1·5+2·4+3·3+4·2+5·9+6·8+7·7+8·6+1·2 = 222 ≡ 2 → 9; + // 1234567800019: 1·6+2·5+3·4+4·3+5·2+6·9+7·8+8·7+1·3+9·2 = 237 ≡ 6 → 5 + expect(cnpj).toBe("12345678000195"); + }); + + test("should draw the alphanumeric ordem block again when it comes out as 0000", () => { + const cnpj = generateWithForcedDraws(UNASSIGNED_BRANCH_DRAWS, 36, () => generateCnpj(2)); + + expect(cnpj).toBe("12345678000195"); + expect(isValidCnpj(cnpj, { version: 2 })).toBe(true); + }); + test("should compute the first check digit as 9 when the weighted sum leaves remainder 2", () => { const cnpj = generateWithForcedDraws(REMAINDER_TWO_DRAWS, 10, () => generateCnpj(1)); @@ -157,14 +177,14 @@ describe("generateCnpj", () => { }); test("should generate alphanumeric CNPJs including E, O, T and U, which a previously restricted alphabet excluded even though isValidCnpj accepts them (the official RFB example '12.ABC.345/01DE-35' contains an E)", () => { - const usedChars = new Set(); + const usedCharacters = new Set(); for (let i = 0; i < 1000; i++) { - for (const char of generateCnpj(2)) { - usedChars.add(char); + for (const character of generateCnpj(2)) { + usedCharacters.add(character); } } - for (const char of ["E", "O", "T", "U"]) { - expect(usedChars.has(char)).toBe(true); + for (const character of ["E", "O", "T", "U"]) { + expect(usedCharacters.has(character)).toBe(true); } }); }); @@ -192,7 +212,7 @@ describe("generateCnpj", () => { }); test("should keep the ordem block numeric on the alphanumeric version, with letters in the raiz", () => { - const raizChars = new Set(); + const raizCharacters = new Set(); for (let index = 0; index < 100; index++) { const cnpj = generateCnpj({ version: 2, branch: 9999 }); @@ -201,12 +221,12 @@ describe("generateCnpj", () => { expect(cnpj.slice(8, 12)).toBe("9999"); expect(isValidCnpj(cnpj, { version: 2 })).toBe(true); - for (const char of cnpj.slice(0, 8)) { - raizChars.add(char); + for (const character of cnpj.slice(0, 8)) { + raizCharacters.add(character); } } - expect([...raizChars].some((char) => /[A-Z]/.test(char))).toBe(true); + expect([...raizCharacters].some((character) => /[A-Z]/.test(character))).toBe(true); }); test("should generate a numeric CNPJ with a branch when the version is 1", () => { @@ -286,6 +306,33 @@ describe("generateCnpj", () => { ); }); + test("should never write the unassigned ordem 0000, whatever root comes before it", () => { + const draw = fc.integer({ min: 0, max: 9 }); + const roots = fc.array(draw, { minLength: 8, maxLength: 8 }); + const branches = fc + .array(draw, { minLength: 4, maxLength: 4 }) + .filter((branch) => branch.some((digit) => digit !== 0)); + + fc.assert( + fc.property( + roots, + branches, + fc.constantFrom(1 as const, 2 as const), + (root, branch, version) => { + fc.pre(new Set([...root, ...branch]).size > 1); + + const draws = [...root, 0, 0, 0, 0, ...root, ...branch]; + const cnpj = generateWithForcedDraws(draws, version === 2 ? 36 : 10, () => + generateCnpj(version), + ); + + expect(cnpj.slice(8, 12)).toBe(branch.join("")); + expect(isValidCnpj(cnpj, { version })).toBe(true); + }, + ), + ); + }); + test("should write any ordem from 1 to 9999 into positions 9 to 12 of both versions", () => { fc.assert( fc.property(fc.integer({ min: 1, max: 9999 }), (branch) => { diff --git a/src/generate-cnpj/generate-cnpj.ts b/src/generate-cnpj/generate-cnpj.ts index e9ace8f4f..5f8bad433 100644 --- a/src/generate-cnpj/generate-cnpj.ts +++ b/src/generate-cnpj/generate-cnpj.ts @@ -11,7 +11,13 @@ const MIN_BRANCH = 1; const MAX_BRANCH = 9999; -const VALID_CNPJ_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; +/** + * The ordem block no establishment is given: the establishments of a CNPJ root are numbered from + * "0001", the matriz, on, so a random block that comes out as "0000" is drawn again. + */ +const UNASSIGNED_BRANCH = "0000"; + +const VALID_CNPJ_CHARACTERS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; /** * The parameters `generateCnpj` accepts, an alternative to passing the version positionally. @@ -33,12 +39,14 @@ export type GenerateCnpjParams = { branch?: number; }; -const generateRandomCnpjChars = (length: number): string => { - let chars = ""; +const generateRandomCnpjCharacters = (length: number): string => { + let characters = ""; for (let i = 0; i < length; i++) { - chars += VALID_CNPJ_CHARS.charAt(Math.floor(Math.random() * VALID_CNPJ_CHARS.length)); + characters += VALID_CNPJ_CHARACTERS.charAt( + Math.floor(Math.random() * VALID_CNPJ_CHARACTERS.length), + ); } - return chars; + return characters; }; // `Number.isInteger` as a type guard, so an out of range `branch` narrows to `number`. @@ -56,9 +64,12 @@ const generateBase = ( ? branch.toString().padStart(BRANCH_LENGTH, "0") : generatePart(BRANCH_LENGTH)); -const generateNonRepeatedBase = (generate: () => string): string => { +const isUsableBase = (base: string): boolean => + !isRepeatedDigits(base) && base.slice(ROOT_LENGTH) !== UNASSIGNED_BRANCH; + +const generateUsableBase = (generate: () => string): string => { let base = generate(); - while (isRepeatedDigits(base)) { + while (!isUsableBase(base)) { base = generate(); } return base; @@ -68,7 +79,7 @@ const generateCnpjWith = ( branch: number | undefined, generatePart: (length: number) => string, ): string => { - const base = generateNonRepeatedBase(() => generateBase(branch, generatePart)); + const base = generateUsableBase(() => generateBase(branch, generatePart)); const firstCheckDigit = String(calculateCnpjCheckDigit(base, CNPJ_FIRST_DIGIT_WEIGHTS)); const secondCheckDigit = String( calculateCnpjCheckDigit(base + firstCheckDigit, CNPJ_SECOND_DIGIT_WEIGHTS), @@ -90,6 +101,11 @@ const isGenerateCnpjParams = ( * object carrying that same version plus the "número de ordem" (filial) block to write in * positions 9 to 12. * + * A random ordem block is never "0000": the establishments of a root are numbered from "0001", + * the matriz, on, so that block is never assigned and a draw that comes out as "0000" is made + * again, the way a base of one repeated character is. Up to 2.4.0 about 1 in 10,000 numeric + * CNPJs came out with it. + * * @param {1 | 2 | GenerateCnpjParams} [versionOrParams] - The version of the CNPJ to be * generated: `1` for the numeric CNPJ and `2` for the alphanumeric one, or an options object. * Defaults to `1`, and never throws: `null`, `undefined` and any other runtime value that is @@ -113,6 +129,9 @@ const isGenerateCnpjParams = ( * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/cnpj * @see Official: https://www.gov.br/receitafederal/pt-br/centrais-de-conteudo/publicacoes/documentos-tecnicos/cnpj/manual-dv-cnpj.pdf * @see Official: https://www.gov.br/receitafederal/pt-br/acesso-a-informacao/acoes-e-programas/programas-e-atividades/cnpj-alfanumerico + * @see Official: https://www.gov.br/receitafederal/pt-br/centrais-de-conteudo/publicacoes/perguntas-e-respostas/cnpj/cnpj-alfanumerico.pdf + * Receita Federal, CNPJ alfanumérico, perguntas e respostas: positions 9 to 12 are the "ordem do + * estabelecimento", the matriz being the ordem 0001 and the filiais the ones after it. */ export const generateCnpj = (versionOrParams: 1 | 2 | GenerateCnpjParams = 1): string => { const params: GenerateCnpjParams = isGenerateCnpjParams(versionOrParams) @@ -121,6 +140,6 @@ export const generateCnpj = (versionOrParams: 1 | 2 | GenerateCnpjParams = 1): s return generateCnpjWith( params.branch, - params.version === 2 ? generateRandomCnpjChars : generateRandomNumber, + params.version === 2 ? generateRandomCnpjCharacters : generateRandomNumber, ); }; diff --git a/src/generate-pis/generate-pis.ts b/src/generate-pis/generate-pis.ts index 7d85a6cc7..a303e4d70 100644 --- a/src/generate-pis/generate-pis.ts +++ b/src/generate-pis/generate-pis.ts @@ -16,11 +16,16 @@ import { isRepeatedDigits } from "../_internals/is-repeated-digits/is-repeated-d * * The eSocial MOS states the NIS must have 11 numeric digits including the check digit, and the * SIRC technical manual confirms the check digit is verified with módulo 11; neither publishes - * the weight vector used below, which follows the community reference cited as `Based on:`. + * the weight vector used below, which follows the community reference cited as `Based on:`. No + * official document found publishes it: the Caixa layouts that carry the number (Cadastro NIS + * em lote, GRRF) only ask for a "Número de PIS/PASEP válido", or for a numeric value "maior que + * zero", without saying how the check digit is computed. * * @see Official: https://www.gov.br/inss/pt-br/direitos-e-deveres/inscricao-e-contribuicao/inscricao * @see Official: https://www.gov.br/esocial/pt-br/documentacao-tecnica/manuais/mos-manual-de-orientacao-do-esocial-vs-2-4.pdf * @see Official: https://www.sirc.gov.br/wp-content/uploads/manual_sirc_recomendacoes_tecnicas_v7.pdf + * @see Official: https://www.caixa.gov.br/Downloads/fgts-grrf-aplicativo-arquivos/Leiaute_Folha_Pgto_GRRF_v204.pdf + * Caixa, Leiaute GRRF v2.04, field 6 "PIS/PASEP": "Número de PIS/PASEP válido", with no algorithm. * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/pis.py */ export const generatePis = (): string => { diff --git a/src/generate-pix-payload/constants.ts b/src/generate-pix-payload/constants.ts index 20c7caabf..76aa57a87 100644 --- a/src/generate-pix-payload/constants.ts +++ b/src/generate-pix-payload/constants.ts @@ -23,6 +23,3 @@ export const AMOUNT_COMPARISON_DECIMAL_PLACES = 10; * length. */ export const TLV_OVERHEAD = 4; - -/** The characters the Pix manual allows in a `txid`, capped at the 25 the BR Code holds. */ -export const TXID_REGEX = /^[A-Za-z0-9]{1,25}$/; diff --git a/src/generate-pix-payload/generate-pix-payload.test.ts b/src/generate-pix-payload/generate-pix-payload.test.ts index 017e43ef5..2d0d7844d 100644 --- a/src/generate-pix-payload/generate-pix-payload.test.ts +++ b/src/generate-pix-payload/generate-pix-payload.test.ts @@ -1,6 +1,7 @@ import * as fc from "fast-check"; import { crc16Ccitt } from "../_internals/crc16-ccitt/crc16-ccitt"; +import { anyText, cnpjs, cpfs, phones } from "../_internals/test/arbitraries"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; import { generateCnpj } from "../generate-cnpj/generate-cnpj"; import { generateCpf } from "../generate-cpf/generate-cpf"; @@ -501,6 +502,51 @@ describe("generatePixPayload", () => { ); }); + test("should never generate a payload isValidPixPayload rejects, whatever it is given", () => { + const emails = fc.stringMatching( + /^[a-zA-Z0-9.!#$&'*+/=?^_`{|}~-]{1,20}@[a-z0-9]{1,10}\.[a-z]{2,3}$/, + ); + const keys = fc.oneof( + cpfs(), + cpfs().map( + (cpf) => `${cpf.slice(0, 3)}.${cpf.slice(3, 6)}.${cpf.slice(6, 9)}-${cpf.slice(9)}`, + ), + cnpjs(), + cnpjs(2), + phones("mobile"), + phones("mobile").map((phone) => `+55 ${phone}`), + emails, + fc.uuid(), + fc.uuid().map((uuid) => uuid.toUpperCase()), + anyText, + ); + const params = fc.record( + { + key: keys, + url: fc.oneof(urls, anyText), + merchantName: fc.oneof(names, anyText), + merchantCity: fc.oneof(cities, fc.string({ unit: "grapheme", minLength: 16 })), + amount: fc.oneof( + cents.map((value) => value / 100), + fc.double(), + ), + txid: fc.oneof(txids, anyText), + description: anyText, + }, + { requiredKeys: ["merchantName", "merchantCity"] }, + ); + + fc.assert( + fc.property(params, (pix) => { + const payload = generatePixPayload(pix); + + fc.pre(payload !== null); + + expect(isValidPixPayload(payload ?? "")).toBe(true); + }), + ); + }); + test("should fold the merchant name and city down to the lengths the BR Code allows", () => { fc.assert( fc.property( diff --git a/src/generate-pix-payload/generate-pix-payload.ts b/src/generate-pix-payload/generate-pix-payload.ts index 13c5c72e8..614e7ecbc 100644 --- a/src/generate-pix-payload/generate-pix-payload.ts +++ b/src/generate-pix-payload/generate-pix-payload.ts @@ -26,6 +26,7 @@ import { PIX_TRANSACTION_CURRENCY, PIX_TRANSACTION_CURRENCY_ID, PIX_TXID_ID, + PIX_TXID_REGEX, PIX_URL_ID, PIX_URL_MAX_LENGTH, } from "../_internals/constants/pix"; @@ -40,7 +41,6 @@ import { AMOUNT_REGEX, AMOUNT_COMPARISON_DECIMAL_PLACES, TLV_OVERHEAD, - TXID_REGEX, } from "./constants"; /** The parameters `generatePixPayload` takes to build a Pix BR Code. */ @@ -126,7 +126,7 @@ const resolveFormattedAmount = ( ) return null; - if (txid !== undefined && (typeof txid !== "string" || !TXID_REGEX.test(txid))) return null; + if (txid !== undefined && (typeof txid !== "string" || !PIX_TXID_REGEX.test(txid))) return null; return formattedAmount; }; @@ -168,6 +168,10 @@ const resolveFormattedAmount = ( * does not survive that round trip (`0.005`, `123.456`) is refused rather than rounded into a * payload that asks the payer for a different sum. * + * Every payload returned here is one `isValidPixPayload` accepts: the key in its DICT form, + * the name and city within 25 and 15 characters, the 62-05 `txid` always written (`***` when + * none is given, and always for a dynamic payload) and a non-zero amount. + * * @param {GeneratePixPayloadParams} params - The parameters of the payload. * @param {string} [params.key] - The Pix key of the receiver. Required unless `url` is given. * @param {string} [params.url] - The PSP location of a dynamic payload. Required unless `key` diff --git a/src/generate-processo-juridico/generate-processo-juridico.ts b/src/generate-processo-juridico/generate-processo-juridico.ts index 0dcaf2dad..882d783fe 100644 --- a/src/generate-processo-juridico/generate-processo-juridico.ts +++ b/src/generate-processo-juridico/generate-processo-juridico.ts @@ -53,7 +53,8 @@ const COURTS = [...PROCESSO_JURIDICO_TRIBUNALS.keys()]; * generateProcessoJuridico({ court: 10 }); // null (no such órgão) * ``` * - * Resolução CNJ nº 65/2008 defines this Número Único de Processo layout and its check digits, and + * Resolução CNJ nº 65/2008 defines this Número Único de Processo layout, its check digits (art. 1º, + * § 2º, and the algorithm in its Anexo VIII, "CÁLCULO DO DÍGITO VERIFICADOR"), and * closes the list of órgão (`J`) and tribunal (`TR`) codes in art. 1º, § 4º and § 5º. * * @see Official: https://atos.cnj.jus.br/atos/detalhar/119 diff --git a/src/generate-renavam/generate-renavam.ts b/src/generate-renavam/generate-renavam.ts index 1fd230711..96b291617 100644 --- a/src/generate-renavam/generate-renavam.ts +++ b/src/generate-renavam/generate-renavam.ts @@ -20,10 +20,19 @@ const BASE_LENGTH = 10; * generateRenavam(); // "12345678900" * ``` * - * The Código de Trânsito Brasileiro creates the RENAVAM registry but does not define its check - * digit, so the algorithm follows the two community references cited as `Based on:`. + * Portaria DENATRAN nº 27, de 25 de janeiro de 2013, art. 1º, gives the Portaria DENATRAN nº + * 03/86, art. 3º, its wording in force: the Código RENAVAM is "composto de 11 (onze) dígitos", + * and "possui 10 dígitos e um dígito verificador, calculado através do módulo 11, peso 9". Read + * as the usual módulo 11 with weights 2 to 9 from the right, that gives 3, 2, 9, 8, 7, 6, 5, 4, 3 + * and 2 over the ten base digits, the weights used here; the portaria does not write them out + * nor say what a remainder of 0, 1 or 10 turns into, so that part follows the community + * references cited as `Based on:`. Nor does it say how a 9 digit code issued before 1 April 2013 + * is read in 11: padding it with zeros on the left is market practice. * * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l9503compilado.htm + * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/arquivos-senatran/portarias/2013/portaria0272013.pdf + * Portaria DENATRAN nº 27/2013, art. 1º: "10 dígitos e um dígito verificador, calculado através do + * módulo 11, peso 9"; art. 2º: the 11 digit numbering from 1 April 2013. * @see Based on: https://github.com/klawdyo/validation-br/blob/main/src/renavam.ts * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/renavam.py */ diff --git a/src/generate-voter-id/generate-voter-id.ts b/src/generate-voter-id/generate-voter-id.ts index 8dbb1a964..d4ecbf10e 100644 --- a/src/generate-voter-id/generate-voter-id.ts +++ b/src/generate-voter-id/generate-voter-id.ts @@ -29,7 +29,7 @@ const getFederativeUnion = (state: StateCode | "ZZ"): string => * for, or `"ZZ"` for a voter id issued abroad. Defaults to `"ZZ"` when omitted or unknown, a key * of the prototype chain (`"__proto__"`, `"constructor"`) and a value that is not a string * included, so a malformed state never throws. - * @returns {string} A valid 12-digit voter id string without formatting. + * @returns {string} A valid 12-digit voter id string without formatting, leading zeros kept. * * @example * ```typescript @@ -38,14 +38,23 @@ const getFederativeUnion = (state: StateCode | "ZZ"): string => * generateVoterId("XX" as StateCode); // falls back to "ZZ" instead of throwing * ``` * - * Resolução TSE nº 23.659/2021, art. 36, parágrafo único, confirms the federative union table and - * the two-step módulo 11 structure; the weights themselves are not published by the TSE and follow - * the community reference cited as `Based on:`. + * The 8-digit sequential number is always returned with its leading zeros, so the result has 12 + * digits. The TSE drops those zeros when it issues a voter id; the same id without them (e.g. + * "123450159" for "000123450159") is accepted by `isValidVoterId` too, and `parseVoterId` keeps + * whichever form it is given. + * + * Resolução TSE nº 23.659/2021, art. 36, confirms the structure ("composto por até 12 algarismos", + * the first eight sequential), the federative union table and the two-step módulo 11 structure; + * the weights themselves and the São Paulo and Minas Gerais remainder rule have no official + * source (the resolution, like the Resolução TSE nº 21.538/2003 it revoked, says only "Módulo + * 11") and follow the community reference cited as `Based on:`. * * The TSE resolution page sits behind a bot filter and answers HTTP 403 to every non-browser * client, so it has to be opened in a browser. * * @see Official: https://www.tse.jus.br/legislacao/compilada/res/2021/resolucao-no-23-659-de-26-de-outubro-de-2021 + * Resolução TSE nº 23.659/2021, art. 36: "os oito primeiros algarismos serão sequenciados, + * desprezando-se, na emissão, os zeros à esquerda". * @see Based on: https://siga0984.wordpress.com/2019/05/01/algoritmos-validacao-de-titulo-de-eleitor/ */ export const generateVoterId = ( diff --git a/src/get-address-info-by-cep/get-address-info-by-cep.test.ts b/src/get-address-info-by-cep/get-address-info-by-cep.test.ts index 14e7396a9..d75d7e115 100644 --- a/src/get-address-info-by-cep/get-address-info-by-cep.test.ts +++ b/src/get-address-info-by-cep/get-address-info-by-cep.test.ts @@ -255,6 +255,16 @@ describe("getAddressInfoByCep", () => { expectDefaultAddress(result); }); + it("should throw GetAddressInfoByCepValidationError for a negative or fractional number, without a request", async () => { + await expect(getAddressInfoByCep(-1_310_100)).rejects.toThrow( + GetAddressInfoByCepValidationError, + ); + await expect(getAddressInfoByCep(131_010.1)).rejects.toThrow( + GetAddressInfoByCepValidationError, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("should accept CEP with mask", async () => { const result = await getAddressInfoByCep(VALID_CEP_MASKED); diff --git a/src/get-address-info-by-cep/get-address-info-by-cep.ts b/src/get-address-info-by-cep/get-address-info-by-cep.ts index 39489e34f..f1fa8e787 100644 --- a/src/get-address-info-by-cep/get-address-info-by-cep.ts +++ b/src/get-address-info-by-cep/get-address-info-by-cep.ts @@ -1,4 +1,5 @@ import { fetchWithRetry } from "../_internals/fetch-with-retry/fetch-with-retry"; +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; import { isValidCep } from "../is-valid-cep/is-valid-cep"; @@ -185,6 +186,10 @@ const providerMap: Record Promise> = * back the moment its own failure lands, and therefore the moment an all-failed rejection can * surface. * + * A number is only read as a CEP when it is a non-negative safe integer: a sign or a decimal + * point would otherwise be dropped and another CEP looked up, so such a number is rejected + * before any request is made. + * * @param {string|number} cep - The CEP (Brazilian postal code) to search for. Can be a string or number. * @param {GetAddressInfoByCepOptions} options - Optional configuration for the function. * @param {CepProvider[]} options.providers - List of providers to use. Defaults to `["viacep", "brasilapi"]` @@ -210,6 +215,9 @@ const providerMap: Record Promise> = * * // Using number input * const address = await getAddressInfoByCep(1310100); + * + * // A negative or fractional number is rejected + * await getAddressInfoByCep(-1310100); // throws GetAddressInfoByCepValidationError * ``` * * @see Official: https://www.correios.com.br/enviar/precisa-de-ajuda/tudo-sobre-cep @@ -230,7 +238,7 @@ export const getAddressInfoByCep = async ( cepString = cepString.padStart(8, "0"); } - if (!isValidCep(cepString)) { + if (!isLookupCode(cep) || !isValidCep(cepString)) { throw new GetAddressInfoByCepValidationError("CEP inválido"); } diff --git a/src/get-area-code-info/get-area-code-info.ts b/src/get-area-code-info/get-area-code-info.ts index c1a16466c..d5bc5030e 100644 --- a/src/get-area-code-info/get-area-code-info.ts +++ b/src/get-area-code-info/get-area-code-info.ts @@ -47,10 +47,17 @@ export type AreaCodeInfo = { * * Resolução Anatel nº 749/2022, art. 15, defines the Código Nacional (area code); the gov.br * page below lists the codes actually allocated and links, under "POR MUNICÍPIO", to the Anexo - * of Resolução Anatel nº 263/2001, which gives the Código Nacional of every municipality. + * of Resolução Anatel nº 263/2001, which gives the Código Nacional of every municipality. The + * current Anatel table (`Codigos_Nacionais.csv` of the Painel de Áreas Tarifárias, 21/09/2026) + * confirms the 67 codes, their states and the four cross-border ones. * * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 * @see Official: https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais + * @see Official: https://informacoes.anatel.gov.br/paineis/areas-tarifarias/codigos-nacionais + * Anatel, Painel de Dados de Áreas Tarifárias, "Códigos Nacionais", the page the Anatel FAQ + * points to for the CN of every municipality ("67 (sessenta e sete) áreas de numeração"). + * @see Official: https://www.anatel.gov.br/dadosabertos/paineis_de_dados/areastarifarias/pgcn.zip + * `Codigos_Nacionais.csv` of 21/09/2026, the CN of all 5,571 municipalities in force. * @see Based on: https://informacoes.anatel.gov.br/legislacao/resolucoes/2001/383-resolucao-263 * Anexo of Resolução nº 263/2001 (revoked; still the table Anatel's Códigos Nacionais page links to). * @see Based on: https://brasilapi.com.br/docs#tag/DDD diff --git a/src/get-area-codes-by-state/get-area-codes-by-state.ts b/src/get-area-codes-by-state/get-area-codes-by-state.ts index 5bcd23493..ad2fb15d7 100644 --- a/src/get-area-codes-by-state/get-area-codes-by-state.ts +++ b/src/get-area-codes-by-state/get-area-codes-by-state.ts @@ -29,10 +29,17 @@ import { AREA_CODE_SECONDARY_STATES, AREA_CODE_STATES } from "../_internals/cons * * Resolução Anatel nº 749/2022, art. 15, defines the Código Nacional (area code). The Anexo the * gov.br page below links to, giving the Código Nacional of every municipality, is the one this - * inverse lookup was derived from and is no longer in force. + * inverse lookup was derived from and is no longer in force; the current Anatel table + * (`Codigos_Nacionais.csv` of the Painel de Áreas Tarifárias, 21/09/2026) gives the same result + * for every state. * * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 * @see Official: https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais + * @see Official: https://informacoes.anatel.gov.br/paineis/areas-tarifarias/codigos-nacionais + * Anatel, Painel de Dados de Áreas Tarifárias, "Códigos Nacionais", the page the Anatel FAQ + * points to for the CN of every municipality ("67 (sessenta e sete) áreas de numeração"). + * @see Official: https://www.anatel.gov.br/dadosabertos/paineis_de_dados/areastarifarias/pgcn.zip + * `Codigos_Nacionais.csv` of 21/09/2026, the CN of all 5,571 municipalities in force. * @see Based on: https://informacoes.anatel.gov.br/legislacao/resolucoes/2001/383-resolucao-263 * Anexo of Resolução nº 263/2001, revoked, and still the table Anatel's page links to. */ diff --git a/src/get-boleto-info/constants.ts b/src/get-boleto-info/constants.ts index 76aabaccf..a7770ba7b 100644 --- a/src/get-boleto-info/constants.ts +++ b/src/get-boleto-info/constants.ts @@ -2,9 +2,11 @@ * The "fator de vencimento" (expiration factor) counts days since the base date 07/10/1997 that * Carta-Circular BCB nº 2.926/2000 places in positions 6-9 of the barcode, and cycles every * `CYCLE_LENGTH` days once it reaches its 4-digit maximum: it reached 9999 on 21/02/2025 and - * restarted at 1000 on 22/02/2025. FEBRABAN announced that reset in Comunicado FB-009/2023, - * which is not published on FEBRABAN's public site; the Bradesco cobrança layout manual below - * reproduces the rule and its correlation table. + * restarted at 1000 on 22/02/2025. No official document with that reset could be found: FEBRABAN + * publishes no communiqué on it on its site (its search returns none), the Convenção da Cobrança + * of 05/02/2021 refers the factor to an Anexo VI that does not carry it, and neither the + * Carta-Circular nor the Layout de Arrecadação covers it. The rule and its correlation table are + * only in bank manuals, such as the Bradesco cobrança layout manual below (Versão 17). * * `RANGE_BEFORE` and `RANGE_AFTER` are a heuristic of this library, not a published rule. * Neither FEBRABAN nor the Banco Central publishes any way of telling an old cycle factor from @@ -14,10 +16,12 @@ * * @see Official: https://www.bcb.gov.br/pre/normativos/c_circ/2000/pdf/c_circ_2926_v1_O.pdf * @see Official: https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf + * FEBRABAN "Layout Padrão de Arrecadação/Recebimento com Utilização do Código de Barras", + * Versão 08 (file of 11/05/2026), "Vigência: a partir de 01.06.2026". * @see Official: https://portal.febraban.org.br/pagina/3425/33/pt-br/layout-febraban * @see Based on: https://banco.bradesco/assets/pessoajuridica/pdf/4008-524-0121-layout-cobranca-versao-portugues.pdf - * Bradesco "Layout da Cobrança" manual: base date 07/10/1997, 03/07/2000 = 1000, 21/02/2025 = 9999 - * and a restart at 1000 on 22/02/2025. + * Bradesco "Layout da Cobrança" manual, Versão 17: base date 07/10/1997, 03/07/2000 = 1000, + * 21/02/2025 = 9999 and a restart at 1000 on 22/02/2025. */ export const DAY_IN_MS = 86_400_000; diff --git a/src/get-boleto-info/get-boleto-info.test.ts b/src/get-boleto-info/get-boleto-info.test.ts index d37d2fd17..1e6562836 100644 --- a/src/get-boleto-info/get-boleto-info.test.ts +++ b/src/get-boleto-info/get-boleto-info.test.ts @@ -65,6 +65,15 @@ describe("getBoletoInfo", () => { ); }); + test("when it is a FEBRABAN Convenção da Cobrança Situação 2 slip (bank 988, moeda 0, factor 0000, ISPB 18236120 in positions 10 to 19 of the barcode), reading the ISPB instead of an amount", () => { + expect(getBoletoInfo("98800000060114971860168524522114100000018236120")).toStrictEqual({ + amount: 0, + expirationDate: null, + bankCode: "988", + ispb: "18236120", + }); + }); + test("when the amount field is all zeros (same fixture as the 'valid without mask' boleto, amount positions 37-46 zeroed and the main check digit recalculated)", () => { expect(getBoletoInfo("00190000090114971860168524522114675860000000000")?.amount).toBe(0); }); @@ -266,6 +275,7 @@ describe("getBoletoInfo types", () => { amount: number; expirationDate: Date | null; bankCode: string; + ispb?: string; type?: "arrecadacao"; segment?: number; value?: number; diff --git a/src/get-boleto-info/get-boleto-info.ts b/src/get-boleto-info/get-boleto-info.ts index 34403fd92..2a86a4b5d 100644 --- a/src/get-boleto-info/get-boleto-info.ts +++ b/src/get-boleto-info/get-boleto-info.ts @@ -1,3 +1,4 @@ +import { ISPB_INDEX, ISPB_ONLY_PREFIX } from "../_internals/constants/boleto"; import { parseArrecadacao } from "../_internals/parse-arrecadacao/parse-arrecadacao"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; import { isValidBoleto } from "../is-valid-boleto/is-valid-boleto"; @@ -15,12 +16,17 @@ import { /** The fields `getBoletoInfo` reads out of a bank slip (boleto). */ export type BoletoInfo = { - /** Amount in cents. */ + /** Amount in cents; `0` for a Situação 2 slip, which carries the `ispb` in its place. */ amount: number; /** Due date read from the "fator de vencimento", or `null` when the bank slip carries none. */ expirationDate: Date | null; /** Three digit bank code (COMPE), empty for an arrecadação bank slip. */ bankCode: string; + /** + * The 8 digit ISPB of the institution that issued a FEBRABAN Convenção da Cobrança "Situação 2" + * slip (bank code `988`, código de moeda `0`), present only on such a slip. + */ + ispb?: string; /** Present and set to "arrecadacao" only for convênio/tributos bank slips. */ type?: "arrecadacao"; /** Arrecadação segment (1 to 7, or 9 for the bank's own use), the kind of biller the bank slip belongs to. */ @@ -108,6 +114,10 @@ export type GetBoletoInfoOptions = { * the first cycle, so a `referenceDate` older than the scheme itself still resolves a factor to * the oldest date that factor can denote rather than to one before the 07/10/1997 base date. * + * A FEBRABAN Convenção da Cobrança "Situação 2" slip, issued by an institution identified only + * by its ISPB (bank code `988`, código de moeda `0`, see `isValidBoleto`), carries that ISPB where + * the amount would be: it comes back as `ispb`, with `amount` set to `0`. + * * @param {string} value - The boleto digitable line (can be with or without mask). * @param {GetBoletoInfoOptions} [options] - Optional options. * @param {Date} options.referenceDate - Date used to resolve the "fator de vencimento" cycle. Defaults to now. @@ -120,26 +130,33 @@ export type GetBoletoInfoOptions = { * }); * // { amount: 102656, expirationDate: new Date(2018, 6, 15), bankCode: '001' } * + * getBoletoInfo('98800000060114971860168524522114100000018236120'); + * // { amount: 0, expirationDate: null, bankCode: '988', ispb: '18236120' } + * * getBoletoInfo('846100000005246100291102005460339004695895061080'); * // { amount: 2461, expirationDate: null, bankCode: '', type: 'arrecadacao', segment: 4, value: 24.61, hasEffectiveValue: true } * * getBoletoInfo('invalid'); // null * ``` * - * Carta-Circular BCB nº 2.926/2000 specifies the linha digitável fields and the módulo 11 - * check digit (using 1 for remainders 0, 10 and 1) of the 47 digit cobrança bancária slip, - * including the position of the fator de vencimento field. The FEBRABAN "Layout Padrão de + * Carta-Circular BCB nº 2.926/2000 specifies the linha digitável fields, the código de moeda + * `9` (real) in position 4 of the barcode and the módulo 11 check digit (1 when 11 minus the + * remainder gives 0, 10 or 11, i.e. when the remainder is 0 or 1) of the 47 digit cobrança + * bancária slip, including the position of the fator de vencimento field. The FEBRABAN "Layout Padrão de * Arrecadação/Recebimento com Utilização do Código de Barras" and the FEBRABAN layout index - * cover the arrecadação slip. The 22/02/2025 reset of the fator de vencimento is in neither: - * the Bradesco cobrança layout manual below reproduces the FEBRABAN rule. See + * cover the arrecadação slip. The 22/02/2025 reset of the fator de vencimento is in neither, + * and no FEBRABAN communiqué on it is published: the rule is only in bank manuals, such as the + * Bradesco cobrança layout manual below (Versão 17). See * `src/get-boleto-info/constants.ts` for the fator de vencimento cycle base date and reset. * * @see Official: https://www.bcb.gov.br/pre/normativos/c_circ/2000/pdf/c_circ_2926_v1_O.pdf * @see Official: https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf + * FEBRABAN "Layout Padrão de Arrecadação/Recebimento com Utilização do Código de Barras", + * Versão 08 (file of 11/05/2026), "Vigência: a partir de 01.06.2026". * @see Official: https://portal.febraban.org.br/pagina/3425/33/pt-br/layout-febraban * @see Based on: https://banco.bradesco/assets/pessoajuridica/pdf/4008-524-0121-layout-cobranca-versao-portugues.pdf - * Bradesco "Layout da Cobrança" manual: base date 07/10/1997, 03/07/2000 = 1000, 21/02/2025 = 9999 - * and a restart at 1000 on 22/02/2025. + * Bradesco "Layout da Cobrança" manual, Versão 17: base date 07/10/1997, 03/07/2000 = 1000, + * 21/02/2025 = 9999 and a restart at 1000 on 22/02/2025. */ export const getBoletoInfo = (value: string, options?: GetBoletoInfoOptions): BoletoInfo | null => { if (!isValidBoleto(value)) return null; @@ -167,6 +184,10 @@ export const getBoletoInfo = (value: string, options?: GetBoletoInfoOptions): Bo options?.referenceDate ?? new Date(), ); + if (sanitized.startsWith(ISPB_ONLY_PREFIX)) { + return { amount: 0, expirationDate, bankCode, ispb: sanitized.slice(ISPB_INDEX) }; + } + const amount = Number(sanitized.slice(37, 47)); return { amount, expirationDate, bankCode }; diff --git a/src/get-cbo/get-cbo.test.ts b/src/get-cbo/get-cbo.test.ts index 4d9c21c66..15f3a6d08 100644 --- a/src/get-cbo/get-cbo.test.ts +++ b/src/get-cbo/get-cbo.test.ts @@ -1,7 +1,7 @@ import * as fc from "fast-check"; import { CBO_TITLES } from "../_internals/constants/cbo"; -import { anyGarbage } from "../_internals/test/arbitraries"; +import { anyGarbage, digitsUpTo } from "../_internals/test/arbitraries"; import { expectNeverThrows } from "../_internals/test/properties"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; import { isValidCbo } from "../is-valid-cbo/is-valid-cbo"; @@ -132,6 +132,23 @@ describe("getCbo", () => { ); }); + const lookupInputs = fc.oneof( + codeArbitrary, + codeArbitrary.map((code) => `${code.slice(0, 4)}-${code.slice(4)}`), + fc.nat({ max: 999_999 }), + digitsUpTo(8), + anyGarbage, + fc.anything(), + ); + + test("should return null exactly when isValidCbo is false", () => { + fc.assert( + fc.property(lookupInputs, (value) => { + expect(getCbo(value as string) === null).toBe(!isValidCbo(value as string)); + }), + ); + }); + test("should resolve every known code with the hyphen mask", () => { fc.assert( fc.property(codeArbitrary, (code) => { diff --git a/src/get-cbo/get-cbo.ts b/src/get-cbo/get-cbo.ts index 54025c041..375cea54e 100644 --- a/src/get-cbo/get-cbo.ts +++ b/src/get-cbo/get-cbo.ts @@ -1,9 +1,8 @@ -import { CBO_FORMAT_REGEX, CBO_TITLES } from "../_internals/constants/cbo"; -import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; +import { CBO_TITLES } from "../_internals/constants/cbo"; import { padLookupCode } from "../_internals/pad-lookup-code/pad-lookup-code"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; - -const CBO_LENGTH = 6; +import { CBO_LENGTH } from "../is-valid-cbo/constants"; +import { isValidCbo } from "../is-valid-cbo/is-valid-cbo"; /** * A CBO (Classificação Brasileira de Ocupações) occupation. @@ -33,7 +32,8 @@ export type Cbo = { * * @param {string|number} value - The CBO code to look up, with or without the hyphen * mask, e.g. `"2124-05"`, `"212405"` or `212405`. - * @returns {Cbo|null} The matching occupation, or null when the code is unknown or invalid. + * @returns {Cbo|null} The matching occupation, or null when the code is unknown or invalid, + * which is exactly when `isValidCbo` returns false. * * @example * ```typescript @@ -52,16 +52,9 @@ export type Cbo = { * official CSV was used. */ export const getCbo = (value: string | number): Cbo | null => { - if (!isLookupCode(value)) return null; - - const code = padLookupCode(value, CBO_LENGTH); - - if (!CBO_FORMAT_REGEX.test(code)) return null; - - const digits = sanitizeToDigits(code); - const description = CBO_TITLES[digits]; + if (!isValidCbo(value)) return null; - if (description === undefined) return null; + const code = sanitizeToDigits(padLookupCode(value, CBO_LENGTH)); - return { code: digits, description }; + return { code, description: CBO_TITLES[code] }; }; diff --git a/src/get-certidao-info/constants.ts b/src/get-certidao-info/constants.ts index 6ebaf08b0..ab597c6df 100644 --- a/src/get-certidao-info/constants.ts +++ b/src/get-certidao-info/constants.ts @@ -5,12 +5,14 @@ * (proclamas), Livro E (demais atos), Livro E desdobrado para emancipações and Livro E * desdobrado para interdições. * - * The in-force art. 473, V of the Código Nacional de Normas da Corregedoria Nacional de Justiça - * lists only the codes 1 to 7, and no CNJ primary text reachable today publishes the other two: - * the Anexo IV of the revoked Provimento CNJ nº 63/2017 lists the same seven. The codes 8 - * (emancipação) and 9 (interdição) come from the `Based on:` references below: ghiorzi.org prints - * the nine book list and the cited Casilhero support class maps the same nine. They are kept - * because matrículas carrying them circulate. + * The codes 1 to 7 are the table in force, art. 473, V of the Código Nacional de Normas da + * Corregedoria Nacional de Justiça (Provimento CNJ nº 149/2023, inciso V in the redação of the + * Provimento CN nº 182/2024), from "1: Livro A (Nascimento)" to "7: Livro E (Demais atos + * relativos ao registro civil)". The codes 8 and 9 come from the Provimento CNJ nº 3/2009, art. + * 7º, V: "8: Livro E (Desdobrado para registro específico das Emancipações)" and "9: Livro E + * (Desdobrado para registro específico das Interdições)". That provimento was revoked by the + * Provimento CNJ nº 63/2017, but the certidões issued under it from 2010 on carry those + * matrículas and are still valid documents, so both codes are kept. * * @see Official: https://atos.cnj.jus.br/atos/detalhar/5243 * Código Nacional de Normas da Corregedoria Nacional de Justiça - Foro Extrajudicial (Provimento @@ -28,11 +30,14 @@ * structure: "a matrícula, de inserção obrigatória nas certidões (primeira e demais vias) emitidas * pelos Cartórios de Registro Civil das Pessoas Naturais a partir de 1º de janeiro de 2010, é * formada pelos seguintes elementos", incisos I to IX fixing the same 6 + 2 + 2 + 4 + 1 + 5 + 3 + - * 7 + 2 positions art. 473 carries today (revoked; historical). + * 7 + 2 positions art. 473 carries today (revoked by the Provimento CNJ nº 63/2017; historical). + * Its inciso V also listed "8: Livro E (Desdobrado para registro específico das Emancipações)" + * and "9: Livro E (Desdobrado para registro específico das Interdições)", which art. 473, V no + * longer has but which are still accepted, the certidões issued under it carrying them, and its + * inciso IX had the check digits "formado automaticamente por meio do programa" the CNJ + * Corregedoria handed to the registrars, whose algorithm was never published. * @see Based on: http://ghiorzi.org/DVnew.htm * Description of the nine books and their codes. - * @see Based on: https://github.com/Casilhero/brazilian-validators/blob/main/src/Support/CertidaoInfo.php - * Reference implementation agreeing on the same nine books, in the same order. */ export const CERTIDAO_TYPES = [ "birth", diff --git a/src/get-certidao-info/get-certidao-info.test.ts b/src/get-certidao-info/get-certidao-info.test.ts index 136e0bcfd..f9086ffaa 100644 --- a/src/get-certidao-info/get-certidao-info.test.ts +++ b/src/get-certidao-info/get-certidao-info.test.ts @@ -38,7 +38,7 @@ describe("getCertidaoInfo", () => { expect(getCertidaoInfo("not-a-matricula")).toBeNull(); }); - test("when the book code is 0, outside the nine books of the Provimento", () => { + test("when the book code is 0, outside the seven books of the Provimento", () => { expect(getCertidaoInfo("10453901552013000012021000012387")).toBeNull(); }); @@ -53,6 +53,26 @@ describe("getCertidaoInfo", () => { }); describe("should return the parsed matrícula", () => { + test("for an emancipation act, book code 8 of the revoked Provimento CNJ nº 3/2009", () => { + expect(getCertidaoInfo("10453901552013800012021000012343")).toEqual({ + registryCns: "104539", + acervo: "01", + service: "55", + year: 2013, + type: "emancipation", + typeCode: 8, + book: "00012", + page: "021", + term: "0000123", + checkDigits: "43", + }); + }); + + test("for an interdiction act, book code 9 of the revoked Provimento CNJ nº 3/2009", () => { + expect(getCertidaoInfo("10453901552013900012021000012398")?.type).toBe("interdiction"); + expect(getCertidaoInfo("10453901552013900012021000012398")?.typeCode).toBe(9); + }); + test("for 104539.01.55.2013.1.00012.021.0000123-21, the worked example of ghiorzi.org/DVnew.htm", () => { expect(getCertidaoInfo("104539 01 55 2013 1 00012 021 0000123 21")).toEqual({ registryCns: "104539", @@ -107,14 +127,6 @@ describe("getCertidaoInfo", () => { expect(getCertidaoInfo("10453901552013700012021000012315")?.type).toBe("other"); }); - test("for an emancipation act, book code 8", () => { - expect(getCertidaoInfo("10453901552013800012021000012343")?.type).toBe("emancipation"); - }); - - test("for an interdiction act, book code 9", () => { - expect(getCertidaoInfo("10453901552013900012021000012398")?.type).toBe("interdiction"); - }); - test("for a matrícula whose first modulus 11 remainder is 10 (826683 01 55 2015 2 09245 842 9990114 18)", () => { expect(getCertidaoInfo("82668301552015209245842999011418")).toEqual({ registryCns: "826683", @@ -137,7 +149,7 @@ describe("getCertidaoInfo", () => { fc.stringMatching(/^[0-9]{2}$/), fc.constant("55"), fc.integer({ min: 1000, max: 9999 }), - fc.integer({ min: 1, max: 9 }), + fc.integer({ min: 1, max: 7 }), fc.stringMatching(/^[0-9]{5}$/), fc.stringMatching(/^[0-9]{3}$/), fc.stringMatching(/^[0-9]{7}$/), diff --git a/src/get-certidao-info/get-certidao-info.ts b/src/get-certidao-info/get-certidao-info.ts index 6bb9f1348..9dcc139ed 100644 --- a/src/get-certidao-info/get-certidao-info.ts +++ b/src/get-certidao-info/get-certidao-info.ts @@ -4,16 +4,24 @@ import { isValidCertidao } from "../is-valid-certidao/is-valid-certidao"; import { CERTIDAO_TYPES } from "./constants"; /** - * The nine books (tipo do livro) a matrícula de registro civil can point to, in the order of the - * codes 1 to 9. `getCertidaoInfo` names the book of a matrícula with one of these, and - * `isValidCertidao` accepts a list of them. + * The books (tipo do livro) a matrícula de registro civil can point to, codes 1 to 9. + * `getCertidaoInfo` names the book of a matrícula with one of them, and `isValidCertidao` accepts + * a list of them. * - * The in-force art. 473, V of the Código Nacional de Normas da Corregedoria Nacional de Justiça - * lists only the codes 1 to 7, and no CNJ primary text reachable today publishes the other two: - * the Anexo IV of the revoked Provimento CNJ nº 63/2017 lists the same seven. The codes 8 - * (`"emancipation"`) and 9 (`"interdiction"`) come from the `Based on:` references below: ghiorzi.org and - * validation-br both print the nine book list. They are kept because matrículas carrying them - * circulate. + * Art. 473, V of the Código Nacional de Normas da Corregedoria Nacional de Justiça (Provimento + * CNJ nº 149/2023, inciso V in the redação of the Provimento CN nº 182/2024), the table in force, + * lists the codes 1 to 7, from "1: Livro A (Nascimento)" to "7: Livro E (Demais atos relativos ao + * registro civil)". `"emancipation"` (code 8) and `"interdiction"` (code 9) come from the + * Provimento CNJ nº 3/2009, art. 7º, V, revoked by the Provimento CNJ nº 63/2017: "8: Livro E + * (Desdobrado para registro específico das Emancipações)" and "9: Livro E (Desdobrado para + * registro específico das Interdições)". They are kept because the certidões issued under it + * from 2010 on carry those matrículas and are still valid documents. + * + * @see Official: https://atos.cnj.jus.br/atos/detalhar/5243 + * Provimento CNJ nº 149/2023, art. 473, V: "1: Livro A (Nascimento)" to "7: Livro E (Demais atos + * relativos ao registro civil)". + * @see Official: https://atos.cnj.jus.br/atos/detalhar/1310 + * Provimento CNJ nº 3/2009, art. 7º, V (revoked): the codes 8 and 9. */ export type CertidaoType = | "birth" @@ -23,7 +31,9 @@ export type CertidaoType = | "stillbirth" | "banns" | "other" + /** Book code 8 of the revoked Provimento CNJ nº 3/2009 (see `CertidaoType`). */ | "emancipation" + /** Book code 9 of the revoked Provimento CNJ nº 3/2009 (see `CertidaoType`). */ | "interdiction"; /** The fields `getCertidaoInfo` reads out of the matrícula of a certidão de registro civil. */ @@ -64,8 +74,9 @@ export type CertidaoInfo = { * * Accepts the same input forms as `isValidCertidao` and returns `null` when the matrícula is * not valid, which includes a serviço other than the `55` art. 473, III fixes for the registro - * civil das pessoas naturais, and a book code that is not one of the nine books defined by the - * Provimento, since an unknown book cannot be named. + * civil das pessoas naturais, and the book code 0, which names no book: art. 473, V has the codes + * 1 to 7 and the revoked Provimento CNJ nº 3/2009 added 8 and 9, which are still read (see + * `CertidaoType`). * * Only a string is accepted: the 32 digits of a matrícula are more than a JavaScript number can * hold, so a numeric argument always gives `null` instead of being read as a rounded value. @@ -98,7 +109,12 @@ export type CertidaoInfo = { * structure: "a matrícula, de inserção obrigatória nas certidões (primeira e demais vias) emitidas * pelos Cartórios de Registro Civil das Pessoas Naturais a partir de 1º de janeiro de 2010, é * formada pelos seguintes elementos", incisos I to IX fixing the same 6 + 2 + 2 + 4 + 1 + 5 + 3 + - * 7 + 2 positions art. 473 carries today (revoked; historical). + * 7 + 2 positions art. 473 carries today (revoked by the Provimento CNJ nº 63/2017; historical). + * Its inciso V also listed "8: Livro E (Desdobrado para registro específico das Emancipações)" + * and "9: Livro E (Desdobrado para registro específico das Interdições)", which art. 473, V no + * longer has but which are still accepted, the certidões issued under it carrying them, and its + * inciso IX had the check digits "formado automaticamente por meio do programa" the CNJ + * Corregedoria handed to the registrars, whose algorithm was never published. * @see Based on: http://ghiorzi.org/DVnew.htm * Worked example of the two check digits (sums 288 and 309). * @see Based on: https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts diff --git a/src/get-cfop/get-cfop.test.ts b/src/get-cfop/get-cfop.test.ts index c7ae83e24..39642070d 100644 --- a/src/get-cfop/get-cfop.test.ts +++ b/src/get-cfop/get-cfop.test.ts @@ -1,7 +1,7 @@ import * as fc from "fast-check"; import { CFOP_TABLE } from "../_internals/constants/cfop"; -import { anyGarbage } from "../_internals/test/arbitraries"; +import { anyGarbage, digitsUpTo } from "../_internals/test/arbitraries"; import { expectNeverThrows } from "../_internals/test/properties"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; import { isValidCfop } from "../is-valid-cfop/is-valid-cfop"; @@ -168,6 +168,23 @@ describe("getCfop", () => { expectNeverThrows(getCfop, anyGarbage); }); + const lookupInputs = fc.oneof( + codeArbitrary, + codeArbitrary.map((code) => ` ${code[0]}.${code.slice(1)} `), + fc.nat({ max: 99_999 }), + digitsUpTo(6), + anyGarbage, + fc.anything(), + ); + + test("should return null exactly when isValidCfop is false", () => { + fc.assert( + fc.property(lookupInputs, (value) => { + expect(getCfop(value as string) === null).toBe(!isValidCfop(value as string)); + }), + ); + }); + test("should resolve every known code, as a string or a number, and agree with isValidCfop", () => { fc.assert( fc.property(codeArbitrary, (code) => { diff --git a/src/get-cfop/get-cfop.ts b/src/get-cfop/get-cfop.ts index 9abfde24e..283a7ac88 100644 --- a/src/get-cfop/get-cfop.ts +++ b/src/get-cfop/get-cfop.ts @@ -1,6 +1,6 @@ -import { CFOP_FORMAT_REGEX, CFOP_TABLE } from "../_internals/constants/cfop"; -import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; +import { CFOP_TABLE } from "../_internals/constants/cfop"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { isValidCfop } from "../is-valid-cfop/is-valid-cfop"; /** * A CFOP (Código Fiscal de Operações e Prestações) code. @@ -36,7 +36,7 @@ export type Cfop = { * @param {string|number} value - The CFOP code to look up, with or without the `N.NNN` mask, * e.g. `"1.101"`, `"1101"` or `1101`. * @returns {Cfop|null} The matching CFOP entry, or null when the code is unknown or - * invalid. + * invalid, which is exactly when `isValidCfop` returns false. * * @example * ```typescript @@ -58,16 +58,9 @@ export type Cfop = { * Ajuste SINIEF 07/01, the historical text that gave the CFOP its 4 digit form. */ export const getCfop = (value: string | number): Cfop | null => { - if (!isLookupCode(value)) return null; + if (!isValidCfop(value)) return null; - const code = String(value).trim(); + const code = sanitizeToDigits(value); - if (!CFOP_FORMAT_REGEX.test(code)) return null; - - const digits = sanitizeToDigits(code); - const description = CFOP_TABLE[digits]; - - if (description === undefined) return null; - - return { code: digits, description }; + return { code, description: CFOP_TABLE[code] }; }; diff --git a/src/get-cid10/get-cid10.test.ts b/src/get-cid10/get-cid10.test.ts index 61ba5ab6a..2b6bbf8cb 100644 --- a/src/get-cid10/get-cid10.test.ts +++ b/src/get-cid10/get-cid10.test.ts @@ -63,9 +63,26 @@ describe("getCid10", () => { expect(getCid10("I10.0")).toBeNull(); }); + it("should find the U07 codes the SIM table adds to the V2008 files", () => { + expect(getCid10("U07")).toEqual({ code: "U07", description: "Uso emergencial do U07" }); + expect(getCid10("U07.0")).toEqual({ + code: "U070", + description: "Doença por cigarro eletrônico", + }); + expect(getCid10("U07.1")).toEqual({ + code: "U071", + description: "Infecção pelo novo Coronavírus (COVID-19)", + }); + expect(getCid10("U07.2")).toEqual({ + code: "U072", + description: "COVID-19, vírus não identificado", + }); + }); + it("should return null for a category the table does not have", () => { expect(getCid10("A10")).toBeNull(); - expect(getCid10("U07.1")).toBeNull(); + expect(getCid10("U09.9")).toBeNull(); + expect(getCid10("U10.9")).toBeNull(); }); it("should return null for a value that is not written in a documented form", () => { diff --git a/src/get-cid10/get-cid10.ts b/src/get-cid10/get-cid10.ts index 90a508dbd..159b60c75 100644 --- a/src/get-cid10/get-cid10.ts +++ b/src/get-cid10/get-cid10.ts @@ -21,8 +21,10 @@ export type Cid10 = { * ignored. Anything else (`"A00-0"`, `"A00.00"`, a dagger or asterisk suffix, a value that is * not a string) is rejected instead of having a code picked out of it. * - * The tables are the CID-10 V2008 files, the revision DATASUS publishes as CSV: a code that is - * not in those files, such as `U07.1` (COVID-19), is not found. + * The tables are the CID-10 V2008 files, the revision DATASUS publishes as CSV, plus the `U07` + * category of the CID-10 table DATASUS keeps for the SIM (`U07`, `U07.0`, `U07.1` and `U07.2`, + * the COVID-19 codes among them), which the V2008 files predate. A code in neither, such as + * `U09.9` and `U10.9`, which the WHO added later, is not found. * * @param {string} value - The CID-10 code to look up, e.g. `"A00.0"`, `"A000"` or `"A00"`. * @returns {Cid10|null} The matching category or subcategory, or null when the code is unknown @@ -33,6 +35,7 @@ export type Cid10 = { * getCid10("A00.0"); // { code: "A000", description: "Cólera devida a Vibrio cholerae 01, biótipo cholerae" } * getCid10("a000"); // { code: "A000", description: "Cólera devida a Vibrio cholerae 01, biótipo cholerae" } * getCid10("A00"); // { code: "A00", description: "Cólera" } + * getCid10("U07.1"); // { code: "U071", description: "Infecção pelo novo Coronavírus (COVID-19)" } * getCid10("A00.5"); // null (A00 has no subcategory 5) * getCid10("A00-0"); // null (not a documented form) * ``` @@ -42,6 +45,9 @@ export type Cid10 = { * published by DATASUS (Ministério da Saúde). * @see Official: http://www2.datasus.gov.br/cid10/V2008/descrcsv.htm * The DATASUS page that links the archive and documents its files, columns and encoding. + * @see Official: ftp://ftp.datasus.gov.br/dissemin/publicos/SIM/CID10/TABELAS/CID10.DBF + * The CID-10 table of the SIM (Sistema de Informações sobre Mortalidade), source of the `U07` + * codes the V2008 files lack. */ export const getCid10 = (value: string): Cid10 | null => { const code = normalizeCid10(value); diff --git a/src/get-cities/get-cities.ts b/src/get-cities/get-cities.ts index aec8398d2..6f8150bc3 100644 --- a/src/get-cities/get-cities.ts +++ b/src/get-cities/get-cities.ts @@ -35,6 +35,9 @@ let allCitiesCache: string[] | undefined; * ``` * * @see Official: https://servicodados.ibge.gov.br/api/docs/localidades + * @see Official: https://geoftp.ibge.gov.br/organizacao_do_territorio/estrutura_territorial/divisao_territorial/2025/DTB_2025.zip + * IBGE, Divisão Territorial Brasileira 2025 (data base 31/12/2025): the same 5,571 municipality + * codes as the bundled table. */ export const getCities = (state?: StateCode): string[] => { if (!state) { diff --git a/src/get-cnae/get-cnae.test.ts b/src/get-cnae/get-cnae.test.ts index e286c7f35..2fd31210c 100644 --- a/src/get-cnae/get-cnae.test.ts +++ b/src/get-cnae/get-cnae.test.ts @@ -1,7 +1,7 @@ import * as fc from "fast-check"; import { CNAE_SUBCLASSES } from "../_internals/constants/cnae"; -import { anyGarbage } from "../_internals/test/arbitraries"; +import { anyGarbage, digitsUpTo } from "../_internals/test/arbitraries"; import { expectNeverThrows } from "../_internals/test/properties"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; import { formatCnae } from "../format-cnae/format-cnae"; @@ -96,6 +96,23 @@ describe("getCnae", () => { expectNeverThrows(getCnae, anyGarbage); }); + const lookupInputs = fc.oneof( + codeArbitrary, + codeArbitrary.map((code) => `${code.slice(0, 4)}-${code[4]}/${code.slice(5)}`), + fc.nat({ max: 9_999_999 }), + digitsUpTo(9), + anyGarbage, + fc.anything(), + ); + + test("should return null exactly when isValidCnae is false", () => { + fc.assert( + fc.property(lookupInputs, (value) => { + expect(getCnae(value as string) === null).toBe(!isValidCnae(value as string)); + }), + ); + }); + test("should resolve every known code, as a string or a number, and agree with formatCnae and isValidCnae", () => { fc.assert( fc.property(codeArbitrary, (code) => { diff --git a/src/get-cnae/get-cnae.ts b/src/get-cnae/get-cnae.ts index aa28ad74c..d5e7feeb3 100644 --- a/src/get-cnae/get-cnae.ts +++ b/src/get-cnae/get-cnae.ts @@ -1,9 +1,8 @@ -import { CNAE_FORMAT_REGEX, CNAE_SUBCLASSES } from "../_internals/constants/cnae"; -import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; +import { CNAE_SUBCLASSES } from "../_internals/constants/cnae"; import { padLookupCode } from "../_internals/pad-lookup-code/pad-lookup-code"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; - -const CNAE_LENGTH = 7; +import { CNAE_LENGTH } from "../is-valid-cnae/constants"; +import { isValidCnae } from "../is-valid-cnae/is-valid-cnae"; /** * A CNAE (Classificação Nacional de Atividades Econômicas) subclass. @@ -36,7 +35,8 @@ export type Cnae = { * * @param {string|number} value - The CNAE code to look up, with or without the * `NNNN-N/NN` mask. - * @returns {Cnae|null} The matching subclass, or null when the code is unknown or invalid. + * @returns {Cnae|null} The matching subclass, or null when the code is unknown or invalid, + * which is exactly when `isValidCnae` returns false. * * @example * ```typescript @@ -54,16 +54,9 @@ export type Cnae = { * CONCLA's CNAE search and structure browser, which publishes CNAE-Subclasses 2.3. */ export const getCnae = (value: string | number): Cnae | null => { - if (!isLookupCode(value)) return null; - - const subclass = padLookupCode(value, CNAE_LENGTH); - - if (!CNAE_FORMAT_REGEX.test(subclass)) return null; - - const digits = sanitizeToDigits(subclass); - const description = CNAE_SUBCLASSES[digits]; + if (!isValidCnae(value)) return null; - if (description === undefined) return null; + const code = sanitizeToDigits(padLookupCode(value, CNAE_LENGTH)); - return { code: digits, description }; + return { code, description: CNAE_SUBCLASSES[code] }; }; diff --git a/src/get-cnpj-info/get-cnpj-info.ts b/src/get-cnpj-info/get-cnpj-info.ts index 520a65d69..49ffb84a4 100644 --- a/src/get-cnpj-info/get-cnpj-info.ts +++ b/src/get-cnpj-info/get-cnpj-info.ts @@ -79,7 +79,8 @@ const INITIAL_HEADQUARTERS_BRANCH = "0001"; * @see Official: https://www.gov.br/receitafederal/pt-br/centrais-de-conteudo/publicacoes/perguntas-e-respostas/cnpj/cnpj-alfanumerico.pdf * Receita Federal Q&A on the alphanumeric CNPJ: questions 21 and 23 (root and branch), 25 (the * branch `0001` and the headquarters) and the `AA345678/000A-29` and `12.345.678/000A-08` - * examples. + * examples. Its Ex1, `AA345678/0003-29`, is a misprint (the check digits of `AA3456780003` are + * 86), so it is not used. * @see Official: https://www.gov.br/receitafederal/pt-br/centrais-de-conteudo/publicacoes/documentos-tecnicos/cnpj/manual-dv-cnpj.pdf * Check digit manual, source of the `12.ABC.345/01DE-35` example. * @see Official: https://www.gov.br/receitafederal/pt-br/acesso-a-informacao/acoes-e-programas/programas-e-atividades/cnpj-alfanumerico diff --git a/src/get-format-license-plate/constants.ts b/src/get-format-license-plate/constants.ts deleted file mode 100644 index c1b16fb97..000000000 --- a/src/get-format-license-plate/constants.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const OLD_FORMAT_REGEX = /^[A-Z]{3}[0-9]{4}$/; -export const MERCOSUL_REGEX = /^[A-Z]{3}[0-9][A-Z][0-9]{2}$/; diff --git a/src/get-format-license-plate/get-format-license-plate.test.ts b/src/get-format-license-plate/get-format-license-plate.test.ts index 5b40a9cfa..6d3ae96b8 100644 --- a/src/get-format-license-plate/get-format-license-plate.test.ts +++ b/src/get-format-license-plate/get-format-license-plate.test.ts @@ -1,7 +1,8 @@ import * as fc from "fast-check"; -import { licensePlates } from "../_internals/test/arbitraries"; +import { asciiAlphanumericText, licensePlates } from "../_internals/test/arbitraries"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; +import { isValidLicensePlate } from "../is-valid-license-plate/is-valid-license-plate"; import { type LicensePlateFormat, getFormatLicensePlate } from "./get-format-license-plate"; describe("getFormatLicensePlate", () => { @@ -41,6 +42,24 @@ describe("getFormatLicensePlate", () => { ); }); + const plates = fc.constantFrom("LLLNNNN", "LLLNLNN").chain(licensePlates); + const lookupInputs = fc.oneof( + plates, + plates.map((plate) => `${plate.slice(0, 3)}-${plate.slice(3).toLowerCase()}`), + asciiAlphanumericText, + fc.anything(), + ); + + test("should return null exactly when isValidLicensePlate is false", () => { + fc.assert( + fc.property(lookupInputs, (value) => { + expect(getFormatLicensePlate(value as string) === null).toBe( + !isValidLicensePlate(value as string), + ); + }), + ); + }); + test("should never throw and always return a known format or null", () => { fc.assert( fc.property(fc.anything(), (value) => { diff --git a/src/get-format-license-plate/get-format-license-plate.ts b/src/get-format-license-plate/get-format-license-plate.ts index 2925eb688..57bb9f6e0 100644 --- a/src/get-format-license-plate/get-format-license-plate.ts +++ b/src/get-format-license-plate/get-format-license-plate.ts @@ -1,5 +1,6 @@ import { sanitizeToAlphanumeric } from "../_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric"; -import { MERCOSUL_REGEX, OLD_FORMAT_REGEX } from "./constants"; +import { OLD_FORMAT_REGEX } from "../is-valid-license-plate/constants"; +import { isValidLicensePlate } from "../is-valid-license-plate/is-valid-license-plate"; /** The Brazilian license plate formats `getFormatLicensePlate` can identify: the old `LLLNNNN` and the Mercosul `LLLNLNN`. */ export type LicensePlateFormat = "LLLNNNN" | "LLLNLNN"; @@ -17,7 +18,7 @@ export type LicensePlateFormat = "LLLNNNN" | "LLLNLNN"; * * @param {string} value - The license plate value to be checked. * @returns {LicensePlateFormat | null} The identified format, or `null` when it doesn't match - * any supported format. + * any supported format, which is exactly when `isValidLicensePlate` returns false. * * @example * ```typescript @@ -39,12 +40,7 @@ export type LicensePlateFormat = "LLLNNNN" | "LLLNLNN"; * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/resolucao9692022anexos.pdf */ export const getFormatLicensePlate = (value: string): LicensePlateFormat | null => { - if (typeof value !== "string") return null; + if (!isValidLicensePlate(value)) return null; - const parsed = sanitizeToAlphanumeric(value); - - if (OLD_FORMAT_REGEX.test(parsed)) return "LLLNNNN"; - if (MERCOSUL_REGEX.test(parsed)) return "LLLNLNN"; - - return null; + return OLD_FORMAT_REGEX.test(sanitizeToAlphanumeric(value)) ? "LLLNNNN" : "LLLNLNN"; }; diff --git a/src/get-gtin-info/constants.ts b/src/get-gtin-info/constants.ts index c82c4e203..2f400e415 100644 --- a/src/get-gtin-info/constants.ts +++ b/src/get-gtin-info/constants.ts @@ -16,9 +16,8 @@ export const NORMALIZED_LENGTH = 14; * The five zeros a GTIN-8 gets after the first digit of the 14 digit form: that digit is the * padding zero of a bare GTIN-8, or the indicator digit of a GTIN-14 that packs one, and is never * part of the prefix. GS1 leaves the prefixes 0000001 to 0000099 unused so that no longer GTIN - * collides with one (General Specifications, table 1-4), and the "Tabela Prefixo GS1" of the - * Portal da NF-e reads the prefix the same way: from positions 7 to 9 after those zeros, from - * positions 2 to 4 otherwise. + * collides with one (General Specifications, table 1-4), so the prefix is read from positions 7 + * to 9 after those zeros, from positions 2 to 4 otherwise. */ export const GS1_8_PADDING = "00000"; diff --git a/src/get-gtin-info/get-gtin-info.ts b/src/get-gtin-info/get-gtin-info.ts index c5dea8e1f..3d86d8298 100644 --- a/src/get-gtin-info/get-gtin-info.ts +++ b/src/get-gtin-info/get-gtin-info.ts @@ -56,8 +56,8 @@ const PREFIX_LENGTH = 3; * `isValidGtin` (with no options) returns false. Leading zeros count, so the value is never read * from a number. * - * `type` and `length` describe the value as it was written. The prefix is read the way the - * "Tabela Prefixo GS1" of the Portal da NF-e tells: the value is left padded with zeros to 14 + * `type` and `length` describe the value as it was written. The prefix is read as the GS1 + * General Specifications (tables 1-4, 1-5 and 1-9) lay the numbers out: the value is left padded with zeros to 14 * digits, and the prefix is positions 7 to 9 when positions 2 to 6 are zeros (a GTIN-8, or a * GTIN-14 that packs one) and positions 2 to 4 otherwise. The first digit is the padding zero or * the indicator digit and is never part of the prefix, so a GTIN-12 has a prefix that starts with @@ -67,8 +67,8 @@ const PREFIX_LENGTH = 3; * the General Specifications are told apart, since both are fixed by a standard or a rule. The * prefix is not checked against the list of Member Organisations: GS1 keeps assigning ranges, so * a copy of that list would turn down valid numbers as it ages. SEFAZ does run that check - * (rules I03-20 and I12-20 of NT 2021.003, against its own "Tabela Prefixo GS1", which lists the - * restricted and special ranges as valid) and, for the 789 and 790 prefixes, looks the number up + * (rules I03-20 and I12-20 of NT 2021.003, against its own "Tabela Prefixo GS1", whose contents + * could not be read, so which ranges it accepts is not stated here) and, for the 789 and 790 prefixes, looks the number up * in the Cadastro Centralizado de GTIN, which no offline check can stand in for. * * @param {string} value - The GTIN to be parsed, digits only. @@ -84,8 +84,8 @@ const PREFIX_LENGTH = 3; * `cEANTrib`, rules I03-10, I03-20, I12-10, I12-20, 9I03-10 and 9I12-10 ("prefixo do Brasil * (iniciado em 789 ou 790)"). * @see Official: https://www.nfe.fazenda.gov.br/portal/exibirArquivo.aspx?conteudo=Oc+fygAxwmc%3D - * "Tabela Prefixo GS1" of the Portal da NF-e: how to read the prefix from the 14 digit form, and - * the range 789 to 790 for GS1 Brasil. + * "Tabela Prefixo GS1" of the Portal da NF-e, the table rules I03-20 and I12-20 check the prefix + * against; its contents are not relied on here. * * @example * ```typescript diff --git a/src/get-holidays/constants.ts b/src/get-holidays/constants.ts index cc10e3ea1..4faca64e2 100644 --- a/src/get-holidays/constants.ts +++ b/src/get-holidays/constants.ts @@ -2,26 +2,51 @@ import { type StateCode } from "../_internals/constants/states"; import { type HolidayDateRule } from "../_internals/resolve-state-holiday-date/resolve-state-holiday-date"; import { type HolidayType } from "./get-holidays"; -type StateHolidayEntry = HolidayDateRule & { - name: string; - type?: HolidayType; +/** + * The years a holiday entry is in force: from `since` (inclusive) up to `until` (exclusive). An + * absent bound leaves that side open. + */ +export type HolidayPeriod = { since?: number; until?: number; }; -export const FIXED_HOLIDAYS = { - "Ano novo": { day: 1, month: 1 }, - Tiradentes: { day: 21, month: 4 }, - "Dia do trabalhador": { day: 1, month: 5 }, - "Independência do Brasil": { day: 7, month: 9 }, - "Nossa Senhora Aparecida": { day: 12, month: 10 }, - Finados: { day: 2, month: 11 }, - "Proclamação da República": { day: 15, month: 11 }, - Natal: { day: 25, month: 12 }, -} as const; +type StateHolidayEntry = HolidayDateRule & + HolidayPeriod & { + name: string; + type?: HolidayType; + }; + +type NationalHolidayEntry = HolidayPeriod & { + name: string; + day: number; + month: number; +}; export const CONSCIENCIA_NEGRA_NATIONAL_SINCE_YEAR = 2024; +/** + * The name the first round of the elections is emitted under, the feriado nacional of art. 380 + * of the Código Eleitoral. + */ +export const ELECTION_HOLIDAY_NAME = "Eleições (primeiro turno)"; + +/** + * First year the first round of every even year's elections falls on a date the Constitution + * fixes: EC nº 16, de 04/06/1997, set it on the first Sunday of October (arts. 28, 29, II, and 77) + * and the 1998 general elections were the first held under it. + */ +export const ELECTION_SINCE_YEAR = 1998; + +/** + * Years whose first round the Constitution moved off the first Sunday of October, as `[month, + * day]` pairs (month 1 to 12): art. 1º of EC nº 107, de 02/07/2020, "As eleições municipais + * previstas para outubro de 2020 realizar-se-ão no dia 15 de novembro, em primeiro turno". + */ +export const ELECTION_DATE_OVERRIDES: ReadonlyMap = new Map([ + [2020, [11, 15]], +]); + /** * The name the 20 November entries are emitted under, national and state alike. * @@ -29,7 +54,8 @@ export const CONSCIENCIA_NEGRA_NATIONAL_SINCE_YEAR = 2024; * Nacional de Zumbi e da Consciência Negra", and the state laws behind the pre-2024 entries of * Mato Grosso, Rio de Janeiro and Amazonas are worded alike to each other: each institutes 20 * November as a feriado estadual and names the date after the federal commemorative one, "Dia - * Nacional da Consciência Negra" (see the `@see` entries below for the three texts). The form + * Nacional da Consciência Negra" (see the `@see` entries below for the three texts), while the + * older Alagoas law names the date after Zumbi dos Palmares alone. The form * below drops a "Nacional" that would read as wrong on a state entry, is the one 2.3.0 already * emitted for the national holiday, and keeps the name continuous across the 2023/2024 boundary * where the state entries give way to the national one. Amapá is the exception: art. 1º of its @@ -38,15 +64,122 @@ export const CONSCIENCIA_NEGRA_NATIONAL_SINCE_YEAR = 2024; */ export const CONSCIENCIA_NEGRA_HOLIDAY_NAME = "Dia da Consciência Negra"; +/** + * The fixed-date feriados nacionais, each emitted only for the years a federal norm declared it. + * A holiday with a gap in its history is listed once per period. The supported years start in + * 1900, under the republican calendar of Decreto nº 155-B/1890; its 3 May, 13 May, 14 July and + * 12 October ("descobrimento da América") entries, all dropped by Decreto nº 19.488/1930, are not + * carried. + * + * - Ano novo, Independência do Brasil and Proclamação da República: every supported year + * (Decreto nº 155-B/1890, Decreto nº 19.488/1930, Lei nº 662/1949). + * - Tiradentes: Decreto nº 155-B/1890 up to 1930, since Decreto nº 19.488, de 15/12/1930, left + * it out of its list; Decreto nº 22.647, de 17/04/1933, restored it from 1933; Lei nº 662/1949, + * in force from its publication on 13/04/1949, left it out again, and art. 3º of Lei nº 1.266, + * de 08/12/1950, restored it from 1951 on. + * - Dia do trabalhador: from 1925, the first 1 May after Decreto nº 4.859, de 26/09/1924. + * - Nossa Senhora Aparecida: from 1980, Lei nº 6.802, de 30/06/1980. + * - Finados: up to 1948 and from 2003 on. Decreto nº 155-B/1890, Decreto nº 19.488/1930 and + * Decreto-lei nº 486/1938 declared it up to 1948; Lei nº 662/1949, which regulated the whole + * list of feriados nacionais and revoked "as disposições em contrário", left it out, and no + * federal norm declared it again until Lei nº 10.607, de 19/12/2002, added it to that list from + * 2003 on. The Câmara report on the bill behind Lei nº 10.607/2002 (PL nº 3.721/2000) reads the + * law the same way: "Só inova ao sugerir o dia de finados". Up to 2.4.0 it was listed every + * year. + * - Natal: from 1922, Decreto nº 4.497, de 19/01/1922. + * - Dia da Consciência Negra: from `CONSCIENCIA_NEGRA_NATIONAL_SINCE_YEAR`, Lei nº 14.759/2023. + * + * @see Official: https://www.planalto.gov.br/ccivil_03/decreto/1851-1899/D155-B.htm + * Decreto nº 155-B, de 14/01/1890, the first republican calendar: 1 January, 21 April, 3 May, 13 + * May, 14 July, 7 September, 12 October, 2 November and 15 November. + * @see Official: https://www.lexml.gov.br/urn/urn:lex:br:federal:decreto:1922-01-19;4497 + * Decreto nº 4.497, de 19/01/1922, which added 25 December to the feriados nacionais. + * @see Official: https://www.lexml.gov.br/urn/urn:lex:br:federal:decreto:1924-09-26;4859 + * Decreto nº 4.859, de 26/09/1924, which declared 1 May a feriado nacional. + * @see Official: https://www2.camara.leg.br/legin/fed/decret/1930-1939/decreto-19488-15-dezembro-1930-508040-publicacaooriginal-1-pe.html + * Decreto nº 19.488, de 15/12/1930, art. 1º: "São considerados feriados nacionais os seguintes + * dias: 1º de janeiro, consagrado à comemoração da fraternidade universal; 1º de maio, consagrado + * à confraternidade universal das classes operárias; 7 de setembro, consagrado à comemoração da + * Independência do Brasil; 2 de novembro, consagrado à comemoração dos mortos; 15 de novembro, + * consagrado à comemoração do advento da República; 25 de dezembro, consagrado à comemoração da + * unidade espiritual dos povos christãos". + * @see Official: https://www2.camara.leg.br/legin/fed/decret/1930-1939/decreto-22647-17-abril-1933-558774-publicacaooriginal-80337-pe.html + * Decreto nº 22.647, de 17/04/1933, which restored the feriado nacional of 21 April, dedicated to + * the memory of the martyrs of liberty in the person of Tiradentes, four days before that year's + * date. + * @see Official: https://www2.camara.leg.br/legin/fed/declei/1930-1939/decreto-lei-486-10-junho-1938-349693-publicacaooriginal-1-pe.html + * Decreto-lei nº 486, de 10/06/1938 (DOU 11/06/1938), art. 1º: "São feriados nacionais os + * seguintes dias: 1 de janeiro […]; 21 de abril […]; 1 de maio […]; 7 de setembro […]; 2 de + * novembro - dedicado à comemoração dos mortos; 15 de novembro […]; 25 de dezembro […]". + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l0662.htm + * Lei nº 662, de 06/04/1949 (DOU 13/04/1949), art. 1º in its original wording: "São feriados + * nacionais os dias 1º de janeiro, 1º de maio, 7 de setembro, 15 de novembro e 25 de dezembro"; + * art. 4º: "revogadas as disposições em contrário". + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/L1266.htm + * Lei nº 1.266, de 08/12/1950, art. 3º: "É feriado nacional o dia 21 de abril, consagrado à + * glorificação de Tiradentes". + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l6802.htm + * Lei nº 6.802, de 30/06/1980, art. 1º: "É declarado feriado nacional o dia 12 de outubro, para + * culto público e oficial a Nossa Senhora Aparecida, Padroeira do Brasil". + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/2002/l10607.htm + * Lei nº 10.607, de 19/12/2002, which gave art. 1º of Lei nº 662/1949 its current list: "1º de + * janeiro, 21 de abril, 1º de maio, 7 de setembro, 2 de novembro, 15 de novembro e 25 de + * dezembro". + * @see Official: https://www2.camara.leg.br/legin/fed/lei/2002/lei-10607-19-dezembro-2002-473572-norma-pl.html + * Lei nº 10.607/2002 came from PL nº 3.721/2000; the report of the Comissão de Educação, + * Cultura e Desporto on it (30/05/2001) says the bill "Só inova ao sugerir o dia de finados". + */ +export const FIXED_HOLIDAYS: readonly NationalHolidayEntry[] = [ + { name: "Ano novo", day: 1, month: 1 }, + { name: "Tiradentes", day: 21, month: 4, until: 1931 }, + { name: "Tiradentes", day: 21, month: 4, since: 1933, until: 1949 }, + { name: "Tiradentes", day: 21, month: 4, since: 1951 }, + { name: "Dia do trabalhador", day: 1, month: 5, since: 1925 }, + { name: "Independência do Brasil", day: 7, month: 9 }, + { name: "Nossa Senhora Aparecida", day: 12, month: 10, since: 1980 }, + { name: "Finados", day: 2, month: 11, until: 1949 }, + { name: "Finados", day: 2, month: 11, since: 2003 }, + { name: "Proclamação da República", day: 15, month: 11 }, + { + name: CONSCIENCIA_NEGRA_HOLIDAY_NAME, + day: 20, + month: 11, + since: CONSCIENCIA_NEGRA_NATIONAL_SINCE_YEAR, + }, + { name: "Natal", day: 25, month: 12, since: 1922 }, +]; + /** First year Alagoas' 16 September is a feriado estadual, not a ponto facultativo (Lei AL nº 9.358/2024). */ const AL_EMANCIPACAO_FERIADO_SINCE_YEAR = 2024; -/** First year Paraíba's 26 July is no longer a holiday: Lei PB nº 10.601/2015 revoked its basis on 17/12/2015. */ -const PB_MORTE_JOAO_PESSOA_UNTIL_YEAR = 2016; +/** + * First year Alagoas' 30 November is a feriado estadual: art. 2º of Lei AL nº 7.530/2013, vetoed on + * sanction, was promulgated by the Assembleia Legislativa on 08/04/2014. + */ +const AL_DIA_DO_EVANGELICO_SINCE_YEAR = 2014; + +/** + * First year Paraíba's 26 July and the 5 August of Lei PB nº 3.489/1967 are no longer holidays + * under that law: Lei PB nº 10.601/2015 revoked its art. 2º on 17/12/2015 and made 5 August the + * Data Magna from 2016. + */ +const PB_LEI_3489_ARTICLE_2_UNTIL_YEAR = 2016; /** First year Tocantins' 18 March is no longer a holiday: Lei TO nº 2.013/2009 repealed the feriado clause on 18/02/2009. */ const TO_AUTONOMIA_UNTIL_YEAR = 2009; +/** + * First year Santa Catarina's 25 November is a holiday: Lei SC nº 10.306, of December 1996, made + * it the data magna of the state after that year's date had passed. + */ +const SC_ALEXANDRIA_SINCE_YEAR = 1997; + +/** + * First year Santa Catarina's 11 August is a holiday: Lei SC nº 12.906, de 22/01/2004, added it to + * the caput of art. 1º of Lei SC nº 10.306/1996, which until then named 25 November alone. + */ +const SC_DIA_DO_ESTADO_SINCE_YEAR = 2004; + /** * First year Santa Catarina's 25 November moves to the following Sunday: Lei SC nº 11.213, de * 11/11/1999, added the transfer clause to Lei SC nº 10.306/1996 and, by its art. 2º, entered @@ -66,60 +199,150 @@ const SC_ALEXANDRIA_TRANSFER_GAP_YEAR = 2004; /** * First year Santa Catarina's 11 August and 25 November both move to the following Sunday: Lei SC * nº 13.408, de 15/07/2005, added the transfer clause covering the two dates and entered into - * force on the day it was published (DO 17.680, de 15/07/2005), before that year's 11 August. Up - * to 2004 the 11 August holiday was always observed on the date itself. + * force on the day it was published (DO 17.680, de 15/07/2005), before that year's 11 August. In + * 2004, its only year before the transfer, the 11 August holiday was observed on the date itself. */ const SC_NEXT_SUNDAY_TRANSFER_SINCE_YEAR = 2005; /** * Feriados estaduais, um `@see` por entrada. * + * Entries typed `"optional"` are the pontos facultativos a state norm cited here declares: + * Alagoas' 16 September of 2020 to 2023, Amazonas' 8 December and Pernambuco's 6 March of 2008 + * and 2009. The federal pontos facultativos (Carnaval Monday + * and Tuesday, Corpus Christi) are emitted by `getHolidays` itself, and together they are what + * `includeOptional` switches on. + * * Only one of these is a feriado civil under art. 1º, II of Lei 9.093/1995, which authorizes * "a data magna do Estado fixada em lei estadual", in the singular. The remaining entries rest * on ordinary state laws (and, for a few states, on the state constitution) that declare further * feriados estaduais; the library reports them because they are observed in practice, not * because art. 1º, II covers them. * - * The statutory date is what is emitted. Four states shift the observed date and only Santa - * Catarina's shift is modelled here (`nextSundayWhenWeekday`, from + * The statutory date is what is emitted, save for the two shifts modelled here: Alagoas' 30 + * November moves back to Monday from a Tuesday and on to Friday from a Thursday + * (`tuesdayToMondayThursdayToFriday`, Lei AL nº 7.530/2013, art. 2º parágrafo único), and Santa + * Catarina's shift (`nextSundayWhenWeekday`, from * `SC_ALEXANDRIA_TRANSFER_SINCE_YEAR` on for 25 November, apart from the * `SC_ALEXANDRIA_TRANSFER_GAP_YEAR` gap, and from `SC_NEXT_SUNDAY_TRANSFER_SINCE_YEAR` on for - * 11 August): Acre moves feriados falling from - * Tuesday to Thursday on to the following Friday (Lei AC nº 2.126/2009, except 15/06), and the + * 11 August). Of the shifts left out, Acre's law moves feriados falling from Tuesday to Thursday + * on to the Friday (Lei AC nº 2.126/2009, in the wording of Lei AC nº 2.247/2009, the Aniversário + * do Acre on 15/06 excepted), but the state's own annual calendar decrees apply it unevenly (see + * the `@see` below), so the two official sources conflict and the statutory date is kept; and the * Goiás executive may move 26/07 and 28/10 to a nearby dia útil by decree (Lei GO nº 20.756/2020, - * art. 269, § 1º), neither of which can be resolved from a year alone; São Paulo moved 09/07 to - * 25/05 for 2020 alone (Lei SP nº 17.264/2020), a one-off this table does not carry. + * art. 269, § 1º), and has moved 26/07 by a decree of its own every year since 2022 (see the `@see` + * below), a date no rule derives from the year; São Paulo moved 09/07 to + * 25/05 for 2020 alone (Lei SP nº 17.264/2020), a one-off this table does not carry, and so are + * Piauí's 19/10 of 2020 and 2021, brought forward to 15/05/2020 and 18/03/2021 by Leis PI nº + * 7.371/2020 and 7.490/2021, and Tocantins' 05/10/2026, which the executive moved to 09/10 for its + * own offices under Lei TO nº 1.088/1999 (both cited below). + * + * Every entry starts (`since`) in the first year the norm cited for it was in force on the date, + * and one that lost its basis stops (`until`, exclusive) in the first year it no longer applied, so + * a year before a state's law carries no entry for it. Where a date was probably a holiday under an + * older norm that could not be located (the 02/07 of Bahia before its 1989 Constitution is the + * clearest case), the entry starts at the norm cited here all the same. * + * @see Official: https://legis.ac.gov.br/detalhar/2249 + * Lei AC nº 3.137, de 27/06/2016, which creates the Dia Estadual do Católico on 20 January as a + * feriado estadual, listed from 2017, the first 20 January after the law. + * @see Official: https://legis.ac.gov.br/detalhar/6247 + * Lei AC nº 2.126, de 19/06/2009, "dispõe sobre o adiamento de feriados". Its original art. 1º + * moved to Friday the feriados estaduais "que caírem nos demais dias úteis, à exceção dos alusivos + * ao aniversário do Estado do Acre (15 de junho) e da Revolução Acreana (6 de agosto)"; its + * parágrafo único: "Quando da ocorrência de mais de um feriado na semana, serão comemorados em dias + * subseqüentes, de forma tal que o repouso e o lazer deem-se de forma contínua". + * @see Official: https://app.al.ac.leg.br/legisla-e/legislacao/visualizar/8517 + * Lei AC nº 2.247, de 21/12/2009 (DOE 30/12/2009), which gave that art. 1º its current wording: + * "Serão comemorados por adiamento, nas sextas-feiras, os feriados estaduais que caírem entre as + * terças e quintas-feiras, à exceção do alusivo ao aniversário do Estado do Acre". Not modelled, + * because the annual decrees that apply it do not agree with it or with each other: Decreto AC nº + * 7.613/2020 (calendar of 2021) moved 20/01 (a Wednesday) to Friday 22/01 but brought 17/11 (a + * Wednesday) forward to Tuesday 16/11; Decreto AC nº 11.610/2024 (2025) moved 23/01 (a Thursday) + * to 24/01; Decreto AC nº 11.809/2025 (2026) moved 20/01 (a Tuesday) to Thursday 22/01, Friday 23/01 + * being the Dia do Evangélico, and left 17/11 (a Tuesday) on its date. The 06/08 the original + * wording excepted, the Início da Revolução Acreana, is only a ponto facultativo in those decrees, + * and no state law declaring it a feriado was located, so it has no entry. * @see Official: https://legis.ac.gov.br/detalhar/1087 - * Lei AC nº 1.538/2004, Dia do Evangélico (23/01) + * Lei AC nº 1.538, de 29/01/2004, Dia do Evangélico (23/01), listed from 2005 * @see Official: https://legis.ac.gov.br/detalhar/1828 - * Lei AC nº 1.411/2001, Dia Internacional da Mulher (08/03) + * Lei AC nº 1.411, de 14/09/2001, Dia Internacional da Mulher (08/03), listed from 2002 * @see Official: https://legis.ac.gov.br/detalhar/618 - * Lei AC nº 14/1964, Aniversário do Acre (15/06) + * Lei AC nº 14, de 02/09/1964, Aniversário do Acre (15/06), listed from 1965 * @see Official: https://legis.ac.gov.br/detalhar/940 - * Lei AC nº 243/1968, art. 2º, Dia da Amazônia (05/09): "É considerado feriado estadual o dia 5 de - * setembro em homenagem ao DIA DA AMAZÔNIA". Lei AC nº 1.526/2004, cited here before, only adds - * the date to the calendário oficial de eventos. + * Lei AC nº 243, de 04/12/1968, art. 2º, Dia da Amazônia (05/09), listed from 1969: "É + * considerado feriado estadual o dia 5 de setembro em homenagem ao DIA DA AMAZÔNIA". Lei AC nº + * 1.526/2004, cited here before, only adds the date to the calendário oficial de eventos. * @see Official: https://legis.ac.gov.br/detalhar/688 - * Lei AC nº 57/1965, Assinatura do Tratado de Petrópolis (17/11) + * Lei AC nº 57, de 14/12/1965, Assinatura do Tratado de Petrópolis (17/11), listed from 1966 * @see Official: https://sapl.al.al.leg.br/norma/3363 - * Lei AL nº 5.508/1993, São João (24/06) + * Lei AL nº 5.508, de 07/07/1993, São João (24/06), listed from 1994 * @see Official: https://sapl.al.al.leg.br/norma/3364 - * Lei AL nº 5.509/1993, São Pedro (29/06) + * Lei AL nº 5.509, de 07/07/1993, São Pedro (29/06), listed from 1994 + * @see Based on: https://www.legisweb.com.br/legislacao/?id=116708 + * Lei AL nº 5.724, de 01/08/1995, art. 1º, which considers "Feriado Estadual o dia 20 de novembro - + * Morte do Líder Negro Zumbi dos Palmares" (state holiday from 1995, the law predating that year's + * date, until it became national in 2024). The law names the date after Zumbi alone; the entry + * carries `CONSCIENCIA_NEGRA_HOLIDAY_NAME`, the name the other pre-2024 state entries and the + * national one share. The ALE-AL SAPL record of the law could not be reached, so the text was read + * in the LegisWeb reproduction cited here. * @see Official: https://sapl.al.al.leg.br/norma/3117 * Lei AL nº 9.358, de 26/08/2024, Emancipação Política de Alagoas (16/09): "DISPÕE SOBRE O FERIADO * ESTADUAL DA EMANCIPAÇÃO POLÍTICA DO ESTADO DE ALAGOAS - DIA 16 DE SETEMBRO". Until 2023 the date - * was only the ponto facultativo of the Decreto AL nº 68.782/2019. + * was only a ponto facultativo of the state's annual calendar decrees; the optional entry starts in + * 2020, the year of the one decree cited, Decreto AL nº 68.782, de 30/12/2019, which "dispõe + * sobre os feriados nacionais e estaduais para o exercício de 2020, define os pontos facultativos". + * The earlier annual decrees were not located. A 2026 check against the official sources could + * neither reach this SAPL record nor find the law elsewhere: Decreto AL nº 106.093, de 29/12/2025 + * (the 2026 calendar), lists "16 de setembro, Emancipação Política de Alagoas (feriado estadual)" + * but cites only Leis AL nº 5.508/1993, 5.509/1993, 5.724/1995 and 7.530/2013. The entry is kept + * as it is. + * @see Official: https://diario.imprensaoficial.al.gov.br/apinova/api/editions/downloadPdf/24602 + * Lei AL nº 7.530, de 08/08/2013 (DOE-AL de 09/08/2013, p. 89), art. 1º: "Fica instituído o Dia + * Estadual do Evangélico, a ser comemorado no dia 30 de novembro de cada ano". Its art. 2º, which + * makes the date a feriado, was vetoed on sanction and is cited next. + * @see Official: https://diario.imprensaoficial.al.gov.br/apinova/api/editions/downloadPdf/24776 + * The same art. 2º and its parágrafo único, promulgated by the Presidente da Assembleia Legislativa + * on 08/04/2014 after the veto was overridden (DOE-AL de 28/04/2014, p. 43): "Art. 2º- Em alusão à + * data comemorativa de que trata esta Lei, fica declarado feriado estadual no dia 30 de novembro. + * Parágrafo único: acaso o dia 30 de novembro recaia em uma terça-feira ou quinta-feira, o + * feriado ficará antecipado para a segunda-feira ou adiado para a sexta-feira, respectivamente." + * Listed from 2014, the first 30 November after the promulgation, and moved as the parágrafo + * único says (`tuesdayToMondayThursdayToFriday`). The annual Decreto AL nº 106.093/2025, for 2026, + * cites the law among the four state laws it applies. Missing up to 2.4.0. * @see Official: https://al.ap.leg.br/ver_texto_lei.php?iddocumento=17488 - * Lei AP nº 667/2002, art. 1º par. único, Dia de São José (19/03) + * Lei AP nº 667, de 16/04/2002, art. 1º par. único, Dia de São José (19/03), listed from 2003 + * @see Official: https://www.al.ap.leg.br/ver_texto_lei.php?iddocumento=77794 + * Lei AP nº 2.213, de 11/07/2017 (DOE nº 6479, de 11/07/2017), Dia de Cabralzinho (15/05), listed + * from 2018, the first 15 May after the law: "Fica instituído como Feriado Estadual, o dia 15 de + * maio "Dia de Cabralzinho"". Missing up to 2.4.0. + * @see Official: https://www.al.ap.leg.br/ver_texto_lei.php?iddocumento=35896 + * Lei AP nº 1.696, de 09/07/2012 (DOE nº 5264, de 10/07/2012), Dia de São Tiago (25/07): "Fica + * instituído como Feriado Estadual, o dia 25 de julho, "Dia de São Tiago"", in force on its + * publication, so listed from 2012. The ALAP record notes an "Ação Direta de Inconstitucionalidade + * nº 4820/2012" against it, whose outcome could not be checked (the STF portal refuses the + * connection). + * @see Official: https://www.al.ap.leg.br/ver_texto_lei.php?iddocumento=96475 + * Lei AP nº 2.430, de 23/07/2019 (DOE nº 6965, de 23/07/2019), which keeps 25 July only as a + * "data comemorativa" and, by its art. 3º, "Fica revogada a Lei Estadual nº 1.696, de 9 de julho + * de 2012", in force on its publication two days before that year's date, so the São Tiago entry + * stops after 2018. Two other Amapá dates are not holidays: 05/10, whose Lei AP nº 1.204/2008 + * says the commemoration "não inclui dispensa de trabalho", and 25/11, which Lei AP nº 393/1997 + * makes a feriado only "para as pessoas que sejam doadoras de sangue voluntárias e permanentes". * @see Official: https://silegis.al.ap.leg.br/proposicaopdf/2CEatualizadaeconsolidadaateEC071comSumario.pdf - * Constituição Estadual do AP, art. 355, Criação do Território Federal do Amapá (13/09): "O dia 13 - * de Setembro, data magna do Amapá, é feriado em todo o território do Estado". + * Constituição Estadual do AP, de 20/12/1991, art. 355, Criação do Território Federal do Amapá + * (13/09), listed from 1992: "O dia 13 de Setembro, data magna do Amapá, é feriado em todo o + * território do Estado". * @see Official: https://al.ap.leg.br/ver_texto_lei.php?iddocumento=22214 - * Lei AP nº 1.169/2007, Dia Estadual da Consciência Negra (state holiday until it became national - * in 2024) + * Lei AP nº 1.169, de 27/12/2007 (DOE nº 4157, de 27/12/2007), art. 1º: "Fica declarado feriado no + * Estado do Amapá o dia vinte de novembro, data em que se comemora o DIA ESTADUAL DA CONSCIÊNCIA + * NEGRA". In force on its publication, after that year's 20 November, so it is listed from 2008 + * (up to 2.4.0 from 2007) until it became national in 2024. * @see Official: https://sapl.al.am.leg.br/norma/8919 - * Lei AM nº 25/1977, Elevação do Amazonas à categoria de Província (05/09) + * Lei AM nº 25, de 21/12/1977, Elevação do Amazonas à categoria de Província (05/09), listed + * from 1978. The ALEAM record has the ementa "DECLARA feriado estadual o dia 05 de setembro" and + * no text; the state's decrees date the law "de 09 de dezembro de 1977" instead (DOE-AM of + * 29/08/2024). Either date gives 1978 as the first year. * @see Official: https://sapl.al.am.leg.br/norma/2873 * Lei AM nº 84/2010, Dia da Consciência Negra (state holiday until it became national in 2024). * Its ementa carries the same wording as the Mato Grosso and Rio de Janeiro laws: "INSTITUI no @@ -127,34 +350,90 @@ const SC_NEXT_SUNDAY_TRANSFER_SINCE_YEAR = 2005; * Zumbi dos Palmares e Dia Nacional da Consciência Negra, como feriado estadual". * @see Official: https://sapl.cmm.am.gov.br/norma/3932 * Lei Municipal de Manaus nº 496/1999, Nossa Senhora da Conceição (08/12): "INSTITUI feriado - * religioso no Município de Manaus no dia 8 de dezembro". No state norm declaring 08/12 was - * located in the ALEAM records, so the entry is reported as an optional day, not as a feriado - * estadual. + * religioso no Município de Manaus no dia 8 de dezembro". No state law makes 08/12 a holiday: it + * is a municipal holiday of the capital. The state declares it a ponto facultativo in its own + * offices by decree (DOE-AM of 02/12/2025: "DECLARAR ponto facultativo nas repartições públicas, + * autarquias e fundações do Estado, no dia 08 de dezembro de 2025"), so it is listed as + * `"optional"`, as in 2.4.0. Only the 2025 decree was located; the other years keep the 2.4.0 + * entry, since no source says otherwise. + * @see Official: https://diario.imprensaoficial.am.gov.br/portal/edicoes/download/17974 + * DOE-AM of 02/12/2025, the decree quoted above. * @see Official: https://www.legislabahia.ba.gov.br/documentos/constituicao-do-estado-da-bahia-de-05-de-outubro-de-1989 - * Constituição Estadual da BA, art. 6º § 3º, Independência da Bahia (02/07): "O Dois de Julho, - * data magna da Bahia ..., é feriado em todo o território do Estado". + * Constituição Estadual da BA, de 05/10/1989, art. 6º § 3º, Independência da Bahia (02/07), + * listed from 1990: "O Dois de Julho, data magna da Bahia ..., é feriado em todo o território do + * Estado". The date is older as a holiday than that text: the Casa Civil's calendar of 2026 + * (LegislaBahia, feriados2026.pdf) cites "Lei Estadual 38, de 03.01.1936" for it, but that law's + * text was not located, so the entry starts at the Constitution. * @see Official: https://belt.al.ce.gov.br/index.php/constituicao-do-ceara/emendas-a-constituicao-do-ceara/item/5643-emenda-constitucional-n-73-de-1-de-dezembro-de-2011-d-o-06-12-11 - * Constituição Estadual do CE, art. 18 par. único (EC nº 73/2011), Abolição da Escravidão no Ceará - * (25/03): the text fixes the data magna, and the feriado follows from Lei 9.093/1995, art. 1º, - * II. + * Constituição Estadual do CE, art. 18 par. único (EC nº 73, de 01/12/2011, DO 06/12/2011), + * Abolição da Escravidão no Ceará (25/03), listed from 2012: the text fixes the data magna, and + * the feriado follows from Lei 9.093/1995, art. 1º, II. * @see Official: https://www.sinj.df.gov.br/sinj/Norma/18459/Lei_72_27_12_1989.html - * Lei distrital nº 72/1989, art. 1º, I, Fundação de Brasília (21/04), and art. 1º par. único, + * Lei distrital nº 72, de 27/12/1989, listed from 1990: art. 1º, I, Fundação de Brasília (21/04), + * and art. 1º par. único, * Corpus Christi: "São, igualmente feriados, a Sexta-feira da Paixão e Corpus Christi, datas - * móveis". + * móveis". Its art. 1º, II also makes 12/10, "data consagrada a Nossa Senhora Aparecida, + * Padroeira de Brasília", a feriado; that day and the Sexta-feira da Paixão have no DF entry of + * their own, since the national entries of the same name already make them holidays (Decreto DF + * nº 48.117/2025, the 2026 calendar, calls the Paixão a "feriado nacional" too). * @see Official: https://www.sinj.df.gov.br/sinj/Norma/48922/Lei_963_1995.html - * Lei distrital nº 963/1995, Dia do Evangélico (30/11) + * Lei distrital nº 963, de 04/12/1995, Dia do Evangélico (30/11), listed from 1996 * @see Official: https://www3.al.es.gov.br/Arquivo/Documents/legislacao/html/LEI110102019.html - * Lei ES nº 11.010/2019, art. 1º par. único, Nossa Senhora da Penha (padroeira do estado, "sempre - * na segunda-feira, oitavo dia posterior ao domingo de Páscoa") + * Lei ES nº 11.010, de 03/07/2019 (DOE 04/07/2019), art. 1º par. único, Nossa Senhora da Penha + * (padroeira do estado, "sempre na segunda-feira, oitavo dia posterior ao domingo de Páscoa"), + * listed from 2020, the first of those Mondays after the law * @see Official: https://legisla.casacivil.go.gov.br/pesquisa_legislacao/100979/lei-20756 * Lei GO nº 20.756/2020, art. 269, II, the three feriados estaduais of Goiás: "a) 26 de julho, * consagrado à fundação da cidade de Goiás; b) 24 de outubro, comemorativo ao lançamento da pedra - * fundamental de Goiânia; c) 28 de outubro, consagrado ao servidor público". + * fundamental de Goiânia; c) 28 de outubro, consagrado ao servidor público". The law is of + * 28/01/2020 and entered into force on 28/07/2020, after that year's 26 July, so 26/07 is listed + * from 2021. These are days of the servants' statute, whose caput reads "não haverá expediente em + * nenhuma repartição ou serviço do Estado ... nos seguintes feriados"; no Goiás law fixing a data + * magna as the feriado civil of Lei 9.093/1995, art. 1º, II was located (a search of the Casa + * Civil legislation base for "data magna" returns nothing). The entries are kept as the "feriados + * estaduais" that statute names. Its § 1º: "Fica o Chefe do Poder Executivo autorizado a + * transferir os feriados de que tratam as alíneas "a" e "c" do inciso II deste artigo para outro + * dia útil próximo". + * @see Official: https://legisla.casacivil.go.gov.br/api/v2/pesquisa/legislacoes/115759 + * Decreto GO nº 10.935, de 23/06/2026, art. 2º: "Fica transferido para o dia 20 de julho de 2026 o + * feriado consagrado à fundação da Cidade de Goiás". The Casa Civil base holds one such decree per + * year: nº 10.118 (2022, to 25/07), nº 10.288 (2023, to 24/07), nº 10.481 (2024, to 22/07), nº + * 10.709 (2025, to 28/07) and nº 10.935 (2026, to 20/07). The entry keeps the statutory 26/07, + * since the observed date is only known once each year's decree is out. + * @see Official: https://legisla.casacivil.go.gov.br/pesquisa_legislacao/83678/lei-10460 + * Lei GO nº 10.460, de 22/02/1988, the servants' statute Lei GO nº 20.756/2020 replaced, which + * already declared 24/10 and 28/10, so both are listed from 1988. Whether it also carried 26/07 + * could not be confirmed, so that date starts with Lei GO nº 20.756/2020. * @see Official: https://arquivos.al.ma.leg.br:8443/ged/legislacao/LEI_2457 - * Lei MA nº 2.457/1964, Adesão do Maranhão à Independência (28/07) + * Lei MA nº 2.457, de 02/10/1964, Adesão do Maranhão à Independência (28/07), listed from 1965: + * "É feriado estadual o dia 28 de julho, data magna do Estado" + * @see Official: http://arquivos.al.ma.leg.br:8080/ged/legislacao/LEI_11539 + * Lei MA nº 11.539, de 21/09/2021, promulgated by the Assembleia Legislativa, art. 1º: "Fica + * considerado Feriado Estadual Religioso, o Feriado de Corpus Christi" (Easter plus 60). A + * cautelar of the TJMA suspended it on an ADI brought by Fecomércio-MA, Fiema and the Associação + * Comercial do Maranhão, and the Órgão Especial judged that ADI improcedente on 06/03/2024, + * revoking the cautelar (cited next). The date the cautelar was granted was not located, so + * whether the 2022 and 2023 dates were in force cannot be established: the entry starts in 2024, + * the first Corpus Christi after the judgment. It replaces the national optional entry typed + * `"state"`, as the Distrito Federal one does. Missing up to 2.4.0. + * @see Official: https://www.tjma.jus.br/midia/tj/noticia/512660/tjma-julga-constitucional-lei-que-tornou-corpus-christi-feriado-estadual + * TJMA, "TJMA julga constitucional lei que tornou Corpus Christi feriado estadual" (06/03/2024): + * the Órgão Especial "reconheceu como constitucional a Lei nº 11.539, de 21 de setembro de 2021 + * ... que incluiu o feriado de Corpus Christi entre os feriados estaduais", by unanimity. + * @see Official: http://arquivos.al.ma.leg.br:8080/ged/legislacao/LEI_12800 + * Lei MA nº 12.800, de 19/03/2026, art. 1º: "Fica instituído o dia 8 de março, Dia Internacional + * da Mulher, como feriado estadual a ser celebrado anualmente", in force on its publication, after + * that year's date, so listed from 2027. The press reports an ADI against it filed with the TJMA + * on 30/03/2026; no decision was located. Maranhão's 28 August, which Lei MA nº 10.100/2014 makes + * a "feriado estadual aos bancários" only, is not a general holiday and has no entry. + * @see Official: https://www.almg.gov.br/atividade-parlamentar/leis/legislacao-mineira/lei/texto/?tipo=CON&num=1989&ano=1989&comp=&cons=1 + * Constituição Estadual de MG, art. 256 (EC nº 89/2011): "São considerados: I - data magna do + * Estado o dia 21 de abril, Dia de Tiradentes; II - Dia de Minas o dia 16 de julho; III - Dia dos + * Gerais o dia 8 de dezembro". The data magna falls on the national Tiradentes and 16/07 and 08/12 + * are not declared feriados, so Minas Gerais has no entry. * @see Official: https://www.al.mt.gov.br/norma-juridica/urn:lex:br;mato.grosso:estadual:lei.ordinaria:2002-12-27;7879 - * Lei MT nº 7.879, de 27/12/2002, Dia da Consciência Negra (state holiday until it became - * national in 2024). Art. 1º, as published in the Diário Oficial do Estado de Mato Grosso of + * Lei MT nº 7.879, de 27/12/2002, Dia da Consciência Negra (state holiday from 2003 until it + * became national in 2024). Art. 1º, as published in the Diário Oficial do Estado de Mato Grosso of * 27/12/2002 (p. 6), the text the ALMT ficha técnica links: "Fica instituído o dia 20 de * novembro, data do aniversário da morte de Zumbi dos Palmares e Dia Nacional da Consciência * Negra, como feriado estadual"; its ementa repeats the same wording, and the ficha técnica @@ -162,35 +441,68 @@ const SC_NEXT_SUNDAY_TRANSFER_SINCE_YEAR = 2005; * elsewhere is not in the ALMT norm base at all, under any norm type: 7.879/2002 is the law that * creates it. * @see Official: https://aacpdappls.net.ms.gov.br/appls/legislacao/secoge/govato.nsf/1b758e65922af3e904256b220050342a/a489a293563f506304256e450002e9f8 - * Lei MS nº 10/1979, Criação do Estado de Mato Grosso do Sul (11/10) + * Lei MS nº 10, de 29/10/1979, Criação do Estado de Mato Grosso do Sul (11/10), listed from 1980 * @see Official: https://bancodeleis.alepa.pa.gov.br/arquivos/lei5999_1996_93239.pdf - * Lei PA nº 5.999/1996, Adesão do Pará à Independência (15/08) + * Lei PA nº 5.999, de 10/09/1996, Adesão do Pará à Independência (15/08), listed from 1997 * @see Official: https://sapl.al.pb.leg.br/norma/11988 - * Lei PB nº 10.601/2015, Data Magna do Estado da Paraíba (05/08): "INSTITUI COMO FERIADO CIVIL O - * DIA 05 DE AGOSTO, DATA MAGNA DO ESTADO DA PARAÍBA". Its art. 2º also revoked art. 2º of Lei PB - * nº 3.489/1967, the basis of the 26/07 Morte de João Pessoa entry, which is therefore emitted - * only up to 2015. + * Lei PB nº 10.601, de 16/12/2015, Data Magna do Estado da Paraíba (05/08), listed from 2016: + * "INSTITUI COMO FERIADO CIVIL O DIA 05 DE AGOSTO, DATA MAGNA DO ESTADO DA PARAÍBA". Its art. 2º + * also revoked art. 2º of Lei PB nº 3.489, de 30/08/1967, the basis of the 26/07 Morte de João + * Pessoa entry, which is therefore emitted from 1968 up to 2015. That art. 2º read "São + * considerados feriados estaduais o 5 de agosto, em comemoração à fundação da Paraíba, em 1585, e + * o 26 de julho, em homenagem a memória do ex-Presidente João Pessoa", so 5 August was already a + * feriado estadual under it: from 1968 to 2015 it is emitted as "Fundação da Paraíba", the name + * that law gives it (2.4.0 listed 05/08 in every year, as "Data Magna do Estado da Paraíba"). Lei PB nº 3.489/1967 entered + * into force on its publication (DOE 03/09/1967), after that year's date. Lei PB nº 14.171, de + * 11/12/2025, which declares 20 November a feriado estadual, has no entry: the date has been the + * national holiday of Lei 14.759/2023 since 2024. + * @see Official: https://sapl.al.pb.leg.br/media/sapl/public/normajuridica/1967/2945/2945_texto_integral.pdf + * Lei PB nº 3.489, de 30/08/1967, the scanned text of the law, art. 2º as quoted above. * @see Official: https://www.legislacao.pr.gov.br/legislacao/pesquisarAto.do?action=exibir&codAto=134573 - * Lei PR nº 18.384/2014, Emancipação Política do Paraná (19/12), expressly "não se constituindo em - * feriado civil" + * Lei PR nº 18.384, de 17/12/2014, Emancipação Política do Paraná (19/12), a data comemorativa its + * own text says is "não se constituindo em feriado civil", nor a ponto facultativo it declares. + * Its art. 3º revoked the Lei PR nº 4.658, de 18/12/1962 (DO 21/12/1962), whose art. 1º read "Fica + * consagrada a data de 19 de Dezembro como feriado estadual", so 19/12 is listed from 1963, the + * first one after that law, up to 2013, the last one before its revocation. + * @see Official: https://www.legislacao.pr.gov.br/legislacao/exibirAto.do?action=localizarAto&codTipoAto=1&nroAto=4658&dataAto=18%2F12%2F1962&tipoVisualizacao=original + * Lei PR nº 4.658, de 18/12/1962, art. 1º, as quoted above. * @see Official: https://legis.alepe.pe.gov.br/texto.aspx?tiponorma=1&numero=16241&complemento=0&ano=2017&tipo=&url= - * Lei PE nº 16.241/2017, art. 49, Revolução Pernambucana (06/03): "Dia 6 de março: Data Magna do + * Lei PE nº 16.241, de 14/12/2017, art. 49, Revolução Pernambucana (06/03), listed from 2018: "Dia 6 de março: Data Magna do * Estado de Pernambuco e feriado civil no âmbito do Estado de Pernambuco". Revoked the Lei PE nº - * 16.059/2017 cited here before, which had itself superseded the movable "primeiro domingo de - * março" of Lei PE nº 13.835/2009. + * 16.059, de 08/06/2017, cited here before, which had itself superseded the movable "primeiro + * domingo de março" of Lei PE nº 13.835/2009, always a Sunday. Before it, Lei PE nº 13.386, de + * 24/12/2007, art. 1º, "Fica instituído o dia 06 (seis) de março como data Magna do Estado de + * Pernambuco", gave the date "a condição de ponto facultativo", so 06/03 is listed as `"optional"` + * in 2008 and 2009. + * @see Official: https://legis.alepe.pe.gov.br/texto.aspx?tiponorma=1&numero=13386&complemento=0&ano=2007&tipo=&url= + * Lei PE nº 13.386, de 24/12/2007, as quoted above. * @see Official: https://sapl.al.pi.leg.br/norma/5849 - * Lei PI nº 176/1937, Dia do Piauí (19/10) + * Lei PI nº 176, de 30/08/1937, Dia do Piauí (19/10), listed from 1937: "19 de Outubro será + * feriado estadual, com a denominação de DIA DO PIAUHY" + * @see Official: https://sapl.al.pi.leg.br/norma/pesquisar?ementa=feriado + * The ALEPI search for "feriado", which lists Lei PI nº 176/1937 as "Norma sem alterações + * posteriores" and the two one-year laws that moved its date: Lei PI nº 7.371, de 11/05/2020, to + * 15/05/2020, and Lei PI nº 7.490, de 17/03/2021, "ANTECIPA PARA O DIA 18 DE MARÇO DO ANO EM CURSO, + * O FERIADO ALUSIVO AO DIA DO PIAUÍ". Neither is carried: each applies to one year only. + * @see Official: http://alerjln1.alerj.rj.gov.br/contlei.nsf/f25edae7e64db53b032564fe005262ef/063f7c027766eab48325744a007a4ab0?OpenDocument + * Lei RJ nº 5.243, de 14/05/2008, art. 1º, which "Institui, no âmbito do Estado do Rio de Janeiro, + * a terça-feira de carnaval como Feriado Estadual", in force on its publication. That year's + * Carnaval (5 February 2008) had already passed, so the entry is listed from 2009, under the name + * of the national Carnaval Tuesday it replaces. The STF ADI 4131 against it was não conhecida + * (cited below), so the law stands. The Monday stays a ponto facultativo in the state. * @see Official: http://alerjln1.alerj.rj.gov.br/CONTLEI.NSF/c8aa0900025feef6032564ec0060dfff/1baf90ca125ff96f8325740a00776600 - * Lei RJ nº 5.198/2008, São Jorge (23/04): the ALERJ text of the law. Its Ficha Técnica records - * no ação de inconstitucionalidade; the STF case is cited separately below. + * Lei RJ nº 5.198, de 05/03/2008, São Jorge (23/04), listed from 2008: the ALERJ text of the law. Its Ficha Técnica records + * no ação de inconstitucionalidade; the STF case is cited separately below. Rio de Janeiro's Dia do + * Comércio (third Monday of October), which the annex of Lei RJ nº 5.645/2010 labels "FERIADO", + * has no entry: the law behind it, Lei RJ nº 160/1977, only says it "será comemorado". * @see Official: https://portal.stf.jus.br/processos/detalhe.asp?incidente=2624787 * STF ADI 4092, which upheld that law. Decisão de julgamento of 28/08/2023, Tribunal Pleno, * sessão virtual: "O Tribunal, por maioria, declarou a constitucionalidade da Lei do Estado do Rio * de Janeiro n. 5.198, de 5 de março de 2008, e, por conseguinte, julgou improcedente a presente * ação direta … Plenário, Sessão Virtual de 18.8.2023 a 25.8.2023"; trânsito em julgado 28/10/2023. * @see Official: http://alerjln1.alerj.rj.gov.br/CONTLEI.NSF/69d90307244602bb032567e800668618/80a541c3a5a9d63183256c7d0057bf25 - * Lei RJ nº 4.007, de 11/11/2002, Dia da Consciência Negra (state holiday until it became - * national in 2024). Art. 1º: "Fica instituído o dia 20 de novembro, data do aniversário da + * Lei RJ nº 4.007, de 11/11/2002, Dia da Consciência Negra (state holiday from 2002 until it + * became national in 2024). Art. 1º: "Fica instituído o dia 20 de novembro, data do aniversário da * morte de Zumbi dos Palmares e dia Nacional da consciência Negra, como feriado Estadual", the * same wording Mato Grosso's law of the same year carries. Its Ficha Técnica records no ação de * inconstitucionalidade either. @@ -199,8 +511,8 @@ const SC_NEXT_SUNDAY_TRANSFER_SINCE_YEAR = 2005; * declaração de inconstitucionalidade da Lei n. 5.243, do Estado do Rio de Janeiro, de 14 de maio * de 2008" and was não conhecida on 21/09/2018 (trânsito em julgado 25/10/2018). * @see Official: http://www.al.rn.leg.br/storage/legislacao//arq5064574f632ec.pdf - * Lei RN nº 8.913/2006, Mártires de Cunhaú e Uruaçu (03/10), the single entry of Rio Grande do - * Norte: a "Resumo da Lei" search for "feriado" in the ALRN legislation base + * Lei RN nº 8.913, de 06/12/2006, Mártires de Cunhaú e Uruaçu (03/10), listed from 2007, the + * single entry of Rio Grande do Norte: a "Resumo da Lei" search for "feriado" in the ALRN legislation base * (https://www.al.rn.leg.br/legislacao/pesquisa) returns this law and no other. * @see Official: https://www.al.rn.leg.br/noticia/19157/rn-faz-519-anos-e-data-foi-criada-por-lei-estadual-em-alusao-ao-marco-de-touros * Lei RN nº 7.831, de 30/05/2000, the "Dia do Rio Grande do Norte" (07/08), which is *not* a @@ -220,16 +532,19 @@ const SC_NEXT_SUNDAY_TRANSFER_SINCE_YEAR = 2005; * Constituição Estadual page, linked below), art. 6º § 1º, Revolução Farroupilha (20/09): "O dia * 20 de setembro é a data magna, sendo considerado feriado no Estado. (Redação dada pela Emenda * Constitucional n.º 11, de 03/10/95) … (Renumerado pela Emenda Constitucional n.º 83, de - * 28/09/23)". + * 28/09/23)". Listed from 1995, the year Decreto RS nº 36.180, de 18/09/1995, declared that 20 + * September a holiday, two weeks before the amendment made it permanent. * @see Official: https://ww2.al.rs.gov.br/dal/Legisla%C3%A7%C3%A3o/Constitui%C3%A7%C3%A3oEstadual/tabid/3683/Default.aspx * The Assembleia Legislativa do RS page that publishes that compiled text; it is a link hub and * carries no article text of its own. * @see Official: https://sapl.al.ro.leg.br/norma/4958 - * Lei RO nº 2.291, de 22/04/2010, Criação do Estado de Rondônia (04/01): "DECLARA O DIA 4 DE - * JANEIRO DATA MAGNA E FERIADO CIVIL ESTADUAL". Lei RO nº 3.170/2013, cited here before, is a + * Lei RO nº 2.291, de 22/04/2010, Criação do Estado de Rondônia (04/01), listed from 2011: "DECLARA + * O DIA 4 DE JANEIRO DATA MAGNA E FERIADO CIVIL ESTADUAL". Lei RO nº 3.170/2013, cited here before, is a * supplementary credit law: "AUTORIZA O PODER EXECUTIVO A ABRIR CRÉDITO SUPLEMENTAR POR ANULAÇÃO * ... EM FAVOR DAS UNIDADES ORÇAMENTÁRIAS: DEPARTAMENTO DE ESTRADAS E RODAGEM - DER/RO, - * SECRETARIA DE ESTADO DE ASSISTÊNCIA SOCIAL - SEAS", nothing to do with holidays. + * SECRETARIA DE ESTADO DE ASSISTÊNCIA SOCIAL - SEAS", nothing to do with holidays. Lei RO nº + * 2.291/2010 revoked Decreto-Lei RO nº 39, de 31/12/1982, under which 04/01 was already a + * holiday; that decree's text was not located, so the entry starts at the 2010 law. * @see Official: https://sapl.al.ro.leg.br/norma/3003 * Lei RO nº 1.026, de 20/12/2001, the other law cited for Rondônia, whose art. 1º did create a * second feriado estadual — "Fica instituído feriado no Estado de Rondônia, o dia 18 de junho, @@ -242,15 +557,20 @@ const SC_NEXT_SUNDAY_TRANSFER_SINCE_YEAR = 2005; * 19.3.2020"; trânsito em julgado 11/08/2020. The declaration is erga omnes and ex tunc, so the * date is absent for every year, not only from 2020 on. * @see Official: http://sapl.al.rr.leg.br/media/sapl/public/normajuridica/1991/3912/constituicao_estadual_do_estado_de_roraima.pdf - * Constituição Estadual de RR, art. 9º, Criação do Estado de Roraima (05/10): "Cinco de outubro, + * Constituição Estadual de RR, de 31/12/1991, art. 9º, Criação do Estado de Roraima (05/10), + * listed from 1992: "Cinco de outubro, * data magna de Roraima, é feriado em todo o território do Estado". * @see Official: http://leis.alesc.sc.gov.br/html/2022/18531_2022_lei.html * Lei SC nº 18.531/2022, the in-force consolidation, whose Anexo Único carries both Santa Catarina * holidays and the Sunday transfer: "Sempre que o dia 11 de agosto coincidir com dia útil da * semana, o feriado e os eventos alusivos à data serão transferidos para o domingo subsequente" - * and the same clause for 25 de novembro. + * and the same clause for 25 de novembro. The law does not say whether a Saturday is a "dia útil + * da semana"; the table reads it as not one (Monday to Friday move, a Saturday date stays), the + * reading under which the clause keeps the holiday on a day off either way. * @see Official: http://leis.alesc.sc.gov.br/html/1996/10306_1996_lei.html - * Lei SC nº 10.306/1996, art. 1º, in the wording of Lei SC nº 12.906/2004: "É considerada data + * Lei SC nº 10.306, of December 1996, which made 25 November the data magna after that year's + * date, so it is listed from 1997, and whose art. 1º reads, in the wording of Lei SC nº + * 12.906/2004 that added 11 August from 2004 on: "É considerada data * magna do Estado o dia 11 de agosto, Dia do Estado de Santa Catarina, e dia de Santa Catarina de * Alexandria, dia 25 de novembro". * @see Official: http://leis.alesc.sc.gov.br/html/1999/11213_1999_lei.html @@ -273,12 +593,12 @@ const SC_NEXT_SUNDAY_TRANSFER_SINCE_YEAR = 2005; * published (DO 17.680, de 15/07/2005): "Sempre que o dia 11 de agosto e o dia 25 de novembro * coincidirem com dias úteis da semana, os feriados e os eventos alusivos às datas serão * transferidos para o domingo subseqüente". Both of that year's dates fall after it. The two - * holidays are therefore split by year: 11 August is fixed up to 2004 and transferring from 2005 - * on, while 25 November is fixed up to 1998, transferring from 1999 to 2003, fixed again in 2004 - * and transferring from 2005 on. Lei SC nº 16.719/2015, cited here before, was revoked by Lei SC nº + * holidays are therefore split by year: 11 August is fixed in 2004, its first year, and + * transferring from 2005 on, while 25 November is fixed from 1997 to 1998, transferring from 1999 + * to 2003, fixed again in 2004 and transferring from 2005 on. Lei SC nº 16.719/2015, cited here before, was revoked by Lei SC nº * 17.335/2017, itself consolidated and revoked by Lei SC nº 18.531/2022. * @see Official: https://www.al.sp.gov.br/repositorio/legislacao/lei/1997/lei-9497-05.03.1997.html - * Lei SP nº 9.497, de 05/03/1997, Revolução Constitucionalista (09/07), art. 1º: "Fica + * Lei SP nº 9.497, de 05/03/1997, Revolução Constitucionalista (09/07), listed from 1997, art. 1º: "Fica * instituído, como feriado civil, o dia 9 (nove) de julho, data magna do Estado de São Paulo, * conforme autorizado pelo Artigo 1.º, inciso II, da Lei Federal n. 9.093, de 12 de setembro de * 1995". The "710/1995" cited for this holiday elsewhere is the number of the projeto de lei that @@ -293,35 +613,57 @@ const SC_NEXT_SUNDAY_TRANSFER_SINCE_YEAR = 2005; * so this single-year entry is the one place the table reports a holiday under * `CONSCIENCIA_NEGRA_HOLIDAY_NAME` where the law itself says "Dia Estadual". * @see Official: https://aleselegis.al.se.leg.br/Arquivo/Documents/legislacao/html/CE11989.html - * Constituição Estadual de SE, art. 269 (EC nº 20/2000), Independência de Sergipe (08/07): "Será - * feriado estadual o dia 08 de julho, data consagrada à Independência de Sergipe". + * Constituição Estadual de SE, de 05/10/1989, art. 269. Its original wording made two feriados + * estaduais: "Serão feriados estaduais o dia 08 de julho e 24 de outubro, datas consagradas à + * Independência de Sergipe e à comemoração popular e tradicional da mesma independência". EC nº + * 20, de 31/05/2000, rewrote it to "Será feriado estadual o dia 08 de julho, data consagrada à + * Independência de Sergipe". So Independência de Sergipe (08/07) is listed from 1990, the first 8 + * July after the Constitution (2.4.0 listed it in every year), and the + * Comemoração Popular da Independência de Sergipe (24/10) from 1989, nineteen days after the + * Constitution, up to 1999. * @see Official: https://www.al.to.leg.br/arquivo/15717 - * Lei TO nº 960/1998, whose art. 1º caput only institutes the Dia da Autonomia (18/03); the - * feriado estadual sat in the parágrafo único. + * Lei TO nº 960, de 17/03/1998, whose art. 1º caput only institutes the Dia da Autonomia (18/03); + * the feriado estadual sat in the parágrafo único, so it is listed from 1998. * @see Official: https://www.al.to.leg.br/arquivo/15724 * Lei TO nº 2.013, de 18/02/2009, which replaced that parágrafo único with a purely commemorative * provision, so 18/03 is emitted only up to 2008. * @see Official: https://www.al.to.leg.br/arquivo/6883 - * Lei TO nº 627/1993, Padroeira do Estado (Nossa Senhora da Natividade, 08/09) + * Lei TO nº 627, de 28/12/1993, Padroeira do Estado (Nossa Senhora da Natividade, 08/09), listed + * from 1994 * @see Official: https://www.al.to.leg.br/arquivo/6358 - * Lei TO nº 98/1989, Criação do Estado do Tocantins (05/10) + * Lei TO nº 98, de 17/11/1989, Criação do Estado do Tocantins (05/10), listed from 1990 + * @see Official: https://www.al.to.leg.br/arquivos/7336.pdf + * Lei TO nº 1.088, de 23/09/1999, art. 1º: "Fica o Chefe do Poder Executivo autorizado a antecipar + * ou prorrogar feriados e dias santificados, sempre que convier aos interesses do serviço + * público". Under it the executive moved 05/10/2026 to 09/10/2026 (Decreto TO nº 7.238/2026, per + * the press; its official text could not be loaded from the DOE-TO site). Such a decree is a + * one-year move for the state's own offices, so the statutory 05/10 is kept. */ export const STATE_HOLIDAYS: Partial> = { AC: [ - { name: "Dia do Evangélico", day: 23, month: 1 }, - { name: "Dia Internacional da Mulher", day: 8, month: 3 }, - { name: "Aniversário do Acre", day: 15, month: 6 }, - { name: "Dia da Amazônia", day: 5, month: 9 }, - { name: "Assinatura do Tratado de Petrópolis", day: 17, month: 11 }, + { name: "Dia do Católico", day: 20, month: 1, since: 2017 }, + { name: "Dia do Evangélico", day: 23, month: 1, since: 2005 }, + { name: "Dia Internacional da Mulher", day: 8, month: 3, since: 2002 }, + { name: "Aniversário do Acre", day: 15, month: 6, since: 1965 }, + { name: "Dia da Amazônia", day: 5, month: 9, since: 1969 }, + { name: "Assinatura do Tratado de Petrópolis", day: 17, month: 11, since: 1966 }, ], AL: [ - { name: "São João", day: 24, month: 6 }, - { name: "São Pedro", day: 29, month: 6 }, + { name: "São João", day: 24, month: 6, since: 1994 }, + { name: "São Pedro", day: 29, month: 6, since: 1994 }, + { + name: CONSCIENCIA_NEGRA_HOLIDAY_NAME, + day: 20, + month: 11, + since: 1995, + until: CONSCIENCIA_NEGRA_NATIONAL_SINCE_YEAR, + }, { name: "Emancipação Política de Alagoas", day: 16, month: 9, type: "optional", + since: 2020, until: AL_EMANCIPACAO_FERIADO_SINCE_YEAR, }, { @@ -330,20 +672,29 @@ export const STATE_HOLIDAYS: Partial> = { month: 9, since: AL_EMANCIPACAO_FERIADO_SINCE_YEAR, }, + { + name: "Dia Estadual do Evangélico", + day: 30, + month: 11, + tuesdayToMondayThursdayToFriday: true, + since: AL_DIA_DO_EVANGELICO_SINCE_YEAR, + }, ], AP: [ - { name: "Dia de São José", day: 19, month: 3 }, - { name: "Criação do Território Federal do Amapá", day: 13, month: 9 }, + { name: "Dia de São José", day: 19, month: 3, since: 2003 }, + { name: "Dia de Cabralzinho", day: 15, month: 5, since: 2018 }, + { name: "Dia de São Tiago", day: 25, month: 7, since: 2012, until: 2019 }, + { name: "Criação do Território Federal do Amapá", day: 13, month: 9, since: 1992 }, { name: "Dia Estadual da Consciência Negra", day: 20, month: 11, - since: 2007, + since: 2008, until: CONSCIENCIA_NEGRA_NATIONAL_SINCE_YEAR, }, ], AM: [ - { name: "Elevação do Amazonas à categoria de Província", day: 5, month: 9 }, + { name: "Elevação do Amazonas à categoria de Província", day: 5, month: 9, since: 1978 }, { name: CONSCIENCIA_NEGRA_HOLIDAY_NAME, day: 20, @@ -353,60 +704,92 @@ export const STATE_HOLIDAYS: Partial> = { }, { name: "Nossa Senhora da Conceição", day: 8, month: 12, type: "optional" }, ], - BA: [{ name: "Independência da Bahia", day: 2, month: 7 }], - CE: [{ name: "Abolição da Escravidão no Ceará", day: 25, month: 3 }], + BA: [{ name: "Independência da Bahia", day: 2, month: 7, since: 1990 }], + CE: [{ name: "Abolição da Escravidão no Ceará", day: 25, month: 3, since: 2012 }], DF: [ - { name: "Fundação de Brasília", day: 21, month: 4 }, - { name: "Corpus Christi", easterOffset: 60 }, - { name: "Dia do Evangélico", day: 30, month: 11 }, + { name: "Fundação de Brasília", day: 21, month: 4, since: 1990 }, + { name: "Corpus Christi", easterOffset: 60, since: 1990 }, + { name: "Dia do Evangélico", day: 30, month: 11, since: 1996 }, ], - ES: [{ name: "Nossa Senhora da Penha", easterOffset: 8 }], + ES: [{ name: "Nossa Senhora da Penha", easterOffset: 8, since: 2020 }], GO: [ - { name: "Fundação da Cidade de Goiás", day: 26, month: 7 }, - { name: "Lançamento da Pedra Fundamental de Goiânia", day: 24, month: 10 }, - { name: "Dia do Servidor Público", day: 28, month: 10 }, + { name: "Fundação da Cidade de Goiás", day: 26, month: 7, since: 2021 }, + { name: "Lançamento da Pedra Fundamental de Goiânia", day: 24, month: 10, since: 1988 }, + { name: "Dia do Servidor Público", day: 28, month: 10, since: 1988 }, + ], + MA: [ + { name: "Adesão do Maranhão à Independência", day: 28, month: 7, since: 1965 }, + { name: "Corpus Christi", easterOffset: 60, since: 2024 }, + { name: "Dia Internacional da Mulher", day: 8, month: 3, since: 2027 }, ], - MA: [{ name: "Adesão do Maranhão à Independência", day: 28, month: 7 }], MT: [ { name: CONSCIENCIA_NEGRA_HOLIDAY_NAME, day: 20, month: 11, + since: 2003, until: CONSCIENCIA_NEGRA_NATIONAL_SINCE_YEAR, }, ], - MS: [{ name: "Criação do Estado de Mato Grosso do Sul", day: 11, month: 10 }], - PA: [{ name: "Adesão do Pará à Independência", day: 15, month: 8 }], + MS: [{ name: "Criação do Estado de Mato Grosso do Sul", day: 11, month: 10, since: 1980 }], + PA: [{ name: "Adesão do Pará à Independência", day: 15, month: 8, since: 1997 }], PB: [ - { name: "Data Magna do Estado da Paraíba", day: 5, month: 8 }, + { + name: "Data Magna do Estado da Paraíba", + day: 5, + month: 8, + since: PB_LEI_3489_ARTICLE_2_UNTIL_YEAR, + }, + { + name: "Fundação da Paraíba", + day: 5, + month: 8, + since: 1968, + until: PB_LEI_3489_ARTICLE_2_UNTIL_YEAR, + }, { name: "Morte de João Pessoa", day: 26, month: 7, - until: PB_MORTE_JOAO_PESSOA_UNTIL_YEAR, + since: 1968, + until: PB_LEI_3489_ARTICLE_2_UNTIL_YEAR, + }, + ], + + PE: [ + { + name: "Revolução Pernambucana", + day: 6, + month: 3, + type: "optional", + since: 2008, + until: 2010, }, + { name: "Revolução Pernambucana", day: 6, month: 3, since: 2018 }, ], - PR: [{ name: "Emancipação Política do Paraná", day: 19, month: 12, type: "optional" }], - PE: [{ name: "Revolução Pernambucana", day: 6, month: 3 }], - PI: [{ name: "Dia do Piauí", day: 19, month: 10 }], + PI: [{ name: "Dia do Piauí", day: 19, month: 10, since: 1937 }], + PR: [{ name: "Emancipação Política do Paraná", day: 19, month: 12, since: 1963, until: 2014 }], RJ: [ - { name: "São Jorge", day: 23, month: 4 }, + { name: "Carnaval (terça-feira)", easterOffset: -47, since: 2009 }, + { name: "São Jorge", day: 23, month: 4, since: 2008 }, { name: CONSCIENCIA_NEGRA_HOLIDAY_NAME, day: 20, month: 11, + since: 2002, until: CONSCIENCIA_NEGRA_NATIONAL_SINCE_YEAR, }, ], - RN: [{ name: "Mártires de Cunhaú e Uruaçu", day: 3, month: 10 }], - RS: [{ name: "Revolução Farroupilha", day: 20, month: 9 }], - RO: [{ name: "Criação do Estado de Rondônia", day: 4, month: 1 }], - RR: [{ name: "Criação do Estado de Roraima", day: 5, month: 10 }], + RN: [{ name: "Mártires de Cunhaú e Uruaçu", day: 3, month: 10, since: 2007 }], + RS: [{ name: "Revolução Farroupilha", day: 20, month: 9, since: 1995 }], + RO: [{ name: "Criação do Estado de Rondônia", day: 4, month: 1, since: 2011 }], + RR: [{ name: "Criação do Estado de Roraima", day: 5, month: 10, since: 1992 }], SC: [ { name: "Dia do Estado de Santa Catarina", day: 11, month: 8, + since: SC_DIA_DO_ESTADO_SINCE_YEAR, until: SC_NEXT_SUNDAY_TRANSFER_SINCE_YEAR, }, { @@ -420,6 +803,7 @@ export const STATE_HOLIDAYS: Partial> = { name: "Dia de Santa Catarina de Alexandria", day: 25, month: 11, + since: SC_ALEXANDRIA_SINCE_YEAR, until: SC_ALEXANDRIA_TRANSFER_SINCE_YEAR, }, { @@ -446,7 +830,7 @@ export const STATE_HOLIDAYS: Partial> = { }, ], SP: [ - { name: "Revolução Constitucionalista", day: 9, month: 7 }, + { name: "Revolução Constitucionalista", day: 9, month: 7, since: 1997 }, { name: CONSCIENCIA_NEGRA_HOLIDAY_NAME, day: 20, @@ -455,19 +839,30 @@ export const STATE_HOLIDAYS: Partial> = { until: CONSCIENCIA_NEGRA_NATIONAL_SINCE_YEAR, }, ], - SE: [{ name: "Independência de Sergipe", day: 8, month: 7 }], + SE: [ + { name: "Independência de Sergipe", day: 8, month: 7, since: 1990 }, + { + name: "Comemoração Popular da Independência de Sergipe", + day: 24, + month: 10, + since: 1989, + until: 2000, + }, + ], TO: [ { name: "Autonomia do Estado do Tocantins", day: 18, month: 3, + since: 1998, until: TO_AUTONOMIA_UNTIL_YEAR, }, { name: "Padroeira do Estado (Nossa Senhora da Natividade)", day: 8, month: 9, + since: 1994, }, - { name: "Criação do Estado do Tocantins", day: 5, month: 10 }, + { name: "Criação do Estado do Tocantins", day: 5, month: 10, since: 1990 }, ], }; diff --git a/src/get-holidays/get-holidays.test.ts b/src/get-holidays/get-holidays.test.ts index 59ad8266a..8a2677789 100644 --- a/src/get-holidays/get-holidays.test.ts +++ b/src/get-holidays/get-holidays.test.ts @@ -11,8 +11,14 @@ import { test, } from "../_internals/test/runtime"; import { isBusinessDay } from "../is-business-day/is-business-day"; +import { isHoliday } from "../is-holiday/is-holiday"; import { STATE_HOLIDAYS } from "./constants"; -import { getHolidays, type GetHolidaysParams, type Holiday } from "./get-holidays"; +import { + getHolidays, + type GetHolidaysParams, + type Holiday, + type HolidayType, +} from "./get-holidays"; const PROTOTYPE_KEYS = Object.getOwnPropertyNames(Object.prototype); @@ -22,6 +28,975 @@ function getHolidaysFor(year: number, stateCode: StateCode | null): Holiday[] { return stateCode === null ? getHolidays(year) : getHolidays({ year, stateCode }); } +function scNamesIn(year: number): string[] { + return getHolidays({ year, stateCode: "SC" }).map((holiday) => holiday.name); +} + +function carnavalIn(year: number, stateCode: StateCode): Holiday[] { + return getHolidays({ year, stateCode }).filter((holiday) => holiday.name.startsWith("Carnaval")); +} + +function alEvangelicoIn(year: number): Holiday[] { + return getHolidays({ year, stateCode: "AL" }).filter( + (holiday) => holiday.name === "Dia Estadual do Evangélico", + ); +} + +function carnaval(year: number): Holiday[] { + return getHolidays(year).filter((holiday) => holiday.name.startsWith("Carnaval")); +} + +const NATIONAL_HOLIDAY_PERIODS: { + name: string; + month: number; + day: number; + year: number; + listed: boolean; +}[] = [ + // Tiradentes: Decreto nº 155-B/1890 up to 1930, dropped by Decreto nº 19.488/1930, restored by + // Decreto nº 22.647/1933, dropped by Lei nº 662/1949, restored by Lei nº 1.266/1950 from 1951. + { name: "Tiradentes", month: 3, day: 21, year: 1930, listed: true }, + { name: "Tiradentes", month: 3, day: 21, year: 1931, listed: false }, + { name: "Tiradentes", month: 3, day: 21, year: 1932, listed: false }, + { name: "Tiradentes", month: 3, day: 21, year: 1933, listed: true }, + { name: "Tiradentes", month: 3, day: 21, year: 1948, listed: true }, + { name: "Tiradentes", month: 3, day: 21, year: 1949, listed: false }, + { name: "Tiradentes", month: 3, day: 21, year: 1950, listed: false }, + { name: "Tiradentes", month: 3, day: 21, year: 1951, listed: true }, + // Dia do trabalhador: Decreto nº 4.859, de 26/09/1924. + { name: "Dia do trabalhador", month: 4, day: 1, year: 1924, listed: false }, + { name: "Dia do trabalhador", month: 4, day: 1, year: 1925, listed: true }, + // Nossa Senhora Aparecida: Lei nº 6.802, de 30/06/1980. + { name: "Nossa Senhora Aparecida", month: 9, day: 12, year: 1979, listed: false }, + { name: "Nossa Senhora Aparecida", month: 9, day: 12, year: 1980, listed: true }, + // Finados: Decreto nº 155-B/1890, Decreto nº 19.488/1930 and Decreto-lei nº 486/1938 up to + // Lei nº 662/1949, which left it out, then Lei nº 10.607/2002. + { name: "Finados", month: 10, day: 2, year: 1900, listed: true }, + { name: "Finados", month: 10, day: 2, year: 1948, listed: true }, + { name: "Finados", month: 10, day: 2, year: 1949, listed: false }, + { name: "Finados", month: 10, day: 2, year: 2002, listed: false }, + { name: "Finados", month: 10, day: 2, year: 2003, listed: true }, + // Natal: Decreto nº 4.497, de 19/01/1922. + { name: "Natal", month: 11, day: 25, year: 1921, listed: false }, + { name: "Natal", month: 11, day: 25, year: 1922, listed: true }, + // In force in every supported year. + { name: "Ano novo", month: 0, day: 1, year: 1900, listed: true }, + { name: "Independência do Brasil", month: 8, day: 7, year: 1900, listed: true }, + { name: "Proclamação da República", month: 10, day: 15, year: 1900, listed: true }, +]; + +const STATE_HOLIDAY_FIRST_YEARS: { + stateCode: StateCode; + name: string; + type: HolidayType; + year: number; + listed: boolean; + source: string; +}[] = [ + { + stateCode: "AC", + name: "Dia do Evangélico", + type: "state", + year: 2004, + listed: false, + source: "Lei AC nº 1.538, de 29/01/2004", + }, + { + stateCode: "AC", + name: "Dia do Evangélico", + type: "state", + year: 2005, + listed: true, + source: "Lei AC nº 1.538, de 29/01/2004", + }, + { + stateCode: "AC", + name: "Dia Internacional da Mulher", + type: "state", + year: 2001, + listed: false, + source: "Lei AC nº 1.411, de 14/09/2001", + }, + { + stateCode: "AC", + name: "Dia Internacional da Mulher", + type: "state", + year: 2002, + listed: true, + source: "Lei AC nº 1.411, de 14/09/2001", + }, + { + stateCode: "AC", + name: "Aniversário do Acre", + type: "state", + year: 1964, + listed: false, + source: "Lei AC nº 14, de 02/09/1964", + }, + { + stateCode: "AC", + name: "Aniversário do Acre", + type: "state", + year: 1965, + listed: true, + source: "Lei AC nº 14, de 02/09/1964", + }, + { + stateCode: "AC", + name: "Dia da Amazônia", + type: "state", + year: 1968, + listed: false, + source: "Lei AC nº 243, de 04/12/1968", + }, + { + stateCode: "AC", + name: "Dia da Amazônia", + type: "state", + year: 1969, + listed: true, + source: "Lei AC nº 243, de 04/12/1968", + }, + { + stateCode: "AC", + name: "Assinatura do Tratado de Petrópolis", + type: "state", + year: 1965, + listed: false, + source: "Lei AC nº 57, de 14/12/1965", + }, + { + stateCode: "AC", + name: "Assinatura do Tratado de Petrópolis", + type: "state", + year: 1966, + listed: true, + source: "Lei AC nº 57, de 14/12/1965", + }, + { + stateCode: "AL", + name: "São João", + type: "state", + year: 1993, + listed: false, + source: "Lei AL nº 5.508, de 07/07/1993", + }, + { + stateCode: "AL", + name: "São João", + type: "state", + year: 1994, + listed: true, + source: "Lei AL nº 5.508, de 07/07/1993", + }, + { + stateCode: "AL", + name: "São Pedro", + type: "state", + year: 1993, + listed: false, + source: "Lei AL nº 5.509, de 07/07/1993", + }, + { + stateCode: "AL", + name: "São Pedro", + type: "state", + year: 1994, + listed: true, + source: "Lei AL nº 5.509, de 07/07/1993", + }, + { + stateCode: "AC", + name: "Dia do Católico", + type: "state", + year: 2016, + listed: false, + source: "Lei AC nº 3.137, de 27/06/2016", + }, + { + stateCode: "AC", + name: "Dia do Católico", + type: "state", + year: 2017, + listed: true, + source: "Lei AC nº 3.137, de 27/06/2016", + }, + { + stateCode: "AL", + name: "Dia da Consciência Negra", + type: "state", + year: 1994, + listed: false, + source: "Lei AL nº 5.724, de 01/08/1995", + }, + { + stateCode: "AL", + name: "Dia da Consciência Negra", + type: "state", + year: 1995, + listed: true, + source: "Lei AL nº 5.724, de 01/08/1995", + }, + { + stateCode: "AL", + name: "Dia da Consciência Negra", + type: "state", + year: 2023, + listed: true, + source: "Lei AL nº 5.724, de 01/08/1995", + }, + { + stateCode: "AL", + name: "Dia da Consciência Negra", + type: "state", + year: 2024, + listed: false, + source: "Lei AL nº 5.724/1995, superseded by the national holiday of Lei nº 14.759/2023", + }, + { + stateCode: "AL", + name: "Dia da Consciência Negra", + type: "national", + year: 2024, + listed: true, + source: "Lei nº 14.759/2023", + }, + { + stateCode: "AL", + name: "Emancipação Política de Alagoas", + type: "optional", + year: 2019, + listed: false, + source: "Decreto AL nº 68.782, de 30/12/2019, the 2020 calendar", + }, + { + stateCode: "AL", + name: "Emancipação Política de Alagoas", + type: "optional", + year: 2020, + listed: true, + source: "Decreto AL nº 68.782, de 30/12/2019, the 2020 calendar", + }, + { + stateCode: "AL", + name: "Dia Estadual do Evangélico", + type: "state", + year: 2013, + listed: false, + source: "Lei AL nº 7.530/2013, whose art. 2º was vetoed until its promulgation on 08/04/2014", + }, + { + stateCode: "AL", + name: "Dia Estadual do Evangélico", + type: "state", + year: 2014, + listed: true, + source: "Lei AL nº 7.530/2013, art. 2º, promulgated on 08/04/2014", + }, + { + stateCode: "MA", + name: "Dia Internacional da Mulher", + type: "state", + year: 2026, + listed: false, + source: "Lei MA nº 12.800, de 19/03/2026, published after that year's 8 March", + }, + { + stateCode: "MA", + name: "Dia Internacional da Mulher", + type: "state", + year: 2027, + listed: true, + source: "Lei MA nº 12.800, de 19/03/2026", + }, + { + stateCode: "PB", + name: "Fundação da Paraíba", + type: "state", + year: 1967, + listed: false, + source: "Lei PB nº 3.489, de 30/08/1967, published after that year's 5 August", + }, + { + stateCode: "PB", + name: "Fundação da Paraíba", + type: "state", + year: 1968, + listed: true, + source: "Lei PB nº 3.489, de 30/08/1967, art. 2º", + }, + { + stateCode: "PB", + name: "Fundação da Paraíba", + type: "state", + year: 2015, + listed: true, + source: "Lei PB nº 3.489, de 30/08/1967, art. 2º", + }, + { + stateCode: "PB", + name: "Fundação da Paraíba", + type: "state", + year: 2016, + listed: false, + source: "Lei PB nº 10.601/2015, art. 2º, revoked art. 2º of Lei PB nº 3.489/1967", + }, + { + stateCode: "AP", + name: "Dia de São José", + type: "state", + year: 2002, + listed: false, + source: "Lei AP nº 667, de 16/04/2002", + }, + { + stateCode: "AP", + name: "Dia de São José", + type: "state", + year: 2003, + listed: true, + source: "Lei AP nº 667, de 16/04/2002", + }, + { + stateCode: "AP", + name: "Criação do Território Federal do Amapá", + type: "state", + year: 1991, + listed: false, + source: "Constituição Estadual do AP, de 20/12/1991", + }, + { + stateCode: "AP", + name: "Criação do Território Federal do Amapá", + type: "state", + year: 1992, + listed: true, + source: "Constituição Estadual do AP, de 20/12/1991", + }, + { + stateCode: "AP", + name: "Dia de Cabralzinho", + type: "state", + year: 2017, + listed: false, + source: "Lei AP nº 2.213, de 11/07/2017, published after that year's 15 May", + }, + { + stateCode: "AP", + name: "Dia de Cabralzinho", + type: "state", + year: 2018, + listed: true, + source: "Lei AP nº 2.213, de 11/07/2017", + }, + { + stateCode: "AP", + name: "Dia de São Tiago", + type: "state", + year: 2011, + listed: false, + source: "Lei AP nº 1.696, de 09/07/2012", + }, + { + stateCode: "AP", + name: "Dia de São Tiago", + type: "state", + year: 2012, + listed: true, + source: "Lei AP nº 1.696, de 09/07/2012, in force from 10/07/2012", + }, + { + stateCode: "AP", + name: "Dia de São Tiago", + type: "state", + year: 2018, + listed: true, + source: "Lei AP nº 1.696, de 09/07/2012", + }, + { + stateCode: "AP", + name: "Dia de São Tiago", + type: "state", + year: 2019, + listed: false, + source: + "Lei AP nº 2.430, de 23/07/2019, revoked Lei AP nº 1.696/2012 before that year's 25 July", + }, + { + stateCode: "AP", + name: "Dia Estadual da Consciência Negra", + type: "state", + year: 2007, + listed: false, + source: "Lei AP nº 1.169, de 27/12/2007, published after that year's 20 November", + }, + { + stateCode: "AP", + name: "Dia Estadual da Consciência Negra", + type: "state", + year: 2008, + listed: true, + source: "Lei AP nº 1.169, de 27/12/2007", + }, + { + stateCode: "AM", + name: "Elevação do Amazonas à categoria de Província", + type: "state", + year: 1977, + listed: false, + source: "Lei AM nº 25, de 21/12/1977", + }, + { + stateCode: "AM", + name: "Elevação do Amazonas à categoria de Província", + type: "state", + year: 1978, + listed: true, + source: "Lei AM nº 25, de 21/12/1977", + }, + { + stateCode: "BA", + name: "Independência da Bahia", + type: "state", + year: 1989, + listed: false, + source: "Constituição Estadual da BA, de 05/10/1989", + }, + { + stateCode: "BA", + name: "Independência da Bahia", + type: "state", + year: 1990, + listed: true, + source: "Constituição Estadual da BA, de 05/10/1989", + }, + { + stateCode: "CE", + name: "Abolição da Escravidão no Ceará", + type: "state", + year: 2011, + listed: false, + source: "EC CE nº 73, de 01/12/2011", + }, + { + stateCode: "CE", + name: "Abolição da Escravidão no Ceará", + type: "state", + year: 2012, + listed: true, + source: "EC CE nº 73, de 01/12/2011", + }, + { + stateCode: "DF", + name: "Fundação de Brasília", + type: "state", + year: 1989, + listed: false, + source: "Lei distrital nº 72, de 27/12/1989", + }, + { + stateCode: "DF", + name: "Fundação de Brasília", + type: "state", + year: 1990, + listed: true, + source: "Lei distrital nº 72, de 27/12/1989", + }, + { + stateCode: "DF", + name: "Corpus Christi", + type: "state", + year: 1989, + listed: false, + source: "Lei distrital nº 72, de 27/12/1989", + }, + { + stateCode: "DF", + name: "Corpus Christi", + type: "optional", + year: 1989, + listed: true, + source: "Lei distrital nº 72, de 27/12/1989 (the national ponto facultativo)", + }, + { + stateCode: "DF", + name: "Corpus Christi", + type: "state", + year: 1990, + listed: true, + source: "Lei distrital nº 72, de 27/12/1989", + }, + { + stateCode: "DF", + name: "Dia do Evangélico", + type: "state", + year: 1995, + listed: false, + source: "Lei distrital nº 963, de 04/12/1995", + }, + { + stateCode: "DF", + name: "Dia do Evangélico", + type: "state", + year: 1996, + listed: true, + source: "Lei distrital nº 963, de 04/12/1995", + }, + { + stateCode: "ES", + name: "Nossa Senhora da Penha", + type: "state", + year: 2019, + listed: false, + source: "Lei ES nº 11.010, de 03/07/2019", + }, + { + stateCode: "ES", + name: "Nossa Senhora da Penha", + type: "state", + year: 2020, + listed: true, + source: "Lei ES nº 11.010, de 03/07/2019", + }, + { + stateCode: "GO", + name: "Fundação da Cidade de Goiás", + type: "state", + year: 2020, + listed: false, + source: "Lei GO nº 20.756/2020, in force from 28/07/2020", + }, + { + stateCode: "GO", + name: "Fundação da Cidade de Goiás", + type: "state", + year: 2021, + listed: true, + source: "Lei GO nº 20.756/2020, in force from 28/07/2020", + }, + { + stateCode: "GO", + name: "Lançamento da Pedra Fundamental de Goiânia", + type: "state", + year: 1987, + listed: false, + source: "Lei GO nº 10.460, de 22/02/1988", + }, + { + stateCode: "GO", + name: "Lançamento da Pedra Fundamental de Goiânia", + type: "state", + year: 1988, + listed: true, + source: "Lei GO nº 10.460, de 22/02/1988", + }, + { + stateCode: "GO", + name: "Dia do Servidor Público", + type: "state", + year: 1987, + listed: false, + source: "Lei GO nº 10.460, de 22/02/1988", + }, + { + stateCode: "GO", + name: "Dia do Servidor Público", + type: "state", + year: 1988, + listed: true, + source: "Lei GO nº 10.460, de 22/02/1988", + }, + { + stateCode: "MA", + name: "Adesão do Maranhão à Independência", + type: "state", + year: 1964, + listed: false, + source: "Lei MA nº 2.457, de 02/10/1964", + }, + { + stateCode: "MA", + name: "Adesão do Maranhão à Independência", + type: "state", + year: 1965, + listed: true, + source: "Lei MA nº 2.457, de 02/10/1964", + }, + { + stateCode: "MT", + name: "Dia da Consciência Negra", + type: "state", + year: 2002, + listed: false, + source: "Lei MT nº 7.879, de 27/12/2002", + }, + { + stateCode: "MT", + name: "Dia da Consciência Negra", + type: "state", + year: 2003, + listed: true, + source: "Lei MT nº 7.879, de 27/12/2002", + }, + { + stateCode: "MS", + name: "Criação do Estado de Mato Grosso do Sul", + type: "state", + year: 1979, + listed: false, + source: "Lei MS nº 10, de 29/10/1979", + }, + { + stateCode: "MS", + name: "Criação do Estado de Mato Grosso do Sul", + type: "state", + year: 1980, + listed: true, + source: "Lei MS nº 10, de 29/10/1979", + }, + { + stateCode: "PA", + name: "Adesão do Pará à Independência", + type: "state", + year: 1996, + listed: false, + source: "Lei PA nº 5.999, de 10/09/1996", + }, + { + stateCode: "PA", + name: "Adesão do Pará à Independência", + type: "state", + year: 1997, + listed: true, + source: "Lei PA nº 5.999, de 10/09/1996", + }, + { + stateCode: "PB", + name: "Data Magna do Estado da Paraíba", + type: "state", + year: 2015, + listed: false, + source: "Lei PB nº 10.601, de 16/12/2015", + }, + { + stateCode: "PB", + name: "Data Magna do Estado da Paraíba", + type: "state", + year: 2016, + listed: true, + source: "Lei PB nº 10.601, de 16/12/2015", + }, + { + stateCode: "PB", + name: "Morte de João Pessoa", + type: "state", + year: 1967, + listed: false, + source: "Lei PB nº 3.489, de 30/08/1967", + }, + { + stateCode: "PB", + name: "Morte de João Pessoa", + type: "state", + year: 1968, + listed: true, + source: "Lei PB nº 3.489, de 30/08/1967", + }, + { + stateCode: "PE", + name: "Revolução Pernambucana", + type: "state", + year: 2017, + listed: false, + source: "Lei PE nº 16.059, de 08/06/2017", + }, + { + stateCode: "PE", + name: "Revolução Pernambucana", + type: "state", + year: 2018, + listed: true, + source: "Lei PE nº 16.059, de 08/06/2017", + }, + { + stateCode: "PI", + name: "Dia do Piauí", + type: "state", + year: 1936, + listed: false, + source: "Lei PI nº 176, de 30/08/1937", + }, + { + stateCode: "PI", + name: "Dia do Piauí", + type: "state", + year: 1937, + listed: true, + source: "Lei PI nº 176, de 30/08/1937", + }, + { + stateCode: "RJ", + name: "São Jorge", + type: "state", + year: 2007, + listed: false, + source: "Lei RJ nº 5.198, de 05/03/2008", + }, + { + stateCode: "RJ", + name: "São Jorge", + type: "state", + year: 2008, + listed: true, + source: "Lei RJ nº 5.198, de 05/03/2008", + }, + { + stateCode: "RJ", + name: "Dia da Consciência Negra", + type: "state", + year: 2001, + listed: false, + source: "Lei RJ nº 4.007, de 11/11/2002", + }, + { + stateCode: "RJ", + name: "Dia da Consciência Negra", + type: "state", + year: 2002, + listed: true, + source: "Lei RJ nº 4.007, de 11/11/2002", + }, + { + stateCode: "RN", + name: "Mártires de Cunhaú e Uruaçu", + type: "state", + year: 2006, + listed: false, + source: "Lei RN nº 8.913, de 06/12/2006", + }, + { + stateCode: "RN", + name: "Mártires de Cunhaú e Uruaçu", + type: "state", + year: 2007, + listed: true, + source: "Lei RN nº 8.913, de 06/12/2006", + }, + { + stateCode: "RS", + name: "Revolução Farroupilha", + type: "state", + year: 1994, + listed: false, + source: "Decreto RS nº 36.180, de 18/09/1995, and EC RS nº 11, de 03/10/1995", + }, + { + stateCode: "RS", + name: "Revolução Farroupilha", + type: "state", + year: 1995, + listed: true, + source: "Decreto RS nº 36.180, de 18/09/1995, and EC RS nº 11, de 03/10/1995", + }, + { + stateCode: "RO", + name: "Criação do Estado de Rondônia", + type: "state", + year: 2010, + listed: false, + source: "Lei RO nº 2.291, de 22/04/2010", + }, + { + stateCode: "RO", + name: "Criação do Estado de Rondônia", + type: "state", + year: 2011, + listed: true, + source: "Lei RO nº 2.291, de 22/04/2010", + }, + { + stateCode: "RR", + name: "Criação do Estado de Roraima", + type: "state", + year: 1991, + listed: false, + source: "Constituição Estadual de RR, de 31/12/1991", + }, + { + stateCode: "RR", + name: "Criação do Estado de Roraima", + type: "state", + year: 1992, + listed: true, + source: "Constituição Estadual de RR, de 31/12/1991", + }, + { + stateCode: "SC", + name: "Dia do Estado de Santa Catarina", + type: "state", + year: 2003, + listed: false, + source: "Lei SC nº 12.906, de 22/01/2004", + }, + { + stateCode: "SC", + name: "Dia do Estado de Santa Catarina", + type: "state", + year: 2004, + listed: true, + source: "Lei SC nº 12.906, de 22/01/2004", + }, + { + stateCode: "SC", + name: "Dia de Santa Catarina de Alexandria", + type: "state", + year: 1996, + listed: false, + source: "Lei SC nº 10.306, of December 1996", + }, + { + stateCode: "SC", + name: "Dia de Santa Catarina de Alexandria", + type: "state", + year: 1997, + listed: true, + source: "Lei SC nº 10.306, of December 1996", + }, + { + stateCode: "SP", + name: "Revolução Constitucionalista", + type: "state", + year: 1996, + listed: false, + source: "Lei SP nº 9.497, de 05/03/1997", + }, + { + stateCode: "SP", + name: "Revolução Constitucionalista", + type: "state", + year: 1997, + listed: true, + source: "Lei SP nº 9.497, de 05/03/1997", + }, + { + stateCode: "SE", + name: "Independência de Sergipe", + type: "state", + year: 1989, + listed: false, + source: "Constituição Estadual de SE, de 05/10/1989, art. 269", + }, + { + stateCode: "SE", + name: "Independência de Sergipe", + type: "state", + year: 1990, + listed: true, + source: "Constituição Estadual de SE, de 05/10/1989, art. 269", + }, + { + stateCode: "SE", + name: "Independência de Sergipe", + type: "state", + year: 2000, + listed: true, + source: "Constituição Estadual de SE, art. 269, in the wording of EC SE nº 20, de 31/05/2000", + }, + { + stateCode: "SE", + name: "Comemoração Popular da Independência de Sergipe", + type: "state", + year: 1988, + listed: false, + source: "Constituição Estadual de SE, de 05/10/1989, art. 269, original wording", + }, + { + stateCode: "SE", + name: "Comemoração Popular da Independência de Sergipe", + type: "state", + year: 1989, + listed: true, + source: "Constituição Estadual de SE, de 05/10/1989, art. 269, original wording", + }, + { + stateCode: "SE", + name: "Comemoração Popular da Independência de Sergipe", + type: "state", + year: 1999, + listed: true, + source: "Constituição Estadual de SE, de 05/10/1989, art. 269, original wording", + }, + { + stateCode: "SE", + name: "Comemoração Popular da Independência de Sergipe", + type: "state", + year: 2000, + listed: false, + source: "EC SE nº 20, de 31/05/2000, dropped 24 October from art. 269", + }, + { + stateCode: "TO", + name: "Autonomia do Estado do Tocantins", + type: "state", + year: 1997, + listed: false, + source: "Lei TO nº 960, de 17/03/1998", + }, + { + stateCode: "TO", + name: "Autonomia do Estado do Tocantins", + type: "state", + year: 1998, + listed: true, + source: "Lei TO nº 960, de 17/03/1998", + }, + { + stateCode: "TO", + name: "Padroeira do Estado (Nossa Senhora da Natividade)", + type: "state", + year: 1993, + listed: false, + source: "Lei TO nº 627, de 28/12/1993", + }, + { + stateCode: "TO", + name: "Padroeira do Estado (Nossa Senhora da Natividade)", + type: "state", + year: 1994, + listed: true, + source: "Lei TO nº 627, de 28/12/1993", + }, + { + stateCode: "TO", + name: "Criação do Estado do Tocantins", + type: "state", + year: 1989, + listed: false, + source: "Lei TO nº 98, de 17/11/1989", + }, + { + stateCode: "TO", + name: "Criação do Estado do Tocantins", + type: "state", + year: 1990, + listed: true, + source: "Lei TO nº 98, de 17/11/1989", + }, +]; + +/** + * The entries `getHolidays` lists for a state on one day of a year. + * @param {StateCode} stateCode - The state. + * @param {number} month - The month, 0 based. + * @param {number} day - The day of the month. + * @param {number} year - The year. + * @returns {Holiday[]} The entries on that day. + */ +const statesHolidaysOn = (stateCode: StateCode, month: number, day: number, year: number) => + getHolidays({ year, stateCode }).filter( + (holiday) => holiday.date.getMonth() === month && holiday.date.getDate() === day, + ); + describe("getHolidays", () => { test("should return fixed holidays for the given year", () => { const year = 2024; @@ -54,6 +1029,23 @@ describe("getHolidays", () => { ).toBeUndefined(); }); + for (const { name, month, day, year, listed } of NATIONAL_HOLIDAY_PERIODS) { + test(`should ${listed ? "list" : "not list"} ${name} in ${year}, following the federal norm in force that year`, () => { + const holiday = { name, date: new Date(year, month, day), type: "national" }; + + if (listed) { + expect(getHolidays(year)).toContainEqual(holiday); + } else { + expect(getHolidays(year).filter((entry) => entry.name === name)).toEqual([]); + } + }); + } + + test("should count 2 November 1990, a Friday between Lei nº 662/1949 and Lei nº 10.607/2002, as a business day, since no federal norm declared Finados then, and keep 2 November 2004, a Tuesday, a holiday", () => { + expect(isBusinessDay(new Date(1990, 10, 2, 12))).toBe(true); + expect(isBusinessDay(new Date(2004, 10, 2, 12))).toBe(false); + }); + test("should calculate Easter-related holidays correctly, including Corpus Christi 60 days after Easter Sunday (independently verified: Easter 2031 is Sun 2031-04-13)", () => { const year = 2031; const holidays = getHolidays(year); @@ -61,6 +1053,7 @@ describe("getHolidays", () => { const easterDate = new Date(2031, 3, 13); const expectedHolidays = [ { name: "Páscoa", date: easterDate, type: "religious" }, + { name: "Carnaval (segunda-feira)", date: new Date(2031, 1, 24), type: "optional" }, { name: "Carnaval (terça-feira)", date: new Date(2031, 1, 25), type: "optional" }, { name: "Sexta-feira Santa", date: new Date(2031, 3, 11), type: "national" }, { name: "Corpus Christi", date: new Date(2031, 5, 12), type: "optional" }, @@ -71,11 +1064,88 @@ describe("getHolidays", () => { } }); - test("should return 13 holidays for 2024: 9 fixed holidays (including Consciência Negra) plus 4 Easter-related holidays", () => { + test("should return 15 holidays for 2024: 9 fixed holidays (including Consciência Negra), the first round of the elections and 5 Easter-related holidays", () => { const year = 2024; const holidays = getHolidays(year); - expect(holidays.length).toBe(13); + expect(holidays.length).toBe(15); + }); + + test("should list the first round of the elections as a national holiday on the first Sunday of October of every even year from 1998 on (Código Eleitoral, art. 380; CF arts. 28, 29, II, and 77 in the wording of EC nº 16/1997), matching the dates the elections were held", () => { + const elections = [ + [1998, 9, 4], + [2000, 9, 1], + [2002, 9, 6], + [2018, 9, 7], + [2022, 9, 2], + [2024, 9, 6], + [2026, 9, 4], + ] as const; + + for (const [year, month, day] of elections) { + expect(getHolidays(year)).toContainEqual({ + name: "Eleições (primeiro turno)", + date: new Date(year, month, day), + type: "national", + }); + expect( + getHolidays(year).filter((holiday) => holiday.name === "Eleições (primeiro turno)"), + ).toHaveLength(1); + } + }); + + test("should list the 2020 first round on 15 November, the date EC nº 107/2020 moved it to, and not on 4 October", () => { + const holidays = getHolidays(2020); + + expect(holidays).toContainEqual({ + name: "Eleições (primeiro turno)", + date: new Date(2020, 10, 15), + type: "national", + }); + expect(holidays).toContainEqual({ + name: "Proclamação da República", + date: new Date(2020, 10, 15), + type: "national", + }); + expect(isHoliday({ targetDate: new Date(2020, 9, 4) })).toBe(false); + }); + + test("should not list an election day in odd years, before 1998, or for the second round (1996 and 1997 have none, 2024-10-27 is not a holiday)", () => { + for (const year of [1996, 1997, 1999, 2023, 2025]) { + expect( + getHolidays(year).filter((holiday) => holiday.name === "Eleições (primeiro turno)"), + ).toEqual([]); + } + + expect(isHoliday({ targetDate: new Date(2024, 9, 27) })).toBe(false); + expect(isHoliday({ targetDate: new Date(2024, 9, 6) })).toBe(true); + }); + + test("should list both Carnaval days as optional, the Monday at Easter minus 48 and the Tuesday at Easter minus 47, as every federal portaria does (Portaria MGI nº 8.617/2023: 12 and 13 February 2024; Portaria MGI nº 9.783/2024: 3 and 4 March 2025; Portaria MGI nº 11.460/2025: 16 and 17 February 2026)", () => { + expect(carnaval(2024)).toEqual([ + { name: "Carnaval (segunda-feira)", date: new Date(2024, 1, 12), type: "optional" }, + { name: "Carnaval (terça-feira)", date: new Date(2024, 1, 13), type: "optional" }, + ]); + expect(carnaval(2025)).toEqual([ + { name: "Carnaval (segunda-feira)", date: new Date(2025, 2, 3), type: "optional" }, + { name: "Carnaval (terça-feira)", date: new Date(2025, 2, 4), type: "optional" }, + ]); + expect(carnaval(2026)).toEqual([ + { name: "Carnaval (segunda-feira)", date: new Date(2026, 1, 16), type: "optional" }, + { name: "Carnaval (terça-feira)", date: new Date(2026, 1, 17), type: "optional" }, + ]); + }); + + test("should leave out the partial pontos facultativos of the federal calendar: Quarta-feira de Cinzas (until 14h), 28 October (Dia do Servidor Público) and the 24 and 31 December afternoons", () => { + const days = getHolidays(2024).map((holiday) => [ + holiday.date.getMonth(), + holiday.date.getDate(), + ]); + + expect(days).not.toContainEqual([1, 14]); + expect(days).not.toContainEqual([9, 28]); + expect(days).not.toContainEqual([11, 24]); + expect(days).not.toContainEqual([11, 31]); }); test("should calculate Easter Sunday correctly across widely spaced years (independently verified via the Anonymous Gregorian algorithm: 1900-04-15, 1954-04-18, 2075-04-07)", () => { @@ -340,13 +1410,55 @@ describe("getHolidays", () => { expect(secondHoliday.name).not.toBe("MUTATED"); expect(secondHoliday.date.getFullYear()).toBe(2024); }); - test("should keep Nossa Senhora da Conceição for AM as an optional day, as the state calendar decree does", () => { - const holiday = getHolidays({ year: 2024, stateCode: "AM" }).find( - (h) => h.name === "Nossa Senhora da Conceição", + test("should list AM's 8 December as a state ponto facultativo, which the state declares by decree (DOE-AM 02/12/2025) for its offices, as in 2.4.0", () => { + expect( + getHolidays({ year: 2025, stateCode: "AM" }).filter( + (holiday) => holiday.date.getMonth() === 11 && holiday.date.getDate() === 8, + ), + ).toEqual([ + { name: "Nossa Senhora da Conceição", date: new Date(2025, 11, 8), type: "optional" }, + ]); + expect(isBusinessDay(new Date(2025, 11, 8, 12), { stateCode: "AM" })).toBe(false); + expect( + isBusinessDay(new Date(2025, 11, 8, 12), { stateCode: "AM", includeOptional: false }), + ).toBe(true); + }); + + test("should list PR's 19 December from 1963 to 2013 under Lei PR nº 4.658/1962, and not after Lei PR nº 18.384/2014 revoked it ('não se constituindo em feriado civil')", () => { + expect(statesHolidaysOn("PR", 11, 19, 1962)).toEqual([]); + expect(statesHolidaysOn("PR", 11, 19, 1963)).toEqual([ + { name: "Emancipação Política do Paraná", date: new Date(1963, 11, 19), type: "state" }, + ]); + expect(statesHolidaysOn("PR", 11, 19, 2013)).toHaveLength(1); + expect(statesHolidaysOn("PR", 11, 19, 2014)).toEqual([]); + expect(isBusinessDay(new Date(2013, 11, 19, 12), { stateCode: "PR" })).toBe(false); + expect(isBusinessDay(new Date(2025, 11, 19, 12), { stateCode: "PR" })).toBe(true); + }); + + test("should list PE's 6 March as a ponto facultativo in 2008 and 2009 under Lei PE nº 13.386/2007, and as a state holiday from 2018", () => { + expect(statesHolidaysOn("PE", 2, 6, 2007)).toEqual([]); + expect(statesHolidaysOn("PE", 2, 6, 2008)).toEqual([ + { name: "Revolução Pernambucana", date: new Date(2008, 2, 6), type: "optional" }, + ]); + expect(statesHolidaysOn("PE", 2, 6, 2009)).toHaveLength(1); + expect(statesHolidaysOn("PE", 2, 6, 2010)).toEqual([]); + expect(statesHolidaysOn("PE", 2, 6, 2018)).toEqual([ + { name: "Revolução Pernambucana", date: new Date(2018, 2, 6), type: "state" }, + ]); + }); + + test("should keep the state optional entries to the ones a state norm declares: AL's 16 September of 2020 to 2023, AM's 8 December and PE's 6 March of 2008 and 2009", () => { + const optionalStateEntries = Object.entries(STATE_HOLIDAYS).flatMap(([stateCode, entries]) => + entries + .filter((entry) => entry.type === "optional") + .map((entry) => `${stateCode} ${entry.name}`), ); - expect(holiday?.type).toBe("optional"); - expect(holiday?.date).toEqual(new Date(2024, 11, 8)); + expect(optionalStateEntries).toEqual([ + "AL Emancipação Política de Alagoas", + "AM Nossa Senhora da Conceição", + "PE Revolução Pernambucana", + ]); }); test("should apply state laws for Consciência Negra before it became national", () => { @@ -434,6 +1546,20 @@ describe("getHolidays", () => { ).toHaveLength(1); }); + test("should move AL's Dia Estadual do Evangélico back to Monday from a Tuesday and on to Friday from a Thursday, as Lei AL nº 7.530/2013, art. 2º parágrafo único requires (30/11/2027 is a Tuesday, 30/11/2023 a Thursday, 30/11/2022 a Wednesday)", () => { + expect(alEvangelicoIn(2027)).toEqual([ + { name: "Dia Estadual do Evangélico", date: new Date(2027, 10, 29), type: "state" }, + ]); + expect(alEvangelicoIn(2023)).toEqual([ + { name: "Dia Estadual do Evangélico", date: new Date(2023, 11, 1), type: "state" }, + ]); + expect(alEvangelicoIn(2022)).toEqual([ + { name: "Dia Estadual do Evangélico", date: new Date(2022, 10, 30), type: "state" }, + ]); + expect(isBusinessDay(new Date(2027, 10, 30), { stateCode: "AL" })).toBe(true); + expect(isBusinessDay(new Date(2027, 10, 29), { stateCode: "AL" })).toBe(false); + }); + test("should list the three Goiás state holidays of Lei GO nº 20.756/2020, art. 269, II", () => { const holidays = getHolidays({ year: 2024, stateCode: "GO" }); @@ -454,6 +1580,23 @@ describe("getHolidays", () => { }); }); + test("should replace the national optional Corpus Christi with an MA state entry from 2024 on, Lei MA nº 11.539/2021 having been upheld by the TJMA on 06/03/2024, and keep it optional in 2023", () => { + expect( + getHolidays({ year: 2024, stateCode: "MA" }).filter((h) => h.name === "Corpus Christi"), + ).toEqual([{ name: "Corpus Christi", date: new Date(2024, 4, 30), type: "state" }]); + + expect( + getHolidays({ year: 2023, stateCode: "MA" }).filter((h) => h.name === "Corpus Christi"), + ).toEqual([{ name: "Corpus Christi", date: new Date(2023, 5, 8), type: "optional" }]); + + expect(isBusinessDay(new Date(2026, 5, 4), { stateCode: "MA", includeOptional: false })).toBe( + false, + ); + expect(isBusinessDay(new Date(2026, 5, 4), { stateCode: "PI", includeOptional: false })).toBe( + true, + ); + }); + test("should replace the national optional Corpus Christi with a DF state entry, which Lei distrital nº 72/1989 art. 1º parágrafo único declares a feriado, without listing the date twice", () => { const dfHolidays = getHolidays({ year: 2024, stateCode: "DF" }); const nationalHolidays = getHolidays(2024); @@ -475,6 +1618,32 @@ describe("getHolidays", () => { }); }); + test("should replace the national optional Carnaval Tuesday with an RJ state entry from 2009 on, Lei RJ nº 5.243, de 14/05/2008, coming after the 2008 Carnaval (Tue 2008-02-05, Tue 2009-02-24), without listing the date twice", () => { + expect(carnavalIn(2008, "RJ")).toEqual([ + { name: "Carnaval (segunda-feira)", date: new Date(2008, 1, 4), type: "optional" }, + { name: "Carnaval (terça-feira)", date: new Date(2008, 1, 5), type: "optional" }, + ]); + expect(carnavalIn(2009, "RJ")).toEqual([ + { name: "Carnaval (segunda-feira)", date: new Date(2009, 1, 23), type: "optional" }, + { name: "Carnaval (terça-feira)", date: new Date(2009, 1, 24), type: "state" }, + ]); + expect(carnavalIn(2009, "SP")).toContainEqual({ + name: "Carnaval (terça-feira)", + date: new Date(2009, 1, 24), + type: "optional", + }); + }); + + test("should list Acre's Dia do Católico on its statutory 20 January (Lei AC nº 3.137/2016; the Friday transfer of Lei AC nº 2.126/2009 is not modelled), so Monday 2025-01-20 is not a business day in AC", () => { + expect(getHolidays({ year: 2025, stateCode: "AC" })).toContainEqual({ + name: "Dia do Católico", + date: new Date(2025, 0, 20), + type: "state", + }); + expect(isBusinessDay(new Date(2025, 0, 20, 12), { stateCode: "AC" })).toBe(false); + expect(isBusinessDay(new Date(2025, 0, 20, 12), { stateCode: "AM" })).toBe(true); + }); + test("should list DF's Fundação de Brasília (Lei distrital nº 72/1989, art. 1º, I) next to the national Tiradentes, which falls on the same 21 April under a different name", () => { const dfHolidays = getHolidays({ year: 2024, stateCode: "DF" }); @@ -523,12 +1692,9 @@ describe("getHolidays", () => { }); }); - test("should keep the Santa Catarina 11 August holiday on its statutory weekday before 2005, the year Lei SC nº 13.408/2005 extended the transfer to it (11/08/2003 is a Monday)", () => { - expect(getHolidays({ year: 2003, stateCode: "SC" })).toContainEqual({ - name: "Dia do Estado de Santa Catarina", - date: new Date(2003, 7, 11), - type: "state", - }); + test("should list no Santa Catarina 11 August before 2004, the year Lei SC nº 12.906/2004 made it a holiday (11/08/2003, a Monday, is an ordinary day)", () => { + expect(scNamesIn(2003)).not.toContain("Dia do Estado de Santa Catarina"); + expect(isBusinessDay(new Date(2003, 7, 11, 12), { stateCode: "SC" })).toBe(true); }); test("should keep the Santa Catarina 25 November holiday on its statutory weekday before 1999, the year Lei SC nº 11.213/1999 introduced its transfer (25/11/1998 is a Wednesday)", () => { @@ -585,18 +1751,29 @@ describe("getHolidays", () => { }); test("should list each Santa Catarina holiday exactly once in every year the four 25 November ranges and the two 11 August ranges border on", () => { - for (const year of [1998, 1999, 2003, 2004, 2005, 2025]) { - const names = getHolidays({ year, stateCode: "SC" }).map((holiday) => holiday.name); + for (const year of [1997, 1998, 1999, 2003, 2004, 2005, 2025]) { + expect( + scNamesIn(year).filter((name) => name === "Dia de Santa Catarina de Alexandria"), + ).toEqual(["Dia de Santa Catarina de Alexandria"]); + } - expect(names.filter((name) => name === "Dia do Estado de Santa Catarina")).toEqual([ + for (const year of [2004, 2005, 2025]) { + expect(scNamesIn(year).filter((name) => name === "Dia do Estado de Santa Catarina")).toEqual([ "Dia do Estado de Santa Catarina", ]); - expect(names.filter((name) => name === "Dia de Santa Catarina de Alexandria")).toEqual([ - "Dia de Santa Catarina de Alexandria", - ]); } }); + for (const { stateCode, name, type, year, listed, source } of STATE_HOLIDAY_FIRST_YEARS) { + test(`should ${listed ? "list" : "not list"} ${stateCode}'s ${name} typed ${type} in ${year} (${source})`, () => { + const matching = getHolidays({ year, stateCode }).filter( + (holiday) => holiday.name === name && holiday.type === type, + ); + + expect(matching).toHaveLength(listed ? 1 : 0); + }); + } + test("should treat a prototype chain key as an unknown stateCode instead of throwing", () => { const nationalHolidays = getHolidays(2024); @@ -649,7 +1826,10 @@ describe("getHolidays", () => { expect(roHolidays.filter((h) => h.date.getMonth() === 5 && h.date.getDate() === 18)).toEqual( [], ); - expect(roHolidays).toContainEqual({ + } + + for (const year of [2019, 2024]) { + expect(getHolidays({ year, stateCode: "RO" })).toContainEqual({ name: "Criação do Estado de Rondônia", date: new Date(year, 0, 4), type: "state", diff --git a/src/get-holidays/get-holidays.ts b/src/get-holidays/get-holidays.ts index 60a2ae5c1..ffcf5febe 100644 --- a/src/get-holidays/get-holidays.ts +++ b/src/get-holidays/get-holidays.ts @@ -3,9 +3,11 @@ import { type StateCode } from "../_internals/constants/states"; import { isNullish } from "../_internals/is-nullish/is-nullish"; import { resolveStateHolidayDate } from "../_internals/resolve-state-holiday-date/resolve-state-holiday-date"; import { - CONSCIENCIA_NEGRA_HOLIDAY_NAME, - CONSCIENCIA_NEGRA_NATIONAL_SINCE_YEAR, + ELECTION_DATE_OVERRIDES, + ELECTION_HOLIDAY_NAME, + ELECTION_SINCE_YEAR, FIXED_HOLIDAYS, + type HolidayPeriod, STATE_HOLIDAYS, } from "./constants"; @@ -68,21 +70,62 @@ const buildHolidays = (holidays: MemoizedHoliday[]): Holiday[] => type, })); +/** + * Whether a holiday entry is in force in `year`: from its `since` (inclusive) up to its `until` + * (exclusive), an absent bound leaving that side open. + * + * @param {number} year - The year being computed. + * @param {HolidayPeriod} period - The entry's bounds. + * @returns {boolean} `true` when the entry applies to `year`. + */ +const isInForce = (year: number, { since, until }: HolidayPeriod): boolean => { + // Stryker disable next-line ConditionalExpression: `since` is undefined for most entries, and `year < undefined` is already always false, so the explicit `since !== undefined` guard never changes the outcome + if (since !== undefined && year < since) return false; + // Stryker disable next-line ConditionalExpression: `until` is undefined for most entries, and `year >= undefined` is already always false, so the explicit `until !== undefined` guard never changes the outcome + if (until !== undefined && year >= until) return false; + + return true; +}; + +const DAYS_IN_WEEK = 7; +const OCTOBER = 9; + +/** + * The date of the first round of the elections of `year`: the first Sunday of October, unless + * `ELECTION_DATE_OVERRIDES` carries another date for that year. + * + * @param {number} year - An even year from `ELECTION_SINCE_YEAR` on. + * @returns {Date} The election day, at local midnight. + */ +const resolveElectionDate = (year: number): Date => { + const override = ELECTION_DATE_OVERRIDES.get(year); + + if (override !== undefined) return new Date(year, override[0] - 1, override[1]); + + // Sunday is weekday 0, so the days from 1 October to the first Sunday are 7 minus its weekday, + // or none when 1 October is already a Sunday. + const firstOfOctober = new Date(year, OCTOBER, 1).getDay(); + + return new Date(year, OCTOBER, 1 + ((DAYS_IN_WEEK - firstOfOctober) % DAYS_IN_WEEK)); +}; + const computeHolidays = (year: number, stateCode: StateCode | undefined): Holiday[] => { const holidays: Holiday[] = []; - for (const [name, { day, month }] of Object.entries(FIXED_HOLIDAYS)) { + for (const entry of FIXED_HOLIDAYS) { + if (!isInForce(year, entry)) continue; + holidays.push({ - name, - date: new Date(year, month - 1, day), + name: entry.name, + date: new Date(year, entry.month - 1, entry.day), type: "national", }); } - if (year >= CONSCIENCIA_NEGRA_NATIONAL_SINCE_YEAR) { + if (year >= ELECTION_SINCE_YEAR && year % 2 === 0) { holidays.push({ - name: CONSCIENCIA_NEGRA_HOLIDAY_NAME, - date: new Date(year, 10, 20), + name: ELECTION_HOLIDAY_NAME, + date: resolveElectionDate(year), type: "national", }); } @@ -90,6 +133,11 @@ const computeHolidays = (year: number, stateCode: StateCode | undefined): Holida const easterDate = resolveStateHolidayDate(year, { easterOffset: 0 }); holidays.push( + { + name: "Carnaval (segunda-feira)", + date: resolveStateHolidayDate(year, { easterOffset: -48 }), + type: "optional", + }, { name: "Carnaval (terça-feira)", date: resolveStateHolidayDate(year, { easterOffset: -47 }), @@ -121,12 +169,9 @@ const computeHolidays = (year: number, stateCode: StateCode | undefined): Holida if (stateHolidays) { for (const entry of stateHolidays) { - const { name, type, since, until } = entry; - // Stryker disable next-line ConditionalExpression: `since` is undefined for most entries, and `year < undefined` is already always false, so the explicit `since !== undefined` guard never changes the outcome - if (since !== undefined && year < since) continue; - // Stryker disable next-line ConditionalExpression: `until` is undefined for most entries, and `year >= undefined` is already always false, so the explicit `until !== undefined` guard never changes the outcome - if (until !== undefined && year >= until) continue; + if (!isInForce(year, entry)) continue; + const { name, type } = entry; const date = resolveStateHolidayDate(year, entry); const stateHoliday: Holiday = { name, date, type: type ?? "state" }; // Name and date together are the identity of a holiday here: a state entry only replaces @@ -162,6 +207,28 @@ const computeHolidays = (year: number, stateCode: StateCode | undefined): Holida * per `year`/`stateCode` combination; the returned array (and each `Holiday.date`) is * always a fresh copy, so mutating it never affects subsequent calls. * + * Each fixed-date national holiday is listed only for the years a federal norm declared it + * (Sexta-feira Santa, which the federal calendar portarias list as a feriado nacional, is listed + * every year), so the older years of the supported range differ from today's list: Nossa Senhora Aparecida is listed from + * 1980 (Lei 6.802/1980), Natal from 1922 and Dia do trabalhador from 1925, Tiradentes up to 1930, + * from 1933 to 1948 and from 1951, since Lei 662/1949 left it out of its list until Lei + * 1.266/1950 restored it, and Finados up to 1948 and from 2003, since Lei 662/1949 left it out as + * well and only Lei 10.607/2002 put it back (up to 2.4.0 Finados was listed every year). + * `FIXED_HOLIDAYS` in `src/get-holidays/constants.ts` cites the decree or law behind each period. + * + * The first round of the elections is a national holiday in the even years from 1998 on, typed + * `"national"` and named `"Eleições (primeiro turno)"`: art. 380 of the Código Eleitoral makes a + * feriado nacional of "o dia em que se realizarem eleições de data fixada pela Constituição + * Federal", and since EC nº 16/1997 the Constitution fixes the first round on the first Sunday of + * October (arts. 28, 29, II, and 77), which EC nº 107/2020 moved to 15 November for 2020 alone. It + * always falls on a Sunday, so it changes what `getHolidays` and `isHoliday` answer and never what + * the business day utils count. The second round, "no último domingo de outubro ... se houver", is + * left out: whether it is held depends on the first round's results, and only in the states and + * municipalities where one is needed, so its date is not a holiday a year alone can establish. + * Earlier elections are not listed either: before 1998 their dates were set by ordinary law or + * counted back from the end of the term, and Lei 1.266/1950, art. 1º, which made the day of the + * general elections a feriado nacional, was revoked by Lei 10.607/2002. + * * If `stateCode` is provided but is not a valid/known state code, it is ignored and * only national holidays are returned (this mirrors passing no `stateCode` at all, * and is kept for backwards compatibility). The lookup is an own-property one, so a @@ -171,19 +238,33 @@ const computeHolidays = (year: number, stateCode: StateCode | undefined): Holida * When a state entry falls on the same date as a national one and carries the same name, the * state entry replaces it instead of being listed twice: this is how the Distrito Federal's * Corpus Christi, a feriado under Lei distrital nº 72/1989 art. 1º parágrafo único, comes back - * typed `"state"` for `stateCode: "DF"` while staying `"optional"` everywhere else. + * typed `"state"` for `stateCode: "DF"` (and, from 2024 on, for `stateCode: "MA"`, under Lei MA nº + * 11.539/2021) while staying `"optional"` everywhere else, and how Rio de + * Janeiro's Carnaval Tuesday, a feriado estadual under Lei RJ nº 5.243/2008 from 2009 on, comes + * back typed `"state"` for `stateCode: "RJ"`. * * Only one state holiday per UF is a feriado civil under Lei 9.093/1995 art. 1º, II, which * authorizes "a data magna do Estado fixada em lei estadual" in the singular; the other entries * of `STATE_HOLIDAYS` rest on ordinary state laws and are reported because they are observed in - * practice. The date returned is the statutory one. Santa Catarina's two holidays are the only - * observance shift the table models: each moves to the following Sunday when it falls Monday to + * practice. The date returned is the statutory one, save for two observance shifts the state laws + * spell out. Alagoas' 30 November moves back to the Monday when it falls on a Tuesday and on to + * the Friday when it falls on a Thursday (Lei AL nº 7.530/2013, art. 2º parágrafo único, from + * 2014 on). Santa Catarina's two holidays each move to the following Sunday when they fall Monday to * Friday, 11 August from 2005 on, when Lei SC nº 13.408/2005 extended the transfer to it, and * 25 November from 1999 on, when Lei SC nº 11.213/1999 first introduced it, except in 2004, the * year art. 3º of Lei SC nº 12.906/2004 left it without a transfer clause. Outside those ranges - * each holiday stays on 11 August or 25 November. Acre's Tuesday-to-Thursday shift and the Goiás decrees - * that may move 26/07 and 28/10 are not modelled, because neither can be resolved from a year - * alone. + * each holiday stays on 11 August or 25 November. Acre's shift of the feriados falling Tuesday to + * Thursday to the Friday (Lei AC nº 2.126/2009) is not modelled, because the state's own annual + * decrees apply it unevenly (in 2026 they move 20 January and leave 17 November, a Tuesday, where + * it is), and neither are the Goiás decrees that may move 26/07 and 28/10, which cannot be + * resolved from a year alone. + * + * Each state entry is listed only from the first year the state norm cited for it applied, and up + * to the last one, so a year before a state's law has no entry for it: 11 August in Santa Catarina + * starts in 2004 (Lei SC nº 12.906/2004) and 25 November in 1997 (Lei SC nº 10.306/1996), São + * Paulo's 9 July in 1997 (Lei SP nº 9.497/1997), Rio de Janeiro's São Jorge in 2008 (Lei RJ nº + * 5.198/2008). The first year of every entry is given next to its law in + * `src/get-holidays/constants.ts`. * * @param {number} year - The year for which to retrieve holidays (must be between 1900 and 2099) * @returns {Holiday[]} An array of holidays sorted by date @@ -202,20 +283,40 @@ const computeHolidays = (year: number, stateCode: StateCode | undefined): Holida * * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l0662.htm * Lei 662/1949, the base national holidays law (Ano novo, Dia do trabalhador, Independência do - * Brasil, Proclamação da República, Natal). + * Brasil, Proclamação da República, Natal), whose original list left out the Tiradentes and + * Finados of the decrees before it. The decrees behind the years before 1949 are cited on + * `FIXED_HOLIDAYS`. * @see Official: https://www.planalto.gov.br/ccivil_03/leis/2002/l10607.htm * Lei 10.607/2002, rewrote that art. 1º into the list in force: it added Finados (2 November) - * to the national holidays and folded in Tiradentes (21 April), already national since art. 3º - * of Lei 1.266/1950, which its own art. 3º revoked. + * to the list of national holidays, absent from it since 1949, and folded in + * Tiradentes (21 April), already national + * since art. 3º of Lei 1.266/1950, which its own art. 3º revoked. * @see Official: https://www.planalto.gov.br/ccivil_03/leis/L1266.htm * Lei 1.266/1950, art. 3º, which first made Tiradentes a national holiday: "É feriado nacional o * dia 21 de abril, consagrado à glorificação de Tiradentes". Revoked by Lei 10.607/2002 only * after that law had carried 21 April into Lei 662/1949. * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l6802.htm - * Lei 6.802/1980, declared Nossa Senhora Aparecida (12 October) a national holiday. + * Lei 6.802, de 30/06/1980, declared Nossa Senhora Aparecida (12 October) a national holiday, + * listed from 1980 on. * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2023/lei/l14759.htm * Lei 14.759/2023, nationalized Dia da Consciência Negra (20 November) from * `CONSCIENCIA_NEGRA_NATIONAL_SINCE_YEAR` (2024) onward. + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l4737compilado.htm + * Código Eleitoral (Lei 4.737/1965), art. 380: "Será feriado nacional o dia em que se realizarem + * eleições de data fixada pela Constituição Federal; nos demais casos, serão as eleições marcadas + * para um domingo ou dia já considerado feriado por lei anterior". + * @see Official: https://www.planalto.gov.br/ccivil_03/constituicao/constituicao.htm + * Constituição Federal, art. 77, in the wording of EC nº 16/1997: the presidential election + * "realizar-se-á, simultaneamente, no primeiro domingo de outubro, em primeiro turno, e no último + * domingo de outubro, em segundo turno, se houver"; arts. 28 and 29, II, fix the same first + * Sunday of October for governors and mayors. + * @see Official: https://www.planalto.gov.br/ccivil_03/constituicao/emendas/emc/emc16.htm + * EC nº 16, de 04/06/1997, which gave arts. 28, 29, II, and 77 that wording, first applied to the + * 1998 elections. + * @see Official: https://www.planalto.gov.br/ccivil_03/constituicao/emendas/emc/emc107.htm + * EC nº 107, de 02/07/2020, art. 1º: "As eleições municipais previstas para outubro de 2020 + * realizar-se-ão no dia 15 de novembro, em primeiro turno, e no dia 29 de novembro de 2020, em + * segundo turno, onde houver". * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l9093.htm * Lei 9.093/1995, the framework law authorizing one state civil holiday (art. 1º, II, "a data * magna do Estado fixada em lei estadual") and up to four municipal religious holidays, "neste @@ -223,18 +324,45 @@ const computeHolidays = (year: number, stateCode: StateCode | undefined): Holida * `STATE_HOLIDAYS`. * @see Official: https://www.in.gov.br/web/dou/-/portaria-mgi-n-11.460-de-29-de-dezembro-de-2025-678388627 * Portaria MGI nº 11.460/2025, the federal executive's annual calendar of feriados nacionais and - * pontos facultativos, reissued every December. It is the source of the typing of three of the - * four entries derived from Easter, which no federal law declares: "Paixão de Cristo (feriado - * nacional)" (Easter minus 2, emitted as `"Sexta-feira Santa"` typed `national`), "Carnaval (ponto - * facultativo)" (Easter minus 47) and "Corpus Christi (ponto facultativo)" (Easter plus 60), both - * typed `optional`. Sexta-feira Santa has no statutory basis of its own: Lei 9.093/1995 art. 2º - * places it among the *municipal* religious holidays, and it is typed `national` here because the - * portaria observes it nationwide. The fourth entry, Easter Sunday itself, is emitted as - * `"Páscoa"` typed `religious` and has no normative basis at all: the portaria never mentions it, - * no federal law declares it, and its date is derived arithmetically by `resolveStateHolidayDate` - * with the Meeus/Jones/Butcher algorithm. It is a convenience entry, listed because callers - * computing a liturgical calendar expect it, not because it is a holiday anyone observes as a day - * off. + * pontos facultativos, reissued every December. It is the source of the typing of the entries + * derived from Easter, which no federal law declares: "Paixão de Cristo (feriado nacional)" + * (Easter minus 2, emitted as `"Sexta-feira Santa"` typed `national`), the two Carnaval days, + * "16 de fevereiro Carnaval (ponto facultativo); 17 de fevereiro Carnaval (ponto facultativo)" + * (Easter minus 48 and minus 47, emitted as `"Carnaval (segunda-feira)"` and `"Carnaval + * (terça-feira)"`), and "Corpus Christi (ponto facultativo)" (Easter plus 60), all three typed + * `optional`. Every portaria lists both Carnaval days: Portaria MGI nº 8.617/2023, for 2024, reads + * "12 de fevereiro Carnaval (ponto facultativo); 13 de fevereiro Carnaval (ponto facultativo); 14 + * de fevereiro Quarta-Feira de Cinzas (ponto facultativo até as 14 horas)". Sexta-feira Santa has + * no statutory basis of its own: Lei 9.093/1995 art. 2º places it among the *municipal* religious + * holidays, and it is typed `national` here because the portaria observes it nationwide. The + * remaining entry, Easter Sunday itself, is emitted as `"Páscoa"` typed `religious` and has no + * normative basis at all: the portaria never mentions it, no federal law declares it, and its date + * is derived arithmetically by `resolveStateHolidayDate` with the Meeus/Jones/Butcher algorithm. + * It is a convenience entry, listed because callers computing a liturgical calendar expect it, not + * because it is a holiday anyone observes as a day off. + * + * The portaria's other pontos facultativos are left out, because each is partial or concerns the + * servants alone: the Quarta-feira de Cinzas is one only "até as 14 horas", 28 October is the Dia + * do Servidor Público of Lei 8.112/1990, art. 236, a date about the servants themselves that the + * financial market does not skip, the 24 and 31 December entries cover only their afternoons, and + * the bridge days a given year adds next to a Thursday or Tuesday holiday (20 April and 5 June in + * 2026) are one-off decisions no rule can derive from the year. + * @see Official: https://www.in.gov.br/en/web/dou/-/portaria-mgi-n-8.617-de-26-de-dezembro-de-2023-533937211 + * Portaria MGI nº 8.617, de 26/12/2023 (DOU of 28/12/2023, Seção 1, p. 148), the 2024 calendar, + * art. 1º, II and III, quoted above for the two Carnaval days. + * @see Official: https://www.in.gov.br/web/dou/-/portaria-mgi-n-9.783-de-27-de-dezembro-de-2024-604395869 + * Portaria MGI nº 9.783, de 27/12/2024 (DOU of 30/12/2024, Seção 1, p. 794), the 2025 calendar, + * art. 1º: "II - 3 de março, Carnaval (ponto facultativo); III - 4 de março, Carnaval (ponto + * facultativo)". The three portarias of 2024 to 2026 list both Carnaval days as ponto + * facultativo and never as feriado nacional, and none of their later amendments touches them. + * @see Official: https://www.bcb.gov.br/estabilidadefinanceira/exibenormativo?tipo=Resolu%C3%A7%C3%A3o%20CMN&numero=4880 + * Resolução CMN nº 4.880, de 23/12/2020 (DOU of 24/12/2020, in force from 01/03/2021), art. 6º, + * which FEBRABAN's bank holiday calendar follows: "Não são considerados dias úteis, para fins de + * operações praticadas no mercado financeiro e de prestação de informações ao Banco Central do + * Brasil, os sábados, domingos e feriados de âmbito nacional, bem como: I - a segunda-feira e a + * terça-feira de Carnaval; e II - o dia dedicado a Corpus Christi", the same three days typed + * `optional` here. The Quarta-feira de Cinzas is not in that list: art. 2º, § 1º, II only allows a + * "horário especial de atendimento" on it, so it stays a dia útil for the financial market too. */ export function getHolidays(year: number): Holiday[]; /** diff --git a/src/get-iban-info/get-iban-info.test.ts b/src/get-iban-info/get-iban-info.test.ts index 2d74cd34d..2db7db611 100644 --- a/src/get-iban-info/get-iban-info.test.ts +++ b/src/get-iban-info/get-iban-info.test.ts @@ -77,6 +77,18 @@ describe("getIbanInfo", () => { }); }); + test("for an ISPB with letters, which Resolução BCB 585/2026 allows", () => { + expect(getIbanInfo("BR10 12AB 34CD 0000 1093 2840 814P 2")).toEqual({ + countryCode: "BR", + checkDigits: "10", + bankIspb: "12AB34CD", + branch: "00001", + account: "0932840814", + accountType: "P", + owner: "2", + }); + }); + test("for a valid IBAN with an account type letter other than C or P", () => { expect(getIbanInfo("BR5400000000000010932840814D2")).toEqual({ countryCode: "BR", @@ -111,7 +123,7 @@ describe("getIbanInfo", () => { expect(getIbanInfo("BR150000000000001093284081412")).toBeNull(); }); - test("when the owner indicator is 0, which Circular 3.625 art. 2 § 1 does not assign, even though the check digits match", () => { + test("when the owner indicator is 0, which Resolução BCB 585/2026 art. 2 § 1 does not assign, even though the check digits match", () => { expect(getIbanInfo("BR6900000000000010932840814P0")).toBeNull(); }); diff --git a/src/get-iban-info/get-iban-info.ts b/src/get-iban-info/get-iban-info.ts index f72da77b7..297679536 100644 --- a/src/get-iban-info/get-iban-info.ts +++ b/src/get-iban-info/get-iban-info.ts @@ -7,15 +7,18 @@ export type IbanInfo = { countryCode: "BR"; /** The 2 digit ISO 7064 MOD 97-10 check digits. */ checkDigits: string; - /** The 8 digit ISPB (Identificador do Sistema de Pagamentos Brasileiro) of the institution. */ + /** + * The 8 character ISPB (Identificador do Sistema de Pagamentos Brasileiro) of the institution, + * digits or, since Resolução BCB nº 585/2026, upper case letters too. + */ bankIspb: string; /** The 5 digit branch (agência) number, zero-padded. */ branch: string; /** The 10 digit account (conta) number, zero-padded. */ account: string; /** - * The 1 letter account type, as published in the "Dicionário de Tipos" of the Catálogo de - * Mensagens e de Arquivos do SFN. `"C"` (conta corrente) and `"P"` (conta poupança) are the + * The 1 letter account type, as published in the "dicionário de tipos" of the Catálogo de + * Serviços do Sistema Financeiro Nacional. `"C"` (conta corrente) and `"P"` (conta poupança) are the * usual values, but any letter is allowed. */ accountType: string; @@ -44,7 +47,7 @@ const ACCOUNT_TYPE_END = ACCOUNT_END + ACCOUNT_TYPE_LENGTH; * Parses a Brazilian IBAN (International Bank Account Number) into its fields. * * The 29 character Brazilian IBAN is laid out as 2 (country code, always `BR`) + 2 (ISO 7064 - * MOD 97-10 check digits) + 8 (ISPB) + 5 (branch) + 10 (account) + 1 (account type, any letter, + * MOD 97-10 check digits) + 8 (ISPB, alphanumeric) + 5 (branch) + 10 (account) + 1 (account type, any letter, * usually `C` for conta corrente or `P` for conta poupança) + 1 (owner indicator, `1` to `9` * then `A` to `Z`). Only * Brazilian IBANs are supported: the field layout of the other ISO 13616 countries is out of @@ -79,8 +82,11 @@ const ACCOUNT_TYPE_END = ACCOUNT_END + ACCOUNT_TYPE_LENGTH; * getIbanInfo("BR15 000 00000 0000 1093 2840 814P 2"); // null (a separator inside a group) * ``` * + * @see Official: https://www.bcb.gov.br/estabilidadefinanceira/exibenormativo?tipo=Resolu%C3%A7%C3%A3o%20BCB&numero=585 + * Resolução BCB nº 585, de 24/08/2026 (DOU 25/08/2026), art. 2º, which revoked Circular BCB nº + * 3.625/2013 and keeps its layout, with an alphanumeric ISPB. * @see Official: https://www.bcb.gov.br/pre/normativos/circ/2013/pdf/circ_3625_v1_O.pdf - * Circular BCB nº 3.625/2013 + * Circular BCB nº 3.625/2013 (revoked), the original layout * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf * Diretrizes de Implementação do IBAN no Brasil * @see Official: https://www.iso.org/standard/81090.html diff --git a/src/get-legal-nature/get-legal-nature.test.ts b/src/get-legal-nature/get-legal-nature.test.ts index 9f49814c5..b8d4f9830 100644 --- a/src/get-legal-nature/get-legal-nature.test.ts +++ b/src/get-legal-nature/get-legal-nature.test.ts @@ -29,9 +29,8 @@ describe("getLegalNature", () => { expect(getLegalNature(2062)).toEqual(SOCIEDADE_EMPRESARIA_LIMITADA); }); - it("should strip the mask of a number just like the mask of a string", () => { - expect(getLegalNature(206.2)).toEqual(SOCIEDADE_EMPRESARIA_LIMITADA); - expect(getLegalNature(206.2)).toEqual(getLegalNature("206.2")); + it("should strip the mask of a string, the dot included", () => { + expect(getLegalNature("206.2")).toEqual(SOCIEDADE_EMPRESARIA_LIMITADA); }); it("should return the legal nature entry for a masked code (206-2)", () => { @@ -146,6 +145,11 @@ describe("getLegalNature", () => { expect(getLegalNature()).toBeNull(); }); + it("should return null for a negative or fractional number", () => { + expect(getLegalNature(-2062)).toBeNull(); + expect(getLegalNature(206.2)).toBeNull(); + }); + describe("properties", () => { const knownCode = fc.constantFrom(...Object.keys(LEGAL_NATURE)); @@ -175,6 +179,30 @@ describe("getLegalNature", () => { ); }); + const lookupInputs = fc.oneof( + knownCode, + knownCode.map((code) => `${code.slice(0, 3)}-${code.slice(3)}`), + knownCode.map(Number), + knownCode.map((code) => Number(code) / 10), + digitsUpTo(6), + anyValue, + fc.anything(), + ); + + test("should return null exactly when isValidLegalNature rejects the string form of the value, or the value is a number that is not a non-negative safe integer", () => { + fc.assert( + fc.property(lookupInputs, (value) => { + const isRejectedNumber = + typeof value === "number" && !(Number.isSafeInteger(value) && value >= 0); + const text = typeof value === "number" ? String(value) : value; + + expect(getLegalNature(value as string) === null).toBe( + isRejectedNumber || !isValidLegalNature(text as string), + ); + }), + ); + }); + test("should never throw and always return null or an entry of the table", () => { fc.assert( fc.property(anyValue, (value) => { diff --git a/src/get-legal-nature/get-legal-nature.ts b/src/get-legal-nature/get-legal-nature.ts index 9a3418766..e5ba4f736 100644 --- a/src/get-legal-nature/get-legal-nature.ts +++ b/src/get-legal-nature/get-legal-nature.ts @@ -3,7 +3,9 @@ import { type LegalNatureCategory, } from "../_internals/constants/legal-nature-categories"; import { SEPARATORS_REGEX } from "../_internals/constants/separators"; +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { LEGACY_LEGAL_NATURE, LEGAL_NATURE } from "../is-valid-legal-nature/constants"; +import { isValidLegalNature } from "../is-valid-legal-nature/is-valid-legal-nature"; export type { LegalNatureCategory } from "../_internals/constants/legal-nature-categories"; @@ -57,14 +59,14 @@ export const buildLegalNature = (code: string, description: string): LegalNature : { ...entry, legacy: false }; }; -const lookUp = (code: string): LegalNature | null => - Object.hasOwn(LEGAL_NATURE, code) ? buildLegalNature(code, LEGAL_NATURE[code]) : null; - /** * Looks a Brazilian legal nature (natureza jurídica) code up. * - * The usual mask characters (hyphens, dots, whitespace) are stripped before the lookup, from a - * number as well as from a string, so `getLegalNature(206.2)` resolves like `getLegalNature("206.2")`. + * The usual mask characters (hyphens, dots, whitespace) are stripped from a string before the + * lookup, so `getLegalNature("206.2")` resolves like `getLegalNature("206-2")`. A number is only + * read as a code when it is a non-negative safe integer: its sign and its decimal point are not + * mask characters, so `getLegalNature(-2062)` and `getLegalNature(206.2)` return `null` instead + * of being read as `2062`. * * No legal nature code starts with a zero, its first digit is the CONCLA category (1 to 5), so * nothing is ever padded here: a number and the string of the same digits are read identically, @@ -83,7 +85,9 @@ const lookUp = (code: string): LegalNature | null => * * @param {string|number} value - The legal nature code to look up, with or without formatting. * @returns {LegalNature|null} The matching legal nature entry, or null when the code is unknown - * or invalid. + * or invalid, which is exactly when `isValidLegalNature` returns false for the string form of the + * value (a number is read as the string it prints as), or when the value is a number that is not + * a non-negative safe integer. * * The CONCLA table page sits behind a bot filter and answers HTTP 403 to every non-browser * client, so it has to be opened in a browser; the detailed structure PDF next to it is served @@ -111,12 +115,19 @@ const lookUp = (code: string): LegalNature | null => * // } * getLegalNature("3123")?.legacy; // true (Partido Político, retired without a successor) * getLegalNature("206-2")?.code; // "2062" - * getLegalNature(206.2)?.category.description; // "Entidades Empresariais" + * getLegalNature("206.2")?.category.description; // "Entidades Empresariais" * getLegalNature("0000"); // null + * getLegalNature(206.2); // null (not a non-negative safe integer) * ``` */ export const getLegalNature = (value: string | number): LegalNature | null => { - if (typeof value !== "string" && typeof value !== "number") return null; + if (!isLookupCode(value)) return null; + + const text = String(value); + + if (!isValidLegalNature(text)) return null; + + const code = text.replace(SEPARATORS_REGEX, ""); - return lookUp(String(value).replace(SEPARATORS_REGEX, "")); + return buildLegalNature(code, LEGAL_NATURE[code]); }; diff --git a/src/get-municipalities/get-municipalities.ts b/src/get-municipalities/get-municipalities.ts index 27a6a76bd..765279c0b 100644 --- a/src/get-municipalities/get-municipalities.ts +++ b/src/get-municipalities/get-municipalities.ts @@ -39,6 +39,9 @@ const buildMunicipalities = (stateCode: StateCode): Municipality[] => * ``` * * @see Official: https://servicodados.ibge.gov.br/api/docs/localidades + * @see Official: https://geoftp.ibge.gov.br/organizacao_do_territorio/estrutura_territorial/divisao_territorial/2025/DTB_2025.zip + * IBGE, Divisão Territorial Brasileira 2025 (data base 31/12/2025): the same 5,571 municipality + * codes as the bundled table. */ export const getMunicipalities = (stateCode?: StateCode): Municipality[] => { if (stateCode === undefined) { diff --git a/src/get-municipality-by-code/get-municipality-by-code.ts b/src/get-municipality-by-code/get-municipality-by-code.ts index e0f16bddf..b49b195d0 100644 --- a/src/get-municipality-by-code/get-municipality-by-code.ts +++ b/src/get-municipality-by-code/get-municipality-by-code.ts @@ -23,6 +23,9 @@ export type { Municipality } from "../_internals/constants/municipalities"; * ``` * * @see Official: https://servicodados.ibge.gov.br/api/docs/localidades + * @see Official: https://geoftp.ibge.gov.br/organizacao_do_territorio/estrutura_territorial/divisao_territorial/2025/DTB_2025.zip + * IBGE, Divisão Territorial Brasileira 2025 (data base 31/12/2025): the same 5,571 municipality + * codes as the bundled table. */ export const getMunicipalityByCode = (code: string | number): Municipality | null => { if (!isLookupCode(code)) return null; diff --git a/src/get-nfe-key-info/get-nfe-key-info.test.ts b/src/get-nfe-key-info/get-nfe-key-info.test.ts index 9c385dfcf..1138a3a01 100644 --- a/src/get-nfe-key-info/get-nfe-key-info.test.ts +++ b/src/get-nfe-key-info/get-nfe-key-info.test.ts @@ -3,7 +3,12 @@ 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 { EMISSION_TYPES_BY_MODEL, FORBIDDEN_CODES, VALID_MODELS } from "./constants"; +import { + EMISSION_TYPES_BY_MODEL, + FORBIDDEN_CODES, + VALID_MODELS, +} from "../is-valid-nfe-key/constants"; +import { isValidNfeKey } from "../is-valid-nfe-key/is-valid-nfe-key"; import { getNfeKeyInfo, type NfeKeyInfo, type NfeKeyModel } from "./get-nfe-key-info"; const KEY_SP = "35170458716523000119550010000000121000123458"; @@ -146,6 +151,27 @@ describe("getNfeKeyInfo", () => { expect(getNfeKeyInfo(KEY_CPF_PADDED)?.taxId).toHaveLength(14); }); + test("for the alphanumeric CNPJ of NT Conjunta 2025.001, reading its letters into taxId", () => { + // cUF 35, AAMM 2607, CNPJ 12ABC34501DE35, mod 55, serie 001, nNF 123, tpEmis 1, + // cNF 10203040; cDV 3 from the ASCII-minus-48 weighted sum 756 (756 mod 11 = 8). + expect(getNfeKeyInfo("35260712ABC34501DE35550010000001231102030403")).toEqual({ + stateCode: "SP", + year: 2026, + month: 7, + taxId: "12ABC34501DE35", + model: "55", + series: 1, + number: 123, + emissionType: 1, + code: "10203040", + checkDigit: 3, + }); + expect(getNfeKeyInfo("35260712abc34501de35550010000001231102030403")?.taxId).toBe( + "12ABC34501DE35", + ); + expect(getNfeKeyInfo("35260712ABC34501DEA5550010000001231102030408")).toBeNull(); + }); + test("for tpEmis 9, the off-line NFC-e contingency, same shape as the SP key with the tpEmis field changed and the check digit recalculated", () => { expect(getNfeKeyInfo("35170458716523000119550010000000129000123453")?.emissionType).toBe(9); }); @@ -241,6 +267,22 @@ describe("getNfeKeyInfo", () => { ); }); + test("should return null exactly when isValidNfeKey returns false", () => { + const key = parts.map(([uf, year, month, taxId, document, series, number, tail]) => + buildNfeKey( + `${uf}${year}${String(month).padStart(2, "0")}${taxId}${document.model}${series}${String(number).padStart(9, "0")}${document.emissionType}${tail}`, + ), + ); + + const input = fc.oneof(key, fc.string(), fc.anything()); + + fc.assert( + fc.property(input, (value) => { + expect(getNfeKeyInfo(value as string) === null).toBe(!isValidNfeKey(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-nfe-key-info/get-nfe-key-info.ts b/src/get-nfe-key-info/get-nfe-key-info.ts index 8995b7acc..5ba22f630 100644 --- a/src/get-nfe-key-info/get-nfe-key-info.ts +++ b/src/get-nfe-key-info/get-nfe-key-info.ts @@ -1,19 +1,19 @@ import { IBGE_UF_CODES } from "../_internals/constants/ibge-uf-codes"; -import { NFE_KEY_LENGTH, XML_ID_PREFIX_REGEX } from "../_internals/constants/nfe-key"; import { type StateCode } from "../_internals/constants/states"; -import { mod11 } from "../_internals/mod11/mod11"; -import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; import { - ABSENT_NUMBER, + AUTHORIZATION_SITE_INDEX, AUTHORIZATION_SITE_MODELS, - EMISSION_TYPES_BY_MODEL, - FORBIDDEN_CODES, - FORBIDDEN_CODE_MODELS, - FORMAT_REGEX, + CHECK_DIGIT_INDEX, + EMISSION_TYPE_INDEX, + MODEL_END, + MODEL_START, NUMBER_END, NUMBER_START, + SHORT_CODE_START, VALID_MODELS, -} from "./constants"; +} from "../is-valid-nfe-key/constants"; +import { isValidNfeKey } from "../is-valid-nfe-key/is-valid-nfe-key"; +import { parseNfeKey } from "../parse-nfe-key/parse-nfe-key"; export type { StateCode } from "../_internals/constants/states"; @@ -34,7 +34,10 @@ export type NfeKeyInfo = { year: number; /** Issue month, 1 to 12. */ month: number; - /** The 14 digit CNPJ (or zero padded CPF) of the issuer. */ + /** + * The 14 character CNPJ of the issuer, numeric or alphanumeric (upper case letters in its + * first 12 characters), or its 11 digit CPF left padded with zeros to 14. + */ taxId: string; /** Document model: "55" NF-e, "57" CT-e, "58" MDF-e, "62" NFCom, "63" BP-e, "64" GTV-e, "65" NFC-e, "66" NF3e, "67" CT-e OS. */ model: NfeKeyModel; @@ -55,28 +58,24 @@ export type NfeKeyInfo = { checkDigit: number; }; -const EMISSION_TYPE_INDEX = 34; - -const AUTHORIZATION_SITE_INDEX = 35; - -const SHORT_CODE_START = 36; - -const CODE_END = 43; - -const CHECK_DIGIT_INDEX = 43; - -const isForbiddenCode = (model: string, code: string, number: number): boolean => - FORBIDDEN_CODE_MODELS.includes(model) && - (FORBIDDEN_CODES.includes(code) || Number(code) === number); +/** `VALID_MODELS` widened to strings, so a model read out of the key can be looked up in it. */ +const MODEL_CODES: readonly string[] = VALID_MODELS; /** * Parses a DF-e (Documento Fiscal eletrônico) access key (chave de acesso) into its fields. * - * Covers every document whose access key is the same 44 digit string: NF-e (modelo 55), NFC-e + * Covers every document whose access key is the same 44 character string: NF-e (modelo 55), NFC-e * (65), CT-e (57), MDF-e (58), CT-e OS (67), GTV-e (64), BP-e (63), NF3e (66) and NFCom (62). * Accepts the same input forms as `isValidNfeKey` (the printed mask of 4 digit groups, split by * whitespace, `.`, `-` or `/`, and the `NFe`, `CTe`, `MDFe`, `BPe`, `NF3e` and `NFCom` prefixes - * of the XML `Id` attribute) and returns `null` when the key is not valid. + * of the XML `Id` attribute) and returns `null` exactly when `isValidNfeKey` returns `false`. + * + * `taxId` is read as written in positions 7 to 20: a numeric CNPJ, an alphanumeric CNPJ (whose + * letters, positions 7 to 18 of the key, come back upper cased; the current schemas type the key + * as `[0-9]{6}[0-9A-Z]{12}[0-9]{26}`), or a CPF left padded with zeros to 14 digits, the form the + * NF-e gives a CPF issuer. No field tells a padded CPF from a CNPJ that starts with `000`, so + * `taxId` is returned whole and the caller that needs the document type reads it with + * `isValidCpf` or `isValidCnpj`; a `taxId` with a letter is always an alphanumeric CNPJ. * * The emission type (`tpEmis`) is checked against the codes the MOC of that model assigns, so * the accepted set changes with the model: 1 to 7 and 9 for NF-e and NFC-e, `{1, 3, 4, 5, 7, 8}` @@ -104,7 +103,12 @@ const isForbiddenCode = (model: string, code: string, number: number): boolean = * Manual de Orientação do Contribuinte (MOC) NF-e, "chave de acesso". * @see Official: https://dfe-portal.svrs.rs.gov.br/NFE/Documentos * NF-e schema package (PL_010b, NT2025.002 v1.30): `tiposBasico_v4.00.xsd`, the `TNF` and - * `TCodUfIBGE` types. + * `TCodUfIBGE` types, and `TChNFe`, the 44 character key `` + * (PL_009 had `[0-9]{44}`), in production from 01/07/2026 under NT 2026.004 v1.01. + * @see Official: https://www.nfe.fazenda.gov.br/portal/exibirArquivo.aspx?conteudo=5ZkvIZt10mQ%3D + * Nota Técnica Conjunta 2025.001 (CNPJ alfanumérico nos DF-e): positions 7 to 18 of the key take + * the letters of the alphanumeric CNPJ, and the check digit is computed "considerando o valor + * decimal dos caracteres com base na tabela ASCII, subtraindo-se 48". * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/2007/AJ_009_07 * Ajuste SINIEF 09/07, cláusula primeira, caput: the CT-e, modelo 57. * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/2019/AJ036_19 @@ -133,67 +137,34 @@ const isForbiddenCode = (model: string, code: string, number: number): boolean = * // { stateCode: "SP", year: 2017, month: 4, taxId: "58716523000119", model: "55", * // series: 1, number: 12, emissionType: 1, code: "00012345", checkDigit: 8 } * + * getNfeKeyInfo("35260712ABC34501DE35550010000001231102030403"); + * // { stateCode: "SP", year: 2026, month: 7, taxId: "12ABC34501DE35", model: "55", + * // series: 1, number: 123, emissionType: 1, code: "10203040", checkDigit: 3 } + * * getNfeKeyInfo("invalid"); // null * ``` */ export const getNfeKeyInfo = (value: string): NfeKeyInfo | null => { - if (typeof value !== "string") return null; - - const body = value.trim().replace(XML_ID_PREFIX_REGEX, "").trimStart(); - - if (!FORMAT_REGEX.test(body)) return null; - - const digits = sanitizeToDigits(body); - - if (digits.length !== NFE_KEY_LENGTH) return null; - - const uf = digits.slice(0, 2); - - const stateCode = IBGE_UF_CODES[uf]; - - if (stateCode === undefined) return null; - - const month = Number(digits.slice(4, 6)); - - if (month < 1 || month > 12) return null; - - const modelDigits = digits.slice(20, 22); - const model = VALID_MODELS.find((candidate) => candidate === modelDigits); - - if (model === undefined) return null; - - if (digits.slice(NUMBER_START, NUMBER_END) === ABSENT_NUMBER) return null; - - const emissionType = Number(digits[EMISSION_TYPE_INDEX]); - - if (!EMISSION_TYPES_BY_MODEL[model].includes(emissionType)) return null; + if (!isValidNfeKey(value)) return null; + const digits = parseNfeKey(value); + const model = VALID_MODELS[MODEL_CODES.indexOf(digits.slice(MODEL_START, MODEL_END))]; const hasAuthorizationSite = AUTHORIZATION_SITE_MODELS.includes(model); - const code = digits.slice( - hasAuthorizationSite ? SHORT_CODE_START : AUTHORIZATION_SITE_INDEX, - CODE_END, - ); - const number = Number(digits.slice(NUMBER_START, NUMBER_END)); - - if (isForbiddenCode(model, code, number)) return null; - - const checkDigit = Number(digits[CHECK_DIGIT_INDEX]); - - if (mod11(digits.slice(0, CHECK_DIGIT_INDEX), { variant: "arrecadacao" }) !== checkDigit) { - return null; - } const parsed: NfeKeyInfo = { - stateCode, + stateCode: IBGE_UF_CODES[digits.slice(0, 2)], year: 2000 + Number(digits.slice(2, 4)), - month, + month: Number(digits.slice(4, 6)), taxId: digits.slice(6, 20), model, series: Number(digits.slice(22, 25)), - number, - emissionType, - code, - checkDigit, + number: Number(digits.slice(NUMBER_START, NUMBER_END)), + emissionType: Number(digits[EMISSION_TYPE_INDEX]), + code: digits.slice( + hasAuthorizationSite ? SHORT_CODE_START : AUTHORIZATION_SITE_INDEX, + CHECK_DIGIT_INDEX, + ), + checkDigit: Number(digits[CHECK_DIGIT_INDEX]), }; if (hasAuthorizationSite) parsed.authorizationSite = Number(digits[AUTHORIZATION_SITE_INDEX]); 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 54f40fd05..4e79de33f 100644 --- a/src/get-nfse-key-info/get-nfse-key-info.ts +++ b/src/get-nfse-key-info/get-nfse-key-info.ts @@ -82,12 +82,15 @@ export type NfseKeyInfo = { * 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 + * An alphanumeric CNPJ is accepted since the restricted-production (RTC) schema bundle of + * 2026-07-27 widened `TSIdNFSe` to letters in the registration; the service handles it in + * production since 2026-08-10, while the production bundle of 2026-02-09 is still digits only. 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. + * official document states how a letter enters the check digit of the key: the NFS-e technical + * notes 001 to 009, the Anexo I and the Perguntas e Respostas of 08/09/2026 do not, and Nota + * Técnica Conjunta 2025.001 lists the DF-e it covers without the NFS-e. By analogy with that NT + * and the CNPJ's own check digits, each character 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/get-pix-key-info/constants.ts b/src/get-pix-key-info/constants.ts deleted file mode 100644 index bd93b183f..000000000 --- a/src/get-pix-key-info/constants.ts +++ /dev/null @@ -1,25 +0,0 @@ -export const EMAIL_MAX_LENGTH = 77; - -/** - * A DICT random key (EVP) is a lowercase UUID written with its punctuation. The DICT issues - * version 4 UUIDs, but neither the registered pattern nor the example of the manual - * (`123e4567-e12b-12d1-a456-426655440000`, whose version nibble is `1`) constrains the - * version, so the version and variant nibbles are not enforced. - */ -export const EVP_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -/** - * The characters a value written as a phone number may carry: digits, the `+` of the - * international prefix and the spaces, dots, hyphens and parentheses of the usual masks. - * Sanitizing to digits alone would read `"abc(11) 98765-4321xyz"` as a phone number, so the - * value is matched against this before it is sanitized. - */ -export const PHONE_SYNTAX_REGEX = /^[\d ()+.-]+$/; - -/** - * The forms a value written as a CPF may take: the bare 11 digits or the documented mask, with - * the dots and the hyphen optional and a space accepted wherever a separator goes. Sanitizing - * to digits alone would read `"abc123.456.789-09"` as a CPF, so the value is matched against - * this before it is sanitized. - */ -export const CPF_SYNTAX_REGEX = /^\d{3}[ .]?\d{3}[ .]?\d{3}[ -]?\d{2}$/; diff --git a/src/get-pix-key-info/get-pix-key-info.test.ts b/src/get-pix-key-info/get-pix-key-info.test.ts index 8595a78d3..c18f9a3a0 100644 --- a/src/get-pix-key-info/get-pix-key-info.test.ts +++ b/src/get-pix-key-info/get-pix-key-info.test.ts @@ -5,6 +5,7 @@ import { formatCnpj } from "../format-cnpj/format-cnpj"; import { generateCnpj } from "../generate-cnpj/generate-cnpj"; import { generateCpf } from "../generate-cpf/generate-cpf"; import { generatePhone } from "../generate-phone/generate-phone"; +import { isValidPixKey } from "../is-valid-pix-key/is-valid-pix-key"; import { type PixKeyInfo, type PixKeyType, getPixKeyInfo } from "./get-pix-key-info"; const AMBIGUOUS = "51998259765"; @@ -67,7 +68,21 @@ describe("getPixKeyInfo", () => { test("when it is an invalid e-mail", () => { expect(getPixKeyInfo("fulano@")).toBeNull(); expect(getPixKeyInfo("@example.com")).toBeNull(); - expect(getPixKeyInfo("fulano@example")).toBeNull(); + expect(getPixKeyInfo("fulano@example.com.")).toBeNull(); + expect(getPixKeyInfo("fulano@-example.com")).toBeNull(); + expect(getPixKeyInfo("fulano@example-.com")).toBeNull(); + expect(getPixKeyInfo("fulano@exa_mple.com")).toBeNull(); + expect(getPixKeyInfo("ful%ano@example.com")).toBeNull(); + expect(getPixKeyInfo('"fulano"@example.com')).toBeNull(); + expect(getPixKeyInfo("fulano@@example.com")).toBeNull(); + }); + + test("when the e-mail carries a &, which DICT API 2.6.0 took out of the pattern", () => { + expect(getPixKeyInfo("a&b@example.com")).toBeNull(); + }); + + test("when a domain label is longer than the 63 characters of the DICT pattern", () => { + expect(getPixKeyInfo(`a@${"b".repeat(64)}.com`)).toBeNull(); }); test("when the e-mail is longer than 77 characters", () => { @@ -185,6 +200,40 @@ describe("getPixKeyInfo", () => { }); }); + test("when it only matches the DICT pattern, not isValidEmail", () => { + for (const email of [ + "a!b@example.com", + "a#b@example.com", + "a$b@example.com", + "a*b@example.com", + "a/b@example.com", + "a=b@example.com", + "a?b@example.com", + "a^b@example.com", + "a`b@example.com", + "a{b}@example.com", + "a|b@example.com", + "a~b@example.com", + ".ab@example.com", + "a..b@example.com", + "ab.@example.com", + "a@localhost", + "a@example.123", + ]) { + expect(getPixKeyInfo(email)).toEqual({ type: "email", value: email }); + } + }); + + test("when it is the single label domain isValidEmail rejects, lowercased", () => { + expect(getPixKeyInfo("Fulano@Example")).toEqual({ type: "email", value: "fulano@example" }); + }); + + test("when a domain label is exactly 63 characters long", () => { + const email = `a@${"b".repeat(63)}.com`; + + expect(getPixKeyInfo(email)).toEqual({ type: "email", value: email }); + }); + test("when it is exactly 77 characters long", () => { const email = `${"a".repeat(65)}@example.com`; @@ -345,6 +394,22 @@ describe("getPixKeyInfo", () => { ); }); + test("should return null exactly when isValidPixKey returns false, and the type accept filters on", () => { + const built = keys.map(([kind, email, evp]) => buildPixKey(kind, email, evp)); + const input = fc.oneof(built, fc.string(), fc.anything()); + const accept = fc.subarray([...PIX_KEY_KINDS]); + + fc.assert( + fc.property(input, accept, (value, kinds) => { + const parsed = getPixKeyInfo(value as string); + const isAccepted = parsed !== null && kinds.includes(parsed.type); + + expect(parsed === null).toBe(!isValidPixKey(value as string)); + expect(isValidPixKey(value as string, { accept: kinds })).toBe(isAccepted); + }), + ); + }); + test("should never throw and always return a Pix key or null", () => { fc.assert( fc.property(fc.anything(), (value) => { diff --git a/src/get-pix-key-info/get-pix-key-info.ts b/src/get-pix-key-info/get-pix-key-info.ts index 61e26fe14..0a7fad456 100644 --- a/src/get-pix-key-info/get-pix-key-info.ts +++ b/src/get-pix-key-info/get-pix-key-info.ts @@ -1,15 +1,13 @@ import { PHONE_COUNTRY_CODE } from "../_internals/constants/phone"; +import { + type PixKeyType, + detectPixKeyType, +} from "../_internals/detect-pix-key-type/detect-pix-key-type"; import { normalizePhone } from "../_internals/normalize-phone/normalize-phone"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -import { isValidCnpj } from "../is-valid-cnpj/is-valid-cnpj"; -import { isValidCpf } from "../is-valid-cpf/is-valid-cpf"; -import { isValidEmail } from "../is-valid-email/is-valid-email"; -import { isValidPhone } from "../is-valid-phone/is-valid-phone"; import { parseCnpj } from "../parse-cnpj/parse-cnpj"; -import { CPF_SYNTAX_REGEX, EMAIL_MAX_LENGTH, EVP_REGEX, PHONE_SYNTAX_REGEX } from "./constants"; -/** The kinds of Pix key `getPixKeyInfo` recognizes. */ -export type PixKeyType = "cpf" | "cnpj" | "email" | "phone" | "evp"; +export type { PixKeyType } from "../_internals/detect-pix-key-type/detect-pix-key-type"; /** A Pix key recognized by `getPixKeyInfo`, normalized to the canonical DICT form of its kind. */ export type PixKeyInfo = { @@ -19,21 +17,13 @@ export type PixKeyInfo = { value: string; }; -/** - * Reads a value written as a phone number, i.e. one holding nothing but digits and the - * characters of the usual masks, as the E.164 mobile key of the DICT. - * - * @param {string} trimmed - The trimmed value to read. - * @returns {PixKeyInfo|null} The phone key, or `null` when the value is not a mobile number. - */ -const resolvePhoneKey = (trimmed: string): PixKeyInfo | null => { - if (!PHONE_SYNTAX_REGEX.test(trimmed)) return null; - - const national = normalizePhone(trimmed); - - return isValidPhone(national, { accept: ["mobile"] }) - ? { type: "phone", value: `+${PHONE_COUNTRY_CODE}${national}` } - : null; +/** Writes a trimmed value, already known to be a key of each kind, in the canonical DICT form. */ +const NORMALIZERS: Readonly string>> = { + cpf: sanitizeToDigits, + cnpj: (trimmed) => parseCnpj(trimmed, { version: 2 }), + email: (trimmed) => trimmed.toLowerCase(), + phone: (trimmed) => `+${PHONE_COUNTRY_CODE}${normalizePhone(trimmed)}`, + evp: (trimmed) => trimmed.toLowerCase(), }; /** @@ -43,15 +33,21 @@ const resolvePhoneKey = (trimmed: string): PixKeyInfo | null => { * The canonical forms are the ones listed in "Formatação das chaves do DICT no BR Code": * - `cpf`: 11 digits, no mask; * - `cnpj`: 14 characters, no mask, uppercase for the alphanumeric format; - * - `email`: trimmed and lowercased, at most 77 characters; + * - `email`: trimmed and lowercased, matching the pattern the DICT API registers for an e-mail + * key (DICT API 2.12.1) and at most 77 characters. That pattern is not the syntax + * `isValidEmail` checks: the local part may carry any of ``.!#$'*+/=?^_`{|}~-``, dots included + * anywhere, and the domain may be a single label, so `"a{b}@example.com"` and `"a@localhost"` + * are e-mail keys. The `&` was taken out of the pattern in version 2.6.0 of the DICT API, so + * `"a&b@example.com"` is not a key; * - `phone`: E.164, `+55` followed by the DDD and the subscriber number, so at most 14 * characters. The manual registers a "número de telefone celular", so only mobile numbers * are recognized; a landline is not a Pix key. Masked, bare and `+55` prefixed inputs are * all accepted; * - `evp`: the random key, a lowercase UUID written with its punctuation (8-4-4-4-12 - * hexadecimal digits). The DICT issues version 4 UUIDs, but neither the pattern the manual - * registers nor its own example (`123e4567-e12b-12d1-a456-426655440000`, whose version - * nibble is `1`) constrains the version, so the version and variant nibbles are not enforced. + * hexadecimal digits), which the DICT generates. Neither the pattern the DICT API registers + * nor its example (`123e4567-e89b-12d3-a456-426655440000`) nor the one of the Pix manual + * (`123e4567-e12b-12d1-a456-426655440000`), both with the version nibble `1`, constrains the + * UUID version, so the version and variant nibbles are not enforced. * * The CPF and the phone number are recognized by the way they are written, not only by the * digits they carry: a value is read as a CPF when it is the bare 11 digits or the documented @@ -67,7 +63,8 @@ const resolvePhoneKey = (trimmed: string): PixKeyInfo | null => { * a CPF, even when its digits carry a valid CPF check digit. * * @param {string} value - The Pix key to be parsed. - * @returns {PixKeyInfo|null} The normalized key, or `null` when the value is not a valid Pix key. + * @returns {PixKeyInfo|null} The normalized key, or `null` exactly when `isValidPixKey` returns + * `false` for the value. * * @example * ```typescript @@ -82,35 +79,17 @@ const resolvePhoneKey = (trimmed: string): PixKeyInfo | null => { * * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/II_ManualdePadroesparaIniciacaodoPix.pdf * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/API-DICT.html - * DICT (Diretório de Identificadores de Contas Transacionais) API specification, key format - * reference. + * DICT (Diretório de Identificadores de Contas Transacionais) API specification 2.12.1, key + * format reference. Tag "Chave", type `EMAIL`: "E-mail deve possuir no máximo 77 caracteres e + * deve ser em minúsculo", with the pattern this library applies verbatim, which has had no `&` + * since version 2.6.0. * @see Official: https://github.com/bacen/pix-api * Pix (SPI) OpenAPI spec. */ export const getPixKeyInfo = (value: string): PixKeyInfo | null => { - if (typeof value !== "string") return null; - - const trimmed = value.trim(); - - if (EVP_REGEX.test(trimmed)) return { type: "evp", value: trimmed.toLowerCase() }; - - if (trimmed.includes("@")) { - const email = trimmed.toLowerCase(); - - return isValidEmail(email) && email.length <= EMAIL_MAX_LENGTH - ? { type: "email", value: email } - : null; - } - - if (isValidCnpj(trimmed, { version: 2 })) { - return { type: "cnpj", value: parseCnpj(trimmed, { version: 2 }) }; - } - - if (CPF_SYNTAX_REGEX.test(trimmed)) { - const digits = sanitizeToDigits(trimmed); + const type = detectPixKeyType(value); - if (isValidCpf(digits)) return { type: "cpf", value: digits }; - } + if (type === null) return null; - return resolvePhoneKey(trimmed); + return { type, value: NORMALIZERS[type](value.trim()) }; }; diff --git a/src/get-pix-payload-info/get-pix-payload-info.test.ts b/src/get-pix-payload-info/get-pix-payload-info.test.ts index 4c4a23873..871664a06 100644 --- a/src/get-pix-payload-info/get-pix-payload-info.test.ts +++ b/src/get-pix-payload-info/get-pix-payload-info.test.ts @@ -5,6 +5,7 @@ import { cpfs } from "../_internals/test/arbitraries"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; import { generateCpf } from "../generate-cpf/generate-cpf"; import { generatePixPayload } from "../generate-pix-payload/generate-pix-payload"; +import { isValidPixPayload } from "../is-valid-pix-payload/is-valid-pix-payload"; import { type PixPayloadInfo, type PixPointOfInitiation, @@ -20,12 +21,31 @@ const BACEN_DYNAMIC = const BACEN_COMPOSITE = "00020101021226700014br.gov.bcb.pix2548pix.example.com/8b3da2f39a4140d1a91abd93113bd4415204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***80740014br.gov.bcb.pix2552pix.example.com/rec/2353c790eefb11eaadc10242ac1200026304FB42"; -const BRCODE_MANUAL = +const BRCODE_MANUAL_AS_PUBLISHED = "00020104141234567890123426580014BR.GOV.BCB.PIX0136123e4567-e12b-12d1-a456-42665544000027300012BR.COM.OUTRO011001234567895204000053039865406123.455802BR5917NOME DO RECEBEDOR6008BRASILIA61087007490062190515RP12345678-201980390012BR.COM.OUTRO01190123.ABCD.3456.WXYZ6304AD38"; -const COMMUNITY_STATIC = +const COMMUNITY_STATIC_AS_PUBLISHED = "00020126580014br.gov.bcb.pix0136bee05743-4291-4f3c-9259-595df1307ba1520400005303986540510.005802BR5914Alexandre Lima6019Presidente Prudente62180514Um-Id-Qualquer6304D475"; +const withNewCrc = (payload: string): string => { + const withoutCrc = payload.slice(0, -4); + + return withoutCrc + crc16Ccitt(withoutCrc); +}; + +// The same payloads with the "-" dropped from the txid (§2.6.2 allows letters and digits only) +// and, for the community one, the city cut to the 15 characters of object 60. +const BRCODE_MANUAL = withNewCrc( + BRCODE_MANUAL_AS_PUBLISHED.replace("62190515RP12345678-2019", "62180514RP123456782019"), +); + +const COMMUNITY_STATIC = withNewCrc( + COMMUNITY_STATIC_AS_PUBLISHED.replace( + "6019Presidente Prudente62180514Um-Id-Qualquer", + "6010Presidente62160512UmIdQualquer", + ), +); + const KEY_MARKED_SINGLE_USE = "00020101021226330014br.gov.bcb.pix0111123456789095204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***63043CAC"; @@ -44,7 +64,20 @@ const tlv = (id: string, value: string): string => const hasValidCrc = (payload: string): boolean => crc16Ccitt(payload.slice(0, -4)) === payload.slice(-4); -const buildPayload = (merchantAccountInformation: string, additionalData?: string): string => { +const ABSENT_TXID = tlv("62", tlv("05", "***")); + +const A25 = "A".repeat(25); + +const NO_TXID = tlv("01", "***"); + +const DYNAMIC_TXID = tlv("05", "ABC123"); + +const A26 = "A".repeat(26); + +const buildPayload = ( + merchantAccountInformation: string, + additionalData: string | null = tlv("05", "***"), +): string => { const withoutCrc = [ tlv("00", "01"), tlv("26", merchantAccountInformation), @@ -53,7 +86,7 @@ const buildPayload = (merchantAccountInformation: string, additionalData?: strin tlv("58", "BR"), tlv("59", "Fulano de Tal"), tlv("60", "BRASILIA"), - additionalData === undefined ? "" : tlv("62", additionalData), + additionalData === null ? "" : tlv("62", additionalData), "6304", ].join(""); @@ -64,22 +97,29 @@ const MERCHANT_ACCOUNT_INFORMATION = tlv("00", "br.gov.bcb.pix") + tlv("01", "12 const WITHDRAWAL_FACILITATOR_ISPB = "12345678"; -const buildWithdrawalPayload = (fss: string, amount?: string): string => { +const buildPayloadWithOptionalAmount = ( + merchantAccountInformation: string, + amount?: string, +): string => { const withoutCrc = [ tlv("00", "01"), - tlv("26", MERCHANT_ACCOUNT_INFORMATION + tlv("03", fss)), + tlv("26", merchantAccountInformation), tlv("52", "0000"), tlv("53", "986"), amount === undefined ? "" : tlv("54", amount), tlv("58", "BR"), tlv("59", "Fulano de Tal"), tlv("60", "BRASILIA"), + ABSENT_TXID, "6304", ].join(""); return withoutCrc + crc16Ccitt(withoutCrc); }; +const buildWithdrawalPayload = (fss: string, amount?: string): string => + buildPayloadWithOptionalAmount(MERCHANT_ACCOUNT_INFORMATION + tlv("03", fss), amount); + const buildPayloadWithMerchantAccountInformationTag = (tag: string): string => { const withoutCrc = [ tlv("00", "01"), @@ -89,6 +129,7 @@ const buildPayloadWithMerchantAccountInformationTag = (tag: string): string => { tlv("58", "BR"), tlv("59", "Fulano de Tal"), tlv("60", "BRASILIA"), + ABSENT_TXID, "6304", ].join(""); @@ -103,6 +144,7 @@ const buildPayloadWithoutCountryCode = (): string => { tlv("53", "986"), tlv("59", "Fulano de Tal"), tlv("60", "BRASILIA"), + ABSENT_TXID, "6304", ].join(""); @@ -117,6 +159,7 @@ const buildPayloadBody = (merchantCity: string, crcTag: string): string => tlv("58", "BR") + tlv("59", "Fulano de Tal") + tlv("60", merchantCity) + + ABSENT_TXID + crcTag; const buildPayloadWithCrcTag = (crcTag: string): string => { @@ -125,27 +168,14 @@ const buildPayloadWithCrcTag = (crcTag: string): string => { return withoutCrc + crc16Ccitt(withoutCrc); }; -const buildPayloadWithAmount = (amount: string): string => { - const withoutCrc = [ - tlv("00", "01"), - tlv("26", MERCHANT_ACCOUNT_INFORMATION), - tlv("52", "0000"), - tlv("53", "986"), - tlv("54", amount), - tlv("58", "BR"), - tlv("59", "Fulano de Tal"), - tlv("60", "BRASILIA"), - "6304", - ].join(""); - - return withoutCrc + crc16Ccitt(withoutCrc); -}; +const buildPayloadWithAmount = (amount: string): string => + buildPayloadWithOptionalAmount(MERCHANT_ACCOUNT_INFORMATION, amount); const DYNAMIC_URL = "pix.example.com/qr/v2/1234"; const buildDynamicPayloadWithAmount = (amount: string): string => { const location = tlv("00", "br.gov.bcb.pix") + tlv("25", DYNAMIC_URL); - const withoutCrc = `${tlv("00", "01")}${tlv("01", "12")}${tlv("26", location)}${tlv("52", "0000")}${tlv("53", "986")}${tlv("54", amount)}${tlv("58", "BR")}${tlv("59", "Fulano de Tal")}${tlv("60", "BRASILIA")}6304`; + const withoutCrc = `${tlv("00", "01")}${tlv("01", "12")}${tlv("26", location)}${tlv("52", "0000")}${tlv("53", "986")}${tlv("54", amount)}${tlv("58", "BR")}${tlv("59", "Fulano de Tal")}${tlv("60", "BRASILIA")}${ABSENT_TXID}6304`; return withoutCrc + crc16Ccitt(withoutCrc); }; @@ -159,6 +189,7 @@ const buildPayloadWithMerchantName = (merchantName: string): string => { tlv("58", "BR"), tlv("59", merchantName), tlv("60", "BRASILIA"), + ABSENT_TXID, "6304", ].join(""); @@ -172,6 +203,15 @@ const buildPayloadWithMerchantCity = (merchantCity: string): string => { }; describe("getPixPayloadInfo", () => { + test("should read a dynamic payload whose 62-05 is filled, leaving the ignored txid out (§2.7)", () => { + const location = tlv("00", "br.gov.bcb.pix") + tlv("25", DYNAMIC_URL); + + expect(getPixPayloadInfo(buildPayload(location, DYNAMIC_TXID))).toStrictEqual( + getPixPayloadInfo(buildPayload(location)), + ); + expect(getPixPayloadInfo(buildPayload(location, DYNAMIC_TXID))).not.toHaveProperty("txid"); + }); + describe("should return null", () => { test("when it is an empty or blank string", () => { expect(getPixPayloadInfo("")).toBeNull(); @@ -249,9 +289,9 @@ describe("getPixPayloadInfo", () => { }); test("when the fss of a Pix Saque is not the 8 digits of an ISPB", () => { - expect(getPixPayloadInfo(buildWithdrawalPayload("1234567", "0.00"))).toBeNull(); - expect(getPixPayloadInfo(buildWithdrawalPayload("123456789", "0.00"))).toBeNull(); - expect(getPixPayloadInfo(buildWithdrawalPayload("1234567x", "0.00"))).toBeNull(); + expect(getPixPayloadInfo(buildWithdrawalPayload("1234567", "50.00"))).toBeNull(); + expect(getPixPayloadInfo(buildWithdrawalPayload("123456789", "50.00"))).toBeNull(); + expect(getPixPayloadInfo(buildWithdrawalPayload("1234567x", "50.00"))).toBeNull(); }); test("when the fss of a Pix Saque is written next to a PSP location", () => { @@ -263,9 +303,51 @@ describe("getPixPayloadInfo", () => { }); test("when the additional data template is malformed", () => { - const merchantAccountInformation = tlv("00", "br.gov.bcb.pix") + tlv("01", "some-key"); + expect(getPixPayloadInfo(buildPayload(MERCHANT_ACCOUNT_INFORMATION, "9"))).toBeNull(); + }); + + test("when the additional data template is absent, although the manual has 62-05 always present", () => { + expect(getPixPayloadInfo(buildPayload(MERCHANT_ACCOUNT_INFORMATION))).not.toBeNull(); + expect(getPixPayloadInfo(buildPayload(MERCHANT_ACCOUNT_INFORMATION, null))).toBeNull(); + }); + + test("when the additional data template carries no txid (62-05)", () => { + expect(getPixPayloadInfo(buildPayload(MERCHANT_ACCOUNT_INFORMATION, NO_TXID))).toBeNull(); + }); + + test("when the txid is not 1 to 25 letters and digits", () => { + const withTxid = (txid: string): string => + buildPayload(MERCHANT_ACCOUNT_INFORMATION, tlv("05", txid)); + + expect(getPixPayloadInfo(withTxid(A25))?.txid).toBe(A25); + expect(getPixPayloadInfo(withTxid(A26))).toBeNull(); + expect(getPixPayloadInfo(withTxid("Um-Id-Qualquer"))).toBeNull(); + expect(getPixPayloadInfo(withTxid("RP12345678 2019"))).toBeNull(); + expect(getPixPayloadInfo(withTxid("**"))).toBeNull(); + expect(getPixPayloadInfo(withTxid("****"))).toBeNull(); + }); + + test("when the key is not written in the DICT form", () => { + const withKey = (key: string): string => + buildPayload(tlv("00", "br.gov.bcb.pix") + tlv("01", key)); - expect(getPixPayloadInfo(buildPayload(merchantAccountInformation, "9"))).toBeNull(); + expect(getPixPayloadInfo(withKey("12345678909"))?.key).toBe("12345678909"); + expect(getPixPayloadInfo(withKey("123.456.789-09"))).toBeNull(); + expect(getPixPayloadInfo(withKey("11987654321"))).toBeNull(); + expect(getPixPayloadInfo(withKey("Fulano@Example.com"))).toBeNull(); + expect(getPixPayloadInfo(withKey("123E4567-E12B-12D1-A456-426655440000"))).toBeNull(); + expect(getPixPayloadInfo(withKey("some-key"))).toBeNull(); + expect(getPixPayloadInfo(withKey(" 12345678909"))).toBeNull(); + }); + + test("when the PSP location is longer than 77 characters", () => { + const withUrl = (url: string): string => + buildPayload(tlv("00", "br.gov.bcb.pix") + tlv("25", url)); + const url77 = `pix.example.com/${"a".repeat(61)}`; + + expect(url77).toHaveLength(77); + expect(getPixPayloadInfo(withUrl(url77))?.url).toBe(url77); + expect(getPixPayloadInfo(withUrl(`${url77}a`))).toBeNull(); }); test("when a merchant account information template is malformed TLV, without throwing", () => { @@ -301,11 +383,26 @@ describe("getPixPayloadInfo", () => { expect(getPixPayloadInfo(buildPayloadWithAmount("1.234"))).toBeNull(); }); - test("when a key payload states a transaction amount of zero without the fss of a Pix Saque", () => { + test("when a key payload states a transaction amount of zero", () => { expect(hasValidCrc(buildPayloadWithAmount("0.00"))).toBe(true); expect(getPixPayloadInfo(buildPayloadWithAmount("0.00"))).toBeNull(); expect(getPixPayloadInfo(buildPayloadWithAmount("0"))).toBeNull(); expect(getPixPayloadInfo(buildPayloadWithAmount("0.0"))).toBeNull(); + expect(getPixPayloadInfo(buildPayloadWithAmount("0."))).toBeNull(); + }); + + test("when the merchant name is longer than 25 characters", () => { + expect(getPixPayloadInfo(buildPayloadWithMerchantName(A25))?.merchantName).toBe( + "A".repeat(25), + ); + expect(getPixPayloadInfo(buildPayloadWithMerchantName(A26))).toBeNull(); + }); + + test("when the merchant city is longer than 15 characters", () => { + expect(getPixPayloadInfo(buildPayloadWithMerchantCity("Presidente Prud"))?.merchantCity).toBe( + "Presidente Prud", + ); + expect(getPixPayloadInfo(buildPayloadWithMerchantCity("Presidente Prudente"))).toBeNull(); }); test("when the merchant name is present but empty", () => { @@ -318,10 +415,19 @@ describe("getPixPayloadInfo", () => { }); describe("should parse a static payload", () => { - test("should ignore the transaction amount and the txid of a dynamic payload, which belong to the PSP location", () => { + test("should read a payload surrounded by whitespace, as pasted from a Pix copia e cola", () => { + expect(getPixPayloadInfo(` \t${BACEN_STATIC}\n `)).toStrictEqual({ + key: "123e4567-e12b-12d1-a456-426655440000", + merchantName: "Fulano de Tal", + merchantCity: "BRASILIA", + pointOfInitiation: "static", + }); + }); + + test("should ignore the transaction amount of a dynamic payload, which belongs to the PSP location", () => { expect( getPixPayloadInfo( - "00020101021226480014br.gov.bcb.pix2526pix.example.com/qr/v2/123452040000530398654041.005802BR5901A6001B62100506ABC1236304C7F9", + "00020101021226480014br.gov.bcb.pix2526pix.example.com/qr/v2/123452040000530398654041.005802BR5901A6001B62070503***63042636", ), ).toEqual({ url: "pix.example.com/qr/v2/1234", @@ -331,15 +437,6 @@ describe("getPixPayloadInfo", () => { }); }); - test("should accept a transaction amount of zero in a dynamic payload, whose amount the PSP location settles", () => { - expect(getPixPayloadInfo(buildDynamicPayloadWithAmount("0.00"))).toEqual({ - url: DYNAMIC_URL, - merchantName: "Fulano de Tal", - merchantCity: "BRASILIA", - pointOfInitiation: "dynamic", - }); - }); - test("from the static QR Code example in the Bacen 'Manual de Padrões para Iniciação do Pix', with no key of its own for a field the payload does not carry", () => { expect(getPixPayloadInfo(BACEN_STATIC)).toStrictEqual({ key: "123e4567-e12b-12d1-a456-426655440000", @@ -349,27 +446,40 @@ describe("getPixPayloadInfo", () => { }); }); - test("for a Pix Saque BR Code, reading back the fss (26-03) of §2.6 with a transaction amount of zero", () => { + test("for a Pix Saque BR Code, reading back the fss (26-03) of §2.6 with the amount of the withdrawal", () => { expect( - getPixPayloadInfo(buildWithdrawalPayload(WITHDRAWAL_FACILITATOR_ISPB, "0.00")), + getPixPayloadInfo(buildWithdrawalPayload(WITHDRAWAL_FACILITATOR_ISPB, "50.00")), ).toEqual({ key: "12345678909", withdrawalFacilitator: "12345678", merchantName: "Fulano de Tal", merchantCity: "BRASILIA", - amount: 0, + amount: 50, pointOfInitiation: "static", }); }); - test("for a Pix Saque BR Code whose amount is written as the plain '0' of the BR Code field table", () => { - expect(getPixPayloadInfo(buildWithdrawalPayload(WITHDRAWAL_FACILITATOR_ISPB, "0"))).toEqual({ - key: "12345678909", - withdrawalFacilitator: "12345678", + test("for a Pix Saque BR Code with a transaction amount of zero, written '0.00' as the Pix API has a saque state it or '0' as in the Manual do BR Code field table", () => { + for (const amount of ["0.00", "0"]) { + expect( + getPixPayloadInfo(buildWithdrawalPayload(WITHDRAWAL_FACILITATOR_ISPB, amount)), + ).toEqual({ + key: "12345678909", + withdrawalFacilitator: "12345678", + merchantName: "Fulano de Tal", + merchantCity: "BRASILIA", + amount: 0, + pointOfInitiation: "static", + }); + } + }); + + test("for a dynamic payload with a transaction amount of zero, which the PSP location settles and the payer ignores", () => { + expect(getPixPayloadInfo(buildDynamicPayloadWithAmount("0.00"))).toEqual({ + url: DYNAMIC_URL, merchantName: "Fulano de Tal", merchantCity: "BRASILIA", - amount: 0, - pointOfInitiation: "static", + pointOfInitiation: "dynamic", }); }); @@ -397,28 +507,33 @@ describe("getPixPayloadInfo", () => { expect(getPixPayloadInfo(BACEN_STATIC)).not.toHaveProperty("txid"); }); - test("with an amount and a txid, as in a widely published community example", () => { + test("with an amount and a txid, as in a widely published community example brought within the Pix manual", () => { expect(getPixPayloadInfo(COMMUNITY_STATIC)).toEqual({ key: "bee05743-4291-4f3c-9259-595df1307ba1", merchantName: "Alexandre Lima", - merchantCity: "Presidente Prudente", + merchantCity: "Presidente", amount: 10, - txid: "Um-Id-Qualquer", + txid: "UmIdQualquer", pointOfInitiation: "static", }); }); - test("picking the Pix arrangement out of the multi-arrangement payload from the 'Manual do BR Code' §2.2", () => { + test("picking the Pix arrangement out of the multi-arrangement payload from the 'Manual do BR Code' §2.2, with a txid the Pix manual allows", () => { expect(getPixPayloadInfo(BRCODE_MANUAL)).toEqual({ key: "123e4567-e12b-12d1-a456-426655440000", merchantName: "NOME DO RECEBEDOR", merchantCity: "BRASILIA", amount: 123.45, - txid: "RP12345678-2019", + txid: "RP123456782019", pointOfInitiation: "static", }); }); + test("rejecting the payloads as published, whose txid and city the Pix manual does not allow", () => { + expect(getPixPayloadInfo(BRCODE_MANUAL_AS_PUBLISHED)).toBeNull(); + expect(getPixPayloadInfo(COMMUNITY_STATIC_AS_PUBLISHED)).toBeNull(); + }); + test("when the merchant account information sits at the last valid id (51), not just at the usual 26", () => { expect(getPixPayloadInfo(buildPayloadWithMerchantAccountInformationTag("51"))).toEqual({ key: "12345678909", @@ -438,6 +553,11 @@ describe("getPixPayloadInfo", () => { expect(getPixPayloadInfo(buildPayloadWithAmount("100"))?.amount).toBe(100); }); + test("accepting a transaction amount with the decimal mark and no decimals, an EMV example", () => { + expect(getPixPayloadInfo(buildPayloadWithAmount("98."))?.amount).toBe(98); + expect(getPixPayloadInfo(buildPayloadWithAmount("98.7"))?.amount).toBe(98.7); + }); + test("without a key property when the payload is dynamic (carries a url instead)", () => { expect(getPixPayloadInfo(BACEN_DYNAMIC)).not.toHaveProperty("key"); }); @@ -446,7 +566,7 @@ describe("getPixPayloadInfo", () => { expect(getPixPayloadInfo(BACEN_STATIC)).not.toHaveProperty("url"); }); - test("without a txid property when the payload carries no additional data template at all", () => { + test("without a txid property when the payload carries the *** marker", () => { expect(getPixPayloadInfo(buildPayload(MERCHANT_ACCOUNT_INFORMATION))).not.toHaveProperty( "txid", ); @@ -579,6 +699,38 @@ describe("getPixPayloadInfo", () => { ); }); + test("should return null exactly when isValidPixPayload returns false", () => { + const generated = fc + .tuple(names, fc.uuid(), fc.option(fc.stringMatching(/^[1-9]\d{0,3}\.\d{2}$/))) + .map(([merchantName, key, amount]) => + generatePixPayload({ + key, + merchantName, + merchantCity: "BRASILIA", + ...(amount === null ? {} : { amount: Number(amount) }), + }), + ); + const fixtures = fc.constantFrom( + BACEN_STATIC, + BACEN_DYNAMIC, + BACEN_COMPOSITE, + BRCODE_MANUAL, + BRCODE_MANUAL_AS_PUBLISHED, + COMMUNITY_STATIC, + COMMUNITY_STATIC_AS_PUBLISHED, + KEY_MARKED_SINGLE_USE, + ); + const input = fc.oneof(generated, fixtures, fc.string(), fc.anything()); + + fc.assert( + fc.property(input, (value) => { + const isValid = isValidPixPayload(value as string); + + expect(getPixPayloadInfo(value as string) === null).toBe(!isValid); + }), + ); + }); + test("should never throw and always return a BR Code or null", () => { fc.assert( fc.property(fc.anything(), (value) => { diff --git a/src/get-pix-payload-info/get-pix-payload-info.ts b/src/get-pix-payload-info/get-pix-payload-info.ts index 8cca8d94b..764d6d630 100644 --- a/src/get-pix-payload-info/get-pix-payload-info.ts +++ b/src/get-pix-payload-info/get-pix-payload-info.ts @@ -1,40 +1,34 @@ import { PIX_ABSENT_TXID, PIX_ADDITIONAL_DATA_ID, - PIX_COUNTRY_CODE, - PIX_COUNTRY_CODE_ID, - PIX_CRC_LENGTH, - PIX_CRC_TAG, PIX_DESCRIPTION_ID, PIX_DYNAMIC_POINT_OF_INITIATION, - PIX_GUI, - PIX_GUI_ID, PIX_KEY_ID, - PIX_MERCHANT_ACCOUNT_INFORMATION_FIRST_ID, - PIX_MERCHANT_ACCOUNT_INFORMATION_LAST_ID, - PIX_MERCHANT_CATEGORY_CODE_ID, PIX_MERCHANT_CITY_ID, PIX_MERCHANT_NAME_ID, - PIX_PAYLOAD_FORMAT_INDICATOR, - PIX_PAYLOAD_FORMAT_INDICATOR_ID, PIX_POINT_OF_INITIATION_ID, - PIX_STATIC_POINT_OF_INITIATION, PIX_TRANSACTION_AMOUNT_ID, - PIX_TRANSACTION_AMOUNT_MAX_LENGTH, - PIX_TRANSACTION_CURRENCY, - PIX_TRANSACTION_CURRENCY_ID, PIX_TXID_ID, PIX_URL_ID, PIX_WITHDRAWAL_FACILITATOR_ID, } from "../_internals/constants/pix"; -import { crc16Ccitt } from "../_internals/crc16-ccitt/crc16-ccitt"; -import { isValidPixUrl } from "../_internals/is-valid-pix-url/is-valid-pix-url"; +import { findPixMerchantAccountInformation } from "../_internals/find-pix-merchant-account-information/find-pix-merchant-account-information"; import { type TlvFields, parseTlv } from "../_internals/parse-tlv/parse-tlv"; +import { isValidPixPayload } from "../is-valid-pix-payload/is-valid-pix-payload"; /** - * How a Pix BR Code is meant to be presented for payment: `"dynamic"` when it carries a PSP - * location or when the "Point of Initiation Method" object (`01`) is `"12"`, the value the - * Manual do BR Code reads as "só pode ser utilizado uma vez"; `"static"` otherwise. + * `"dynamic"` or `"static"`, from two different signals of a BR Code: + * - a PSP location in 26-25, which is what makes a QR Code dynamic in the Manual de Padrões + * para Iniciação do Pix (§2.4.2: it "é configurado com uma URL que é acessada no momento de + * sua leitura"), as opposed to the static one that carries the key (§2.4.1); + * - the "Point of Initiation Method" (`01`) set to `"12"`, which the EMV® QRCPS-MPM uses "when + * a new QR Code is shown for each transaction" (`"11"`: "the same QR Code is shown for more + * than one transaction") and the Pix manual reads as "não deve ser iniciado mais de um + * pagamento com este mesmo QR Code" (§2.7.2). + * + * It is `"dynamic"` when either is present and `"static"` when neither is, so a payload that + * carries a key and `01` = `"12"` (a static QR Code in the §2.4 sense, marked single use) is + * reported as `"dynamic"`. `url` and `key` tell the two §2.4 kinds apart. */ export type PixPointOfInitiation = "static" | "dynamic"; @@ -59,194 +53,72 @@ export type PixPayloadInfo = { amount?: number; /** Transaction ID, absent when the payload carries the `***` marker. */ txid?: string; - /** Whether the payload is presented as a single use one ("dynamic") or not ("static"). */ + /** + * `"dynamic"` when the payload carries a PSP location (`url`) or marks itself single use + * with `01` = `"12"`, `"static"` otherwise; see `PixPointOfInitiation`. + */ pointOfInitiation: PixPointOfInitiation; }; -const AMOUNT_REGEX = /^\d+(?:\.\d{1,2})?$/; - -const WITHDRAWAL_FACILITATOR_REGEX = /^\d{8}$/; - -const CRC_TAG_LENGTH = PIX_CRC_TAG.length + PIX_CRC_LENGTH; - -const findMerchantAccountInformation = (fields: TlvFields): TlvFields | null => { - for ( - let id = PIX_MERCHANT_ACCOUNT_INFORMATION_FIRST_ID; - id <= PIX_MERCHANT_ACCOUNT_INFORMATION_LAST_ID; - id++ - ) { - const template = fields[id.toString()]; - - if (template === undefined) continue; - - const objects = parseTlv(template); - - if (objects?.[PIX_GUI_ID]?.toLowerCase() === PIX_GUI) return objects; - } - - return null; -}; - -const isValidCrc = (payload: string): boolean => { - const checksum = payload.slice(-PIX_CRC_LENGTH); - - if (payload.slice(-CRC_TAG_LENGTH, -PIX_CRC_LENGTH) !== PIX_CRC_TAG) return false; - - // A checksum that is not four uppercase hexadecimal digits can never equal crc16Ccitt's - // always-hexadecimal output, so the comparison below turns it down on its own. - return crc16Ccitt(payload.slice(0, -PIX_CRC_LENGTH)) === checksum.toUpperCase(); -}; - -const resolvePointOfInitiation = (fields: TlvFields): string | undefined | null => { - const pointOfInitiation = fields[PIX_POINT_OF_INITIATION_ID]; - - if ( - pointOfInitiation !== undefined && - pointOfInitiation !== PIX_STATIC_POINT_OF_INITIATION && - pointOfInitiation !== PIX_DYNAMIC_POINT_OF_INITIATION - ) { - return null; - } - - return pointOfInitiation; -}; - -const isValidAmount = ( - amount: string | undefined, - { url, withdrawalFacilitator }: MerchantKeyInfo, -): boolean => { - if (amount === undefined) return true; - - if (!AMOUNT_REGEX.test(amount) || amount.length > PIX_TRANSACTION_AMOUNT_MAX_LENGTH) return false; - - return url !== undefined || withdrawalFacilitator !== undefined || Number(amount) > 0; -}; - -type MerchantKeyInfo = { - key?: string; - url?: string; - description?: string; - withdrawalFacilitator?: string; -}; - -const resolveMerchantKeyInfo = (fields: TlvFields): MerchantKeyInfo | null => { - const merchantAccountInformation = findMerchantAccountInformation(fields); - - if (!merchantAccountInformation) return null; - - const key = merchantAccountInformation[PIX_KEY_ID]; - const url = merchantAccountInformation[PIX_URL_ID]; - const description = merchantAccountInformation[PIX_DESCRIPTION_ID]; - const withdrawalFacilitator = merchantAccountInformation[PIX_WITHDRAWAL_FACILITATOR_ID]; - - if ((key === undefined) === (url === undefined)) return null; - if (key !== undefined && !key) return null; - if (url !== undefined && !isValidPixUrl(url)) return null; - if (withdrawalFacilitator !== undefined && url !== undefined) return null; - if ( - withdrawalFacilitator !== undefined && - !WITHDRAWAL_FACILITATOR_REGEX.test(withdrawalFacilitator) - ) { - return null; - } - - return { key, url, description, withdrawalFacilitator }; -}; - -const resolveTxid = (fields: TlvFields): string | undefined | null => { - const additionalData = fields[PIX_ADDITIONAL_DATA_ID]; - - if (additionalData === undefined) return undefined; - - const objects = parseTlv(additionalData); - - if (!objects) return null; - - return objects[PIX_TXID_ID]; -}; - -type OptionalPixFields = MerchantKeyInfo & { - amount?: string; - txid?: string; - pointOfInitiation?: string; -}; - -const buildPixPayload = ( - merchantName: string, - merchantCity: string, - optional: OptionalPixFields, -): PixPayloadInfo => { - const { key, url, description, withdrawalFacilitator, amount, txid, pointOfInitiation } = - optional; - const isDynamic = url !== undefined || pointOfInitiation === PIX_DYNAMIC_POINT_OF_INITIATION; - const pix: PixPayloadInfo = { - merchantName, - merchantCity, - pointOfInitiation: isDynamic ? "dynamic" : "static", - }; - - if (key !== undefined) pix.key = key; - if (url !== undefined) pix.url = url; - if (description !== undefined) pix.description = description; - if (withdrawalFacilitator !== undefined) pix.withdrawalFacilitator = withdrawalFacilitator; - - if (amount !== undefined && url === undefined) pix.amount = Number(amount); - if (txid !== undefined && txid !== PIX_ABSENT_TXID && url === undefined) pix.txid = txid; - - return pix; -}; +/** + * Reads a TLV string `isValidPixPayload` has already found well-formed. The spread turns the + * `null` `parseTlv` gives for a malformed string, which cannot happen here, into `{}`, so the + * result is typed as the objects without a branch nothing could ever take. + * + * @param {string} value - A TLV string `isValidPixPayload` has already parsed. + * @returns {TlvFields} The objects of the string, keyed by ID. + */ +const readTlv = (value: string): TlvFields => ({ ...parseTlv(value) }); /** * Parses a Pix BR Code payload, the string behind a Pix QR Code and behind "Pix copia e cola". * - * The payload is rejected when its TLV (tag-length-value) structure is malformed, when the CRC - * does not match, when a mandatory object is missing or malformed, or when none of the - * "Merchant Account Information" templates (IDs 26 to 51) carries the `br.gov.bcb.pix` GUI - * together with either a key (static) or a URL (dynamic). + * The payload is read only when `isValidPixPayload` accepts it, under the rules of the Manual + * de Padrões para Iniciação do Pix documented there: a Pix key in the DICT form (§2.5.1) or a + * PSP location of at most 77 characters (§2.5.2), a merchant name of at most 25 characters and + * a merchant city of at most 15, and an "Additional Data Field Template" (62) that always + * carries the `txid` (62-05), "sempre presente em um BR Code" (§2.6, footnote 21), either the + * `***` marker or 1 to 25 letters and digits (§2.6.2). Next to a PSP location its content is + * ignored (§2.7: "Os campos Valor e Identificador da Transação (txid) não devem ser preenchidos + * no QR Code dinâmico. Se preenchidos, seu conteúdo deve ser ignorado"), so `txid` is left out + * there, as `amount` is, and for the `***` marker. * - * The Pix key itself is not validated: the manual states a static QR Code can be generated - * with a key that no longer exists in the DICT, so key ownership is only settled at payment - * time. The "Additional Data Field Template" (ID 62) is mandatory in the BR Code table but - * optional in the EMV® specification it refers to, so it is accepted when absent. The lengths - * the manual reserves for the merchant name (25), the merchant city (15) and the `txid` (25) - * are generator side limits, enforced by `generatePixPayload`; payloads in the wild routinely - * overrun them, so they are not enforced here, and neither is the 77 character limit of the - * Pix key field (26-01). + * Whether the key is registered in the DICT is only settled at payment time, so a key in the + * right form is read even when no account holds it. * * Unreserved Templates (IDs 80 to 99) are ignored. The "QR Code composto" of Pix Automático * (Pix recorrente) writes its recurrence location in one of them: when such a payload also - * carries a payment location in 26-25, as the composite example of the Pix manual does, it is - * parsed here as an ordinary dynamic payload and its recurrence location is dropped, so a - * consumer that has to tell the two apart cannot rely on this parser. Only a payload with no - * Pix template at all in IDs 26 to 51 returns `null`. + * carries a key or a payment location in IDs 26 to 51, as the composite examples of §2.8 do, + * it is parsed here as an ordinary static or dynamic payload and its recurrence location is + * dropped, so a consumer that has to tell the two apart cannot rely on this parser. A + * composite that carries only the recurrence returns `null`. * - * The merchant account information must carry exactly one of a Pix key (26-01) or a PSP - * location (26-25); the location is checked with the same host and path rule - * `generatePixPayload` applies. The "Point of Initiation Method" object (`01`) is advisory, as - * the Manual do BR Code marks it `Uso: O` and only assigns a meaning to the value `"12"` - * ("Se o valor 12 estiver presente, significa que o BR Code só pode ser utilizado uma vez"): - * it may be absent from either shape, and only a value outside `{"11", "12"}` is rejected. - * `pointOfInitiation` is reported as `"dynamic"` when the payload carries a PSP location or - * when `01` is `"12"`, and as `"static"` otherwise. When the payload carries a PSP location the - * transaction amount (54) and the `txid` (62-05) are ignored, as the manual mandates, because - * the PSP location is the source of truth for both. + * The "Point of Initiation Method" object (`01`) is optional ("O campo BR Code 01 (Point of + * Initiation Method), opcional", §2.7.2) and, when present, `"11"` or `"12"`. `pointOfInitiation` + * is reported as `"dynamic"` when the payload carries a PSP location or when `01` is `"12"`, + * and as `"static"` otherwise. When the payload carries a PSP location its transaction amount + * (54) is left out, since §2.7 has the payer ignore it ("Se preenchidos, seu conteúdo deve ser + * ignorado"): the PSP location is the source of truth. * - * A payload built around a Pix key that carries the transaction amount (54) must state an - * amount greater than zero, unless it is a Pix Saque BR Code: §2.6 of the Pix manual puts the - * ISPB of the "facilitador de serviço de saque" in sub-object 26-03 (`fss`) of the same - * template this parser already reads, and states that "a presença do campo fss, com um ISPB - * válido […] indica que esse é um QR Code para Pix Saque", whose amount is settled at payment - * time. So `54` set to `"0"` or `"0.00"` is accepted together with `fss` and rejected without - * it; that rejection is a deliberate restriction of this library, not a rule of the manual, - * whose field table allows `"0"` in any payload. A `fss` that is not 8 digits is rejected, and - * so is a `fss` written next to a PSP location: §2.7 of the Manual de Padrões para Iniciação do - * Pix maps the dynamic QR Code to exactly two sub-objects, `00` (GUI) and `25` (URL), while - * `fss` belongs to the static template of §2.6, whose §2.6.1 states that "não há funcionalidade - * de Pix Troco para QR Codes estáticos, apenas para QR Codes dinâmicos". + * The transaction amount (54), when present, follows the EMV® QRCPS-MPM: digits with an + * optional `.` decimal mark that "may be present even if there are no decimals" (`"98."` reads + * as 98), at most two decimals and 13 characters. It may be zero (Manual do BR Code, Tabela 1: + * "Ex.: "0", "1.00", "123.99"") in a Pix Saque BR Code and next to a PSP location, the payloads + * the Pix API gives a zero amount to ("Para cobranças imediatas que representem um saque: deve + * apresentar o valor 0.00 (zero)"); a payload built around a key alone must state one greater + * than zero, as the EMV has it ("shall be different from zero"). §2.6 of the Pix manual puts the + * ISPB of the "facilitador de serviço de saque" in sub-object 26-03 (`fss`) and states that "a + * presença do campo fss, com um ISPB válido […] indica que esse é um QR Code para Pix Saque"; + * `withdrawalFacilitator` reads the `fss` back. A `fss` that is not 8 digits is + * rejected, and so is a `fss` written next to a PSP location: §2.7 of the Manual de Padrões + * para Iniciação do Pix maps the dynamic QR Code to exactly two sub-objects, `00` (GUI) and + * `25` (URL), while `fss` belongs to the static template of §2.6, whose §2.6.1 states that + * "não há funcionalidade de Pix Troco para QR Codes estáticos, apenas para QR Codes + * dinâmicos". * * @param {string} value - The BR Code payload to be parsed. - * @returns {PixPayloadInfo|null} The Pix data of the payload, or `null` when it is not a valid Pix - * BR Code. + * @returns {PixPayloadInfo|null} The Pix data of the payload, or `null` exactly when + * `isValidPixPayload` returns `false`. * * @example * ```typescript @@ -263,58 +135,49 @@ const buildPixPayload = ( * ``` * * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/spb_docs/ManualBRCode.pdf + * Manual do BR Code v2.0.1, Tabela 1, `54` Transaction Amount. * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/II_ManualdePadroesparaIniciacaodoPix.pdf + * Manual de Padrões para Iniciação do Pix v2.10.0, §2.6, §2.7 and the `valor.original` of the + * Pix API. + * @see Official: https://www.emvco.com/terms-of-use/?u=/wp-content/uploads/documents/EMVCo-Merchant-Presented-QR-Specification-v1-1.pdf + * EMV® QRCPS-MPM v1.1, cited by the Pix manual, "Transaction Amount (ID "54")": "If present, + * the Transaction Amount shall be different from zero [...] the "." character may be present + * even if there are no decimals"; its valid examples are "98.73", "98" and "98.". * @see Official: https://github.com/bacen/pix-api * Pix (SPI) OpenAPI spec. * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/API-DICT.html * DICT (Diretório de Identificadores de Contas Transacionais) API specification. */ export const getPixPayloadInfo = (value: string): PixPayloadInfo | null => { - if (typeof value !== "string") return null; - - const payload = value.trim(); - - // Stryker disable next-line ConditionalExpression,EqualityOperator: a payload this short has no room left for any of the mandatory fields checked below, so it can never parse to a non-null result even without this guard - if (payload.length <= CRC_TAG_LENGTH || !isValidCrc(payload)) return null; - - const fields = parseTlv(payload); - - if (!fields) return null; - - if (fields[PIX_PAYLOAD_FORMAT_INDICATOR_ID] !== PIX_PAYLOAD_FORMAT_INDICATOR) return null; - - const pointOfInitiation = resolvePointOfInitiation(fields); - - if (pointOfInitiation === null) return null; - - if (fields[PIX_MERCHANT_CATEGORY_CODE_ID] === undefined) return null; - if (fields[PIX_TRANSACTION_CURRENCY_ID] !== PIX_TRANSACTION_CURRENCY) return null; - if (fields[PIX_COUNTRY_CODE_ID]?.toUpperCase() !== PIX_COUNTRY_CODE) return null; - - const merchantName = fields[PIX_MERCHANT_NAME_ID]; - - if (merchantName === undefined || merchantName === "") return null; - - const merchantCity = fields[PIX_MERCHANT_CITY_ID]; - - if (merchantCity === undefined || merchantCity === "") return null; - - const merchantKeyInfo = resolveMerchantKeyInfo(fields); - - if (!merchantKeyInfo) return null; + if (!isValidPixPayload(value)) return null; + const fields = readTlv(value.trim()); + const merchantAccountInformation: TlvFields = { + ...findPixMerchantAccountInformation(fields), + }; + const url = merchantAccountInformation[PIX_URL_ID]; const amount = fields[PIX_TRANSACTION_AMOUNT_ID]; + // isValidPixPayload has checked that 62 is there and carries 62-05. + const txid = String(readTlv(String(fields[PIX_ADDITIONAL_DATA_ID]))[PIX_TXID_ID]); + const isDynamic = + url !== undefined || fields[PIX_POINT_OF_INITIATION_ID] === PIX_DYNAMIC_POINT_OF_INITIATION; + const pix: PixPayloadInfo = { + // isValidPixPayload has checked that both are there and not empty. + merchantName: String(fields[PIX_MERCHANT_NAME_ID]), + merchantCity: String(fields[PIX_MERCHANT_CITY_ID]), + pointOfInitiation: isDynamic ? "dynamic" : "static", + }; + const key = merchantAccountInformation[PIX_KEY_ID]; + const description = merchantAccountInformation[PIX_DESCRIPTION_ID]; + const withdrawalFacilitator = merchantAccountInformation[PIX_WITHDRAWAL_FACILITATOR_ID]; - if (!isValidAmount(amount, merchantKeyInfo)) return null; - - const txid = resolveTxid(fields); + if (key !== undefined) pix.key = key; + if (url !== undefined) pix.url = url; + if (description !== undefined) pix.description = description; + if (withdrawalFacilitator !== undefined) pix.withdrawalFacilitator = withdrawalFacilitator; - if (txid === null) return null; + if (amount !== undefined && url === undefined) pix.amount = Number(amount); + if (txid !== PIX_ABSENT_TXID && url === undefined) pix.txid = txid; - return buildPixPayload(merchantName, merchantCity, { - ...merchantKeyInfo, - amount, - txid, - pointOfInitiation, - }); + return pix; }; diff --git a/src/get-service-item/get-service-item.ts b/src/get-service-item/get-service-item.ts index 302fec387..9769a8e55 100644 --- a/src/get-service-item/get-service-item.ts +++ b/src/get-service-item/get-service-item.ts @@ -48,7 +48,9 @@ export type ServiceItem = { * ``` * * @see Official: https://www.planalto.gov.br/ccivil_03/leis/lcp/lcp116.htm - * Lei Complementar 116/2003, "Lista de serviços anexa". + * Lei Complementar 116/2003, "Lista de serviços anexa", last amended by Lei Complementar 183/2021 + * (subitem 11.05); the later LC 214/2025 and LC 218/2025 changed the body of the law, not the + * list. * @see Official: https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual * Sistema Nacional NFS-e, `ANEXO_B-NBS2-LISTA_SERVICO_NACIONAL`, sheet `LISTA.SERV.NAC.`: the * list in force in machine readable form, which `SERVICE_ITEM_DESCRIPTIONS` is generated from, diff --git a/src/index.test.ts b/src/index.test.ts index 5f82fd68d..0f4625d49 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -84,6 +84,7 @@ import { type IsValidPhoneOptions, type IsValidPixKeyOptions, type IsValidRegistroProfissionalParams, + type IsValidVinOptions, type LegalNature, type LegalNatureCategory, type LicensePlateFormat, @@ -421,6 +422,7 @@ describe("Public API", () => { IsValidPhoneOptions: IsValidPhoneOptions; IsValidPixKeyOptions: IsValidPixKeyOptions; IsValidRegistroProfissionalParams: IsValidRegistroProfissionalParams; + IsValidVinOptions: IsValidVinOptions; LegalNature: LegalNature; LegalNatureCategory: LegalNatureCategory; LicensePlateFormat: LicensePlateFormat; @@ -468,13 +470,13 @@ describe("Public API contract: never throws on bad input", () => { typeof value === "function" && !isErrorClass(name) && !NETWORK_ENTRY_POINTS.has(name), ) as [string, (...args: unknown[]) => unknown][]; - for (const [name, fn] of entries) { + for (const [name, utility] of entries) { for (const [label, value] of BAD_INPUTS) { test(`${name} should not throw for ${label}`, async () => { let thrown: unknown; try { - const result = fn(value); + const result = utility(value); if (result instanceof Promise) await result; } catch (error) { @@ -488,7 +490,7 @@ describe("Public API contract: never throws on bad input", () => { let thrown: unknown; try { - const result = fn("123", value); + const result = utility("123", value); if (result instanceof Promise) await result; } catch (error) { diff --git a/src/index.ts b/src/index.ts index 1c92f44a7..59afeb2d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -256,7 +256,7 @@ export { isValidRenavam } from "./is-valid-renavam/is-valid-renavam"; export { isValidServiceItem } from "./is-valid-service-item/is-valid-service-item"; export { isValidServicePhone } from "./is-valid-service-phone/is-valid-service-phone"; export { isValidSuframa } from "./is-valid-suframa/is-valid-suframa"; -export { isValidVin } from "./is-valid-vin/is-valid-vin"; +export { type IsValidVinOptions, isValidVin } from "./is-valid-vin/is-valid-vin"; export { isValidVoterId } from "./is-valid-voter-id/is-valid-voter-id"; export { parseBoleto } from "./parse-boleto/parse-boleto"; export { parseCaepf } from "./parse-caepf/parse-caepf"; diff --git a/src/is-business-day/is-business-day.test.ts b/src/is-business-day/is-business-day.test.ts index aab4eda88..550a231b5 100644 --- a/src/is-business-day/is-business-day.test.ts +++ b/src/is-business-day/is-business-day.test.ts @@ -91,6 +91,18 @@ describe("isBusinessDay", () => { expect(isBusinessDay(new Date(2025, 7, 17, 12), { stateCode: "SC" })).toBe(false); }); + it("should treat the Carnaval Tuesday as a non-business day in RJ even with includeOptional false, since Lei RJ nº 5.243/2008 declares it a feriado estadual, while the Monday stays optional", () => { + const rj = { stateCode: "RJ", includeOptional: false } as const; + + expect(isBusinessDay(new Date(2024, 1, 13, 12), rj)).toBe(false); + expect(isBusinessDay(new Date(2024, 1, 12, 12), rj)).toBe(true); + expect( + isBusinessDay(new Date(2024, 1, 13, 12), { stateCode: "SP", includeOptional: false }), + ).toBe(true); + expect(isBusinessDay(new Date(2008, 1, 5, 12), rj)).toBe(true); + expect(isBusinessDay(new Date(2009, 1, 24, 12), rj)).toBe(false); + }); + it("should treat Corpus Christi as a non-business day in the DF even with includeOptional false, since Lei distrital nº 72/1989 declares it a feriado", () => { expect( isBusinessDay(new Date(2024, 4, 30, 12), { stateCode: "DF", includeOptional: false }), @@ -110,6 +122,16 @@ describe("isBusinessDay", () => { expect(isBusinessDay(new Date(2024, 1, 13, 12), { includeOptional: false })).toBe(true); }); + it("should return false for Carnaval Monday by default and true when includeOptional is false, the ponto facultativo of Portaria MGI nº 8.617/2023 (2024-02-12) and Portaria MGI nº 11.460/2025 (2026-02-16), and a non-business day of Resolução CMN nº 4.880/2020, art. 6º", () => { + expect(isBusinessDay(new Date(2024, 1, 12, 12))).toBe(false); + expect(isBusinessDay(new Date(2024, 1, 12, 12), { includeOptional: false })).toBe(true); + expect(isBusinessDay(new Date(2026, 1, 16, 12))).toBe(false); + }); + + it("should keep Quarta-feira de Cinzas 2024-02-14 a business day, a ponto facultativo only until 14h", () => { + expect(isBusinessDay(new Date(2024, 1, 14, 12))).toBe(true); + }); + it("should still return false for a national (non-optional) holiday when includeOptional is false", () => { expect(isBusinessDay(new Date(2024, 0, 1, 12), { includeOptional: false })).toBe(false); }); diff --git a/src/is-business-day/is-business-day.ts b/src/is-business-day/is-business-day.ts index 77d1cda86..c30dc6a27 100644 --- a/src/is-business-day/is-business-day.ts +++ b/src/is-business-day/is-business-day.ts @@ -12,9 +12,9 @@ export type { StateCode } from "../_internals/constants/states"; export type BusinessDayOptions = { /** Two letter state code whose state holidays are also treated as non-business days (default: national holidays only). */ stateCode?: StateCode; - /** Whether optional-type holidays (`Holiday.type === "optional"`, e.g. Carnaval, Corpus Christi) count as non-business days (default: `true`). */ + /** Whether optional-type holidays (`Holiday.type === "optional"`: the federal pontos facultativos Carnaval Monday and Tuesday and Corpus Christi, and the state ones `getHolidays` lists) count as non-business days (default: `true`). */ includeOptional?: boolean; - /** Whether Saturday counts as a business day, the labour law count of Instrução Normativa MTP nº 2/2021, art. 14, I (default: `false`, the Monday to Friday banking count). */ + /** Whether Saturday counts as a business day, the payroll deadline count of Instrução Normativa MTP nº 2/2021, art. 14, I, "na contagem dos dias será incluído o sábado" (default: `false`, a Monday to Friday count). */ includeSaturday?: boolean; }; @@ -33,12 +33,24 @@ const SATURDAY = 6; * specific local day, for the same reason documented in `isHoliday`. * * `options.includeOptional` defaults to `true`: holidays whose `Holiday.type` is - * `"optional"` (Carnaval and Corpus Christi) are treated as non-business days even though - * they are not statutory holidays. Pass `false` to only treat statutory (`"national"` and - * `"state"`) holidays as non-business days. + * `"optional"` (Carnaval Monday and Tuesday and Corpus Christi, the federal pontos facultativos + * `getHolidays` lists, plus the state pontos facultativos its table sources: Alagoas' 16 + * September of 2020 to 2023, Amazonas' 8 December and Pernambuco's 6 March of 2008 and 2009) are + * treated as non-business days even though they are not + * statutory holidays. Pass `false` to only treat statutory (`"national"` and `"state"`) holidays as + * non-business days. The partial pontos facultativos of the federal calendar (the Quarta-feira de + * Cinzas morning, 28 October, the 24 and 31 December afternoons) are business days either way. * - * `options.includeSaturday` defaults to `false`, the Monday to Friday count banks and courts - * use. Pass `true` for the labour law count of the payroll deadline of CLT art. 459 § 1º ("até o + * `options.includeSaturday` defaults to `false`, a Monday to Friday count less the holidays + * above. That is not by itself the calendar of banks or of courts. The financial market also + * skips the Carnaval Monday and Tuesday and Corpus Christi (Resolução CMN nº 4.880/2020, art. 6º), + * which the default `includeOptional: true` covers, and a bank closes on the local holidays of its + * branch. The federal courts close as well on the days of Lei 5.010/1966, art. 62 (20 December + * to 6 January, Holy Wednesday to Easter Sunday, Carnaval Monday and Tuesday, 11 August, 1 and 2 + * November, 8 December), and a procedural deadline counts only dias úteis, CPC art. 216 treating + * as feriados "os sábados, os domingos e os dias em que não haja expediente forense", which + * depends on each court's own calendar; neither calendar is reproduced here. Pass `true` for the + * labour law count of the payroll deadline of CLT art. 459 § 1º ("até o * quinto dia útil do mês subsequente ao vencido"), which the labour inspection reads through * Instrução Normativa MTP nº 2/2021, art. 14, I: "na contagem dos dias será incluído o sábado, * excluindo-se o domingo e o feriado, inclusive o municipal". Sunday and holidays are still @@ -63,9 +75,12 @@ const SATURDAY = 6; * non-string value that stands for "no state" instead. `addBusinessDays`, `subBusinessDays` * and `differenceInBusinessDays` reject the same value with `null`. * - * Two state rules change what `includeOptional: false` answers. The Distrito Federal declares - * Corpus Christi a feriado (Lei distrital nº 72/1989, art. 1º parágrafo único), so with - * `stateCode: "DF"` it is typed `"state"` and still counts; and Santa Catarina's two holidays + * Three state rules change what `includeOptional: false` answers. The Distrito Federal and, from + * 2024 on, Maranhão declare Corpus Christi a feriado (Lei distrital nº 72/1989, art. 1º parágrafo + * único; Lei MA nº 11.539/2021), so with `stateCode: "DF"` or `"MA"` it is typed `"state"` and + * still counts; Rio de Janeiro declares the Carnaval + * Tuesday a feriado estadual (Lei RJ nº 5.243/2008, from 2009 on), so with `stateCode: "RJ"` it + * still counts while the Monday does not; and Santa Catarina's two holidays * are observed on the following Sunday when they fall Monday to Friday (Lei SC nº 18.531/2022), * so 11 August 2025, a Monday, is a business day there. * @@ -90,7 +105,8 @@ const SATURDAY = 6; * isBusinessDay(new Date(2024, 0, 6), { includeSaturday: true }); // true (labour law count) * isBusinessDay(new Date(2024, 8, 7), { includeSaturday: true }); // false (Independência, a holiday on a Saturday) * isBusinessDay(new Date(2024, 0, 7), { includeSaturday: true }); // false (Sunday is never included) - * isBusinessDay(new Date(2024, 1, 13)); // false (Carnaval, optional holiday, counted by default) + * isBusinessDay(new Date(2024, 1, 12)); // false (Carnaval Monday, optional holiday, counted by default) + * isBusinessDay(new Date(2024, 1, 13)); // false (Carnaval Tuesday, optional holiday, counted by default) * isBusinessDay(new Date(2024, 1, 13), { includeOptional: false }); // true * isBusinessDay(new Date(2024, 6, 9), { stateCode: "SP" }); // false (Revolução Constitucionalista) * isBusinessDay(new Date(2024, 6, 9)); // true (state holiday ignored without stateCode) @@ -108,7 +124,8 @@ const SATURDAY = 6; * Lei 10.607/2002, added Finados (2 November) and folded in Tiradentes (21 April), which had * been national since art. 3º of the Lei 1.266/1950 it revoked. * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l6802.htm - * Lei 6.802/1980, declared Nossa Senhora Aparecida a national holiday. + * Lei 6.802/1980, declared Nossa Senhora Aparecida a national holiday, listed from 1980 on; each + * national holiday is listed only for the years a federal norm declared it (see `getHolidays`). * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2023/lei/l14759.htm * Lei 14.759/2023, nationalized Dia da Consciência Negra from 2024. * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l9093.htm @@ -119,17 +136,43 @@ const SATURDAY = 6; * @see Official: https://www.planalto.gov.br/ccivil_03/decreto-lei/del5452.htm * CLT art. 459 § 1º (wording given by Lei 7.855/1989), the "quinto dia útil do mês subsequente ao * vencido" payroll deadline that `includeSaturday` exists for. - * @see Official: https://www.gov.br/trabalho-e-emprego/pt-br/assuntos/inspecao-do-trabalho/areas-de-atuacao/in-2-de-8-denovembro-de-2021.pdf - * Instrução Normativa MTP nº 2, de 8 de novembro de 2021, art. 14, I: the rule `includeSaturday` - * implements, verbatim "na contagem dos dias será incluído o sábado, excluindo-se o domingo e o - * feriado, inclusive o municipal". + * @see Official: https://www.bcb.gov.br/estabilidadefinanceira/exibenormativo?tipo=Resolu%C3%A7%C3%A3o%20CMN&numero=4880 + * Resolução CMN nº 4.880/2020, art. 6º: "Não são considerados dias úteis, para fins de operações + * praticadas no mercado financeiro e de prestação de informações ao Banco Central do Brasil, os + * sábados, domingos e feriados de âmbito nacional, bem como: I - a segunda-feira e a terça-feira + * de Carnaval; e II - o dia dedicado a Corpus Christi". The Quarta-feira de Cinzas is not listed, + * so it is a dia útil for the financial market as well. + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l5010.htm + * Lei 5.010/1966, art. 62: "Além dos fixados em lei, serão feriados na Justiça Federal, inclusive + * nos Tribunais Superiores: I - os dias compreendidos entre 20 de dezembro e 6 de janeiro, + * inclusive; II - os dias da Semana Santa, compreendidos entre a quarta-feira e o Domingo de + * Páscoa; III - os dias de segunda e terça-feira de Carnaval; IV - os dias 11 de agosto, 1º e 2 de + * novembro e 8 de dezembro". + * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2015-2018/2015/lei/l13105.htm + * CPC (Lei 13.105/2015), art. 216: "Além dos declarados em lei, são feriados, para efeito forense, + * os sábados, os domingos e os dias em que não haja expediente forense". + * @see Official: https://www.in.gov.br/web/dou/-/instrucao-normativa-359448244 + * Instrução Normativa MTP nº 2, de 8 de novembro de 2021 (DOU of 12/11/2021, in force from + * 10/12/2021), art. 14, I: the rule `includeSaturday` implements, verbatim "na contagem dos dias + * será incluído o sábado, excluindo-se o domingo e o feriado, inclusive o municipal". The caput + * frames it as guidance to the labour inspection on the payroll deadline alone: "Para efeito de + * orientação quanto ao prazo para o pagamento dos salários, os Auditores-Fiscais do Trabalho + * deverão observar o seguinte". No later act amends art. 14 (the retificação of 07/12/2021, IN + * GMTP/MTP nº 2 and nº 3/2022 and IN SIT/MTE nº 2/2025 touch other articles). Its inciso II asks + * for the wages to be "à disposição do empregado até o quinto dia útil" when paid through a bank, + * which does not open on a Saturday (Resolução CMN nº 4.880/2020, art. 6º); the IN does not settle + * that case. * @see Official: https://www.in.gov.br/web/dou/-/portaria-mgi-n-11.460-de-29-de-dezembro-de-2025-678388627 * Portaria MGI nº 11.460/2025, the federal executive's annual calendar of feriados nacionais and - * pontos facultativos: the source of three of the four Easter-derived entries, namely - * Sexta-feira Santa being observed nationally and Carnaval and Corpus Christi being ponto - * facultativo, which is what `includeOptional` switches on. The fourth, Páscoa, has no entry in + * pontos facultativos: the source of the Easter-derived entries but one, namely Sexta-feira Santa + * being observed nationally and the Carnaval Monday and Tuesday ("16 de fevereiro Carnaval (ponto + * facultativo); 17 de fevereiro Carnaval (ponto facultativo)") and Corpus Christi being ponto + * facultativo, which is what `includeOptional` switches on. The exception, Páscoa, has no entry in * the portaria; `getHolidays` derives Easter Sunday arithmetically with the Meeus/Jones/Butcher - * algorithm, and it never affects this function because Easter is always a Sunday. + * algorithm, and it never affects this function because Easter is always a Sunday. The 2024 and + * 2025 calendars, Portaria MGI nº 8.617/2023 (DOU of 28/12/2023) and Portaria MGI nº 9.783/2024 + * (DOU of 30/12/2024), list the two Carnaval days the same way, as ponto facultativo and never as + * feriado nacional (the `getHolidays` JSDoc links them). */ export const isBusinessDay = (value: Date, options?: BusinessDayOptions): boolean => { if (!isValidDate(value)) return false; diff --git a/src/is-holiday/is-holiday.ts b/src/is-holiday/is-holiday.ts index 1883f76d0..aa9b6d809 100644 --- a/src/is-holiday/is-holiday.ts +++ b/src/is-holiday/is-holiday.ts @@ -41,12 +41,17 @@ export type IsHolidayOptions = IsHolidayParams; * when that date is a national holiday. `undefined`, or an absent property, is the only * non-string value that stands for "no state" instead. * - * The date a state holiday is checked against is the statutory one, except for Santa Catarina's - * two holidays, which `getHolidays` moves to the following Sunday when they fall Monday to + * The date a state holiday is checked against is the statutory one, except for Alagoas' 30 + * November, which `getHolidays` moves back to the Monday from a Tuesday and on to the Friday from a + * Thursday (Lei AL nº 7.530/2013, art. 2º parágrafo único), and for Santa Catarina's two holidays, which `getHolidays` moves to the following Sunday when they fall Monday to * Friday: 11 August from 2005 on, as Lei SC nº 13.408/2005 introduced, and 25 November from 1999 * on, as Lei SC nº 11.213/1999 introduced, save for 2004, the year art. 3º of Lei SC nº * 12.906/2004 left that date without a transfer clause. Lei SC nº 18.531/2022 now carries both. * + * The first round of the elections, a feriado nacional under art. 380 of the Código Eleitoral, is + * one of the holidays `getHolidays` lists, on the first Sunday of October of every even year from + * 1998 on (15 November in 2020), so `isHoliday` is true on that Sunday; the second round is not. + * * @param {IsHolidayParams} [options] - Options for the check. * @param {Date} options.targetDate - The date to check. * @param {StateCode} [options.stateCode] - Optional Brazilian state code to also consider state holidays. @@ -70,18 +75,23 @@ export type IsHolidayOptions = IsHolidayParams; * Lei 10.607/2002, added Finados (2 November) and folded in Tiradentes (21 April), which had * been national since art. 3º of the Lei 1.266/1950 it revoked. * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l6802.htm - * Lei 6.802/1980, declared Nossa Senhora Aparecida a national holiday. + * Lei 6.802/1980, declared Nossa Senhora Aparecida a national holiday, listed from 1980 on; each + * national holiday is listed only for the years a federal norm declared it (see `getHolidays`). * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2023/lei/l14759.htm * Lei 14.759/2023, nationalized Dia da Consciência Negra from 2024. * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l9093.htm * Lei 9.093/1995, the framework law authorizing state and municipal holidays. * @see Official: https://www.in.gov.br/web/dou/-/portaria-mgi-n-11.460-de-29-de-dezembro-de-2025-678388627 * Portaria MGI nº 11.460/2025, the federal executive's annual calendar of feriados nacionais and - * pontos facultativos, the source behind three of the four Easter-derived entries: Sexta-feira - * Santa, Carnaval and Corpus Christi. Páscoa is not one of them; the portaria never mentions - * Easter Sunday, whose date `getHolidays` derives arithmetically with the Meeus/Jones/Butcher - * algorithm. See the `getHolidays` JSDoc for why Sexta-feira Santa is typed `national` without a - * law of its own. + * pontos facultativos, the source behind the Easter-derived entries but one: Sexta-feira Santa, + * both Carnaval days ("16 de fevereiro Carnaval (ponto facultativo); 17 de fevereiro Carnaval + * (ponto facultativo)", emitted as `"Carnaval (segunda-feira)"` and `"Carnaval (terça-feira)"`) + * and Corpus Christi. Páscoa is the exception; the portaria never mentions Easter Sunday, whose + * date `getHolidays` derives arithmetically with the Meeus/Jones/Butcher algorithm. The + * portaria's partial pontos facultativos (the Quarta-feira de Cinzas morning, the Dia do Servidor + * Público on 28 October, the 24 and 31 December afternoons) are left out. See the `getHolidays` + * JSDoc for why Sexta-feira Santa is typed `national` without a law of its own and for why those + * days are left out. */ export const isHoliday = (options?: IsHolidayParams): boolean => { if (isNullish(options) || typeof options !== "object") { diff --git a/src/is-valid-bank-account/is-valid-bank-account.test.ts b/src/is-valid-bank-account/is-valid-bank-account.test.ts index c9c14d97d..7b3f108b7 100644 --- a/src/is-valid-bank-account/is-valid-bank-account.test.ts +++ b/src/is-valid-bank-account/is-valid-bank-account.test.ts @@ -22,7 +22,7 @@ const LISTED_CODES = new Set( ), ); -/** The bank codes `isValidBankAccount` validates with a published check digit algorithm. */ +/** The bank codes `isValidBankAccount` validates with a check digit rule. */ const ALGORITHM_BANK_CODES = ["001", "033", "041", "104", "237", "260", "341", "399", "745"]; const CHECK_CHARACTERS = [...Array.from({ length: 10 }, (_, digit) => String(digit)), "X", "P"]; diff --git a/src/is-valid-bank-account/is-valid-bank-account.ts b/src/is-valid-bank-account/is-valid-bank-account.ts index 030aee945..7c03a1b5c 100644 --- a/src/is-valid-bank-account/is-valid-bank-account.ts +++ b/src/is-valid-bank-account/is-valid-bank-account.ts @@ -252,12 +252,21 @@ const sanitizeCheckDigit = (value: string): string => * Validates a Brazilian bank account. The bank code must belong to the Banco Central do Brasil * STR participants list, otherwise the account is rejected. * - * Banks validated by their published check digit algorithm: + * Banks validated by a check digit rule: * Banco do Brasil (001), Santander (033), Banrisul (041), Caixa Econômica Federal (104), - * Bradesco (237), Nubank (260, Verhoeff), Itaú Unibanco (341), HSBC/Kirton (399) and - * Citibank (745). + * Bradesco (237), Itaú Unibanco (341), HSBC/Kirton (399) and Citibank (745), with the rules + * of the "Regras de Validação de dígito verificador de agência e conta corrente" compendium + * of Icatu Seguros, and Nubank (260), with a Verhoeff check digit. No act of the Banco + * Central, of another government body or of Febraban sets any of these rules: the compendium + * is a private compilation of the rules of each bank, and Nubank publishes no rule at all; + * its Verhoeff digit is the one the open source validators listed below derived from real + * accounts. The FEBRABAN Layout Padrão CNAB 240 v11.0 (11/09/2026), notes G009, G011 and G012, + * defines each of the agency, account and agency/account check digits only as a "código adotado + * pelo Banco responsável pela conta corrente", allows it to be alphanumeric and a 2 position + * account digit, and gives no algorithm; the DICT API of the Banco Central takes the account + * "incluindo verificador. Se verificador for letra, substituir por 0" and computes nothing. * - * Banks validated by structure only, because they publish no check digit rule: + * Banks validated by structure only, because no check digit rule of theirs is known: * Inter (077), Ailos (085), XP (102), Unicred (136), Stone (197), BTG Pactual (208), * Original (212), PagBank (290), BMG (318), Mercado Pago (323), C6 (336), PicPay (380), * Cora (403), Pan (623), BV (655), Daycoval (707), Sicredi (748) and Sicoob (756). @@ -285,6 +294,9 @@ const sanitizeCheckDigit = (value: string): string => * on the next release. * * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/str1/ParticipantesSTR.csv + * @see Official: https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20padrao%20CNAB240%20V%2011_0%20-%202026_09_11.pdf + * FEBRABAN, Layout Padrão CNAB 240 v11.0 (11/09/2026), notes G009 to G012: "Código adotado pelo + * Banco", with no algorithm. * @see Based on: https://github.com/eduardokum/laravel-boleto/blob/master/manuais/Regras%20Validacao%20Conta%20Corrente%20VI_EPS.pdf * Icatu Seguros compendium of per bank agency/account check digit rules. * @see Based on: https://github.com/ajmiciano/banktools-br/tree/master/lib/banktools-br/banks diff --git a/src/is-valid-boleto/constants.ts b/src/is-valid-boleto/constants.ts index a5e5b049d..37221a0e8 100644 --- a/src/is-valid-boleto/constants.ts +++ b/src/is-valid-boleto/constants.ts @@ -1,9 +1,9 @@ export const CHECK_DIGIT_POSITION = 4; export const PARTIALS = [ - { start: 0, end: 9, checkIdx: 9 }, - { start: 10, end: 20, checkIdx: 20 }, - { start: 21, end: 31, checkIdx: 31 }, + { start: 0, end: 9, checkIndex: 9 }, + { start: 10, end: 20, checkIndex: 20 }, + { start: 21, end: 31, checkIndex: 31 }, ]; export const CONVERT_POSITIONS = [ diff --git a/src/is-valid-boleto/is-valid-boleto.test.ts b/src/is-valid-boleto/is-valid-boleto.test.ts index f86092a2f..f9d27c855 100644 --- a/src/is-valid-boleto/is-valid-boleto.test.ts +++ b/src/is-valid-boleto/is-valid-boleto.test.ts @@ -50,12 +50,39 @@ describe("isValidBoleto", () => { expect(isValidBoleto("00190000090114971860168524522114975860000102656")).toBe(false); }); + test("when the código de moeda is not 9 (same fixture as the boleto valid without mask, with the moeda in barcode position 4 changed and both the campo 1 check digit and the DV geral recalculated): Carta-Circular BCB nº 2.926/2000 assigns only 9 (real)", () => { + // Moeda 7: campo 1 "001700000" + DV 1, DV geral 2. + expect(isValidBoleto("00170000010114971860168524522114275860000102656")).toBe(false); + // Moeda 8: campo 1 DV 0, DV geral 4. + expect(isValidBoleto("00180000000114971860168524522114475860000102656")).toBe(false); + expect(isValidBoleto("0017000001 01149.718601 68524.522114 2 75860000102656")).toBe(false); + // Moeda 0 outside the Situação 2 layout: campo 1 DV 8, DV geral 1. + expect(isValidBoleto("00100000080114971860168524522114175860000102656")).toBe(false); + }); + + test("when the código de moeda is 0 but the rest of the FEBRABAN Situação 2 layout (bank 988, factor 0000, ISPB padded with zeros) is not followed, check digits recalculated each time", () => { + // Bank 001 instead of 988. + expect(isValidBoleto("00100000080114971860168524522114800000018236120")).toBe(false); + // Factor 7586 instead of 0000. + expect(isValidBoleto("98800000060114971860168524522114775860018236120")).toBe(false); + // Positions 10 and 11 of the barcode "10" instead of the zeros padding the ISPB. + expect(isValidBoleto("98800000060114971860168524522114700001018236120")).toBe(false); + }); + test(`when length is greater than ${BOLETO_LENGTH}, even with extra digits appended to an otherwise valid boleto`, () => { expect(isValidBoleto("00190000090114971860168524522114675860000102656999")).toBe(false); }); }); describe("should return true", () => { + test("when it is a FEBRABAN Convenção da Cobrança Situação 2 slip: bank 988, moeda 0, factor 0000 and ISPB 18236120 padded with zeros (campo 1 DV 6, DV geral 1)", () => { + expect(isValidBoleto("98800000060114971860168524522114100000018236120")).toBe(true); + }); + + test("when bank 988 uses the código de moeda 9 (campo 1 DV 7, DV geral 9)", () => { + expect(isValidBoleto("98890000070114971860168524522114975860000102656")).toBe(true); + }); + test("when is a boleto valid without mask", () => { expect(isValidBoleto("00190000090114971860168524522114675860000102656")).toBe(true); }); @@ -63,10 +90,6 @@ describe("isValidBoleto", () => { test("when is a boleto valid with mask", () => { expect(isValidBoleto("0019000009 01149.718601 68524.522114 6 75860000102656")).toBe(true); }); - - test("when the código de moeda is not 9 (same fixture as the boleto valid without mask, with the moeda in barcode position 4 changed to 7 and both the campo 1 and the DV geral recalculated): Carta-Circular BCB nº 2.926/2000 fixes that position at 9, and the leniency kept from 2.3.0 accepts any other digit", () => { - expect(isValidBoleto("00170000010114971860168524522114275860000102656")).toBe(true); - }); }); describe("arrecadação", () => { diff --git a/src/is-valid-boleto/is-valid-boleto.ts b/src/is-valid-boleto/is-valid-boleto.ts index af6e8596e..99316ef85 100644 --- a/src/is-valid-boleto/is-valid-boleto.ts +++ b/src/is-valid-boleto/is-valid-boleto.ts @@ -1,4 +1,11 @@ -import { BOLETO_LENGTH } from "../_internals/constants/boleto"; +import { + BOLETO_LENGTH, + CURRENCY_CODE_INDEX, + FACTOR_INDEX, + ISPB_ONLY_PREFIX, + ISPB_ONLY_ZEROS, + REAL_CURRENCY_CODE, +} from "../_internals/constants/boleto"; import { mod10 } from "../_internals/mod10/mod10"; import { mod11 } from "../_internals/mod11/mod11"; import { parseArrecadacao } from "../_internals/parse-arrecadacao/parse-arrecadacao"; @@ -6,14 +13,25 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d import { CHECK_DIGIT_POSITION, CONVERT_POSITIONS, PARTIALS } from "./constants"; const isValidPartials = (digits: string): boolean => { - for (const { start, end, checkIdx } of PARTIALS) { + for (const { start, end, checkIndex } of PARTIALS) { const partial = digits.slice(start, end); const expected = mod10(partial); - if (digits.charCodeAt(checkIdx) - 48 !== expected) return false; + if (digits.charCodeAt(checkIndex) - 48 !== expected) return false; } return true; }; +/** + * Whether position 4 holds the código de moeda `9` or the slip follows the Situação 2 layout + * (`ISPB_ONLY_PREFIX`, then `ISPB_ONLY_ZEROS` from the factor on), the only place a `0` is + * assigned. + * @param {string} digits - The 47 digits of the linha digitável. + * @returns {boolean} Whether the código de moeda fits the slip. + */ +const isValidCurrency = (digits: string): boolean => + digits[CURRENCY_CODE_INDEX] === REAL_CURRENCY_CODE || + (digits.startsWith(ISPB_ONLY_PREFIX) && digits.startsWith(ISPB_ONLY_ZEROS, FACTOR_INDEX)); + const parseToBoleto = (digits: string): string => { let result = ""; for (const [start, end] of CONVERT_POSITIONS) { @@ -36,9 +54,12 @@ const isValidCheckDigit = (boleto: string): boolean => { * "arrecadação" (convênio/tributos) bank slip: 48 digit linha digitável or 44 digit * barcode, both starting with `8`. * - * One leniency is kept from 2.3.0: the código de moeda in position 4 of the cobrança bancária - * barcode is not checked, although Carta-Circular BCB nº 2.926/2000 fixes it at `9` (real), so - * a slip carrying any other moeda digit still validates. + * The código de moeda in position 4 of the cobrança bancária barcode (and of the linha + * digitável) must be `9` (real), the only code Carta-Circular BCB nº 2.926/2000 assigns. The one + * exception is the FEBRABAN Convenção da Cobrança "Situação 2" slip of an institution identified + * only by its ISPB, which carries bank code `988`, código de moeda `0`, fator de vencimento + * `0000` and the ISPB, padded with zeros to 10 digits, where the amount would be. Up to 2.4.0 + * any digit in position 4 was accepted. * * @param {string} value - The bank slip number to validate. * @returns {boolean} True if the bank slip number is valid, false otherwise. @@ -48,16 +69,28 @@ const isValidCheckDigit = (boleto: string): boolean => { * isValidBoleto("00190000090114971860168524522114675860000102656"); // true * isValidBoleto("0019000009 01149.718601 68524.522114 6 75860000102656"); // true * isValidBoleto("846100000005246100291102005460339004695895061080"); // true (arrecadação) + * isValidBoleto("00170000010114971860168524522114275860000102656"); // false (código de moeda 7) + * isValidBoleto("98800000060114971860168524522114100000018236120"); // true (Situação 2: 988, moeda 0, ISPB) * ``` * - * Carta-Circular BCB nº 2.926/2000 specifies the linha digitável fields and the módulo 11 - * check digit (using 1 for remainders 0, 10 and 1) of the 47 digit cobrança bancária slip, - * including the position of the fator de vencimento field. The FEBRABAN "Layout Padrão de + * Carta-Circular BCB nº 2.926/2000 specifies the linha digitável fields, the código de moeda + * `9` (real) in position 4 of the barcode and the módulo 11 check digit (1 when 11 minus the + * remainder gives 0, 10 or 11, i.e. when the remainder is 0 or 1) of the 47 digit cobrança + * bancária slip, including the position of the fator de vencimento field. The FEBRABAN "Layout Padrão de * Arrecadação/Recebimento com Utilização do Código de Barras" and the FEBRABAN layout index * cover the arrecadação slip. * * @see Official: https://www.bcb.gov.br/pre/normativos/c_circ/2000/pdf/c_circ_2926_v1_O.pdf + * Carta-Circular BCB nº 2.926/2000, anexo, layout of the barcode: position 04, "Codigo da moeda + * (9 - real)"; positions 06 to 09, the fator de vencimento counted from 07/10/1997. + * @see Official: https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Conven%C3%A7%C3%A3o%20da%20Cobran%C3%A7a%20-%2005_02_2021_f.pdf + * FEBRABAN Convenção da Cobrança (FB-0061/2021), item 2.3.2: "Situação 1", "Código de Moeda = 9 + * (Real)"; "Situação 2", an institution "detentora apenas do ISPB, que será identificada pelo + * Número Código 988": position 04 "Zero", positions 06 to 09 "Zeros", positions 10 to 19 "ISPB + * com zeros a esquerda". * @see Official: https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf + * FEBRABAN "Layout Padrão de Arrecadação/Recebimento com Utilização do Código de Barras", + * Versão 08 (file of 11/05/2026), "Vigência: a partir de 01.06.2026". * @see Official: https://portal.febraban.org.br/pagina/3425/33/pt-br/layout-febraban */ export const isValidBoleto = (value: string): boolean => { @@ -67,6 +100,8 @@ export const isValidBoleto = (value: string): boolean => { if (digits.length !== BOLETO_LENGTH) return false; + if (!isValidCurrency(digits)) return false; + if (!isValidPartials(digits)) return false; const boleto = parseToBoleto(digits); diff --git a/src/is-valid-caepf/constants.ts b/src/is-valid-caepf/constants.ts index 66bef5776..5fde05a4d 100644 --- a/src/is-valid-caepf/constants.ts +++ b/src/is-valid-caepf/constants.ts @@ -1,7 +1,10 @@ /** * Layout of the CAEPF (Cadastro de Atividade Econômica da Pessoa Física): 14 digits printed as * "000.000.000/000-00", the first 9 being the CPF base of the holder, the next 3 the sequence - * of the holder's registrations and the last 2 the check digits. + * of the holder's registrations and the last 2 the check digits. Only the first split is + * official: the SERPRO documentation of the Receita Federal's cadastro gives 14 positions, "9 + * primeiros números do CPF + número de inscrição resumido" of 5 positions; the 3 + 2 split of + * those 5 comes with the check digit rule from the references cited below. * * The weights below are the CNPJ's modulus 11 in the formulation of the cited reference: read * from the right they cycle from 9 down to 2, and the check digit is the remainder itself, with @@ -14,6 +17,11 @@ * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/caepf * The registry's own page at the Receita Federal, which describes the cadastro but publishes * neither the 14 digit layout nor the check digit rule. + * @see Official: https://bcadastros.serpro.gov.br/documentacao/cadastro_caepf/ + * SERPRO, documentation of the Receita Federal's Cadastro Compartilhado (bCadastros), CAEPF: + * "nroAepfCompleto — Número de inscrição completo (14 posições: 9 primeiros números do CPF + + * número de inscrição resumido)", with the example "00000002500171", which the rule here accepts. + * Neither it nor the IN RFB nº 1.828/2018, the norm of the CAEPF, gives a check digit rule. * @see Based on: http://ghiorzi.org/DVnew.htm * Description of the CAEPF layout and of the * shift of 12 applied to the check digit pair. diff --git a/src/is-valid-caepf/is-valid-caepf.test.ts b/src/is-valid-caepf/is-valid-caepf.test.ts index 0335de0e2..569962d79 100644 --- a/src/is-valid-caepf/is-valid-caepf.test.ts +++ b/src/is-valid-caepf/is-valid-caepf.test.ts @@ -70,9 +70,18 @@ describe("isValidCaepf", () => { test("when the shift of 12 is not applied (29311861000172 instead of 29311861000184)", () => { expect(isValidCaepf("29311861000172")).toBe(false); }); + + test("when it is a negative or fractional number", () => { + expect(isValidCaepf(-29_311_861_000_184)).toBe(false); + expect(isValidCaepf(293_118_610_001.84)).toBe(false); + }); }); describe("should return true", () => { + test("for 00000002500171, the example of SERPRO's documentation of the Receita Federal's CAEPF cadastro", () => { + expect(isValidCaepf("00000002500171")).toBe(true); + }); + test("for 293.118.610/001-84 (Casilhero/brazilian-validators CaepfTest, from ghiorzi.org)", () => { expect(isValidCaepf("293.118.610/001-84")).toBe(true); expect(isValidCaepf("29311861000184")).toBe(true); diff --git a/src/is-valid-caepf/is-valid-caepf.ts b/src/is-valid-caepf/is-valid-caepf.ts index 0dabe6c5b..cc6835caa 100644 --- a/src/is-valid-caepf/is-valid-caepf.ts +++ b/src/is-valid-caepf/is-valid-caepf.ts @@ -1,4 +1,5 @@ import { generateChecksum } from "../_internals/generate-checksum/generate-checksum"; +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { isRepeatedDigits } from "../_internals/is-repeated-digits/is-repeated-digits"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; import { toStringSafe } from "../_internals/to-string-safe/to-string-safe"; @@ -18,7 +19,11 @@ const getCheckDigit = (base: string, weights: number[]): number => * * The CAEPF replaced the CEI for individuals who hire employees, such as rural producers and * notary officials. It has 14 digits printed as "000.000.000/000-00": the 9 digit CPF base of - * the holder, a 3 digit sequence for the holder's several registrations and 2 check digits. + * the holder, a 3 digit sequence for the holder's several registrations and 2 check digits. The + * 14 positions and the CPF base are official (SERPRO's documentation of the Receita Federal's + * cadastro: "9 primeiros números do CPF + número de inscrição resumido"); the split of the last 5 + * into a sequence and 2 check digits is not, and comes with the check digit rule from the + * references cited below. * Both check digits are the CNPJ's modulus 11 in the formulation of the cited reference: the * weights cycle from 9 down to 2 from the right and the check digit is the remainder itself, * with a remainder of 10 read as 0 — the same digit the CNPJ's 2-to-9 weights with @@ -30,8 +35,12 @@ const getCheckDigit = (base: string, weights: number[]): number => * well-formed `"00000000000012"` is invalid. * * The Receita Federal does not publish the check digit rule of the CAEPF, the shift of 12 and - * the repeated-base rejection included, so the calculation follows the reference implementations - * cited below. + * the repeated-base rejection included: no norm (IN RFB nº 1.828/2018), layout or manual found + * has it, and the eSocial only checks that the number is "constante das bases da RFB". The + * calculation follows the reference implementations cited below; it agrees with the one official + * example found, SERPRO's `"00000002500171"`. + * + * A number is only read as a CAEPF when it is a non-negative safe integer. * * @param {string|number} value - The CAEPF value to be validated. * @returns {boolean} True if the CAEPF is valid, false otherwise. @@ -44,11 +53,17 @@ const getCheckDigit = (base: string, weights: number[]): number => * isValidCaepf("29311861000185"); // false (invalid check digits) * isValidCaepf("00000000000000"); // false (repeated base digits) * isValidCaepf("00000000000012"); // false (repeated base digits) + * isValidCaepf(293118610001.84); // false (not a non-negative safe integer) * ``` * * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/caepf * The registry's own page at the Receita Federal, which describes the cadastro but publishes * neither the 14 digit layout nor the check digit rule. + * @see Official: https://bcadastros.serpro.gov.br/documentacao/cadastro_caepf/ + * SERPRO, documentation of the Receita Federal's Cadastro Compartilhado (bCadastros), CAEPF: + * "nroAepfCompleto — Número de inscrição completo (14 posições: 9 primeiros números do CPF + + * número de inscrição resumido)", with the example "00000002500171", which the rule here accepts. + * Neither it nor the IN RFB nº 1.828/2018, the norm of the CAEPF, gives a check digit rule. * @see Based on: http://ghiorzi.org/DVnew.htm * Description of the CAEPF layout and of the * shift of 12 applied to the check digit pair. @@ -58,6 +73,8 @@ const getCheckDigit = (base: string, weights: number[]): number => * Third reference implementation. */ export const isValidCaepf = (value: string | number): boolean => { + if (!isLookupCode(value)) return false; + const digits = sanitizeToDigits(value); if (!CAEPF_FORMAT_REGEX.test(toStringSafe(value).trim())) return false; diff --git a/src/is-valid-cbo/constants.ts b/src/is-valid-cbo/constants.ts new file mode 100644 index 000000000..3357c16ba --- /dev/null +++ b/src/is-valid-cbo/constants.ts @@ -0,0 +1,2 @@ +/** How many digits a CBO occupation code has, the width a bare digit value is padded to. */ +export const CBO_LENGTH = 6; diff --git a/src/is-valid-cbo/is-valid-cbo.ts b/src/is-valid-cbo/is-valid-cbo.ts index 2064d8444..92ff1a21d 100644 --- a/src/is-valid-cbo/is-valid-cbo.ts +++ b/src/is-valid-cbo/is-valid-cbo.ts @@ -1,4 +1,8 @@ -import { getCbo } from "../get-cbo/get-cbo"; +import { CBO_FORMAT_REGEX, CBO_TITLES } from "../_internals/constants/cbo"; +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; +import { padLookupCode } from "../_internals/pad-lookup-code/pad-lookup-code"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { CBO_LENGTH } from "./constants"; /** * Validates if a CBO (Classificação Brasileira de Ocupações) code exists in the official @@ -35,4 +39,12 @@ import { getCbo } from "../get-cbo/get-cbo"; * Community mirror of the same table, the fallback `CBO_TITLES` was built from before the * official CSV was used. */ -export const isValidCbo = (value: string | number): boolean => getCbo(value) !== null; +export const isValidCbo = (value: string | number): boolean => { + if (!isLookupCode(value)) return false; + + const code = padLookupCode(value, CBO_LENGTH); + + if (!CBO_FORMAT_REGEX.test(code)) return false; + + return CBO_TITLES[sanitizeToDigits(code)] !== undefined; +}; diff --git a/src/is-valid-cei/is-valid-cei.test.ts b/src/is-valid-cei/is-valid-cei.test.ts index 4d8d7bd30..6bd9124d6 100644 --- a/src/is-valid-cei/is-valid-cei.test.ts +++ b/src/is-valid-cei/is-valid-cei.test.ts @@ -64,6 +64,11 @@ describe("isValidCei", () => { test("when only the check digit is wrong (11.583.00249/85 with a 4)", () => { expect(isValidCei("115830024984")).toBe(false); }); + + test("when it is a negative or fractional number", () => { + expect(isValidCei(-277_297_118_187)).toBe(false); + expect(isValidCei(2_772_971_181.87)).toBe(false); + }); }); describe("should return true", () => { diff --git a/src/is-valid-cei/is-valid-cei.ts b/src/is-valid-cei/is-valid-cei.ts index 2f88fe50f..bb508b07d 100644 --- a/src/is-valid-cei/is-valid-cei.ts +++ b/src/is-valid-cei/is-valid-cei.ts @@ -15,9 +15,14 @@ import { isValidCeiCnoNumber } from "../_internals/is-valid-cei-cno-number/is-va * included; anything else, a letter among the digits included, is rejected instead of being * read past. * - * The Receita Federal does not publish the check digit rule of the CEI/CNO numbering, so the - * calculation follows the reference implementations cited below, cross-checked against the CNO - * open data of the Receita Federal. + * The Receita Federal does not publish the check digit rule of the CEI/CNO numbering: no norm + * (IN RFB nº 2.061/2021), layout or manual found has it, and the eSocial only checks that the + * number is "constante das bases da RFB". Only the 12 positions and the CNO keeping the CEI + * number are official. The calculation follows the reference implementations cited below, + * cross-checked against the CNO open data of the Receita Federal and SERPRO's example + * `"000000336854"`. + * + * A number is only read as a CEI when it is a non-negative safe integer. * * @param {string|number} value - The CEI value to be validated. * @returns {boolean} True if the CEI is valid, false otherwise. @@ -29,11 +34,20 @@ import { isValidCeiCnoNumber } from "../_internals/is-valid-cei-cno-number/is-va * isValidCei(249859674386); // true * isValidCei("24.985.96743/68"); // false (invalid check digit) * isValidCei("000000000000"); // false (repeated digits) + * isValidCei(2772971181.87); // false (not a non-negative safe integer) * ``` * * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/cno * The registry's own page at the Receita Federal, which describes the cadastro but publishes * neither the mask nor the check digit rule. + * @see Official: https://bcadastros.serpro.gov.br/documentacao/cadastro_cno/ + * SERPRO, documentation of the Receita Federal's Cadastro Compartilhado (bCadastros), CNO: "cno — + * Número de inscrição da obra", 12 positions, with the example "000000336854", which the rule + * here accepts. Neither it nor the IN RFB nº 2.061/2021, the norm of the CNO, gives a check digit + * rule. + * @see Official: https://www.gov.br/esocial/pt-br/documentacao-tecnica/manuais/mos-s-1-3-consolidada-ate-a-no-s-1-3-07-2026.pdf + * Manual de Orientação do eSocial S-1.3, item 9.1: "O número de inscrição no CNO permanece o + * mesmo número do CEI". * @see Official: https://dados.gov.br/dados/conjuntos-dados/cadastro-nacional-de-obras-cno * Cadastro Nacional de Obras (CNO), dados abertos da Receita Federal: the catalogue entry for the * dataset this rule was cross-checked against and where the test vectors come from. The check was diff --git a/src/is-valid-cep/is-valid-cep.test.ts b/src/is-valid-cep/is-valid-cep.test.ts index c25e6535f..29a933466 100644 --- a/src/is-valid-cep/is-valid-cep.test.ts +++ b/src/is-valid-cep/is-valid-cep.test.ts @@ -50,6 +50,11 @@ describe("isValidCep", () => { expect(isValidCep("abc01310100")).toBe(false); expect(isValidCep("0131010a")).toBe(false); }); + + test("when it is a negative or fractional number", () => { + expect(isValidCep(-20_040_020)).toBe(false); + expect(isValidCep(2_004_002.1)).toBe(false); + }); }); describe("should return true", () => { diff --git a/src/is-valid-cep/is-valid-cep.ts b/src/is-valid-cep/is-valid-cep.ts index ff4493464..ccc68fce9 100644 --- a/src/is-valid-cep/is-valid-cep.ts +++ b/src/is-valid-cep/is-valid-cep.ts @@ -1,4 +1,5 @@ import { SEPARATORS_REGEX } from "../_internals/constants/separators"; +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; const CEP_REGEX = /^\d{8}$/; @@ -8,6 +9,8 @@ const CEP_REGEX = /^\d{8}$/; * Spaces, dots and hyphens are ignored, so every punctuated form of a CEP is accepted, but * any other character, a letter in particular, makes the value invalid. * + * A number is only read as a CEP when it is a non-negative safe integer. + * * @param {string|number} cep - The CEP value to be validated. * @returns {boolean} True if the CEP is valid, false otherwise. * @@ -19,13 +22,14 @@ const CEP_REGEX = /^\d{8}$/; * isValidCep(20040020); // true * isValidCep("abc01310100"); // false (invalid format) * isValidCep("12345"); // false (invalid length) + * isValidCep(-20040020); // false (not a non-negative safe integer) * ``` * * @see Official: https://www.correios.com.br/enviar/precisa-de-ajuda/tudo-sobre-cep * @see Official: https://www.correios.com.br/enviar/precisa-de-ajuda/guia-de-enderecamento/guia-de-enderecamento */ export const isValidCep = (cep: string | number): boolean => { - if (typeof cep !== "string" && typeof cep !== "number") return false; + if (!isLookupCode(cep)) return false; return CEP_REGEX.test(String(cep).replace(SEPARATORS_REGEX, "")); }; diff --git a/src/is-valid-certidao/is-valid-certidao.test.ts b/src/is-valid-certidao/is-valid-certidao.test.ts index d41d21f3c..db27730d0 100644 --- a/src/is-valid-certidao/is-valid-certidao.test.ts +++ b/src/is-valid-certidao/is-valid-certidao.test.ts @@ -65,7 +65,7 @@ describe("isValidCertidao", () => { expect(isValidCertidao(1_045_390_155)).toBe(false); }); - test("when the book code is 0, outside the nine books, even with matching check digits", () => { + test("when the book code is 0, outside the seven books, even with matching check digits", () => { expect(isValidCertidao("10453901552013000012021000012387")).toBe(false); }); @@ -76,6 +76,13 @@ describe("isValidCertidao", () => { }); describe("should return true", () => { + test("for the book codes 7, 8 and 9: art. 473, V, and the revoked Provimento CNJ nº 3/2009, art. 7º, V", () => { + // 8 (emancipações) and 9 (interdições) are on certidões issued under the Provimento nº 3/2009 + expect(isValidCertidao("10453901552013700012021000012315")).toBe(true); + expect(isValidCertidao("10453901552013800012021000012343")).toBe(true); + expect(isValidCertidao("10453901552013900012021000012398")).toBe(true); + }); + test("for 104539.01.55.2013.1.00012.021.0000123-21, the worked example of ghiorzi.org/DVnew.htm", () => { expect(isValidCertidao("104539 01 55 2013 1 00012 021 0000123 21")).toBe(true); expect(isValidCertidao("10453901552013100012021000012321")).toBe(true); @@ -149,13 +156,19 @@ describe("isValidCertidao", () => { ); }); - test("should return true for an interdiction act (book code 9) when accepted", () => { + test("should accept the book codes 8 and 9 of the Provimento CNJ nº 3/2009 when their types are accepted", () => { expect( isValidCertidao("10453901552013900012021000012398", { accept: ["interdiction"] }), ).toBe(true); + expect( + isValidCertidao("10453901552013800012021000012343", { accept: ["emancipation"] }), + ).toBe(true); + expect(isValidCertidao("10453901552013800012021000012343", { accept: ["other"] })).toBe( + false, + ); }); - test("should return false when the book code is 0, outside the nine books of the Provimento, and accept is given", () => { + test("should return false when the book code is 0, outside the seven books of the Provimento, and accept is given", () => { expect(isValidCertidao("10453901552013000012021000012387", { accept: ["birth"] })).toBe( false, ); @@ -228,6 +241,21 @@ describe("isValidCertidao", () => { ); }); + test("should reject the book code 0 whatever its check digits", () => { + fc.assert( + fc.property( + fc.stringMatching(/^[0-9]{8}55[0-9]{4}$/), + fc.constant(0), + fc.stringMatching(/^[0-9]{15}$/), + (head, typeCode, tail) => { + const base = `${head}${typeCode}${tail}`; + + expect(CHECK_DIGIT_PAIRS.some((pair) => isValidCertidao(`${base}${pair}`))).toBe(false); + }, + ), + ); + }); + test("should never throw and always judge a matrícula with a boolean", () => { fc.assert( fc.property(fc.anything(), (value) => { diff --git a/src/is-valid-certidao/is-valid-certidao.ts b/src/is-valid-certidao/is-valid-certidao.ts index 24b910632..72bb6a3f3 100644 --- a/src/is-valid-certidao/is-valid-certidao.ts +++ b/src/is-valid-certidao/is-valid-certidao.ts @@ -42,11 +42,19 @@ const getCheckDigit = (value: string): number => { * 0, 1, ... In both passes the check digit is the remainder itself, with a remainder of 10 read * as 1. * - * The book-type digit (fifteenth position of the matrícula) always has to name one of the nine - * books (see `CertidaoType`, reused from `getCertidaoInfo`), so a matrícula whose digit is `0` is - * rejected however good its check digits are, the same way `getCertidaoInfo` returns `null` for - * it. `options.accept` narrows that further to the listed types; when it is omitted, or when it - * is not an array, every book type is accepted. + * No official document publishes that algorithm. Art. 473, IX only names the "Número do dígito + * verificador (31º e 32º números da matrícula)"; the Provimento CNJ nº 3/2009 had it "formado + * automaticamente por meio do programa" the CNJ Corregedoria distributed to the registrars; the + * Caixa's Cadastro NIS layout says only that the 2 digits are "calculados pelo módulo 11". The + * weights and the remainder rule follow the community references cited as `Based on:`. + * + * The book-type digit (fifteenth position of the matrícula) has to name one of the books of + * `CertidaoType`, reused from `getCertidaoInfo`: the codes 1 to 7 of art. 473, V, and the codes 8 + * (`"emancipation"`) and 9 (`"interdiction"`) of the Provimento CNJ nº 3/2009, art. 7º, V, which + * was revoked by the Provimento CNJ nº 63/2017 but under which the certidões issued from 2010 on + * still carry them. A matrícula whose digit is `0` is rejected however good its check digits are, + * the same way `getCertidaoInfo` returns `null` for it. `options.accept` narrows that further to + * the listed types; when it is omitted, or when it is not an array, every book type is accepted. * * Only a string is accepted: the 32 digits of a matrícula are more than a JavaScript number can * hold, so a numeric argument is always rejected instead of being read as a rounded value. @@ -62,6 +70,8 @@ const getCheckDigit = (value: string): number => { * isValidCertidao("09430001552010100020112000012087"); // true * isValidCertidao("104539 01 55 2013 1 00012 021 0000123 22"); // false (invalid check digits) * isValidCertidao("09400301542011100110002005191744"); // false (serviço is not 55) + * isValidCertidao("10453901552013900012021000012398"); // true (book code 9, Provimento CNJ nº 3/2009) + * isValidCertidao("10453901552013000012021000012387"); // false (book code 0 names no book) * isValidCertidao("123456"); // false (wrong length) * isValidCertidao("104539 01 55 2013 1 00012 021 0000123 21", { accept: ["birth"] }); // true * isValidCertidao("104539 01 55 2013 1 00012 021 0000123 21", { accept: ["death"] }); // false @@ -70,7 +80,8 @@ const getCheckDigit = (value: string): number => { * @see Official: https://atos.cnj.jus.br/atos/detalhar/5243 * Código Nacional de Normas da Corregedoria Nacional de Justiça - Foro Extrajudicial (Provimento * CNJ nº 149/2023), art. 473 as currently published: the in-force layout of the 32 digit - * matrícula. Inciso II and §§ 1º and 3º to 5º carry the redação of the Provimento CN nº 237, de + * matrícula, its inciso V listing the book codes "1: Livro A (Nascimento)" to "7: Livro E (Demais + * atos relativos ao registro civil)". Inciso II and §§ 1º and 3º to 5º carry the redação of the Provimento CN nº 237, de * 13/07/2026; the rest of the article, § 2º included, and the digit layout this library depends * on, come from the Provimento CN nº 182, de 17/09/2024. * @see Official: https://atos.cnj.jus.br/atos/detalhar/1311 @@ -83,7 +94,12 @@ const getCheckDigit = (value: string): number => { * structure: "a matrícula, de inserção obrigatória nas certidões (primeira e demais vias) emitidas * pelos Cartórios de Registro Civil das Pessoas Naturais a partir de 1º de janeiro de 2010, é * formada pelos seguintes elementos", incisos I to IX fixing the same 6 + 2 + 2 + 4 + 1 + 5 + 3 + - * 7 + 2 positions art. 473 carries today (revoked; historical). + * 7 + 2 positions art. 473 carries today (revoked by the Provimento CNJ nº 63/2017; historical). + * Its inciso V also listed "8: Livro E (Desdobrado para registro específico das Emancipações)" + * and "9: Livro E (Desdobrado para registro específico das Interdições)", which art. 473, V no + * longer has but which are still accepted, the certidões issued under it carrying them, and its + * inciso IX had the check digits "formado automaticamente por meio do programa" the CNJ + * Corregedoria handed to the registrars, whose algorithm was never published. * @see Based on: http://ghiorzi.org/DVnew.htm * Worked example of the two check digits (sums 288 and 309). * @see Based on: https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts diff --git a/src/is-valid-cid10/is-valid-cid10.test.ts b/src/is-valid-cid10/is-valid-cid10.test.ts index bd36ccb5b..883fba82c 100644 --- a/src/is-valid-cid10/is-valid-cid10.test.ts +++ b/src/is-valid-cid10/is-valid-cid10.test.ts @@ -45,10 +45,19 @@ describe("isValidCid10", () => { expect(isValidCid10("I109")).toBe(false); }); + it("should accept the U07 codes the SIM table adds to the V2008 files", () => { + expect(isValidCid10("U07")).toBe(true); + expect(isValidCid10("U07.0")).toBe(true); + expect(isValidCid10("U07.1")).toBe(true); + expect(isValidCid10("u072")).toBe(true); + expect(isValidCid10("U07.3")).toBe(false); + }); + it("should reject a category the table does not have", () => { expect(isValidCid10("A10")).toBe(false); expect(isValidCid10("A10.0")).toBe(false); - expect(isValidCid10("U07.1")).toBe(false); + expect(isValidCid10("U09.9")).toBe(false); + expect(isValidCid10("U10.9")).toBe(false); }); it("should reject a value that is not written in a documented form", () => { @@ -90,11 +99,11 @@ describe("isValidCid10", () => { expect(codes.toSorted()).toEqual(Object.keys(CID10_DESCRIPTIONS).toSorted()); }); - it("should hold the 2045 categories and 12188 subcategories of CID-10 V2008", () => { + it("should hold the 2045 categories and 12188 subcategories of CID-10 V2008, plus U07 and its 3 subcategories", () => { const codes = Object.keys(CID10_DESCRIPTIONS); - expect(codes.filter((code) => code.length === 3)).toHaveLength(2045); - expect(codes.filter((code) => code.length === 4)).toHaveLength(12_188); + expect(codes.filter((code) => code.length === 3)).toHaveLength(2046); + expect(codes.filter((code) => code.length === 4)).toHaveLength(12_191); }); }); diff --git a/src/is-valid-cid10/is-valid-cid10.ts b/src/is-valid-cid10/is-valid-cid10.ts index d6c7969fa..4cfef139a 100644 --- a/src/is-valid-cid10/is-valid-cid10.ts +++ b/src/is-valid-cid10/is-valid-cid10.ts @@ -14,8 +14,10 @@ const CATEGORY_LENGTH = 3; * not a string) is rejected. * * The check reads a table of codes only, so it does not cost the descriptions `getCid10` - * carries. The tables are the CID-10 V2008 files, the revision DATASUS publishes as CSV: a code - * that is not in those files, such as `U07.1` (COVID-19), is not valid here. + * carries. The tables are the CID-10 V2008 files, the revision DATASUS publishes as CSV, plus the + * `U07` category of the CID-10 table DATASUS keeps for the SIM (`U07`, `U07.0`, `U07.1` and + * `U07.2`, the COVID-19 codes among them), which the V2008 files predate. A code in neither, such + * as `U09.9` and `U10.9`, which the WHO added later, is not valid here. * * @param {string} value - The CID-10 code to be validated, e.g. `"A00.0"`, `"A000"` or `"A00"`. * @returns {boolean} True when the code is a known category or subcategory, false otherwise. @@ -25,6 +27,7 @@ const CATEGORY_LENGTH = 3; * isValidCid10("A00.0"); // true * isValidCid10("a000"); // true * isValidCid10("A00"); // true + * isValidCid10("U07.1"); // true (COVID-19, from the SIM table) * isValidCid10("A00.5"); // false (A00 has no subcategory 5) * isValidCid10("A00-0"); // false (not a documented form) * ``` @@ -34,6 +37,9 @@ const CATEGORY_LENGTH = 3; * published by DATASUS (Ministério da Saúde). * @see Official: http://www2.datasus.gov.br/cid10/V2008/descrcsv.htm * The DATASUS page that links the archive and documents its files, columns and encoding. + * @see Official: ftp://ftp.datasus.gov.br/dissemin/publicos/SIM/CID10/TABELAS/CID10.DBF + * The CID-10 table of the SIM (Sistema de Informações sobre Mortalidade), source of the `U07` + * codes the V2008 files lack. */ export const isValidCid10 = (value: string): boolean => { const code = normalizeCid10(value); diff --git a/src/is-valid-cnae/constants.ts b/src/is-valid-cnae/constants.ts new file mode 100644 index 000000000..2b08b4ec2 --- /dev/null +++ b/src/is-valid-cnae/constants.ts @@ -0,0 +1,2 @@ +/** How many digits a CNAE subclass code has, the width a bare digit value is padded to. */ +export const CNAE_LENGTH = 7; diff --git a/src/is-valid-cnae/is-valid-cnae.ts b/src/is-valid-cnae/is-valid-cnae.ts index 6a481bdd7..edd5ff372 100644 --- a/src/is-valid-cnae/is-valid-cnae.ts +++ b/src/is-valid-cnae/is-valid-cnae.ts @@ -1,4 +1,8 @@ -import { getCnae } from "../get-cnae/get-cnae"; +import { CNAE_FORMAT_REGEX, CNAE_SUBCLASSES } from "../_internals/constants/cnae"; +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; +import { padLookupCode } from "../_internals/pad-lookup-code/pad-lookup-code"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { CNAE_LENGTH } from "./constants"; /** * Validates if a CNAE (Classificação Nacional de Atividades Econômicas) subclass code @@ -33,4 +37,12 @@ import { getCnae } from "../get-cnae/get-cnae"; * @see Official: https://concla.ibge.gov.br/busca-online-cnae.html * CONCLA's CNAE search and structure browser, which publishes CNAE-Subclasses 2.3. */ -export const isValidCnae = (value: string | number): boolean => getCnae(value) !== null; +export const isValidCnae = (value: string | number): boolean => { + if (!isLookupCode(value)) return false; + + const subclass = padLookupCode(value, CNAE_LENGTH); + + if (!CNAE_FORMAT_REGEX.test(subclass)) return false; + + return CNAE_SUBCLASSES[sanitizeToDigits(subclass)] !== undefined; +}; diff --git a/src/is-valid-cnh/is-valid-cnh.ts b/src/is-valid-cnh/is-valid-cnh.ts index c643a7bdd..170ccb32f 100644 --- a/src/is-valid-cnh/is-valid-cnh.ts +++ b/src/is-valid-cnh/is-valid-cnh.ts @@ -27,13 +27,29 @@ const FORMAT_REGEX = /^\d{11}$/; * 2 security check digits, but no official text publishes the check-digit weights; the algorithm * below follows the community reference cited as `Based on:`. * - * Art. 4º § 1º of the same resolution states that the check digit is computed by the DSR system - * with a "módulo 11" routine in which a remainder of 0 or 1 yields the digit 0. That rounding is - * not the rule the registry numbers use in practice: the first verifier keeps the remainder - * itself, so a remainder of 1 yields the digit 1 (which is why `"00000000119"` is accepted). The - * implementation follows the cited `Based on:` reference, not § 1º. + * Art. 4º § 1º of the same resolution has the DSR system compute "O dígito verificador" with a + * "módulo 11" routine in which a remainder of 0 or 1 yields the digit 0. It does not say which + * number it applies to: in the singular, right after inciso II, the Número do Espelho da CNH (9 + * characters plus 1 check digit, the only number of the article with a single one), it reads + * best as the rule of that digit, but its wording is generic, and it gives no weights. So it is + * not a source for the 2 check digits of the registry number, whose first verifier keeps the + * remainder itself, so a remainder of 1 yields the digit 1 (which is why `"00000000119"` is + * accepted). Resoluções CONTRAN nº 976/2022, nº 998/2023 and nº 1.006/2024 amend Resolução nº + * 886/2021, but none of them touches art. 4º. + * + * Resolução CONTRAN nº 1.020/2025, the newer habilitação norm, repeats the layout in its art. 10 + * ("nove caracteres e dois dígitos verificadores") without any check digit rule, and its art. 140 + * does not revoke Resolução nº 886/2021, so art. 4º § 1º of the 886 stays the only official text + * on the calculation. * * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/Resolucao8862021F.pdf + * Resolução CONTRAN nº 886/2021, art. 4º, I: the Número do Registro Nacional, of "9 (nove) + * caracteres" and "2 (dois) dígitos verificadores de segurança"; the Número do Espelho da CNH of + * the same article has 9 characters and 1 check digit, the one § 1º most likely computes. + * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/Resolucao10202025.pdf + * Resolução CONTRAN nº 1.020, de 1º de dezembro de 2025 (DOU of 09/12/2025), art. 10: the same + * three numbers, the registro nacional "composto de nove caracteres e dois dígitos verificadores", + * with no "módulo 11" rule and no weights; its art. 140 does not revoke Resolução nº 886/2021. * @see Based on: https://siga0984.wordpress.com/2019/05/01/algoritmos-validacao-de-cnh/ */ export const isValidCnh = (value: string): boolean => { diff --git a/src/is-valid-cno/is-valid-cno.test.ts b/src/is-valid-cno/is-valid-cno.test.ts index 0e73e043e..c2882843b 100644 --- a/src/is-valid-cno/is-valid-cno.test.ts +++ b/src/is-valid-cno/is-valid-cno.test.ts @@ -49,9 +49,18 @@ describe("isValidCno", () => { test("when a check digit of 0 is replaced by another digit (401800097960 of the Receita Federal CNO dataset)", () => { expect(isValidCno("401800097961")).toBe(false); }); + + test("when it is a negative or fractional number", () => { + expect(isValidCno(-110_840_168_062)).toBe(false); + expect(isValidCno(1_108_401_680.62)).toBe(false); + }); }); describe("should return true", () => { + test("for 000000336854, the example of SERPRO's documentation of the Receita Federal's CNO cadastro", () => { + expect(isValidCno("000000336854")).toBe(true); + }); + test("for 110840168062, an obra in Botelhos/MG of the Receita Federal CNO open dataset", () => { expect(isValidCno("110840168062")).toBe(true); expect(isValidCno("11.084.01680/62")).toBe(true); diff --git a/src/is-valid-cno/is-valid-cno.ts b/src/is-valid-cno/is-valid-cno.ts index 9eb6cc86f..ce24af627 100644 --- a/src/is-valid-cno/is-valid-cno.ts +++ b/src/is-valid-cno/is-valid-cno.ts @@ -14,9 +14,14 @@ import { isValidCeiCnoNumber } from "../_internals/is-valid-cei-cno-number/is-va * included; anything else, a letter among the digits included, is rejected instead of being * read past. * - * The Receita Federal does not publish the check digit rule of the CEI/CNO numbering, so the - * calculation follows the reference implementations cited below, cross-checked against the CNO - * open data of the Receita Federal. + * The Receita Federal does not publish the check digit rule of the CEI/CNO numbering: no norm + * (IN RFB nº 2.061/2021), layout or manual found has it, and the eSocial only checks that the + * number is "constante das bases da RFB". Only the 12 positions and the CNO keeping the CEI + * number are official. The calculation follows the reference implementations cited below, + * cross-checked against the CNO open data of the Receita Federal and SERPRO's example + * `"000000336854"`. + * + * A number is only read as a CNO when it is a non-negative safe integer. * * @param {string|number} value - The CNO value to be validated. * @returns {boolean} True if the CNO is valid, false otherwise. @@ -28,11 +33,20 @@ import { isValidCeiCnoNumber } from "../_internals/is-valid-cei-cno-number/is-va * isValidCno(401800097960); // true * isValidCno("110840168063"); // false (invalid check digit) * isValidCno("000000000000"); // false (repeated digits) + * isValidCno(1108401680.62); // false (not a non-negative safe integer) * ``` * * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/cno * The registry's own page at the Receita Federal, which describes the cadastro but publishes * neither the mask nor the check digit rule. + * @see Official: https://bcadastros.serpro.gov.br/documentacao/cadastro_cno/ + * SERPRO, documentation of the Receita Federal's Cadastro Compartilhado (bCadastros), CNO: "cno — + * Número de inscrição da obra", 12 positions, with the example "000000336854", which the rule + * here accepts. Neither it nor the IN RFB nº 2.061/2021, the norm of the CNO, gives a check digit + * rule. + * @see Official: https://www.gov.br/esocial/pt-br/documentacao-tecnica/manuais/mos-s-1-3-consolidada-ate-a-no-s-1-3-07-2026.pdf + * Manual de Orientação do eSocial S-1.3, item 9.1: "O número de inscrição no CNO permanece o + * mesmo número do CEI". * @see Official: https://dados.gov.br/dados/conjuntos-dados/cadastro-nacional-de-obras-cno * Cadastro Nacional de Obras (CNO), dados abertos da Receita Federal: the catalogue entry for the * dataset this rule was cross-checked against and where the test vectors come from. The check was diff --git a/src/is-valid-cnpj/is-valid-cnpj.test.ts b/src/is-valid-cnpj/is-valid-cnpj.test.ts index 642647689..6594acb5c 100644 --- a/src/is-valid-cnpj/is-valid-cnpj.test.ts +++ b/src/is-valid-cnpj/is-valid-cnpj.test.ts @@ -103,6 +103,13 @@ describe("isValidCnpj", () => { expect(isValidCnpj("00000000000000", { version: 2 })).toBe(false); }); + test("when it is the Ex1 of the Receita Federal Q&A as printed, whose check digits are 86, not 29", () => { + // Perguntas e respostas do CNPJ alfanumérico, question 23: "Ex1: AA345678/0003-29". The + // IN RFB nº 2.229/2024 algorithm gives 86 for AA3456780003; 29 belongs to Ex2. + expect(isValidCnpj("AA345678/0003-29", { version: 2 })).toBe(false); + expect(isValidCnpj("AA345678/0003-86", { version: 2 })).toBe(true); + }); + test("when an alphanumeric CNPJ is too short", () => { expect(isValidCnpj("AB.1C2.D3E/4F5G-3")).toBe(false); }); @@ -156,6 +163,14 @@ describe("isValidCnpj", () => { expect(isValidCnpj("1Z 000 000 0000 39", { version: 2 })).toBe(true); }); + test("when it is one of the examples of the Receita Federal's own reference code and Q&A", () => { + expect(isValidCnpj("12.ABC.345/01DE-35", { version: 2 })).toBe(true); + expect(isValidCnpj("ABCDEFGHIJKL80", { version: 2 })).toBe(true); + expect(isValidCnpj("AA345678/000A-29", { version: 2 })).toBe(true); + expect(isValidCnpj("12.345.678/000A-08", { version: 2 })).toBe(true); + expect(isValidCnpj("ABCDEFGHIJKL81", { version: 2 })).toBe(false); + }); + test("when is a lowercase alphanumeric CNPJ", () => { expect(isValidCnpj("q0slfmbd7vx439", { version: 2 })).toBe(true); }); diff --git a/src/is-valid-cnpj/is-valid-cnpj.ts b/src/is-valid-cnpj/is-valid-cnpj.ts index 34730ed78..3fb32953c 100644 --- a/src/is-valid-cnpj/is-valid-cnpj.ts +++ b/src/is-valid-cnpj/is-valid-cnpj.ts @@ -47,16 +47,41 @@ const isValidChecksum = (cnpj: string): boolean => * // Version 2 (alphanumeric) * isValidCnpj("Q0.SLF.MBD/7VX4-39", { version: 2 }); // true (alphanumeric) * isValidCnpj("Q0SLFMBD7VX439", { version: 2 }); // true (alphanumeric) - * isValidCnpj("q0slfmbd7vx439", { version: 2 }); // true (case-insensitive) + * isValidCnpj("q0slfmbd7vx439", { version: 2 }); // true (upper-cased first) * ``` * + * The official character set of the alphanumeric CNPJ is the capital letters `A` to `Z` and the + * digits in the 12 base positions, and digits only in the 2 check digits (Receita Federal, CNPJ + * alfanumérico, and its DV manual, which reads a letter by its ASCII code). A lower case letter is + * not part of it: under `version: 2` this function accepts one only as input normalization, the + * way it accepts mask characters, and upper-cases the input before checking it, so + * `"q0slfmbd7vx439"` is read as `"Q0SLFMBD7VX439"`. + * * Version 2 has no reserved-value list because the Receita Federal manual defines none for the * alphanumeric format, so a repeated-character alphanumeric base (e.g. all `A`s) that passes the * checksum is accepted, unlike the numeric reserved numbers rejected under version 1. * + * The rule itself is the Anexo XV of IN RFB nº 2.119/2022, added by Instrução Normativa RFB + * nº 2.229/2024: 12 alphanumeric positions and 2 numeric check digits, each character read as its + * ASCII code minus 48, weights 2 to 9 "atribuídos da direita para esquerda" and restarting after + * the 8th character, and a check digit of 0 when the remainder by 11 is 0 or 1. The IN as first + * printed in the DOU of 16/10/2024 said "da esquerda para direita"; the retificação printed in the + * DOU of 25/10/2024 corrected it to the right-to-left order the worked example `12.ABC.345/01DE-35` + * and this function use. + * + * The Ex1 of question 23 of the Receita Federal's Q&A, "AA345678/0003-29", is a misprint: the + * algorithm above gives it the check digits 86 (29 belongs to its Ex2, `AA345678/000A-29`), so + * `isValidCnpj("AA345678/0003-29", { version: 2 })` is `false`. + * + * @see Official: http://normas.receita.fazenda.gov.br/sijut2consulta/link.action?idAto=141102 + * Instrução Normativa RFB nº 2.229, de 15 de outubro de 2024, and the retificação of its Anexo + * Único in the DOU of 25/10/2024 (item 4.2.1: weights "atribuídos da direita para esquerda"). * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/cnpj * @see Official: https://www.gov.br/receitafederal/pt-br/centrais-de-conteudo/publicacoes/documentos-tecnicos/cnpj/manual-dv-cnpj.pdf * @see Official: https://www.gov.br/receitafederal/pt-br/acesso-a-informacao/acoes-e-programas/programas-e-atividades/cnpj-alfanumerico + * @see Official: https://www.gov.br/receitafederal/pt-br/centrais-de-conteudo/publicacoes/perguntas-e-respostas/cnpj/cnpj-alfanumerico.pdf + * Receita Federal, CNPJ alfanumérico, perguntas e respostas: the base positions take the digits 0 + * to 9 and the capital letters A to Z, the 2 check digits stay numeric. */ export const isValidCnpj = (cnpj: string, options?: IsValidCnpjOptions): boolean => { if (typeof cnpj !== "string") return false; diff --git a/src/is-valid-cns/is-valid-cns.test.ts b/src/is-valid-cns/is-valid-cns.test.ts index b8c66d145..fdcc3a63f 100644 --- a/src/is-valid-cns/is-valid-cns.test.ts +++ b/src/is-valid-cns/is-valid-cns.test.ts @@ -104,6 +104,11 @@ describe("isValidCns", () => { test("when the 15 digits are grouped outside the printed 3-4-4-4 mask", () => { expect(isValidCns("1234 5678 9010 000")).toBe(false); }); + + test("when it is a negative or fractional number", () => { + expect(isValidCns(-139_457_218_230_006)).toBe(false); + expect(isValidCns(13_945_721_823.0006)).toBe(false); + }); }); describe("should return true", () => { diff --git a/src/is-valid-cns/is-valid-cns.ts b/src/is-valid-cns/is-valid-cns.ts index 50f3a4a11..9db3746c3 100644 --- a/src/is-valid-cns/is-valid-cns.ts +++ b/src/is-valid-cns/is-valid-cns.ts @@ -5,6 +5,7 @@ import { CNS_FORMAT_REGEX, } from "../_internals/constants/cns"; import { generateChecksum } from "../_internals/generate-checksum/generate-checksum"; +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; const DEFINITIVE_FIRST_DIGIT_REGEX = /^[12]/; @@ -47,6 +48,8 @@ const isValidProvisional = (digits: string): boolean => * among the digits or a separator inside a group included, is rejected instead of being read * past. * + * A number is only read as a CNS when it is a non-negative safe integer. + * * @param {string|number} value - The CNS value to be validated. * @returns {boolean} True if the CNS is valid, false otherwise. * @@ -58,19 +61,28 @@ const isValidProvisional = (digits: string): boolean => * isValidCns("123.4567-8901/0000"); // true (any of the mask characters) * isValidCns("123456789010001"); // false (wrong check digit) * isValidCns("12345678901"); // false (wrong length) + * isValidCns(13945721823.0006); // false (not a non-negative safe integer) * ``` * + * @see Official: https://web.archive.org/web/20190106003442/http://cartaonet.datasus.gov.br/Rotina_JavaScript.doc + * DATASUS, "Rotina de validação de CNS e Número Provisório", the routine the Cartão Nacional de + * Saúde site (cartaonet.datasus.gov.br) published for download; the site is gone, so the link is + * the Wayback Machine copy of the official file (last captured on 06/01/2019). It has two + * routines, "Números que iniciam com '1' ou '2'" (the definitive CNS, PIS base + "000" or "001" + + * check digit) and "Números que iniciam com '7', '8' ou '9'" (the provisional number, weights 15 + * to 1 summing to a multiple of 11), and names no other first digit: nothing official covers a + * number starting with 5. Its 2007 version said "O Número Provisório sempre começa com '8'". * @see Official: https://rni-docs.anvisa.gov.br/docs/regras_gerais/validacoes/validacaoCNS/ * ANVISA's two validation routines, the ones implemented here. The page sits behind a bot filter * and answers HTTP 403 to every non-browser client, so it has to be opened in a browser. * @see Based on: https://integracao.esusab.ufsc.br/ledi/documentacao/regras/algoritmo_CNS.html * e-SUS APS documentation of the same DATASUS algorithm, reachable without a browser. It applies * the provisional routine to numbers starting with 5, 7, 8 or 9; this implementation follows the - * ANVISA page, which restricts it to 7, 8 and 9, so a 5 prefixed number is rejected even when its - * weighted sum checks out. + * DATASUS routine and the ANVISA page, which restrict it to 7, 8 and 9, so a 5 prefixed number is + * rejected even when its weighted sum checks out: no official document names the prefix 5. */ export const isValidCns = (value: string | number): boolean => { - if (typeof value !== "string" && typeof value !== "number") return false; + if (!isLookupCode(value)) return false; const digits = sanitizeToDigits(value); diff --git a/src/is-valid-cpf/is-valid-cpf.ts b/src/is-valid-cpf/is-valid-cpf.ts index 3462381fc..d02435aeb 100644 --- a/src/is-valid-cpf/is-valid-cpf.ts +++ b/src/is-valid-cpf/is-valid-cpf.ts @@ -35,7 +35,17 @@ const isValidChecksum = (cpf: string): boolean => * cited below in its place; its Receita Federal permalink redirects into the norms viewer, which * has to be opened in a browser. * + * The norm of the CPF itself, Instrução Normativa RFB nº 2.172/2024, says nothing about the check + * digits. The reserved numbers come from the Receita Federal's DJE arrecadação layout, whose CPF + * field needs "os 2 dígitos verificadores consistentes, validados conforme rotina específica" and + * lists "000.000.000-00, 111.111.111-11, [...] 999.999.999-99" as not valid. + * * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/meu-cpf + * @see Official: https://normas.receita.fazenda.gov.br/sijut2consulta/link.action?idAto=135611 + * Instrução Normativa RFB nº 2.172/2024, the norm of the CPF: no rule for the check digits. + * @see Official: http://normas.receita.fazenda.gov.br/sijut2consulta/anexoOutros.action?idArquivoBinario=36307 + * Receita Federal, leiaute DJE (version DJE/004 of 10/06/2002), field "Número CPF ou CNPJ": the 10 + * CPFs whose 11 digits are all the same are not valid. * @see Official: https://normas.receita.fazenda.gov.br/sijut2consulta/link.action?idAto=151372 * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/cpf.py */ diff --git a/src/is-valid-gtin/constants.ts b/src/is-valid-gtin/constants.ts index f24e6b371..628288cd7 100644 --- a/src/is-valid-gtin/constants.ts +++ b/src/is-valid-gtin/constants.ts @@ -4,10 +4,15 @@ import { type GtinLength } from "./is-valid-gtin"; export const DIGITS_REGEX = /^\d+$/; /** - * A GTIN of zeros only. Its check digit works out (the GS1 check digit of zeros is zero), but GS1 - * never allocates it: every GTIN carries a GS1 Company Prefix, and the prefix 0000000 of table 1-4 - * of the General Specifications only ever holds a GTIN-8 behind it, never zeros. The NF-e leiaute - * reserves the literal "SEM GTIN" for an item with no GTIN, so zeros are a placeholder, not a code. + * A GTIN of zeros only, which `isValidGtin` rejects by a rule of this library, not of an official + * source. Its check digit works out (the GS1 check digit of zeros is zero), so rule I03-10 of the + * NF-e (rejection 611, "cEAN com dígito de controle inválido", NT 2021.003 v1.50) lets it through, + * and no NF-e rule or technical note rejects a GTIN of zeros. The GS1 General Specifications + * (release 26.0) do not forbid it either: table 1-4 reserves the GS1 Prefix 0000000 "to issue + * Restricted Circulation Numbers within a company", and table 1-5 the GS1-8 Prefixes 000 to 099, + * so a GTIN of zeros is a company-internal RCN, not a number GS1 allocates for open trade. The + * NF-e leiaute reserves the literal "SEM GTIN" for an item with no GTIN, so zeros are almost + * always a placeholder, which is why they are rejected; the other RCN are accepted. */ export const ALL_ZEROS_REGEX = /^0+$/; diff --git a/src/is-valid-gtin/is-valid-gtin.test.ts b/src/is-valid-gtin/is-valid-gtin.test.ts index 70822c4c6..0dd8f68d0 100644 --- a/src/is-valid-gtin/is-valid-gtin.test.ts +++ b/src/is-valid-gtin/is-valid-gtin.test.ts @@ -144,7 +144,7 @@ describe("isValidGtin", () => { ); }); - test("should accept no check digit at all for a body of zeros only", () => { + test("should reject a value of zeros only, a rule of this library that no NF-e rejection states", () => { expect(isValidGtin("0000000000000")).toBe(false); expect(isValidGtin("00000000")).toBe(false); expect(isValidGtin("000000000000")).toBe(false); diff --git a/src/is-valid-gtin/is-valid-gtin.ts b/src/is-valid-gtin/is-valid-gtin.ts index 07b15282b..d604c87cd 100644 --- a/src/is-valid-gtin/is-valid-gtin.ts +++ b/src/is-valid-gtin/is-valid-gtin.ts @@ -24,25 +24,36 @@ export type IsValidGtinOptions = { * * The prefix does not change the verdict: Restricted Circulation Numbers (prefixes 02, 04 and 20 * to 29, the codes a shop prints on its own scale labels) and the ISSN, ISBN and coupon ranges - * share the structure and the check digit, and the "Tabela Prefixo GS1" SEFAZ validates `cEAN` - * against lists them as valid. `getGtinInfo` tells the restricted and the Brazilian prefixes + * share the structure and the check digit. SEFAZ checks the prefix against its own "Tabela + * Prefixo GS1" (rules I03-20 and I12-20), which could not be read to say which ranges it accepts. + * `getGtinInfo` tells the restricted and the Brazilian prefixes * apart for the caller that needs to. Whether the number is registered with GS1 (the Cadastro * Centralizado de GTIN lookup of rules 9I03-10 and 9I12-10) cannot be checked offline. * + * A value of zeros only is rejected, although its check digit is valid. That is a rule of this + * library: no NF-e rule rejects it (rejection 611 is only the check digit), and the GS1 General + * Specifications reserve the GS1 Prefix 0000000 for Restricted Circulation Numbers within a + * company (table 1-4) rather than forbid it. Zeros are rejected as the usual placeholder for a + * missing GTIN, which the NF-e writes as "SEM GTIN". + * * @param {string} value - The GTIN to be validated, digits only. * @param {IsValidGtinOptions} [options] - Optional options. * @param {GtinLength[]} [options.lengths] - The lengths to accept. Defaults to all four. * @returns {boolean} True if the GTIN is valid, false otherwise. * * @see Official: https://ref.gs1.org/standards/genspecs/ - * GS1 General Specifications, release 26.0: section 7.9.1 (check digit, tables 7-8 and 7-9). + * GS1 General Specifications, release 26.0: section 7.9.1 (check digit, tables 7-8 and 7-9); + * tables 1-4 and 1-5 (GS1 Prefix 0000000 and GS1-8 Prefixes 000 to 099, Restricted Circulation + * Numbers within a company). * @see Official: https://www.gs1.org/services/how-calculate-check-digit-manually * GS1, "How to calculate a check digit manually", source of the 6291041500213 example. * @see Official: https://www.nfe.fazenda.gov.br/portal/exibirArquivo.aspx?conteudo=SrQT9ys8ODo%3D - * SEFAZ Nota Técnica 2021.003 (Validação GTIN, replaces NT 2017.001): fields I03 `cEAN` and I12 - * `cEANTrib`, rules I03-10 and I12-10. + * SEFAZ Nota Técnica 2021.003 (Validação GTIN, replaces NT 2017.001), v1.50 of September 2026: + * fields I03 `cEAN` and I12 `cEANTrib`, rules I03-10 and I12-10 (rejections 611 and 612, "com + * dígito de controle inválido"). * @see Official: https://www.nfe.fazenda.gov.br/portal/exibirArquivo.aspx?conteudo=Oc+fygAxwmc%3D - * "Tabela Prefixo GS1" of the Portal da NF-e, the prefix ranges rules I03-20 and I12-20 accept. + * "Tabela Prefixo GS1" of the Portal da NF-e, the table rules I03-20 and I12-20 check the prefix + * against (not readable from where this was written, so its contents are not relied on). * * @example * ```typescript @@ -54,7 +65,7 @@ export type IsValidGtinOptions = { * isValidGtin("7890000000018"); // false (wrong check digit) * isValidGtin("17890000000014", { lengths: [8, 12, 13] }); // false (GTIN-14 not accepted) * isValidGtin("SEM GTIN"); // false - * isValidGtin("0000000000000"); // false (zeros only, never allocated by GS1) + * isValidGtin("0000000000000"); // false (zeros only, a rule of this library) * ``` */ export const isValidGtin = (value: string, options?: IsValidGtinOptions): boolean => { diff --git a/src/is-valid-iban/is-valid-iban.test.ts b/src/is-valid-iban/is-valid-iban.test.ts index 14738974e..d65517a31 100644 --- a/src/is-valid-iban/is-valid-iban.test.ts +++ b/src/is-valid-iban/is-valid-iban.test.ts @@ -45,6 +45,12 @@ describe("isValidIban", () => { expect(isValidIban("BR3860701190000010000012345C1")).toBe(true); }); + test("for an ISPB with letters, the 'oito caracteres alfanuméricos' of Resolução BCB 585/2026 art. 2 III", () => { + expect(isValidIban("BR1012AB34CD000010932840814P2")).toBe(true); + expect(isValidIban("BR170000000A000010000012345C2")).toBe(true); + expect(isValidIban("br1012ab34cd000010932840814p2")).toBe(true); + }); + test("for a valid IBAN whose account type is a letter other than C or P", () => { expect(isValidIban("BR5400000000000010932840814D2")).toBe(true); expect(isValidIban("BR7800000000000010932840814S2")).toBe(true); @@ -80,7 +86,7 @@ describe("isValidIban", () => { expect(isValidIban("BR150000000000001093284081412")).toBe(false); }); - test("when the owner indicator is 0, which Circular 3.625 art. 2 § 1 does not assign, even though the check digits match", () => { + test("when the owner indicator is 0, which Resolução BCB 585/2026 art. 2 § 1 does not assign, even though the check digits match", () => { expect(isValidIban("BR6900000000000010932840814P0")).toBe(false); }); @@ -89,7 +95,8 @@ describe("isValidIban", () => { }); test("when a digit position holds a letter instead, even if the check digits happen to match", () => { - expect(isValidIban("BR170000000A000010000012345C2")).toBe(false); + expect(isValidIban("BR32000000000000A0000012345C2")).toBe(false); + expect(isValidIban("BR320000000000001000001234AC2")).toBe(false); }); test("when it carries a character outside the print format", () => { diff --git a/src/is-valid-iban/is-valid-iban.ts b/src/is-valid-iban/is-valid-iban.ts index 634c9db14..7cb85258f 100644 --- a/src/is-valid-iban/is-valid-iban.ts +++ b/src/is-valid-iban/is-valid-iban.ts @@ -35,8 +35,12 @@ const hasValidCheckDigits = (iban: string): boolean => { * rejected instead of having the offending character stripped. * * The last character is the owner indicator, `1` for the first or only holder up to `9` for the - * ninth and then `A` to `Z` from the tenth, per Circular BCB nº 3.625/2013 art. 2º § 1º, so a - * value ending in `0` is rejected. + * ninth and then `A` to `Z` from the tenth, per Resolução BCB nº 585/2026 art. 2º § 1º (the + * same rule as the revoked Circular BCB nº 3.625/2013), so a value ending in `0` is rejected. + * + * The 8 character ISPB may hold letters: art. 2º III of the Resolução makes it "oito + * caracteres alfanuméricos", where the Circular said "numéricos". Up to 2.4.0 a letter there was + * rejected. * * @param {string} value - The IBAN to be validated. * @returns {boolean} True when `value` is a structurally valid Brazilian IBAN whose ISO 7064 @@ -48,13 +52,17 @@ const hasValidCheckDigits = (iban: string): boolean => { * isValidIban("BR15 0000 0000 0000 1093 2840 814P 2"); // true (grouping spaces) * isValidIban("BR15-0000-0000-0000-1093-2840-814P-2"); // true (any of the mask characters) * isValidIban("br1500000000000010932840814p2"); // true (case-insensitive) + * isValidIban("BR1012AB34CD000010932840814P2"); // true (alphanumeric ISPB) * isValidIban("BR1500000000000010932840814P3"); // false (bad check digits) * isValidIban("BR15 000 00000 0000 1093 2840 814P 2"); // false (a separator inside a group) * isValidIban("DE89370400440532013000"); // false (non Brazilian IBAN) * ``` * + * @see Official: https://www.bcb.gov.br/estabilidadefinanceira/exibenormativo?tipo=Resolu%C3%A7%C3%A3o%20BCB&numero=585 + * Resolução BCB nº 585, de 24/08/2026 (DOU 25/08/2026), art. 2º, which revoked Circular BCB nº + * 3.625/2013 and keeps its layout, with an alphanumeric ISPB. * @see Official: https://www.bcb.gov.br/pre/normativos/circ/2013/pdf/circ_3625_v1_O.pdf - * Circular BCB nº 3.625/2013 + * Circular BCB nº 3.625/2013 (revoked), the original layout * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf * Diretrizes de Implementação do IBAN no Brasil * @see Official: https://www.iso.org/standard/81090.html diff --git a/src/is-valid-ie/constants.ts b/src/is-valid-ie/constants.ts index 21408ee5b..6d3fba0c5 100644 --- a/src/is-valid-ie/constants.ts +++ b/src/is-valid-ie/constants.ts @@ -4,11 +4,45 @@ * @see Official: http://www.sintegra.gov.br/insc_est.html */ +export const AC_PREFIXES = ["01"]; + export const AL_PREFIXES = ["24"]; +/** + * The tipo de empresa, the third digit of an AL registration, as the SINTEGRA AL page lists it: + * "0-Normal, 3-Produtor Rural, 5-Substituta, 7- Micro-Empresa Ambulante, 8-Micro-Empresa". + */ +export const AL_COMPANY_TYPES = ["0", "3", "5", "7", "8"]; + export const BA_MOD_10_DIGITS = [0, 1, 2, 3, 4, 5, 8]; -export const GO_PREFIXES = ["10", "11", "15"]; +/** + * 07, the "campo fixo" of SEFAZ-DF's rule sheet and the start of the SINTEGRA example (which does + * not call it fixed); 08 since the numbers starting with 07 ran out. + */ +export const DF_PREFIXES = ["07", "08"]; + +/** + * The union of three official sources that disagree: IN nº 946/09-GSF, art. 39, I (as worded by + * IN nº 1.535/22-GSE) gives 10 and 20 (pessoa jurídica) and 11 (pessoa física); the SINTEGRA GO + * page gives "10 ou 11 ou 20 a 29"; the Secretaria da Economia's roteiro de crítica (2012) gives + * "10 ou 11 ou 15". No norm names 15 or 21 to 29. + */ +export const GO_PREFIXES = [ + "10", + "11", + "15", + "20", + "21", + "22", + "23", + "24", + "25", + "26", + "27", + "28", + "29", +]; export const GO_DUAL_DIGIT_IE = 11_094_402; @@ -18,12 +52,32 @@ export const GO_SPECIAL_RANGE_END = 10_119_997; export const MA_PREFIXES = ["12"]; +/** + * 28 and 50 from the SINTEGRA MS page (2024) and a SEFAZ-MS e-CCE communiqué; Resolução/SEF + * nº 1.344/1999, the only norm, gives 28 alone. + */ export const MS_PREFIXES = ["28", "50"]; +/** + * The six prefixes the SINTEGRA PA page lists ("Composição: 15NNNNNN-D; 75NNNNNN-D; 76NNNNNN-D; + * 77NNNNNN-D; 78NNNNNN-D; 79 NNNNNN-D"), the page SEFA-PA's notice of 08/10/2024 on the prefix 75 + * points to for the details of the registration. + */ export const PA_PREFIXES = ["15", "75", "76", "77", "78", "79"]; +/** + * The weights of the old 14 digit CACEPE number on the SINTEGRA PE page ("Dígito Verificador da + * Inscrição Estadual Antiga"), from the first of its 13 principal digits to the last. + */ +export const PE_LEGACY_WEIGHTS = [5, 4, 3, 2, 1, 9, 8, 7, 6, 5, 4, 3, 2]; + export const SP_FIRST_WEIGHTS = [1, 3, 4, 5, 6, 7, 8, 10]; export const SP_SECOND_WEIGHTS = [3, 2, 10, 9, 8, 7, 6, 5, 4, 3, 2]; +/** + * The tipo digits (positions 3 and 4) of the old 11 digit TO form, as the SINTEGRA TO page lists + * them: 01 Produtor Rural, 02 Indústria e Comércio, 03 Empresas Rudimentares, 99 Empresas do + * Cadastro Antigo (suspensas). The 9 digit form in force has no tipo digits. + */ export const TO_TYPES = ["01", "02", "03", "99"]; diff --git a/src/is-valid-ie/is-valid-ie.test.ts b/src/is-valid-ie/is-valid-ie.test.ts index 0e24bb373..ee1fe9f09 100644 --- a/src/is-valid-ie/is-valid-ie.test.ts +++ b/src/is-valid-ie/is-valid-ie.test.ts @@ -60,6 +60,22 @@ describe("isValidIe", () => { test("should return true for another valid IE", () => { expect(isValidIe({ value: "240000005", stateCode: "AL" })).toBe(true); }); + + test("should return true for every tipo de empresa the SINTEGRA page lists: 0, 3, 5, 7 and 8", () => { + expect(isValidIe({ value: "240000005", stateCode: "AL" })).toBe(true); + expect(isValidIe({ value: "243000006", stateCode: "AL" })).toBe(true); + expect(isValidIe({ value: "245000003", stateCode: "AL" })).toBe(true); + expect(isValidIe({ value: "247000000", stateCode: "AL" })).toBe(true); + expect(isValidIe({ value: "248000004", stateCode: "AL" })).toBe(true); + }); + + test("should return false for a tipo de empresa the page does not list, even with a matching digit", () => { + expect(isValidIe({ value: "241000009", stateCode: "AL" })).toBe(false); + expect(isValidIe({ value: "242000002", stateCode: "AL" })).toBe(false); + expect(isValidIe({ value: "244000000", stateCode: "AL" })).toBe(false); + expect(isValidIe({ value: "246000007", stateCode: "AL" })).toBe(false); + expect(isValidIe({ value: "249000008", stateCode: "AL" })).toBe(false); + }); }); describe("AP", () => { @@ -207,10 +223,29 @@ describe("isValidIe", () => { expect(isValidIe({ value: "0754002000508", stateCode: "DF" })).toBe(true); }); - test("should return false when the IE does not start with 07", () => { + test("should return false when the IE does not start with 07 or 08", () => { expect(isValidIe({ value: "0108368143017", stateCode: "DF" })).toBe(false); }); + test("should return true for the prefix 08, which DF moved to when the 07 numbers ran out", () => { + // 8 x 3 + 3 x 2 + 1 x 5 + 1 x 2 = 37, 37 % 11 = 4, 11 - 4 = 7 + // 8 x 4 + 3 x 3 + 1 x 6 + 1 x 3 + 7 x 2 = 64, 64 % 11 = 9, 11 - 9 = 2 + expect(isValidIe({ value: "0830000100172", stateCode: "DF" })).toBe(true); + expect(isValidIe({ value: "08.300001.001-72", stateCode: "DF" })).toBe(true); + expect(isValidIe({ value: "0800000000176", stateCode: "DF" })).toBe(true); + expect(isValidIe({ value: "0899999900145", stateCode: "DF" })).toBe(true); + }); + + test("should return false for the prefixes 06 and 09, just outside 07 and 08, even with matching digits", () => { + expect(isValidIe({ value: "0630000100129", stateCode: "DF" })).toBe(false); + expect(isValidIe({ value: "0930000100144", stateCode: "DF" })).toBe(false); + }); + + test("should return false for a prefix 08 registration with an incorrect verifier digit", () => { + expect(isValidIe({ value: "0830000100173", stateCode: "DF" })).toBe(false); + expect(isValidIe({ value: "0830000100182", stateCode: "DF" })).toBe(false); + }); + test("should return false when the length is not 13 digits", () => { expect(isValidIe({ value: "07008368143094", stateCode: "DF" })).toBe(false); }); @@ -264,12 +299,29 @@ describe("isValidIe", () => { expect(isValidIe({ value: "109161794", stateCode: "GO" })).toBe(false); }); - test("should return false when the IE does not start with 10, 11 or 15", () => { + test("should return false when the IE does not start with 10, 11, 15 or 20 to 29", () => { expect(isValidIe({ value: "121031131", stateCode: "GO" })).toBe(false); }); - test("should return false for prefixes 20 to 29, which the current SEFAZ-GO rule does not accept", () => { - expect(isValidIe({ value: "209876549", stateCode: "GO" })).toBe(false); + test("should return true for the prefix 20, the one the Secretaria da Economia issues to companies since 13/01/2023", () => { + // 2 x 9 = 18, 18 % 11 = 7, 11 - 7 = 4 + expect(isValidIe({ value: "200000004", stateCode: "GO" })).toBe(true); + expect(isValidIe({ value: "209876549", stateCode: "GO" })).toBe(true); + }); + + test("should return true for every prefix from 20 to 29 the SINTEGRA page lists", () => { + expect(isValidIe({ value: "210000007", stateCode: "GO" })).toBe(true); + expect(isValidIe({ value: "290000009", stateCode: "GO" })).toBe(true); + expect(isValidIe({ value: "299999998", stateCode: "GO" })).toBe(true); + }); + + test("should return false for the prefixes 19 and 30, just outside 20 to 29, even with a matching digit", () => { + expect(isValidIe({ value: "190000007", stateCode: "GO" })).toBe(false); + expect(isValidIe({ value: "300000006", stateCode: "GO" })).toBe(false); + }); + + test("should return false for a prefix 20 registration with an incorrect digit", () => { + expect(isValidIe({ value: "200000005", stateCode: "GO" })).toBe(false); }); test("should return false when the length is different from 9", () => { @@ -373,10 +425,31 @@ describe("isValidIe", () => { expect(isValidIe({ value: "12345678901", stateCode: "MT" })).toBe(false); }); - test("should return false when the length is different from 11", () => { + test("should return false when the length is different from 9 or 11", () => { expect(isValidIe({ value: "1234567890112", stateCode: "MT" })).toBe(false); }); + test("should return true for the 9 digit form Portaria SEFAZ-MT nº 59/2025 art. 8º § 1º prescribes", () => { + // Padded to 00130000019: 9 x 1 + 8 x 3 + 2 x 1 = 35, 35 % 11 = 2, 11 - 2 = 9 + expect(isValidIe({ value: "130000019", stateCode: "MT" })).toBe(true); + expect(isValidIe({ value: "13.000.001-9", stateCode: "MT" })).toBe(true); + expect(isValidIe({ value: "00130000019", stateCode: "MT" })).toBe(true); + }); + + test("should return true for an all zero registration in the 9 digit form, as in the 11 digit one", () => { + expect(isValidIe({ value: "000000000", stateCode: "MT" })).toBe(true); + expect(isValidIe({ value: "00000000000", stateCode: "MT" })).toBe(true); + }); + + test("should return false for a 9 digit IE with an incorrect verified digit", () => { + expect(isValidIe({ value: "130000010", stateCode: "MT" })).toBe(false); + }); + + test("should return false for 8 or 10 digits, even though padding them to 11 would give a valid checksum", () => { + expect(isValidIe({ value: "30000017", stateCode: "MT" })).toBe(false); + expect(isValidIe({ value: "0130000019", stateCode: "MT" })).toBe(false); + }); + test("should return false when the length is 12, even though the first ten digits alone would form a valid checksum", () => { expect(isValidIe({ value: "604741204699", stateCode: "MT" })).toBe(false); }); @@ -407,7 +480,7 @@ describe("isValidIe", () => { expect(isValidIe({ value: "0280000006", stateCode: "MS" })).toBe(false); }); - test("should return false when the IE does not start with 28", () => { + test("should return false when the IE does not start with 28 or 50", () => { expect(isValidIe({ value: "853511942", stateCode: "MS" })).toBe(false); }); }); @@ -425,15 +498,33 @@ describe("isValidIe", () => { expect(isValidIe({ value: "150000030", stateCode: "PA" })).toBe(true); }); - test("should return true for IEs with prefixes 75 to 79", () => { + test("should return true for the prefix 75, which SEFA-PA has issued since October 2024", () => { + // 7 x 9 + 5 x 8 = 103, 103 % 11 = 4, 11 - 4 = 7 + expect(isValidIe({ value: "750000007", stateCode: "PA" })).toBe(true); expect(isValidIe({ value: "750000023", stateCode: "PA" })).toBe(true); + expect(isValidIe({ value: "759999996", stateCode: "PA" })).toBe(true); + }); + + test("should return true for the prefixes 76 to 79, which the SINTEGRA page lists", () => { expect(isValidIe({ value: "760000000", stateCode: "PA" })).toBe(true); expect(isValidIe({ value: "770000002", stateCode: "PA" })).toBe(true); expect(isValidIe({ value: "780000005", stateCode: "PA" })).toBe(true); expect(isValidIe({ value: "790000008", stateCode: "PA" })).toBe(true); }); - test("should return false when the IE does not start with 15, 75, 76, 77, 78 or 79", () => { + test("should return false for a prefix 76 to 79 registration with an incorrect digit", () => { + expect(isValidIe({ value: "760000001", stateCode: "PA" })).toBe(false); + expect(isValidIe({ value: "790000009", stateCode: "PA" })).toBe(false); + }); + + test("should return false for the prefixes 74, 80, 14 and 16, next to 75 to 79 and 15, even with a matching digit", () => { + expect(isValidIe({ value: "740000004", stateCode: "PA" })).toBe(false); + expect(isValidIe({ value: "800000005", stateCode: "PA" })).toBe(false); + expect(isValidIe({ value: "140000003", stateCode: "PA" })).toBe(false); + expect(isValidIe({ value: "160000009", stateCode: "PA" })).toBe(false); + }); + + test("should return false when the IE does not start with 15 or 75 to 79", () => { expect(isValidIe({ value: "120000008", stateCode: "PA" })).toBe(false); }); @@ -473,8 +564,27 @@ describe("isValidIe", () => { expect(isValidIe({ value: "288625706", stateCode: "PE" })).toBe(true); }); - test("should return false when the length is different from 9 digits", () => { + test("should return false when the length is neither 9 nor 14 digits", () => { expect(isValidIe({ value: "0925870110", stateCode: "PE" })).toBe(false); + expect(isValidIe({ value: "1810010000004", stateCode: "PE" })).toBe(false); + expect(isValidIe({ value: "181001000000490", stateCode: "PE" })).toBe(false); + }); + + test("should return true for the old 14 digit CACEPE number, the SINTEGRA page's worked example", () => { + // (5 x 1) + (4 x 8) + (3 x 1) + (9 x 1) + (2 x 4) = 57, 57 % 11 = 2, 11 - 2 = 9 + expect(isValidIe({ value: "18.1.001.0000004-9", stateCode: "PE" })).toBe(true); + }); + + test("should return false for the old 14 digit CACEPE number with an incorrect digit", () => { + expect(isValidIe({ value: "18100100000048", stateCode: "PE" })).toBe(false); + }); + + test("should subtract 10 from a difference above 9 in the old 14 digit CACEPE number", () => { + // remainder 1: 11 - 1 = 10, less 10 = 0 + expect(isValidIe({ value: "00001000000000", stateCode: "PE" })).toBe(true); + // remainder 0: 11 - 0 = 11, less 10 = 1 + expect(isValidIe({ value: "00000000000001", stateCode: "PE" })).toBe(true); + expect(isValidIe({ value: "00000000000000", stateCode: "PE" })).toBe(false); }); test("should return false when the digit is incorrect", () => { @@ -699,6 +809,23 @@ describe("isValidIe", () => { expect(isValidIe({ value: "P011004244002", stateCode: "SP" })).toBe(false); }); + test("should return false for a produtor rural IE whose second character is not the 0 of P0MMMSSSSD000, even with a matching digit", () => { + expect(isValidIe({ value: "P790674670712", stateCode: "SP" })).toBe(false); + expect(isValidIe({ value: "P111004244002", stateCode: "SP" })).toBe(false); + expect(isValidIe({ value: "p790674670712", stateCode: "SP" })).toBe(false); + }); + + test("should return true for a produtor rural IE starting with P0, the boundary of the rule", () => { + // 1 x 3 + 1 x 4 + 4 x 7 + 2 x 8 + 4 x 10 = 91, 91 % 11 = 3 + expect(isValidIe({ value: "P011004243002", stateCode: "SP" })).toBe(true); + expect(isValidIe({ value: "P000000000000", stateCode: "SP" })).toBe(true); + }); + + test("should return false for the SINTEGRA page's closing line P-011000424.3/002, which has one 0 too many", () => { + expect(isValidIe({ value: "P-011000424.3/002", stateCode: "SP" })).toBe(false); + expect(isValidIe({ value: "P011004243002", stateCode: "SP" })).toBe(true); + }); + test("should return false for a produtor rural IE with a length different from 13", () => { expect(isValidIe({ value: "P01100424300", stateCode: "SP" })).toBe(false); }); @@ -767,6 +894,7 @@ describe("isValidIe", () => { ["BA", "612345-57"], ["BA", "1000003-06"], ["CE", "06000001-5"], + ["DF", "073.00001.001-09"], ["ES", "999999990"], ["GO", "10.987.654-7"], ["MA", "120000385"], @@ -776,8 +904,10 @@ describe("isValidIe", () => { ["PA", "75000002-3"], ["PB", "06000001-5"], ["PE", "0321418-40"], + ["PE", "18.1.001.0000004-9"], ["PI", "012345679"], ["PR", "123.45678-50"], + ["RJ", "99.999.99-3"], ["RN", "20.040.040-1"], ["RN", "20.0.040.040-0"], ["RO", "0000000062521-3"], @@ -797,6 +927,7 @@ describe("isValidIe", () => { ["SP", "110.042.490.114"], ["SP", "P-01100424.3/002"], ["TO", "29010227836"], + ["TO", "29 022783 6"], ]; test("should accept every worked example the SINTEGRA pages print", () => { diff --git a/src/is-valid-ie/is-valid-ie.ts b/src/is-valid-ie/is-valid-ie.ts index ca7871042..7d28249ea 100644 --- a/src/is-valid-ie/is-valid-ie.ts +++ b/src/is-valid-ie/is-valid-ie.ts @@ -4,8 +4,11 @@ import { mod10 } from "../_internals/mod10/mod10"; import { sanitizeToAlphanumeric } from "../_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; import { + AC_PREFIXES, + AL_COMPANY_TYPES, AL_PREFIXES, BA_MOD_10_DIGITS, + DF_PREFIXES, GO_DUAL_DIGIT_IE, GO_PREFIXES, GO_SPECIAL_RANGE_END, @@ -13,6 +16,7 @@ import { MA_PREFIXES, MS_PREFIXES, PA_PREFIXES, + PE_LEGACY_WEIGHTS, SP_FIRST_WEIGHTS, SP_SECOND_WEIGHTS, TO_TYPES, @@ -45,7 +49,12 @@ type WeightedSumParams = { wrapTo?: number; }; -const calcWeightedSum = ({ source, length, startWeight, wrapTo }: WeightedSumParams): number => { +const calculateWeightedSum = ({ + source, + length, + startWeight, + wrapTo, +}: WeightedSumParams): number => { let weight = startWeight; let sum = 0; @@ -62,10 +71,20 @@ const calcWeightedSum = ({ source, length, startWeight, wrapTo }: WeightedSumPar return sum; }; -const calcMod11CheckDigit = (sum: number, mod = 11): number => { - const rest = sum % mod; - const dig = mod - rest; - return dig >= 10 ? 0 : dig; +const sumWithWeights = (body: string, weights: readonly number[]): number => { + let sum = 0; + + for (let i = 0; i < body.length; i++) { + sum += (body.charCodeAt(i) - 48) * weights[i]; + } + + return sum; +}; + +const calculateMod11CheckDigit = (sum: number, modulus = 11): number => { + const rest = sum % modulus; + const digit = modulus - rest; + return digit >= 10 ? 0 : digit; }; const validateMod11Ie = (ie: string, prefixes?: readonly string[]): boolean => { @@ -73,52 +92,57 @@ const validateMod11Ie = (ie: string, prefixes?: readonly string[]): boolean => { if (prefixes && !startsWithAny(ie, prefixes)) return false; const body = ie.slice(0, 8); - const sum = calcWeightedSum({ source: body, length: body.length, startWeight: body.length + 1 }); - const dig = calcMod11CheckDigit(sum); + const sum = calculateWeightedSum({ + source: body, + length: body.length, + startWeight: body.length + 1, + }); + const digit = calculateMod11CheckDigit(sum); - return Number.parseInt(ie.charAt(8), 10) === dig; + return Number.parseInt(ie.charAt(8), 10) === digit; }; -const calcDFDigit = (body: string): number => { - const sum = calcWeightedSum({ +const calculateDfCheckDigit = (body: string): number => { + const sum = calculateWeightedSum({ source: body, length: body.length, startWeight: body.length - 7, wrapTo: 9, }); - return calcMod11CheckDigit(sum); + return calculateMod11CheckDigit(sum); }; -const SP_RURAL_PATTERN = /^P\d{12}$/; +const SP_RURAL_PATTERN = /^P0\d{11}$/; const SP_COMPANY_PATTERN = /^\d{12}$/; /** - * The 13 digit rule AC and DF share, each under its own prefix. + * The 13 digit rule AC and DF share, each under its own prefixes. * @param {string} ie - The sanitized registration. - * @param {string} prefix - The two digits the state's registrations start with. - * @returns {boolean} True when the registration follows the rule under that prefix. + * @param {readonly string[]} prefixes - The two digits the state's registrations may start with. + * @returns {boolean} True when the registration follows the rule under one of those prefixes. */ -const validateAcDfRule = (ie: string, prefix: string): boolean => { +const validateAcDfRule = (ie: string, prefixes: readonly string[]): boolean => { if (!checkLength(ie, 13)) return false; - if (!ie.startsWith(prefix)) return false; + if (!startsWithAny(ie, prefixes)) return false; const body = ie.slice(0, 11); - const firstDig = calcDFDigit(body); - const secondDig = calcDFDigit(body + firstDig); + const firstDigit = calculateDfCheckDigit(body); + const secondDigit = calculateDfCheckDigit(body + firstDigit); return ( - Number.parseInt(ie.charAt(11), 10) === firstDig && - Number.parseInt(ie.charAt(12), 10) === secondDig + Number.parseInt(ie.charAt(11), 10) === firstDigit && + Number.parseInt(ie.charAt(12), 10) === secondDigit ); }; -const validateAC: IeValidator = (ie) => validateAcDfRule(ie, "01"); +const validateAC: IeValidator = (ie) => validateAcDfRule(ie, AC_PREFIXES); // AL writes its rule as the weighted sum times ten, modulo eleven, with a ten mapped back to 0, // which is the complement the shared modulus 11 rule takes: both give 0 for a remainder of 0 or // 1 and `11 - remainder` for every other one. -const validateAL: IeValidator = (ie) => validateMod11Ie(ie, AL_PREFIXES); +const validateAL: IeValidator = (ie) => + AL_COMPANY_TYPES.includes(ie.charAt(2)) && validateMod11Ie(ie, AL_PREFIXES); const validateAP: IeValidator = (ie: string) => { if (!checkLength(ie, 9)) return false; @@ -127,87 +151,87 @@ const validateAP: IeValidator = (ie: string) => { const length = ie.length; const position = length - 1; const body = ie.slice(0, position); - const bodyInt = Number.parseInt(body, 10); + const bodyNumber = Number.parseInt(body, 10); let p = 0; let d = 0; - if (bodyInt >= 3_000_001 && bodyInt <= 3_017_000) { + if (bodyNumber >= 3_000_001 && bodyNumber <= 3_017_000) { p = 5; - } else if (bodyInt >= 3_017_001 && bodyInt <= 3_019_022) { + } else if (bodyNumber >= 3_017_001 && bodyNumber <= 3_019_022) { p = 9; d = 1; } - const sum = p + calcWeightedSum({ source: ie, length: body.length, startWeight: length }); + const sum = p + calculateWeightedSum({ source: ie, length: body.length, startWeight: length }); - let dig = 11 - (sum % 11); - if (dig === 10) { - dig = 0; + let digit = 11 - (sum % 11); + if (digit === 10) { + digit = 0; } - if (dig === 11) { - dig = d; + if (digit === 11) { + digit = d; } - return dig === Number.parseInt(ie.charAt(position), 10); + return digit === Number.parseInt(ie.charAt(position), 10); }; const validateBA: IeValidator = (ie: string) => { if (!checkLength(ie, [8, 9])) return false; - const pos = ie.length === 9 ? 1 : 0; - const charAt = Number.parseInt(ie.slice(pos, pos + 1), 10); - const mod = BA_MOD_10_DIGITS.includes(charAt) ? 10 : 11; + const position = ie.length === 9 ? 1 : 0; + const modulusDigit = Number.parseInt(ie.slice(position, position + 1), 10); + const modulus = BA_MOD_10_DIGITS.includes(modulusDigit) ? 10 : 11; const body = ie.slice(0, -2); - const firstSum = calcWeightedSum({ + const firstSum = calculateWeightedSum({ source: ie, length: body.length, startWeight: body.length + 1, }); - const secondDig = calcMod11CheckDigit(firstSum, mod); + const secondDigit = calculateMod11CheckDigit(firstSum, modulus); - const bodyWithSecond = body + secondDig; - const secondSum = calcWeightedSum({ + const bodyWithSecond = body + secondDigit; + const secondSum = calculateWeightedSum({ source: bodyWithSecond, length: bodyWithSecond.length, startWeight: bodyWithSecond.length + 1, }); - const firstDig = calcMod11CheckDigit(secondSum, mod); + const firstDigit = calculateMod11CheckDigit(secondSum, modulus); return ( - Number.parseInt(ie.slice(-2, -1), 10) === firstDig && - Number.parseInt(ie.slice(-1), 10) === secondDig + Number.parseInt(ie.slice(-2, -1), 10) === firstDigit && + Number.parseInt(ie.slice(-1), 10) === secondDigit ); }; -const validateDF: IeValidator = (ie) => validateAcDfRule(ie, "07"); +const validateDF: IeValidator = (ie) => validateAcDfRule(ie, DF_PREFIXES); const validateGO: IeValidator = (ie: string) => { if (!checkLength(ie, 9)) return false; if (!startsWithAny(ie, GO_PREFIXES)) return false; const body = ie.slice(0, 8); - const bodyInt = Number.parseInt(body, 10); + const bodyNumber = Number.parseInt(body, 10); const checkDigit = Number.parseInt(ie.charAt(8), 10); - if (bodyInt === GO_DUAL_DIGIT_IE) { + if (bodyNumber === GO_DUAL_DIGIT_IE) { return checkDigit === 0 || checkDigit === 1; } - const sum = calcWeightedSum({ source: ie, length: body.length, startWeight: 9 }); + const sum = calculateWeightedSum({ source: ie, length: body.length, startWeight: 9 }); const rest = sum % 11; - let dig: number; + let digit: number; if (rest === 0) { - dig = 0; + digit = 0; } else if (rest === 1) { - dig = bodyInt >= GO_SPECIAL_RANGE_START && bodyInt <= GO_SPECIAL_RANGE_END ? 1 : 0; + digit = bodyNumber >= GO_SPECIAL_RANGE_START && bodyNumber <= GO_SPECIAL_RANGE_END ? 1 : 0; } else { - dig = 11 - rest; + digit = 11 - rest; } - return checkDigit === dig; + return checkDigit === digit; }; const validateMA: IeValidator = (ie) => validateMod11Ie(ie, MA_PREFIXES); @@ -220,59 +244,76 @@ const validateMG: IeValidator = (ie: string) => { // The first digit doubles every second character from the right and adds the digits of each // product, the modulus 10 rule `mod10` implements. - const firstDig = mod10(bodyWithZero); + const firstDigit = mod10(bodyWithZero); - const bodyWithFirst = body + firstDig; - const secondSum = calcWeightedSum({ + const bodyWithFirst = body + firstDigit; + const secondSum = calculateWeightedSum({ source: bodyWithFirst, length: bodyWithFirst.length, startWeight: 3, wrapTo: 11, }); - const secondDig = calcMod11CheckDigit(secondSum); + const secondDigit = calculateMod11CheckDigit(secondSum); return ( - Number.parseInt(ie.charAt(11), 10) === firstDig && - Number.parseInt(ie.charAt(12), 10) === secondDig + Number.parseInt(ie.charAt(11), 10) === firstDigit && + Number.parseInt(ie.charAt(12), 10) === secondDigit ); }; +// SEFAZ-MT now issues 9 digits and SINTEGRA prints 11: the 9 digit form is the 11 digit one +// without its two leading zeros, which add nothing to the weighted sum, so it is padded back. const validateMT: IeValidator = (ie: string) => { - if (!checkLength(ie, 11)) return false; + if (!checkLength(ie, [9, 11])) return false; - const body = ie.slice(0, 10); - const sum = calcWeightedSum({ source: ie, length: body.length, startWeight: 3, wrapTo: 9 }); - const dig = calcMod11CheckDigit(sum); + const padded = ie.padStart(11, "0"); + const body = padded.slice(0, 10); + const sum = calculateWeightedSum({ + source: padded, + length: body.length, + startWeight: 3, + wrapTo: 9, + }); + const digit = calculateMod11CheckDigit(sum); - return Number.parseInt(ie.charAt(10), 10) === dig; + return Number.parseInt(padded.charAt(10), 10) === digit; }; const validateMS: IeValidator = (ie) => validateMod11Ie(ie, MS_PREFIXES); const validatePA: IeValidator = (ie) => validateMod11Ie(ie, PA_PREFIXES); +// The old 14 digit CACEPE number: 13 principal digits and one check digit, `11 - remainder`, +// less 10 when that is above 9 (a remainder of 1 gives 0 and a remainder of 0 gives 1). +const validatePELegacy = (ie: string): boolean => { + const digit = 11 - (sumWithWeights(ie.slice(0, 13), PE_LEGACY_WEIGHTS) % 11); + + return Number.parseInt(ie.charAt(13), 10) === (digit > 9 ? digit - 10 : digit); +}; + const validatePE: IeValidator = (ie: string) => { + if (checkLength(ie, 14)) return validatePELegacy(ie); if (!checkLength(ie, 9)) return false; const body = ie.slice(0, 7); - const firstSum = calcWeightedSum({ + const firstSum = calculateWeightedSum({ source: ie, length: body.length, startWeight: body.length + 1, }); - const firstDig = calcMod11CheckDigit(firstSum); + const firstDigit = calculateMod11CheckDigit(firstSum); - const bodyWithFirst = body + firstDig; - const secondSum = calcWeightedSum({ + const bodyWithFirst = body + firstDigit; + const secondSum = calculateWeightedSum({ source: bodyWithFirst, length: bodyWithFirst.length, startWeight: bodyWithFirst.length + 1, }); - const secondDig = calcMod11CheckDigit(secondSum); + const secondDigit = calculateMod11CheckDigit(secondSum); return ( - Number.parseInt(ie.charAt(7), 10) === firstDig && - Number.parseInt(ie.charAt(8), 10) === secondDig + Number.parseInt(ie.charAt(7), 10) === firstDigit && + Number.parseInt(ie.charAt(8), 10) === secondDigit ); }; @@ -280,26 +321,26 @@ const validatePR: IeValidator = (ie: string) => { if (!checkLength(ie, 10)) return false; const body = ie.slice(0, 8); - const firstSum = calcWeightedSum({ + const firstSum = calculateWeightedSum({ source: ie, length: body.length, startWeight: body.length - 5, wrapTo: 7, }); - const firstDig = calcMod11CheckDigit(firstSum); + const firstDigit = calculateMod11CheckDigit(firstSum); - const bodyWithFirst = body + firstDig; - const secondSum = calcWeightedSum({ + const bodyWithFirst = body + firstDigit; + const secondSum = calculateWeightedSum({ source: bodyWithFirst, length: bodyWithFirst.length, startWeight: bodyWithFirst.length - 5, wrapTo: 7, }); - const secondDig = calcMod11CheckDigit(secondSum); + const secondDigit = calculateMod11CheckDigit(secondSum); return ( - Number.parseInt(ie.charAt(8), 10) === firstDig && - Number.parseInt(ie.charAt(9), 10) === secondDig + Number.parseInt(ie.charAt(8), 10) === firstDigit && + Number.parseInt(ie.charAt(9), 10) === secondDigit ); }; @@ -307,10 +348,10 @@ const validateRJ: IeValidator = (ie: string) => { if (!checkLength(ie, 8)) return false; const body = ie.slice(0, 7); - const sum = calcWeightedSum({ source: ie, length: body.length, startWeight: 2, wrapTo: 7 }); - const dig = calcMod11CheckDigit(sum); + const sum = calculateWeightedSum({ source: ie, length: body.length, startWeight: 2, wrapTo: 7 }); + const digit = calculateMod11CheckDigit(sum); - return Number.parseInt(ie.charAt(7), 10) === dig; + return Number.parseInt(ie.charAt(7), 10) === digit; }; const validateRN: IeValidator = (ie: string) => { @@ -320,10 +361,10 @@ const validateRN: IeValidator = (ie: string) => { const length = ie.length; const position = length - 1; const body = ie.slice(0, position); - const sum = calcWeightedSum({ source: ie, length: body.length, startWeight: length }); - const dig = calcMod11CheckDigit(sum); + const sum = calculateWeightedSum({ source: ie, length: body.length, startWeight: length }); + const digit = calculateMod11CheckDigit(sum); - return Number.parseInt(ie.charAt(position), 10) === dig; + return Number.parseInt(ie.charAt(position), 10) === digit; }; const validateRO: IeValidator = (ie: string) => { @@ -332,16 +373,16 @@ const validateRO: IeValidator = (ie: string) => { const length = ie.length; const position = length - 1; const body = ie.slice(0, position); - const sum = calcWeightedSum({ source: ie, length: body.length, startWeight: 6, wrapTo: 9 }); + const sum = calculateWeightedSum({ source: ie, length: body.length, startWeight: 6, wrapTo: 9 }); const rest = sum % 11; - let dig = 11 - rest; + let digit = 11 - rest; - if (dig >= 10) { - dig -= 10; + if (digit >= 10) { + digit -= 10; } - return dig === Number.parseInt(ie.charAt(position), 10); + return digit === Number.parseInt(ie.charAt(position), 10); }; const validateRR: IeValidator = (ie: string) => { @@ -367,38 +408,31 @@ const validateRS: IeValidator = (ie: string) => { if (!checkLength(ie, 10)) return false; const body = ie.slice(0, 9); - const sum = calcWeightedSum({ source: ie, length: body.length, startWeight: 2, wrapTo: 9 }); - const dig = calcMod11CheckDigit(sum); + const sum = calculateWeightedSum({ source: ie, length: body.length, startWeight: 2, wrapTo: 9 }); + const digit = calculateMod11CheckDigit(sum); - return Number.parseInt(ie.charAt(9), 10) === dig; + return Number.parseInt(ie.charAt(9), 10) === digit; }; -const calcSPDigit = (body: string, weights: readonly number[]): number => { - let sum = 0; - - for (let i = 0; i < body.length; i++) { - sum += (body.charCodeAt(i) - 48) * weights[i]; - } - - return (sum % 11) % 10; -}; +const calculateSpCheckDigit = (body: string, weights: readonly number[]): number => + (sumWithWeights(body, weights) % 11) % 10; const validateSP: IeValidator = (ie: string) => { if (SP_RURAL_PATTERN.test(ie)) { const body = ie.slice(1, 9); - const dig = calcSPDigit(body, SP_FIRST_WEIGHTS); + const digit = calculateSpCheckDigit(body, SP_FIRST_WEIGHTS); - return Number.parseInt(ie.charAt(9), 10) === dig; + return Number.parseInt(ie.charAt(9), 10) === digit; } if (!SP_COMPANY_PATTERN.test(ie)) return false; - const firstDig = calcSPDigit(ie.slice(0, 8), SP_FIRST_WEIGHTS); - const secondDig = calcSPDigit(ie.slice(0, 11), SP_SECOND_WEIGHTS); + const firstDigit = calculateSpCheckDigit(ie.slice(0, 8), SP_FIRST_WEIGHTS); + const secondDigit = calculateSpCheckDigit(ie.slice(0, 11), SP_SECOND_WEIGHTS); return ( - Number.parseInt(ie.charAt(8), 10) === firstDig && - Number.parseInt(ie.charAt(11), 10) === secondDig + Number.parseInt(ie.charAt(8), 10) === firstDigit && + Number.parseInt(ie.charAt(11), 10) === secondDigit ); }; @@ -411,10 +445,10 @@ const validateTO: IeValidator = (ie: string) => { const body = isLegacy ? ie.slice(0, 2) + ie.slice(4, 10) : ie.slice(0, 8); const position = isLegacy ? 10 : 8; - const sum = calcWeightedSum({ source: body, length: body.length, startWeight: 9 }); - const dig = calcMod11CheckDigit(sum); + const sum = calculateWeightedSum({ source: body, length: body.length, startWeight: 9 }); + const digit = calculateMod11CheckDigit(sum); - return Number.parseInt(ie.charAt(position), 10) === dig; + return Number.parseInt(ie.charAt(position), 10) === digit; }; const IE_VALIDATORS: Record = { @@ -466,29 +500,83 @@ const validateIe = (stateCode: unknown, value: unknown): boolean => { /** * Validates a Brazilian state tax registration number (IE). * - * Per state notes, all of them deliberate and unchanged since 2.3.0: - * - DF: the SINTEGRA page is published but empty, and no SEFAZ-DF roteiro is published either, - * so DF follows the 13 digit AC rule under the prefix 07. - * - GO: the SINTEGRA page is superseded by the SEFAZ-GO roteiro, which is the source of the - * prefixes 10, 11 and 15, of the 10103105 to 10119997 range and of the dual digit - * registration 11094402. - * - RJ: the SINTEGRA page publishes only the modulus rule; the 8 digit length and the weights - * 2, 7, 6, 5, 4, 3 and 2 come from the SINTEGRA validator itself, not from the page. - * - SP: characters other than "P" and digits are rejected on purpose, a deliberate deviation - * from the Regra Geral of the SINTEGRA page, which ignores them instead. - * - AL: the tipo de empresa digit (third position) is not restricted to 0, 3, 5, 7 and 8. - * - PE: only the current 9 digit eFisco format is accepted; the old 14 digit CACEPE format - * documented on the same page is not. - * - TO: the SINTEGRA page documents only the 11 digit form, the one carrying the tipo digits in - * positions 3 and 4. The 9 digit form is also accepted, applying the same modulus 11 rule with - * weights 9 down to 2 to the first eight digits; it is 2.3.0 behavior kept for compatibility - * and no published SEFAZ-TO roteiro covers it. + * Per state notes: + * - DF: the SINTEGRA page gives the format "07 300001 001 - DD" and the AC rule (weights 4, 3, 2, + * 9 down to 2, then 5, 4, 3, 2, 9 down to 2, a 10 or 11 read as 0). It does not say 07 is + * fixed: its bracket marks "07 300001" as a whole as the "número seqüencial". SEFAZ-DF's own + * rule sheet (2013) does: "07 = campo fixo". The prefixes 07 and 08 are accepted, under the + * same rule: DF moved to 08 when the numbers starting with 07 ran out. No SEFAZ-DF act + * announces that change, and its rule sheet still says 07; the CF/DF validator of its service + * portal checks the 13 digits and both check digits and not the prefix. The prefix 08 rests on + * that validator and on the software notes below. + * - GO: the official sources disagree on the prefixes, so all of them are accepted: 10, 11, 15 and + * 20 to 29. The norm, IN nº 946/09-GSF, art. 39, I, as worded by IN nº 1.535/22-GSE (in force + * since 26/10/2022), gives 10 and 20 for a pessoa jurídica and 11 for a pessoa física; the + * Secretaria da Economia has issued 20 to new companies since 13/01/2023, when the range + * starting with 10 ran out. The SINTEGRA page (updated 02/09/2022) gives "10 ou 11 ou 20 a 29". + * The Secretaria's roteiro de crítica (published 20/08/2012) gives "10 ou 11 ou 15", the + * 10103105 to 10119997 range and the dual digit registration 11094402. No norm names 15 or 21 + * to 29. The sources agree on the weights and the modulus, not on the remainder 1: the SINTEGRA + * page (2022) always reads a remainder of 0 or 1 as 0, while the 2012 roteiro reads a remainder + * of 1 as 1 in the range 10103105 to 10119997 and accepts both digits for 11094402. This + * library follows the roteiro, as 2.4.0 did, so `101031050` is rejected. + * - MG: the first check digit is the sum of the product digits subtracted from "a primeira dezena + * exata imediatamente superior". A sum that is already a multiple of ten is read as its own + * ten, so the digit is 0 rather than 10, the only reading that yields a single digit. + * - MS: the prefixes are 28 and 50, the two the SINTEGRA page gives (updated 19/04/2024: "Os dois + * primeiros dígitos serão sempre representados pelos números "28" ou "50""); a SEFAZ-MS + * communiqué on its e-CCE register says new company registrations start with 50. The only norm, + * Resolução/SEF nº 1.344/1999, art. 1º, still gives 28 alone ("o primeiro dígito será sempre + * representado pelo número 2", "o segundo dígito será sempre representado pelo número 8"); no + * act amending it could be found. The check digit rule is the same in all of them. + * - MT: 9 digits, the form Portaria SEFAZ-MT nº 59/2025, art. 8º, § 1º prescribes (as did + * art. 6º of Portaria nº 5/2014, which it revoked), or the 11 digits the SINTEGRA page prints. + * No norm fixes the prefix. No official text gives the check digit rule of the 9 digit form: + * this library reads it as the 11 digit one padded with two leading zeros, which the weights 3 + * and 2 turn into nothing, so "130000019" and "00130000019" are the same registration. + * - PA: the prefixes are 15 and 75 to 79, the six the SINTEGRA page lists ("15: Número Padrão + * Pará", and the same for 75, 76, 77, 78 and 79). SEFA-PA's notice of 08/10/2024 announces 75 + * alone and points to that page for the details; no SEFA-PA notice or act names 76 to 79, which + * rest on the page only. + * - RR: the prefix 24 is required. The SINTEGRA page only says "os dois primeiros indicativos do + * Estado da Federação" and never writes 24; the prefix is read from its ten valid examples and + * its worked example, which all start with 24. + * - SP: letters other than "P" are rejected on purpose, a deliberate deviation from the Regra + * Geral of the SINTEGRA page, which ignores them instead; punctuation and spaces are ignored as + * it says. A produtor rural registration is "P" then "0MMMSSSSD000", the zero included. The + * "P" may be written in lower case: the registration is upper-cased before it is checked, a + * convenience the page does not forbid, since it only tells which characters to ignore. + * - AL: the third digit is the tipo de empresa and must be one of the five the SINTEGRA page lists, + * 0 (Normal), 3 (Produtor Rural), 5 (Substituta), 7 (Micro-Empresa Ambulante) or + * 8 (Micro-Empresa). The page gives the list as the meaning of that digit without calling it + * closed; no SEFAZ-AL text gives another value. + * - AM: the SINTEGRA page gives two branches: "Se Soma < 11 Então Dígito = 11 - Soma" and + * "Senão Quociente = Soma / 11; Se Resto < ou = 1 Então Dígito = 0 Senão Dígito = 11 - Resto". + * It does not define "Resto" and does not say what happens to a Soma of 0 or 1, for which the + * first branch gives 11 or 10, not a digit. This library's reading: "Resto" is Soma modulo 11, + * and a Soma of 0 or 1 falls under "Resto < ou = 1", giving 0. For a Soma of 2 to 10 both + * branches give the same digit, so AM uses the shared modulus 11 rule (weights 9 down to 2, a + * remainder of 0 or 1 giving 0). + * - PE: the 9 digit eFisco number (7 digits and 2 check digits) and the old 14 digit CACEPE + * number (13 digits and 1 check digit), both on the SINTEGRA page. Portaria SF nº 087/2007 + * converted every 14 digit number into a 9 digit one (the first 6 digits dropped, the next 7 + * kept, a new pair of check digits) but set no date after which the old number is void: + * Portaria SF nº 124/2008 removed the end date for its use in the e-Fisco systems, and the + * SEFAZ-PE consolidated legislation still lists Portaria 087/2007 in force. Only AIDFs granted + * from 10/07/2007 on must print the new number. + * - TO: the 9 digit form is the one in force: Portaria SEFAZ-TO nº 676/2002, art. 3º, and the + * RICMS-TO (Decreto nº 2.912/2006), art. 90, give "nove dígitos": the state prefix (2), the + * sequential number (6) and the check digit (1). SEFAZ-TO's roteiro checks it with weights 9 + * down to 2 over the first eight digits. Neither norm fixes the prefix, so it is not checked. + * The SINTEGRA page documents only the old 11 digit form, whose tipo digits in positions 3 and + * 4 are left out of the sum and may only be 01, 02, 03 or 99; it is still accepted, although + * the Portaria let it be used only until the validity of the forms that carry it and, in the + * transit of goods, until 31/12/2004. * - An all zero registration is accepted for every state whose published formula yields a - * check digit of 0 for it (AM, BA with 8 or 9 digits, CE, ES, MG, MT, PB, PE, PI, PR, RJ, RS, - * SC, SE, SP and TO with 9 digits), unlike isValidCpf and isValidCnpj, which reject repeated - * digits. AM is on that list through the second branch of its published formula only: the - * page's first branch, "Se Soma < 11 Então Dígito = 11 - Soma", gives 11 for an all zero - * registration, while the "resto <= 1 ⇒ 0" branch, the one implemented here, gives 0. + * check digit of 0 for it (AM, BA with 8 or 9 digits, CE, ES, MG, MT with 9 or 11 digits, PB, + * PE with 9 digits, PI, PR, RJ, RS, SC, SE, SP and TO with 9 digits), unlike isValidCpf and + * isValidCnpj, which reject repeated digits. AM is on that list through this library's reading + * of its formula (see the AM note): the page's first branch alone would give 11. * * The state can also be passed first and the registration second, `isValidIe('SP', '110042490114')`, * the 2.3.0 form, which still works and is deprecated. The two forms are told apart by the first @@ -506,48 +594,137 @@ const validateIe = (stateCode: unknown, value: unknown): boolean => { * isValidIe({ value: 'P011004243002', stateCode: 'SP' }); // true * isValidIe({ value: '12345', stateCode: 'RJ' }); // false * isValidIe({ value: '109161793', stateCode: 'go' as StateCode }); // true (case-insensitive) + * isValidIe({ value: '200000004', stateCode: 'GO' }); // true (prefix 20) + * isValidIe({ value: '130000019', stateCode: 'MT' }); // true (9 digits) * ``` * * @see Official: http://www.sintegra.gov.br/insc_est.html * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_AC.html * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_AL.html + * "FORMAÇÃO: 24XNNNNND, sendo: 24 – Código do Estado; X – Tipo de empresa (0-Normal, 3-Produtor + * Rural, 5-Substituta, 7- Micro-Empresa Ambulante, 8-Micro-Empresa)". * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_AM.html + * "Soma = (Hx9)+(Gx8)+(Fx7)+(Ex6)+(Dx5)+(Cx4)+(Bx3)+(Ax2); Se Soma < 11 Então Dígito = 11 - Soma; + * Senão Quociente = Soma / 11; Se Resto < ou = 1 Então Dígito = 0; Senão Dígito = 11 - Resto". * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_AP.html * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_BA.html * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_CE.html * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_DF.html - * The page is published but empty: it carries no format, no weights and no worked example, - * and no SEFAZ-DF roteiro is published either, so DF follows the 13 digit AC rule under the - * prefix 07. + * The rule is an image: "07 300001 001 - DD", "07 300001" bracketed as "número seqüencial", + * "001, se matriz; 002, 003, ..., se filial(is)", "multiplicar cada algarismo da inscrição, da + * direita para a esquerda, pela seqüência de 2 a 9", "se o resultado obtido for igual a 10 ou + * 11, o primeiro dígito verificador será igual a zero", worked example 073.00001.001-09. + * @see Official: https://static.fazenda.df.gov.br/arquivos/im1_numero_inscricao_cfdf_sintegra.gif + * SEFAZ-DF, "Cálculo do Digito Verificador do CFDF" (2013): "07 00001 001 - DD: 07 = campo + * fixo; 00001 = número seqüencial; 001 = 001, se matriz; 002, 003, ..., se filial(is)", and the + * same módulo 11 rule as the SINTEGRA page. + * @see Official: https://ww1.receita.fazenda.df.gov.br/iss/situcao-cadastral + * Receita DF service portal: its CF/DF validator requires 13 digits and recomputes both check + * digits (weights 2 to 9 from the right, a remainder of 0 or 1 giving 0), whatever the prefix. + * @see Based on: https://tdn.totvs.com/pages/viewpage.action?pageId=566472384 + * TOTVS release note DFWKFOUNDATION-4046: DF registrations may start with 07 or 08, the numbers + * starting with 07 having run out at 07.999.999, and per the DF tax authority the check digit + * rule did not change. + * @see Based on: https://github.com/caelum/caelum-stella/issues/267 + * @see Based on: https://github.com/caelum/caelum-stella/issues/269 + * Reports of valid DF registrations starting with 08 being rejected. * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_ES.html * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_GO.html - * Superseded for Goiás by the SEFAZ-GO roteiro below: this page still gives the prefixes as - * 10, 11 or 20 to 29 and knows nothing of the special ranges. + * Updated 02/09/2022: "8 dígitos (ABCDEFGH) + 1 dígito verificador (I); onde AB pode ser igual a + * 10 ou 11 ou 20 a 29", the only source of the prefixes 21 to 29. The prefix 15 and the special + * ranges come from the roteiro below. + * @see Official: https://appasp.economia.go.gov.br/Legislacao/arquivos/secretario/in/IN_0946_2009.htm + * IN nº 946/09-GSF, art. 39, I, as worded by IN nº 1.535/22-GSE: "os 2 (dois) primeiros formam + * os números 10 (dez) ou 20 (vinte), que identificam o contribuinte pessoa jurídica, ou 11 (onze), + * que identifica o contribuinte pessoa física". + * @see Official: https://appasp.economia.go.gov.br/Legislacao/arquivos/secretario/in/IN_1535_2022.htm + * IN nº 1.535/22-GSE (DOE 26/10/2022), art. 1º, the wording above. * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_MA.html * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_MG.html + * D1: "Subtrai-se o resultado da soma do item anterior, da primeira dezena exata imediatamente + * superior"; a sum that is a multiple of ten gives 0 (see the MG note above). * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_MS.html + * Updated 19/04/2024: "Os dois primeiros dígitos serão sempre representados pelos números "28" ou + * "50"", then the formula of Resolução/SEF nº 1.344/1999, art. 2º. + * @see Official: https://aacpdappls.net.ms.gov.br/appls/legislacao/serc/legato.nsf/e5c724b4c70cb1da04256b1f005348a8/54d078bcf5f3198a04256ad60073bf8b?OpenDocument + * Resolução/SEF nº 1.344/1999 (DOE 18/05/1999), art. 1º: "I – o primeiro dígito será sempre + * representado pelo número 2; II - o segundo dígito será sempre representado pelo número 8": the + * prefix 28 alone; art. 2º: the check digit rule. + * @see Based on: https://crcms.org.br/sefaz-ms-vai-adotar-novo-sistema-de-cadastro-fiscal-o-e-cce-veja-o-que-vai-mudar-2/ + * CRC-MS (03/09/2025) relaying a SEFAZ-MS communiqué on the e-CCE register: new company (CCIS) + * registrations "será iniciada com o dígito 50", those of the Cadastro da Agropecuária keep 28, + * with no range reserved to a registration type. No SEFAZ-MS page carrying that text was found. * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_MT.html + * "FORMATO: NNNNNNNNNN-D", weights 3, 2 and 9 down to 2, example "0013000001-9": the 11 digit form. + * @see Official: https://app1.sefaz.mt.gov.br/Sistema/Legislacao/legislacaotribut.nsf/173e6c0d2202fdcb03258b1700659f1e/0d06efc6c2fa7bc303258c6c004c4788 + * Portaria SEFAZ-MT nº 59/2025 (DOE 14/04/2025), art. 8º, § 1º: "A identificação numérica do + * contribuinte no CCE/MT é composta de 9 (nove) dígitos, sendo os 8 (oito) primeiros sequenciais + * e o último algarismo configura o dígito verificador." Its art. 59 revokes Portaria nº 5/2014, + * whose art. 6º, as worded by Portaria nº 86/2023, said the same. * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_PA.html + * Updated 11/06/2024: "Composição: 15NNNNNN-D; 75NNNNNN-D; 76NNNNNN-D; 77NNNNNN-D; 78NNNNNN-D; + * 79 NNNNNN-D", each "Número Padrão Pará", weights 2 to 9 from the right, "Se o resto da divisão + * é 0 ou 1, o dígito é 0", worked examples 15999999-5 and 75000002-3. + * @see Official: https://site-sefa-wordpress.sefa.pa.gov.br/2024/10/08/inscricoes-estaduais-podem-iniciar-com-numeracao-75/ + * SEFA-PA, "Inscrições estaduais podem iniciar com numeração 75" (08/10/2024): "A partir do dia + * 07/10/2024 as novas Inscrições Estaduais [...] também poderão ser iniciadas pelo número "75"", + * "75.XXX.XXX-X", and "O detalhamento sobre a IE do Pará está no seguinte link: + * http://www.sintegra.gov.br/Cad_Estados/cad_PA.html". * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_PB.html * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_PE.html + * eFisco: weights 8 down to 2, then 9 down to 2, "Se o resto da divisão for igual a 1 ou 0, o + * primeiro dígito será igual a zero", worked example 0321418-40. "Inscrição Estadual Antiga": "O + * número de inscrição estadual no Cadastro de Contribuintes do Estado de Pernambuco - CACEPE + * possui 14 algarismos (dígitos), sendo 13 principais e 1 verificador", weights 5, 4, 3, 2, 1, 9 + * down to 2, "Quando essa diferença for maior que "9", subtraia "10" unidades", worked example + * 18.1.001.0000004-9. + * @see Official: https://www.sefaz.pe.gov.br/Legislacao/Tributaria/Documents/Legislacao/Portarias/2007/Port087_2007.htm + * Portaria SF nº 087/2007, item I: the CACEPE number, "constituído de 14 (quatorze) algarismos, + * passa a ter 9 (nove)"; item III: from 10/07/2007 new AIDFs print "apenas da nova inscrição". + * @see Official: https://www.sefaz.pe.gov.br/Legislacao/Tributaria/Documents/legislacao/Portarias/2008/Port124_2008.htm + * Portaria SF nº 124/2008: "a conveniência de não estabelecer termo final em relação ao prazo + * referente à utilização da antiga inscrição". * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_PI.html * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_PR.html * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_RJ.html - * Publishes only the modulus rule: the 8 digit length and the weights 2, 7, 6, 5, 4, 3 and 2 - * come from the SINTEGRA validator itself, not from this page. + * The rule is an image: the mask "99.999.99-3" (8 digits), the sum "(N1x2)+(N2x7)+(N3x6)+(N4x5) + * +(N5x4)+(N6x3)+(N7x2)" divided by 11, and "Se o resto da divisão por 11 for menor ou igual à 1 + * (um), Então o dígito verificador será = 0 (zero)". * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_RN.html * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_RO.html * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_RR.html + * "O Número de Inscrição Estadual é composto por 9 Dígitos, sendo os dois primeiros indicativos + * do Estado da Federação", "Dígito Verificador de módulo 9", examples 24006628-1 to 24001340-7. * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_RS.html * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_SC.html * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_SE.html * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_SP.html + * Item II, produtor rural: "Inicia sempre com "P" e apresenta a sequência 0MMMSSSSD000", the + * source of the zero required after the "P"; worked example P-01100424.3/002 (P011004243002). + * The page's closing line, "Fica então formada a inscrição P-011000424.3/002", has one "0" too + * many (14 characters) and is not a valid registration. * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_TO.html - * Documents only the 11 digit form, with the tipo digits 01, 02, 03 and 99 in positions 3 and 4; - * the 9 digit form the validator also accepts is not covered by this page or by any other - * published SEFAZ-TO roteiro. + * "Aplica-se o cálculo "módulo 11" com os algarismos 1,2,5,6,7,8,9,10 da Inscrição Estadual"; + * "Os dígitos "3" e "4" não entram no cálculo", which may only be 01, 02, 03 or 99. + * @see Official: https://web.archive.org/web/20240625000620id_/http://www2.sefaz.to.gov.br/Servicos/Sintegra/calinse.htm + * SEFAZ-TO's own roteiro (archived copy of 25/06/2024; the original address no longer serves + * it): "Cálculo da Inscrição Estadual Nova (Em vigor desde junho de 2.002)", worked example + * 29 022783 6, weights 9 down to 2, "SE O RESTO DA DIVISÃO FOR MENOR QUE DOIS DÍGITO = ZERO". + * @see Official: https://dtri.sefaz.to.gov.br/legislacao/ntributaria/portarias/sefaz/Portaria676-02.htm + * Portaria SEFAZ-TO nº 676/2002, art. 3º: "o número de inscrição estadual é composto de nove + * dígitos que representam: I – os dois primeiros algarismos, o prefixo do Estado; II – os seis + * seguintes, o número seqüencial da inscrição; III – o último algarismo, o dígito verificador ou + * de segurança"; § 2º and § 4º limit the use of the eleven digit number. + * @see Official: https://dtri.sefaz.to.gov.br/legislacao/ntributaria/decretos/Decreto2.912-06.htm + * RICMS-TO, Decreto nº 2.912/2006, art. 90, unchanged up to Decreto nº 7.219/2026: the same nine + * digits as the Portaria's art. 3º. * @see Official: https://goias.gov.br/economia/roteiro-de-critica-da-inscricao-estadual-de-goias/ - * SEFAZ-GO's roteiro de crítica, the source of the Goiás prefixes and special ranges. + * The Secretaria da Economia's roteiro de crítica (20/08/2012): "onde AB pode ser igual a 10 ou + * 11 ou 15", the only source of the prefix 15 and of the special ranges. + * @see Official: https://goias.gov.br/economia/contribuintes-goianos-passam-a-ter-novo-numero-de-inscricao-estadual/ + * Secretaria da Economia notice (20/01/2023): company (Pessoa Jurídica) registrations made from + * 13/01/2023 start with 20, "a faixa de numeração iniciada com o dígito 10 se esgotou"; those of + * Pessoas Físicas still start with 11. */ export function isValidIe(params: IsValidIeParams): boolean; /** diff --git a/src/is-valid-landline-phone/constants.ts b/src/is-valid-landline-phone/constants.ts index 9f680b87a..c031fbfd6 100644 --- a/src/is-valid-landline-phone/constants.ts +++ b/src/is-valid-landline-phone/constants.ts @@ -1 +1,17 @@ +/** + * The first digits (N8) a Brazilian 8 digit user access code may carry: art. 11, I, "a" of + * Resolução Anatel nº 749/2022, `"2" a "6": Serviço Telefônico Fixo Comutado (STFC) e Serviço de + * Comunicação Multimídia (SCM)`; every other digit is reserva técnica. The "57" STFC-FATB series + * of art. 11, II was revoked by Resolução nº 768/2024. + * + * Resolução Anatel nº 777/2025, art. 21, rewrites art. 11 with effect from 1 March 2027 (the + * date the Anatel compiled text gives it, after Acórdão nº 202/2025): only "2" to "5" stay STFC, + * and the SCM moves to 9 digit numbers starting with 6 (art. 12 in the wording of art. 22). That + * rule is scheduled, not in force, so 6 is still accepted; from that date a landline starting + * with 6 will have to be rejected. + * + * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 + * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2025/2022-resolucao-777 + * Resolução Anatel nº 777/2025, arts. 21 and 22: the arts. 11 and 12 in force on 1 March 2027. + */ export const LANDLINE_VALID_FIRST_NUMBERS = [2, 3, 4, 5, 6]; diff --git a/src/is-valid-landline-phone/is-valid-landline-phone.ts b/src/is-valid-landline-phone/is-valid-landline-phone.ts index bf51912fb..637074110 100644 --- a/src/is-valid-landline-phone/is-valid-landline-phone.ts +++ b/src/is-valid-landline-phone/is-valid-landline-phone.ts @@ -14,6 +14,11 @@ const isValidLandlineFirstNumber = (value: string): boolean => { * A Brazilian country code (`+55`, `0055` or a bare `55`) is accepted and removed before * validation, under the rule documented in `parsePhone`. * + * The number is the DDD plus 8 digits, the first of them 2 to 6: art. 11, I, "a" of Resolução + * Anatel nº 749/2022 destines `"2" a "6"` to the STFC (fixed line) and the SCM. From 1 March 2027 + * Resolução Anatel nº 777/2025, art. 21, narrows that to `"2" a "5"`, leaving 6 to 9 digit SCM + * numbers; the change is scheduled, not in force, so 6 is still accepted. + * * @param {string} value - The phone number to validate. * @returns {boolean} True if the phone number is a valid landline phone, false otherwise. * @@ -26,6 +31,9 @@ const isValidLandlineFirstNumber = (value: string): boolean => { * ``` * * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 + * Resolução Anatel nº 749/2022, art. 11, I, "a". + * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2025/2022-resolucao-777 + * Resolução Anatel nº 777/2025, art. 21: the art. 11 in force on 1 March 2027. */ export const isValidLandlinePhone = (value: string): boolean => { if (typeof value !== "string") return false; diff --git a/src/is-valid-license-plate/constants.ts b/src/is-valid-license-plate/constants.ts new file mode 100644 index 000000000..fbcceda9c --- /dev/null +++ b/src/is-valid-license-plate/constants.ts @@ -0,0 +1,4 @@ +/** The old Brazilian plate sequence, `LLLNNNN`, over the sanitized value. */ +export const OLD_FORMAT_REGEX = /^[A-Z]{3}[0-9]{4}$/; +/** The Mercosul plate sequence, `LLLNLNN`, over the sanitized value. */ +export const MERCOSUL_REGEX = /^[A-Z]{3}[0-9][A-Z][0-9]{2}$/; diff --git a/src/is-valid-license-plate/is-valid-license-plate.ts b/src/is-valid-license-plate/is-valid-license-plate.ts index 3882bb9cd..33cc64cf6 100644 --- a/src/is-valid-license-plate/is-valid-license-plate.ts +++ b/src/is-valid-license-plate/is-valid-license-plate.ts @@ -1,13 +1,14 @@ -import { getFormatLicensePlate } from "../get-format-license-plate/get-format-license-plate"; +import { sanitizeToAlphanumeric } from "../_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric"; +import { MERCOSUL_REGEX, OLD_FORMAT_REGEX } from "./constants"; /** * Validates if a Brazilian license plate (placa de carro ou moto) is valid. * * Supports the old Brazilian format (ABC-1234) and the Mercosul format (ABC1D23), the single * sequence Resolução CONTRAN nº 969/2022 defines for every vehicle, motorcycles included. - * Accepts the usual mask characters (hyphens, spaces) and is case-insensitive, mirroring - * `getFormatLicensePlate`/`parseLicensePlate` (single source of truth for the supported - * formats). + * Accepts the usual mask characters (hyphens, spaces) and is case-insensitive. The two formats + * checked here are the ones `getFormatLicensePlate` names, and it returns `null` exactly when + * this returns false. * * @param {string} value - The license plate value to be validated. * @returns {boolean} True if the license plate is valid, false otherwise. @@ -34,5 +35,10 @@ import { getFormatLicensePlate } from "../get-format-license-plate/get-format-li * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/resolucao9692022.pdf * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/resolucao9692022anexos.pdf */ -export const isValidLicensePlate = (value: string): boolean => - getFormatLicensePlate(value) !== null; +export const isValidLicensePlate = (value: string): boolean => { + if (typeof value !== "string") return false; + + const parsed = sanitizeToAlphanumeric(value); + + return OLD_FORMAT_REGEX.test(parsed) || MERCOSUL_REGEX.test(parsed); +}; diff --git a/src/is-valid-mobile-phone/constants.ts b/src/is-valid-mobile-phone/constants.ts index 63835132b..763b97d7f 100644 --- a/src/is-valid-mobile-phone/constants.ts +++ b/src/is-valid-mobile-phone/constants.ts @@ -1,14 +1,30 @@ /** - * The first digit (N9) a Brazilian mobile access code may carry, per numbering rule. - * - * Version 1 is the pre-Resolução 749/2022 set kept for 2.3.0 compatibility. Version 2 is the - * set of art. 12, I, "a" of the resolution: `“7”, "8" e “9”: Serviço Móvel Pessoal (SMP), - * ressalvado o disposto no inciso II deste artigo`, the ressalva being art. 12, II, "a", - * `“700”: Serviço Móvel Global por Satélite (SMGS)`, a series outside the SMP. + * The first digits (N9) a Brazilian mobile access code may carry, under both numbering rules: art. + * 12, I, "a" of Resolução Anatel nº 749/2022, `“7”, "8" e “9”: Serviço Móvel Pessoal (SMP), + * ressalvado o disposto no inciso II deste artigo`. The ressalva is art. 12, II, "a", `“700”: + * Serviço Móvel Global por Satélite (SMGS)`, a series outside the SMP, which only version 2 leaves + * out. A first digit of 6 is not SMP under either version. * * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 */ -export const MOBILE_VALID_FIRST_NUMBERS_V1 = [6, 7, 8, 9]; -export const MOBILE_VALID_FIRST_NUMBERS_V2 = [7, 8, 9]; +export const MOBILE_VALID_FIRST_NUMBERS = [7, 8, 9]; +/** + * The `700` series version 2 rejects: art. 12, II, "a" of Resolução Anatel nº 749/2022 gives it to + * the Serviço Móvel Global por Satélite (SMGS), not to the SMP. + * + * Resolução Anatel nº 777/2025, art. 22, rewrites the whole of art. 12 with effect from 1 March + * 2027 (the date the Anatel compiled text gives it, after Acórdão nº 202/2025): N9 "6" becomes + * the Serviço de Comunicação Multimídia (SCM), only "8" and "9" stay SMP, the `700` series + * becomes "SMGS e SMP por Satélite", and every other first digit, a "7" outside `700` included, + * becomes reserva técnica. That rule is scheduled, not in force, so neither version applies it + * yet. A version 2 that follows it from that date will need to accept only 8 and 9 as the first + * digit, plus the `700` series as satellite SMP, and to reject every other `7` number; 6 stays + * rejected, now as SCM. + * + * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 + * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2025/2022-resolucao-777 + * Resolução Anatel nº 777/2025, art. 22: the new art. 12 of Resolução nº 749/2022, in force on 1 + * March 2027. + */ export const MOBILE_SATELLITE_PREFIX = "700"; diff --git a/src/is-valid-mobile-phone/is-valid-mobile-phone.test.ts b/src/is-valid-mobile-phone/is-valid-mobile-phone.test.ts index 9161ec8f9..3e6a4326e 100644 --- a/src/is-valid-mobile-phone/is-valid-mobile-phone.test.ts +++ b/src/is-valid-mobile-phone/is-valid-mobile-phone.test.ts @@ -36,10 +36,21 @@ describe("isValidMobilePhone", () => { expect(isValidMobilePhone("+1 415 555 2671")).toBe(false); }); - test("when version 2 is requested but the first number digit is the version-1-only 6", () => { + test("when the first number digit is 6, which is not SMP, under either version", () => { + // 2.4.0 returned true for the default version 1 + expect(isValidMobilePhone("11612345678")).toBe(false); + expect(isValidMobilePhone("11612345678", { version: 1 })).toBe(false); + expect(isValidMobilePhone("+55 (11) 61234-5678")).toBe(false); expect(isValidMobilePhone("11612345678", { version: 2 })).toBe(false); }); + test("when the first number digit is below 7, under either version", () => { + for (const digit of ["0", "1", "2", "3", "4", "5", "6"]) { + expect(isValidMobilePhone(`11${digit}12345678`)).toBe(false); + expect(isValidMobilePhone(`11${digit}12345678`, { version: 2 })).toBe(false); + } + }); + test("when version 2 is requested and the number is in the 700 satellite series", () => { expect(isValidMobilePhone("11700123456", { version: 2 })).toBe(false); expect(isValidMobilePhone("(11) 70012-3456", { version: 2 })).toBe(false); @@ -65,8 +76,10 @@ describe("isValidMobilePhone", () => { test("when is a valid mobile phone version 1", () => { expect(isValidMobilePhone("11712345678", { version: 1 })).toBe(true); - expect(isValidMobilePhone("11612345678", { version: 1 })).toBe(true); + expect(isValidMobilePhone("11712345678")).toBe(true); + expect(isValidMobilePhone("11812345678")).toBe(true); expect(isValidMobilePhone("11700123456", { version: 1 })).toBe(true); + expect(isValidMobilePhone("11700123456")).toBe(true); }); test("when it carries the country code", () => { diff --git a/src/is-valid-mobile-phone/is-valid-mobile-phone.ts b/src/is-valid-mobile-phone/is-valid-mobile-phone.ts index d6afbdf7a..20a867f33 100644 --- a/src/is-valid-mobile-phone/is-valid-mobile-phone.ts +++ b/src/is-valid-mobile-phone/is-valid-mobile-phone.ts @@ -2,30 +2,22 @@ import { PHONE_NATIONAL_MAX_LENGTH } from "../_internals/constants/phone"; import { isValidDDD } from "../_internals/is-valid-ddd/is-valid-ddd"; import { normalizePhone } from "../_internals/normalize-phone/normalize-phone"; import { type PhoneVersion } from "../is-valid-phone/is-valid-phone"; -import { - MOBILE_SATELLITE_PREFIX, - MOBILE_VALID_FIRST_NUMBERS_V1, - MOBILE_VALID_FIRST_NUMBERS_V2, -} from "./constants"; +import { MOBILE_SATELLITE_PREFIX, MOBILE_VALID_FIRST_NUMBERS } from "./constants"; export type { PhoneVersion } from "../is-valid-phone/is-valid-phone"; /** Options of `isValidMobilePhone`. */ export type IsValidMobilePhoneOptions = { - /** Numbering rule to enforce over the 11 digit number: `1` (default) accepts 6, 7, 8 or 9 as the first number digit, `2` accepts 7, 8 or 9 and rejects the `700` series. */ + /** Numbering rule to enforce over the 11 digit number: both accept 7, 8 or 9 as the first number digit; `1` (default) also accepts the `700` series, `2` rejects it. */ version?: PhoneVersion; }; const isValidMobileFirstNumber = (value: string, version?: PhoneVersion): boolean => { - const firstDigit = value.charCodeAt(2) - 48; + if (!MOBILE_VALID_FIRST_NUMBERS.includes(value.charCodeAt(2) - 48)) return false; - if (!version || version === 1) { - return MOBILE_VALID_FIRST_NUMBERS_V1.includes(firstDigit); - } + if (!version || version === 1) return true; - if (value.startsWith(MOBILE_SATELLITE_PREFIX, 2)) return false; - - return MOBILE_VALID_FIRST_NUMBERS_V2.includes(firstDigit); + return !value.startsWith(MOBILE_SATELLITE_PREFIX, 2); }; /** @@ -34,11 +26,10 @@ const isValidMobileFirstNumber = (value: string, version?: PhoneVersion): boolea * A Brazilian country code (`+55`, `0055` or a bare `55`) is accepted and removed before * validation, under the rule documented in `parsePhone`. * - * The `version` option controls which mobile numbering rule is enforced: - * - `1` (default): accepts the legacy 11-digit format, whose first number digit - * (right after the DDD) may be 6, 7, 8 or 9. - * - `2`: enforces the current format, whose first number digit must be 7, 8 or 9 and whose - * `700` series is left out. + * The first number digit (right after the DDD) must be 7, 8 or 9 under both numbering rules. + * The `version` option only decides the `700` series: + * - `1` (default): accepts it. + * - `2`: rejects it, since it belongs to the satellite service. * * @param {string} value - The phone number to validate. * @param {IsValidMobilePhoneOptions} options - Optional validation options. @@ -51,21 +42,31 @@ const isValidMobileFirstNumber = (value: string, version?: PhoneVersion): boolea * isValidMobilePhone("11987654321", { version: 2 }); // true * isValidMobilePhone("11712345678", { version: 1 }); // true * isValidMobilePhone("11712345678", { version: 2 }); // true (7 is SMP as well) - * isValidMobilePhone("11612345678", { version: 2 }); // false (6 is Reserva Técnica) + * isValidMobilePhone("11612345678"); // false (6 is not SMP) + * isValidMobilePhone("11612345678", { version: 2 }); // false + * isValidMobilePhone("11700123456"); // true (version 1 keeps the 700 series) * isValidMobilePhone("11700123456", { version: 2 }); // false (the 700 series is satellite) * isValidMobilePhone("+55 11 98765-4321"); // true * ``` * - * `version: 1` (the default) is the pre-Resolução 749/2022 rule, which also accepts a leading - * 6, kept for 2.3.0 compatibility. `version: 2` enforces art. 12, I, "a" of the resolution, - * `“7”, "8" e “9”: Serviço Móvel Pessoal (SMP), ressalvado o disposto no inciso II deste - * artigo`, so 6 is Reserva Técnica and is rejected. + * Both versions enforce art. 12, I, "a" of Resolução Anatel nº 749/2022, `“7”, "8" e “9”: + * Serviço Móvel Pessoal (SMP), ressalvado o disposto no inciso II deste artigo`, so a first + * number digit of 6 is rejected. Up to 2.4.0 `version: 1` accepted it, although it is not SMP. * * That ressalva is art. 12, II, "a", `“700”: Serviço Móvel Global por Satélite (SMGS)`: the * `700` series is not SMP, so `version: 2` rejects `isValidMobilePhone("11700123456")`. * `version: 1` does not carve the series out and accepts it, for 2.3.0 compatibility. * + * Resolução Anatel nº 777/2025, art. 22, gives art. 12 a new wording in force on 1 March 2027: + * "6" SCM, "8" and "9" SMP, `700` "SMGS e SMP por Satélite", and every other first digit, + * including a "7" outside `700`, reserva técnica. It is scheduled, not in force, so it is not + * applied yet; from that date a `version: 2` that follows it will have to accept only 8 and 9 + * (plus the `700` series, as satellite SMP) and reject the other `7` numbers. + * * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 + * Resolução Anatel nº 749/2022, art. 12, I, "a": `“7”, "8" e “9”: Serviço Móvel Pessoal (SMP)`. + * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2025/2022-resolucao-777 + * Resolução Anatel nº 777/2025, art. 22: the art. 12 in force on 1 March 2027. */ export const isValidMobilePhone = (value: string, options?: IsValidMobilePhoneOptions): boolean => { if (typeof value !== "string") return false; diff --git a/src/is-valid-ncm/is-valid-ncm.ts b/src/is-valid-ncm/is-valid-ncm.ts index ab08009cc..4669bfa62 100644 --- a/src/is-valid-ncm/is-valid-ncm.ts +++ b/src/is-valid-ncm/is-valid-ncm.ts @@ -43,6 +43,8 @@ const getCache = (): Set => { * ``` * * @see Official: https://portalunico.siscomex.gov.br/classif/api/publico/nomenclatura/download/json + * Nomenclature of the Portal Único Siscomex. The bundled codes are the 10,515 eight digit codes of + * the file "Vigente em 26/09/2026", whose latest act is Resolução Gecex nº 926/2026. */ export const isValidNcm = (value: string | number): boolean => { if (!isLookupCode(value)) return false; diff --git a/src/get-nfe-key-info/constants.ts b/src/is-valid-nfe-key/constants.ts similarity index 65% rename from src/get-nfe-key-info/constants.ts rename to src/is-valid-nfe-key/constants.ts index 33c8298b1..6bd0caaaa 100644 --- a/src/get-nfe-key-info/constants.ts +++ b/src/is-valid-nfe-key/constants.ts @@ -1,6 +1,6 @@ /** - * The `mod` (modelo do documento) values `getNfeKeyInfo` supports, every one of them a document - * whose "chave de acesso" is the same 44 digit string built the same way: 55 NF-e, 57 CT-e, + * The `mod` (modelo do documento) values `isValidNfeKey` supports, every one of them a document + * whose "chave de acesso" is the same 44 character string built the same way: 55 NF-e, 57 CT-e, * 58 MDF-e, 62 NFCom, 63 BP-e, 64 GTV-e (the CT-e Guia de Transporte de Valores), 65 NFC-e, * 66 NF3e and 67 CT-e OS (Conhecimento de Transporte Eletrônico para Outros Serviços). * @@ -10,7 +10,7 @@ */ export const VALID_MODELS = ["55", "57", "58", "62", "63", "64", "65", "66", "67"] as const; -/** One of the `mod` values `getNfeKeyInfo` supports. */ +/** One of the `mod` values `isValidNfeKey` supports. */ type ValidModel = (typeof VALID_MODELS)[number]; /** @@ -26,7 +26,9 @@ type ValidModel = (typeof VALID_MODELS)[number]; * 5 contingência FS-DA, 7 autorização pela SVC-RS and 8 autorização pela SVC-SP. CT-e OS * (field D27) drops 3 and 4, and the GTV-e (field D15) uses 1, 2 contingência off-line, 7 and * 8. Rule G011 of the same annex, "(7=SVC-RS e 8=SVC-SP)", is what makes 8 a real code here, - * even though the NF-e MOC never assigns it. + * even though the NF-e MOC never assigns it. The current package, PL_CTe_400_RTC, enumerates only + * 1 and 2 for the GTV-e; 7 and 8 are kept so the keys of GTV-e authorised under the earlier + * PL_CTe_400 still validate, since a key carries no schema version. * * MDF-e (MDF-e MOC 3.00b Anexo I, domain D7): 1 normal, 2 contingência off-line and 3 Regime * Especial NFF. NFCom, BP-e and NF3e (their own Anexo I, domain D7): 1 normal and @@ -90,18 +92,26 @@ export const FORBIDDEN_CODES: readonly string[] = [ export const FORBIDDEN_CODE_MODELS: readonly string[] = ["55", "65"]; /** - * Shape the key has to be written in once the prefix is stripped: the digits, optionally split - * into the printed groups of 4 by whitespace or the usual mask characters, a run of them between - * two groups included, the same rule the CPF, CNPJ, CAEPF and CNS regexes of this library follow. - * A separator inside a group of 4, or any other character, is rejected instead of being stripped. - * The group count is left open so the 44 digit length is still checked where the key is read. + * Shape the key has to be written in once the prefix is stripped and its letters upper cased: + * the characters, optionally split into the printed groups of 4 by whitespace or the usual mask + * characters, a run of them between two groups included, the same rule the CPF, CNPJ, CAEPF and + * CNS regexes of this library follow. A separator inside a group of 4, or any other character, is + * rejected instead of being stripped. The groups take letters too, so a key with an alphanumeric + * CNPJ can be grouped like any other; which positions may hold one, and the 44 character length, + * are checked against `NFE_KEY_REGEX` once the separators are gone. */ -export const FORMAT_REGEX = /^\d{4}(?:[\s.\-/]*\d{4})*$/; +export const FORMAT_REGEX = /^[0-9A-Z]{4}(?:[\s.\-/]*[0-9A-Z]{4})*$/; -/** Start of the document number (nNF) inside the 44 digit key. */ +/** Position of the model (mod) inside the 44 character key. */ +export const MODEL_START = 20; + +/** End (exclusive) of the model (mod) inside the 44 character key. */ +export const MODEL_END = 22; + +/** Start of the document number (nNF) inside the 44 character key. */ export const NUMBER_START = 25; -/** End (exclusive) of the document number (nNF) inside the 44 digit key. */ +/** End (exclusive) of the document number (nNF) inside the 44 character key. */ export const NUMBER_END = 34; /** @@ -110,3 +120,18 @@ export const NUMBER_END = 34; * model repeats the same regex for its own number field. */ export const ABSENT_NUMBER = "000000000"; + +/** Position of the emission type (tpEmis) inside the 44 character key. */ +export const EMISSION_TYPE_INDEX = 34; + +/** + * Position of `nSiteAutoriz` for the models that carry it, and start of the 8 digit numeric code + * (cNF) for every other model. + */ +export const AUTHORIZATION_SITE_INDEX = 35; + +/** Start of the 7 digit numeric code (cNF) of the models that carry `nSiteAutoriz`. */ +export const SHORT_CODE_START = 36; + +/** Position of the check digit (cDV), which is also the end (exclusive) of the numeric code. */ +export const CHECK_DIGIT_INDEX = 43; diff --git a/src/is-valid-nfe-key/is-valid-nfe-key.test.ts b/src/is-valid-nfe-key/is-valid-nfe-key.test.ts index e2b744ce2..60e0e65c5 100644 --- a/src/is-valid-nfe-key/is-valid-nfe-key.test.ts +++ b/src/is-valid-nfe-key/is-valid-nfe-key.test.ts @@ -165,8 +165,8 @@ describe("isValidNfeKey", () => { }); test("when the check digit does not match", () => { - const brokenDv = `${VALID_B.slice(0, 43)}${VALID_B.at(-1) === "8" ? "7" : "8"}`; - expect(isValidNfeKey(brokenDv)).toBe(false); + const brokenCheckDigit = `${VALID_B.slice(0, 43)}${VALID_B.at(-1) === "8" ? "7" : "8"}`; + expect(isValidNfeKey(brokenCheckDigit)).toBe(false); }); test("when there is garbage before the digits, since the format is anchored at the start", () => { @@ -239,6 +239,67 @@ describe("isValidNfeKey", () => { } }); + describe("with the alphanumeric CNPJ of NT Conjunta 2025.001 (TChNFe [0-9]{6}[0-9A-Z]{12}[0-9]{26})", () => { + // cUF 35, AAMM 2607, CNPJ 12ABC34501DE35, mod 55, serie 001, nNF 123, tpEmis 1, + // cNF 10203040. Check digit by hand, each character at its ASCII code minus 48 (A=17, B=18, + // C=19, D=20, E=21), weights 2-9 from the right: the weighted sum is 756, 756 mod 11 = 8, + // so cDV = 11 - 8 = 3. + const ALPHANUMERIC = "35260712ABC34501DE35550010000001231102030403"; + + test("should accept the key of CNPJ 12ABC34501DE35", () => { + expect(isValidNfeKey(ALPHANUMERIC)).toBe(true); + }); + + test("should accept it masked in groups of 4 and behind the XML Id prefix", () => { + expect(isValidNfeKey("3526 0712 ABC3 4501 DE35 5500 1000 0001 2311 0203 0403")).toBe(true); + expect(isValidNfeKey("3526.0712.ABC3.4501.DE35.5500.1000.0001.2311.0203.0403")).toBe(true); + expect(isValidNfeKey(`NFe${ALPHANUMERIC}`)).toBe(true); + }); + + test("should reject a separator inside a group of 4 of it", () => { + expect(isValidNfeKey("3526 0712 AB C3 4501 DE35 5500 1000 0001 2311 0203 0403")).toBe(false); + }); + + test("should read lower case letters as upper case, the case folding isValidCnpj applies to version 2", () => { + expect(isValidNfeKey(ALPHANUMERIC.toLowerCase())).toBe(true); + expect(isValidNfeKey("35260712abC34501dE35550010000001231102030403")).toBe(true); + }); + + test("should accept a letter in position 7 and in position 18, the two ends of the CNPJ root and order", () => { + // Same fields with A in position 7 and Z in position 18: cDV recalculated to 9. + expect(isValidNfeKey("352607A2ABC34501DZ35550010000001231102030409")).toBe(true); + }); + + test("should reject a letter in positions 1 to 6", () => { + // Letter in position 6 (the month) and in position 3 (the year), cDV recalculated. + expect(isValidNfeKey("35260A12ABC34501DE35550010000001231102030400")).toBe(false); + expect(isValidNfeKey("35A60712ABC34501DE35550010000001231102030406")).toBe(false); + }); + + test("should reject a letter in positions 19 and 20, the CNPJ check digits", () => { + expect(isValidNfeKey("35260712ABC34501DEA5550010000001231102030408")).toBe(false); + expect(isValidNfeKey("35260712ABC34501DE3A550010000001231102030405")).toBe(false); + }); + + test("should reject a letter after position 20, in the model or the document number", () => { + expect(isValidNfeKey("35260712ABC34501DE35A50010000001231102030406")).toBe(false); + expect(isValidNfeKey("35260712ABC34501DE355500100000A1231102030400")).toBe(false); + }); + + test("should accept the alphanumeric CNPJ in the key of the other models, NFCom and CT-e", () => { + expect(isValidNfeKey("35260712ABC34501DE35620010000001231102030405")).toBe(true); + expect(isValidNfeKey("35260712ABC34501DE35570010000001231102030400")).toBe(true); + }); + + test("should compute the check digit with the letters valued at their ASCII code minus 48", () => { + const accepted = NFE_CHECK_DIGITS.filter((digit) => + isValidNfeKey(`${ALPHANUMERIC.slice(0, 43)}${digit}`), + ); + + expect(accepted).toEqual(["3"]); + }); + }); + describe("properties", () => { test("should accept at most one check digit for any 43 digit base", () => { fc.assert( diff --git a/src/is-valid-nfe-key/is-valid-nfe-key.ts b/src/is-valid-nfe-key/is-valid-nfe-key.ts index 8d837a916..fc1d7f96c 100644 --- a/src/is-valid-nfe-key/is-valid-nfe-key.ts +++ b/src/is-valid-nfe-key/is-valid-nfe-key.ts @@ -1,13 +1,49 @@ -import { getNfeKeyInfo } from "../get-nfe-key-info/get-nfe-key-info"; +import { IBGE_UF_CODES } from "../_internals/constants/ibge-uf-codes"; +import { NFE_KEY_REGEX, XML_ID_PREFIX_REGEX } from "../_internals/constants/nfe-key"; +import { mod11 } from "../_internals/mod11/mod11"; +import { sanitizeToAlphanumeric } from "../_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric"; +import { + ABSENT_NUMBER, + AUTHORIZATION_SITE_INDEX, + CHECK_DIGIT_INDEX, + EMISSION_TYPE_INDEX, + EMISSION_TYPES_BY_MODEL, + FORBIDDEN_CODES, + FORBIDDEN_CODE_MODELS, + FORMAT_REGEX, + MODEL_END, + MODEL_START, + NUMBER_END, + NUMBER_START, + VALID_MODELS, +} from "./constants"; + +/** + * Rule B03-10 of the NF-e MOC. The two models it covers, NF-e and NFC-e, spend no digit on + * `nSiteAutoriz`, so their `cNF` is always the 8 digits before the check digit. + * + * @param {string} model - The model of the key. + * @param {string} key - The 44 characters of the key. + * @returns {boolean} True when the rule turns the numeric code down. + */ +const isForbiddenCode = (model: string, key: string): boolean => { + if (!FORBIDDEN_CODE_MODELS.includes(model)) return false; + + const code = key.slice(AUTHORIZATION_SITE_INDEX, CHECK_DIGIT_INDEX); + + return ( + FORBIDDEN_CODES.includes(code) || Number(code) === Number(key.slice(NUMBER_START, NUMBER_END)) + ); +}; /** * Validates a DF-e (Documento Fiscal eletrônico) access key (chave de acesso). * - * Covers every document whose access key is the same 44 digit string: NF-e (modelo 55), NFC-e + * Covers every document whose access key is the same 44 character string: NF-e (modelo 55), NFC-e * (65), CT-e (57), MDF-e (58), CT-e OS (67, the Conhecimento de Transporte Eletrônico para * Outros Serviços), GTV-e (64, the CT-e Guia de Transporte de Valores), BP-e (63), NF3e (66) * and NFCom (62). The CF-e-SAT (59) is out: its 44 position "chave de consulta" is composed - * differently. The 44 digits may be split into the printed groups of 4 by whitespace, `.`, `-` + * differently. The 44 characters may be split into the printed groups of 4 by whitespace, `.`, `-` * or `/`, a run of them between two groups included, the same mask rule `isValidCpf` and * `isValidCnpj` follow; a separator inside a group of 4, or any other character, is rejected * instead of being stripped. The `NFe`, `CTe`, `MDFe`, `BPe`, `NF3e` and `NFCom` prefixes found @@ -16,13 +52,23 @@ import { getNfeKeyInfo } from "../get-nfe-key-info/get-nfe-key-info"; * * The key is `cUF(2) AAMM(4) CNPJ/CPF(14) mod(2) serie(3) nNF(9) tpEmis(1) cNF(8) cDV(1)`, with * NFCom and NF3e spending position 36 on `nSiteAutoriz` and leaving 7 digits for `cNF`. + * Every position is a digit except 7 to 18, the root and order of the issuer's CNPJ, which may + * be the upper case letters of an alphanumeric CNPJ: the current schemas type the key as + * `[0-9]{6}[0-9A-Z]{12}[0-9]{26}`, where the older ones (NF-e PL_009) had `[0-9]{44}`. A letter + * anywhere else, the two CNPJ check digits in positions 19 and 20 included, is rejected. Lower + * case letters are read as upper case, the same case folding `isValidCnpj` applies to an + * alphanumeric CNPJ (`{ version: 2 }`), even though the schema pattern itself admits upper + * case only. A CPF issuer (a key the NF-e allows since NT 2018.001) is always 11 digits left + * padded with zeros, so a letter there always belongs to an alphanumeric CNPJ. * `tpEmis` must be one of the codes the MOC of that model assigns, so the accepted set changes * with the model: 1 to 7 and 9 for NF-e and NFC-e, `{1, 3, 4, 5, 7, 8}` for the CT-e, * `{1, 5, 7, 8}` for the CT-e OS, `{1, 2, 7, 8}` for the GTV-e, `{1, 2, 3}` for the MDF-e and * `{1, 2}` for the BP-e, the NF3e and the NFCom. Code 8, the authorização pela SVC-SP, is * assigned by the CT-e MOC only, never by the NF-e one. - * The check digit (`cDV`) is a modulus 11 over the first 43 digits, weights 2-9 cycling from - * the right, where a remainder of 0 or 1 maps to check digit 0. + * The check digit (`cDV`) is a modulus 11 over the first 43 characters, weights 2-9 cycling from + * the right, where a remainder of 0 or 1 maps to check digit 0. Each character is valued at its + * ASCII code minus 48, which is the digit itself for `0` to `9` and 17 to 42 for `A` to `Z`, the + * same values the alphanumeric CNPJ check digits use. * * For NF-e and NFC-e the numeric code is also checked against rule B03-10 of the NF-e MOC, * which forbids the twenty repeated and sequential `cNF` values it lists and a `cNF` equal to @@ -50,8 +96,22 @@ import { getNfeKeyInfo } from "../get-nfe-key-info/get-nfe-key-info"; * NF3e MOC 1.00a, Visão Geral and Anexo I: modelo 66 and `nSiteAutoriz`. * @see Official: https://dfe-portal.svrs.rs.gov.br/NFCOM/Documentos * NFCom MOC 1.00a, Visão Geral and Anexo I: modelo 62 and `nSiteAutoriz`. + * @see Official: https://www.nfe.fazenda.gov.br/portal/exibirArquivo.aspx?conteudo=5ZkvIZt10mQ%3D + * Nota Técnica Conjunta 2025.001 (CNPJ alfanumérico nos DF-e), chave de acesso: the check digit + * is the modulus 11 "considerando o valor decimal dos caracteres com base na tabela ASCII, + * subtraindo-se 48", and positions 7 to 18 take the letters of the alphanumeric CNPJ. + * @see Official: https://www.nfe.fazenda.gov.br/portal/listaConteudo.aspx?tipoConteudo=04BIflQt1aY= + * NF-e NT 2026.004 v1.01 and the schema package PL_010 (v1.30): `tiposBasico_v4.00.xsd`, type + * `TChNFe`, ``, in production from + * 01/07/2026. The CT-e (PL_CTe_400_RTC, `TChDFe`), MDF-e (PL_MDFe_300b, `TChMDFe`) and NFCom + * (RTC) schemas carry the same pattern. + * @see Based on: https://github.com/nfephp-org/sped-nfe/tree/master/schemes + * NFePHP mirror of the NF-e schema packages, where `PL_010_V1.30` has the `TChNFe` pattern + * above and `PL_009_V4` the older `[0-9]{44}`. * @see Based on: https://github.com/nfephp-org/sped-common/blob/master/src/Keys.php - * NFePHP `Keys::build`/`Keys::isValid` reference implementation. + * NFePHP `Keys::build`/`Keys::isValid` reference implementation, whose `ACCESS_KEY_PATTERN` is + * `/^[0-9]{6}[A-Z0-9]{12}[0-9]{26}$/` and whose `verifyingDigit` values each character as + * `ord($char) - 48`. * @see Based on: https://github.com/vmarchesin/br-validate-dfe-access-key * Second reference implementation and source of additional test vectors. * @@ -61,9 +121,43 @@ import { getNfeKeyInfo } from "../get-nfe-key-info/get-nfe-key-info"; * isValidNfeKey("NFe35170458716523000119550010000000121000123458"); // true (XML Id prefix) * isValidNfeKey("3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458"); // true (masked) * isValidNfeKey("3517.0458.7165.2300.0119.5500.1000.0000.1210.0012.3458"); // true (any of the mask characters) + * isValidNfeKey("35260712ABC34501DE35550010000001231102030403"); // true (alphanumeric CNPJ 12ABC34501DE35) + * isValidNfeKey("35260712ABC34501DEA5550010000001231102030408"); // false (a letter in position 19) * isValidNfeKey("351 70458716523000119550010000000121000123458"); // false (a separator inside a group of 4) * isValidNfeKey("99170458716523000119550010000000121000123458"); // false (invalid cUF) * isValidNfeKey("35170458716523000119010010000000121000123450"); // false (invalid mod) * ``` */ -export const isValidNfeKey = (value: string): boolean => getNfeKeyInfo(value) !== null; +export const isValidNfeKey = (value: string): boolean => { + if (typeof value !== "string") return false; + + const body = value.trim().replace(XML_ID_PREFIX_REGEX, "").trimStart().toUpperCase(); + + if (!FORMAT_REGEX.test(body)) return false; + + const key = sanitizeToAlphanumeric(body); + + if (!NFE_KEY_REGEX.test(key)) return false; + + if (IBGE_UF_CODES[key.slice(0, 2)] === undefined) return false; + + const month = Number(key.slice(4, 6)); + + if (month < 1 || month > 12) return false; + + const modelDigits = key.slice(MODEL_START, MODEL_END); + const model = VALID_MODELS.find((candidate) => candidate === modelDigits); + + if (model === undefined) return false; + + if (key.slice(NUMBER_START, NUMBER_END) === ABSENT_NUMBER) return false; + + if (!EMISSION_TYPES_BY_MODEL[model].includes(Number(key[EMISSION_TYPE_INDEX]))) return false; + + if (isForbiddenCode(model, key)) return false; + + return ( + mod11(key.slice(0, CHECK_DIGIT_INDEX), { variant: "arrecadacao" }) === + Number(key[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 cb2fc1b0b..44acb6d8e 100644 --- a/src/is-valid-nfse-key/is-valid-nfse-key.ts +++ b/src/is-valid-nfse-key/is-valid-nfse-key.ts @@ -57,11 +57,18 @@ const isValidTaxId = (typeDigit: string, registration: string): boolean => { * 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. + * An alphanumeric CNPJ is accepted since the restricted-production (RTC) schema bundle of + * 2026-07-27 widened the key to it. The service has handled the alphanumeric CNPJ in production + * since 2026-08-10 ("Atualizações e Implantações"), while the production bundle of the "Documentação + * atual" page (2026-02-09) still types the key as digits only. Letters are read in upper case, lower case input included, + * as `isValidCnpj` with version 2 reads them. No official document states how a letter enters + * the check digit of the key: none of the NFS-e technical notes (001 to 009), the Anexo I v1.01 + * nor the Perguntas e Respostas v1.00 of 08/09/2026 (which still calls the key "50 dígitos + * numéricos") does, and NT Conjunta 2025.001, whose ASCII minus 48 rule covers the DF-e key, + * lists its scope as "NFe, NFCe, CTe, CTe OS, GTVe, MDFe, BPe, BPe TM, NF3e e NFCom", leaving the + * NFS-e out. The rule used here is an analogy with that NT and with the CNPJ's own check digits, + * not an official rule: each character counts as its ASCII code minus 48, `A` as 17. For a key + * of digits only it gives the same digit as before. * * @param {string} value - The access key value to be validated. * @returns {boolean} True if the access key is valid, false otherwise. @@ -78,16 +85,21 @@ 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}". 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". + * "PRE[0-9]{8}(1[0-9]{14}|2[0-9A-Z]{14})[0-9]{33}". + * @see Official: https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/producao-restrita/esquemas-nfse-rtc-v1-01-20260727.zip + * The schema bundle v1.01-20260727 itself, on the "Produção Restrita" page of the Portal NFS-e. + * @see Official: https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/atualizacoes-e-implantacoes + * "Atualizações e Implantações": "PRODUÇÃO 10/08/2026 Evolução para tratamento do CNPJ + * alfanumérico". * @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". + * todos os caracteres [...] pelos números correspondentes da tabela ASCII subtraindo 48". Its + * §1 limits it to "NFe, NFCe, CTe, CTe OS, GTVe, MDFe, BPe, BPe TM, NF3e e NFCom": the NFS-e is + * not among them, so it is applied here by analogy only. * * @example * ```typescript diff --git a/src/is-valid-passport/is-valid-passport.ts b/src/is-valid-passport/is-valid-passport.ts index 1721497f3..c523dcac0 100644 --- a/src/is-valid-passport/is-valid-passport.ts +++ b/src/is-valid-passport/is-valid-passport.ts @@ -23,7 +23,10 @@ import { PASSPORT_REGEX } from "./constants"; * isValidPassport("DC-221345extra") // false * * The Polícia Federal passport FAQ states the layout: "Ele é composto por duas letras - chamadas - * de 'série', e por seis dígitos subsequentes. Por exemplo: Passaporte CS265436." + * de 'série', e por seis dígitos subsequentes. Por exemplo: Passaporte CS265436." It is the only + * official source of the layout: neither the Decreto nº 5.978/2006 (Regulamento de Documentos de + * Viagem) nor the IN nº 173-DG/PF/2020, as amended up to the IN nº 283/2024, defines the number, + * and the FAQ lists no forbidden letter, so none is rejected. * * @see Official: https://www.gov.br/pf/pt-br/assuntos/passaporte * @see Official: https://www.gov.br/pf/pt-br/assuntos/passaporte/ajuda/duvidas_/caderneta/caderneta-numero-onde-fica-e diff --git a/src/is-valid-phone/is-valid-phone.test.ts b/src/is-valid-phone/is-valid-phone.test.ts index 5ed45db46..f5e196ff0 100644 --- a/src/is-valid-phone/is-valid-phone.test.ts +++ b/src/is-valid-phone/is-valid-phone.test.ts @@ -65,6 +65,12 @@ describe("isValidPhone", () => { expect(isValidPhone("11700123456", { version: 2 })).toBe(false); }); + test("when the mobile number starts with 6, which is not SMP, under the default version too", () => { + // 2.4.0 returned true for the default version 1 + expect(isValidPhone("11612345678")).toBe(false); + expect(isValidPhone("11612345678", { version: 1 })).toBe(false); + }); + test("when the kind is not accepted", () => { expect(isValidPhone("11987654321", { accept: ["landline"] })).toBe(false); expect(isValidPhone("1130000000", { accept: ["mobile"] })).toBe(false); @@ -88,6 +94,7 @@ describe("isValidPhone", () => { test("when is a valid mobile phone version 1", () => { expect(isValidPhone("11712345678", { version: 1 })).toBe(true); + expect(isValidPhone("11700123456")).toBe(true); }); test("when it carries the country code", () => { diff --git a/src/is-valid-phone/is-valid-phone.ts b/src/is-valid-phone/is-valid-phone.ts index 363be4fe8..72dd6397c 100644 --- a/src/is-valid-phone/is-valid-phone.ts +++ b/src/is-valid-phone/is-valid-phone.ts @@ -9,7 +9,7 @@ import { isValidMobilePhone } from "../is-valid-mobile-phone/is-valid-mobile-pho import { isValidServicePhone } from "../is-valid-service-phone/is-valid-service-phone"; import { DEFAULT_ACCEPT } from "./constants"; -/** The Brazilian mobile numbering rule to enforce over the 11 digit number: `1` the legacy one (6, 7, 8 or 9), `2` the current one (7, 8 or 9, without the `700` series). */ +/** The Brazilian mobile numbering rule to enforce over the 11 digit number: both take a first number digit of 7, 8 or 9; `1` also takes the `700` series, `2` leaves it out. */ export type PhoneVersion = 1 | 2; /** The kinds of Brazilian phone number `isValidPhone` can accept. */ @@ -33,9 +33,10 @@ export type IsValidPhoneOptions = { * `["mobile", "landline"]`, i.e. geographic numbers only. Add `"service"` to also accept the * non-geographic numbers recognized by `isValidServicePhone`; pass `[]` to accept none. * - * `options.version` is forwarded to `isValidMobilePhone` and only affects mobile numbers: - * `1` (the default) accepts a first number digit of 6, 7, 8 or 9, and `2` the 7, 8 and 9 of - * Resolução Anatel nº 749/2022, art. 12, I, "a", minus its `700` satellite series. + * `options.version` is forwarded to `isValidMobilePhone` and only affects mobile numbers: both + * versions take the first number digits 7, 8 and 9 of Resolução Anatel nº 749/2022, art. 12, I, + * "a", so a leading 6 is rejected (up to 2.4.0 the default `1` accepted it); `2` also leaves out + * the `700` satellite series, which `1` (the default) keeps. * * @param {string} value - The phone number to validate. * @param {IsValidPhoneOptions} options - Optional validation options. @@ -49,6 +50,7 @@ export type IsValidPhoneOptions = { * isValidPhone("11987654321", { version: 2 }); // true * isValidPhone("11712345678", { version: 2 }); // true (7 is SMP as well) * isValidPhone("11700123456", { version: 2 }); // false (the 700 series is satellite) + * isValidPhone("11612345678"); // false (6 is not SMP) * isValidPhone("1130000000"); // true (landline) * isValidPhone("+55 11 98765-4321"); // true * isValidPhone("08001234567"); // false (service numbers are not accepted by default) diff --git a/src/is-valid-pis/is-valid-pis.ts b/src/is-valid-pis/is-valid-pis.ts index ddd493ee0..53a772143 100644 --- a/src/is-valid-pis/is-valid-pis.ts +++ b/src/is-valid-pis/is-valid-pis.ts @@ -20,19 +20,24 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d * * The eSocial MOS states the NIS must have 11 numeric digits including the check digit, and the * SIRC technical manual confirms the check digit is verified with módulo 11; neither publishes - * the weight vector used below, which follows the community reference cited as `Based on:`. + * the weight vector used below, which follows the community reference cited as `Based on:`. No + * official document found publishes it: the Caixa layouts that carry the number (Cadastro NIS + * em lote, GRRF) only ask for a "Número de PIS/PASEP válido", or for a numeric value "maior que + * zero", without saying how the check digit is computed. * * @see Official: https://www.gov.br/inss/pt-br/direitos-e-deveres/inscricao-e-contribuicao/inscricao * @see Official: https://www.gov.br/esocial/pt-br/documentacao-tecnica/manuais/mos-manual-de-orientacao-do-esocial-vs-2-4.pdf * @see Official: https://www.sirc.gov.br/wp-content/uploads/manual_sirc_recomendacoes_tecnicas_v7.pdf + * @see Official: https://www.caixa.gov.br/Downloads/fgts-grrf-aplicativo-arquivos/Leiaute_Folha_Pgto_GRRF_v204.pdf + * Caixa, Leiaute GRRF v2.04, field 6 "PIS/PASEP": "Número de PIS/PASEP válido", with no algorithm. * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/pis.py */ export const isValidPis = (pis: string): boolean => { if (typeof pis !== "string") return false; - const hasInvalidChars = /[^0-9\s().,*/-]/.test(pis); + const hasInvalidCharacters = /[^0-9\s().,*/-]/.test(pis); - if (hasInvalidChars) return false; + if (hasInvalidCharacters) return false; const digits = sanitizeToDigits(pis); diff --git a/src/is-valid-pix-key/is-valid-pix-key.test.ts b/src/is-valid-pix-key/is-valid-pix-key.test.ts index 12b79d43f..18476fcba 100644 --- a/src/is-valid-pix-key/is-valid-pix-key.test.ts +++ b/src/is-valid-pix-key/is-valid-pix-key.test.ts @@ -46,7 +46,11 @@ describe("isValidPixKey", () => { test("when it is not a key of any accepted kind", () => { expect(isValidPixKey("chave pix")).toBe(false); expect(isValidPixKey("11257245286")).toBe(false); - expect(isValidPixKey("fulano@example")).toBe(false); + expect(isValidPixKey("fulano@")).toBe(false); + }); + + test("when the e-mail carries a &, which DICT API 2.6.0 took out of the pattern", () => { + expect(isValidPixKey("a&b@example.com")).toBe(false); }); }); @@ -65,6 +69,12 @@ describe("isValidPixKey", () => { expect(isValidPixKey("fulano_da_silva.recebedor@example.com")).toBe(true); }); + test("for an e-mail that only matches the DICT pattern, not isValidEmail", () => { + for (const email of ["a{b}@example.com", ".ab@example.com", "a@localhost"]) { + expect(isValidPixKey(email)).toBe(true); + } + }); + test("for a mobile phone", () => { expect(isValidPixKey("+5561912345678")).toBe(true); expect(isValidPixKey("(11) 98765-4321")).toBe(true); diff --git a/src/is-valid-pix-key/is-valid-pix-key.ts b/src/is-valid-pix-key/is-valid-pix-key.ts index 547b9bef4..f3cdac3df 100644 --- a/src/is-valid-pix-key/is-valid-pix-key.ts +++ b/src/is-valid-pix-key/is-valid-pix-key.ts @@ -1,4 +1,7 @@ -import { type PixKeyType, getPixKeyInfo } from "../get-pix-key-info/get-pix-key-info"; +import { + type PixKeyType, + detectPixKeyType, +} from "../_internals/detect-pix-key-type/detect-pix-key-type"; /** Options of `isValidPixKey`. */ export type IsValidPixKeyOptions = { @@ -9,10 +12,10 @@ export type IsValidPixKeyOptions = { /** * Validates a Pix key (chave Pix) against the DICT key formats. * - * A value is valid when `getPixKeyInfo` recognizes it as a CPF, a CNPJ, an e-mail address, a - * Brazilian mobile phone number or a random key (EVP), and when that kind is listed in - * `options.accept`. The manual registers a "número de telefone celular", so a landline is not - * a valid phone key. + * A value is valid when it is recognized, by the rules `getPixKeyInfo` documents, as a CPF, a + * CNPJ, an e-mail address, a Brazilian mobile phone number or a random key (EVP), and when that + * kind is listed in `options.accept`. The manual registers a "número de telefone celular", so a + * landline is not a valid phone key. * * @param {string} value - The Pix key to validate. * @param {IsValidPixKeyOptions} [options] - Optional validation options. @@ -31,17 +34,19 @@ export type IsValidPixKeyOptions = { * * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/II_ManualdePadroesparaIniciacaodoPix.pdf * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/API-DICT.html - * DICT (Diretório de Identificadores de Contas Transacionais) API specification, key format - * reference. + * DICT (Diretório de Identificadores de Contas Transacionais) API specification 2.12.1, key + * format reference. Tag "Chave", type `EMAIL`: "E-mail deve possuir no máximo 77 caracteres e + * deve ser em minúsculo", with the pattern this library applies verbatim, which has had no `&` + * since version 2.6.0. * @see Official: https://github.com/bacen/pix-api * Pix (SPI) OpenAPI spec. */ export const isValidPixKey = (value: string, options?: IsValidPixKeyOptions): boolean => { - const key = getPixKeyInfo(value); + const type = detectPixKeyType(value); - if (!key) return false; + if (type === null) return false; const accept = options?.accept; - return Array.isArray(accept) ? accept.includes(key.type) : true; + return Array.isArray(accept) ? accept.includes(type) : true; }; diff --git a/src/is-valid-pix-payload/is-valid-pix-payload.test.ts b/src/is-valid-pix-payload/is-valid-pix-payload.test.ts index 61952ae3b..a8b9e52de 100644 --- a/src/is-valid-pix-payload/is-valid-pix-payload.test.ts +++ b/src/is-valid-pix-payload/is-valid-pix-payload.test.ts @@ -1,5 +1,6 @@ import * as fc from "fast-check"; +import { crc16Ccitt } from "../_internals/crc16-ccitt/crc16-ccitt"; import { cpfs } from "../_internals/test/arbitraries"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; import { generatePixPayload } from "../generate-pix-payload/generate-pix-payload"; @@ -14,12 +15,77 @@ const BACEN_DYNAMIC = const BACEN_COMPOSITE = "00020101021226700014br.gov.bcb.pix2548pix.example.com/8b3da2f39a4140d1a91abd93113bd4415204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***80740014br.gov.bcb.pix2552pix.example.com/rec/2353c790eefb11eaadc10242ac1200026304FB42"; -const BRCODE_MANUAL = +const BACEN_COMPOSITE_STATIC = + "00020126580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-4266554400005204000053039865406100.505802BR5913Fulano de Tal6008BRASILIA62070503***80740014br.gov.bcb.pix2552pix.example.com/rec/2353c790eefb11eaadc10242ac12000263042875"; + +const BRCODE_MANUAL_AS_PUBLISHED = "00020104141234567890123426580014BR.GOV.BCB.PIX0136123e4567-e12b-12d1-a456-42665544000027300012BR.COM.OUTRO011001234567895204000053039865406123.455802BR5917NOME DO RECEBEDOR6008BRASILIA61087007490062190515RP12345678-201980390012BR.COM.OUTRO01190123.ABCD.3456.WXYZ6304AD38"; -const COMMUNITY_STATIC = +const COMMUNITY_STATIC_AS_PUBLISHED = "00020126580014br.gov.bcb.pix0136bee05743-4291-4f3c-9259-595df1307ba1520400005303986540510.005802BR5914Alexandre Lima6019Presidente Prudente62180514Um-Id-Qualquer6304D475"; +const STATIC_BODY = BACEN_STATIC.slice(0, -8); + +const A25 = "A".repeat(25); + +const A26 = "A".repeat(26); + +const withCrc = (body: string): string => { + const withoutCrc = `${body}6304`; + + return withoutCrc + crc16Ccitt(withoutCrc); +}; + +const tlv = (id: string, value: string): string => + `${id}${value.length.toString().padStart(2, "0")}${value}`; + +const withNewCrc = (payload: string): string => withCrc(payload.slice(0, -8)); + +// The published payloads with the "-" dropped from the txid (§2.6.2 allows letters and digits +// only) and, for the community one, the city cut to the 15 characters of object 60. +const BRCODE_MANUAL = withNewCrc( + BRCODE_MANUAL_AS_PUBLISHED.replace("62190515RP12345678-2019", "62180514RP123456782019"), +); + +const COMMUNITY_STATIC = withNewCrc( + COMMUNITY_STATIC_AS_PUBLISHED.replace( + "6019Presidente Prudente62180514Um-Id-Qualquer", + "6010Presidente62160512UmIdQualquer", + ), +); + +const buildBody = (merchantAccountInformation: string, rest: string): string => + tlv("00", "01") + tlv("26", tlv("00", "br.gov.bcb.pix") + merchantAccountInformation) + rest; + +const COMMON_OBJECTS = "5204000053039865802BR5913Fulano de Tal6008BRASILIA"; + +const withKey = (key: string): string => + withCrc(buildBody(tlv("01", key), `${COMMON_OBJECTS}62070503***`)); + +const withUrl = (url: string): string => + withCrc(buildBody(tlv("25", url), `${COMMON_OBJECTS}62070503***`)); + +const withAdditionalData = (additionalData: string): string => + withCrc(buildBody(tlv("01", "12345678909"), COMMON_OBJECTS + tlv("62", additionalData))); + +const withTxid = (txid: string): string => withAdditionalData(tlv("05", txid)); + +const withMerchant = (name: string, city: string): string => + withCrc( + buildBody( + tlv("01", "12345678909"), + `5204000053039865802BR${tlv("59", name)}${tlv("60", city)}62070503***`, + ), + ); + +const withAmount = (amount: string): string => + withCrc( + STATIC_BODY.replace( + "5303986", + `530398654${amount.length.toString().padStart(2, "0")}${amount}`, + ), + ); + describe("isValidPixPayload", () => { describe("should return true", () => { test("for the static QR Code example in the Bacen 'Manual de Padrões para Iniciação do Pix'", () => { @@ -34,14 +100,32 @@ describe("isValidPixPayload", () => { expect(isValidPixPayload(BACEN_COMPOSITE)).toBe(true); }); - test("for the multi-arrangement payload from the 'Manual do BR Code' §2.2", () => { + test("for the multi-arrangement payload from the 'Manual do BR Code' §2.2, with a txid the Pix manual allows", () => { expect(isValidPixPayload(BRCODE_MANUAL)).toBe(true); }); - test("for a widely published community payload with an amount and a txid", () => { + test("for a widely published community payload with an amount and a txid, brought within the Pix manual", () => { expect(isValidPixPayload(COMMUNITY_STATIC)).toBe(true); }); + test("for the composite QR Code example of §2.8.2, a key with an amount and a recurrence location", () => { + expect(isValidPixPayload(BACEN_COMPOSITE_STATIC)).toBe(true); + }); + + test("for a key in each DICT form", () => { + for (const key of [ + "12345678909", + "00038166000105", + "12ABC34501DE35", + "+5561912345678", + "fulano_da_silva.recebedor@example.com", + "a{b}@example.com", + "123e4567-e12b-12d1-a456-426655440000", + ]) { + expect(isValidPixPayload(withKey(key))).toBe(true); + } + }); + test("when the payload is surrounded by whitespace", () => { expect(isValidPixPayload(` ${BACEN_STATIC}\n`)).toBe(true); }); @@ -50,14 +134,6 @@ describe("isValidPixPayload", () => { expect(isValidPixPayload(BACEN_STATIC.replace(/1D3D$/, "1d3d"))).toBe(true); }); - test("when the additional data template is absent", () => { - expect( - isValidPixPayload( - "00020126580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-4266554400005204000053039865802BR5913Fulano de Tal6008BRASILIA6304740C", - ), - ).toBe(true); - }); - test("when a key payload marks itself single use with the point of initiation method 12", () => { expect( isValidPixPayload( @@ -82,13 +158,55 @@ describe("isValidPixPayload", () => { ).toBe(true); }); - test("for a Pix Saque BR Code, whose fss (26-03) goes with a transaction amount of zero", () => { + test("for a Pix Saque BR Code, whose fss (26-03) goes with the amount of the withdrawal", () => { + expect( + isValidPixPayload( + withCrc( + "00020126700014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-426655440000030812345678520400005303986540550.005802BR5913Fulano de Tal6008BRASILIA62070503***", + ), + ), + ).toBe(true); + }); + + test("for a Pix Saque BR Code with a transaction amount of zero, the 0.00 the Pix API has a saque state", () => { expect( isValidPixPayload( "00020126700014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-42665544000003081234567852040000530398654040.005802BR5913Fulano de Tal6008BRASILIA62070503***63043A07", ), ).toBe(true); }); + + test("for a dynamic payload with a transaction amount of zero, which the payer ignores", () => { + expect( + isValidPixPayload( + withCrc( + "00020126480014br.gov.bcb.pix2526pix.example.com/qr/v2/123452040000530398654040.005802BR5913Fulano de Tal6008BRASILIA62070503***", + ), + ), + ).toBe(true); + }); + + test("when the amount carries the decimal mark with no decimals, as the EMV examples allow", () => { + expect(isValidPixPayload(withAmount("98."))).toBe(true); + expect(isValidPixPayload(withAmount("98"))).toBe(true); + expect(isValidPixPayload(withAmount("98.7"))).toBe(true); + expect(isValidPixPayload(withAmount("98.73"))).toBe(true); + expect(isValidPixPayload(withAmount("0.01"))).toBe(true); + }); + }); + + test("should accept a payload with a PSP location whose 62-05 is filled, since §2.7 has the payer ignore it", () => { + expect(isValidPixPayload(BACEN_DYNAMIC)).toBe(true); + + for (const txid of ["ABC123", "7978c0c97ea847e78e8849634473c1f1", "RP12345678-2019"]) { + const field = `05${String(txid.length).padStart(2, "0")}${txid}`; + const body = BACEN_DYNAMIC.slice(0, -8).replace( + "62070503***", + `62${String(field.length).padStart(2, "0")}${field}`, + ); + + expect(isValidPixPayload(withCrc(body))).toBe(true); + } }); describe("should return false", () => { @@ -138,6 +256,14 @@ describe("isValidPixPayload", () => { expect(isValidPixPayload("000X016304EAB2")).toBe(false); }); + test("when an object declares the length 00, below the 01 to 99 of the EMV specification", () => { + expect(isValidPixPayload(withCrc(STATIC_BODY))).toBe(true); + expect(isValidPixPayload(withCrc(`${STATIC_BODY}6100`))).toBe(false); + const emptyTxid = STATIC_BODY.replace("62070503***", "62040500"); + + expect(isValidPixPayload(withCrc(emptyTxid))).toBe(false); + }); + test("when the payload format indicator is not 01", () => { expect( isValidPixPayload( @@ -146,6 +272,20 @@ describe("isValidPixPayload", () => { ).toBe(false); }); + test("when the payload format indicator is not the first object", () => { + const rest = STATIC_BODY.slice("000201".length); + + expect(isValidPixPayload(withCrc(`000201${rest}`))).toBe(true); + const afterMerchantAccountInformation = `${rest.slice(0, 62)}000201${rest.slice(62)}`; + + expect(isValidPixPayload(withCrc(afterMerchantAccountInformation))).toBe(false); + expect(isValidPixPayload(withCrc(`${rest}000201`))).toBe(false); + }); + + test("when the payload format indicator is repeated later with another value", () => { + expect(isValidPixPayload(withCrc(`${STATIC_BODY}000202`))).toBe(false); + }); + test("when the point of initiation method is neither 11 nor 12", () => { expect( isValidPixPayload( @@ -154,18 +294,39 @@ describe("isValidPixPayload", () => { ).toBe(false); }); - test("when a key payload states a transaction amount of zero without the fss of a Pix Saque", () => { + test("when a key payload states a transaction amount of zero", () => { expect( isValidPixPayload( "00020126580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-42665544000052040000530398654040.005802BR5913Fulano de Tal6008BRASILIA62070503***63042451", ), ).toBe(false); + expect(isValidPixPayload(withAmount("0"))).toBe(false); + expect(isValidPixPayload(withAmount("0."))).toBe(false); + expect(isValidPixPayload(withAmount("00.00"))).toBe(false); + }); + + test("when the amount is not digits with at most one decimal mark and two decimals", () => { + expect(isValidPixPayload(withAmount(".5"))).toBe(false); + expect(isValidPixPayload(withAmount("98.."))).toBe(false); + expect(isValidPixPayload(withAmount("9.8.7"))).toBe(false); + expect(isValidPixPayload(withAmount("98.735"))).toBe(false); + expect(isValidPixPayload(withAmount("98,73"))).toBe(false); + expect(isValidPixPayload(withAmount("3 705"))).toBe(false); + expect(isValidPixPayload(withAmount("x98"))).toBe(false); + }); + + test("when the amount is longer than 13 characters", () => { + expect(isValidPixPayload(withAmount("9999999999.99"))).toBe(true); + expect(isValidPixPayload(withAmount("99999999999.9"))).toBe(true); + expect(isValidPixPayload(withAmount("99999999999.99"))).toBe(false); }); test("when the fss of a Pix Saque is not the 8 digits of an ISPB", () => { expect( isValidPixPayload( - "00020126690014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-4266554400000307123456752040000530398654040.005802BR5913Fulano de Tal6008BRASILIA62070503***630450C2", + withCrc( + "00020126690014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-4266554400000307123456752040000530398654050.005802BR5913Fulano de Tal6008BRASILIA62070503***", + ), ), ).toBe(false); }); @@ -194,6 +355,21 @@ describe("isValidPixPayload", () => { ).toBe(false); }); + test("when the merchant category code is not 4 digits", () => { + const withCode = (code: string): string => + withCrc( + STATIC_BODY.replace("52040000", `52${code.length.toString().padStart(2, "0")}${code}`), + ); + + expect(isValidPixPayload(withCode("0000"))).toBe(true); + expect(isValidPixPayload(withCode("5812"))).toBe(true); + expect(isValidPixPayload(withCode("abcd"))).toBe(false); + expect(isValidPixPayload(withCode("58a2"))).toBe(false); + expect(isValidPixPayload(withCode("581"))).toBe(false); + expect(isValidPixPayload(withCode("58123"))).toBe(false); + expect(isValidPixPayload(withCode("x5812"))).toBe(false); + }); + test("when the merchant name is missing", () => { expect( isValidPixPayload( @@ -234,6 +410,74 @@ describe("isValidPixPayload", () => { ).toBe(false); }); + test("when the additional data template is absent, although the manual has 62-05 always present", () => { + expect( + isValidPixPayload( + "00020126580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-4266554400005204000053039865802BR5913Fulano de Tal6008BRASILIA6304740C", + ), + ).toBe(false); + }); + + test("when the additional data template carries no txid (62-05)", () => { + expect(isValidPixPayload(withAdditionalData("0103***"))).toBe(false); + }); + + test("when the txid is neither *** nor 1 to 25 letters and digits (§2.6.2)", () => { + expect(isValidPixPayload(withTxid(A25))).toBe(true); + expect(isValidPixPayload(withTxid("abcXYZ019"))).toBe(true); + expect(isValidPixPayload(withTxid(A26))).toBe(false); + expect(isValidPixPayload(withTxid("RP12345678-2019"))).toBe(false); + expect(isValidPixPayload(withTxid("Um Id"))).toBe(false); + expect(isValidPixPayload(withTxid("pedido_42"))).toBe(false); + expect(isValidPixPayload(withTxid("**"))).toBe(false); + }); + + test("when the payloads are taken as published, with a txid and a city the Pix manual does not allow", () => { + expect(isValidPixPayload(BRCODE_MANUAL_AS_PUBLISHED)).toBe(false); + expect(isValidPixPayload(COMMUNITY_STATIC_AS_PUBLISHED)).toBe(false); + }); + + test("when the key is not written in the DICT form (§2.5.1)", () => { + for (const key of [ + "123.456.789-09", + "11987654321", + "(61) 91234-5678", + "Fulano@Example.com", + "123E4567-E12B-12D1-A456-426655440000", + "12345678900", + "some-key", + " 12345678909", + ]) { + expect(isValidPixPayload(withKey(key))).toBe(false); + } + }); + + test("when the PSP location is longer than 77 characters (§2.5.2)", () => { + const url77 = `pix.example.com/${"a".repeat(61)}`; + + expect(url77).toHaveLength(77); + expect(isValidPixPayload(withUrl(url77))).toBe(true); + expect(isValidPixPayload(withUrl(`${url77}a`))).toBe(false); + }); + + test("when the merchant name is longer than 25 characters", () => { + expect(isValidPixPayload(withMerchant(A25, "BRASILIA"))).toBe(true); + expect(isValidPixPayload(withMerchant(A26, "BRASILIA"))).toBe(false); + }); + + test("when the merchant city is longer than 15 characters", () => { + expect(isValidPixPayload(withMerchant("Fulano de Tal", "Presidente Prud"))).toBe(true); + expect(isValidPixPayload(withMerchant("Fulano de Tal", "Presidente Prudente"))).toBe(false); + }); + + test("when the country code is not the uppercase BR of ISO 3166-1", () => { + const lowercase = STATIC_BODY.replace("5802BR", "5802br"); + const mixedCase = STATIC_BODY.replace("5802BR", "5802Br"); + + expect(isValidPixPayload(withCrc(lowercase))).toBe(false); + expect(isValidPixPayload(withCrc(mixedCase))).toBe(false); + }); + test("when it is a boleto or free text", () => { expect(isValidPixPayload("10491443385511900000200000000141325230000093423")).toBe(false); expect(isValidPixPayload("pix copia e cola")).toBe(false); diff --git a/src/is-valid-pix-payload/is-valid-pix-payload.ts b/src/is-valid-pix-payload/is-valid-pix-payload.ts index a07623165..33e7733e3 100644 --- a/src/is-valid-pix-payload/is-valid-pix-payload.ts +++ b/src/is-valid-pix-payload/is-valid-pix-payload.ts @@ -1,35 +1,176 @@ -import { getPixPayloadInfo } from "../get-pix-payload-info/get-pix-payload-info"; +import { + PIX_ABSENT_TXID, + PIX_ADDITIONAL_DATA_ID, + PIX_COUNTRY_CODE, + PIX_COUNTRY_CODE_ID, + PIX_CRC_LENGTH, + PIX_CRC_TAG, + PIX_DYNAMIC_POINT_OF_INITIATION, + PIX_KEY_ID, + PIX_MERCHANT_CATEGORY_CODE_ID, + PIX_MERCHANT_CATEGORY_CODE_REGEX, + PIX_MERCHANT_CITY_ID, + PIX_MERCHANT_CITY_MAX_LENGTH, + PIX_MERCHANT_NAME_ID, + PIX_MERCHANT_NAME_MAX_LENGTH, + PIX_PAYLOAD_FORMAT_INDICATOR, + PIX_PAYLOAD_FORMAT_INDICATOR_ID, + PIX_PAYLOAD_FORMAT_INDICATOR_OBJECT, + PIX_POINT_OF_INITIATION_ID, + PIX_STATIC_POINT_OF_INITIATION, + PIX_TRANSACTION_AMOUNT_ID, + PIX_TRANSACTION_AMOUNT_MAX_LENGTH, + PIX_TRANSACTION_CURRENCY, + PIX_TRANSACTION_CURRENCY_ID, + PIX_TXID_ID, + PIX_TXID_REGEX, + PIX_URL_ID, + PIX_WITHDRAWAL_FACILITATOR_ID, +} from "../_internals/constants/pix"; +import { crc16Ccitt } from "../_internals/crc16-ccitt/crc16-ccitt"; +import { findPixMerchantAccountInformation } from "../_internals/find-pix-merchant-account-information/find-pix-merchant-account-information"; +import { isValidPixUrl } from "../_internals/is-valid-pix-url/is-valid-pix-url"; +import { type TlvFields, parseTlv } from "../_internals/parse-tlv/parse-tlv"; +import { getPixKeyInfo } from "../get-pix-key-info/get-pix-key-info"; + +const AMOUNT_REGEX = /^\d+(?:\.\d{0,2})?$/; + +const WITHDRAWAL_FACILITATOR_REGEX = /^\d{8}$/; + +const CRC_TAG_LENGTH = PIX_CRC_TAG.length + PIX_CRC_LENGTH; + +const isValidCrc = (payload: string): boolean => { + const checksum = payload.slice(-PIX_CRC_LENGTH); + + if (payload.slice(-CRC_TAG_LENGTH, -PIX_CRC_LENGTH) !== PIX_CRC_TAG) return false; + + // A checksum that is not four uppercase hexadecimal digits can never equal crc16Ccitt's + // always-hexadecimal output, so the comparison below turns it down on its own. + return crc16Ccitt(payload.slice(0, -PIX_CRC_LENGTH)) === checksum.toUpperCase(); +}; + +const isValidPointOfInitiation = (fields: TlvFields): boolean => { + const pointOfInitiation = fields[PIX_POINT_OF_INITIATION_ID]; + + return ( + pointOfInitiation === undefined || + pointOfInitiation === PIX_STATIC_POINT_OF_INITIATION || + pointOfInitiation === PIX_DYNAMIC_POINT_OF_INITIATION + ); +}; + +const isValidMerchantAccountInformation = (merchantAccountInformation: TlvFields): boolean => { + const key = merchantAccountInformation[PIX_KEY_ID]; + const url = merchantAccountInformation[PIX_URL_ID]; + const withdrawalFacilitator = merchantAccountInformation[PIX_WITHDRAWAL_FACILITATOR_ID]; + + if (url !== undefined) { + return key === undefined && withdrawalFacilitator === undefined && isValidPixUrl(url); + } + + return ( + key !== undefined && + getPixKeyInfo(key)?.value === key && + (withdrawalFacilitator === undefined || + WITHDRAWAL_FACILITATOR_REGEX.test(withdrawalFacilitator)) + ); +}; + +/** + * Zero is left to the payloads the BCB gives it to: a PSP location, whose amount the payer + * ignores, and a Pix Saque `fss`. + * @param {string | undefined} amount - The transaction amount (54), if any. + * @param {TlvFields} merchantAccountInformation - The Pix template the payload carries. + * @returns {boolean} Whether the amount is well written and allowed in this payload. + */ +const isValidAmount = ( + amount: string | undefined, + merchantAccountInformation: TlvFields, +): boolean => + amount === undefined || + (AMOUNT_REGEX.test(amount) && + amount.length <= PIX_TRANSACTION_AMOUNT_MAX_LENGTH && + (Number(amount) > 0 || + merchantAccountInformation[PIX_URL_ID] !== undefined || + merchantAccountInformation[PIX_WITHDRAWAL_FACILITATOR_ID] !== undefined)); + +const isWithin = (value: string | undefined, maxLength: number): boolean => + value !== undefined && value.length <= maxLength; + +const isValidTxid = (additionalData: string | undefined, isDynamic: boolean): boolean => { + const txid = additionalData === undefined ? undefined : parseTlv(additionalData)?.[PIX_TXID_ID]; + + if (txid === undefined) return false; + + // With a PSP location the payer ignores whatever 62-05 carries (§2.7), so any value stands. + return isDynamic || txid === PIX_ABSENT_TXID || PIX_TXID_REGEX.test(txid); +}; /** * Validates a Pix BR Code payload, the string behind a Pix QR Code and behind "Pix copia e - * cola". + * cola", against the Manual de Padrões para Iniciação do Pix and, where the manual is silent, + * the EMV® QRCPS-MPM it builds on. * - * The payload is valid when its TLV (tag-length-value) structure is well-formed, when the - * mandatory objects are present and well-formed (payload format indicator `01`, merchant - * category code, currency `986`, country `BR`, merchant name and merchant city), when one of - * the "Merchant Account Information" templates (IDs 26 to 51) carries the `br.gov.bcb.pix` GUI - * together with a key (static QR Code) or a URL (dynamic QR Code), and when the CRC-16 matches - * the rest of the payload. The "Point of Initiation Method" object (`01`) is advisory: the - * Manual do BR Code marks it `Uso: O` and only assigns a meaning to the value `"12"`, so it may - * be absent from either shape and only a value outside `{"11", "12"}` makes the payload - * invalid. A payload built around a key that states a transaction amount (`54`) must state one - * greater than zero, unless it is a Pix Saque BR Code, i.e. unless it carries the ISPB of the - * "facilitador de serviço de saque" in sub-object 26-03 (`fss`) as §2.6 of the Pix manual - * prescribes; rejecting `"0"`/`"0.00"` without `fss` is a deliberate restriction of this - * library, not a rule of the manual. A `fss` written next to a PSP location makes the payload - * invalid: §2.7 of the Manual de Padrões para Iniciação do Pix maps the dynamic QR Code to - * exactly two sub-objects, `00` (GUI) and `25` (URL), and `fss` belongs to the static template - * of §2.6. + * The payload is valid when: + * - it is well-formed TLV (tag-length-value), every length from `01` to `99` (EMV); + * - it starts with the payload format indicator `000201` (EMV: "shall be the first data + * object"), and no later object `00` says otherwise; + * - the "Point of Initiation Method" (`01`), "opcional" in the manual (§2.7.2), is absent, + * `"11"` or `"12"` (EMV); + * - one of the "Merchant Account Information" templates (IDs 26 to 51) carries the + * `br.gov.bcb.pix` GUI, compared without regard to case (§2.5: "O GUI [...] é case + * insensitive"), with exactly one of: + * - a Pix key (26-01) written in the DICT form: "A regra para formatação das chaves Pix no + * BR Code [...] segue estritamente as regras definidas no Manual Operacional do DICT" + * (§2.5.1), i.e. a key `getPixKeyInfo` recognizes and writes back unchanged + * (`"12345678909"`, `"+5561912345678"`, a lowercase e-mail or UUID, never a mask), and + * optionally the 8 digit ISPB of a Pix Saque facilitator (`fss`, 26-03, §2.6); + * - a PSP location (26-25), a host and path of at most 77 characters (§2.5.2: "O tamanho + * máximo da URL completa (sem o prefixo de protocolo) é de 77 caracteres", all the 99 + * characters of the template leave next to the GUI), never next to a `fss`, which belongs to the static template of §2.6 (§2.7 maps the dynamic QR Code to + * `00` and `25`); + * - the merchant category code (`52`) is 4 digits, the currency (`53`) is `986` and the + * country (`58`) is `BR`, the ISO 3166-1 alpha-2 code the EMV refers to, as ISO writes it. + * Neither the Manual do BR Code (v2.0.1, Tabela 1: `58` "“BR” – Código de país ISO3166-1 alpha + * 2") nor the Manual de Padrões (v2.10.0) says whether `br` is accepted: the only case rule + * either states is for the GUI, and every official example writes `BR`, so `br` is rejected, + * a choice of this library; + * - the merchant name (`59`) has at most 25 characters and the merchant city (`60`) at most 15 + * (EMV "var. up to 25" and "var. up to 15"); + * - a transaction amount (`54`), when present, is digits with an optional `.` decimal mark, + * which "may be present even if there are no decimals" (`"98.73"`, `"98"` and `"98."` are the + * EMV examples), at most two decimals and 13 characters. It may be zero (the Manual do BR + * Code, Tabela 1, gives `"0"` as an example) in a Pix Saque BR Code, one with a `fss`, and in + * a payload with a PSP location: the Pix API has "cobranças imediatas que representem um + * saque" state "o valor 0.00 (zero)", and allows zero in any charge whose + * `valor.modalidadeAlteracao` is 1. A payload built around a key alone must state an amount + * greater than zero, the EMV rule ("shall be different from zero") where the BCB assigns zero + * no meaning; up to 2.4.0 the rule was the same; + * - the "Additional Data Field Template" (`62`) carries a `txid` (62-05), "sempre presente em + * um BR Code" (§2.6, footnote 21): `***` when there is none (footnote 25), or 1 to 25 letters + * and digits (§2.6.2: "limitado a 25 caracteres", "Letras minúsculas [...] Letras maiúsculas + * [...] Dígitos decimais"). Next to a PSP location only its presence is checked: "Os campos + * Valor e Identificador da Transação (txid) não devem ser preenchidos no QR Code dinâmico. Se + * preenchidos, seu conteúdo deve ser ignorado" (§2.7), so a filled txid does not make the + * payload invalid, as in 2.4.0, and the transaction amount is only checked for its format. + * The static rule excludes the `-` of the Manual do BR Code v2.0.1 example + * (`RP12345678-2019`), whose §2.6.2 character set is the Pix-specific rule for the field; + * - the CRC-16 (`63`) closes the payload and matches it. No official source states the case of + * its hexadecimal digits: the Manual do BR Code only says "4 nibbles do resultado. Exemplo: + * 0xAC05 => “AC05”", and every example of both BCB manuals is upper case. Reading `"1d3d"` as + * `"1D3D"` is a choice of this library, kept from 2.4.0; `generatePixPayload` always writes + * upper case. * - * The key itself is not checked against the DICT formats: the manual states a static QR Code - * can be generated with a key that is not (or is no longer) registered, so use `isValidPixKey` - * when that matters. + * Whether the key is registered in the DICT is not something a payload can tell: "Um QR Code + * estático pode potencialmente ser gerado com uma chave inválida, mas será um QR Code + * inválido" (§2.4.1, footnote 5). * * Unreserved Templates (IDs 80 to 99) are ignored. The "QR Code composto" of Pix Automático * (Pix recorrente) writes its recurrence location in one of them: when such a payload also - * carries a payment location in 26-25, as the composite example of the Pix manual does, it is - * accepted here and read as an ordinary dynamic payload, its recurrence location dropped. Only - * a payload with no Pix template at all in IDs 26 to 51 is reported as invalid. + * carries a key or a payment location in IDs 26 to 51, as the composite examples of §2.8 do, + * it is accepted here and read as an ordinary static or dynamic payload, its recurrence + * location dropped. A composite that carries only the recurrence, with no key or payment + * location in IDs 26 to 51, is reported as invalid. * * @param {string} value - The BR Code payload to validate. * @returns {boolean} True if the payload is a valid Pix BR Code, false otherwise. @@ -44,11 +185,60 @@ import { getPixPayloadInfo } from "../get-pix-payload-info/get-pix-payload-info" * isValidPixPayload("00020126580014br.gov.bcb.pix..."); // false (broken CRC) * ``` * - * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/spb_docs/ManualBRCode.pdf * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/II_ManualdePadroesparaIniciacaodoPix.pdf + * Manual de Padrões para Iniciação do Pix v2.10.0, §2.4 to §2.8, quoted above, and the + * `valor.original` of the Pix API: "Para cobranças imediatas que não envolvam saque ou troco: + * deve apresentar valores maiores do que zero, exceto no caso de o campo + * valor.modalidadeAlteracao apresentar valor 1; Para cobranças imediatas que representem um + * saque: deve apresentar o valor 0.00 (zero)". + * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/spb_docs/ManualBRCode.pdf + * Manual do BR Code v2.0.1, Tabela 1: `54` Transaction Amount, "01..13", "valor da transação. + * Ex.: "0", "1.00", "123.99"". + * @see Official: https://www.emvco.com/terms-of-use/?u=/wp-content/uploads/documents/EMVCo-Merchant-Presented-QR-Specification-v1-1.pdf + * EMV® QRCPS-MPM v1.1, cited by the Pix manual, "Data Objects Under the Root of a QR Code": + * Merchant Category Code `"52"`, format `N` (numeric), length `"04"`, "As defined by [ISO + * 18245]"; Country Code `"58"`, "as defined by [ISO 3166-1 alpha 2]"; Merchant Name `"59"`, + * "var. up to "25""; Merchant City `"60"`, "var. up to "15""; "Position of Data Objects": "The + * Payload Format Indicator (ID "00") shall be the first data object in the QR Code"; + * "Transaction Amount (ID "54")": "If present, the Transaction Amount shall be different from + * zero, shall only include (numeric) digits "0" to "9" and may contain a single "." character + * as the decimal mark [...] the "." character may be present even if there are no decimals". * @see Official: https://github.com/bacen/pix-api * Pix (SPI) OpenAPI spec. * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/API-DICT.html * DICT (Diretório de Identificadores de Contas Transacionais) API specification. */ -export const isValidPixPayload = (value: string): boolean => getPixPayloadInfo(value) !== null; +export const isValidPixPayload = (value: string): boolean => { + if (typeof value !== "string") return false; + + const payload = value.trim(); + + // Stryker disable next-line ConditionalExpression,EqualityOperator: a payload this short has no room left for any of the mandatory fields checked below, so it can never be valid even without this guard + if (payload.length <= CRC_TAG_LENGTH || !isValidCrc(payload)) return false; + + const fields = parseTlv(payload); + + if (!fields) return false; + + if (!payload.startsWith(PIX_PAYLOAD_FORMAT_INDICATOR_OBJECT)) return false; + if (fields[PIX_PAYLOAD_FORMAT_INDICATOR_ID] !== PIX_PAYLOAD_FORMAT_INDICATOR) return false; + if (!isValidPointOfInitiation(fields)) return false; + // An absent code reads as "undefined", which is not 4 digits either. + if (!PIX_MERCHANT_CATEGORY_CODE_REGEX.test(String(fields[PIX_MERCHANT_CATEGORY_CODE_ID]))) + return false; + if (fields[PIX_TRANSACTION_CURRENCY_ID] !== PIX_TRANSACTION_CURRENCY) return false; + if (fields[PIX_COUNTRY_CODE_ID] !== PIX_COUNTRY_CODE) return false; + if (!isWithin(fields[PIX_MERCHANT_NAME_ID], PIX_MERCHANT_NAME_MAX_LENGTH)) return false; + if (!isWithin(fields[PIX_MERCHANT_CITY_ID], PIX_MERCHANT_CITY_MAX_LENGTH)) return false; + + const merchantAccountInformation = findPixMerchantAccountInformation(fields); + + if (!merchantAccountInformation) return false; + if (!isValidMerchantAccountInformation(merchantAccountInformation)) return false; + if (!isValidAmount(fields[PIX_TRANSACTION_AMOUNT_ID], merchantAccountInformation)) return false; + + return isValidTxid( + fields[PIX_ADDITIONAL_DATA_ID], + merchantAccountInformation[PIX_URL_ID] !== undefined, + ); +}; diff --git a/src/is-valid-processo-juridico/is-valid-processo-juridico.ts b/src/is-valid-processo-juridico/is-valid-processo-juridico.ts index e8cda1d84..3d8a04c37 100644 --- a/src/is-valid-processo-juridico/is-valid-processo-juridico.ts +++ b/src/is-valid-processo-juridico/is-valid-processo-juridico.ts @@ -60,7 +60,8 @@ const verifyCourtAndTribunal = (value: string): boolean => { * isValidProcessoJuridico("ab00020802520125150049"); // false (invalid format) * ``` * - * Resolução CNJ nº 65/2008 defines this Número Único de Processo layout and its check digits, and + * Resolução CNJ nº 65/2008 defines this Número Único de Processo layout, its check digits (art. 1º, + * § 2º, and the algorithm in its Anexo VIII, "CÁLCULO DO DÍGITO VERIFICADOR"), and * closes the list of órgão (`J`) and tribunal (`TR`) codes in art. 1º, § 4º and § 5º. * * @see Official: https://atos.cnj.jus.br/atos/detalhar/119 diff --git a/src/is-valid-registro-profissional/constants.ts b/src/is-valid-registro-profissional/constants.ts index 7af5c522b..4572dbc71 100644 --- a/src/is-valid-registro-profissional/constants.ts +++ b/src/is-valid-registro-profissional/constants.ts @@ -13,7 +13,43 @@ export type RegistroProfissionalCouncil = "OAB" | "CRM" | "CRO" | "CRP" | "CRC"; */ export const PROFESSIONAL_NUMBER_UF_REGEX = /^(?\d{4,6})(?[A-Z]{2})$/; -export const CRO_REGEX = /^(?\d{3,6})(?[A-Z]{2})$/; +/** + * A CRO number in either of two shapes, read after the separators are stripped: + * - the conventional one, the number followed by the UF (`"12345/SP"`); + * - the one of the Consolidação das Normas do CFO (Resolução CFO-63/2005), art. 115, § 1º: the + * sigla of the Conselho Regional (`CRO-SP`) before the number, joined by a hyphen to the + * category of the inscrição when there is one (`TPD`, `TSB`, `ASB`, `APD`, `CLM`/`CLF`, + * `LPM`/`LPF`, `PV` provisória, `T` temporária), and the number followed by `-IS` for an + * inscrição secundária of the alíneas "a" to "e" or by `-R` for an inscrição remida + * (`"CRO-SP 12345"`, `"CRO-SP-TPD 1234-IS"`, `"CRO-SP 12345-R"`). + * + * The art. 115 fixes no digit count ("numeração cronológica infinita", § 5º), so both shapes take + * the conventional 3 to 6 digits. Which category and suffix go together is checked against + * `CRO_SUFFIXES_BY_CATEGORY`. + */ +export const CRO_REGEX = + /^(?:\d{3,6}(?[A-Z]{2})|CRO(?[A-Z]{2})(?[A-Z]{1,3})?\d{3,6}(?IS|R)?)$/; + +/** + * The categories of art. 115, § 1º, of the Consolidação das Normas do CFO (`""` for the + * cirurgião-dentista, alínea "a", which has none), each with the suffixes its number may carry: + * `IS` for the inscrição secundária of the alíneas "a" to "e" (alínea "j") and `R` for the + * inscrição remida, "o mesmo da inscrição principal" (alínea "k"), which only the + * cirurgião-dentista and the técnico em prótese dentária hold. + */ +export const CRO_SUFFIXES_BY_CATEGORY: Readonly> = { + "": ["IS", "R"], + TPD: ["IS", "R"], + TSB: ["IS"], + ASB: ["IS"], + APD: ["IS"], + CLM: [], + CLF: [], + LPM: [], + LPF: [], + PV: [], + T: [], +}; export const CRP_REGEX = /^(?\d{2})(?\d{4,6})$/; diff --git a/src/is-valid-registro-profissional/is-valid-registro-profissional.test.ts b/src/is-valid-registro-profissional/is-valid-registro-profissional.test.ts index cea859a8a..196e39781 100644 --- a/src/is-valid-registro-profissional/is-valid-registro-profissional.test.ts +++ b/src/is-valid-registro-profissional/is-valid-registro-profissional.test.ts @@ -12,6 +12,24 @@ const STATE_CODES = DATA.map((state) => state.code); describe("isValidRegistroProfissional", () => { describe("should return false", () => { + test("for a CRO number of art. 115 whose sigla, category or suffix is not one it lists", () => { + for (const value of [ + "CRO-XX 12345", + "CRO-SP-ABC 1234", + "CRO-SP-PV 1234-IS", + "CRO-SP-TSB 1234-R", + "CRO-SP-CLM 1234-R", + "CRO 12345", + "CRM-SP 12345", + ]) { + expect(isValidRegistroProfissional({ value, council: "CRO" })).toBe(false); + } + + expect( + isValidRegistroProfissional({ value: "CRO-RJ 12345", council: "CRO", stateCode: "SP" }), + ).toBe(false); + }); + test("when value is null", () => { // @ts-expect-error: intentionally invalid input expect(isValidRegistroProfissional({ value: null, council: "OAB" })).toBe(false); @@ -140,6 +158,28 @@ describe("isValidRegistroProfissional", () => { expect(isValidRegistroProfissional({ value: "12345/MG", council: "CRO" })).toBe(true); }); + test("for a CRO number in the form of the Consolidação das Normas do CFO, art. 115, § 1º", () => { + for (const value of [ + "CRO-SP 12345", + "CRO-SP-TPD 1234", + "CRO-SP-TSB 1234", + "CRO-SP-ASB 1234", + "CRO-SP-APD 1234", + "CRO-SP-CLM 1234", + "CRO-SP-CLF 1234", + "CRO-SP-LPM 1234", + "CRO-SP-LPF 1234", + "CRO-SP-PV 1234", + "CRO-SP-T 1234", + "CRO-SP 12345-IS", + "CRO-SP-TSB 1234-IS", + "CRO-SP 12345-R", + "CRO-SP-TPD 1234-R", + ]) { + expect(isValidRegistroProfissional({ value, council: "CRO", stateCode: "SP" })).toBe(true); + } + }); + test("for a valid CRP number, ignoring params.stateCode", () => { expect( isValidRegistroProfissional({ value: "06/12345", council: "CRP", stateCode: "SP" }), diff --git a/src/is-valid-registro-profissional/is-valid-registro-profissional.ts b/src/is-valid-registro-profissional/is-valid-registro-profissional.ts index 73c4f08ba..5e2bddc6e 100644 --- a/src/is-valid-registro-profissional/is-valid-registro-profissional.ts +++ b/src/is-valid-registro-profissional/is-valid-registro-profissional.ts @@ -5,6 +5,7 @@ import { sanitizeToAlphanumeric } from "../_internals/sanitize-to-alphanumeric/s import { CRC_REGEX, CRO_REGEX, + CRO_SUFFIXES_BY_CATEGORY, CRP_MAX_REGION, CRP_MIN_REGION, CRP_REGEX, @@ -38,6 +39,10 @@ const isKnownCrpRegion = (value: string): boolean => { return region >= CRP_MIN_REGION && region <= CRP_MAX_REGION; }; +const isKnownCroCategory = (category: string, suffix: string | undefined): boolean => + Object.hasOwn(CRO_SUFFIXES_BY_CATEGORY, category) && + (suffix === undefined || CRO_SUFFIXES_BY_CATEGORY[category].includes(suffix)); + /** * Checks the structure of a professional council registration number (registro/inscrição * profissional). @@ -50,7 +55,11 @@ const isKnownCrpRegion = (value: string): boolean => { * Supported councils and what is validated: * - `"OAB"` (Ordem dos Advogados do Brasil): 4 to 6 digits + UF, e.g. `"123456/SP"`. * - `"CRM"` (Conselho Regional de Medicina): 4 to 6 digits + UF, e.g. `"123456-SP"`. - * - `"CRO"` (Conselho Regional de Odontologia): 3 to 6 digits + UF, e.g. `"12345/SP"`. + * - `"CRO"` (Conselho Regional de Odontologia): 3 to 6 digits + UF, e.g. `"12345/SP"`, or the + * form of the Consolidação das Normas do CFO, art. 115, § 1º: the sigla of the Conselho + * Regional first, joined by a hyphen to the category of the inscrição when there is one, then + * the number, with `-IS` for a secundária and `-R` for a remida, e.g. `"CRO-SP 12345"`, + * `"CRO-SP-TPD 1234"`, `"CRO-SP-PV 1234"`, `"CRO-SP 12345-IS"`. * - `"CRP"` (Conselho Regional de Psicologia): 2 digit regional code + 4 to 6 digits, e.g. * `"06/12345"`. The regional code must be one of the 24 Conselhos Regionais of the CFP * system, CRP-01 to CRP-24. It is not a literal UF (some regions cover more than one state), @@ -70,15 +79,25 @@ const isKnownCrpRegion = (value: string): boolean => { * unification (RNP) its registration number format could not be confirmed from an official, * publicly documented source. * - * Only the CRC shape and the CRP regional codes rest on a published source: the CFP page lists - * the 24 Conselhos Regionais and nothing else, so the 4 to 6 digit body of a CRP number is as - * unsourced as the OAB, CRM and CRO ranges. The OAB, the CFM and the CFO do not publish the - * format of the numbers their seccionais and regionais issue, so the digit ranges accepted for - * `"OAB"`, `"CRM"` and `"CRO"` are conventional rather than normative, and two counterexamples - * are known: the OAB/SP public search field is `maxlength="7"` and rejects only inputs of two - * characters or fewer, and the CFM's Manual de Procedimentos Administrativos documents a `300` - * prefixed CRM for foreign-trained physicians and a trailing `P` for inscrição provisória, - * neither of which the accepted shape can express. + * Only the CRO form of art. 115, the CRC shape and the CRP regional codes rest on a published + * source. The CFO's Consolidação das Normas (Resolução CFO-63/2005), art. 115, § 1º, has each + * number "precedido da sigla do Conselho Regional", with the category letters listed above, but + * no digit count, so the 3 to 6 digits of both CRO shapes and the number-then-UF shape are + * conventional. The CFP page lists the 24 Conselhos Regionais and nothing else (the CRP-25 of + * Amapá is only a 2023 proposal of the CRP-10), so the 4 to 6 digit body of a CRP number is as + * unsourced as the OAB and CRM ranges. The CRC shape `UF-000000/O-D` is the one of the Manual de + * Registro of 2009; the Resolução CFC nº 1.707/2023 in force only says that "a numeração do + * Registro Originário será única e sequencial em cada CRC" and adds the `T` of the transfer, and + * no longer has the Provisório (`P`) and Secundário (`S`) registrations the manual describes, + * which are still accepted for the numbers issued under it; the manual's check digit is + * "calculado de forma automatizada pelo sistema de cadastro" and its algorithm is not published. + * The OAB (Provimento nº 95/2000, Regulamento Geral) and the CFM (Decreto nº 44.045/1958, art. + * 9º) do not publish the format of the numbers their seccionais and regionais issue, so the digit + * ranges accepted for `"OAB"` and `"CRM"` are conventional rather than normative, and two + * counterexamples are known: the OAB/SP public search field is `maxlength="7"` and rejects only + * inputs of two characters or fewer, and the CFM's Manual de Procedimentos Administrativos + * documents a `300` prefixed CRM for foreign-trained physicians and a trailing `P` for inscrição + * provisória, neither of which the accepted shape can express. * * Everything it needs travels in a single object, the shape `isValidBankAccount` takes: a * registration number means nothing without the council that issued it, so the two are read @@ -97,6 +116,7 @@ const isKnownCrpRegion = (value: string): boolean => { * isValidRegistroProfissional({ value: "123456/SP", council: "OAB" }); // true * isValidRegistroProfissional({ value: "123456-SP", council: "OAB", stateCode: "SP" }); // true * isValidRegistroProfissional({ value: "123456-RJ", council: "OAB", stateCode: "SP" }); // false (UF mismatch) + * isValidRegistroProfissional({ value: "CRO-SP-TPD 1234", council: "CRO" }); // true (CFO art. 115) * isValidRegistroProfissional({ value: "06/12345", council: "CRP" }); // true * isValidRegistroProfissional({ value: "SP-123456/O-3", council: "CRC" }); // true * isValidRegistroProfissional({ value: "SP-123456/O-3 T-MG", council: "CRC" }); // true (transferido) @@ -118,15 +138,23 @@ const isKnownCrpRegion = (value: string): boolean => { * Conselho Federal de Psicologia: the 24 Conselhos Regionais of the system, numbered CRP-01 to * CRP-24. The page establishes the regional codes only; it publishes no length for the inscription * number itself. - * @see Official: https://www.oab.org.br/ - * Ordem dos Advogados do Brasil (OAB), the federal body that regulates the profession, which - * publishes no format for the número de inscrição and the seccional. - * @see Official: https://portal.cfm.org.br/ - * Conselho Federal de Medicina (CFM), the autarquia federal that regulates the profession, which - * publishes no format for the registration number and the UF. - * @see Official: https://cfo.org.br/ - * Conselho Federal de Odontologia (CFO), the autarquia federal that regulates the profession, - * which publishes no format for the registration number and the UF. + * @see Official: https://www.oab.org.br/util/print?numero=95/2000&print=Legislacao&origem=Provimentos + * Provimento nº 95/2000 of the Conselho Federal da OAB (Cadastro Nacional dos Advogados), art. 2º, + * parágrafo único, as worded by the Provimento nº 227/2024: the CNA keeps "o número e o tipo de + * inscrição na OAB (advogado, estagiário ou suplementar)", with no format for the number. + * @see Official: https://www.planalto.gov.br/ccivil_03/decreto/1950-1969/d44045.htm + * Decreto nº 44.045/1958, art. 9º, the regulation of the Conselhos de Medicina: a "carteira + * profissional numerada" with the "número da inscrição anotada nesse Conselho Regional", and no + * format for it. + * @see Official: https://transparencia.cfo.org.br/wp-content/uploads/2023/09/Consolida%C3%A7%C3%A3o-das-Normas-Atualizado-emsetembro-de-2023.pdf + * Consolidação das Normas para Procedimentos nos Conselhos de Odontologia (Resolução CFO-63/2005, + * updated to September 2023), art. 115, § 1º: "o número de inscrição principal atribuído a + * cirurgião-dentista será precedido da sigla do Conselho Regional", the other categories "ligada + * por hífen às letras" `TPD`, `TSB`, `ASB`, `APD`, `CLM`/`CLF`, `LPM`/`LPF`, `PV` and `T`, the + * secundária "seguido das letras 'IS', ligadas por hífen" and the remida "seguida da letra 'R'". + * @see Official: https://transparencia.cfp.org.br/wp-content/uploads/2025/12/Classificacao-de-Portes-dos-CRPs-2026.csv + * Conselho Federal de Psicologia, classification of the CRPs for 2026 (Resolução CFP nº 08/2024): + * the same 24 regionals, CRP 01 to CRP 24. */ export const isValidRegistroProfissional = (params: IsValidRegistroProfissionalParams): boolean => { if (isNullish(params)) return false; @@ -143,10 +171,13 @@ export const isValidRegistroProfissional = (params: IsValidRegistroProfissionalP if (!match?.groups) return false; - const { region, uf, transferUf } = match.groups; + const { region, uf: trailingUf, councilUf, category, suffix, transferUf } = match.groups; + const uf = trailingUf ?? councilUf; if (region !== undefined && !isKnownCrpRegion(region)) return false; + if (councilUf !== undefined && !isKnownCroCategory(category ?? "", suffix)) return false; + if (transferUf !== undefined && !isStateCode(transferUf)) return false; if (uf === undefined) return true; diff --git a/src/is-valid-renavam/is-valid-renavam.ts b/src/is-valid-renavam/is-valid-renavam.ts index b1e636463..169436c5e 100644 --- a/src/is-valid-renavam/is-valid-renavam.ts +++ b/src/is-valid-renavam/is-valid-renavam.ts @@ -37,10 +37,19 @@ const FORMAT_REGEX = /^\d{9}$|^\d{11}$/; * isValidRenavam(-639884962); // false (not a non-negative safe integer) * ``` * - * The Código de Trânsito Brasileiro creates the RENAVAM registry but does not define its check - * digit, so the algorithm below follows the two community references cited as `Based on:`. + * Portaria DENATRAN nº 27, de 25 de janeiro de 2013, art. 1º, gives the Portaria DENATRAN nº + * 03/86, art. 3º, its wording in force: the Código RENAVAM is "composto de 11 (onze) dígitos", + * and "possui 10 dígitos e um dígito verificador, calculado através do módulo 11, peso 9". Read + * as the usual módulo 11 with weights 2 to 9 from the right, that gives 3, 2, 9, 8, 7, 6, 5, 4, 3 + * and 2 over the ten base digits, the weights used here; the portaria does not write them out + * nor say what a remainder of 0, 1 or 10 turns into, so that part follows the community + * references cited as `Based on:`. Nor does it say how a 9 digit code issued before 1 April 2013 + * is read in 11: padding it with zeros on the left is market practice. * * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l9503compilado.htm + * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/arquivos-senatran/portarias/2013/portaria0272013.pdf + * Portaria DENATRAN nº 27/2013, art. 1º: "10 dígitos e um dígito verificador, calculado através do + * módulo 11, peso 9"; art. 2º: the 11 digit numbering from 1 April 2013. * @see Based on: https://github.com/klawdyo/validation-br/blob/main/src/renavam.ts * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/renavam.py */ diff --git a/src/is-valid-service-item/is-valid-service-item.ts b/src/is-valid-service-item/is-valid-service-item.ts index 8ed1fa4ef..b992245f7 100644 --- a/src/is-valid-service-item/is-valid-service-item.ts +++ b/src/is-valid-service-item/is-valid-service-item.ts @@ -28,7 +28,9 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d * ``` * * @see Official: https://www.planalto.gov.br/ccivil_03/leis/lcp/lcp116.htm - * Lei Complementar 116/2003, "Lista de serviços anexa". + * Lei Complementar 116/2003, "Lista de serviços anexa", last amended by Lei Complementar 183/2021 + * (subitem 11.05); the later LC 214/2025 and LC 218/2025 changed the body of the law, not the + * list. * @see Official: https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual * Sistema Nacional NFS-e, `ANEXO_B-NBS2-LISTA_SERVICO_NACIONAL`, sheet `LISTA.SERV.NAC.`: the * list in force in machine readable form. diff --git a/src/is-valid-service-phone/is-valid-service-phone.test.ts b/src/is-valid-service-phone/is-valid-service-phone.test.ts index b4ea3bfcf..a63fd7c3d 100644 --- a/src/is-valid-service-phone/is-valid-service-phone.test.ts +++ b/src/is-valid-service-phone/is-valid-service-phone.test.ts @@ -59,8 +59,7 @@ describe("isValidServicePhone", () => { expect(isValidServicePhone("999")).toBe(false); }); - test("for the handset emergency aliases 112 and 911, which Anatel designates in neither the Anexo of Ato nº 43.151/2004 nor Ato nº 12.712/2024 (and 911 falls outside the 1N₂N₁ range Resolução nº 749/2022 art. 13 destines to public utility services)", () => { - expect(isValidServicePhone("112")).toBe(false); + test("for 911, listed with 112 on the Anatel SUP page but outside the 1N₂N₁ range Resolução nº 749/2022 art. 13 destines to public utility services", () => { expect(isValidServicePhone("911")).toBe(false); }); }); @@ -110,6 +109,12 @@ describe("isValidServicePhone", () => { expect(isValidServicePhone("193")).toBe(true); expect(isValidServicePhone("199")).toBe(true); }); + + test("for the codes the Anatel SUP page of 22/06/2023 lists beyond Ato nº 43.151/2004", () => { + for (const code of ["111", "112", "125", "136", "185", "188", "195", "196"]) { + expect(isValidServicePhone(code)).toBe(true); + } + }); }); describe("properties", () => { diff --git a/src/is-valid-service-phone/is-valid-service-phone.ts b/src/is-valid-service-phone/is-valid-service-phone.ts index 252b2affc..7e991a0b0 100644 --- a/src/is-valid-service-phone/is-valid-service-phone.ts +++ b/src/is-valid-service-phone/is-valid-service-phone.ts @@ -14,7 +14,9 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d * * Service numbers are dialed without a DDD, so they are validated by prefix and length alone: * - the Códigos Não Geográficos `0300`, `0303`, `0500`, `0800` and `0900`, each followed by - * 7 digits (11 in total, the shorter, extinct `0800` + 6 form is rejected); + * 7 digits (11 in total, the shorter, extinct `0800` + 6 form is rejected): the 10 digit CNG + * series of Resolução Anatel nº 749/2022 art. 18, dialed behind the Prefixo Nacional `0` (art. + * 28); * - the abbreviated `300X` and `400X` numbers, followed by 4 digits, e.g. `3003-1234`. Anatel * withdrew the 4-digit codes rather than allocating them (Resolução nº 86/1998 art. 43 I and * Ato nº 43.151/2004 art. 2º II both ordered them released), so the accepted roots are the @@ -22,11 +24,12 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d * "Número Único" carrier prefixes in market use, such as `4020` and `4062`, are out of scope * and are rejected; * - the 3-digit Códigos de Acesso a Serviços de Utilidade Pública that Anatel has designated, - * e.g. `190` and `192`, the consolidated table being the Anexo of Ato nº 43.151/2004. - * Undesignated codes in the `1XX` range are rejected, and so are `112` and `911`: Anatel - * designates neither, and `911` is not even inside the `1N₂N₁` range Resolução nº 749/2022 - * art. 13 destines to public utility services. Handsets route both by GSM convention, which - * is not a numbering designation. + * e.g. `190` and `192`, as listed on Anatel's gov.br SUP page (modified on 22/06/2023) and in + * the Anexo of Ato nº 43.151/2004, the last consolidated act. Undesignated codes in the `1XX` + * range are rejected. The page lists `112/911` for the Polícia Militar on handsets: `112` is + * accepted, while `911` stays rejected, since it is not inside the `1N₂N₁` range Resolução nº + * 749/2022 art. 13 destines to public utility services (every other series is reserva + * técnica), a conflict between the two official texts that keeps the 2.4.0 answer. * * Only the structure is checked: the number does not have to be assigned to anyone, and the * `0500` rule that encodes a donation amount in the last two digits is not enforced. @@ -44,6 +47,9 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d * * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 * Resolução Anatel nº 749/2022, arts. 13, 14, 18 and 28. + * @see Official: https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais/servicos-de-utilidade-publica-e-de-emergencia + * Anatel, "Serviços de Utilidade Pública e de Emergência (SUP)", modified on 22/06/2023, the + * current list of the 3-digit public utility codes. * @see Official: https://informacoes.anatel.gov.br/legislacao/atos-de-numeracao/2004/1648-ato-43151 * Ato Anatel nº 43.151/2004, whose Anexo designates the 3-digit public utility codes. */ diff --git a/src/is-valid-suframa/is-valid-suframa.ts b/src/is-valid-suframa/is-valid-suframa.ts index 6e63a3c5f..0bfd46bd9 100644 --- a/src/is-valid-suframa/is-valid-suframa.ts +++ b/src/is-valid-suframa/is-valid-suframa.ts @@ -9,13 +9,18 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d * * The number is `SS.NNNN.LLD`: sector of activity, sequential number, locality of the SUFRAMA * unit that registered the company and check digit. The NF-e field is numeric with 8 or 9 - * positions, because a sector code such as `01` loses its leading zero, so an 8 digit value is - * read with that zero back in place. The sector code can never be `00`. The check digit is módulo + * positions and the MOC only says "SS pode começar por '0'"; reading an 8 digit value as one whose + * sector code lost its leading zero, and putting that zero back, is this library's inference. The sector code can never be `00`. The check digit is módulo * 11 with weights 2 to 9 from right to left, and is 0 when the remainder is 0 or 1. * * The manual lists sector and locality codes only as examples, so they are not checked against a * table. * + * The rule is published by the NF-e Manual de Orientação do Contribuinte (CONFAZ/ENCAT), not by + * the SUFRAMA: its Resolução CAS nº 64/2021, art. 5º, only says the inscrição is "um número de + * identificação e controle gerado por ocasião do cadastramento", permanent and never reused, and + * neither its cadastro FAQ nor the CADSUF manual gives a layout or a check digit. + * * @param {string} suframa - The Inscrição SUFRAMA to validate. * @returns {boolean} True if the Inscrição SUFRAMA is valid, false otherwise. * diff --git a/src/is-valid-vin/constants.ts b/src/is-valid-vin/constants.ts index 83de72057..c753486c0 100644 --- a/src/is-valid-vin/constants.ts +++ b/src/is-valid-vin/constants.ts @@ -1,24 +1,46 @@ /** - * VIN (Vehicle Identification Number / chassi) layout: 17 characters, excluding the letters - * `I`, `O` and `Q` (dropped to avoid confusion with `1` and `0`), per ISO 3779:2009 structure. + * VIN (Vehicle Identification Number / chassi) layout: 17 characters in three sections, the WMI + * (3 characters), the VDS (6) and the VIS (8), per Resolução CONTRAN nº 968/2022, art. 3º, I to + * IV, written with the digits and the capital letters except `I`, `O` and `Q` (dropped to avoid + * confusion with `1` and `0`), per ISO 3779:2009. This is the rule `isValidVin` checks by default. + * + * The resolution itself lists no forbidden character: it only has the VIN engraved "de acordo + * com as especificações e formatos estabelecidos pela norma [...] ABNT NBR 6066:2022" (art. 5º), + * a paid standard with no official free copy, and forbids "caracteres especiais, espaços em + * branco ou qualquer outra simbologia" in the RENAVAM records (art. 43, § 1º). The `I`, `O` and + * `Q` exclusion is ISO 3779's; the VINs the resolution's Anexo II assigns for regularization + * (WMI `XXX`, codes such as `D0A`, `C0L` and `1MP`) are written with `0` and `1`, so they pass it. + * * The check digit at the 9th position, its transliteration table and its weighted MOD 11 - * algorithm are a North-American requirement (49 CFR 565.15 / SAE J853), not something - * Resolução CONTRAN nº 968/2022 (which replaced Resolução CONTRAN nº 24/1998 from 1 January 2025, - * art. 50, II) or ABNT NBR 6066, which define the Brazilian VIN structure, mandate; many Brazilian-built VINs do not carry a matching check digit. + * algorithm, and the model year code at the 10th position that may not be `U`, `Z` or `0`, are + * North-American requirements (49 CFR 565.15 / SAE J853), which `isValidVin` only enforces under + * `{ checkDigit: true }`: Resolução CONTRAN nº 968/2022 (which replaced Resolução CONTRAN nº + * 24/1998 from 1 January 2025, art. 50, II) does not mandate them, and many Brazilian-built VINs + * do not carry a matching check digit; in the regularization VINs of its Anexo II the 9th + * character is copied from the original chassis or filled with `0`. + * * The ISO catalogue page sits behind a bot filter and answers HTTP 403 to every non-browser * client, so it has to be opened in a browser, where it renders the standard's paywalled * abstract rather than its text. + * * @see Official: https://www.iso.org/standard/52200.html * @see Official: https://www.ecfr.gov/current/title-49/section-565.15 * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/resolucao9682022.pdf - * Resolução CONTRAN nº 968, de 20 de junho de 2022, art. 2º, I (VIN of 17 characters in three sections) - * and art. 50, II (revocation of Resolução nº 24/1998 from 1 January 2025). + * Resolução CONTRAN nº 968, de 20 de junho de 2022, art. 3º, I to IV (VIN, "combinação de 17 + * caracteres", in the three sections WMI, VDS and VIS of 3, 6 and 8 characters), art. 5º (engraving + * per ABNT NBR 6066:2022) and art. 50, II (revocation of Resolução nº 24/1998 from 1 January 2025). + * It lists no forbidden character and no check digit. * @see Official: https://vpic.nhtsa.dot.gov/api/ */ export const VIN_LENGTH = 17; export const VIN_CHECK_DIGIT_POSITION = 8; +export const VIN_MODEL_YEAR_POSITION = 9; + +/** Characters 49 CFR 565.15 leaves out of the model year code, the 10th position. */ +export const VIN_MODEL_YEAR_EXCLUDED = ["U", "Z", "0"]; + export const VIN_TRANSLITERATION: Record = { 0: 0, 1: 1, diff --git a/src/is-valid-vin/is-valid-vin.test.ts b/src/is-valid-vin/is-valid-vin.test.ts index 30bf44fc9..f6af19cea 100644 --- a/src/is-valid-vin/is-valid-vin.test.ts +++ b/src/is-valid-vin/is-valid-vin.test.ts @@ -1,7 +1,7 @@ import * as fc from "fast-check"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; -import { isValidVin } from "./is-valid-vin"; +import { type IsValidVinOptions, isValidVin } from "./is-valid-vin"; const VIN_CHECK_CHARACTERS = Array.from({ length: 11 }, (_, index) => index === 10 ? "X" : String(index), @@ -9,20 +9,30 @@ const VIN_CHECK_CHARACTERS = Array.from({ length: 11 }, (_, index) => describe("isValidVin", () => { describe("should return true", () => { + test("for a Brazilian VIN that carries no 49 CFR 565.15 check digit", () => { + // 2.4.0 returned false: the check digit of 9BWZZZ377VT004251 would be 2, not 7 + expect(isValidVin("9BWZZZ377VT004251")).toBe(true); + expect(isValidVin("9BWZZZ377VT004251", { checkDigit: false })).toBe(true); + }); + test("for a known valid VIN with a numeric check digit", () => { expect(isValidVin("1HGCM82633A004352")).toBe(true); + expect(isValidVin("1HGCM82633A004352", { checkDigit: true })).toBe(true); }); test("for a known valid VIN with an X check digit", () => { expect(isValidVin("1M8GDM9AXKP042788")).toBe(true); + expect(isValidVin("1M8GDM9AXKP042788", { checkDigit: true })).toBe(true); }); test("for a second known valid VIN", () => { expect(isValidVin("JH4TB2H26CC000000")).toBe(true); + expect(isValidVin("JH4TB2H26CC000000", { checkDigit: true })).toBe(true); }); test("for a lowercase value", () => { expect(isValidVin("1m8gdm9axkp042788")).toBe(true); + expect(isValidVin("1m8gdm9axkp042788", { checkDigit: true })).toBe(true); }); test("for a value with leading/trailing whitespace", () => { @@ -31,8 +41,27 @@ describe("isValidVin", () => { }); describe("should return false", () => { - test("when the check digit does not match", () => { - expect(isValidVin("1HGCM82633A004353")).toBe(false); + test("when the check digit does not match and checkDigit is true", () => { + expect(isValidVin("1HGCM82633A004353", { checkDigit: true })).toBe(false); + expect(isValidVin("9BWZZZ377VT004251", { checkDigit: true })).toBe(false); + // @ts-expect-error: intentionally not a boolean + expect(isValidVin("1HGCM82633A004353", { checkDigit: 1 })).toBe(false); + // the Brazilian rule mandates no check digit (2.4.0 returned false here) + expect(isValidVin("1HGCM82633A004353")).toBe(true); + // @ts-expect-error: intentionally not a boolean + expect(isValidVin("1HGCM82633A004353", { checkDigit: 0 })).toBe(true); + }); + + test("when the model year code is U, Z or 0 and checkDigit is true, even with a matching check digit", () => { + // 1HGCM8263 + U/Z/0 + A004352: 1·8+8·7+7·6+3·5+4·4+8·3+2·2+6·10 = 225 plus the 10th + // character weighted 9 (U 4 → 36, Z 9 → 81, 0 → 0) and A004352 (1·8+0+0+4·5+3·4+5·3+2·2 = 59) + // gives 320, 365 and 284, whose remainders 1, 2 and 9 are written in position 9 + expect(isValidVin("1HGCM8261UA004352", { checkDigit: true })).toBe(false); + expect(isValidVin("1HGCM8262ZA004352", { checkDigit: true })).toBe(false); + expect(isValidVin("1HGCM82690A004352", { checkDigit: true })).toBe(false); + expect(isValidVin("1HGCM8261UA004352")).toBe(true); + expect(isValidVin("1HGCM8262ZA004352")).toBe(true); + expect(isValidVin("1HGCM82690A004352")).toBe(true); }); test("when every character is the same, even though the check digit matches", () => { @@ -70,8 +99,9 @@ describe("isValidVin", () => { expect(isValidVin("1HGCM82633A0043522")).toBe(false); }); - test("when the check digit character is a letter other than X", () => { - expect(isValidVin("1HGCM826C3A004352")).toBe(false); + test("when the check digit character is a letter other than X and checkDigit is true", () => { + expect(isValidVin("1HGCM826C3A004352", { checkDigit: true })).toBe(false); + expect(isValidVin("1HGCM826C3A004352")).toBe(true); }); test("when it contains a symbol", () => { @@ -131,17 +161,31 @@ describe("isValidVin", () => { const vins = fc.stringMatching(/^[0-9A-HJ-NPR-Z]{17}$/); - test("should accept exactly one check character for any body", () => { + test("should accept any 17 characters of the ISO 3779 alphabet under the Brazilian rule", () => { + fc.assert( + fc.property(vins, (vin) => { + fc.pre(new Set(vin).size > 1); + + expect(isValidVin(vin)).toBe(true); + }), + ); + }); + + test("should accept exactly one check character for any body when checkDigit is true", () => { fc.assert( fc.property(bodies, (body) => { // A body of a single repeated character can have its one accepted candidate rejected // as a repeated-character VIN, so it is left to the literal tests above. fc.pre(new Set(body).size > 1); + // body[8] lands in position 10, the model year code + fc.pre(!["U", "Z", "0"].includes(body[8])); const candidates = VIN_CHECK_CHARACTERS.map( (character) => `${body.slice(0, 8)}${character}${body.slice(8)}`, ); - const accepted = candidates.filter((candidate) => isValidVin(candidate)); + const accepted = candidates.filter((candidate) => + isValidVin(candidate, { checkDigit: true }), + ); expect(accepted.length).toBe(1); }), @@ -152,6 +196,9 @@ describe("isValidVin", () => { fc.assert( fc.property(vins, (vin) => { expect(isValidVin(` ${vin.toLowerCase()} `)).toBe(isValidVin(vin)); + expect(isValidVin(` ${vin.toLowerCase()} `, { checkDigit: true })).toBe( + isValidVin(vin, { checkDigit: true }), + ); }), ); }); @@ -182,8 +229,10 @@ describe("isValidVin", () => { }); describe("isValidVin types", () => { - test("should take a string and return a boolean", () => { + test("should take a string and options and return a boolean", () => { expectTypeOf(isValidVin).parameter(0).toEqualTypeOf(); + expectTypeOf(isValidVin).parameter(1).toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf(isValidVin).returns.toEqualTypeOf(); }); }); diff --git a/src/is-valid-vin/is-valid-vin.ts b/src/is-valid-vin/is-valid-vin.ts index ef2e8cada..9f323da9e 100644 --- a/src/is-valid-vin/is-valid-vin.ts +++ b/src/is-valid-vin/is-valid-vin.ts @@ -3,43 +3,65 @@ import { isRepeatedDigits } from "../_internals/is-repeated-digits/is-repeated-d import { VIN_CHECK_DIGIT_POSITION, VIN_LENGTH, + VIN_MODEL_YEAR_EXCLUDED, + VIN_MODEL_YEAR_POSITION, VIN_TRANSLITERATION, VIN_WEIGHTS, } from "./constants"; +/** Options of `isValidVin`. */ +export type IsValidVinOptions = { + /** + * Whether to also enforce the North-American rules of 49 CFR 565.15: the check digit at the 9th + * position and a model year code other than `U`, `Z` or `0` at the 10th (default: `false`, the + * Brazilian rule, which mandates neither; read for truthiness). + */ + checkDigit?: boolean; +}; + /** * Validates a VIN (Vehicle Identification Number / chassi). * - * Checks the length (17 characters), the excluded letters (`I`, `O`, `Q` are never valid; ISO - * 3779:2009 structure) and the check digit at the 9th position, with the check digit and - * transliteration computed per 49 CFR 565.15. That 9th-position check digit is a North-American - * requirement (49 CFR 565.15 / SAE J853): Resolução CONTRAN nº 968/2022 (in force since 1 July 2022, revoking Resolução CONTRAN nº 24/1998 from - * 1 January 2025 by its art. 50, II) and ABNT NBR 6066 define - * the Brazilian VIN structure but do not mandate it, so many Brazilian-built VINs do not carry - * a matching check digit. This function is therefore a North-American-style structural check, - * not a universal validator of Brazilian VINs. Case-insensitive and trims surrounding whitespace. + * By default checks 17 characters in the three sections of Resolução CONTRAN nº 968/2022, art. 3º + * (the WMI, the VDS and the VIS), each a digit or a capital letter other than `I`, `O` and `Q`. + * The `I`, `O` and `Q` exclusion comes from ISO 3779:2009, not from the resolution, which lists no + * forbidden character and refers the engraving to ABNT NBR 6066:2022 (art. 5º), a paid standard + * with no official free copy; the regularization VINs of its Anexo II are written without those + * letters. The resolution does not mandate a check digit, and many Brazilian-built VINs do not + * carry one, so `isValidVin("9BWZZZ377VT004251")`, a Volkswagen built in Brazil, is valid. + * + * `{ checkDigit: true }` adds the North-American rules of 49 CFR 565.15 (SAE J853): the 9th + * character has to be the weighted MOD 11 check digit (`0` to `9` or `X`) of the transliterated + * VIN, and the 10th, the model year code, may not be `U`, `Z` or `0`. Use it for a VIN of a + * vehicle built for the United States or Canada. + * + * Case-insensitive and trims surrounding whitespace. A VIN is printed as one unbroken run of 17 + * characters, so, unlike the documents this package masks (`isValidCpf`, `isValidCnpj`, + * `isValidNfeKey`), it has no group boundary to write a separator at and none is accepted: a + * space, `.`, `-` or `/` among the characters is rejected instead of being stripped. * - * A VIN is printed as one unbroken run of 17 characters, so, unlike the documents this package - * masks (`isValidCpf`, `isValidCnpj`, `isValidNfeKey`), it has no group boundary to write a - * separator at and none is accepted: a space, `.`, `-` or `/` among the characters is rejected - * instead of being stripped. + * A value whose 17 characters are all the same (`"00000000000000000"`) is rejected, as every other + * validator of this package rejects a repeated-digit document (`isValidCpf("00000000000")`, + * `isValidCns`, `isValidCaepf`, `isValidCei`): no WMI, VDS and VIS are built out of a single + * repeated character, and it is what a placeholder or a zero-filled field looks like. * - * A value whose 17 characters are all the same (`"00000000000000000"`) is rejected even when it - * carries a matching check digit, as every other validator of this package rejects a - * repeated-digit document (`isValidCpf("00000000000")`, `isValidCns`, `isValidCaepf`, - * `isValidCei`): no WMI, VDS and VIS are built out of a single repeated character, and it is what - * a placeholder or a zero-filled field looks like. + * Up to 2.4.0 the check digit was always enforced, so a Brazilian VIN without one, such as + * "9BWZZZ377VT004251", was rejected; pass `{ checkDigit: true }` to keep that behaviour. * * @param {string} value - The VIN to be validated. - * @returns {boolean} True when `value` is a 17 character VIN with a matching check digit. + * @param {IsValidVinOptions} [options] - Optional validation options. + * @param {boolean} [options.checkDigit] - If truthy, also enforces the 49 CFR 565.15 check digit + * and model year code. Defaults to false. + * @returns {boolean} True when `value` is a 17 character VIN under the chosen rule. * * @example * ```typescript - * isValidVin("1HGCM82633A004352"); // true - * isValidVin("1m8gdm9axkp042788"); // true (check digit X, lowercase) - * isValidVin("JH4TB2H26CC000000"); // true - * isValidVin("1HGCM82633A004353"); // false (bad check digit) - * isValidVin("00000000000000000"); // false (every character the same, though the check digit matches) + * isValidVin("9BWZZZ377VT004251"); // true (Brazilian VIN, no check digit) + * isValidVin("9BWZZZ377VT004251", { checkDigit: true }); // false (its 9th character is not the check digit) + * isValidVin("1HGCM82633A004352", { checkDigit: true }); // true + * isValidVin("1m8gdm9axkp042788", { checkDigit: true }); // true (check digit X, lowercase) + * isValidVin("1HGCM82633A004353", { checkDigit: true }); // false (bad check digit) + * isValidVin("00000000000000000"); // false (every character the same) * isValidVin("1HGCM8263IA004352"); // false (contains the excluded letter I) * isValidVin("1HGCM82633A00435"); // false (16 characters) * ``` @@ -48,14 +70,20 @@ import { * client, so it has to be opened in a browser, where it renders the standard's paywalled * abstract rather than its text. * + * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/resolucao9682022.pdf + * Resolução CONTRAN nº 968, de 20 de junho de 2022, art. 3º, I to IV: the VIN is a "combinação de + * 17 caracteres" in three sections (WMI, VDS and VIS, of 3, 6 and 8 characters), with no check + * digit and no forbidden character; art. 5º refers the engraving to ABNT NBR 6066:2022; art. 50, + * II revoked Resolução nº 24/1998 from 1 January 2025. * @see Official: https://www.iso.org/standard/52200.html + * ISO 3779:2009, the VIN content and structure, source of the `I`, `O` and `Q` exclusion. * @see Official: https://www.ecfr.gov/current/title-49/section-565.15 - * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/resolucao9682022.pdf - * Resolução CONTRAN nº 968, de 20 de junho de 2022, art. 2º, I (VIN of 17 characters in three sections) - * and art. 50, II (revocation of Resolução nº 24/1998 from 1 January 2025). + * 49 CFR 565.15: "The check digit, zero through nine (0-9) or the letter "X" shall appear in VIN + * position nine (9)", and the model year code of position ten, which leaves out `U`, `Z` and `0`; + * enforced only under `{ checkDigit: true }`. * @see Official: https://vpic.nhtsa.dot.gov/api/ */ -export const isValidVin = (value: string): boolean => { +export const isValidVin = (value: string, options?: IsValidVinOptions): boolean => { if (typeof value !== "string") return false; const vin = value.trim().toUpperCase(); @@ -65,18 +93,20 @@ export const isValidVin = (value: string): boolean => { if (isRepeatedDigits(vin)) return false; // Stryker disable next-line StringLiteral: generateChecksum strips this to digits, so it's inert. - let translitDigits = ""; + let transliteratedDigits = ""; - for (const char of vin) { - if (!(char in VIN_TRANSLITERATION)) return false; + for (const character of vin) { + if (!(character in VIN_TRANSLITERATION)) return false; - translitDigits += VIN_TRANSLITERATION[char]; + transliteratedDigits += VIN_TRANSLITERATION[character]; } - const checkDigit = vin[VIN_CHECK_DIGIT_POSITION]; + if (!(options?.checkDigit ?? false)) return true; + + if (VIN_MODEL_YEAR_EXCLUDED.includes(vin[VIN_MODEL_YEAR_POSITION])) return false; - const remainder = generateChecksum({ base: translitDigits, weight: VIN_WEIGHTS }) % 11; + const remainder = generateChecksum({ base: transliteratedDigits, weight: VIN_WEIGHTS }) % 11; const expected = remainder === 10 ? "X" : String(remainder); - return expected === checkDigit; + return expected === vin[VIN_CHECK_DIGIT_POSITION]; }; diff --git a/src/is-valid-voter-id/is-valid-voter-id.test.ts b/src/is-valid-voter-id/is-valid-voter-id.test.ts index 980e89f75..bee60aa8e 100644 --- a/src/is-valid-voter-id/is-valid-voter-id.test.ts +++ b/src/is-valid-voter-id/is-valid-voter-id.test.ts @@ -24,49 +24,45 @@ describe("isValidVoterId", () => { expect(isValidVoterId("102385010671")).toBe(true); }); - it("should validate a real 13-digit voter id (São Paulo, 9-digit sequential)", () => { - expect(isValidVoterId("1234567880191")).toBe(true); - }); - - it("should ignore the ninth sequential digit when checking a 13-digit voter id, as brutils does", () => { - const variants = [ - "1234567800191", - "1234567810191", - "1234567820191", - "1234567830191", - "1234567840191", - "1234567850191", - "1234567860191", - "1234567870191", - "1234567880191", - "1234567890191", - ]; - - for (const variant of variants) { - expect(isValidVoterId(variant)).toBe(true); - } - - expect(isValidVoterId("1234567880192")).toBe(false); - }); - - it("should reject a 13-digit value whose UF cannot carry a 9-digit sequential number", () => { - expect(isValidVoterId("1234567890396")).toBe(false); - expect(isValidVoterId("123456780396")).toBe(true); - }); - - it("should reject a value with more than 13 digits even when the first 8 and the last 4 match", () => { - expect(isValidVoterId("12345678980191")).toBe(false); - expect(isValidVoterId("1234567880191")).toBe(true); + it("should reject a 13-digit value, since a voter id has at most 12 digits", () => { + // The São Paulo/Minas Gerais 9-digit sequential form 2.4.0 accepted: the resolution caps the id at 12 digits. + expect(isValidVoterId("1234567880191")).toBe(false); + expect(isValidVoterId("1234 5678 8 01 91")).toBe(false); + expect(isValidVoterId("0123456780191")).toBe(false); + expect(isValidVoterId("123456780191")).toBe(true); + }); + + it("should accept a voter id issued without the leading zeros of its sequential number", () => { + // 00012345: 1·5 + 2·6 + 3·7 + 4·8 + 5·9 = 115 ≡ 5; UF 01: 0·7 + 1·8 + 5·9 = 53 ≡ 9 + expect(isValidVoterId("000123450159")).toBe(true); + expect(isValidVoterId("00123450159")).toBe(true); + expect(isValidVoterId("123450159")).toBe(true); + expect(isValidVoterId("1 2345 01 59")).toBe(true); + expect(isValidVoterId("12345 01 59")).toBe(true); + expect(isValidVoterId("123450158")).toBe(false); + }); + + it("should accept a sequential number of every length from 1 to 8 digits once its zeros are dropped", () => { + // 00000012, UF 28: 1·8 + 2·9 = 26 ≡ 4; 2·7 + 8·8 + 4·9 = 114 ≡ 4 + expect(isValidVoterId("122844")).toBe(true); + // 00001234, UF 06: 1·6 + 2·7 + 3·8 + 4·9 = 80 ≡ 3; 6·8 + 3·9 = 75 ≡ 9 + expect(isValidVoterId("12340639")).toBe(true); + expect(isValidVoterId("1234 06 39")).toBe(true); + }); + + it("should accept the shortest form, a single sequential digit, and nothing shorter", () => { + // 00000001: 1·9 = 9; UF 01: 0·7 + 1·8 + 9·9 = 89 ≡ 1 + expect(isValidVoterId("10191")).toBe(true); + expect(isValidVoterId("000000010191")).toBe(true); + // 00000000 (UF 01): remainder 0 → 1 for SP; 8 + 9 = 17 ≡ 6 + expect(isValidVoterId("000000000116")).toBe(true); + expect(isValidVoterId("0116")).toBe(false); }); it("should return false when the UF code is outside 01-28", () => { expect(isValidVoterId("123456789900")).toBe(false); }); - it("should return false for a 13-digit voter id whose UF is not 01 or 02", () => { - expect(isValidVoterId("1234567890345")).toBe(false); - }); - it("should reject a value with a letter attached to the digits", () => { expect(isValidVoterId("ab102385010671")).toBe(false); expect(isValidVoterId("102385010671ab")).toBe(false); @@ -79,7 +75,14 @@ describe("isValidVoterId", () => { it("should accept the documented whitespace and dot masks", () => { expect(isValidVoterId("1023 8501 06 71")).toBe(true); expect(isValidVoterId("1023.8501.06.71")).toBe(true); - expect(isValidVoterId("1234 5678 8 01 91")).toBe(true); + expect(isValidVoterId("0001.2345.01.59")).toBe(true); + }); + + it("should only accept separators between the groups of a sequential number grouped from the right", () => { + // 01234567: 1·3 + 2·4 + 3·5 + 4·6 + 5·7 + 6·8 + 7·9 = 196 ≡ 9; UF 01: 8 + 81 = 89 ≡ 1 + expect(isValidVoterId("123 4567 01 91")).toBe(true); + expect(isValidVoterId("1234 567 01 91")).toBe(false); + expect(isValidVoterId("1234 5678 0 1 91")).toBe(false); }); it("should return false for null, undefined, a number or an empty string", () => { @@ -97,9 +100,14 @@ describe("isValidVoterId", () => { expect(isValidVoterId(102_385_010_671)).toBe(false); }); - it("should reject a value whose length is neither 12 nor 13, even when its checksum would otherwise match", () => { - expect(isValidVoterId("000010191")).toBe(false); + it("should reject a value longer than 12 digits, even when its checksum would otherwise match", () => { expect(isValidVoterId("1234567890370")).toBe(false); + expect(isValidVoterId("0000000000191")).toBe(false); + }); + + it("should read a 9-digit value as the id without its leading zeros", () => { + // 2.4.0 rejected it for its length; it is 000000010191 without the leading zeros + expect(isValidVoterId("000010191")).toBe(true); }); it("should reject the UF code boundaries 0 and 29, even when the checksum would otherwise match", () => { @@ -124,8 +132,19 @@ describe("isValidVoterId", () => { ); }); - test("should reject any digits only value that is neither 12 nor 13 digits long", () => { - expectRejected(isValidVoterId, digitsOfOtherLength(26, [12, 13])); + test("should reject any digits only value longer than 12 digits or shorter than 5", () => { + expectRejected(isValidVoterId, digitsOfOtherLength(26, [5, 6, 7, 8, 9, 10, 11, 12])); + }); + + test("should accept a generated voter id with any number of its leading zeros dropped", () => { + fc.assert( + fc.property(voterIds(), fc.nat(8), (voterId, dropped) => { + const zeros = /^0*/.exec(voterId)?.[0].length ?? 0; + const shortened = voterId.slice(Math.min(dropped, zeros, 7)); + + expect(isValidVoterId(shortened)).toBe(true); + }), + ); }); test("should never throw and always return a boolean", () => { diff --git a/src/is-valid-voter-id/is-valid-voter-id.ts b/src/is-valid-voter-id/is-valid-voter-id.ts index f9bbc3e41..a3f07a905 100644 --- a/src/is-valid-voter-id/is-valid-voter-id.ts +++ b/src/is-valid-voter-id/is-valid-voter-id.ts @@ -1,17 +1,25 @@ import { calculateVoterIdFirstDigit } from "../_internals/calculate-voter-id-first-digit/calculate-voter-id-first-digit"; import { calculateVoterIdSecondDigit } from "../_internals/calculate-voter-id-second-digit/calculate-voter-id-second-digit"; -import { NINE_DIGIT_FEDERATIVE_UNION_CODES } from "../_internals/constants/voter-id"; +import { VOTER_ID_LENGTH } from "../_internals/constants/voter-id"; const SEPARATORS_REGEX = /[\s.]/g; -const FORMAT_REGEX = /^[\s.]*\d{4}[\s.]*\d{4}[\s.]*(?:\d[\s.]*)?\d{2}[\s.]*\d{2}[\s.]*$/; +/** + * The sequential number is either written in full as "0000 0000" or, without its leading zeros, + * as 1 to 7 digits grouped from the right the same way ("123 4567", "1234"); the federative union + * code and the check digits follow as two groups of 2. + */ +const FORMAT_REGEX = /^[\s.]*(?:(?:\d{1,4}[\s.]*)?\d{4}|\d{1,3})[\s.]*\d{2}[\s.]*\d{2}[\s.]*$/; /** * Validates if a Brazilian voter id (título de eleitor) is valid. * - * A voter id normally has 12 digits: an 8-digit sequential number, a 2-digit federative - * union code (01-28) and a 2-digit verification code. São Paulo (01) and Minas Gerais (02) - * may instead issue voter ids with a 9-digit sequential number, totalling 13 digits. + * A voter id has up to 12 digits: an 8-digit sequential number, a 2-digit federative union code + * (01-28) and 2 check digits. The TSE drops the leading zeros of the sequential number when it + * issues the id, so a value with fewer digits is read as that id without its leading zeros and + * left padded with zeros to 12 digits before it is checked: "123450159" is checked as + * "000123450159". At least one sequential digit is required, so the shortest accepted value has + * 5 digits. A 13-digit value is rejected: the resolution allows no more than 12 digits. * * Whitespace and dots are accepted around and between the "0000 0000 00 00" groups, but any * other character, a letter in particular, makes the value invalid. @@ -22,21 +30,35 @@ const FORMAT_REGEX = /^[\s.]*\d{4}[\s.]*\d{4}[\s.]*(?:\d[\s.]*)?\d{2}[\s.]*\d{2} * @example * ```typescript * isValidVoterId("102385010671"); // true (12 digits) - * isValidVoterId("1234567880191"); // true (13 digits, São Paulo) + * isValidVoterId("000123450159"); // true (12 digits, leading zeros kept) + * isValidVoterId("123450159"); // true (the same id, issued without its leading zeros) * isValidVoterId("1023 8501 06 71"); // true (whitespace mask) + * isValidVoterId("1234567880191"); // false (13 digits, more than the 12 the TSE allows) * isValidVoterId("123456780124"); // false (invalid checksum) * isValidVoterId("ab102385010671"); // false (invalid format) * ``` * - * Resolução TSE nº 23.659/2021, art. 36, parágrafo único, confirms the federative union table and - * the two-step módulo 11 structure ("até 12 algarismos"). The weights used in each step and the - * 13-digit São Paulo/Minas Gerais ids are brutils parity, not published by the TSE — siga0984 uses - * a different 9-digit rule for the sequential number. + * Resolução TSE nº 23.659/2021, art. 36, sets the structure: "composto por até 12 algarismos", + * "os oito primeiros algarismos serão sequenciados, desprezando-se, na emissão, os zeros à + * esquerda", then the federative union code and two check digits, the first one "calculado + * sobre o número sequencial" and the second one over the federative union code followed by the + * first check digit, both "determinados com base no 'Módulo 11'". Resolução TSE nº 21.538/2003, + * art. 12, parágrafo único, had the same text and the same federative union table; it was revoked + * by Resolução TSE nº 23.659/2021, art. 140, which is the one in force. + * + * Neither resolution gives the weights of each step, what a remainder of 0, 1 or 10 turns into, + * or any rule of its own for São Paulo and Minas Gerais: the weights and the rule that turns a + * remainder of 0 into 1 for the codes 01 (São Paulo) and 02 (Minas Gerais) have no official + * source and follow the community references cited as `Based on:`. * * The TSE resolution page sits behind a bot filter and answers HTTP 403 to every non-browser * client, so it has to be opened in a browser. * * @see Official: https://www.tse.jus.br/legislacao/compilada/res/2021/resolucao-no-23-659-de-26-de-outubro-de-2021 + * Resolução TSE nº 23.659/2021, art. 36: "composto por até 12 algarismos", "os oito primeiros + * algarismos serão sequenciados, desprezando-se, na emissão, os zeros à esquerda", the table of + * the federative union codes 01 to 28 and the two check digits "determinados com base no + * 'Módulo 11'". * @see Based on: https://siga0984.wordpress.com/2019/05/01/algoritmos-validacao-de-titulo-de-eleitor/ * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/voter_id.py */ @@ -45,16 +67,12 @@ export const isValidVoterId = (value: string): boolean => { if (!FORMAT_REGEX.test(value)) return false; - const digits = value.replace(SEPARATORS_REGEX, ""); - - // Stryker disable next-line MethodExpression: the check digits are computed from the first eight digits only, so passing the whole value instead of the sequential part yields the same result. - const sequentialNumber = digits.slice(0, -4); - const federativeUnion = digits.slice(-4, -2); - const verifier = digits.slice(-2); + const digits = value.replace(SEPARATORS_REGEX, "").padStart(VOTER_ID_LENGTH, "0"); - if (digits.length === 13 && !NINE_DIGIT_FEDERATIVE_UNION_CODES.includes(federativeUnion)) { - return false; - } + // Stryker disable next-line MethodExpression: the first check digit is computed from the first eight digits only, so passing the whole value instead of the sequential part yields the same result. + const sequentialNumber = digits.slice(0, 8); + const federativeUnion = digits.slice(8, 10); + const verifier = digits.slice(10); const ufCode = Number(federativeUnion); diff --git a/src/parse-boleto/parse-boleto.test.ts b/src/parse-boleto/parse-boleto.test.ts index 9a3cb472a..de09c649a 100644 --- a/src/parse-boleto/parse-boleto.test.ts +++ b/src/parse-boleto/parse-boleto.test.ts @@ -92,6 +92,17 @@ describe("parseBoleto", () => { ); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(parseBoleto(1e21)).toBe(""); + expect(parseBoleto(-1)).toBe(""); + expect(parseBoleto(1.5)).toBe(""); + expect(parseBoleto(2 ** 53)).toBe(""); + expect(parseBoleto(Number.MAX_VALUE)).toBe(""); + expect(parseBoleto(Number.NaN)).toBe(""); + expect(parseBoleto(Number.POSITIVE_INFINITY)).toBe(""); + expect(parseBoleto(Number.NEGATIVE_INFINITY)).toBe(""); + }); }); describe("parseBoleto benchmarks", () => { diff --git a/src/parse-boleto/parse-boleto.ts b/src/parse-boleto/parse-boleto.ts index f9f25f4a6..394bca685 100644 --- a/src/parse-boleto/parse-boleto.ts +++ b/src/parse-boleto/parse-boleto.ts @@ -1,5 +1,6 @@ import { ARRECADACAO_LINE_LENGTH, ARRECADACAO_PRODUCT } from "../_internals/constants/arrecadacao"; import { BOLETO_LENGTH } from "../_internals/constants/boleto"; +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; /** @@ -8,6 +9,9 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d * Bank slips starting with `8` are "arrecadação" (convênio/tributos) slips, whose linha * digitável has 48 digits instead of the 47 of a "cobrança bancária" slip. * + * 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 boleto value to be parsed. * @returns {string} The boleto value without formatting. * @@ -18,19 +22,25 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d * * parseBoleto("82630000001-1 09880010070-2 02410202400-0 00020510451-9"); * // "826300000011098800100702024102024000000205104519" + * parseBoleto(1e21); // "" (not a non-negative safe integer) * ``` * - * Carta-Circular BCB nº 2.926/2000 specifies the linha digitável fields and the módulo 11 - * check digit (using 1 for remainders 0, 10 and 1) of the 47 digit cobrança bancária slip, - * including the position of the fator de vencimento field. The FEBRABAN "Layout Padrão de + * Carta-Circular BCB nº 2.926/2000 specifies the linha digitável fields, the código de moeda + * `9` (real) in position 4 of the barcode and the módulo 11 check digit (1 when 11 minus the + * remainder gives 0, 10 or 11, i.e. when the remainder is 0 or 1) of the 47 digit cobrança + * bancária slip, including the position of the fator de vencimento field. The FEBRABAN "Layout Padrão de * Arrecadação/Recebimento com Utilização do Código de Barras" and the FEBRABAN layout index * cover the arrecadação slip. * * @see Official: https://www.bcb.gov.br/pre/normativos/c_circ/2000/pdf/c_circ_2926_v1_O.pdf * @see Official: https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf + * FEBRABAN "Layout Padrão de Arrecadação/Recebimento com Utilização do Código de Barras", + * Versão 08 (file of 11/05/2026), "Vigência: a partir de 01.06.2026". * @see Official: https://portal.febraban.org.br/pagina/3425/33/pt-br/layout-febraban */ export const parseBoleto = (value: string | number): string => { + if (!isLookupCode(value)) return ""; + const digits = sanitizeToDigits(value); return digits.slice( diff --git a/src/parse-caepf/parse-caepf.test.ts b/src/parse-caepf/parse-caepf.test.ts index 2fb7c947a..1bd63845c 100644 --- a/src/parse-caepf/parse-caepf.test.ts +++ b/src/parse-caepf/parse-caepf.test.ts @@ -48,6 +48,18 @@ describe("parseCaepf", () => { expectAlwaysReturnsType(parseCaepf, "string", anyValue); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(parseCaepf(-41_142_260_000_101)).toBe(""); + expect(parseCaepf(-1)).toBe(""); + expect(parseCaepf(1.5)).toBe(""); + expect(parseCaepf(2 ** 53)).toBe(""); + expect(parseCaepf(Number.MAX_VALUE)).toBe(""); + expect(parseCaepf(1e21)).toBe(""); + expect(parseCaepf(Number.NaN)).toBe(""); + expect(parseCaepf(Number.POSITIVE_INFINITY)).toBe(""); + expect(parseCaepf(Number.NEGATIVE_INFINITY)).toBe(""); + }); }); describe("parseCaepf types", () => { diff --git a/src/parse-caepf/parse-caepf.ts b/src/parse-caepf/parse-caepf.ts index 10a668f7b..11eedec4d 100644 --- a/src/parse-caepf/parse-caepf.ts +++ b/src/parse-caepf/parse-caepf.ts @@ -1,4 +1,4 @@ -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"; import { LENGTH } from "./constants"; @@ -10,12 +10,16 @@ import { LENGTH } from "./constants"; * is capped at; a shorter value passes through as far as it goes. Use `isValidCaepf` to check the * number itself. * + * 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 CAEPF value to be parsed. * @returns {string} Up to 14 digits, or an empty string when there is no digit at all. * * @example * ```typescript * parseCaepf("293.118.610/001-84"); // "29311861000184" + * parseCaepf(-41142260000101); // "" (not a non-negative safe integer) * ``` * * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/caepf @@ -23,4 +27,4 @@ import { LENGTH } from "./constants"; * the mask; the mask is the one the sources cited by `isValidCaepf` agree on. */ export const parseCaepf = (value: string | number): string => - isNullish(value) ? "" : sanitizeToDigits(value).slice(0, LENGTH); + isLookupCode(value) ? sanitizeToDigits(value).slice(0, LENGTH) : ""; diff --git a/src/parse-cbo/parse-cbo.test.ts b/src/parse-cbo/parse-cbo.test.ts index bc6c3a0af..271407248 100644 --- a/src/parse-cbo/parse-cbo.test.ts +++ b/src/parse-cbo/parse-cbo.test.ts @@ -46,6 +46,18 @@ describe("parseCbo", () => { expectAlwaysReturnsType(parseCbo, "string", anyValue); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(parseCbo(2124.05)).toBe(""); + expect(parseCbo(-1)).toBe(""); + expect(parseCbo(1.5)).toBe(""); + expect(parseCbo(2 ** 53)).toBe(""); + expect(parseCbo(Number.MAX_VALUE)).toBe(""); + expect(parseCbo(1e21)).toBe(""); + expect(parseCbo(Number.NaN)).toBe(""); + expect(parseCbo(Number.POSITIVE_INFINITY)).toBe(""); + expect(parseCbo(Number.NEGATIVE_INFINITY)).toBe(""); + }); }); describe("parseCbo types", () => { diff --git a/src/parse-cbo/parse-cbo.ts b/src/parse-cbo/parse-cbo.ts index 86eece2f5..5b311b9b4 100644 --- a/src/parse-cbo/parse-cbo.ts +++ b/src/parse-cbo/parse-cbo.ts @@ -1,4 +1,4 @@ -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"; import { LENGTH } from "./constants"; @@ -11,16 +11,20 @@ import { LENGTH } from "./constants"; * as `010205` has to be written out. Use `getCbo` or `isValidCbo`, which do pad a bare numeric * code, to look an occupation up. * + * 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 CBO code to be parsed. * @returns {string} Up to 6 digits, or an empty string when there is no digit at all. * * @example * ```typescript * parseCbo("2124-05"); // "212405" + * parseCbo(2124.05); // "" (not a non-negative safe integer) * ``` * * @see Official: https://www.gov.br/trabalho-e-emprego/pt-br/assuntos/cbo/servicos/downloads/cbo2002-ocupacao.csv * The CBO 2002 occupation table, as published by the Ministério do Trabalho e Emprego. */ export const parseCbo = (value: string | number): string => - isNullish(value) ? "" : sanitizeToDigits(value).slice(0, LENGTH); + isLookupCode(value) ? sanitizeToDigits(value).slice(0, LENGTH) : ""; diff --git a/src/parse-cei/parse-cei.test.ts b/src/parse-cei/parse-cei.test.ts index 393f687fe..b4d3a662a 100644 --- a/src/parse-cei/parse-cei.test.ts +++ b/src/parse-cei/parse-cei.test.ts @@ -48,6 +48,18 @@ describe("parseCei", () => { expectAlwaysReturnsType(parseCei, "string", anyValue); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(parseCei(-249_859_674_386)).toBe(""); + expect(parseCei(-1)).toBe(""); + expect(parseCei(1.5)).toBe(""); + expect(parseCei(2 ** 53)).toBe(""); + expect(parseCei(Number.MAX_VALUE)).toBe(""); + expect(parseCei(1e21)).toBe(""); + expect(parseCei(Number.NaN)).toBe(""); + expect(parseCei(Number.POSITIVE_INFINITY)).toBe(""); + expect(parseCei(Number.NEGATIVE_INFINITY)).toBe(""); + }); }); describe("parseCei types", () => { diff --git a/src/parse-cei/parse-cei.ts b/src/parse-cei/parse-cei.ts index 7e7149668..703ea3c77 100644 --- a/src/parse-cei/parse-cei.ts +++ b/src/parse-cei/parse-cei.ts @@ -1,4 +1,4 @@ -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"; import { LENGTH } from "./constants"; @@ -9,12 +9,16 @@ import { LENGTH } from "./constants"; * capped at; a shorter value passes through as far as it goes, so the mask of an input still * being typed can be stripped with it. Use `isValidCei` to check the number itself. * + * 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 CEI value to be parsed. * @returns {string} Up to 12 digits, or an empty string when there is no digit at all. * * @example * ```typescript * parseCei("27.729.71181/87"); // "277297118187" + * parseCei(-249859674386); // "" (not a non-negative safe integer) * ``` * * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/cno @@ -22,4 +26,4 @@ import { LENGTH } from "./constants"; * the mask; the mask is the one the reference implementations cited by `isValidCei` agree on. */ export const parseCei = (value: string | number): string => - isNullish(value) ? "" : sanitizeToDigits(value).slice(0, LENGTH); + isLookupCode(value) ? sanitizeToDigits(value).slice(0, LENGTH) : ""; diff --git a/src/parse-cep/parse-cep.test.ts b/src/parse-cep/parse-cep.test.ts index 98d14fb75..25ba480fd 100644 --- a/src/parse-cep/parse-cep.test.ts +++ b/src/parse-cep/parse-cep.test.ts @@ -38,6 +38,18 @@ describe("parseCep", () => { expectAlwaysReturnsType(parseCep, "string", anyValue); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(parseCep(-20_040_020)).toBe(""); + expect(parseCep(-1)).toBe(""); + expect(parseCep(1.5)).toBe(""); + expect(parseCep(2 ** 53)).toBe(""); + expect(parseCep(Number.MAX_VALUE)).toBe(""); + expect(parseCep(1e21)).toBe(""); + expect(parseCep(Number.NaN)).toBe(""); + expect(parseCep(Number.POSITIVE_INFINITY)).toBe(""); + expect(parseCep(Number.NEGATIVE_INFINITY)).toBe(""); + }); }); describe("parseCep types", () => { diff --git a/src/parse-cep/parse-cep.ts b/src/parse-cep/parse-cep.ts index 490b51ae6..6a95121ea 100644 --- a/src/parse-cep/parse-cep.ts +++ b/src/parse-cep/parse-cep.ts @@ -1,20 +1,24 @@ import { CEP_LENGTH } from "../_internals/constants/cep"; -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"; /** * Removes CEP formatting characters and returns only digits. * + * 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 CEP value to be parsed. * @returns {string} The CEP value without formatting. * * @example * ```typescript * parseCep("01310-930"); // "01310930" + * parseCep(-20040020); // "" (not a non-negative safe integer) * ``` * * @see Official: https://www.correios.com.br/enviar/precisa-de-ajuda/tudo-sobre-cep * @see Official: https://www.correios.com.br/enviar/precisa-de-ajuda/guia-de-enderecamento/guia-de-enderecamento */ export const parseCep = (value: string | number): string => - isNullish(value) ? "" : sanitizeToDigits(value).slice(0, CEP_LENGTH); + isLookupCode(value) ? sanitizeToDigits(value).slice(0, CEP_LENGTH) : ""; diff --git a/src/parse-certidao/parse-certidao.test.ts b/src/parse-certidao/parse-certidao.test.ts index 611c03a7b..2141ff476 100644 --- a/src/parse-certidao/parse-certidao.test.ts +++ b/src/parse-certidao/parse-certidao.test.ts @@ -55,6 +55,18 @@ describe("parseCertidao", () => { expectAlwaysReturnsType(parseCertidao, "string", anyValue); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(parseCertidao(1_045_390_155.2)).toBe(""); + expect(parseCertidao(-1)).toBe(""); + expect(parseCertidao(1.5)).toBe(""); + expect(parseCertidao(2 ** 53)).toBe(""); + expect(parseCertidao(Number.MAX_VALUE)).toBe(""); + expect(parseCertidao(1e21)).toBe(""); + expect(parseCertidao(Number.NaN)).toBe(""); + expect(parseCertidao(Number.POSITIVE_INFINITY)).toBe(""); + expect(parseCertidao(Number.NEGATIVE_INFINITY)).toBe(""); + }); }); describe("parseCertidao types", () => { diff --git a/src/parse-certidao/parse-certidao.ts b/src/parse-certidao/parse-certidao.ts index 668928323..5633e1f8c 100644 --- a/src/parse-certidao/parse-certidao.ts +++ b/src/parse-certidao/parse-certidao.ts @@ -1,5 +1,5 @@ import { CERTIDAO_LENGTH } from "../_internals/constants/certidao"; -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"; /** @@ -11,6 +11,9 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d * with it. This only takes the mask off: use `isValidCertidao` to check the matrícula and * `getCertidaoInfo` 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 matrícula value to be parsed. * @returns {string} Up to 32 digits, or an empty string when there is no digit at all. * @@ -18,6 +21,7 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d * ```typescript * parseCertidao("104539 01 55 2013 1 00012 021 0000123 21"); * // "10453901552013100012021000012321" + * parseCertidao(1045390155.2); // "" (not a non-negative safe integer) * ``` * * @see Official: https://atos.cnj.jus.br/atos/detalhar/5243 @@ -29,4 +33,4 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d * structure (revoked; historical). */ export const parseCertidao = (value: string | number): string => - isNullish(value) ? "" : sanitizeToDigits(value).slice(0, CERTIDAO_LENGTH); + isLookupCode(value) ? sanitizeToDigits(value).slice(0, CERTIDAO_LENGTH) : ""; diff --git a/src/parse-cfop/parse-cfop.test.ts b/src/parse-cfop/parse-cfop.test.ts index aec7beeb7..d0cfcf50e 100644 --- a/src/parse-cfop/parse-cfop.test.ts +++ b/src/parse-cfop/parse-cfop.test.ts @@ -42,6 +42,18 @@ describe("parseCfop", () => { expectAlwaysReturnsType(parseCfop, "string", anyValue); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(parseCfop(51.02)).toBe(""); + expect(parseCfop(-1)).toBe(""); + expect(parseCfop(1.5)).toBe(""); + expect(parseCfop(2 ** 53)).toBe(""); + expect(parseCfop(Number.MAX_VALUE)).toBe(""); + expect(parseCfop(1e21)).toBe(""); + expect(parseCfop(Number.NaN)).toBe(""); + expect(parseCfop(Number.POSITIVE_INFINITY)).toBe(""); + expect(parseCfop(Number.NEGATIVE_INFINITY)).toBe(""); + }); }); describe("parseCfop types", () => { diff --git a/src/parse-cfop/parse-cfop.ts b/src/parse-cfop/parse-cfop.ts index 077f9cedb..9285750ba 100644 --- a/src/parse-cfop/parse-cfop.ts +++ b/src/parse-cfop/parse-cfop.ts @@ -1,4 +1,4 @@ -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"; import { LENGTH } from "./constants"; @@ -11,16 +11,20 @@ import { LENGTH } from "./constants"; * group from 1 to 7, so nothing is ever padded here. Use `getCfop` or `isValidCfop` to look a * code up in the official table. * + * 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 CFOP code to be parsed. * @returns {string} Up to 4 digits, or an empty string when there is no digit at all. * * @example * ```typescript * parseCfop("5.102"); // "5102" + * parseCfop(51.02); // "" (not a non-negative safe integer) * ``` * * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/sinief/cfop_cvsn_1-6.24 * Consolidated Anexo II of Convênio SINIEF s/nº 1970, which prints the codes in the "N.NNN" form. */ export const parseCfop = (value: string | number): string => - isNullish(value) ? "" : sanitizeToDigits(value).slice(0, LENGTH); + isLookupCode(value) ? sanitizeToDigits(value).slice(0, LENGTH) : ""; diff --git a/src/parse-cnae/parse-cnae.test.ts b/src/parse-cnae/parse-cnae.test.ts index 00a41e6a2..34b2b320d 100644 --- a/src/parse-cnae/parse-cnae.test.ts +++ b/src/parse-cnae/parse-cnae.test.ts @@ -52,6 +52,18 @@ describe("parseCnae", () => { expectAlwaysReturnsType(parseCnae, "string", anyValue); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(parseCnae(620_150.1)).toBe(""); + expect(parseCnae(-1)).toBe(""); + expect(parseCnae(1.5)).toBe(""); + expect(parseCnae(2 ** 53)).toBe(""); + expect(parseCnae(Number.MAX_VALUE)).toBe(""); + expect(parseCnae(1e21)).toBe(""); + expect(parseCnae(Number.NaN)).toBe(""); + expect(parseCnae(Number.POSITIVE_INFINITY)).toBe(""); + expect(parseCnae(Number.NEGATIVE_INFINITY)).toBe(""); + }); }); describe("parseCnae types", () => { diff --git a/src/parse-cnae/parse-cnae.ts b/src/parse-cnae/parse-cnae.ts index aea5a35b6..09575f9e1 100644 --- a/src/parse-cnae/parse-cnae.ts +++ b/src/parse-cnae/parse-cnae.ts @@ -1,4 +1,4 @@ -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"; import { LENGTH } from "./constants"; @@ -11,15 +11,19 @@ import { LENGTH } from "./constants"; * is never left padded, so the leading zeros a code carries have to be written out. Use * `getCnae` or `isValidCnae`, which do pad a bare numeric code, to look a code up. * + * 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 CNAE code to be parsed. * @returns {string} Up to 7 digits, or an empty string when there is no digit at all. * * @example * ```typescript * parseCnae("6201-5/01"); // "6201501" + * parseCnae(620150.1); // "" (not a non-negative safe integer) * ``` * * @see Official: https://servicodados.ibge.gov.br/api/v2/cnae/subclasses */ export const parseCnae = (value: string | number): string => - isNullish(value) ? "" : sanitizeToDigits(value).slice(0, LENGTH); + isLookupCode(value) ? sanitizeToDigits(value).slice(0, LENGTH) : ""; diff --git a/src/parse-cnh/parse-cnh.test.ts b/src/parse-cnh/parse-cnh.test.ts index 010f1ee9e..60e4bd047 100644 --- a/src/parse-cnh/parse-cnh.test.ts +++ b/src/parse-cnh/parse-cnh.test.ts @@ -38,6 +38,17 @@ describe("parseCnh", () => { expectAlwaysReturnsType(parseCnh, "string", anyValue); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(parseCnh(2 ** 53)).toBe(""); + expect(parseCnh(-1)).toBe(""); + expect(parseCnh(1.5)).toBe(""); + expect(parseCnh(Number.MAX_VALUE)).toBe(""); + expect(parseCnh(1e21)).toBe(""); + expect(parseCnh(Number.NaN)).toBe(""); + expect(parseCnh(Number.POSITIVE_INFINITY)).toBe(""); + expect(parseCnh(Number.NEGATIVE_INFINITY)).toBe(""); + }); }); describe("parseCnh types", () => { diff --git a/src/parse-cnh/parse-cnh.ts b/src/parse-cnh/parse-cnh.ts index 03d6cb94b..49a941404 100644 --- a/src/parse-cnh/parse-cnh.ts +++ b/src/parse-cnh/parse-cnh.ts @@ -1,23 +1,32 @@ -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"; import { LENGTH } from "./constants"; /** * Removes CNH (Carteira Nacional de Habilitação) formatting characters and returns only digits. * + * 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 CNH to be parsed. * @returns {string} Up to 11 digits, or an empty string when there is no digit at all. * * @example * ```typescript * parseCnh("123456789-00"); // "12345678900" + * parseCnh(2 ** 53); // "" (not a non-negative safe integer) * ``` * * Resolução CONTRAN nº 886/2021, art. 4º I, defines the CNH registry number as 9 characters plus * 2 security check digits, which is the layout this parser caps at; no official text publishes - * the check-digit weights used to compute them. + * the check-digit weights used to compute them. Resolução CONTRAN nº 1.020/2025, art. 10, repeats + * the same layout ("nove caracteres e dois dígitos verificadores") and does not revoke the 886. * * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/Resolucao8862021F.pdf + * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/Resolucao10202025.pdf + * Resolução CONTRAN nº 1.020, de 1º de dezembro de 2025 (DOU of 09/12/2025), art. 10: the same + * three numbers, the registro nacional "composto de nove caracteres e dois dígitos verificadores", + * with no "módulo 11" rule and no weights; its art. 140 does not revoke Resolução nº 886/2021. */ export const parseCnh = (value: string | number): string => - isNullish(value) ? "" : sanitizeToDigits(value).slice(0, LENGTH); + isLookupCode(value) ? sanitizeToDigits(value).slice(0, LENGTH) : ""; diff --git a/src/parse-cno/parse-cno.test.ts b/src/parse-cno/parse-cno.test.ts index deb7b3998..d442b89c9 100644 --- a/src/parse-cno/parse-cno.test.ts +++ b/src/parse-cno/parse-cno.test.ts @@ -48,6 +48,18 @@ describe("parseCno", () => { expectAlwaysReturnsType(parseCno, "string", anyValue); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(parseCno(-401_800_097_960)).toBe(""); + expect(parseCno(-1)).toBe(""); + expect(parseCno(1.5)).toBe(""); + expect(parseCno(2 ** 53)).toBe(""); + expect(parseCno(Number.MAX_VALUE)).toBe(""); + expect(parseCno(1e21)).toBe(""); + expect(parseCno(Number.NaN)).toBe(""); + expect(parseCno(Number.POSITIVE_INFINITY)).toBe(""); + expect(parseCno(Number.NEGATIVE_INFINITY)).toBe(""); + }); }); describe("parseCno types", () => { diff --git a/src/parse-cno/parse-cno.ts b/src/parse-cno/parse-cno.ts index bc9c49a25..3b34f85a0 100644 --- a/src/parse-cno/parse-cno.ts +++ b/src/parse-cno/parse-cno.ts @@ -1,4 +1,4 @@ -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"; import { LENGTH } from "./constants"; @@ -9,12 +9,16 @@ import { LENGTH } from "./constants"; * is capped at the same length; a shorter value passes through as far as it goes. Use * `isValidCno` to check the number itself. * + * 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 CNO value to be parsed. * @returns {string} Up to 12 digits, or an empty string when there is no digit at all. * * @example * ```typescript * parseCno("11.113.01373/68"); // "111130137368" + * parseCno(-401800097960); // "" (not a non-negative safe integer) * ``` * * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/cno @@ -22,4 +26,4 @@ import { LENGTH } from "./constants"; * the mask; the mask is the one the reference implementations cited by `isValidCno` agree on. */ export const parseCno = (value: string | number): string => - isNullish(value) ? "" : sanitizeToDigits(value).slice(0, LENGTH); + isLookupCode(value) ? sanitizeToDigits(value).slice(0, LENGTH) : ""; diff --git a/src/parse-cnpj/parse-cnpj.test.ts b/src/parse-cnpj/parse-cnpj.test.ts index 2f4b18301..59e97f55a 100644 --- a/src/parse-cnpj/parse-cnpj.test.ts +++ b/src/parse-cnpj/parse-cnpj.test.ts @@ -18,6 +18,11 @@ describe("parseCnpj", () => { expect(parseCnpj("Q0.SLF.MBD/7VX4-39", { version: 2 })).toBe("Q0SLFMBD7VX439"); }); + it("should upper-case lower case letters for version 2, the official set being A to Z", () => { + expect(parseCnpj("12.abc.345/01de-35", { version: 2 })).toBe("12ABC34501DE35"); + expect(parseCnpj("q0slfmbd7vx439", { version: 2 })).toBe("Q0SLFMBD7VX439"); + }); + it("should ignore digits after the CNPJ length", () => { expect(parseCnpj("46843485000186123")).toBe("46843485000186"); }); @@ -57,6 +62,18 @@ describe("parseCnpj", () => { expectAlwaysReturnsType(parseCnpj, "string", anyValue); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(parseCnpj(-11_222_333_000_181)).toBe(""); + expect(parseCnpj(-1)).toBe(""); + expect(parseCnpj(1.5)).toBe(""); + expect(parseCnpj(2 ** 53)).toBe(""); + expect(parseCnpj(Number.MAX_VALUE)).toBe(""); + expect(parseCnpj(1e21)).toBe(""); + expect(parseCnpj(Number.NaN)).toBe(""); + expect(parseCnpj(Number.POSITIVE_INFINITY)).toBe(""); + expect(parseCnpj(Number.NEGATIVE_INFINITY)).toBe(""); + }); }); describe("parseCnpj types", () => { diff --git a/src/parse-cnpj/parse-cnpj.ts b/src/parse-cnpj/parse-cnpj.ts index 10d66a331..4fdb6dc1e 100644 --- a/src/parse-cnpj/parse-cnpj.ts +++ b/src/parse-cnpj/parse-cnpj.ts @@ -1,5 +1,5 @@ import { CNPJ_LENGTH } from "../_internals/constants/cnpj"; -import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { sanitizeCnpj } from "../_internals/sanitize-cnpj/sanitize-cnpj"; import { type FormatCnpjOptions } from "../format-cnpj/format-cnpj"; @@ -9,6 +9,9 @@ export type ParseCnpjOptions = Pick; /** * Removes CNPJ formatting characters and returns a normalized value. * + * 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 CNPJ value to be parsed. * @param {ParseCnpjOptions} [options] - Optional parsing options. * @param {1|2} [options.version] - The CNPJ version to normalize. @@ -18,11 +21,22 @@ export type ParseCnpjOptions = Pick; * ```typescript * parseCnpj("11.222.333/0001-81"); // "11222333000181" * parseCnpj("12.ABC.345/01DE-35", { version: 2 }); // "12ABC34501DE35" + * parseCnpj(-11222333000181); // "" (not a non-negative safe integer) * ``` * + * The official character set of the alphanumeric CNPJ is the capital letters `A` to `Z` and the + * digits in the 12 base positions, and digits only in the 2 check digits (Receita Federal, CNPJ + * alfanumérico, and its DV manual, which reads a letter by its ASCII code). A lower case letter is + * not part of it: under `version: 2` this function accepts one only as input normalization, the + * way it accepts mask characters, and upper-cases the input before returning it, so + * `parseCnpj("12.abc.345/01de-35", { version: 2 })` returns `"12ABC34501DE35"`. + * * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/cnpj * @see Official: https://www.gov.br/receitafederal/pt-br/centrais-de-conteudo/publicacoes/documentos-tecnicos/cnpj/manual-dv-cnpj.pdf * @see Official: https://www.gov.br/receitafederal/pt-br/acesso-a-informacao/acoes-e-programas/programas-e-atividades/cnpj-alfanumerico + * @see Official: https://www.gov.br/receitafederal/pt-br/centrais-de-conteudo/publicacoes/perguntas-e-respostas/cnpj/cnpj-alfanumerico.pdf + * Receita Federal, CNPJ alfanumérico, perguntas e respostas: the base positions take the digits 0 + * to 9 and the capital letters A to Z, the 2 check digits stay numeric. */ export const parseCnpj = (value: string | number, options?: ParseCnpjOptions): string => - isNullish(value) ? "" : sanitizeCnpj(value, options?.version).slice(0, CNPJ_LENGTH); + isLookupCode(value) ? sanitizeCnpj(value, options?.version).slice(0, CNPJ_LENGTH) : ""; diff --git a/src/parse-cns/parse-cns.test.ts b/src/parse-cns/parse-cns.test.ts index 614788971..8df32f03f 100644 --- a/src/parse-cns/parse-cns.test.ts +++ b/src/parse-cns/parse-cns.test.ts @@ -48,6 +48,18 @@ describe("parseCns", () => { expectAlwaysReturnsType(parseCns, "string", anyValue); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(parseCns(-123_456_789_010_000)).toBe(""); + expect(parseCns(-1)).toBe(""); + expect(parseCns(1.5)).toBe(""); + expect(parseCns(2 ** 53)).toBe(""); + expect(parseCns(Number.MAX_VALUE)).toBe(""); + expect(parseCns(1e21)).toBe(""); + expect(parseCns(Number.NaN)).toBe(""); + expect(parseCns(Number.POSITIVE_INFINITY)).toBe(""); + expect(parseCns(Number.NEGATIVE_INFINITY)).toBe(""); + }); }); describe("parseCns types", () => { diff --git a/src/parse-cns/parse-cns.ts b/src/parse-cns/parse-cns.ts index 3700d8f47..3a85e5090 100644 --- a/src/parse-cns/parse-cns.ts +++ b/src/parse-cns/parse-cns.ts @@ -1,4 +1,4 @@ -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"; import { LENGTH } from "./constants"; @@ -9,14 +9,22 @@ import { LENGTH } from "./constants"; * capped at; a shorter value passes through as far as it goes, so the parser can strip the mask * off an input still being typed. Use `isValidCns` to check the number itself. * + * 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 CNS value to be parsed. * @returns {string} Up to 15 digits, or an empty string when there is no digit at all. * * @example * ```typescript * parseCns("123 4567 8901 0000"); // "123456789010000" + * parseCns(-123456789010000); // "" (not a non-negative safe integer) * ``` * + * @see Official: https://web.archive.org/web/20190106003442/http://cartaonet.datasus.gov.br/Rotina_JavaScript.doc + * DATASUS, "Rotina de validação de CNS e Número Provisório" (Wayback Machine copy of the official + * file, cartaonet.datasus.gov.br being gone), which checks 15 digits: the 16th digit printed on + * the card is "o número da via do cartão" and is not typed in. * @see Official: https://rni-docs.anvisa.gov.br/docs/regras_gerais/validacoes/validacaoCNS/ * ANVISA's validation routines, which fix the 15 digit length. The page sits behind a bot filter * and answers HTTP 403 to every non-browser client, so it has to be opened in a browser. @@ -24,4 +32,4 @@ import { LENGTH } from "./constants"; * e-SUS APS documentation of the same DATASUS algorithm, reachable without a browser. */ export const parseCns = (value: string | number): string => - isNullish(value) ? "" : sanitizeToDigits(value).slice(0, LENGTH); + isLookupCode(value) ? sanitizeToDigits(value).slice(0, LENGTH) : ""; diff --git a/src/parse-cpf/parse-cpf.test.ts b/src/parse-cpf/parse-cpf.test.ts index ec858e94f..40afa3734 100644 --- a/src/parse-cpf/parse-cpf.test.ts +++ b/src/parse-cpf/parse-cpf.test.ts @@ -38,6 +38,18 @@ describe("parseCpf", () => { expectAlwaysReturnsType(parseCpf, "string", anyValue); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(parseCpf(-12_345_678_909)).toBe(""); + expect(parseCpf(-1)).toBe(""); + expect(parseCpf(1.5)).toBe(""); + expect(parseCpf(2 ** 53)).toBe(""); + expect(parseCpf(Number.MAX_VALUE)).toBe(""); + expect(parseCpf(1e21)).toBe(""); + expect(parseCpf(Number.NaN)).toBe(""); + expect(parseCpf(Number.POSITIVE_INFINITY)).toBe(""); + expect(parseCpf(Number.NEGATIVE_INFINITY)).toBe(""); + }); }); describe("parseCpf types", () => { diff --git a/src/parse-cpf/parse-cpf.ts b/src/parse-cpf/parse-cpf.ts index 4955026fe..b8c75e2fd 100644 --- a/src/parse-cpf/parse-cpf.ts +++ b/src/parse-cpf/parse-cpf.ts @@ -1,20 +1,24 @@ import { CPF_LENGTH } from "../_internals/constants/cpf"; -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"; /** * Removes CPF formatting characters and returns only digits. * + * 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 CPF value to be parsed. * @returns {string} The CPF value without formatting. * * @example * ```typescript * parseCpf("123.456.789-09"); // "12345678909" + * parseCpf(-12345678909); // "" (not a non-negative safe integer) * ``` * * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/meu-cpf * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/cpf.py */ export const parseCpf = (value: string | number): string => - isNullish(value) ? "" : sanitizeToDigits(value).slice(0, CPF_LENGTH); + isLookupCode(value) ? sanitizeToDigits(value).slice(0, CPF_LENGTH) : ""; diff --git a/src/parse-iban/parse-iban.test.ts b/src/parse-iban/parse-iban.test.ts index add0fcf98..387c967d0 100644 --- a/src/parse-iban/parse-iban.test.ts +++ b/src/parse-iban/parse-iban.test.ts @@ -58,6 +58,17 @@ describe("parseIban", () => { expectAlwaysReturnsType(parseIban, "string", anyValue); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(parseIban(Number.NaN)).toBe(""); + expect(parseIban(-1)).toBe(""); + expect(parseIban(1.5)).toBe(""); + expect(parseIban(2 ** 53)).toBe(""); + expect(parseIban(Number.MAX_VALUE)).toBe(""); + expect(parseIban(1e21)).toBe(""); + expect(parseIban(Number.POSITIVE_INFINITY)).toBe(""); + expect(parseIban(Number.NEGATIVE_INFINITY)).toBe(""); + }); }); describe("parseIban types", () => { diff --git a/src/parse-iban/parse-iban.ts b/src/parse-iban/parse-iban.ts index 3043b0239..50d677d89 100644 --- a/src/parse-iban/parse-iban.ts +++ b/src/parse-iban/parse-iban.ts @@ -1,5 +1,5 @@ import { BR_IBAN_LENGTH } from "../_internals/constants/iban"; -import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { sanitizeToAlphanumeric } from "../_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric"; /** @@ -12,6 +12,9 @@ import { sanitizeToAlphanumeric } from "../_internals/sanitize-to-alphanumeric/s * shorter value passes through as far as it goes. Use `isValidIban` to check the check digits and * `getIbanInfo` to read the 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 IBAN to be parsed. * @returns {string} Up to 29 uppercase alphanumeric characters, or an empty string when there is * no letter or digit at all. @@ -19,12 +22,16 @@ import { sanitizeToAlphanumeric } from "../_internals/sanitize-to-alphanumeric/s * @example * ```typescript * parseIban("BR15 0000 0000 0000 1093 2840 814P 2"); // "BR1500000000000010932840814P2" + * parseIban(Number.NaN); // "" (not a non-negative safe integer) * ``` * + * @see Official: https://www.bcb.gov.br/estabilidadefinanceira/exibenormativo?tipo=Resolu%C3%A7%C3%A3o%20BCB&numero=585 + * Resolução BCB nº 585, de 24/08/2026 (DOU 25/08/2026), art. 2º, which revoked Circular BCB nº + * 3.625/2013 and keeps its layout, with an alphanumeric ISPB. * @see Official: https://www.bcb.gov.br/pre/normativos/circ/2013/pdf/circ_3625_v1_O.pdf - * Circular BCB nº 3.625/2013 + * Circular BCB nº 3.625/2013 (revoked), the original layout * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf * Diretrizes de Implementação do IBAN no Brasil, which fix the 29 character Brazilian length. */ export const parseIban = (value: string | number): string => - isNullish(value) ? "" : sanitizeToAlphanumeric(value).slice(0, BR_IBAN_LENGTH); + isLookupCode(value) ? sanitizeToAlphanumeric(value).slice(0, BR_IBAN_LENGTH) : ""; diff --git a/src/parse-legal-nature/parse-legal-nature.test.ts b/src/parse-legal-nature/parse-legal-nature.test.ts index dfbc0dd6f..d7d9c5959 100644 --- a/src/parse-legal-nature/parse-legal-nature.test.ts +++ b/src/parse-legal-nature/parse-legal-nature.test.ts @@ -34,6 +34,18 @@ describe("parseLegalNature", () => { expectAlwaysReturnsType(parseLegalNature, "string", anyValue); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(parseLegalNature(206.2)).toBe(""); + expect(parseLegalNature(-1)).toBe(""); + expect(parseLegalNature(1.5)).toBe(""); + expect(parseLegalNature(2 ** 53)).toBe(""); + expect(parseLegalNature(Number.MAX_VALUE)).toBe(""); + expect(parseLegalNature(1e21)).toBe(""); + expect(parseLegalNature(Number.NaN)).toBe(""); + expect(parseLegalNature(Number.POSITIVE_INFINITY)).toBe(""); + expect(parseLegalNature(Number.NEGATIVE_INFINITY)).toBe(""); + }); }); describe("parseLegalNature types", () => { diff --git a/src/parse-legal-nature/parse-legal-nature.ts b/src/parse-legal-nature/parse-legal-nature.ts index e76df3551..20c3caeb8 100644 --- a/src/parse-legal-nature/parse-legal-nature.ts +++ b/src/parse-legal-nature/parse-legal-nature.ts @@ -1,16 +1,20 @@ -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"; import { LENGTH } from "./constants"; /** * Removes legal nature (natureza jurídica) formatting characters and returns only digits. * + * 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 legal nature code to be parsed. * @returns {string} Up to 4 digits, or an empty string when there is no digit at all. * * @example * ```typescript * parseLegalNature("206-2"); // "2062" + * parseLegalNature(206.2); // "" (not a non-negative safe integer) * ``` * * The CONCLA table page sits behind a bot filter and answers HTTP 403 to every non-browser @@ -21,4 +25,4 @@ import { LENGTH } from "./constants"; * @see Official: https://concla.ibge.gov.br/images/concla/documentacao/CONCLA-TNJ2021-EstruturaDetalhada.pdf */ export const parseLegalNature = (value: string | number): string => - isNullish(value) ? "" : sanitizeToDigits(value).slice(0, LENGTH); + isLookupCode(value) ? sanitizeToDigits(value).slice(0, LENGTH) : ""; diff --git a/src/parse-ncm/parse-ncm.test.ts b/src/parse-ncm/parse-ncm.test.ts index 83e701d8b..a31e6d78b 100644 --- a/src/parse-ncm/parse-ncm.test.ts +++ b/src/parse-ncm/parse-ncm.test.ts @@ -52,6 +52,18 @@ describe("parseNcm", () => { expectAlwaysReturnsType(parseNcm, "string", anyValue); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(parseNcm(8_471_301.2)).toBe(""); + expect(parseNcm(-1)).toBe(""); + expect(parseNcm(1.5)).toBe(""); + expect(parseNcm(2 ** 53)).toBe(""); + expect(parseNcm(Number.MAX_VALUE)).toBe(""); + expect(parseNcm(1e21)).toBe(""); + expect(parseNcm(Number.NaN)).toBe(""); + expect(parseNcm(Number.POSITIVE_INFINITY)).toBe(""); + expect(parseNcm(Number.NEGATIVE_INFINITY)).toBe(""); + }); }); describe("parseNcm types", () => { diff --git a/src/parse-ncm/parse-ncm.ts b/src/parse-ncm/parse-ncm.ts index fa25d92bf..790a69931 100644 --- a/src/parse-ncm/parse-ncm.ts +++ b/src/parse-ncm/parse-ncm.ts @@ -1,4 +1,4 @@ -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"; import { LENGTH } from "./constants"; @@ -10,15 +10,19 @@ import { LENGTH } from "./constants"; * never left padded, so the leading zeros a code carries have to be written out. Use `isValidNcm`, * which does pad a bare numeric code, to check a code against the official table. * + * 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 NCM code to be parsed. * @returns {string} Up to 8 digits, or an empty string when there is no digit at all. * * @example * ```typescript * parseNcm("8471.30.12"); // "84713012" + * parseNcm(8471301.2); // "" (not a non-negative safe integer) * ``` * * @see Official: https://portalunico.siscomex.gov.br/classif/api/publico/nomenclatura/download/json */ export const parseNcm = (value: string | number): string => - isNullish(value) ? "" : sanitizeToDigits(value).slice(0, LENGTH); + isLookupCode(value) ? sanitizeToDigits(value).slice(0, LENGTH) : ""; diff --git a/src/parse-nfe-key/parse-nfe-key.test.ts b/src/parse-nfe-key/parse-nfe-key.test.ts index 815fe45fe..095136bd5 100644 --- a/src/parse-nfe-key/parse-nfe-key.test.ts +++ b/src/parse-nfe-key/parse-nfe-key.test.ts @@ -45,14 +45,42 @@ describe("parseNfeKey", () => { expect(parseNfeKey("3517 0458")).toBe("35170458"); }); + describe("with the alphanumeric CNPJ of NT Conjunta 2025.001", () => { + const ALPHANUMERIC = "35260712ABC34501DE35550010000001231102030403"; + + it("should keep the letters in positions 7 to 18 instead of dropping them", () => { + expect(parseNfeKey("3526 0712 ABC3 4501 DE35 5500 1000 0001 2311 0203 0403")).toBe( + ALPHANUMERIC, + ); + expect(parseNfeKey(`NFe${ALPHANUMERIC}`)).toBe(ALPHANUMERIC); + }); + + it("should upper case them", () => { + expect(parseNfeKey(ALPHANUMERIC.toLowerCase())).toBe(ALPHANUMERIC); + }); + + it("should drop a letter in positions 1 to 6 and from position 19 on", () => { + expect(parseNfeKey("35260A")).toBe("35260"); + expect(parseNfeKey("35260712ABC34501DEA5")).toBe("35260712ABC34501DE5"); + }); + + it("should undo formatNfeKey", () => { + expect(parseNfeKey(formatNfeKey(ALPHANUMERIC))).toBe(ALPHANUMERIC); + }); + }); + it("should return an empty string for null", () => { // @ts-expect-error not a string or number expect(parseNfeKey(null)).toBe(""); }); describe("properties", () => { - test("should return at most the digits of an access key", () => { - expectMatchesPattern(parseNfeKey, /^\d{0,44}$/, anyText); + test("should return at most the 44 characters of an access key, letters only in positions 7 to 18", () => { + expectMatchesPattern( + parseNfeKey, + /^(?:\d{0,6}|\d{6}[0-9A-Z]{1,12}|\d{6}[0-9A-Z]{12}\d{1,26})$/, + anyText, + ); }); test("should undo formatNfeKey", () => { @@ -67,6 +95,17 @@ describe("parseNfeKey", () => { expectAlwaysReturnsType(parseNfeKey, "string", anyValue); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(parseNfeKey(-1)).toBe(""); + expect(parseNfeKey(1.5)).toBe(""); + expect(parseNfeKey(2 ** 53)).toBe(""); + expect(parseNfeKey(Number.MAX_VALUE)).toBe(""); + expect(parseNfeKey(1e21)).toBe(""); + expect(parseNfeKey(Number.NaN)).toBe(""); + expect(parseNfeKey(Number.POSITIVE_INFINITY)).toBe(""); + expect(parseNfeKey(Number.NEGATIVE_INFINITY)).toBe(""); + }); }); describe("parseNfeKey types", () => { diff --git a/src/parse-nfe-key/parse-nfe-key.ts b/src/parse-nfe-key/parse-nfe-key.ts index 9f6395c11..882fe0dda 100644 --- a/src/parse-nfe-key/parse-nfe-key.ts +++ b/src/parse-nfe-key/parse-nfe-key.ts @@ -1,22 +1,29 @@ import { NFE_KEY_LENGTH, XML_ID_PREFIX_REGEX } from "../_internals/constants/nfe-key"; -import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; +import { sanitizeNfeKey } from "../_internals/sanitize-nfe-key/sanitize-nfe-key"; import { toStringSafe } from "../_internals/to-string-safe/to-string-safe"; /** * Removes the formatting of a DF-e (Documento Fiscal eletrônico) access key (chave de acesso) and - * returns only digits. + * returns only the characters of the key: digits, plus the letters of an alphanumeric CNPJ in + * positions 7 to 18, the only ones the key's schema type (`[0-9]{6}[0-9A-Z]{12}[0-9]{26}`) opens + * to letters. Those letters are kept upper cased, as `parseCnpj` does with `{ version: 2 }`; a + * letter anywhere else is dropped like any other character outside the key. * * The `NFe`, `CTe`, `MDFe`, `BPe`, `NF3e` and `NFCom` prefixes the `Id` attribute of the - * document's XML puts in front of the key are stripped before the digits are read, with any + * document's XML puts in front of the key are stripped before the key is read, with any * whitespace around them, the same way `isValidNfeKey` accepts them. The prefix has to go first * because `NF3e` carries a digit of its own that is not part of the key. * - * The result is capped at the 44 digits of an access key; a shorter value passes through as far - * as it goes, so the grouping of a key still being typed can be stripped with it. Use + * The result is capped at the 44 characters of an access key; a shorter value passes through as + * far as it goes, so the grouping of a key still being typed can be stripped with it. Use * `isValidNfeKey` to check the key and `getNfeKeyInfo` 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 44 digits, or an empty string when there is no digit at all. + * @returns {string} Up to 44 characters, or an empty string when there is no digit at all. * * @example * ```typescript @@ -25,15 +32,24 @@ import { toStringSafe } from "../_internals/to-string-safe/to-string-safe"; * * parseNfeKey("NFe35170458716523000119550010000000121000123458"); * // "35170458716523000119550010000000121000123458" + * + * parseNfeKey("3526 0712 abc3 4501 de35 5500 1000 0001 2311 0203 0403"); + * // "35260712ABC34501DE35550010000001231102030403" (alphanumeric CNPJ) + * parseNfeKey(-1); // "" (not a non-negative safe integer) * ``` * * @see Official: https://www.confaz.fazenda.gov.br/legislacao/arquivo-manuais/moc7-visao-geral.pdf - * Manual de Orientação do Contribuinte (MOC) NF-e, "chave de acesso", which fixes the 44 digits - * and the `Id` attribute the prefixes come from. + * Manual de Orientação do Contribuinte (MOC) NF-e, "chave de acesso", which fixes the 44 + * positions and the `Id` attribute the prefixes come from. + * @see Official: https://www.nfe.fazenda.gov.br/portal/exibirArquivo.aspx?conteudo=5ZkvIZt10mQ%3D + * Nota Técnica Conjunta 2025.001 (CNPJ alfanumérico nos DF-e) and the NF-e schema package + * PL_010 (NT 2026.004): `TChNFe`, ``. */ export const parseNfeKey = (value: string | number): string => { - // Stryker disable next-line StringLiteral: whatever replaces the prefix is stripped again by sanitizeToDigits unless it carries a digit, and the mutant's literal carries none. + if (!isLookupCode(value)) return ""; + + // Stryker disable next-line StringLiteral: whatever replaces the prefix is stripped again by sanitizeNfeKey unless it carries a digit, since a letter before position 7 is dropped, and the mutant's literal carries none. const body = toStringSafe(value).trim().replace(XML_ID_PREFIX_REGEX, ""); - return sanitizeToDigits(body).slice(0, NFE_KEY_LENGTH); + return sanitizeNfeKey(body).slice(0, NFE_KEY_LENGTH); }; diff --git a/src/parse-nfse-key/parse-nfse-key.ts b/src/parse-nfse-key/parse-nfse-key.ts index b87f4e717..840ad871c 100644 --- a/src/parse-nfse-key/parse-nfse-key.ts +++ b/src/parse-nfse-key/parse-nfse-key.ts @@ -48,8 +48,10 @@ const LEADING_LETTERS_REGEX = /^[A-Z]+/; * 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}". + * restricted-production bundle (v1.01-20260727, + * https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/producao-restrita/esquemas-nfse-rtc-v1-01-20260727.zip), + * `TSIdNFSe` is "NFS[0-9]{9}[0-9A-Z]{14}[0-9]{27}"; the production bundle of 2026-02-09 still has + * "NFS[0-9]{50}". * @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. */ diff --git a/src/parse-phone/parse-phone.test.ts b/src/parse-phone/parse-phone.test.ts index 680014eff..e3505a217 100644 --- a/src/parse-phone/parse-phone.test.ts +++ b/src/parse-phone/parse-phone.test.ts @@ -97,6 +97,18 @@ describe("parsePhone", () => { ); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(parsePhone(-11_987_654_321)).toBe(""); + expect(parsePhone(-1)).toBe(""); + expect(parsePhone(1.5)).toBe(""); + expect(parsePhone(2 ** 53)).toBe(""); + expect(parsePhone(Number.MAX_VALUE)).toBe(""); + expect(parsePhone(1e21)).toBe(""); + expect(parsePhone(Number.NaN)).toBe(""); + expect(parsePhone(Number.POSITIVE_INFINITY)).toBe(""); + expect(parsePhone(Number.NEGATIVE_INFINITY)).toBe(""); + }); }); describe("parsePhone types", () => { diff --git a/src/parse-phone/parse-phone.ts b/src/parse-phone/parse-phone.ts index 123d4ffa2..f250caf5b 100644 --- a/src/parse-phone/parse-phone.ts +++ b/src/parse-phone/parse-phone.ts @@ -1,5 +1,5 @@ import { PHONE_NATIONAL_MAX_LENGTH } from "../_internals/constants/phone"; -import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { normalizePhone } from "../_internals/normalize-phone/normalize-phone"; /** @@ -12,6 +12,9 @@ import { normalizePhone } from "../_internals/normalize-phone/normalize-phone"; * digits, so its `55` is read as the DDD. The rule is length-based, not sign-based, which * makes `"+5511987654321"`, `"005511987654321"` and `"5511987654321"` all parse alike. * + * 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 phone value to be parsed. * @returns {string} The phone value without formatting. * @@ -21,10 +24,11 @@ import { normalizePhone } from "../_internals/normalize-phone/normalize-phone"; * parsePhone("+55 (11) 98765-4321"); // "11987654321" * parsePhone("5511987654321"); // "11987654321" * parsePhone("55987654321"); // "55987654321" (area code 55, country code kept out of it) + * parsePhone(-11987654321); // "" (not a non-negative safe integer) * ``` * * @see Official: https://www.itu.int/rec/T-REC-E.164 * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 */ export const parsePhone = (value: string | number): string => - isNullish(value) ? "" : normalizePhone(value).slice(0, PHONE_NATIONAL_MAX_LENGTH); + isLookupCode(value) ? normalizePhone(value).slice(0, PHONE_NATIONAL_MAX_LENGTH) : ""; diff --git a/src/parse-pis/parse-pis.test.ts b/src/parse-pis/parse-pis.test.ts index b2f3bab69..493ffee64 100644 --- a/src/parse-pis/parse-pis.test.ts +++ b/src/parse-pis/parse-pis.test.ts @@ -38,6 +38,18 @@ describe("parsePis", () => { expectAlwaysReturnsType(parsePis, "string", anyValue); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(parsePis(100.1)).toBe(""); + expect(parsePis(-1)).toBe(""); + expect(parsePis(1.5)).toBe(""); + expect(parsePis(2 ** 53)).toBe(""); + expect(parsePis(Number.MAX_VALUE)).toBe(""); + expect(parsePis(1e21)).toBe(""); + expect(parsePis(Number.NaN)).toBe(""); + expect(parsePis(Number.POSITIVE_INFINITY)).toBe(""); + expect(parsePis(Number.NEGATIVE_INFINITY)).toBe(""); + }); }); describe("parsePis types", () => { diff --git a/src/parse-pis/parse-pis.ts b/src/parse-pis/parse-pis.ts index 38dc580a6..e6c6e601e 100644 --- a/src/parse-pis/parse-pis.ts +++ b/src/parse-pis/parse-pis.ts @@ -1,16 +1,20 @@ import { PIS_LENGTH } from "../_internals/constants/pis"; -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"; /** * Removes PIS formatting characters and returns only digits. * + * 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 PIS value to be parsed. * @returns {string} The PIS value without formatting. * * @example * ```typescript * parsePis("120.12345.67-8"); // "12012345678" + * parsePis(100.1); // "" (not a non-negative safe integer) * ``` * * @see Official: https://www.gov.br/inss/pt-br/direitos-e-deveres/inscricao-e-contribuicao/inscricao @@ -19,4 +23,4 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/pis.py */ export const parsePis = (value: string | number): string => - isNullish(value) ? "" : sanitizeToDigits(value).slice(0, PIS_LENGTH); + isLookupCode(value) ? sanitizeToDigits(value).slice(0, PIS_LENGTH) : ""; diff --git a/src/parse-processo-juridico/parse-processo-juridico.test.ts b/src/parse-processo-juridico/parse-processo-juridico.test.ts index 91aab9b36..c98abbd6d 100644 --- a/src/parse-processo-juridico/parse-processo-juridico.test.ts +++ b/src/parse-processo-juridico/parse-processo-juridico.test.ts @@ -42,6 +42,17 @@ describe("parseProcessoJuridico", () => { expectAlwaysReturnsType(parseProcessoJuridico, "string", anyValue); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(parseProcessoJuridico(-1)).toBe(""); + expect(parseProcessoJuridico(1.5)).toBe(""); + expect(parseProcessoJuridico(2 ** 53)).toBe(""); + expect(parseProcessoJuridico(Number.MAX_VALUE)).toBe(""); + expect(parseProcessoJuridico(1e21)).toBe(""); + expect(parseProcessoJuridico(Number.NaN)).toBe(""); + expect(parseProcessoJuridico(Number.POSITIVE_INFINITY)).toBe(""); + expect(parseProcessoJuridico(Number.NEGATIVE_INFINITY)).toBe(""); + }); }); describe("parseProcessoJuridico types", () => { diff --git a/src/parse-processo-juridico/parse-processo-juridico.ts b/src/parse-processo-juridico/parse-processo-juridico.ts index d50760e53..ad8dfb002 100644 --- a/src/parse-processo-juridico/parse-processo-juridico.ts +++ b/src/parse-processo-juridico/parse-processo-juridico.ts @@ -1,21 +1,26 @@ import { PROCESSO_JURIDICO_LENGTH } from "../_internals/constants/processo-juridico"; -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"; /** * Removes legal process formatting characters and returns only digits. * + * 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 legal process value to be parsed. * @returns {string} The legal process value without formatting. * * @example * ```typescript * parseProcessoJuridico("0002080-34.2026.5.15.0049"); // "00020803420265150049" + * parseProcessoJuridico(-1); // "" (not a non-negative safe integer) * ``` * - * Resolução CNJ nº 65/2008 defines this Número Único de Processo layout and its check digits. + * Resolução CNJ nº 65/2008 defines this Número Único de Processo layout and its check digits, + * whose algorithm is in its Anexo VIII. * * @see Official: https://atos.cnj.jus.br/atos/detalhar/119 */ export const parseProcessoJuridico = (value: string | number): string => - isNullish(value) ? "" : sanitizeToDigits(value).slice(0, PROCESSO_JURIDICO_LENGTH); + isLookupCode(value) ? sanitizeToDigits(value).slice(0, PROCESSO_JURIDICO_LENGTH) : ""; diff --git a/src/parse-voter-id/constants.ts b/src/parse-voter-id/constants.ts deleted file mode 100644 index 23ace8ccb..000000000 --- a/src/parse-voter-id/constants.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const LENGTH = 12; - -/** - * Total digit count for voter ids issued by São Paulo (01) and Minas Gerais (02), which may - * carry a 9-digit sequential number instead of the usual 8-digit one. - */ -export const EXTENDED_LENGTH = 13; diff --git a/src/parse-voter-id/parse-voter-id.test.ts b/src/parse-voter-id/parse-voter-id.test.ts index 638b41d99..076452f12 100644 --- a/src/parse-voter-id/parse-voter-id.test.ts +++ b/src/parse-voter-id/parse-voter-id.test.ts @@ -18,16 +18,16 @@ describe("parseVoterId", () => { expect(parseVoterId("12345678032499")).toBe("123456780324"); }); - it("should keep up to 13 digits for São Paulo (01) voter ids", () => { - expect(parseVoterId("1234 5678 8 01 91")).toBe("1234567880191"); + it("should keep 12 digits at most, São Paulo (01) and Minas Gerais (02) included", () => { + // 2.4.0 kept a 13th digit for UF 01/02: the resolution caps the id at 12 digits + expect(parseVoterId("1234 5678 8 01 91")).toBe("123456788019"); + expect(parseVoterId("1234567880299")).toBe("123456788029"); + expect(parseVoterId("123456788019199")).toBe("123456788019"); }); - it("should keep up to 13 digits for Minas Gerais (02) voter ids", () => { - expect(parseVoterId("1234567880299")).toBe("1234567880299"); - }); - - it("should ignore digits after the 13-digit voter id length for SP/MG", () => { - expect(parseVoterId("123456788019199")).toBe("1234567880191"); + it("should keep a voter id issued without its leading zeros as it is", () => { + expect(parseVoterId("12345 01 59")).toBe("123450159"); + expect(parseVoterId("0001 2345 01 59")).toBe("000123450159"); }); it("should return an empty string for null or undefined", () => { @@ -42,8 +42,8 @@ describe("parseVoterId", () => { }); describe("properties", () => { - test("should return at most the digits of the longest voter id", () => { - expectMatchesPattern(parseVoterId, /^\d{0,13}$/, anyText); + test("should return at most the 12 digits of a voter id", () => { + expectMatchesPattern(parseVoterId, /^\d{0,12}$/, anyText); }); test("should undo the formatting of a 12 digit voter id", () => { @@ -58,6 +58,18 @@ describe("parseVoterId", () => { expectAlwaysReturnsType(parseVoterId, "string", anyValue); }); }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(parseVoterId(-123_456_780_124)).toBe(""); + expect(parseVoterId(-1)).toBe(""); + expect(parseVoterId(1.5)).toBe(""); + expect(parseVoterId(2 ** 53)).toBe(""); + expect(parseVoterId(Number.MAX_VALUE)).toBe(""); + expect(parseVoterId(1e21)).toBe(""); + expect(parseVoterId(Number.NaN)).toBe(""); + expect(parseVoterId(Number.POSITIVE_INFINITY)).toBe(""); + expect(parseVoterId(Number.NEGATIVE_INFINITY)).toBe(""); + }); }); describe("parseVoterId types", () => { diff --git a/src/parse-voter-id/parse-voter-id.ts b/src/parse-voter-id/parse-voter-id.ts index a0d1834a8..2200754c5 100644 --- a/src/parse-voter-id/parse-voter-id.ts +++ b/src/parse-voter-id/parse-voter-id.ts @@ -1,13 +1,17 @@ -import { NINE_DIGIT_FEDERATIVE_UNION_CODES } from "../_internals/constants/voter-id"; +import { VOTER_ID_LENGTH } from "../_internals/constants/voter-id"; +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -import { EXTENDED_LENGTH, LENGTH } from "./constants"; /** * Removes voter id (título de eleitor) formatting characters and returns only digits. * - * Keeps up to 13 digits when the 10th and 11th digits identify São Paulo ("01") or Minas - * Gerais ("02"), since those states may issue voter ids with a 9-digit sequential number; - * otherwise keeps up to the usual 12 digits. + * Keeps up to the 12 digits a voter id may have and drops anything past them. A value with fewer + * digits is returned as it is, not padded: a voter id issued without the leading zeros of its + * sequential number keeps that shorter form, which `isValidVoterId` accepts; `formatVoterId` with + * `pad: true` restores the zeros. + * + * 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 voter id value to be parsed. * @returns {string} The voter id value without formatting. @@ -15,27 +19,23 @@ import { EXTENDED_LENGTH, LENGTH } from "./constants"; * @example * ```typescript * parseVoterId("1234 5678 01 24"); // "123456780124" - * parseVoterId("1234 5678 8 01 91"); // "1234567880191" + * parseVoterId("1234 5678 01 24 99"); // "123456780124" (digits past the 12th are dropped) + * parseVoterId("12345 01 59"); // "123450159" (no leading zeros are added) + * parseVoterId(-123456780124); // "" (not a non-negative safe integer) * ``` * - * The 13-digit São Paulo/Minas Gerais cap is brutils parity, not published by the TSE. A - * 14-or-more-digit input whose 10th and 11th digits are "01"/"02" is read as a 13-digit São Paulo - * or Minas Gerais id and capped at 13 digits, discarding anything past that. + * Resolução TSE nº 23.659/2021, art. 36, gives the voter id "até 12 algarismos", so no 13-digit + * form is kept. * * The TSE resolution page sits behind a bot filter and answers HTTP 403 to every non-browser * client, so it has to be opened in a browser. * * @see Official: https://www.tse.jus.br/legislacao/compilada/res/2021/resolucao-no-23-659-de-26-de-outubro-de-2021 + * Resolução TSE nº 23.659/2021, art. 36: "composto por até 12 algarismos". * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/voter_id.py */ export const parseVoterId = (value: string | number): string => { - const digits = sanitizeToDigits(value); - - const federativeUnion = digits.slice(9, 11); - - const maxLength = NINE_DIGIT_FEDERATIVE_UNION_CODES.includes(federativeUnion) - ? EXTENDED_LENGTH - : LENGTH; + if (!isLookupCode(value)) return ""; - return digits.slice(0, maxLength); + return sanitizeToDigits(value).slice(0, VOTER_ID_LENGTH); }; diff --git a/src/sub-business-days/sub-business-days.test.ts b/src/sub-business-days/sub-business-days.test.ts index ae6fb0133..45921053e 100644 --- a/src/sub-business-days/sub-business-days.test.ts +++ b/src/sub-business-days/sub-business-days.test.ts @@ -102,8 +102,8 @@ describe("subBusinessDays", () => { }); describe("includeOptional", () => { - it("should walk back over Carnaval 2024-02-13 by default (Wed 2024-02-14 - 1 -> Mon 2024-02-12)", () => { - expect(subBusinessDays(new Date(2024, 1, 14, 12), 1)).toEqual(new Date(2024, 1, 12, 12)); + it("should walk back over both Carnaval days, Tue 2024-02-13 and Mon 2024-02-12, by default (Wed 2024-02-14 - 1 -> Fri 2024-02-09)", () => { + expect(subBusinessDays(new Date(2024, 1, 14, 12), 1)).toEqual(new Date(2024, 1, 9, 12)); }); it("should stop on Carnaval 2024-02-13 when includeOptional is false", () => { diff --git a/src/sub-business-days/sub-business-days.ts b/src/sub-business-days/sub-business-days.ts index 4133f471d..4d994f01c 100644 --- a/src/sub-business-days/sub-business-days.ts +++ b/src/sub-business-days/sub-business-days.ts @@ -22,7 +22,7 @@ export type { BusinessDayOptions } from "../is-business-day/is-business-day"; * as `00:30` on a day whose clocks jump from `00:00` to `01:00`: the result is then the nearest * instant of that day, `01:30`, as in `addBusinessDays`. * - * `options.includeSaturday` defaults to `false`, the Monday to Friday banking count. Pass `true` + * `options.includeSaturday` defaults to `false`, a Monday to Friday count. Pass `true` * for the labour law count of Instrução Normativa MTP nº 2/2021, art. 14, I, which includes * Saturday and still excludes Sunday and holidays, so a holiday that falls on a Saturday is never * counted. See `isBusinessDay` for the law behind it and for what it does not cover: municipal diff --git a/vite.config.ts b/vite.config.ts index 1d423dc54..d6fb5c604 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,8 +7,8 @@ import { defineConfig } from "vite-plus"; import { type PackUserConfig } from "vite-plus/pack"; import { webdriverio } from "vite-plus/test/browser-webdriverio"; -const rootDir = import.meta.dirname; -const srcDir = resolve(rootDir, "src"); +const rootDirectory = import.meta.dirname; +const sourceDirectory = resolve(rootDirectory, "src"); type PackPlugin = Extract, unknown[]>[number]; @@ -64,14 +64,14 @@ const minifyUmdChunk = (): PackPlugin => ({ * IBGE dataset) instead of the whole root bundle, with no bespoke entry file to maintain per * util. */ -const utilEntryNames = readdirSync(srcDir, { withFileTypes: true }) +const utilityEntryNames = readdirSync(sourceDirectory, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && !entry.name.startsWith("_")) .map((entry) => entry.name) - .filter((name) => existsSync(resolve(srcDir, name, `${name}.ts`))) + .filter((name) => existsSync(resolve(sourceDirectory, name, `${name}.ts`))) .toSorted(); -const utilEntries = Object.fromEntries( - utilEntryNames.map((name) => [name, resolve(srcDir, name, `${name}.ts`)]), +const utilityEntries = Object.fromEntries( + utilityEntryNames.map((name) => [name, resolve(sourceDirectory, name, `${name}.ts`)]), ); /** @@ -562,7 +562,7 @@ export default defineConfig({ pack: [ { ...sharedPack, - entry: { "brazilian-utils": resolve(rootDir, "src/index.ts") }, + entry: { "brazilian-utils": resolve(rootDirectory, "src/index.ts") }, format: ["es", "umd"], globalName: "BrazilianUtils", plugins: [minifyUmdChunk(), emitCjsDtsTwin()], @@ -570,7 +570,7 @@ export default defineConfig({ { ...sharedPack, sourcemap: false, - entry: utilEntries, + entry: utilityEntries, format: ["es", "cjs"], }, ],