From 18977e396e3e3f11f032449d5d4ccc9ca8d5c360 Mon Sep 17 00:00:00 2001 From: azagatti Date: Tue, 23 Jun 2026 23:44:42 -0300 Subject: [PATCH] feat(cnpj): support the alphanumeric CNPJ format Validate the new Receita Federal alphanumeric CNPJ (12 uppercase alphanumeric positions + 2 numeric check digits) while keeping the legacy numeric format working. The check-digit calculation maps each base character via its ASCII code offset by 48 ('0' -> 0, 'A' -> 17 ... 'Z' -> 42), per the official spec, and the 2 check digits remain numeric (regex ^[A-Z\d]{12}\d{2}$). Only the mask characters (. / -) are stripped before validation. Implementation selected from PR #62 (the only one matching the official ASCII-48 offset), refined to match the codebase style (module-scoped regex/weights, a generateChecksum helper mirroring validatePIS) and covered with the full canonical test set from the Receita Federal reference implementation, including the discriminating 00000000000191 (valid) and zeroed/lowercase/letter-in-DV rejection cases. Co-authored-by: Jean Carlos Nesi Co-Authored-By: Claude Opus 4.8 (1M context) --- src/__tests__/usevalidationsbr.spec.ts | 6 +- src/__tests__/validateCNPJ.spec.ts | 51 +++++++++++++++-- src/validations/validateCNPJ.ts | 76 +++++++++++++++++--------- 3 files changed, 101 insertions(+), 32 deletions(-) diff --git a/src/__tests__/usevalidationsbr.spec.ts b/src/__tests__/usevalidationsbr.spec.ts index 2f2ba9a..6381423 100644 --- a/src/__tests__/usevalidationsbr.spec.ts +++ b/src/__tests__/usevalidationsbr.spec.ts @@ -13,14 +13,18 @@ describe('UseValidationBr Cep', () => { describe('UseValidationBr CNPJ', () => { it('should be able return true to valid CNPJ', () => { + expect.assertions(2); + expect(useValidationsBR('cnpj', '66.919.381/0001-15')).toBe(true); + expect(useValidationsBR('cnpj', '12.ABC.345/01DE-35')).toBe(true); }); it('should be able return false to invalid CNPJ', () => { - expect.assertions(2); + expect.assertions(3); expect(useValidationsBR('cnpj', '12.732.455/0001-25')).toBe(false); expect(useValidationsBR('cnpj', '66.919.381/0001-10')).toBe(false); + expect(useValidationsBR('cnpj', '12.ABC.345/01DE-34')).toBe(false); }); it('should be able return false to pass a empty string', () => { diff --git a/src/__tests__/validateCNPJ.spec.ts b/src/__tests__/validateCNPJ.spec.ts index 6b060ae..f10b343 100644 --- a/src/__tests__/validateCNPJ.spec.ts +++ b/src/__tests__/validateCNPJ.spec.ts @@ -2,23 +2,65 @@ import { describe, expect, it } from 'vitest'; import { validateCNPJ } from '../validations/validateCNPJ'; describe('validateCNPJ', () => { - it('should return true for valid CNPJs', () => { + it('should return true for valid numeric CNPJs', () => { expect(validateCNPJ('66.919.381/0001-15')).toBe(true); + expect(validateCNPJ('04.740.714/0001-97')).toBe(true); + expect(validateCNPJ('44.108.058/0001-29')).toBe(true); + expect(validateCNPJ('00000000000191')).toBe(true); }); - it('should return false for invalid CNPJs', () => { + it('should return true for valid alphanumeric CNPJs', () => { + // Official examples from the Receita Federal reference implementation. + expect(validateCNPJ('12.ABC.345/01DE-35')).toBe(true); + expect(validateCNPJ('12ABC34501DE35')).toBe(true); + expect(validateCNPJ('90.021.382/0001-22')).toBe(true); + expect(validateCNPJ('90.024.778/0001-23')).toBe(true); + expect(validateCNPJ('90.025.108/0001-21')).toBe(true); + expect(validateCNPJ('90.025.255/0001-00')).toBe(true); + expect(validateCNPJ('90.024.420/0001-09')).toBe(true); + expect(validateCNPJ('90.024.781/0001-47')).toBe(true); + expect(validateCNPJ('90.024.780/0001-00')).toBe(true); + expect(validateCNPJ('90.024.779/0001-78')).toBe(true); + expect(validateCNPJ('ABCDEFGHIJKL80')).toBe(true); + }); + + it('should return false for invalid check digits', () => { expect(validateCNPJ('48.514.588/0001-70')).toBe(false); expect(validateCNPJ('48514588000170')).toBe(false); expect(validateCNPJ('12.732.455/0001-25')).toBe(false); expect(validateCNPJ('66.919.381/0001-10')).toBe(false); - expect(validateCNPJ('123')).toBe(false); + expect(validateCNPJ('12.ABC.345/01DE-34')).toBe(false); + expect(validateCNPJ('00000000000192')).toBe(false); + expect(validateCNPJ('ABCDEFGHIJKL81')).toBe(false); + }); + + it('should return false for lowercase letters in the base', () => { + expect(validateCNPJ('12.ABc.345/01DE-35')).toBe(false); + expect(validateCNPJ('12abc34501de35')).toBe(false); + }); + + it('should return false for non-numeric check digits', () => { + expect(validateCNPJ('0000000000019L')).toBe(false); + expect(validateCNPJ('000000000001P1')).toBe(false); + }); + + it('should return false for disallowed characters', () => { + expect(validateCNPJ("'!@#$%&*-_=+^~")).toBe(false); + expect(validateCNPJ('$0123456789ABC')).toBe(false); + expect(validateCNPJ('0123456?789ABC')).toBe(false); + expect(validateCNPJ('0123456789ABC#')).toBe(false); }); it('should return false for empty or null strings', () => { expect(validateCNPJ('')).toBe(false); }); - it('should return false for CNPJs with all digits the same', () => { + it('should return false for the zeroed CNPJ', () => { + expect(validateCNPJ('00000000000000')).toBe(false); + expect(validateCNPJ('00.000.000/0000-00')).toBe(false); + }); + + it('should return false for CNPJs with all characters the same', () => { expect(validateCNPJ('11.111.111/1111-11')).toBe(false); expect(validateCNPJ('22222222222222')).toBe(false); }); @@ -28,5 +70,6 @@ describe('validateCNPJ', () => { expect(validateCNPJ('66.919.381/0001-155')).toBe(false); expect(validateCNPJ('6691938100011')).toBe(false); expect(validateCNPJ('669193810001155')).toBe(false); + expect(validateCNPJ('123')).toBe(false); }); }); diff --git a/src/validations/validateCNPJ.ts b/src/validations/validateCNPJ.ts index 6429cc1..8651238 100644 --- a/src/validations/validateCNPJ.ts +++ b/src/validations/validateCNPJ.ts @@ -1,40 +1,62 @@ import { isRepeated } from './utils'; +// First 12 positions accept the alphanumeric base (uppercase letters or digits); +// the 2 check digits are always numeric. See the Receita Federal spec: +// https://www.gov.br/receitafederal/pt-br/centrais-de-conteudo/publicacoes/documentos-tecnicos/cnpj +const cnpjRegex = /^[A-Z\d]{12}\d{2}$/; +const maskRegex = /[./-]/g; +const weights1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]; +const weights2 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]; + /** - * The function `validateCNPJ` validates a Brazilian CNPJ number. - * @param {string} value - The `value` parameter is a string that represents the CNPJ (Cadastro - * Nacional da Pessoa Jurídica) number to be validated. It can be formatted with or without punctuation, - * like '00.000.000/0000-00' or '00000000000000'. - * @returns The function `validateCNPJ` returns a boolean value. It returns `true` if the CNPJ is - * valid, and `false` otherwise. + * The function `charToNumber` converts a CNPJ base character into the value used by the + * check-digit calculation. Following the Receita Federal spec, the character's ASCII code + * is offset by 48, so digits map to themselves ('0' -> 0) and uppercase letters to their + * code ('A' -> 17, 'B' -> 18, ... 'Z' -> 42). + * @param {string} char - A single character from the CNPJ base. + * @returns The numeric value used in the weighted checksum. */ -export function validateCNPJ(value: string): boolean { - const cnpj = String(value).replace(/[^\d]+/g, ''); +function charToNumber(char: string): number { + return char.charCodeAt(0) - 48; +} - if (cnpj === '' || cnpj.length !== 14 || isRepeated(cnpj)) { - return false; - } +/** + * The function `generateChecksum` calculates a MOD 11 check digit for a given base and a + * weight array. Each character of the `base` is converted with `charToNumber` and multiplied + * by the corresponding weight at the same index. + * @param {string} base - The value for which the check digit is calculated. + * @param {number[]} weights - The weights applied to each character of the base. + * @returns The calculated check digit (0-9). + */ +function generateChecksum(base: string, weights: number[]): number { + const sum = base + .split('') + .reduce((acc, char, i) => acc + charToNumber(char) * weights[i], 0); + const remainder = sum % 11; - const calculateDigit = (numbers: string, weights: number[]): number => { - let sum = 0; - for (let i = 0; i < numbers.length; i++) { - sum += Number.parseInt(numbers.charAt(i), 10) * weights[i]; - } - const remainder = sum % 11; - return remainder < 2 ? 0 : 11 - remainder; - }; + return remainder < 2 ? 0 : 11 - remainder; +} - const weights1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]; - const first12 = cnpj.substring(0, 12); - const dv1 = calculateDigit(first12, weights1); +/** + * The function `validateCNPJ` validates a Brazilian CNPJ number, supporting both the + * traditional numeric format and the alphanumeric format (12 uppercase alphanumeric + * positions followed by 2 numeric check digits) introduced by Receita Federal. + * @param {string} value - The `value` parameter is a string that represents the CNPJ + * (Cadastro Nacional da Pessoa Jurídica) to be validated. It can be formatted with or without + * punctuation, like '00.000.000/0000-00', '00000000000000' or '12.ABC.345/01DE-35'. + * @returns The function `validateCNPJ` returns a boolean value. It returns `true` if the CNPJ + * is valid, and `false` otherwise. + */ +export function validateCNPJ(value: string): boolean { + const cnpj = String(value).replace(maskRegex, ''); - if (dv1 !== Number.parseInt(cnpj.charAt(12), 10)) { + if (!cnpjRegex.test(cnpj) || isRepeated(cnpj)) { return false; } - const weights2 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]; - const first13 = cnpj.substring(0, 13); - const dv2 = calculateDigit(first13, weights2); + const base = cnpj.substring(0, 12); + const dv1 = generateChecksum(base, weights1); + const dv2 = generateChecksum(base + dv1, weights2); - return dv2 === Number.parseInt(cnpj.charAt(13), 10); + return `${dv1}${dv2}` === cnpj.substring(12); }