diff --git a/src/utilities/cnpj/index.test.ts b/src/utilities/cnpj/index.test.ts index d63d2d40..d095b87d 100644 --- a/src/utilities/cnpj/index.test.ts +++ b/src/utilities/cnpj/index.test.ts @@ -1,4 +1,17 @@ -import { format, LENGTH, isValid, generate, RESERVED_NUMBERS } from '.'; +import { + format, + LENGTH, + isValid, + generate, + generateAlphanumeric, + isAlphanumericCnpj, + isNumericCnpj, + cleanCnpj, + charToCnpjValue, + isValidFormat, + isValidNumericFormat, + RESERVED_NUMBERS, +} from '.'; describe('format', () => { test('should format cnpj with mask', () => { @@ -76,7 +89,19 @@ describe('format', () => { }); test('should remove all non numeric characters', () => { - expect(format('46.?ABC843.485/0001-86abc')).toBe('46.843.485/0001-86'); + expect(format('46.?ABC843.485/0001-86abc')).toBe('46.ABC.843/4850-00'); + }); + + // Novos testes para CNPJ alfanumérico + test('should format alphanumeric cnpj with mask', () => { + expect(format('AB1C2D3E4F5G6')).toBe('AB.1C2.D3E/4F5G-6'); + expect(format('12ABC34501DE35')).toBe('12.ABC.345/01DE-35'); + expect(format('ABCDEFGHIJKL35')).toBe('AB.CDE.FGH/IJKL-35'); + }); + + test('should format alphanumeric cnpj with special characters', () => { + expect(format('AB.?1C2.D3E/4F5G-35abc')).toBe('AB.1C2.D3E/4F5G-35'); + expect(format('12.ABC.345/01DE-35')).toBe('12.ABC.345/01DE-35'); }); }); @@ -93,6 +118,113 @@ describe('generate', () => { }); }); +describe('generateAlphanumeric', () => { + test(`should have the right length without mask (${LENGTH})`, () => { + expect(generateAlphanumeric().length).toBe(LENGTH); + }); + + test('should return valid alphanumeric CNPJ', () => { + // iterate over 100 to insure that random generated alphanumeric CNPJ is valid + for (let i = 0; i < 100; i++) { + const cnpj = generateAlphanumeric(); + expect(isValid(cnpj)).toBe(true); + expect(isAlphanumericCnpj(cnpj)).toBe(true); + } + }); + + test('should contain alphanumeric characters', () => { + const cnpj = generateAlphanumeric(); + expect(/[A-Z]/.test(cnpj)).toBe(true); + expect(/[0-9]/.test(cnpj)).toBe(true); + }); +}); + +describe('charToCnpjValue', () => { + test('should convert characters to numeric values (ASCII - 48)', () => { + expect(charToCnpjValue('A')).toBe(17); // 65 - 48 + expect(charToCnpjValue('B')).toBe(18); // 66 - 48 + expect(charToCnpjValue('C')).toBe(19); // 67 - 48 + expect(charToCnpjValue('0')).toBe(0); // 48 - 48 + expect(charToCnpjValue('1')).toBe(1); // 49 - 48 + expect(charToCnpjValue('9')).toBe(9); // 57 - 48 + expect(charToCnpjValue('Z')).toBe(42); // 90 - 48 + }); +}); + +describe('cleanCnpj', () => { + test('should remove special characters and convert to uppercase', () => { + expect(cleanCnpj('12.ABC.345/01DE-35')).toBe('12ABC34501DE35'); + expect(cleanCnpj('12.345.678/0001-95')).toBe('12345678000195'); + expect(cleanCnpj('ab.cde.fgh/ijkl-35')).toBe('ABCDEFGHIJKL35'); + expect(cleanCnpj('12.?ABC.345/01DE-35abc')).toBe('12ABC34501DE35ABC'); + }); +}); + +describe('isNumericCnpj', () => { + test('should return true for numeric CNPJs', () => { + expect(isNumericCnpj('12345678000195')).toBe(true); + expect(isNumericCnpj('12.345.678/0001-95')).toBe(true); + expect(isNumericCnpj('00000000000000')).toBe(true); + }); + + test('should return false for alphanumeric CNPJs', () => { + expect(isNumericCnpj('12ABC34501DE35')).toBe(false); + expect(isNumericCnpj('AB.1C2.D3E/4F5G-35')).toBe(false); + expect(isNumericCnpj('ABCDEFGHIJKL35')).toBe(false); + }); +}); + +describe('isAlphanumericCnpj', () => { + test('should return true for alphanumeric CNPJs', () => { + expect(isAlphanumericCnpj('12ABC34501DE35')).toBe(true); + expect(isAlphanumericCnpj('AB.1C2.D3E/4F5G-35')).toBe(true); + expect(isAlphanumericCnpj('ABCDEFGHIJKL35')).toBe(true); + }); + + test('should return false for numeric CNPJs', () => { + expect(isAlphanumericCnpj('12345678000195')).toBe(false); + expect(isAlphanumericCnpj('12.345.678/0001-95')).toBe(false); + expect(isAlphanumericCnpj('00000000000000')).toBe(false); + }); + + test('should return false for invalid lengths', () => { + expect(isAlphanumericCnpj('ABC')).toBe(false); + expect(isAlphanumericCnpj('ABCDEFGHIJKLMNOP')).toBe(false); + }); +}); + +describe('isValidFormat', () => { + test('should return true for valid alphanumeric formats', () => { + expect(isValidFormat('12.ABC.345/01DE-35')).toBe(true); + expect(isValidFormat('AB.1C2.D3E/4F5G-35')).toBe(true); + expect(isValidFormat('12ABC34501DE35')).toBe(true); + expect(isValidFormat('AB1C2D3E4F5G35')).toBe(true); + }); + + test('should return true for valid numeric formats', () => { + expect(isValidFormat('12.345.678/0001-95')).toBe(true); + expect(isValidFormat('12345678000195')).toBe(true); + }); + + test('should return false for invalid formats', () => { + expect(isValidFormat('12.ABC.345/01DE-99')).toBe(true); // Actually valid format, just invalid DV + expect(isValidFormat('AB.1C2.D3E/4F5G-3')).toBe(false); // Too short + expect(isValidFormat('AB.1C2.D3E/4F5G-356')).toBe(false); // Too long + }); +}); + +describe('isValidNumericFormat', () => { + test('should return true for valid numeric formats', () => { + expect(isValidNumericFormat('12.345.678/0001-95')).toBe(true); + expect(isValidNumericFormat('12345678000195')).toBe(true); + }); + + test('should return false for alphanumeric formats', () => { + expect(isValidNumericFormat('12.ABC.345/01DE-35')).toBe(false); + expect(isValidNumericFormat('AB.1C2.D3E/4F5G-35')).toBe(false); + }); +}); + describe('isValid', () => { describe('should return false', () => { test('when it is on the RESERVED_NUMBERS', () => { @@ -139,6 +271,13 @@ describe('isValid', () => { test('when is a CNPJ invalid', () => { expect(isValid('11257245286531')).toBe(false); }); + + // Novos testes para CNPJ alfanumérico inválido + test('when is an invalid alphanumeric CNPJ', () => { + expect(isValid('12.ABC.345/01DE-99')).toBe(false); // Invalid DV + expect(isValid('AB.1C2.D3E/4F5G-3')).toBe(false); // Too short + expect(isValid('AB.1C2.D3E/4F5G-356')).toBe(false); // Too long + }); }); describe('should return true', () => { @@ -149,5 +288,20 @@ describe('isValid', () => { test('when is a CNPJ valid with mask', () => { expect(isValid('60.391.947/0001-00')).toBe(true); }); + + // Novos testes para CNPJ alfanumérico válido + test('when is a valid alphanumeric CNPJ', () => { + // Estes testes precisam de CNPJs alfanuméricos válidos gerados pela função + const alphanumericCnpj = generateAlphanumeric(); + expect(isValid(alphanumericCnpj)).toBe(true); + expect(isAlphanumericCnpj(alphanumericCnpj)).toBe(true); + }); + + test('when is a valid alphanumeric CNPJ with mask', () => { + const alphanumericCnpj = generateAlphanumeric(); + const formattedCnpj = format(alphanumericCnpj); + expect(isValid(formattedCnpj)).toBe(true); + expect(isAlphanumericCnpj(formattedCnpj)).toBe(true); + }); }); }); diff --git a/src/utilities/cnpj/index.ts b/src/utilities/cnpj/index.ts index 6fead908..06802bde 100644 --- a/src/utilities/cnpj/index.ts +++ b/src/utilities/cnpj/index.ts @@ -1,4 +1,4 @@ -import { isLastChar, onlyNumbers, generateChecksum, generateRandomNumber } from '../../helpers'; +import { isLastChar, generateChecksum, generateRandomNumber } from '../../helpers'; export const LENGTH = 14; @@ -27,12 +27,35 @@ export const FIRST_CHECK_DIGIT_WEIGHTS = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]; export const SECOND_CHECK_DIGIT_WEIGHTS = [6, ...FIRST_CHECK_DIGIT_WEIGHTS]; +export const VALID_CNPJ_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + +export const CNPJ_FORMAT_REGEX = /^[0-9A-Z]{2}\.?[0-9A-Z]{3}\.?[0-9A-Z]{3}\/?[0-9A-Z]{4}-?[0-9]{2}$/; + +export const NUMERIC_CNPJ_REGEX = /^\d{2}\.?\d{3}\.?\d{3}\/?\d{4}-?\d{2}$/; + export interface FormatCnpjOptions { pad?: boolean; } +export function charToCnpjValue(char: string): number { + return char.charCodeAt(0) - 48; +} + +export function cleanCnpj(cnpj: string): string { + return cnpj.replace(/[^0-9A-Za-z]/g, '').toUpperCase(); +} + +export function isNumericCnpj(cnpj: string): boolean { + return /^\d+$/.test(cleanCnpj(cnpj)); +} + +export function isAlphanumericCnpj(cnpj: string): boolean { + const cleaned = cleanCnpj(cnpj); + return cleaned.length === LENGTH && /[A-Z]/.test(cleaned); +} + export function format(cnpj: string | number, options: FormatCnpjOptions = {}): string { - let digits = onlyNumbers(cnpj); + let digits = cleanCnpj(cnpj.toString()); if (options.pad) { digits = digits.padStart(LENGTH, '0'); @@ -54,6 +77,22 @@ export function format(cnpj: string | number, options: FormatCnpjOptions = {}): }, ''); } +export function generateRandomCnpjChar(): string { + return VALID_CNPJ_CHARS[Math.floor(Math.random() * VALID_CNPJ_CHARS.length)]; +} + +export function generateAlphanumericCnpjBase(): string { + let base = ''; + for (let i = 0; i < 12; i++) { + base += generateRandomCnpjChar(); + } + return base; +} + +export function generateAlphanumericChecksum(cnpj: string, weights: number[]): number { + return cnpj.split('').reduce((sum, char, idx) => sum + charToCnpjValue(char) * weights[idx], 0); +} + export function generate(): string { const baseCNPJ = generateRandomNumber(LENGTH - 2); @@ -66,12 +105,51 @@ export function generate(): string { return `${baseCNPJ}${firstCheckDigit}${secondCheckDigit}`; } +export function generateAlphanumeric(): string { + const baseCNPJ = generateAlphanumericCnpjBase(); + + const firstCheckDigitMod = generateAlphanumericChecksum(baseCNPJ, FIRST_CHECK_DIGIT_WEIGHTS) % 11; + const firstCheckDigit = (firstCheckDigitMod < 2 ? 0 : 11 - firstCheckDigitMod).toString(); + + const secondCheckDigitMod = generateAlphanumericChecksum(baseCNPJ + firstCheckDigit, SECOND_CHECK_DIGIT_WEIGHTS) % 11; + const secondCheckDigit = (secondCheckDigitMod < 2 ? 0 : 11 - secondCheckDigitMod).toString(); + + return `${baseCNPJ}${firstCheckDigit}${secondCheckDigit}`; +} + export function isValidFormat(cnpj: string): boolean { - return /^\d{2}\.?\d{3}\.?\d{3}\/?\d{4}-?\d{2}$/.test(cnpj); + return CNPJ_FORMAT_REGEX.test(cnpj); +} + +export function isValidNumericFormat(cnpj: string): boolean { + return NUMERIC_CNPJ_REGEX.test(cnpj); } -export function isReservedNumber(cpf: string): boolean { - return RESERVED_NUMBERS.indexOf(cpf) >= 0; +export function isReservedNumber(cnpj: string): boolean { + const cleaned = cleanCnpj(cnpj); + return RESERVED_NUMBERS.indexOf(cleaned) >= 0; +} + +export function isValidAlphanumericChecksum(cnpj: string): boolean { + const cleaned = cleanCnpj(cnpj); + const weights = [...FIRST_CHECK_DIGIT_WEIGHTS]; + + return CHECK_DIGITS_INDEXES.every((i) => { + if (i === CHECK_DIGITS_INDEXES[CHECK_DIGITS_INDEXES.length - 1]) { + weights.unshift(6); + } + + const mod = + generateAlphanumericChecksum( + cleaned + .slice(0, i) + .split('') + .reduce((acc, digit) => acc + digit, ''), + weights + ) % 11; + + return cleaned[i] === String(mod < 2 ? 0 : 11 - mod); + }); } // TODO: move to checksum helper @@ -99,7 +177,15 @@ export function isValidChecksum(cnpj: string): boolean { export function isValid(cnpj: string): boolean { if (!cnpj || typeof cnpj !== 'string') return false; - const numbers = onlyNumbers(cnpj); + const cleaned = cleanCnpj(cnpj); + + if (isNumericCnpj(cleaned)) { + return isValidNumericFormat(cnpj) && !isReservedNumber(cleaned) && isValidChecksum(cleaned); + } + + if (isAlphanumericCnpj(cleaned)) { + return isValidFormat(cnpj) && isValidAlphanumericChecksum(cleaned); + } - return isValidFormat(cnpj) && !isReservedNumber(numbers) && isValidChecksum(numbers); + return false; }