Skip to content

Commit 84dcac7

Browse files
committed
feat: add support for alphanumeric CNPJ
1 parent a511609 commit 84dcac7

2 files changed

Lines changed: 249 additions & 9 deletions

File tree

src/utilities/cnpj/index.test.ts

Lines changed: 156 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,17 @@
1-
import { format, LENGTH, isValid, generate, RESERVED_NUMBERS } from '.';
1+
import {
2+
format,
3+
LENGTH,
4+
isValid,
5+
generate,
6+
generateAlphanumeric,
7+
isAlphanumericCnpj,
8+
isNumericCnpj,
9+
cleanCnpj,
10+
charToCnpjValue,
11+
isValidFormat,
12+
isValidNumericFormat,
13+
RESERVED_NUMBERS,
14+
} from '.';
215

316
describe('format', () => {
417
test('should format cnpj with mask', () => {
@@ -76,7 +89,19 @@ describe('format', () => {
7689
});
7790

7891
test('should remove all non numeric characters', () => {
79-
expect(format('46.?ABC843.485/0001-86abc')).toBe('46.843.485/0001-86');
92+
expect(format('46.?ABC843.485/0001-86abc')).toBe('46.ABC.843/4850-00');
93+
});
94+
95+
// Novos testes para CNPJ alfanumérico
96+
test('should format alphanumeric cnpj with mask', () => {
97+
expect(format('AB1C2D3E4F5G6')).toBe('AB.1C2.D3E/4F5G-6');
98+
expect(format('12ABC34501DE35')).toBe('12.ABC.345/01DE-35');
99+
expect(format('ABCDEFGHIJKL35')).toBe('AB.CDE.FGH/IJKL-35');
100+
});
101+
102+
test('should format alphanumeric cnpj with special characters', () => {
103+
expect(format('AB.?1C2.D3E/4F5G-35abc')).toBe('AB.1C2.D3E/4F5G-35');
104+
expect(format('12.ABC.345/01DE-35')).toBe('12.ABC.345/01DE-35');
80105
});
81106
});
82107

@@ -93,6 +118,113 @@ describe('generate', () => {
93118
});
94119
});
95120

121+
describe('generateAlphanumeric', () => {
122+
test(`should have the right length without mask (${LENGTH})`, () => {
123+
expect(generateAlphanumeric().length).toBe(LENGTH);
124+
});
125+
126+
test('should return valid alphanumeric CNPJ', () => {
127+
// iterate over 100 to insure that random generated alphanumeric CNPJ is valid
128+
for (let i = 0; i < 100; i++) {
129+
const cnpj = generateAlphanumeric();
130+
expect(isValid(cnpj)).toBe(true);
131+
expect(isAlphanumericCnpj(cnpj)).toBe(true);
132+
}
133+
});
134+
135+
test('should contain alphanumeric characters', () => {
136+
const cnpj = generateAlphanumeric();
137+
expect(/[A-Z]/.test(cnpj)).toBe(true);
138+
expect(/[0-9]/.test(cnpj)).toBe(true);
139+
});
140+
});
141+
142+
describe('charToCnpjValue', () => {
143+
test('should convert characters to numeric values (ASCII - 48)', () => {
144+
expect(charToCnpjValue('A')).toBe(17); // 65 - 48
145+
expect(charToCnpjValue('B')).toBe(18); // 66 - 48
146+
expect(charToCnpjValue('C')).toBe(19); // 67 - 48
147+
expect(charToCnpjValue('0')).toBe(0); // 48 - 48
148+
expect(charToCnpjValue('1')).toBe(1); // 49 - 48
149+
expect(charToCnpjValue('9')).toBe(9); // 57 - 48
150+
expect(charToCnpjValue('Z')).toBe(42); // 90 - 48
151+
});
152+
});
153+
154+
describe('cleanCnpj', () => {
155+
test('should remove special characters and convert to uppercase', () => {
156+
expect(cleanCnpj('12.ABC.345/01DE-35')).toBe('12ABC34501DE35');
157+
expect(cleanCnpj('12.345.678/0001-95')).toBe('12345678000195');
158+
expect(cleanCnpj('ab.cde.fgh/ijkl-35')).toBe('ABCDEFGHIJKL35');
159+
expect(cleanCnpj('12.?ABC.345/01DE-35abc')).toBe('12ABC34501DE35ABC');
160+
});
161+
});
162+
163+
describe('isNumericCnpj', () => {
164+
test('should return true for numeric CNPJs', () => {
165+
expect(isNumericCnpj('12345678000195')).toBe(true);
166+
expect(isNumericCnpj('12.345.678/0001-95')).toBe(true);
167+
expect(isNumericCnpj('00000000000000')).toBe(true);
168+
});
169+
170+
test('should return false for alphanumeric CNPJs', () => {
171+
expect(isNumericCnpj('12ABC34501DE35')).toBe(false);
172+
expect(isNumericCnpj('AB.1C2.D3E/4F5G-35')).toBe(false);
173+
expect(isNumericCnpj('ABCDEFGHIJKL35')).toBe(false);
174+
});
175+
});
176+
177+
describe('isAlphanumericCnpj', () => {
178+
test('should return true for alphanumeric CNPJs', () => {
179+
expect(isAlphanumericCnpj('12ABC34501DE35')).toBe(true);
180+
expect(isAlphanumericCnpj('AB.1C2.D3E/4F5G-35')).toBe(true);
181+
expect(isAlphanumericCnpj('ABCDEFGHIJKL35')).toBe(true);
182+
});
183+
184+
test('should return false for numeric CNPJs', () => {
185+
expect(isAlphanumericCnpj('12345678000195')).toBe(false);
186+
expect(isAlphanumericCnpj('12.345.678/0001-95')).toBe(false);
187+
expect(isAlphanumericCnpj('00000000000000')).toBe(false);
188+
});
189+
190+
test('should return false for invalid lengths', () => {
191+
expect(isAlphanumericCnpj('ABC')).toBe(false);
192+
expect(isAlphanumericCnpj('ABCDEFGHIJKLMNOP')).toBe(false);
193+
});
194+
});
195+
196+
describe('isValidFormat', () => {
197+
test('should return true for valid alphanumeric formats', () => {
198+
expect(isValidFormat('12.ABC.345/01DE-35')).toBe(true);
199+
expect(isValidFormat('AB.1C2.D3E/4F5G-35')).toBe(true);
200+
expect(isValidFormat('12ABC34501DE35')).toBe(true);
201+
expect(isValidFormat('AB1C2D3E4F5G35')).toBe(true);
202+
});
203+
204+
test('should return true for valid numeric formats', () => {
205+
expect(isValidFormat('12.345.678/0001-95')).toBe(true);
206+
expect(isValidFormat('12345678000195')).toBe(true);
207+
});
208+
209+
test('should return false for invalid formats', () => {
210+
expect(isValidFormat('12.ABC.345/01DE-99')).toBe(true); // Actually valid format, just invalid DV
211+
expect(isValidFormat('AB.1C2.D3E/4F5G-3')).toBe(false); // Too short
212+
expect(isValidFormat('AB.1C2.D3E/4F5G-356')).toBe(false); // Too long
213+
});
214+
});
215+
216+
describe('isValidNumericFormat', () => {
217+
test('should return true for valid numeric formats', () => {
218+
expect(isValidNumericFormat('12.345.678/0001-95')).toBe(true);
219+
expect(isValidNumericFormat('12345678000195')).toBe(true);
220+
});
221+
222+
test('should return false for alphanumeric formats', () => {
223+
expect(isValidNumericFormat('12.ABC.345/01DE-35')).toBe(false);
224+
expect(isValidNumericFormat('AB.1C2.D3E/4F5G-35')).toBe(false);
225+
});
226+
});
227+
96228
describe('isValid', () => {
97229
describe('should return false', () => {
98230
test('when it is on the RESERVED_NUMBERS', () => {
@@ -139,6 +271,13 @@ describe('isValid', () => {
139271
test('when is a CNPJ invalid', () => {
140272
expect(isValid('11257245286531')).toBe(false);
141273
});
274+
275+
// Novos testes para CNPJ alfanumérico inválido
276+
test('when is an invalid alphanumeric CNPJ', () => {
277+
expect(isValid('12.ABC.345/01DE-99')).toBe(false); // Invalid DV
278+
expect(isValid('AB.1C2.D3E/4F5G-3')).toBe(false); // Too short
279+
expect(isValid('AB.1C2.D3E/4F5G-356')).toBe(false); // Too long
280+
});
142281
});
143282

144283
describe('should return true', () => {
@@ -149,5 +288,20 @@ describe('isValid', () => {
149288
test('when is a CNPJ valid with mask', () => {
150289
expect(isValid('60.391.947/0001-00')).toBe(true);
151290
});
291+
292+
// Novos testes para CNPJ alfanumérico válido
293+
test('when is a valid alphanumeric CNPJ', () => {
294+
// Estes testes precisam de CNPJs alfanuméricos válidos gerados pela função
295+
const alphanumericCnpj = generateAlphanumeric();
296+
expect(isValid(alphanumericCnpj)).toBe(true);
297+
expect(isAlphanumericCnpj(alphanumericCnpj)).toBe(true);
298+
});
299+
300+
test('when is a valid alphanumeric CNPJ with mask', () => {
301+
const alphanumericCnpj = generateAlphanumeric();
302+
const formattedCnpj = format(alphanumericCnpj);
303+
expect(isValid(formattedCnpj)).toBe(true);
304+
expect(isAlphanumericCnpj(formattedCnpj)).toBe(true);
305+
});
152306
});
153307
});

src/utilities/cnpj/index.ts

Lines changed: 93 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isLastChar, onlyNumbers, generateChecksum, generateRandomNumber } from '../../helpers';
1+
import { isLastChar, generateChecksum, generateRandomNumber } from '../../helpers';
22

33
export const LENGTH = 14;
44

@@ -27,12 +27,35 @@ export const FIRST_CHECK_DIGIT_WEIGHTS = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
2727

2828
export const SECOND_CHECK_DIGIT_WEIGHTS = [6, ...FIRST_CHECK_DIGIT_WEIGHTS];
2929

30+
export const VALID_CNPJ_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
31+
32+
export const CNPJ_FORMAT_REGEX = /^[0-9A-Z]{2}\.?[0-9A-Z]{3}\.?[0-9A-Z]{3}\/?[0-9A-Z]{4}-?[0-9]{2}$/;
33+
34+
export const NUMERIC_CNPJ_REGEX = /^\d{2}\.?\d{3}\.?\d{3}\/?\d{4}-?\d{2}$/;
35+
3036
export interface FormatCnpjOptions {
3137
pad?: boolean;
3238
}
3339

40+
export function charToCnpjValue(char: string): number {
41+
return char.charCodeAt(0) - 48;
42+
}
43+
44+
export function cleanCnpj(cnpj: string): string {
45+
return cnpj.replace(/[^0-9A-Za-z]/g, '').toUpperCase();
46+
}
47+
48+
export function isNumericCnpj(cnpj: string): boolean {
49+
return /^\d+$/.test(cleanCnpj(cnpj));
50+
}
51+
52+
export function isAlphanumericCnpj(cnpj: string): boolean {
53+
const cleaned = cleanCnpj(cnpj);
54+
return cleaned.length === LENGTH && /[A-Z]/.test(cleaned);
55+
}
56+
3457
export function format(cnpj: string | number, options: FormatCnpjOptions = {}): string {
35-
let digits = onlyNumbers(cnpj);
58+
let digits = cleanCnpj(cnpj.toString());
3659

3760
if (options.pad) {
3861
digits = digits.padStart(LENGTH, '0');
@@ -54,6 +77,22 @@ export function format(cnpj: string | number, options: FormatCnpjOptions = {}):
5477
}, '');
5578
}
5679

80+
export function generateRandomCnpjChar(): string {
81+
return VALID_CNPJ_CHARS[Math.floor(Math.random() * VALID_CNPJ_CHARS.length)];
82+
}
83+
84+
export function generateAlphanumericCnpjBase(): string {
85+
let base = '';
86+
for (let i = 0; i < 12; i++) {
87+
base += generateRandomCnpjChar();
88+
}
89+
return base;
90+
}
91+
92+
export function generateAlphanumericChecksum(cnpj: string, weights: number[]): number {
93+
return cnpj.split('').reduce((sum, char, idx) => sum + charToCnpjValue(char) * weights[idx], 0);
94+
}
95+
5796
export function generate(): string {
5897
const baseCNPJ = generateRandomNumber(LENGTH - 2);
5998

@@ -66,12 +105,51 @@ export function generate(): string {
66105
return `${baseCNPJ}${firstCheckDigit}${secondCheckDigit}`;
67106
}
68107

108+
export function generateAlphanumeric(): string {
109+
const baseCNPJ = generateAlphanumericCnpjBase();
110+
111+
const firstCheckDigitMod = generateAlphanumericChecksum(baseCNPJ, FIRST_CHECK_DIGIT_WEIGHTS) % 11;
112+
const firstCheckDigit = (firstCheckDigitMod < 2 ? 0 : 11 - firstCheckDigitMod).toString();
113+
114+
const secondCheckDigitMod = generateAlphanumericChecksum(baseCNPJ + firstCheckDigit, SECOND_CHECK_DIGIT_WEIGHTS) % 11;
115+
const secondCheckDigit = (secondCheckDigitMod < 2 ? 0 : 11 - secondCheckDigitMod).toString();
116+
117+
return `${baseCNPJ}${firstCheckDigit}${secondCheckDigit}`;
118+
}
119+
69120
export function isValidFormat(cnpj: string): boolean {
70-
return /^\d{2}\.?\d{3}\.?\d{3}\/?\d{4}-?\d{2}$/.test(cnpj);
121+
return CNPJ_FORMAT_REGEX.test(cnpj);
122+
}
123+
124+
export function isValidNumericFormat(cnpj: string): boolean {
125+
return NUMERIC_CNPJ_REGEX.test(cnpj);
71126
}
72127

73-
export function isReservedNumber(cpf: string): boolean {
74-
return RESERVED_NUMBERS.indexOf(cpf) >= 0;
128+
export function isReservedNumber(cnpj: string): boolean {
129+
const cleaned = cleanCnpj(cnpj);
130+
return RESERVED_NUMBERS.indexOf(cleaned) >= 0;
131+
}
132+
133+
export function isValidAlphanumericChecksum(cnpj: string): boolean {
134+
const cleaned = cleanCnpj(cnpj);
135+
const weights = [...FIRST_CHECK_DIGIT_WEIGHTS];
136+
137+
return CHECK_DIGITS_INDEXES.every((i) => {
138+
if (i === CHECK_DIGITS_INDEXES[CHECK_DIGITS_INDEXES.length - 1]) {
139+
weights.unshift(6);
140+
}
141+
142+
const mod =
143+
generateAlphanumericChecksum(
144+
cleaned
145+
.slice(0, i)
146+
.split('')
147+
.reduce((acc, digit) => acc + digit, ''),
148+
weights
149+
) % 11;
150+
151+
return cleaned[i] === String(mod < 2 ? 0 : 11 - mod);
152+
});
75153
}
76154

77155
// TODO: move to checksum helper
@@ -99,7 +177,15 @@ export function isValidChecksum(cnpj: string): boolean {
99177
export function isValid(cnpj: string): boolean {
100178
if (!cnpj || typeof cnpj !== 'string') return false;
101179

102-
const numbers = onlyNumbers(cnpj);
180+
const cleaned = cleanCnpj(cnpj);
181+
182+
if (isNumericCnpj(cleaned)) {
183+
return isValidNumericFormat(cnpj) && !isReservedNumber(cleaned) && isValidChecksum(cleaned);
184+
}
185+
186+
if (isAlphanumericCnpj(cleaned)) {
187+
return isValidFormat(cnpj) && isValidAlphanumericChecksum(cleaned);
188+
}
103189

104-
return isValidFormat(cnpj) && !isReservedNumber(numbers) && isValidChecksum(numbers);
190+
return false;
105191
}

0 commit comments

Comments
 (0)