From 6bde780845b5b0f067e83bc5a4e1e2e0fa0f41a0 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:32:08 -0300 Subject: [PATCH 1/7] =?UTF-8?q?feat(boleto):=20support=20boleto=20de=20arr?= =?UTF-8?q?ecada=C3=A7=C3=A3o=20and=20a=20referenceDate=20option?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add arrecadação (convênio/tributos) support: 48-digit linha digitável / 44-digit barcode starting with 8, in isValidBoleto, parseBoleto, formatBoleto, getBoletoInfo and generateBoleto({ type: "arrecadacao" }). getBoletoInfo gains a referenceDate option and new BoletoInfo fields (type, segment, value, hasEffectiveValue). fix: account for FEBRABAN'"'"'s 22/02/2025 fator de vencimento cycle reset. --- src/_internals/constants/arrecadacao.ts | 29 +++++ src/_internals/constants/boleto.ts | 2 + .../parse-arrecadacao.test.ts | 88 +++++++++++++ .../parse-arrecadacao/parse-arrecadacao.ts | 108 ++++++++++++++++ src/format-boleto/constants.ts | 4 +- src/format-boleto/format-boleto.test.ts | 41 +++++- src/format-boleto/format-boleto.ts | 40 +++++- src/generate-boleto/constants.ts | 1 - src/generate-boleto/generate-boleto.test.ts | 48 ++++++- src/generate-boleto/generate-boleto.ts | 68 +++++++--- src/get-boleto-info/constants.ts | 13 ++ src/get-boleto-info/get-boleto-info.test.ts | 120 ++++++++++++++++++ src/get-boleto-info/get-boleto-info.ts | 110 ++++++++++++++-- src/is-valid-boleto/constants.ts | 1 - src/is-valid-boleto/is-valid-boleto.test.ts | 61 ++++++++- src/is-valid-boleto/is-valid-boleto.ts | 18 ++- src/parse-boleto/parse-boleto.test.ts | 18 +++ src/parse-boleto/parse-boleto.ts | 30 ++++- 18 files changed, 744 insertions(+), 56 deletions(-) create mode 100644 src/_internals/constants/arrecadacao.ts create mode 100644 src/_internals/constants/boleto.ts create mode 100644 src/_internals/parse-arrecadacao/parse-arrecadacao.test.ts create mode 100644 src/_internals/parse-arrecadacao/parse-arrecadacao.ts delete mode 100644 src/generate-boleto/constants.ts create mode 100644 src/get-boleto-info/constants.ts diff --git a/src/_internals/constants/arrecadacao.ts b/src/_internals/constants/arrecadacao.ts new file mode 100644 index 00000000..b845941b --- /dev/null +++ b/src/_internals/constants/arrecadacao.ts @@ -0,0 +1,29 @@ +/** + * Layout of the "arrecadação" (convênio/tributos) bank slip. + * + * Barcode (44 positions, §04): 01 product ("8"), 02 segment, 03 value identifier + * (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. + * + * @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 + */ + +export const ARRECADACAO_PRODUCT = "8"; + +export const ARRECADACAO_BARCODE_LENGTH = 44; + +export const ARRECADACAO_LINE_LENGTH = 48; + +export const ARRECADACAO_BLOCK_LENGTH = 11; + +export const ARRECADACAO_BLOCKS = 4; + +export const ARRECADACAO_CHECK_DIGIT_POSITION = 3; + +export const ARRECADACAO_VALUE_START = 4; + +export const ARRECADACAO_VALUE_END = 15; + +export const ARRECADACAO_SEGMENTS = ["1", "2", "3", "4", "5", "6", "7"] as const; diff --git a/src/_internals/constants/boleto.ts b/src/_internals/constants/boleto.ts new file mode 100644 index 00000000..8b0cffd0 --- /dev/null +++ b/src/_internals/constants/boleto.ts @@ -0,0 +1,2 @@ +/** Digits of a "cobrança bancária" linha digitável. */ +export const BOLETO_LENGTH = 47; diff --git a/src/_internals/parse-arrecadacao/parse-arrecadacao.test.ts b/src/_internals/parse-arrecadacao/parse-arrecadacao.test.ts new file mode 100644 index 00000000..58d85ffc --- /dev/null +++ b/src/_internals/parse-arrecadacao/parse-arrecadacao.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "../test/runtime"; +import { parseArrecadacao } from "./parse-arrecadacao"; + +const FEBRABAN_BARCODE = "84610000000246100291100054603390069589506108"; +const FEBRABAN_LINE = "846100000005246100291102005460339004695895061080"; + +const MOD11_BARCODE = "85890000460524601791606075930508683148300001"; +const MOD11_LINE = "858900004609524601791605607593050865831483000010"; + +describe("parseArrecadacao", () => { + describe("should return null", () => { + test("when it does not start with 8", () => { + expect(parseArrecadacao("10491443385511900000200000000141325230000093423")).toBeNull(); + }); + + test("when the length is neither 44 nor 48", () => { + expect(parseArrecadacao("8")).toBeNull(); + expect(parseArrecadacao(FEBRABAN_BARCODE.slice(0, 43))).toBeNull(); + expect(parseArrecadacao(`${FEBRABAN_LINE}0`)).toBeNull(); + }); + + test("when the segment is 0 or 8, which the FEBRABAN layout does not define (DV geral recomputed for each)", () => { + expect(parseArrecadacao("80650000000246100291100054603390069589506108")).toBeNull(); + expect(parseArrecadacao("88670000000246100291100054603390069589506108")).toBeNull(); + }); + + test("when the value identifier is not 6, 7, 8 or 9", () => { + expect(parseArrecadacao(`845${FEBRABAN_BARCODE.slice(3)}`)).toBeNull(); + }); + + test("when the general check digit is wrong", () => { + const broken = `${FEBRABAN_BARCODE.slice(0, 3)}9${FEBRABAN_BARCODE.slice(4)}`; + expect(parseArrecadacao(broken)).toBeNull(); + }); + + test("when a block check digit is wrong", () => { + const broken = `${FEBRABAN_LINE.slice(0, 11)}9${FEBRABAN_LINE.slice(12)}`; + expect(parseArrecadacao(broken)).toBeNull(); + }); + }); + + describe("should parse a modulo 10 bank slip (FEBRABAN 'Layout Padrão de Arrecadação' §11 example, position 3 = '6')", () => { + test("from the barcode", () => { + expect(parseArrecadacao(FEBRABAN_BARCODE)).toStrictEqual({ + barcode: FEBRABAN_BARCODE, + segment: 4, + hasEffectiveValue: true, + amount: 2461, + }); + }); + + test("from the linha digitável", () => { + expect(parseArrecadacao(FEBRABAN_LINE)?.barcode).toBe(FEBRABAN_BARCODE); + }); + }); + + describe("should parse a modulo 11 bank slip (mcrvaz/boleto-brasileiro-validator fixture, position 3 = '8')", () => { + test("from the barcode", () => { + expect(parseArrecadacao(MOD11_BARCODE)).toStrictEqual({ + barcode: MOD11_BARCODE, + segment: 5, + hasEffectiveValue: true, + amount: 4605246, + }); + }); + + test("from the linha digitável", () => { + expect(parseArrecadacao(MOD11_LINE)?.barcode).toBe(MOD11_BARCODE); + }); + }); + + describe("should flag reference values", () => { + test("when the identifier is 7 (modulo 10), reusing the FEBRABAN example barcode with position 3 changed to '7' and the DV geral recalculated with the same modulo 10", () => { + expect(parseArrecadacao("84790000000246100291100054603390069589506108")).toStrictEqual({ + barcode: "84790000000246100291100054603390069589506108", + segment: 4, + hasEffectiveValue: false, + amount: 2461, + }); + }); + + test("when the identifier is 9 (modulo 11)", () => { + expect( + parseArrecadacao("859700004603524601791605607593050865831483000010")?.hasEffectiveValue, + ).toBe(false); + }); + }); +}); diff --git a/src/_internals/parse-arrecadacao/parse-arrecadacao.ts b/src/_internals/parse-arrecadacao/parse-arrecadacao.ts new file mode 100644 index 00000000..c12dc072 --- /dev/null +++ b/src/_internals/parse-arrecadacao/parse-arrecadacao.ts @@ -0,0 +1,108 @@ +import { + ARRECADACAO_BARCODE_LENGTH, + ARRECADACAO_BLOCK_LENGTH, + ARRECADACAO_BLOCKS, + ARRECADACAO_CHECK_DIGIT_POSITION, + ARRECADACAO_LINE_LENGTH, + ARRECADACAO_PRODUCT, + ARRECADACAO_VALUE_END, + ARRECADACAO_VALUE_START, +} from "../constants/arrecadacao"; +import { mod10 } from "../mod10/mod10"; +import { mod11 } from "../mod11/mod11"; + +export type ArrecadacaoInfo = { + /** The 44 digit barcode rebuilt from the linha digitável. */ + barcode: string; + /** Arrecadação segment (1 to 7, or 9 for the bank's own use), the kind of biller the bank slip belongs to. */ + segment: number; + /** Whether the amount is an effective value (`true`) or a reference quantity (`false`). */ + hasEffectiveValue: boolean; + /** Amount in cents. */ + amount: number; +}; + +const getCheckDigitAlgorithm = (barcode: string): ((value: string) => number) | null => { + const identifier = barcode[2]; + + if (identifier === "6" || identifier === "7") return mod10; + if (identifier === "8" || identifier === "9") { + return (value: string) => mod11(value, { variant: "arrecadacao" }); + } + + return null; +}; + +const lineToBarcode = (line: string): string => { + let barcode = ""; + + for (let block = 0; block < ARRECADACAO_BLOCKS; block++) { + const start = block * (ARRECADACAO_BLOCK_LENGTH + 1); + barcode += line.slice(start, start + ARRECADACAO_BLOCK_LENGTH); + } + + return barcode; +}; + +/** + * Validates an arrecadação bank slip and returns its parsed information. + * + * Accepts both the 44 digit barcode and the 48 digit linha digitável. The block + * check digits are only verified for the linha digitável, since they are not represented in + * the barcode, where §03-E states they are not represented. + * + * @param {string} digits - Sanitized digits of the bank slip. + * @returns {ArrecadacaoInfo | null} The parsed information, or null when it is not a valid arrecadação bank slip. + * + * @example + * ```typescript + * parseArrecadacao("846100000005246100291102005460339004695895061080"); + * // { barcode: "8461...", segment: 4, hasEffectiveValue: true, amount: 2461 } + * ``` + */ +export const parseArrecadacao = (digits: string): ArrecadacaoInfo | null => { + if (!digits.startsWith(ARRECADACAO_PRODUCT)) return null; + + const isLine = digits.length === ARRECADACAO_LINE_LENGTH; + + if (!isLine && digits.length !== ARRECADACAO_BARCODE_LENGTH) return null; + + const barcode = isLine ? lineToBarcode(digits) : digits; + + const checkDigit = getCheckDigitAlgorithm(barcode); + + if (!checkDigit) return null; + + const withoutCheckDigit = + barcode.slice(0, ARRECADACAO_CHECK_DIGIT_POSITION) + + barcode.slice(ARRECADACAO_CHECK_DIGIT_POSITION + 1); + + if (checkDigit(withoutCheckDigit) !== barcode.charCodeAt(ARRECADACAO_CHECK_DIGIT_POSITION) - 48) + return null; + + if (isLine) { + for (let block = 0; block < ARRECADACAO_BLOCKS; block++) { + const value = barcode.slice( + block * ARRECADACAO_BLOCK_LENGTH, + (block + 1) * ARRECADACAO_BLOCK_LENGTH, + ); + const expected = + digits.charCodeAt(block * (ARRECADACAO_BLOCK_LENGTH + 1) + ARRECADACAO_BLOCK_LENGTH) - 48; + + if (checkDigit(value) !== expected) return null; + } + } + + const segment = barcode.charCodeAt(1) - 48; + + if (segment === 0 || segment === 8) return null; + + const identifier = barcode[2]; + + return { + barcode, + segment, + hasEffectiveValue: identifier === "6" || identifier === "8", + amount: Number(barcode.slice(ARRECADACAO_VALUE_START, ARRECADACAO_VALUE_END)), + }; +}; diff --git a/src/format-boleto/constants.ts b/src/format-boleto/constants.ts index 1429912e..b108409e 100644 --- a/src/format-boleto/constants.ts +++ b/src/format-boleto/constants.ts @@ -1 +1,3 @@ -export const LENGTH = 47; +export const BANCARIO_PATTERN = "00000.00000 00000.000000 00000.000000 0 00000000000000"; + +export const ARRECADACAO_PATTERN = "00000000000-0 00000000000-0 00000000000-0 00000000000-0"; diff --git a/src/format-boleto/format-boleto.test.ts b/src/format-boleto/format-boleto.test.ts index e05c3a31..911faf8f 100644 --- a/src/format-boleto/format-boleto.test.ts +++ b/src/format-boleto/format-boleto.test.ts @@ -1,5 +1,6 @@ +import { ARRECADACAO_LINE_LENGTH } from "../_internals/constants/arrecadacao"; +import { BOLETO_LENGTH } from "../_internals/constants/boleto"; import { describe, expect, test } from "../_internals/test/runtime"; -import { LENGTH } from "./constants"; import { formatBoleto } from "./format-boleto"; describe("formatBoleto", () => { @@ -92,7 +93,7 @@ describe("formatBoleto", () => { ); }); - test(`shouldn't add digits after the boleto length (${LENGTH})`, () => { + test(`shouldn't add digits after the boleto length (${BOLETO_LENGTH})`, () => { expect(formatBoleto("10491443385511900000200000000141325230000093423123123123")).toBe( "10491.44338 55119.000002 00000.000141 3 25230000093423", ); @@ -116,4 +117,40 @@ describe("formatBoleto", () => { expect(formatBoleto("")).toBe(""); expect(formatBoleto("")).toBe(""); }); + + describe("arrecadação", () => { + test("should use the arrecadação mask when it starts with 8", () => { + expect(formatBoleto("846100000005246100291102005460339004695895061080")).toBe( + "84610000000-5 24610029110-2 00546033900-4 69589506108-0", + ); + expect(formatBoleto("858900004609524601791605607593050865831483000010")).toBe( + "85890000460-9 52460179160-5 60759305086-5 83148300001-0", + ); + }); + + test("should keep the cobrança bancária mask for partial values", () => { + expect(formatBoleto("8")).toBe("8"); + expect(formatBoleto("84610000000")).toBe("84610.00000 0"); + expect(formatBoleto("846100000005")).toBe("84610.00000 05"); + expect(formatBoleto("8461000000052")).toBe("84610.00000 052"); + }); + + test("should keep the cobrança bancária mask for the 44 digit barcode", () => { + expect(formatBoleto("84610000000246100291100054603390069589506108")).toBe( + "84610.00000 02461.002911 00054.603390 0 69589506108", + ); + }); + + test(`shouldn't apply the arrecadação mask past its length (${ARRECADACAO_LINE_LENGTH})`, () => { + expect(formatBoleto("846100000005246100291102005460339004695895061080123")).toBe( + "84610.00000 05246.100291 10200.546033 9 00469589506108", + ); + }); + + test("should remove all non numeric characters", () => { + expect(formatBoleto("84610000000-5 24610029110-2 00546033900-4 69589506108-0")).toBe( + "84610000000-5 24610029110-2 00546033900-4 69589506108-0", + ); + }); + }); }); diff --git a/src/format-boleto/format-boleto.ts b/src/format-boleto/format-boleto.ts index 97107af5..9a5158a0 100644 --- a/src/format-boleto/format-boleto.ts +++ b/src/format-boleto/format-boleto.ts @@ -1,19 +1,47 @@ +import { ARRECADACAO_LINE_LENGTH, ARRECADACAO_PRODUCT } from "../_internals/constants/arrecadacao"; import { type FormatParams, format } from "../_internals/format/format"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { ARRECADACAO_PATTERN, BANCARIO_PATTERN } from "./constants"; export type FormatBoletoOptions = Pick; /** * Formats a given value as a Brazilian boleto. * + * A 48 digit linha digitável starting with `8` is an "arrecadação" (convênio/tributos) slip + * and uses the FEBRABAN arrecadação mask (four blocks of 11 digits, each one followed by its + * own check digit) instead of the "cobrança bancária" mask. The 44 digit arrecadação + * *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. + * * @param {string|number} value - The value to be formatted, either as a string or a number. - * @param {Object} options - Optional formatting options. + * @param {FormatBoletoOptions} [options] - Optional formatting options. * @param {boolean} options.pad - Whether to pad the value with leading zeros. - * @returns {string} The formatted boleto string in the pattern "00000000000000000000000000000000000000000000000". + * @returns {string} The formatted boleto string in the pattern "00000.00000 00000.000000 00000.000000 0 00000000000000" or, for arrecadação, "00000000000-0 00000000000-0 00000000000-0 00000000000-0". + * + * @example + * ```typescript + * formatBoleto("10491443385511900000200000000141325230000093423"); + * // "10491.44338 55119.000002 00000.000141 3 25230000093423" + * + * formatBoleto("826300000011098800100702024102024000000205104519"); + * // "82630000001-1 09880010070-2 02410202400-0 00020510451-9" + * ``` + * + * @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 */ -export const formatBoleto = (value: string | number, options?: FormatBoletoOptions): string => - format({ +export const formatBoleto = (value: string | number, options?: FormatBoletoOptions): string => { + if (isNullish(value)) return ""; + + const digits = sanitizeToDigits(value); + + const isArrecadacaoLine = + digits.length === ARRECADACAO_LINE_LENGTH && digits.startsWith(ARRECADACAO_PRODUCT); + + return format({ pad: options?.pad, - value: sanitizeToDigits(value), - pattern: "00000.00000 00000.000000 00000.000000 0 00000000000000", + value: digits, + pattern: isArrecadacaoLine ? ARRECADACAO_PATTERN : BANCARIO_PATTERN, }); +}; diff --git a/src/generate-boleto/constants.ts b/src/generate-boleto/constants.ts deleted file mode 100644 index 1429912e..00000000 --- a/src/generate-boleto/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const LENGTH = 47; diff --git a/src/generate-boleto/generate-boleto.test.ts b/src/generate-boleto/generate-boleto.test.ts index 111c5f97..95b09a28 100644 --- a/src/generate-boleto/generate-boleto.test.ts +++ b/src/generate-boleto/generate-boleto.test.ts @@ -1,34 +1,35 @@ +import { ARRECADACAO_LINE_LENGTH } from "../_internals/constants/arrecadacao"; +import { BOLETO_LENGTH } from "../_internals/constants/boleto"; import { describe, expect, test } from "../_internals/test/runtime"; +import { formatBoleto } from "../format-boleto/format-boleto"; +import { getBoletoInfo } from "../get-boleto-info/get-boleto-info"; import { isValidBoleto } from "../is-valid-boleto/is-valid-boleto"; -import { LENGTH } from "./constants"; +import { parseBoleto } from "../parse-boleto/parse-boleto"; import { generateBoleto } from "./generate-boleto"; describe("generateBoleto", () => { test("should generate a valid boleto", () => { const boleto = generateBoleto(); - expect(boleto).toHaveLength(LENGTH); + expect(boleto).toHaveLength(BOLETO_LENGTH); expect(/^\d+$/.test(boleto)).toBe(true); expect(isValidBoleto(boleto)).toBe(true); }); - test("should generate different boleto on multiple calls", () => { + test("should generate different boletos on multiple calls, retrying with extra draws on the astronomically unlikely case all three collide", () => { const boleto1 = generateBoleto(); const boleto2 = generateBoleto(); const boleto3 = generateBoleto(); - // Very unlikely but possible to generate same boleto const allSame = boleto1 === boleto2 && boleto2 === boleto3; if (allSame) { - // If all same, generate more to verify randomness const set = new Set([boleto1, generateBoleto(), generateBoleto()]); expect(set.size).toBeGreaterThan(1); } }); - test("should generate valid boletos that pass validation with formatting", () => { + test("should generate valid boletos that pass validation once formatted", () => { for (let i = 0; i < 10; i++) { const boleto = generateBoleto(); - // Add formatting and validate const formatted = `${boleto.slice(0, 9)} ${boleto.slice(9, 20)} ${boleto.slice(20, 31)} ${boleto.slice(31, 32)} ${boleto.slice(32)}`; expect(isValidBoleto(formatted)).toBe(true); } @@ -44,4 +45,37 @@ describe("generateBoleto", () => { } expect(boletos.size).toBe(100); }); + + describe("arrecadação", () => { + test("should generate a valid arrecadação bank slip", () => { + const boleto = generateBoleto({ type: "arrecadacao" }); + + expect(boleto).toHaveLength(ARRECADACAO_LINE_LENGTH); + expect(/^8\d+$/.test(boleto)).toBe(true); + expect(isValidBoleto(boleto)).toBe(true); + }); + + test("should generate multiple valid arrecadação bank slips", () => { + for (let i = 0; i < 100; i++) { + const boleto = generateBoleto({ type: "arrecadacao" }); + + expect(isValidBoleto(boleto)).toBe(true); + expect(getBoletoInfo(boleto)?.type).toBe("arrecadacao"); + } + }); + + test("should generate a bank slip that survives format and parse", () => { + for (let i = 0; i < 10; i++) { + const boleto = generateBoleto({ type: "arrecadacao" }); + + expect(parseBoleto(formatBoleto(boleto))).toBe(boleto); + expect(isValidBoleto(formatBoleto(boleto))).toBe(true); + } + }); + + test("should keep generating bancário bank slips by default", () => { + expect(generateBoleto({})).toHaveLength(BOLETO_LENGTH); + expect(generateBoleto({ type: "bancario" })).toHaveLength(BOLETO_LENGTH); + }); + }); }); diff --git a/src/generate-boleto/generate-boleto.ts b/src/generate-boleto/generate-boleto.ts index b6045dc6..5420cd1e 100644 --- a/src/generate-boleto/generate-boleto.ts +++ b/src/generate-boleto/generate-boleto.ts @@ -1,18 +1,14 @@ +import { ARRECADACAO_PRODUCT, ARRECADACAO_SEGMENTS } from "../_internals/constants/arrecadacao"; import { generateRandomNumber } from "../_internals/generate-random-number/generate-random-number"; import { mod10 } from "../_internals/mod10/mod10"; import { mod11 } from "../_internals/mod11/mod11"; -/** - * Generates a valid random Brazilian bank slip (boleto) number. - * - * @returns {string} A valid 47-digit boleto string without formatting. - * - * @example - * ```typescript - * generateBoleto(); // "00190000090114971860168524522114675860000102656" - * ``` - */ -export const generateBoleto = (): string => { +export type GenerateBoletoOptions = { + /** Which kind of bank slip to generate (default: `"bancario"`). */ + type?: "bancario" | "arrecadacao"; +}; + +const generateBancario = (): string => { const line = Array.from({ length: 47 }); const p1Base = generateRandomNumber(9); @@ -31,16 +27,54 @@ export const generateBoleto = (): string => { for (let i = 0; i < 15; i++) line[32 + i] = lastDigits[i]; const boletoWithoutCheck = - line.slice(0, 4).join("") + // [0-3] - line.slice(33, 47).join("") + // [33-46] (skip [32]) - line.slice(4, 9).join("") + // [4-8] - line.slice(10, 20).join("") + // [10-19] - line.slice(21, 31).join(""); // [21-30] + line.slice(0, 4).join("") + + line.slice(33, 47).join("") + + line.slice(4, 9).join("") + + line.slice(10, 20).join("") + + line.slice(21, 31).join(""); const mainCheck = mod11(boletoWithoutCheck); - // Update only position 32 in digitable line (corresponds to boleto[4]) line[32] = mainCheck.toString(); return line.join(""); }; + +const generateArrecadacao = (): string => { + const segment = ARRECADACAO_SEGMENTS[Math.floor(Math.random() * ARRECADACAO_SEGMENTS.length)]; + const useMod11 = Math.random() < 0.5; + const checkDigit = useMod11 ? (value: string) => mod11(value, { variant: "arrecadacao" }) : mod10; + + const body = generateRandomNumber(40); + const head = `${ARRECADACAO_PRODUCT}${segment}${useMod11 ? "8" : "6"}`; + const barcode = head + checkDigit(head + body) + body; + + let line = ""; + + for (let block = 0; block < 4; block++) { + const value = barcode.slice(block * 11, block * 11 + 11); + line += value + checkDigit(value); + } + + return line; +}; + +/** + * Generates a valid random Brazilian bank slip (boleto) number. + * + * Uses `Math.random()` internally, so it is not cryptographically secure, do not use for security purposes. + * + * @param {GenerateBoletoOptions} [options] - Optional options. + * @param {string} options.type - `"bancario"` (default) or `"arrecadacao"`. + * @returns {string} A valid 47-digit boleto string without formatting, or a 48-digit one for arrecadação. + * + * @example + * ```typescript + * generateBoleto(); // "00190000090114971860168524522114675860000102656" + * generateBoleto({ type: "arrecadacao" }); // "846100000005246100291102005460339004695895061080" + * ``` + * + * @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 + */ +export const generateBoleto = (options?: GenerateBoletoOptions): string => + options?.type === "arrecadacao" ? generateArrecadacao() : generateBancario(); diff --git a/src/get-boleto-info/constants.ts b/src/get-boleto-info/constants.ts new file mode 100644 index 00000000..b114dfd1 --- /dev/null +++ b/src/get-boleto-info/constants.ts @@ -0,0 +1,13 @@ +export const DAY_IN_MS = 86_400_000; + +export const BASE_DATE_YEAR = 1997; +export const BASE_DATE_MONTH = 9; +export const BASE_DATE_DAY = 7; + +export const CYCLE_LENGTH = 9000; + +export const MIN_FACTOR = 1000; + +export const RANGE_BEFORE = 3000; + +export const RANGE_AFTER = 5500; diff --git a/src/get-boleto-info/get-boleto-info.test.ts b/src/get-boleto-info/get-boleto-info.test.ts index 14ba5b0a..5f25a800 100644 --- a/src/get-boleto-info/get-boleto-info.test.ts +++ b/src/get-boleto-info/get-boleto-info.test.ts @@ -1,6 +1,21 @@ import { describe, expect, test } from "../_internals/test/runtime"; import { getBoletoInfo } from "./get-boleto-info"; +const withFactor = { + "0000": "00190000090114971860168524522114100000000102656", + "0999": "00190000090114971860168524522114209990000102656", + "1000": "00190000090114971860168524522114210000000102656", + "1001": "00190000090114971860168524522114810010000102656", + "5000": "00190000090114971860168524522114350000000102656", + "7586": "00190000090114971860168524522114675860000102656", + "7654": "00190000090114971860168524522114576540000102656", + "8999": "00190000090114971860168524522114489990000102656", + "9999": "00190000090114971860168524522114799990000102656", +}; + +const ARRECADACAO_LINE = "846100000005246100291102005460339004695895061080"; +const ARRECADACAO_BARCODE = "84610000000246100291100054603390069589506108"; + describe("getBoletoInfo", () => { describe("should return undefined", () => { test("when boleto is empty string", () => { @@ -28,5 +43,110 @@ describe("getBoletoInfo", () => { bankCode: "001", }); }); + + 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); + }); + }); + + describe("fator de vencimento (fixtures share a banco 001, R$ 1.026,56 slip with only the factor and check digits changed; FEBRABAN restarted the factor at 1000 on 22/02/2025 right after it reached 9999 on 21/02/2025, so the same factor can map to two dates 9000 days apart, and referenceDate pins which cycle wins)", () => { + const referenceDate = new Date(2025, 5, 15); + + test("should return null when there is no fator de vencimento", () => { + expect(getBoletoInfo(withFactor["0000"], { referenceDate })?.expirationDate).toBeNull(); + }); + + test("should return null when the fator starts with zero", () => { + expect(getBoletoInfo(withFactor["0999"], { referenceDate })?.expirationDate).toBeNull(); + }); + + test("should resolve the fator 1000 to 22/02/2025 (new cycle)", () => { + expect(getBoletoInfo(withFactor["1000"], { referenceDate })?.expirationDate).toStrictEqual( + new Date(2025, 1, 22), + ); + }); + + test("should resolve the fator 1001 to 23/02/2025 (new cycle)", () => { + expect(getBoletoInfo(withFactor["1001"], { referenceDate })?.expirationDate).toStrictEqual( + new Date(2025, 1, 23), + ); + }); + + test("should resolve the fator 9999 to 21/02/2025 (old cycle)", () => { + expect(getBoletoInfo(withFactor["9999"], { referenceDate })?.expirationDate).toStrictEqual( + new Date(2025, 1, 21), + ); + }); + + test("should resolve a mid cycle fator", () => { + expect(getBoletoInfo(withFactor["7586"], { referenceDate })?.expirationDate).toStrictEqual( + new Date(2018, 6, 15), + ); + expect(getBoletoInfo(withFactor["7654"], { referenceDate })?.expirationDate).toStrictEqual( + new Date(2018, 8, 21), + ); + expect(getBoletoInfo(withFactor["8999"], { referenceDate })?.expirationDate).toStrictEqual( + new Date(2022, 4, 28), + ); + expect(getBoletoInfo(withFactor["5000"], { referenceDate })?.expirationDate).toStrictEqual( + new Date(2036, 1, 5), + ); + }); + + test("should follow the reference date across the cycles (before the restart, factor 1000 could only mean the old cycle)", () => { + expect( + getBoletoInfo(withFactor["1000"], { referenceDate: new Date(2000, 6, 1) })?.expirationDate, + ).toStrictEqual(new Date(2000, 6, 3)); + }); + + test("should resolve a factor inside the safety range to its closest candidate (fixture '7586' with the factor changed to 6614 and the main check digit recalculated: with referenceDate 15/06/2025 neither cycle candidate falls inside the accepted control range, landing in the 'range de segurança' the FEBRABAN manual describes, so the closest one is used anyway)", () => { + expect( + getBoletoInfo("00190000090114971860168524522114466140000102656", { + referenceDate, + })?.expirationDate, + ).toStrictEqual(new Date(2015, 10, 16)); + }); + + test("should default the reference date to now", () => { + const now = new Date(); + + for (const factor of ["1000", "1001", "9999", "5000"] as const) { + expect(getBoletoInfo(withFactor[factor])?.expirationDate).toStrictEqual( + getBoletoInfo(withFactor[factor], { referenceDate: now })?.expirationDate, + ); + } + + expect(getBoletoInfo(withFactor["0000"])?.expirationDate).toBeNull(); + }); + }); + + describe("arrecadação (FEBRABAN Layout Padrão de Arrecadação §11 Formulário Padrão fixture: R$ 24,61, segment 4)", () => { + test("should parse the linha digitável", () => { + expect(getBoletoInfo(ARRECADACAO_LINE)).toStrictEqual({ + amount: 2461, + expirationDate: null, + bankCode: "", + type: "arrecadacao", + segment: 4, + value: 24.61, + hasEffectiveValue: true, + }); + }); + + test("should parse the código de barras", () => { + expect(getBoletoInfo(ARRECADACAO_BARCODE)?.value).toBe(24.61); + }); + + test("should parse a formatted linha digitável", () => { + expect( + getBoletoInfo("84610000000-5 24610029110-2 00546033900-4 69589506108-0")?.segment, + ).toBe(4); + }); + + test("should flag a reference value", () => { + expect( + getBoletoInfo("847900000005246100291102005460339004695895061080")?.hasEffectiveValue, + ).toBe(false); + }); }); }); diff --git a/src/get-boleto-info/get-boleto-info.ts b/src/get-boleto-info/get-boleto-info.ts index 8e6ca986..be051b19 100644 --- a/src/get-boleto-info/get-boleto-info.ts +++ b/src/get-boleto-info/get-boleto-info.ts @@ -1,43 +1,127 @@ +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"; - -const BANCO_CENTRAL_BASE_DATE = new Date(1997, 9, 7); +import { + BASE_DATE_DAY, + BASE_DATE_MONTH, + BASE_DATE_YEAR, + CYCLE_LENGTH, + DAY_IN_MS, + MIN_FACTOR, + RANGE_AFTER, + RANGE_BEFORE, +} from "./constants"; export type BoletoInfo = { + /** Amount in cents. */ 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; + /** 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. */ + segment?: number; + /** Arrecadação amount in reais (`amount` divided by 100). */ + value?: number; + /** Whether the arrecadação amount is an effective value (`true`) or a reference quantity (`false`). */ + hasEffectiveValue?: boolean; +}; + +const toDayNumber = (date: Date): number => + Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / DAY_IN_MS); + +const getBaseDayNumber = (): number => + Math.floor(Date.UTC(BASE_DATE_YEAR, BASE_DATE_MONTH, BASE_DATE_DAY) / DAY_IN_MS); + +const dateFromBase = (days: number): Date => + new Date(BASE_DATE_YEAR, BASE_DATE_MONTH, BASE_DATE_DAY + days); + +const getExpirationDate = (factor: number, referenceDate: Date): Date | null => { + if (!Number.isFinite(factor) || factor < MIN_FACTOR) return null; + + const reference = toDayNumber(referenceDate); + const cycle = Math.floor((reference - getBaseDayNumber() - factor) / CYCLE_LENGTH); + + let closest = 0; + let closestDistance = Number.POSITIVE_INFINITY; + + for (const candidate of [cycle, cycle + 1]) { + const days = candidate * CYCLE_LENGTH + factor; + const difference = getBaseDayNumber() + days - reference; + + if (difference >= -RANGE_BEFORE && difference <= RANGE_AFTER) return dateFromBase(days); + + const distance = Math.abs(difference); + + if (distance < closestDistance) { + closestDistance = distance; + closest = days; + } + } + + return dateFromBase(closest); +}; + +export type GetBoletoInfoOptions = { + /** Date used to resolve the 9000 day "fator de vencimento" cycle (default: now). */ + referenceDate?: Date; }; /** * Extracts information from a Brazilian bank slip (boleto). * + * Supports the 47 digit "cobrança bancária" linha digitável and, additionally, the + * "arrecadação" (convênio/tributos) bank slip: 48 digit linha digitável or 44 digit + * barcode, both starting with `8`. Arrecadação bank slips also return `type`, `segment`, + * `value` and `hasEffectiveValue`, and have no `bankCode` nor `expirationDate`. + * * @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. * @returns {BoletoInfo | undefined} An object containing amount (in cents), expirationDate, and bankCode, or undefined if the boleto is invalid. * * @example * ```typescript * getBoletoInfo('00190000090114971860168524522114675860000102656'); * // { amount: 102656, expirationDate: new Date(2018, 6, 15), bankCode: '001' } + * + * getBoletoInfo('846100000005246100291102005460339004695895061080'); + * // { amount: 2461, expirationDate: null, bankCode: '', type: 'arrecadacao', segment: 4, value: 24.61, hasEffectiveValue: true } * ``` + * + * @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 */ -export const getBoletoInfo = (value: string): BoletoInfo | undefined => { - if (!value || !isValidBoleto(value)) return; +export const getBoletoInfo = ( + value: string, + options?: GetBoletoInfoOptions, +): BoletoInfo | undefined => { + if (!isValidBoleto(value)) return undefined; const sanitized = sanitizeToDigits(value); - const bankCode = sanitized.slice(0, 3); - - const daysSinceBaseDayStr = sanitized.slice(33, 37); - const daysSinceBaseDay = Number(daysSinceBaseDayStr); + const arrecadacao = parseArrecadacao(sanitized); - let expirationDate: Date | null = null; - if (daysSinceBaseDay && daysSinceBaseDay > 0) { - const resultDate = new Date(BANCO_CENTRAL_BASE_DATE); - resultDate.setDate(resultDate.getDate() + daysSinceBaseDay); - expirationDate = resultDate; + if (arrecadacao) { + return { + amount: arrecadacao.amount, + expirationDate: null, + bankCode: "", + type: "arrecadacao", + segment: arrecadacao.segment, + value: arrecadacao.amount / 100, + hasEffectiveValue: arrecadacao.hasEffectiveValue, + }; } + const bankCode = sanitized.slice(0, 3); + + const expirationDate = getExpirationDate( + Number(sanitized.slice(33, 37)), + options?.referenceDate ?? new Date(), + ); + const amount = Number(sanitized.slice(37, 47)) || 0; return { amount, expirationDate, bankCode }; diff --git a/src/is-valid-boleto/constants.ts b/src/is-valid-boleto/constants.ts index 32055276..a5e5b049 100644 --- a/src/is-valid-boleto/constants.ts +++ b/src/is-valid-boleto/constants.ts @@ -1,4 +1,3 @@ -export const LENGTH = 47; export const CHECK_DIGIT_POSITION = 4; export const PARTIALS = [ diff --git a/src/is-valid-boleto/is-valid-boleto.test.ts b/src/is-valid-boleto/is-valid-boleto.test.ts index 4cf05e86..3b502fcd 100644 --- a/src/is-valid-boleto/is-valid-boleto.test.ts +++ b/src/is-valid-boleto/is-valid-boleto.test.ts @@ -1,5 +1,5 @@ +import { BOLETO_LENGTH } from "../_internals/constants/boleto"; import { describe, expect, test } from "../_internals/test/runtime"; -import { LENGTH } from "./constants"; import { isValidBoleto } from "./is-valid-boleto"; describe("isValidBoleto", () => { @@ -18,7 +18,7 @@ describe("isValidBoleto", () => { expect(isValidBoleto(undefined)).toBe(false); }); - test(`when length is less than ${LENGTH}`, () => { + test(`when length is less than ${BOLETO_LENGTH}`, () => { expect(isValidBoleto("123456789")).toBe(false); }); @@ -57,4 +57,61 @@ describe("isValidBoleto", () => { expect(isValidBoleto("0019000009 01149.718601 68524.522114 6 75860000102656")).toBe(true); }); }); + + describe("arrecadação", () => { + const FEBRABAN_LINE = "846100000005246100291102005460339004695895061080"; + const FEBRABAN_BARCODE = "84610000000246100291100054603390069589506108"; + + describe("should return true", () => { + test("for the FEBRABAN 'Layout Padrão de Arrecadação' §11 modulo 10 linha digitável example", () => { + expect(isValidBoleto(FEBRABAN_LINE)).toBe(true); + }); + + test("for the mcrvaz/boleto-brasileiro-validator modulus 10 linha digitável fixture", () => { + expect(isValidBoleto("836200000005667800481000180975657313001589636081")).toBe(true); + }); + + test("for the mrmgomes/boleto-utils modulus 10 linha digitável fixture", () => { + expect(isValidBoleto("846300000003299902962024004101360008002006441147")).toBe(true); + }); + + test("when it is a valid modulo 11 linha digitável", () => { + expect(isValidBoleto("858900004609524601791605607593050865831483000010")).toBe(true); + expect(isValidBoleto("848900000002404201622015806051904292586034111220")).toBe(true); + expect(isValidBoleto("858000000070438403281922630720192528304729600523")).toBe(true); + expect(isValidBoleto("838600000050096000190009000801782309000343062712")).toBe(true); + expect(isValidBoleto("858200000007572503282030560708202107539591904460")).toBe(true); + }); + + test("for the barcode form of the FEBRABAN §11 modulus 10 example", () => { + expect(isValidBoleto(FEBRABAN_BARCODE)).toBe(true); + expect(isValidBoleto("85890000460524601791606075930508683148300001")).toBe(true); + }); + + test("when it has a mask", () => { + expect(isValidBoleto("84610000000-5 24610029110-2 00546033900-4 69589506108-0")).toBe(true); + }); + }); + + describe("should return false", () => { + test("when the general check digit is wrong", () => { + expect(isValidBoleto(`8469${FEBRABAN_BARCODE.slice(4)}`)).toBe(false); + }); + + test("when a block check digit is wrong", () => { + expect(isValidBoleto(`${FEBRABAN_LINE.slice(0, 11)}9${FEBRABAN_LINE.slice(12)}`)).toBe( + false, + ); + }); + + test("when the value identifier is not 6, 7, 8 or 9", () => { + expect(isValidBoleto(`841${FEBRABAN_BARCODE.slice(3)}`)).toBe(false); + }); + + test("when the length is wrong", () => { + expect(isValidBoleto(FEBRABAN_LINE.slice(0, 47))).toBe(false); + expect(isValidBoleto(`${FEBRABAN_LINE}0`)).toBe(false); + }); + }); + }); }); diff --git a/src/is-valid-boleto/is-valid-boleto.ts b/src/is-valid-boleto/is-valid-boleto.ts index 62f467ef..412cf956 100644 --- a/src/is-valid-boleto/is-valid-boleto.ts +++ b/src/is-valid-boleto/is-valid-boleto.ts @@ -1,7 +1,10 @@ +import { ARRECADACAO_PRODUCT } from "../_internals/constants/arrecadacao"; +import { BOLETO_LENGTH } from "../_internals/constants/boleto"; import { mod10 } from "../_internals/mod10/mod10"; import { mod11 } from "../_internals/mod11/mod11"; +import { parseArrecadacao } from "../_internals/parse-arrecadacao/parse-arrecadacao"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -import { CHECK_DIGIT_POSITION, CONVERT_POSITIONS, LENGTH, PARTIALS } from "./constants"; +import { CHECK_DIGIT_POSITION, CONVERT_POSITIONS, PARTIALS } from "./constants"; const isValidPartials = (digits: string): boolean => { for (const { start, end, checkIdx } of PARTIALS) { @@ -30,6 +33,10 @@ const isValidCheckDigit = (boleto: string): boolean => { /** * Validates if a Brazilian bank slip (boleto) number is valid. * + * Supports the 47 digit "cobrança bancária" linha digitável and, additionally, the + * "arrecadação" (convênio/tributos) bank slip: 48 digit linha digitável or 44 digit + * barcode, both starting with `8`. + * * @param {string} value - The bank slip number to validate. * @returns {boolean} True if the bank slip number is valid, false otherwise. * @@ -37,14 +44,19 @@ const isValidCheckDigit = (boleto: string): boolean => { * ```typescript * isValidBoleto("00190000090114971860168524522114675860000102656"); // true * isValidBoleto("0019000009 01149.718601 68524.522114 6 75860000102656"); // true + * isValidBoleto("846100000005246100291102005460339004695895061080"); // true (arrecadação) * ``` + * + * @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 */ export const isValidBoleto = (value: string): boolean => { - if (!value || typeof value !== "string") return false; + if (typeof value !== "string" || value === "") return false; const digits = sanitizeToDigits(value); - if (digits.length !== LENGTH) return false; + if (digits.startsWith(ARRECADACAO_PRODUCT) && parseArrecadacao(digits)) return true; + + if (digits.length !== BOLETO_LENGTH) return false; if (!isValidPartials(digits)) return false; diff --git a/src/parse-boleto/parse-boleto.test.ts b/src/parse-boleto/parse-boleto.test.ts index abeea7d7..08a89f2e 100644 --- a/src/parse-boleto/parse-boleto.test.ts +++ b/src/parse-boleto/parse-boleto.test.ts @@ -19,4 +19,22 @@ describe("parseBoleto", () => { "10491443385511900000200000000141325230000093423", ); }); + + describe("arrecadação", () => { + it("should remove the arrecadação mask characters", () => { + expect(parseBoleto("84610000000-5 24610029110-2 00546033900-4 69589506108-0")).toBe( + "846100000005246100291102005460339004695895061080", + ); + }); + + it("should keep the 48 digits of an arrecadação linha digitável", () => { + expect(parseBoleto("846100000005246100291102005460339004695895061080")).toHaveLength(48); + }); + + it("should ignore digits after the arrecadação length", () => { + expect(parseBoleto("846100000005246100291102005460339004695895061080123")).toBe( + "846100000005246100291102005460339004695895061080", + ); + }); + }); }); diff --git a/src/parse-boleto/parse-boleto.ts b/src/parse-boleto/parse-boleto.ts index 48542c27..a3a8d04a 100644 --- a/src/parse-boleto/parse-boleto.ts +++ b/src/parse-boleto/parse-boleto.ts @@ -1,11 +1,35 @@ +import { ARRECADACAO_LINE_LENGTH, ARRECADACAO_PRODUCT } from "../_internals/constants/arrecadacao"; +import { BOLETO_LENGTH } from "../_internals/constants/boleto"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -import { LENGTH } from "../format-boleto/constants"; /** * Removes boleto formatting characters and returns only digits. * + * 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. + * * @param {string|number} value - The boleto value to be parsed. * @returns {string} The boleto value without formatting. + * + * @example + * ```typescript + * parseBoleto("10491.44338 55119.000002 00000.000141 3 25230000093423"); + * // "10491443385511900000200000000141325230000093423" + * + * parseBoleto("82630000001-1 09880010070-2 02410202400-0 00020510451-9"); + * // "826300000011098800100702024102024000000205104519" + * ``` + * + * @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 */ -export const parseBoleto = (value: string | number): string => - sanitizeToDigits(value).slice(0, LENGTH); +export const parseBoleto = (value: string | number): string => { + if (isNullish(value)) return ""; + + const digits = sanitizeToDigits(value); + + return digits.slice( + 0, + digits.startsWith(ARRECADACAO_PRODUCT) ? ARRECADACAO_LINE_LENGTH : BOLETO_LENGTH, + ); +}; From ac2e84d7deeea2c7e737afc46251f1786a80a957 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:32:08 -0300 Subject: [PATCH 2/7] feat(banks): add getBanks, getBankByCode and getBankByIspb; validate every Bacen bank account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getBanks()/getBankByCode(code)/getBankByIspb(ispb) list and look up the Bacen STR bank table (COMPE + ISPB + name), previously only reachable indirectly through isValidBankAccount. fix(bank-account): replace the 5-bank ad hoc digit check with the documented algorithm for every bank in that table (was Banco do Brasil, Itaú, Bradesco, Santander and Caixa Econômica Federal only). Banco do Brasil (001) and Santander (033) check-digit rules now follow the published algorithms (BB: mod 11 over the account only; Santander: published weights over agency + 00 + account), so some previously accepted digits are now rejected. Unknown bank codes (not in the Bacen STR list) are rejected. --- scripts/banks.ts | 185 ++++ src/_internals/constants/banks.ts | 717 +++++++++++++++ src/get-bank-by-code/get-bank-by-code.test.ts | 102 +++ src/get-bank-by-code/get-bank-by-code.ts | 40 + src/get-bank-by-ispb/get-bank-by-ispb.test.ts | 106 +++ src/get-bank-by-ispb/get-bank-by-ispb.ts | 43 + src/get-banks/get-banks.test.ts | 35 + src/get-banks/get-banks.ts | 21 + src/is-valid-bank-account/constants.ts | 81 ++ .../is-valid-bank-account.test.ts | 866 +++++++++++++++++- .../is-valid-bank-account.ts | 339 ++++--- 11 files changed, 2409 insertions(+), 126 deletions(-) create mode 100644 scripts/banks.ts create mode 100644 src/_internals/constants/banks.ts create mode 100644 src/get-bank-by-code/get-bank-by-code.test.ts create mode 100644 src/get-bank-by-code/get-bank-by-code.ts create mode 100644 src/get-bank-by-ispb/get-bank-by-ispb.test.ts create mode 100644 src/get-bank-by-ispb/get-bank-by-ispb.ts create mode 100644 src/get-banks/get-banks.test.ts create mode 100644 src/get-banks/get-banks.ts create mode 100644 src/is-valid-bank-account/constants.ts diff --git a/scripts/banks.ts b/scripts/banks.ts new file mode 100644 index 00000000..a7f4a796 --- /dev/null +++ b/scripts/banks.ts @@ -0,0 +1,185 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { fetchWithRetry } from "../src/_internals/fetch-with-retry/fetch-with-retry.ts"; + +const scriptsDir = dirname(fileURLToPath(import.meta.url)); + +const BACEN_CSV_URL = "https://www.bcb.gov.br/pom/spb/estatistica/port/ParticipantesSTRport.csv"; + +const BRASIL_API_URL = "https://brasilapi.com.br/api/banks/v1"; + +type BankRow = { + code: string; + ispb: string; + name: string; +}; + +type BrasilApiBank = { + ispb?: string; + code?: number; + name?: string; + fullName?: string; +}; + +const parseCsvLine = (line: string): string[] => { + const fields: string[] = []; + let current = ""; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + + if (inQuotes) { + if (char === '"' && line[i + 1] === '"') { + current += '"'; + i++; + } else if (char === '"') { + inQuotes = false; + } else { + current += char; + } + } else if (char === '"') { + inQuotes = true; + } else if (char === ",") { + fields.push(current); + current = ""; + } else { + current += char; + } + } + + fields.push(current); + + return fields; +}; + +const fetchFromBacen = async (): Promise => { + const response = await fetchWithRetry(BACEN_CSV_URL); + + if (!response.ok) { + throw new Error(`Bacen STR participants request failed with status ${response.status}`); + } + + const text = (await response.text()).replace(/^\uFEFF/, ""); + const [, ...rows] = text.split(/\r\n|\n/).filter((line) => line.length > 0); + + const banks: BankRow[] = []; + + for (const row of rows) { + const [ispb, , code, , , name] = parseCsvLine(row); + + if (!ispb || !code || !name || !/^\d{1,3}$/.test(code)) continue; + + banks.push({ code: code.padStart(3, "0"), ispb, name: name.trim() }); + } + + return banks; +}; + +const fetchFromBrasilApi = async (): Promise => { + const response = await fetchWithRetry(BRASIL_API_URL); + + if (!response.ok) { + throw new Error(`BrasilAPI banks request failed with status ${response.status}`); + } + + const json: BrasilApiBank[] = await response.json(); + + const banks: BankRow[] = []; + + for (const bank of json) { + if ( + typeof bank.code !== "number" || + !Number.isInteger(bank.code) || + bank.code < 0 || + bank.code > 999 || + !bank.ispb + ) + continue; + + const name = (bank.fullName ?? bank.name ?? "").trim(); + + if (!name) continue; + + banks.push({ code: String(bank.code).padStart(3, "0"), ispb: bank.ispb, name }); + } + + return banks; +}; + +const main = async () => { + let banks: BankRow[]; + let source: string; + + try { + banks = await fetchFromBacen(); + source = BACEN_CSV_URL; + } catch (error) { + console.error( + `Bacen STR participants request failed, falling back to BrasilAPI: ${error instanceof Error ? error.message : String(error)}`, + ); + banks = await fetchFromBrasilApi(); + source = BRASIL_API_URL; + } + + const uniqueBanks = new Map(); + + for (const bank of banks) { + uniqueBanks.set(bank.code, bank); + } + + const sorted = [...uniqueBanks.values()].sort((bankA, bankB) => + bankA.code > bankB.code ? 1 : -1, + ); + + if (sorted.length === 0) { + throw new Error("Refusing to write an empty bank dataset"); + } + + console.log(`Generated ${sorted.length} banks from ${source}`); + + await writeFile( + resolve(scriptsDir, "..", "./src/_internals/constants/banks.ts"), + `/** + * 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 + * \`scripts/banks.ts\`. + * @see ${BACEN_CSV_URL} + */ +export type Bank = { + /** Compensation code (COMPE), 3 digits, zero-padded. */ + code: string; + /** Identificador do Sistema de Pagamentos Brasileiro (ISPB), 8 digits, zero-padded. */ + ispb: string; + /** Institution name, as published by Banco Central do Brasil. */ + name: string; +}; + +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 constants = await readFile(constantsPath, "utf8"); + const literal = (compeCodes.match(/.{1,90}/g) ?? []).map((chunk) => `\t"${chunk}"`).join(" +\n"); + const updated = constants.replace( + /export const COMPE_CODES =\n(?:\t"\d*" \+\n)*\t"\d*";/, + `export const COMPE_CODES =\n${literal};`, + ); + + if (updated === constants) { + throw new Error("COMPE_CODES literal not found in src/is-valid-bank-account/constants.ts"); + } + + await writeFile(constantsPath, updated); + console.log(`Updated COMPE_CODES with ${sorted.length} codes`); +}; + +await main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/src/_internals/constants/banks.ts b/src/_internals/constants/banks.ts new file mode 100644 index 00000000..964de7c4 --- /dev/null +++ b/src/_internals/constants/banks.ts @@ -0,0 +1,717 @@ +/** + * 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 + * `scripts/banks.ts`. + * @see https://www.bcb.gov.br/pom/spb/estatistica/port/ParticipantesSTRport.csv + */ +export type Bank = { + /** Compensation code (COMPE), 3 digits, zero-padded. */ + code: string; + /** Identificador do Sistema de Pagamentos Brasileiro (ISPB), 8 digits, zero-padded. */ + ispb: string; + /** Institution name, as published by Banco Central do Brasil. */ + name: string; +}; + +export const BANKS: Bank[] = [ + { code: "001", ispb: "00000000", name: "Banco do Brasil S.A." }, + { code: "003", ispb: "04902979", name: "BANCO DA AMAZONIA S.A." }, + { code: "004", ispb: "07237373", name: "Banco do Nordeste do Brasil S.A." }, + { code: "007", ispb: "33657248", name: "BANCO NACIONAL DE DESENVOLVIMENTO ECONOMICO E SOCIAL" }, + { code: "010", ispb: "81723108", name: "CREDICOAMO CREDITO RURAL COOPERATIVA" }, + { code: "011", ispb: "61809182", name: "CREDIT SUISSE HEDGING-GRIFFO CORRETORA DE VALORES S.A" }, + { code: "012", ispb: "04866275", name: "Banco Inbursa S.A." }, + { code: "014", ispb: "09274232", name: "STATE STREET BRASIL S.A. - BANCO COMERCIAL" }, + { + code: "015", + ispb: "02819125", + name: "UBS Brasil Corretora de Câmbio, Títulos e Valores Mobiliários S.A.", + }, + { + code: "016", + ispb: "04715685", + name: "COOPERATIVA DE CRÉDITO MÚTUO DOS DESPACHANTES DE TRÂNSITO DE SANTA CATARINA E RI", + }, + { code: "017", ispb: "42272526", name: "BNY Mellon Banco S.A." }, + { code: "018", ispb: "57839805", name: "Banco Tricury S.A." }, + { code: "021", ispb: "28127603", name: "BANESTES S.A. BANCO DO ESTADO DO ESPIRITO SANTO" }, + { code: "024", ispb: "10866788", name: "Banco Bandepe S.A." }, + { code: "025", ispb: "03323840", name: "Banco Alfa S.A." }, + { code: "029", ispb: "33885724", name: "Banco Itaú Consignado S.A." }, + { code: "033", ispb: "90400888", name: "BANCO SANTANDER (BRASIL) S.A." }, + { code: "036", ispb: "06271464", name: "Banco Bradesco BBI S.A." }, + { code: "037", ispb: "04913711", name: "Banco do Estado do Pará S.A." }, + { code: "040", ispb: "03609817", name: "Banco Cargill S.A." }, + { code: "041", ispb: "92702067", name: "Banco do Estado do Rio Grande do Sul S.A." }, + { code: "047", ispb: "13009717", name: "Banco do Estado de Sergipe S.A." }, + { code: "060", ispb: "04913129", name: "Confidence Corretora de Câmbio S.A." }, + { code: "062", ispb: "03012230", name: "Hipercard Banco Múltiplo S.A." }, + { code: "063", ispb: "04184779", name: "Banco Bradescard S.A." }, + { code: "064", ispb: "04332281", name: "GOLDMAN SACHS DO BRASIL BANCO MULTIPLO S.A." }, + { code: "065", ispb: "48795256", name: "Banco AndBank (Brasil) S.A." }, + { code: "066", ispb: "02801938", name: "BANCO MORGAN STANLEY S.A." }, + { code: "069", ispb: "61033106", name: "Banco Crefisa S.A." }, + { code: "070", ispb: "00000208", name: "BRB - BANCO DE BRASILIA S.A." }, + { code: "074", ispb: "03017677", name: "Banco J. Safra S.A." }, + { code: "075", ispb: "03532415", name: "Banco ABN Amro S.A." }, + { code: "076", ispb: "07656500", name: "Banco KDB do Brasil S.A." }, + { code: "077", ispb: "00416968", name: "Banco Inter S.A." }, + { code: "078", ispb: "34111187", name: "Haitong Banco de Investimento do Brasil S.A." }, + { code: "079", ispb: "09516419", name: "PICPAY BANK - BANCO MÚLTIPLO S.A" }, + { code: "080", ispb: "73622748", name: "B&T CORRETORA DE CAMBIO LTDA." }, + { code: "081", ispb: "10264663", name: "BancoSeguro S.A." }, + { code: "082", ispb: "07679404", name: "BANCO TOPÁZIO S.A." }, + { code: "083", ispb: "10690848", name: "Banco da China Brasil S.A." }, + { code: "084", ispb: "02398976", name: "SISPRIME DO BRASIL - COOPERATIVA DE CRÉDITO" }, + { code: "085", ispb: "05463212", name: "Cooperativa Central de Crédito - Ailos" }, + { code: "088", ispb: "11476673", name: "BANCO RANDON S.A." }, + { code: "089", ispb: "62109566", name: "CREDISAN COOPERATIVA DE CRÉDITO" }, + { + code: "093", + ispb: "07945233", + name: "PÓLOCRED SOCIEDADE DE CRÉDITO AO MICROEMPREENDEDOR E À EMPRESA DE PEQUENO PORT", + }, + { code: "094", ispb: "11758741", name: "Banco Finaxis S.A." }, + { code: "095", ispb: "11703662", name: "Travelex Banco de Câmbio S.A." }, + { code: "096", ispb: "00997185", name: "Banco B3 S.A." }, + { code: "097", ispb: "04632856", name: "Credisis - Central de Cooperativas de Crédito Ltda." }, + { code: "098", ispb: "78157146", name: "Credialiança Cooperativa de Crédito Rural" }, + { + code: "099", + ispb: "03046391", + name: "UNIPRIME CENTRAL NACIONAL - CENTRAL NACIONAL DE COOPERATIVA DE CREDITO", + }, + { code: "100", ispb: "00806535", name: "Planner Corretora de Valores S.A." }, + { + code: "101", + ispb: "62287735", + name: "RENASCENCA DISTRIBUIDORA DE TÍTULOS E VALORES MOBILIÁRIOS LTDA", + }, + { + code: "102", + ispb: "02332886", + name: "XP INVESTIMENTOS CORRETORA DE CÂMBIO,TÍTULOS E VALORES MOBILIÁRIOS S/A", + }, + { code: "104", ispb: "00360305", name: "CAIXA ECONOMICA FEDERAL" }, + { code: "105", ispb: "07652226", name: "Lecca Crédito, Financiamento e Investimento S/A" }, + { code: "107", ispb: "15114366", name: "Banco Bocom BBM S.A." }, + { + code: "111", + ispb: "36113876", + name: "OLIVEIRA TRUST DISTRIBUIDORA DE TÍTULOS E VALORES MOBILIARIOS S.A.", + }, + { code: "113", ispb: "61723847", name: "NEON CORRETORA DE TÍTULOS E VALORES MOBILIÁRIOS S.A." }, + { + code: "114", + ispb: "05790149", + name: "Central Cooperativa de Crédito no Estado do Espírito Santo - CECOOP", + }, + { code: "117", ispb: "92856905", name: "ADVANCED CORRETORA DE CÂMBIO LTDA" }, + { code: "119", ispb: "13720915", name: "Banco Western Union do Brasil S.A." }, + { code: "120", ispb: "33603457", name: "BANCO RODOBENS S.A." }, + { code: "121", ispb: "10664513", name: "Banco Agibank S.A." }, + { code: "122", ispb: "33147315", name: "Banco Bradesco BERJ S.A." }, + { code: "124", ispb: "15357060", name: "Banco Woori Bank do Brasil S.A." }, + { code: "125", ispb: "45246410", name: "BANCO GENIAL S.A." }, + { code: "126", ispb: "13220493", name: "BR Partners Banco de Investimento S.A." }, + { code: "127", ispb: "09512542", name: "Codepe Corretora de Valores e Câmbio S.A." }, + { code: "128", ispb: "19307785", name: "BRAZA BANK S.A. BANCO DE CÂMBIO" }, + { code: "129", ispb: "18520834", name: "UBS Brasil Banco de Investimento S.A." }, + { + code: "130", + ispb: "09313766", + name: "CARUANA S.A. - SOCIEDADE DE CRÉDITO, FINANCIAMENTO E INVESTIMENTO", + }, + { + code: "131", + ispb: "61747085", + name: "TULLETT PREBON BRASIL CORRETORA DE VALORES E CÂMBIO LTDA", + }, + { code: "132", ispb: "17453575", name: "ICBC do Brasil Banco Múltiplo S.A." }, + { + code: "133", + ispb: "10398952", + name: "CONFEDERAÇÃO NACIONAL DAS COOPERATIVAS CENTRAIS DE CRÉDITO E ECONOMIA FAMILIAR E", + }, + { + code: "134", + ispb: "33862244", + name: "BGC LIQUIDEZ DISTRIBUIDORA DE TÍTULOS E VALORES MOBILIÁRIOS LTDA", + }, + { + code: "136", + ispb: "00315557", + name: "CONFEDERAÇÃO NACIONAL DAS COOPERATIVAS CENTRAIS UNICRED LTDA. - UNICRED DO BRASI", + }, + { code: "138", ispb: "10853017", name: "Get Money Corretora de Câmbio S.A." }, + { code: "139", ispb: "55230916", name: "Intesa Sanpaolo Brasil S.A. - Banco Múltiplo" }, + { code: "140", ispb: "62169875", name: "NU INVEST CORRETORA DE VALORES S.A." }, + { code: "141", ispb: "09526594", name: "BANCO MASTER DE INVESTIMENTO S.A." }, + { code: "142", ispb: "16944141", name: "Broker Brasil Corretora de Câmbio Ltda." }, + { code: "143", ispb: "02992317", name: "Treviso Corretora de Câmbio S.A." }, + { code: "144", ispb: "13059145", name: "BEXS BANCO DE CÂMBIO S/A" }, + { code: "145", ispb: "50579044", name: "LEVYCAM - CORRETORA DE CAMBIO E VALORES LTDA." }, + { code: "146", ispb: "24074692", name: "GUITTA CORRETORA DE CAMBIO LTDA." }, + { + code: "149", + ispb: "15581638", + name: "Facta Financeira S.A. - Crédito Financiamento e Investimento", + }, + { + code: "157", + ispb: "09105360", + name: "ICAP do Brasil Corretora de Títulos e Valores Mobiliários Ltda.", + }, + { + code: "159", + ispb: "05442029", + name: "Casa do Crédito S.A. Sociedade de Crédito ao Microempreendedor", + }, + { + code: "173", + ispb: "13486793", + name: "BRL Trust Distribuidora de Títulos e Valores Mobiliários S.A.", + }, + { code: "174", ispb: "43180355", name: "PEFISA S.A. - CRÉDITO, FINANCIAMENTO E INVESTIMENTO" }, + { code: "177", ispb: "65913436", name: "Guide Investimentos S.A. Corretora de Valores" }, + { + code: "180", + ispb: "02685483", + name: "CM CAPITAL MARKETS CORRETORA DE CÂMBIO, TÍTULOS E VALORES MOBILIÁRIOS LTDA", + }, + { + code: "183", + ispb: "09210106", + name: "SOCRED S.A. - SOCIEDADE DE CRÉDITO AO MICROEMPREENDEDOR E À EMPRESA DE PEQUENO P", + }, + { code: "184", ispb: "17298092", name: "Banco Itaú BBA S.A." }, + { + code: "188", + ispb: "33775974", + name: "ATIVA INVESTIMENTOS S.A. CORRETORA DE TÍTULOS, CÂMBIO E VALORES", + }, + { + code: "189", + ispb: "07512441", + name: "HS FINANCEIRA S/A CREDITO, FINANCIAMENTO E INVESTIMENTOS", + }, + { + code: "190", + ispb: "03973814", + name: "SERVICOOP - COOPERATIVA DE CRÉDITO DOS SERVIDORES PÚBLICOS ESTADUAIS E MUNICIPAI", + }, + { + code: "191", + ispb: "04257795", + name: "Nova Futura Corretora de Títulos e Valores Mobiliários Ltda.", + }, + { + code: "194", + ispb: "20155248", + name: "PARMETAL DISTRIBUIDORA DE TÍTULOS E VALORES MOBILIÁRIOS LTDA", + }, + { code: "195", ispb: "07799277", name: "VALOR SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { code: "196", ispb: "32648370", name: "FAIR CORRETORA DE CAMBIO S.A." }, + { code: "197", ispb: "16501555", name: "STONE INSTITUIÇÃO DE PAGAMENTO S.A." }, + { code: "208", ispb: "30306294", name: "Banco BTG Pactual S.A." }, + { code: "212", ispb: "92894922", name: "Banco Original S.A." }, + { code: "213", ispb: "54403563", name: "Banco Arbi S.A." }, + { code: "217", ispb: "91884981", name: "Banco John Deere S.A." }, + { code: "218", ispb: "71027866", name: "Banco BS2 S.A." }, + { code: "222", ispb: "75647891", name: "BANCO CRÉDIT AGRICOLE BRASIL S.A." }, + { code: "224", ispb: "58616418", name: "Banco Fibra S.A." }, + { code: "233", ispb: "62421979", name: "Banco Cifra S.A." }, + { code: "237", ispb: "60746948", name: "Banco Bradesco S.A." }, + { code: "241", ispb: "31597552", name: "BANCO CLASSICO S.A." }, + { code: "243", ispb: "33923798", name: "BANCO MASTER S/A" }, + { code: "246", ispb: "28195667", name: "Banco ABC Brasil S.A." }, + { code: "249", ispb: "61182408", name: "Banco Investcred Unibanco S.A." }, + { code: "250", ispb: "50585090", name: "BCV - BANCO DE CRÉDITO E VAREJO S.A." }, + { code: "253", ispb: "52937216", name: "Bexs Corretora de Câmbio S/A" }, + { code: "254", ispb: "14388334", name: "PARANÁ BANCO S.A." }, + { code: "259", ispb: "08609934", name: "MONEYCORP BANCO DE CÂMBIO S.A." }, + { code: "260", ispb: "18236120", name: "NU PAGAMENTOS S.A. - INSTITUIÇÃO DE PAGAMENTO" }, + { code: "265", ispb: "33644196", name: "Banco Fator S.A." }, + { code: "266", ispb: "33132044", name: "BANCO CEDULA S.A." }, + { code: "268", ispb: "14511781", name: "BARI COMPANHIA HIPOTECÁRIA" }, + { code: "269", ispb: "53518684", name: "BANCO HSBC S.A." }, + { code: "270", ispb: "61444949", name: "SAGITUR CORRETORA DE CÂMBIO S.A." }, + { + code: "271", + ispb: "27842177", + name: "IB Corretora de Câmbio, Títulos e Valores Mobiliários S.A.", + }, + { code: "272", ispb: "00250699", name: "AGK CORRETORA DE CAMBIO S.A." }, + { + code: "273", + ispb: "08253539", + name: "Cooperativa de Crédito Rural de São Miguel do Oeste - Sulcredi/São Miguel", + }, + { + code: "274", + ispb: "11581339", + name: "BMP SOCIEDADE DE CRÉDITO AO MICROEMPREENDEDOR E A EMPRESA DE PEQUENO PORTE LTDA.", + }, + { code: "276", ispb: "11970623", name: "BANCO SENFF S.A." }, + { + code: "278", + ispb: "27652684", + name: "Genial Investimentos Corretora de Valores Mobiliários S.A.", + }, + { + code: "279", + ispb: "26563270", + name: "PRIMACREDI COOPERATIVA DE CRÉDITO DE PRIMAVERA DO LESTE", + }, + { + code: "280", + ispb: "23862762", + name: "WILL FINANCEIRA S.A. CRÉDITO, FINANCIAMENTO E INVESTIMENTO", + }, + { code: "281", ispb: "76461557", name: "Cooperativa de Crédito Rural Coopavel" }, + { + code: "283", + ispb: "89960090", + name: "RB INVESTIMENTOS DISTRIBUIDORA DE TITULOS E VALORES MOBILIARIOS LIMITADA", + }, + { code: "285", ispb: "71677850", name: "FRENTE CORRETORA DE CÂMBIO S.A." }, + { + code: "288", + ispb: "62237649", + name: "CAROL DISTRIBUIDORA DE TITULOS E VALORES MOBILIARIOS LTDA.", + }, + { code: "289", ispb: "94968518", name: "EFX CORRETORA DE CÂMBIO LTDA." }, + { code: "290", ispb: "08561701", name: "PAGSEGURO INTERNET INSTITUIÇÃO DE PAGAMENTO S.A." }, + { + code: "292", + ispb: "28650236", + name: "BS2 Distribuidora de Títulos e Valores Mobiliários S.A.", + }, + { + code: "293", + ispb: "71590442", + name: "Lastro RDV Distribuidora de Títulos e Valores Mobiliários Ltda.", + }, + { code: "296", ispb: "04062902", name: "OZ CORRETORA DE CÂMBIO S.A." }, + { code: "298", ispb: "17772370", name: "Vip's Corretora de Câmbio Ltda." }, + { code: "299", ispb: "04814563", name: "BANCO AFINZ S.A. - BANCO MÚLTIPLO" }, + { code: "300", ispb: "33042151", name: "Banco de la Nacion Argentina" }, + { code: "301", ispb: "13370835", name: "DOCK INSTITUIÇÃO DE PAGAMENTO S.A." }, + { + code: "306", + ispb: "40303299", + name: "PORTOPAR DISTRIBUIDORA DE TITULOS E VALORES MOBILIARIOS LTDA.", + }, + { + code: "307", + ispb: "03751794", + name: "Terra Investimentos Distribuidora de Títulos e Valores Mobiliários Ltda.", + }, + { code: "309", ispb: "14190547", name: "CAMBIONET CORRETORA DE CÂMBIO LTDA." }, + { + code: "310", + ispb: "22610500", + name: "VORTX DISTRIBUIDORA DE TITULOS E VALORES MOBILIARIOS LTDA.", + }, + { code: "311", ispb: "76641497", name: "DOURADA CORRETORA DE CÂMBIO LTDA." }, + { + code: "312", + ispb: "07693858", + name: "HSCM - SOCIEDADE DE CRÉDITO AO MICROEMPREENDEDOR E À EMPRESA DE PEQUENO PORTE LT", + }, + { code: "313", ispb: "16927221", name: "AMAZÔNIA CORRETORA DE CÂMBIO LTDA." }, + { code: "318", ispb: "61186680", name: "Banco BMG S.A." }, + { code: "319", ispb: "11495073", name: "OM DISTRIBUIDORA DE TÍTULOS E VALORES MOBILIÁRIOS LTDA" }, + { code: "320", ispb: "07450604", name: "China Construction Bank (Brasil) Banco Múltiplo S/A" }, + { + code: "321", + ispb: "18188384", + name: "CREFAZ SOCIEDADE DE CRÉDITO AO MICROEMPREENDEDOR E A EMPRESA DE PEQUENO PORTE LT", + }, + { + code: "322", + ispb: "01073966", + name: "Cooperativa de Crédito Rural de Abelardo Luz - Sulcredi/Crediluz", + }, + { code: "323", ispb: "10573521", name: "MERCADO PAGO INSTITUIÇÃO DE PAGAMENTO LTDA." }, + { code: "324", ispb: "21332862", name: "CARTOS SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { + code: "325", + ispb: "13293225", + name: "Órama Distribuidora de Títulos e Valores Mobiliários S.A.", + }, + { code: "326", ispb: "03311443", name: "PARATI - CREDITO, FINANCIAMENTO E INVESTIMENTO S.A." }, + { + code: "328", + ispb: "05841967", + name: "COOPERATIVA DE ECONOMIA E CRÉDITO MÚTUO DOS FABRICANTES DE CALÇADOS DE SAPIRANGA", + }, + { code: "329", ispb: "32402502", name: "QI Sociedade de Crédito Direto S.A." }, + { code: "330", ispb: "00556603", name: "BANCO BARI DE INVESTIMENTOS E FINANCIAMENTOS S.A." }, + { + code: "331", + ispb: "13673855", + name: "Fram Capital Distribuidora de Títulos e Valores Mobiliários S.A.", + }, + { + code: "332", + ispb: "13140088", + name: "ACESSO SOLUÇÕES DE PAGAMENTO S.A. - INSTITUIÇÃO DE PAGAMENTO", + }, + { code: "334", ispb: "15124464", name: "BANCO BESA S.A." }, + { code: "335", ispb: "27098060", name: "Banco Digio S.A." }, + { code: "336", ispb: "31872495", name: "Banco C6 S.A." }, + { code: "340", ispb: "09554480", name: "SUPERDIGITAL INSTITUIÇÃO DE PAGAMENTO S.A." }, + { code: "341", ispb: "60701190", name: "ITAÚ UNIBANCO S.A." }, + { code: "342", ispb: "32997490", name: "Creditas Sociedade de Crédito Direto S.A." }, + { + code: "343", + ispb: "24537861", + name: "FFA SOCIEDADE DE CRÉDITO AO MICROEMPREENDEDOR E À EMPRESA DE PEQUENO PORTE LTDA.", + }, + { code: "348", ispb: "33264668", name: "Banco XP S.A." }, + { code: "349", ispb: "27214112", name: "AL5 S.A. CRÉDITO, FINANCIAMENTO E INVESTIMENTO" }, + { + code: "350", + ispb: "01330387", + name: "COOPERATIVA DE CRÉDITO RURAL DE PEQUENOS AGRICULTORES E DA REFORMA AGRÁRIA DO CE", + }, + { code: "352", ispb: "29162769", name: "TORO CORRETORA DE TÍTULOS E VALORES MOBILIÁRIOS S.A." }, + { code: "355", ispb: "34335592", name: "ÓTIMO SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { code: "358", ispb: "09464032", name: "MIDWAY S.A. - CRÉDITO, FINANCIAMENTO E INVESTIMENTO" }, + { code: "359", ispb: "05351887", name: "ZEMA CRÉDITO, FINANCIAMENTO E INVESTIMENTO S/A" }, + { + code: "360", + ispb: "02276653", + name: "TRINUS CAPITAL DISTRIBUIDORA DE TÍTULOS E VALORES MOBILIÁRIOS S.A.", + }, + { code: "362", ispb: "01027058", name: "CIELO S.A. - INSTITUIÇÃO DE PAGAMENTO" }, + { + code: "363", + ispb: "62285390", + name: "SINGULARE CORRETORA DE TÍTULOS E VALORES MOBILIÁRIOS S.A.", + }, + { code: "364", ispb: "09089356", name: "EFÍ S.A. - INSTITUIÇÃO DE PAGAMENTO" }, + { + code: "365", + ispb: "68757681", + name: "SIMPAUL CORRETORA DE CAMBIO E VALORES MOBILIARIOS S.A.", + }, + { code: "366", ispb: "61533584", name: "BANCO SOCIETE GENERALE BRASIL S.A." }, + { + code: "367", + ispb: "34711571", + name: "VITREO DISTRIBUIDORA DE TÍTULOS E VALORES MOBILIÁRIOS S.A.", + }, + { code: "368", ispb: "08357240", name: "Banco CSF S.A." }, + { code: "370", ispb: "61088183", name: "Banco Mizuho do Brasil S.A." }, + { code: "371", ispb: "92875780", name: "WARREN CORRETORA DE VALORES MOBILIÁRIOS E CÂMBIO LTDA." }, + { code: "373", ispb: "35977097", name: "UP.P SOCIEDADE DE EMPRÉSTIMO ENTRE PESSOAS S.A." }, + { code: "374", ispb: "27351731", name: "REALIZE CRÉDITO, FINANCIAMENTO E INVESTIMENTO S.A." }, + { code: "376", ispb: "33172537", name: "BANCO J.P. MORGAN S.A." }, + { code: "377", ispb: "17826860", name: "BMS SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { code: "378", ispb: "01852137", name: "BANCO BRASILEIRO DE CRÉDITO SOCIEDADE ANÔNIMA" }, + { + code: "379", + ispb: "01658426", + name: "COOPERFORTE - COOPERATIVA DE ECONOMIA E CRÉDITO MÚTUO DE FUNCIONÁRIOS DE INSTITU", + }, + { code: "380", ispb: "22896431", name: "PICPAY INSTITUIçãO DE PAGAMENTO S.A." }, + { code: "381", ispb: "60814191", name: "BANCO MERCEDES-BENZ DO BRASIL S.A." }, + { + code: "382", + ispb: "04307598", + name: "FIDÚCIA SOCIEDADE DE CRÉDITO AO MICROEMPREENDEDOR E À EMPRESA DE PEQUENO PORTE L", + }, + { code: "383", ispb: "21018182", name: "EBANX INSTITUICAO DE PAGAMENTOS LTDA." }, + { + code: "384", + ispb: "11165756", + name: "GLOBAL FINANÇAS SOCIEDADE DE CRÉDITO AO MICROEMPREENDEDOR E À EMPRESA DE PEQUENO", + }, + { + code: "385", + ispb: "03844699", + name: "COOPERATIVA DE ECONOMIA E CREDITO MUTUO DOS TRABALHADORES PORTUARIOS DA GRANDE V", + }, + { + code: "386", + ispb: "30680829", + name: "NU FINANCEIRA S.A. - Sociedade de Crédito, Financiamento e Investimento", + }, + { code: "387", ispb: "03215790", name: "Banco Toyota do Brasil S.A." }, + { code: "389", ispb: "17184037", name: "Banco Mercantil do Brasil S.A." }, + { code: "390", ispb: "59274605", name: "BANCO GM S.A." }, + { code: "391", ispb: "08240446", name: "COOPERATIVA DE CREDITO RURAL DE IBIAM - SULCREDI/IBIAM" }, + { code: "393", ispb: "59109165", name: "Banco Volkswagen S.A." }, + { code: "394", ispb: "07207996", name: "Banco Bradesco Financiamentos S.A." }, + { + code: "395", + ispb: "08673569", + name: "F.D'GOLD - DISTRIBUIDORA DE TÍTULOS E VALORES MOBILIÁRIOS LTDA.", + }, + { code: "396", ispb: "13884775", name: "HUB INSTITUIÇÃO DE PAGAMENTO S.A." }, + { code: "397", ispb: "34088029", name: "LISTO SOCIEDADE DE CREDITO DIRETO S.A." }, + { code: "398", ispb: "31749596", name: "IDEAL CORRETORA DE TÍTULOS E VALORES MOBILIÁRIOS S.A." }, + { code: "399", ispb: "01701201", name: "Kirton Bank S.A. - Banco Múltiplo" }, + { + code: "400", + ispb: "05491616", + name: "COOPERATIVA DE CRÉDITO, POUPANÇA E SERVIÇOS FINANCEIROS DO CENTRO OESTE - CREDIT", + }, + { code: "401", ispb: "15111975", name: "IUGU INSTITUIÇÃO DE PAGAMENTO S.A." }, + { + code: "402", + ispb: "36947229", + name: "COBUCCIO S/A - SOCIEDADE DE CRÉDITO, FINANCIAMENTO E INVESTIMENTOS", + }, + { code: "403", ispb: "37880206", name: "CORA SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { code: "404", ispb: "37241230", name: "SUMUP SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { code: "406", ispb: "37715993", name: "ACCREDITO - SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { + code: "407", + ispb: "00329598", + name: "ÍNDIGO INVESTIMENTOS DISTRIBUIDORA DE TÍTULOS E VALORES MOBILIÁRIOS LTDA.", + }, + { code: "408", ispb: "36586946", name: "BONUSPAGO SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { code: "410", ispb: "05684234", name: "PLANNER SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { + code: "411", + ispb: "05192316", + name: "Via Certa Financiadora S.A. - Crédito, Financiamento e Investimentos", + }, + { code: "412", ispb: "15173776", name: "SOCIAL BANK BANCO MÚLTIPLO S/A" }, + { code: "413", ispb: "01858774", name: "BANCO BV S.A." }, + { code: "414", ispb: "37526080", name: "LEND SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { code: "416", ispb: "19324634", name: "LAMARA SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { + code: "418", + ispb: "37414009", + name: "ZIPDIN SOLUÇÕES DIGITAIS SOCIEDADE DE CRÉDITO DIRETO S/A", + }, + { code: "419", ispb: "38129006", name: "NUMBRS SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { code: "421", ispb: "39343350", name: "LAR COOPERATIVA DE CRÉDITO - LAR CREDI" }, + { code: "422", ispb: "58160789", name: "Banco Safra S.A." }, + { + code: "423", + ispb: "00460065", + name: "COLUNA S/A DISTRIBUIDORA DE TITULOS E VALORES MOBILIÁRIOS", + }, + { code: "425", ispb: "03881423", name: "SOCINAL S.A. - CRÉDITO, FINANCIAMENTO E INVESTIMENTO" }, + { + code: "426", + ispb: "11285104", + name: "NEON FINANCEIRA - CRÉDITO, FINANCIAMENTO E INVESTIMENTO S.A.", + }, + { + code: "427", + ispb: "27302181", + name: "COOPERATIVA DE CREDITO DOS SERVIDORES DA UNIVERSIDADE FEDERAL DO ESPIRITO SANTO", + }, + { code: "428", ispb: "39664698", name: "CREDSYSTEM SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { code: "429", ispb: "05676026", name: "Crediare S.A. - Crédito, financiamento e investimento" }, + { code: "430", ispb: "00204963", name: "COOPERATIVA DE CREDITO RURAL SEARA - CREDISEARA" }, + { + code: "433", + ispb: "44077014", + name: "BR-CAPITAL DISTRIBUIDORA DE TÍTULOS E VALORES MOBILIÁRIOS S.A.", + }, + { code: "435", ispb: "38224857", name: "DELCRED SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { + code: "438", + ispb: "67030395", + name: "TRUSTEE DISTRIBUIDORA DE TÍTULOS E VALORES MOBILIÁRIOS LTDA.", + }, + { code: "439", ispb: "16695922", name: "ID CORRETORA DE TÍTULOS E VALORES MOBILIÁRIOS S.A." }, + { code: "440", ispb: "82096447", name: "CREDIBRF - COOPERATIVA DE CRÉDITO" }, + { + code: "442", + ispb: "87963450", + name: "MAGNETIS - DISTRIBUIDORA DE TÍTULOS E VALORES MOBILIÁRIOS LTDA", + }, + { code: "443", ispb: "39416705", name: "CREDIHOME SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { code: "444", ispb: "40654622", name: "TRINUS SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { code: "445", ispb: "35551187", name: "PLANTAE S.A. - CRÉDITO, FINANCIAMENTO E INVESTIMENTO" }, + { + code: "447", + ispb: "12392983", + name: "MIRAE ASSET WEALTH MANAGEMENT (BRAZIL) CORRETORA DE CÂMBIO, TÍTULOS E VALORES MO", + }, + { + code: "448", + ispb: "39669186", + name: "HEMERA DISTRIBUIDORA DE TÍTULOS E VALORES MOBILIÁRIOS LTDA.", + }, + { code: "449", ispb: "37555231", name: "DM SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { code: "450", ispb: "13203354", name: "FITBANK INSTITUIÇÃO DE PAGAMENTOS ELETRÔNICOS S.A." }, + { code: "451", ispb: "40475846", name: "J17 - SOCIEDADE DE CRÉDITO DIRETO S/A" }, + { code: "452", ispb: "39676772", name: "CREDIFIT SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { + code: "454", + ispb: "41592532", + name: "MÉRITO DISTRIBUIDORA DE TÍTULOS E VALORES MOBILIÁRIOS LTDA.", + }, + { + code: "455", + ispb: "38429045", + name: "FÊNIX DISTRIBUIDORA DE TÍTULOS E VALORES MOBILIÁRIOS LTDA.", + }, + { code: "456", ispb: "60498557", name: "Banco MUFG Brasil S.A." }, + { code: "457", ispb: "39587424", name: "UY3 SOCIEDADE DE CRÉDITO DIRETO S/A" }, + { + code: "458", + ispb: "07253654", + name: "HEDGE INVESTMENTS DISTRIBUIDORA DE TÍTULOS E VALORES MOBILIÁRIOS LTDA.", + }, + { + code: "459", + ispb: "04546162", + name: "COOPERATIVA DE CRÉDITO MÚTUO DE SERVIDORES PÚBLICOS DO ESTADO DE SÃO PAULO - CRE", + }, + { code: "460", ispb: "42047025", name: "UNAVANTI SOCIEDADE DE CRÉDITO DIRETO S/A" }, + { code: "461", ispb: "19540550", name: "ASAAS GESTÃO FINANCEIRA INSTITUIÇÃO DE PAGAMENTO S.A." }, + { code: "462", ispb: "39908427", name: "STARK SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { + code: "463", + ispb: "40434681", + name: "AZUMI DISTRIBUIDORA DE TíTULOS E VALORES MOBILIáRIOS LTDA.", + }, + { code: "464", ispb: "60518222", name: "Banco Sumitomo Mitsui Brasileiro S.A." }, + { code: "465", ispb: "40083667", name: "CAPITAL CONSIG SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { + code: "467", + ispb: "33886862", + name: "MASTER S/A CORRETORA DE CâMBIO, TíTULOS E VALORES MOBILIáRIOS", + }, + { code: "468", ispb: "04862600", name: "PORTOSEG S.A. - CREDITO, FINANCIAMENTO E INVESTIMENTO" }, + { + code: "469", + ispb: "07138049", + name: "LIGA INVEST DISTRIBUIDORA DE TÍTULOS E VALORES MOBILIÁRIOS LTDA", + }, + { code: "470", ispb: "18394228", name: "CDC SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { + code: "471", + ispb: "04831810", + name: "COOPERATIVA DE ECONOMIA E CREDITO MUTUO DOS SERVIDORES PUBLICOS DE PINHÃO - CRES", + }, + { code: "473", ispb: "33466988", name: "Banco Caixa Geral - Brasil S.A." }, + { code: "475", ispb: "10371492", name: "Banco Yamaha Motor do Brasil S.A." }, + { code: "477", ispb: "33042953", name: "Citibank N.A." }, + { + code: "478", + ispb: "11760553", + name: "GAZINCRED S.A. SOCIEDADE DE CRÉDITO, FINANCIAMENTO E INVESTIMENTO", + }, + { code: "479", ispb: "60394079", name: "Banco ItauBank S.A." }, + { code: "481", ispb: "43599047", name: "SUPERLÓGICA SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { code: "482", ispb: "42259084", name: "SBCASH SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { + code: "484", + ispb: "36864992", + name: "MAF DISTRIBUIDORA DE TÍTULOS E VALORES MOBILIÁRIOS S.A.", + }, + { code: "487", ispb: "62331228", name: "DEUTSCHE BANK S.A. - BANCO ALEMAO" }, + { code: "488", ispb: "46518205", name: "JPMorgan Chase Bank, National Association" }, + { code: "495", ispb: "44189447", name: "Banco de La Provincia de Buenos Aires" }, + { code: "505", ispb: "32062580", name: "Banco Credit Suisse (Brasil) S.A." }, + { code: "506", ispb: "42066258", name: "RJI CORRETORA DE TITULOS E VALORES MOBILIARIOS LTDA" }, + { + code: "507", + ispb: "37229413", + name: "SOCIEDADE DE CRÉDITO, FINANCIAMENTO E INVESTIMENTO EFÍ S.A.", + }, + { + code: "508", + ispb: "61384004", + name: "AVENUE SECURITIES DISTRIBUIDORA DE TÍTULOS E VALORES MOBILIÁRIOS LTDA.", + }, + { code: "509", ispb: "13935893", name: "CELCOIN INSTITUICAO DE PAGAMENTO S.A." }, + { code: "510", ispb: "39738065", name: "FFCRED SOCIEDADE DE CRÉDITO DIRETO S.A.." }, + { code: "511", ispb: "44683140", name: "MAGNUM SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { + code: "512", + ispb: "36266751", + name: "FINVEST DISTRIBUIDORA DE TÍTULOS E VALORES MOBILIÁRIOS LTDA.", + }, + { code: "513", ispb: "44728700", name: "ATF CREDIT SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { code: "516", ispb: "36583700", name: "QISTA S.A. - CRÉDITO, FINANCIAMENTO E INVESTIMENTO" }, + { + code: "518", + ispb: "37679449", + name: "MERCADO CRÉDITO SOCIEDADE DE CRÉDITO, FINANCIAMENTO E INVESTIMENTO S.A.", + }, + { + code: "519", + ispb: "40768766", + name: "LIONS TRUST DISTRIBUIDORA DE TÍTULOS E VALORES MOBILIÁRIOS LTDA.", + }, + { code: "521", ispb: "44019481", name: "PEAK SOCIEDADE DE EMPRÉSTIMO ENTRE PESSOAS S.A." }, + { code: "522", ispb: "47593544", name: "RED SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { code: "523", ispb: "44292580", name: "HR DIGITAL - SOCIEDADE DE CRÉDITO DIRETO S/A" }, + { + code: "524", + ispb: "45854066", + name: "WNT CAPITAL DISTRIBUIDORA DE TÍTULOS E VALORES MOBILIÁRIOS S.A.", + }, + { code: "525", ispb: "34265629", name: "INTERCAM CORRETORA DE CÂMBIO LTDA." }, + { code: "526", ispb: "46026562", name: "MONETARIE SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { code: "527", ispb: "44478623", name: "ATICCA - SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { + code: "528", + ispb: "34829992", + name: "REAG DISTRIBUIDORA DE TÍTULOS E VALORES MOBILIÁRIOS S.A.", + }, + { code: "529", ispb: "17079937", name: "PINBANK BRASIL INSTITUIÇÃO DE PAGAMENTO S.A." }, + { code: "530", ispb: "47873449", name: "SER FINANCE SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { code: "532", ispb: "45745537", name: "EAGLE SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { code: "534", ispb: "00714671", name: "EWALLY INSTITUIÇÃO DE PAGAMENTO S.A." }, + { code: "535", ispb: "39519944", name: "MARÚ SOCIEDADE DE CRÉDITO DIRETO S.A." }, + { code: "536", ispb: "20855875", name: "NEON PAGAMENTOS S.A. - INSTITUIÇÃO DE PAGAMENTO" }, + { + code: "537", + ispb: "45756448", + name: "MICROCASH SOCIEDADE DE CRÉDITO AO MICROEMPREENDEDOR E À EMPRESA DE PEQUENO PORTE", + }, + { + code: "539", + ispb: "00122327", + name: "SANTINVEST S.A. - CREDITO, FINANCIAMENTO E INVESTIMENTOS", + }, + { code: "541", ispb: "00954288", name: "FUNDO GARANTIDOR DE CREDITOS - FGC" }, + { code: "545", ispb: "17352220", name: "SENSO CORRETORA DE CAMBIO E VALORES MOBILIARIOS S.A" }, + { code: "546", ispb: "30980539", name: "U4C INSTITUIÇÃO DE PAGAMENTO S.A." }, + { code: "600", ispb: "59118133", name: "Banco Luso Brasileiro S.A." }, + { code: "604", ispb: "31895683", name: "Banco Industrial do Brasil S.A." }, + { code: "610", ispb: "78626983", name: "Banco VR S.A." }, + { code: "611", ispb: "61820817", name: "Banco Paulista S.A." }, + { code: "612", ispb: "31880826", name: "Banco Guanabara S.A." }, + { code: "613", ispb: "60850229", name: "Omni Banco S.A." }, + { code: "623", ispb: "59285411", name: "Banco Pan S.A." }, + { code: "626", ispb: "61348538", name: "BANCO C6 CONSIGNADO S.A." }, + { code: "630", ispb: "58497702", name: "BANCO LETSBANK S.A." }, + { code: "633", ispb: "68900810", name: "Banco Rendimento S.A." }, + { code: "634", ispb: "17351180", name: "BANCO TRIANGULO S.A." }, + { code: "637", ispb: "60889128", name: "BANCO SOFISA S.A." }, + { code: "643", ispb: "62144175", name: "Banco Pine S.A." }, + { code: "653", ispb: "61024352", name: "BANCO VOITER S.A." }, + { code: "654", ispb: "92874270", name: "BANCO DIGIMAIS S.A." }, + { code: "655", ispb: "59588111", name: "Banco Votorantim S.A." }, + { code: "707", ispb: "62232889", name: "Banco Daycoval S.A." }, + { code: "712", ispb: "78632767", name: "Banco Ourinvest S.A." }, + { code: "720", ispb: "80271455", name: "BANCO RNX S.A." }, + { code: "739", ispb: "00558456", name: "Banco Cetelem S.A." }, + { code: "741", ispb: "00517645", name: "BANCO RIBEIRAO PRETO S.A." }, + { code: "743", ispb: "00795423", name: "Banco Semear S.A." }, + { code: "745", ispb: "33479023", name: "Banco Citibank S.A." }, + { code: "746", ispb: "30723886", name: "Banco Modal S.A." }, + { code: "747", ispb: "01023570", name: "Banco Rabobank International Brasil S.A." }, + { code: "748", ispb: "01181521", name: "BANCO COOPERATIVO SICREDI S.A." }, + { code: "751", ispb: "29030467", name: "Scotiabank Brasil S.A. Banco Múltiplo" }, + { code: "752", ispb: "01522368", name: "Banco BNP Paribas Brasil S.A." }, + { code: "753", ispb: "74828799", name: "Novo Banco Continental S.A. - Banco Múltiplo" }, + { code: "754", ispb: "76543115", name: "Banco Sistema S.A." }, + { code: "755", ispb: "62073200", name: "Bank of America Merrill Lynch Banco Múltiplo S.A." }, + { code: "756", ispb: "02038232", name: "BANCO COOPERATIVO SICOOB S.A. - BANCO SICOOB" }, + { code: "757", ispb: "02318507", name: "BANCO KEB HANA DO BRASIL S.A." }, +]; diff --git a/src/get-bank-by-code/get-bank-by-code.test.ts b/src/get-bank-by-code/get-bank-by-code.test.ts new file mode 100644 index 00000000..2985cb81 --- /dev/null +++ b/src/get-bank-by-code/get-bank-by-code.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "../_internals/test/runtime"; +import { getBankByCode } from "./get-bank-by-code"; + +describe("getBankByCode", () => { + describe("should return null for a negative or fractional number", () => { + test("whose digits would otherwise match a bank", () => { + expect(getBankByCode(-1)).toBeNull(); + expect(getBankByCode(0.01)).toBeNull(); + }); + }); + + describe("should return the bank", () => { + test("when the code is a zero-padded string", () => { + expect(getBankByCode("001")).toEqual({ + code: "001", + ispb: "00000000", + name: "Banco do Brasil S.A.", + }); + }); + + test("when the code is a string without leading zeros", () => { + expect(getBankByCode("1")).toEqual({ + code: "001", + ispb: "00000000", + name: "Banco do Brasil S.A.", + }); + }); + + test("when the code is a number", () => { + expect(getBankByCode(1)).toEqual({ + code: "001", + ispb: "00000000", + name: "Banco do Brasil S.A.", + }); + }); + + test("when the code has a mask", () => { + expect(getBankByCode("0-01")).toEqual({ + code: "001", + ispb: "00000000", + name: "Banco do Brasil S.A.", + }); + }); + + test("for a bank whose code has no leading zeros", () => { + expect(getBankByCode("341")).toEqual({ + code: "341", + ispb: "60701190", + name: "ITAÚ UNIBANCO S.A.", + }); + }); + }); + + test("should return a fresh copy that does not affect subsequent calls when mutated", () => { + const bank = getBankByCode("001"); + if (bank) bank.name = "mutated"; + expect(getBankByCode("001")?.name).not.toBe("mutated"); + }); + + describe("should return null", () => { + test("when no bank has that code", () => { + expect(getBankByCode("999")).toBeNull(); + }); + + test("when the code is longer than 3 digits", () => { + expect(getBankByCode("00001")).toBeNull(); + }); + + test("when the code sanitizes to an empty string", () => { + expect(getBankByCode("abc")).toBeNull(); + }); + + test("when the code is an empty string", () => { + expect(getBankByCode("")).toBeNull(); + }); + + test("when it is null", () => { + // @ts-expect-error + expect(getBankByCode(null)).toBeNull(); + }); + + test("when it is undefined", () => { + // @ts-expect-error + expect(getBankByCode(undefined)).toBeNull(); + }); + + test("when it is a boolean", () => { + // @ts-expect-error + expect(getBankByCode(true)).toBeNull(); + }); + + test("when it is an object", () => { + // @ts-expect-error + expect(getBankByCode({})).toBeNull(); + }); + + test("when it is an array", () => { + // @ts-expect-error + expect(getBankByCode([])).toBeNull(); + }); + }); +}); diff --git a/src/get-bank-by-code/get-bank-by-code.ts b/src/get-bank-by-code/get-bank-by-code.ts new file mode 100644 index 00000000..51f6c899 --- /dev/null +++ b/src/get-bank-by-code/get-bank-by-code.ts @@ -0,0 +1,40 @@ +import { BANKS, type Bank } from "../_internals/constants/banks"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; + +const CODE_LENGTH = 3; + +/** + * Looks up a Brazilian bank by its compensation code (COMPE), published by Banco Central do + * Brasil in the STR (Sistema de Transferência de Reservas) participants list. + * + * @param {string|number} code - The bank's COMPE code, with or without leading zeros. + * @returns {Bank|null} A fresh copy of the matching bank, or `null` when no bank has that code. + * + * @example + * ```typescript + * getBankByCode("001"); // { code: "001", ispb: "00000000", name: "Banco do Brasil S.A." } + * getBankByCode(1); // { code: "001", ispb: "00000000", name: "Banco do Brasil S.A." } + * getBankByCode("999"); // null + * ``` + * + * @see Official: https://www.bcb.gov.br/pom/spb/estatistica/port/ParticipantesSTRport.csv + * @see Based on: https://brasilapi.com.br/api/banks/v1 Fallback source used by the dataset + * generator (`scripts/banks.ts`) when the Bacen CSV request fails. + */ +export const getBankByCode = (code: string | number): Bank | null => { + if (isNullish(code) || (typeof code !== "string" && typeof code !== "number")) return null; + + // Stryker disable next-line EqualityOperator: no institution has COMPE code 000, so 0 and a negative number both resolve to null. + if (typeof code === "number" && (!Number.isInteger(code) || code < 0)) return null; + + const digits = sanitizeToDigits(code); + + if (digits.length === 0 || digits.length > CODE_LENGTH) return null; + + const normalizedCode = digits.padStart(CODE_LENGTH, "0"); + + const bank = BANKS.find((candidate) => candidate.code === normalizedCode); + + return bank ? { ...bank } : null; +}; diff --git a/src/get-bank-by-ispb/get-bank-by-ispb.test.ts b/src/get-bank-by-ispb/get-bank-by-ispb.test.ts new file mode 100644 index 00000000..a6fe9f5f --- /dev/null +++ b/src/get-bank-by-ispb/get-bank-by-ispb.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "../_internals/test/runtime"; +import { getBankByIspb } from "./get-bank-by-ispb"; + +describe("getBankByIspb", () => { + describe("should return null for a negative or fractional number", () => { + test("whose digits would otherwise match a bank", () => { + expect(getBankByIspb(-208)).toBeNull(); + expect(getBankByIspb(2.08)).toBeNull(); + }); + + test("but still resolve zero, the ISPB of Banco do Brasil", () => { + expect(getBankByIspb(0)?.code).toBe("001"); + }); + }); + + describe("should return the bank", () => { + test("when the ispb is a zero-padded string", () => { + expect(getBankByIspb("00000000")).toEqual({ + code: "001", + ispb: "00000000", + name: "Banco do Brasil S.A.", + }); + }); + + test("when the ispb is a string without leading zeros", () => { + expect(getBankByIspb("0")).toEqual({ + code: "001", + ispb: "00000000", + name: "Banco do Brasil S.A.", + }); + }); + + test("when the ispb is a number", () => { + expect(getBankByIspb(0)).toEqual({ + code: "001", + ispb: "00000000", + name: "Banco do Brasil S.A.", + }); + }); + + test("when the ispb has a mask", () => { + expect(getBankByIspb("0000-0000")).toEqual({ + code: "001", + ispb: "00000000", + name: "Banco do Brasil S.A.", + }); + }); + + test("for a bank whose ispb has no leading zeros", () => { + expect(getBankByIspb("60701190")).toEqual({ + code: "341", + ispb: "60701190", + name: "ITAÚ UNIBANCO S.A.", + }); + }); + }); + + test("should return a fresh copy that does not affect subsequent calls when mutated", () => { + const bank = getBankByIspb("00000000"); + if (bank) bank.name = "mutated"; + expect(getBankByIspb("00000000")?.name).not.toBe("mutated"); + }); + + describe("should return null", () => { + test("when no bank has that ispb", () => { + expect(getBankByIspb("99999999")).toBeNull(); + }); + + test("when the ispb is longer than 8 digits", () => { + expect(getBankByIspb("0000000000")).toBeNull(); + }); + + test("when the ispb sanitizes to an empty string", () => { + expect(getBankByIspb("abc")).toBeNull(); + }); + + test("when the ispb is an empty string", () => { + expect(getBankByIspb("")).toBeNull(); + }); + + test("when it is null", () => { + // @ts-expect-error + expect(getBankByIspb(null)).toBeNull(); + }); + + test("when it is undefined", () => { + // @ts-expect-error + expect(getBankByIspb(undefined)).toBeNull(); + }); + + test("when it is a boolean", () => { + // @ts-expect-error + expect(getBankByIspb(true)).toBeNull(); + }); + + test("when it is an object", () => { + // @ts-expect-error + expect(getBankByIspb({})).toBeNull(); + }); + + test("when it is an array", () => { + // @ts-expect-error + expect(getBankByIspb([])).toBeNull(); + }); + }); +}); diff --git a/src/get-bank-by-ispb/get-bank-by-ispb.ts b/src/get-bank-by-ispb/get-bank-by-ispb.ts new file mode 100644 index 00000000..d341a368 --- /dev/null +++ b/src/get-bank-by-ispb/get-bank-by-ispb.ts @@ -0,0 +1,43 @@ +import { BANKS, type Bank } from "../_internals/constants/banks"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; + +const ISPB_LENGTH = 8; + +/** + * Looks up a Brazilian bank by its ISPB (Identificador do Sistema de Pagamentos Brasileiro), + * the 8 digit code that identifies every participant of the SPB, published by Banco Central do + * Brasil in the STR (Sistema de Transferência de Reservas) participants list. Unlike the COMPE + * code (`getBankByCode`), every SPB participant has an ISPB, including institutions with no + * COMPE code of their own. + * + * @param {string|number} value - The bank's ISPB, with or without leading zeros. + * @returns {Bank|null} A fresh copy of the matching bank, or `null` when no bank has that ISPB. + * + * @example + * ```typescript + * getBankByIspb("00000000"); // { code: "001", ispb: "00000000", name: "Banco do Brasil S.A." } + * getBankByIspb(0); // { code: "001", ispb: "00000000", name: "Banco do Brasil S.A." } + * getBankByIspb("60701190"); // { code: "341", ispb: "60701190", name: "ITAÚ UNIBANCO S.A." } + * getBankByIspb("99999999"); // null + * ``` + * + * @see Official: https://www.bcb.gov.br/pom/spb/estatistica/port/ParticipantesSTRport.csv + * @see Based on: https://brasilapi.com.br/api/banks/v1 Fallback source used by the dataset + * generator (`scripts/banks.ts`) when the Bacen CSV request fails. + */ +export const getBankByIspb = (value: string | number): Bank | null => { + if (isNullish(value) || (typeof value !== "string" && typeof value !== "number")) return null; + + if (typeof value === "number" && (!Number.isInteger(value) || value < 0)) return null; + + const digits = sanitizeToDigits(value); + + if (digits.length === 0 || digits.length > ISPB_LENGTH) return null; + + const normalizedIspb = digits.padStart(ISPB_LENGTH, "0"); + + const bank = BANKS.find((candidate) => candidate.ispb === normalizedIspb); + + return bank ? { ...bank } : null; +}; diff --git a/src/get-banks/get-banks.test.ts b/src/get-banks/get-banks.test.ts new file mode 100644 index 00000000..a7f9ff92 --- /dev/null +++ b/src/get-banks/get-banks.test.ts @@ -0,0 +1,35 @@ +import { BANKS } from "../_internals/constants/banks"; +import { describe, expect, it } from "../_internals/test/runtime"; +import { getBanks } from "./get-banks"; + +describe("getBanks", () => { + it("should return every bank", () => { + expect(getBanks()).toHaveLength(BANKS.length); + }); + + it("should include Banco do Brasil", () => { + expect(getBanks()).toContainEqual({ + code: "001", + ispb: "00000000", + name: "Banco do Brasil S.A.", + }); + }); + + it("should include Itaú Unibanco", () => { + expect(getBanks()).toContainEqual({ + code: "341", + ispb: "60701190", + name: "ITAÚ UNIBANCO S.A.", + }); + }); + + it("should return a fresh array on every call", () => { + expect(getBanks()).not.toBe(getBanks()); + }); + + it("should return fresh objects that do not affect subsequent calls when mutated", () => { + const banks = getBanks(); + banks[0].name = "mutated"; + expect(getBanks()[0].name).not.toBe("mutated"); + }); +}); diff --git a/src/get-banks/get-banks.ts b/src/get-banks/get-banks.ts new file mode 100644 index 00000000..b135296a --- /dev/null +++ b/src/get-banks/get-banks.ts @@ -0,0 +1,21 @@ +import { BANKS, type Bank } from "../_internals/constants/banks"; + +/** + * Returns every Brazilian bank with a compensation code (COMPE), published by Banco Central + * do Brasil in the STR (Sistema de Transferência de Reservas) participants list. + * + * Each call returns a fresh array of fresh objects, so mutating the result never affects the + * underlying data or subsequent calls. + * + * @returns {Bank[]} Every known bank, in a fixed table order. + * + * @example + * ```typescript + * getBanks()[0]; // { code: "001", ispb: "00000000", name: "Banco do Brasil S.A." } + * ``` + * + * @see Official: https://www.bcb.gov.br/pom/spb/estatistica/port/ParticipantesSTRport.csv + * @see Based on: https://brasilapi.com.br/api/banks/v1 Fallback source used by the dataset + * generator (`scripts/banks.ts`) when the Bacen CSV request fails. + */ +export const getBanks = (): Bank[] => BANKS.map((bank) => ({ ...bank })); diff --git a/src/is-valid-bank-account/constants.ts b/src/is-valid-bank-account/constants.ts new file mode 100644 index 00000000..c5afee4d --- /dev/null +++ b/src/is-valid-bank-account/constants.ts @@ -0,0 +1,81 @@ +/** + * Weights, lookup tables and bank code lists used by the per bank account check digit rules. + * The weights come from the "Regras de Validação de dígito verificador de agência e conta + * corrente" compendium and were cross checked against independent open source validators. + * + * @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, mirrored in this repo. + * @see Based on: https://github.com/ajmiciano/banktools-br/tree/master/lib/banktools-br/banks + * @see Based on: https://github.com/luizalabs/heimdall/blob/main/heimdall_valid_bank/calculate_number_account.py + * @see Based on: https://github.com/Xerpa/bran_checker/tree/master/lib/banks + */ + +export const COMPE_CODES = + "001003004007010011012014015016017018021024025029033036037040041047060062063064065066069070" + + "074075076077078079080081082083084085088089093094095096097098099100101102104105107111113114" + + "117119120121122124125126127128129130131132133134136138139140141142143144145146149157159173" + + "174177180183184188189190191194195196197208212213217218222224233237241243246249250253254259" + + "260265266268269270271272273274276278279280281283285288289290292293296298299300301306307309" + + "310311312313318319320321322323324325326328329330331332334335336340341342343348349350352355" + + "358359360362363364365366367368370371373374376377378379380381382383384385386387389390391393" + + "394395396397398399400401402403404406407408410411412413414416418419421422423425426427428429" + + "430433435438439440442443444445447448449450451452454455456457458459460461462463464465467468" + + "469470471473475477478479481482484487488495505506507508509510511512513516518519521522523524" + + "525526527528529530532534535536537539541545546600604610611612613623626630633634637643653654" + + "655707712720739741743745746747748751752753754755756757"; + +export const SANTANDER_WEIGHTS = [9, 7, 3, 1, 0, 0, 9, 7, 1, 3, 1, 9, 7, 3]; + +export const BANRISUL_ACCOUNT_WEIGHTS = [3, 2, 4, 7, 6, 5, 4, 3, 2]; + +export const HSBC_AGENCY_ACCOUNT_WEIGHTS = [8, 9, 2, 3, 4, 5, 6, 7, 8, 9]; + +export const CITIBANK_ACCOUNT_WEIGHTS = [11, 10, 9, 8, 7, 6, 5, 4, 3, 2]; + +export const VERHOEFF_MULTIPLICATION = [ + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + [1, 2, 3, 4, 0, 6, 7, 8, 9, 5], + [2, 3, 4, 0, 1, 7, 8, 9, 5, 6], + [3, 4, 0, 1, 2, 8, 9, 5, 6, 7], + [4, 0, 1, 2, 3, 9, 5, 6, 7, 8], + [5, 9, 8, 7, 6, 0, 4, 3, 2, 1], + [6, 5, 9, 8, 7, 1, 0, 4, 3, 2], + [7, 6, 5, 9, 8, 2, 1, 0, 4, 3], + [8, 7, 6, 5, 9, 3, 2, 1, 0, 4], + [9, 8, 7, 6, 5, 4, 3, 2, 1, 0], +]; + +export const VERHOEFF_PERMUTATION = [ + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + [1, 5, 7, 6, 2, 8, 3, 0, 9, 4], + [5, 8, 0, 3, 7, 9, 6, 1, 4, 2], + [8, 9, 1, 6, 0, 4, 3, 5, 2, 7], + [9, 4, 5, 3, 1, 2, 6, 8, 7, 0], + [4, 2, 8, 6, 5, 7, 3, 9, 0, 1], + [2, 7, 9, 3, 8, 0, 6, 4, 1, 5], + [7, 0, 4, 6, 9, 1, 3, 2, 5, 8], +]; + +export const VERHOEFF_INVERSE = [0, 4, 3, 2, 1, 5, 6, 7, 8, 9]; + +export const STRUCTURE_ONLY_BANK_CODES = [ + "077", + "085", + "102", + "136", + "197", + "208", + "212", + "290", + "318", + "323", + "336", + "380", + "403", + "623", + "655", + "707", + "746", + "748", + "756", +]; 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 1bc3dfcc..e0f44f4b 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 @@ -1,8 +1,25 @@ +import { BANKS } from "../_internals/constants/banks"; import { describe, expect, test } from "../_internals/test/runtime"; +import { COMPE_CODES } from "./constants"; import { isValidBankAccount } from "./is-valid-bank-account"; describe("isValidBankAccount", () => { describe("should return false", () => { + test("when params is null", () => { + // @ts-expect-error + expect(isValidBankAccount(null)).toBe(false); + }); + + test("when params is undefined", () => { + // @ts-expect-error + expect(isValidBankAccount(undefined)).toBe(false); + }); + + test("when params is not an object", () => { + // @ts-expect-error + expect(isValidBankAccount("001")).toBe(false); + }); + test("when bankCode is an empty string", () => { expect( isValidBankAccount({ @@ -418,22 +435,20 @@ describe("isValidBankAccount", () => { describe("should return true", () => { describe("for valid inputs with generic validation", () => { - test("when all fields are valid strings with mod10", () => { - // mod10("123456") = 6 + test('when the digit matches mod10 (mod10("123456") = 6)', () => { expect( isValidBankAccount({ - bankCode: "999", + bankCode: "246", agency: "1234", account: "123456", digit: "6", }), ).toBe(true); }); - test("when all fields are valid strings with mod11", () => { - // mod11("123456") = 1 + test('when the digit matches mod11 (mod11("123456") = 1)', () => { expect( isValidBankAccount({ - bankCode: "999", + bankCode: "246", agency: "1234", account: "123456", digit: "1", @@ -446,7 +461,7 @@ describe("isValidBankAccount", () => { test("should sanitize and validate", () => { expect( isValidBankAccount({ - bankCode: "999", + bankCode: "246", agency: "123-4", account: "123.456-78", digit: "2", @@ -454,5 +469,842 @@ describe("isValidBankAccount", () => { ).toBe(true); }); }); + + describe("Banco do Brasil (001)", () => { + test("when account 00210169 has the correct check digit 6 (sum 60, remainder 5, 11 - 5 = 6)", () => { + expect( + isValidBankAccount({ + bankCode: "001", + agency: "1584", + account: "00210169", + digit: "6", + }), + ).toBe(true); + }); + + test("when account 00020394 has the correct check digit 7 (sum 59, remainder 4, 11 - 4 = 7)", () => { + expect( + isValidBankAccount({ + bankCode: "001", + agency: "5892", + account: "00020394", + digit: "7", + }), + ).toBe(true); + }); + + test("when the check digit is X for account 00189062 (sum 122, remainder 1, 11 - 1 = 10 -> X) and false when the digit is 0", () => { + expect( + isValidBankAccount({ + bankCode: "001", + agency: "7138", + account: "00189062", + digit: "X", + }), + ).toBe(true); + + expect( + isValidBankAccount({ + bankCode: "001", + agency: "7138", + account: "00189062", + digit: "0", + }), + ).toBe(false); + }); + + test("when the check digit is 0 for account 10089939 (sum 165, remainder 0), unlike the boleto variant which maps remainder 0 to 1", () => { + expect( + isValidBankAccount({ + bankCode: "001", + agency: "1234", + account: "10089939", + digit: "0", + }), + ).toBe(true); + + expect( + isValidBankAccount({ + bankCode: "001", + agency: "1234", + account: "10089939", + digit: "1", + }), + ).toBe(false); + }); + }); + + describe("Itaú (341)", () => { + test("when the account check digit is correct (agency 2545 + account 02366, mod10 sum 39, remainder 9, 10 - 9 = 1)", () => { + expect( + isValidBankAccount({ + bankCode: "341", + agency: "2545", + account: "02366", + digit: "1", + }), + ).toBe(true); + }); + + test("when the check digit is 0 for agency 1874 + account 10009 (sum 30, remainder 0)", () => { + expect( + isValidBankAccount({ + bankCode: "341", + agency: "1874", + account: "10009", + digit: "0", + }), + ).toBe(true); + }); + }); + + describe("Bradesco (237)", () => { + test("when account 0238069 has the correct check digit 2 (mod11 sum 108, remainder 9, 11 - 9 = 2)", () => { + expect( + isValidBankAccount({ + bankCode: "237", + agency: "1234", + account: "0238069", + digit: "2", + }), + ).toBe(true); + }); + + test("when account 0284025 has the correct check digit 1 (sum 98, remainder 10, 11 - 10 = 1)", () => { + expect( + isValidBankAccount({ + bankCode: "237", + agency: "1234", + account: "0284025", + digit: "1", + }), + ).toBe(true); + }); + + test("when the check digit is 0 for account 0325620 (sum 88, remainder 0)", () => { + expect( + isValidBankAccount({ + bankCode: "237", + agency: "1234", + account: "0325620", + digit: "0", + }), + ).toBe(true); + }); + + test("when the check digit is P for account 0301357 (sum 67, remainder 1), also accepted rendered as 0, and false when the digit is 1", () => { + expect( + isValidBankAccount({ + bankCode: "237", + agency: "1234", + account: "0301357", + digit: "P", + }), + ).toBe(true); + + expect( + isValidBankAccount({ + bankCode: "237", + agency: "1234", + account: "0301357", + digit: "0", + }), + ).toBe(true); + + expect( + isValidBankAccount({ + bankCode: "237", + agency: "1234", + account: "0301357", + digit: "1", + }), + ).toBe(false); + }); + }); + + describe("Santander (033)", () => { + test("when the account check digit is correct (agency 0189 + account 01017417, weighted sum with tens discarded is 51, remainder 1, 10 - 1 = 9)", () => { + expect( + isValidBankAccount({ + bankCode: "033", + agency: "0189", + account: "01017417", + digit: "9", + }), + ).toBe(true); + }); + + test("when agency 3414 + account 01092006 has the correct check digit 4 (sum 46, remainder 6, 10 - 6 = 4)", () => { + expect( + isValidBankAccount({ + bankCode: "033", + agency: "3414", + account: "01092006", + digit: "4", + }), + ).toBe(true); + }); + }); + + describe("Caixa (104)", () => { + test("when the account check digit is correct (agency 0647 + account 00188888888, weighted sum 455, x10 = 4550, remainder 7)", () => { + expect( + isValidBankAccount({ + bankCode: "104", + agency: "0647", + account: "00188888888", + digit: "7", + }), + ).toBe(true); + }); + + test("when agency 2004 + account 00100000448 has the correct check digit 6 (sum 82, x10 = 820, remainder 6)", () => { + expect( + isValidBankAccount({ + bankCode: "104", + agency: "2004", + account: "00100000448", + digit: "6", + }), + ).toBe(true); + }); + + test("when the calculated digit is the exceptional 10 for account 00000000006 (bank variant mod11 remainder 1), rendered as 0", () => { + expect( + isValidBankAccount({ + bankCode: "104", + agency: "0000", + account: "00000000006", + digit: "0", + }), + ).toBe(true); + }); + }); + + describe("generic validation with a two digit check", () => { + test('when the digit matches mod10 followed by mod11 (mod10("123456") = 6, mod11 over "1234566" sum 121, remainder 0) and false for a wrong second digit', () => { + expect( + isValidBankAccount({ + bankCode: "246", + agency: "1234", + account: "123456", + digit: "60", + }), + ).toBe(true); + + expect( + isValidBankAccount({ + bankCode: "246", + agency: "1234", + account: "123456", + digit: "66", + }), + ).toBe(false); + }); + + test('when the second digit is the exceptional 10 (mod10("000016") = 6, bank variant mod11 over "0000166" has remainder 1), rendered as 0', () => { + expect( + isValidBankAccount({ + bankCode: "246", + agency: "1234", + account: "000016", + digit: "60", + }), + ).toBe(true); + }); + + test('when only the bank variant of mod11 matches (mod10 and the boleto variant of mod11 both give 1 for "000014", but the bank variant gives 0)', () => { + expect( + isValidBankAccount({ + bankCode: "246", + agency: "1234", + account: "000014", + digit: "0", + }), + ).toBe(true); + }); + }); + }); + + describe("bank code registry", () => { + test("should return false when the bank code is not in the Banco Central STR participants list", () => { + expect( + isValidBankAccount({ + bankCode: "999", + agency: "1234", + account: "123456", + digit: "6", + }), + ).toBe(false); + }); + + test("should return false for another unassigned bank code that would otherwise pass the generic check", () => { + expect( + isValidBankAccount({ + bankCode: "998", + agency: "1234", + account: "123456", + digit: "1", + }), + ).toBe(false); + }); + + test("should return true for a listed bank that has no published algorithm, using the generic check", () => { + expect( + isValidBankAccount({ + bankCode: "246", + agency: "1234", + account: "123456", + digit: "6", + }), + ).toBe(true); + }); + }); + + describe("Banrisul (041)", () => { + test("should return true for agency 2664 and account 35.850767.0-6 (banktools-br banrisul/account_spec.rb)", () => { + expect( + isValidBankAccount({ + bankCode: "041", + agency: "2664", + account: "358507670", + digit: "6", + }), + ).toBe(true); + }); + + test("should return true for agency 1234 and account 358507671-8 (daniel-dia/br-bank-account-validator banrisul_validator.spec.ts)", () => { + expect( + isValidBankAccount({ + bankCode: "041", + agency: "1234", + account: "358507671", + digit: "8", + }), + ).toBe(true); + }); + + test("should return false for account 35.850767.0-3 (banktools-br banrisul/account_spec.rb invalid digit)", () => { + expect( + isValidBankAccount({ + bankCode: "041", + agency: "2664", + account: "358507670", + digit: "3", + }), + ).toBe(false); + }); + + test("should return false for account 358507671-0 (daniel-dia/br-bank-account-validator banrisul_validator.spec.ts invalid digit)", () => { + expect( + isValidBankAccount({ + bankCode: "041", + agency: "1234", + account: "358507671", + digit: "0", + }), + ).toBe(false); + }); + + test("should return true when the weighted sum is a multiple of 11, where the digit is 0", () => { + expect( + isValidBankAccount({ + bankCode: "041", + agency: "1234", + account: "100000004", + digit: "0", + }), + ).toBe(true); + }); + + test("should return false when the account does not have 9 digits", () => { + expect( + isValidBankAccount({ + bankCode: "041", + agency: "1234", + account: "35850767", + digit: "6", + }), + ).toBe(false); + }); + + test("should return false when the agency does not have 4 digits", () => { + expect( + isValidBankAccount({ + bankCode: "041", + agency: "266", + account: "358507670", + digit: "6", + }), + ).toBe(false); + }); + + test("should return false when the digit has 2 characters", () => { + expect( + isValidBankAccount({ + bankCode: "041", + agency: "2664", + account: "358507670", + digit: "60", + }), + ).toBe(false); + }); + }); + + describe("HSBC / Kirton Bank (399)", () => { + test("should return true for agency 0007 and account 853838-6 (Icatu compendium example, also banktools-br hsbc/account_spec.rb)", () => { + expect( + isValidBankAccount({ + bankCode: "399", + agency: "0007", + account: "853838", + digit: "6", + }), + ).toBe(true); + }); + + test("should return true for agency 1996 and account 498991-4 (banktools-br hsbc/account_spec.rb)", () => { + expect( + isValidBankAccount({ + bankCode: "399", + agency: "1996", + account: "498991", + digit: "4", + }), + ).toBe(true); + }); + + test("should return true for agency 1913 and account 104012-0 (banktools-br hsbc/account_spec.rb)", () => { + expect( + isValidBankAccount({ + bankCode: "399", + agency: "1913", + account: "104012", + digit: "0", + }), + ).toBe(true); + }); + + test("should return false for agency 0007 and account 853838-7 (banktools-br hsbc/account_spec.rb invalid digit)", () => { + expect( + isValidBankAccount({ + bankCode: "399", + agency: "0007", + account: "853838", + digit: "7", + }), + ).toBe(false); + }); + + test("should return false for agency 1996 and account 498991-5 (banktools-br hsbc/account_spec.rb invalid digit)", () => { + expect( + isValidBankAccount({ + bankCode: "399", + agency: "1996", + account: "498991", + digit: "5", + }), + ).toBe(false); + }); + + test("should return true when the remainder is 10, where the digit is 0", () => { + expect( + isValidBankAccount({ + bankCode: "399", + agency: "0000", + account: "000006", + digit: "0", + }), + ).toBe(true); + }); + + test("should return false when the account does not have 6 digits", () => { + expect( + isValidBankAccount({ + bankCode: "399", + agency: "0007", + account: "85383", + digit: "6", + }), + ).toBe(false); + }); + }); + + describe("Citibank (745)", () => { + test("should return true for agency 0075 and account 0007500465-8 (Icatu compendium example, also banktools-br citybank/account_spec.rb)", () => { + expect( + isValidBankAccount({ + bankCode: "745", + agency: "0075", + account: "0007500465", + digit: "8", + }), + ).toBe(true); + }); + + test("should return true for agency 0001 and account 2000967610-4 (banktools-br citybank/account_spec.rb)", () => { + expect( + isValidBankAccount({ + bankCode: "745", + agency: "0001", + account: "2000967610", + digit: "4", + }), + ).toBe(true); + }); + + test("should return true for agency 0062 and account 2574827866-9 (banktools-br citybank/account_spec.rb)", () => { + expect( + isValidBankAccount({ + bankCode: "745", + agency: "0062", + account: "2574827866", + digit: "9", + }), + ).toBe(true); + }); + + test("should return false for agency 0075 and account 0007500465-2 (banktools-br citybank/account_spec.rb invalid digit)", () => { + expect( + isValidBankAccount({ + bankCode: "745", + agency: "0075", + account: "0007500465", + digit: "2", + }), + ).toBe(false); + }); + + test("should return false for agency 0001 and account 2000967610-1 (banktools-br citybank/account_spec.rb invalid digit)", () => { + expect( + isValidBankAccount({ + bankCode: "745", + agency: "0001", + account: "2000967610", + digit: "1", + }), + ).toBe(false); + }); + + test("should return true when the remainder is 0 or 1, where the digit is 0 in both cases", () => { + expect( + isValidBankAccount({ + bankCode: "745", + agency: "0075", + account: "1000000000", + digit: "0", + }), + ).toBe(true); + + expect( + isValidBankAccount({ + bankCode: "745", + agency: "0075", + account: "1000000006", + digit: "0", + }), + ).toBe(true); + }); + + test("should return false when the account does not have 10 digits", () => { + expect( + isValidBankAccount({ + bankCode: "745", + agency: "0075", + account: "007500465", + digit: "8", + }), + ).toBe(false); + }); + }); + + describe("Nubank (260)", () => { + test("should return true for agency 0001 and account 5216125-0 (Xerpa/bran_checker nubank_test.exs)", () => { + expect( + isValidBankAccount({ + bankCode: "260", + agency: "0001", + account: "5216125", + digit: "0", + }), + ).toBe(true); + }); + + test("should return true for agency 0001 and account 1699629-9 (Xerpa/bran_checker nubank_test.exs)", () => { + expect( + isValidBankAccount({ + bankCode: "260", + agency: "0001", + account: "1699629", + digit: "9", + }), + ).toBe(true); + }); + + test("should return true for the 8 digit account 96805203-6 (Xerpa/bran_checker nubank_test.exs)", () => { + expect( + isValidBankAccount({ + bankCode: "260", + agency: "0001", + account: "96805203", + digit: "6", + }), + ).toBe(true); + }); + + test("should return true for the zero padded account 00076832060-9, where the leading zeros are dropped (Xerpa/bran_checker nubank_test.exs)", () => { + expect( + isValidBankAccount({ + bankCode: "260", + agency: "0001", + account: "00076832060", + digit: "9", + }), + ).toBe(true); + }); + + test("should return false for account 5216125-1 (Xerpa/bran_checker nubank_test.exs invalid digit)", () => { + expect( + isValidBankAccount({ + bankCode: "260", + agency: "0001", + account: "5216125", + digit: "1", + }), + ).toBe(false); + }); + + test("should return false for account 1699629-0 (Xerpa/bran_checker nubank_test.exs invalid digit)", () => { + expect( + isValidBankAccount({ + bankCode: "260", + agency: "0001", + account: "96805203", + digit: "3", + }), + ).toBe(false); + }); + + test("should return false when the agency does not have 4 digits", () => { + expect( + isValidBankAccount({ + bankCode: "260", + agency: "001", + account: "5216125", + digit: "0", + }), + ).toBe(false); + }); + + test("should return false when the account has less than 5 digits", () => { + expect( + isValidBankAccount({ + bankCode: "260", + agency: "0001", + account: "5216", + digit: "0", + }), + ).toBe(false); + }); + }); + + describe("banks validated by structure only", () => { + test("should return true for a Banco Inter (077) account that matches the documented format", () => { + expect( + isValidBankAccount({ + bankCode: "077", + agency: "0001", + account: "123456789", + digit: "0", + }), + ).toBe(true); + }); + + test("should return true for a C6 (336) account, which publishes no check digit rule", () => { + expect( + isValidBankAccount({ + bankCode: "336", + agency: "0001", + account: "1792706", + digit: "4", + }), + ).toBe(true); + }); + + test("should return true for a Sicoob (756) account with any check digit", () => { + expect( + isValidBankAccount({ + bankCode: "756", + agency: "3005", + account: "1234567", + digit: "9", + }), + ).toBe(true); + }); + + test("should return false when the check digit is not numeric", () => { + expect( + isValidBankAccount({ + bankCode: "077", + agency: "0001", + account: "123456789", + digit: "X", + }), + ).toBe(false); + }); + + test("should return false when the check digit has 2 characters", () => { + expect( + isValidBankAccount({ + bankCode: "077", + agency: "0001", + account: "123456789", + digit: "01", + }), + ).toBe(false); + }); + }); + + describe("reference vectors of the already supported banks", () => { + test("should return true for Banco do Brasil agency 5725 and account 01055025-9 (banktools-br bb/account_spec.rb)", () => { + expect( + isValidBankAccount({ + bankCode: "001", + agency: "5725", + account: "01055025", + digit: "9", + }), + ).toBe(true); + }); + + test("should return false for Banco do Brasil agency 0647 and account 01226990-7 (banktools-br bb/account_spec.rb invalid digit)", () => { + expect( + isValidBankAccount({ + bankCode: "001", + agency: "0647", + account: "01226990", + digit: "7", + }), + ).toBe(false); + }); + + test("should return true for Bradesco agency 3295 and account 0284.025-1 (banktools-br bradesco/account_spec.rb)", () => { + expect( + isValidBankAccount({ + bankCode: "237", + agency: "3295", + account: "0284025", + digit: "1", + }), + ).toBe(true); + }); + + test("should return false for Bradesco agency 1425 and account 0238.069-3 (banktools-br bradesco/account_spec.rb invalid digit)", () => { + expect( + isValidBankAccount({ + bankCode: "237", + agency: "1425", + account: "0238069", + digit: "3", + }), + ).toBe(false); + }); + + test("should return true for Caixa agency 1278 and account 00118939153-0 (banktools-br caixa_economica/account_spec.rb)", () => { + expect( + isValidBankAccount({ + bankCode: "104", + agency: "1278", + account: "00118939153", + digit: "0", + }), + ).toBe(true); + }); + + test("should return false for Caixa agency 2933 and account 00197787120-2 (banktools-br caixa_economica/account_spec.rb invalid digit)", () => { + expect( + isValidBankAccount({ + bankCode: "104", + agency: "2933", + account: "00197787120", + digit: "2", + }), + ).toBe(false); + }); + + test("should return true for Itau agency 4313 and account 43129-0 (Xerpa/bran_checker itau_test.exs)", () => { + expect( + isValidBankAccount({ + bankCode: "341", + agency: "4313", + account: "43129", + digit: "0", + }), + ).toBe(true); + }); + + test("should return false for Itau agency 4313 and account 43129-9 (Xerpa/bran_checker itau_test.exs invalid digit)", () => { + expect( + isValidBankAccount({ + bankCode: "341", + agency: "4313", + account: "43129", + digit: "9", + }), + ).toBe(false); + }); + + test("should return true for Santander agency 0092 and account 46535495-0 (Xerpa/bran_checker santander_test.exs)", () => { + expect( + isValidBankAccount({ + bankCode: "033", + agency: "0092", + account: "46535495", + digit: "0", + }), + ).toBe(true); + }); + + test("should return false for Santander agency 0060 and account 01098486-1 (Xerpa/bran_checker santander_test.exs invalid digit)", () => { + expect( + isValidBankAccount({ + bankCode: "033", + agency: "0060", + account: "01098486", + digit: "1", + }), + ).toBe(false); + }); + }); + + describe("COMPE_CODES", () => { + test("should hold every code of the Banco Central STR participants list, in the same order", () => { + expect(COMPE_CODES).toBe(BANKS.map((bank) => bank.code).join("")); + }); + + test("should reject 030, which only appears as a misaligned substring of the concatenated codes", () => { + expect(COMPE_CODES.includes("030")).toBe(true); + expect(BANKS.some((bank) => bank.code === "030")).toBe(false); + + expect( + isValidBankAccount({ + bankCode: "030", + agency: "1234", + account: "123456", + digit: "6", + }), + ).toBe(false); + }); + + test("should accept 003, which is a listed code at an aligned position", () => { + expect( + isValidBankAccount({ + bankCode: "003", + agency: "1234", + account: "123456", + digit: "6", + }), + ).toBe(true); + }); }); }); 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 1fa63d29..2496c2ca 100644 --- a/src/is-valid-bank-account/is-valid-bank-account.ts +++ b/src/is-valid-bank-account/is-valid-bank-account.ts @@ -1,178 +1,280 @@ +import { generateChecksum } from "../_internals/generate-checksum/generate-checksum"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; import { mod10 } from "../_internals/mod10/mod10"; import { mod11 } from "../_internals/mod11/mod11"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { + BANRISUL_ACCOUNT_WEIGHTS, + CITIBANK_ACCOUNT_WEIGHTS, + COMPE_CODES, + HSBC_AGENCY_ACCOUNT_WEIGHTS, + SANTANDER_WEIGHTS, + STRUCTURE_ONLY_BANK_CODES, + VERHOEFF_INVERSE, + VERHOEFF_MULTIPLICATION, + VERHOEFF_PERMUTATION, +} from "./constants"; export type IsValidBankAccountOptions = { + /** Three digit bank code (COMPE), e.g. "001" for Banco do Brasil. */ bankCode: string; + /** Agency number, digits only, without its own check digit. */ agency: string; + /** Account number, digits only, without the check digit. */ account: string; + /** The account check digit, one character. */ digit: string; }; +/** @deprecated Use `IsValidBankAccountOptions` instead. */ export type IsValidBankAccountParams = IsValidBankAccountOptions; -const validateBancoDoBrasil = (params: IsValidBankAccountOptions): boolean => { - const { agency, account, digit } = params; +type BankAccountDigits = (agency: string, account: string) => string[]; - if (agency.length < 4 || agency.length > 5) return false; - if (account.length < 8 || account.length > 10) return false; - if (digit.length !== 1) return false; +type BankAccountRule = { + minAgencyLength: number; + maxAgencyLength: number; + minAccountLength: number; + maxAccountLength: number; + digits: BankAccountDigits | null; +}; + +const bancoDoBrasilDigits: BankAccountDigits = (_agency, account) => { + const digit = mod11(account, { variant: "bank" }); - const accountWithoutLeadingZeros = account.replace(/^0+/, "") || "0"; - const fullNumber = agency.padStart(5, "0") + accountWithoutLeadingZeros.padStart(8, "0"); + return [digit === 10 ? "X" : String(digit)]; +}; + +const santanderDigits: BankAccountDigits = (agency, account) => { + const base = `${agency}00${account}`; let sum = 0; - let weight = 2; - for (let i = fullNumber.length - 1; i >= 0; i--) { - const digitValue = fullNumber.charCodeAt(i) - 48; - sum += digitValue * weight; - weight = weight === 9 ? 2 : weight + 1; - } - const remainder = sum % 11; - const calculatedDigit = remainder === 0 || remainder === 1 ? 0 : 11 - remainder; + for (let i = 0; i < base.length; i++) { + sum += ((base.charCodeAt(i) - 48) * SANTANDER_WEIGHTS[i]) % 10; + } - const digitChar = String(calculatedDigit); - return digitChar === digit; + return [String((10 - (sum % 10)) % 10)]; }; -const validateItau = (params: IsValidBankAccountOptions): boolean => { - const { agency, account, digit } = params; +const banrisulDigits: BankAccountDigits = (_agency, account) => { + const remainder = generateChecksum({ base: account, weight: BANRISUL_ACCOUNT_WEIGHTS }) % 11; - if (agency.length !== 4) return false; - if (account.length !== 5) return false; - if (digit.length !== 1) return false; + if (remainder === 0) return ["0"]; + if (remainder === 1) return ["6"]; - const fullNumber = agency + account; - let sum = 0; - - for (let i = 0; i < fullNumber.length; i++) { - const digitValue = fullNumber.charCodeAt(i) - 48; - const weight = i % 2 === 0 ? 2 : 1; - let result = digitValue * weight; - if (result > 9) { - result = Math.floor(result / 10) + (result % 10); - } - sum += result; - } + return [String(11 - remainder)]; +}; - const remainder = sum % 10; - const calculatedDigit = remainder === 0 ? 0 : 10 - remainder; +const caixaDigits: BankAccountDigits = (agency, account) => { + const digit = mod11(agency + account, { variant: "bank" }); - return String(calculatedDigit) === digit; + return [String(digit === 10 ? 0 : digit)]; }; -const validateBradesco = (params: IsValidBankAccountOptions): boolean => { - const { agency, account, digit } = params; +const bradescoDigits: BankAccountDigits = (_agency, account) => { + const digit = mod11(account, { variant: "bank", maxWeight: 7 }); - if (agency.length !== 4) return false; - if (account.length !== 7) return false; - if (digit.length !== 1) return false; + return digit === 10 ? ["P", "0"] : [String(digit)]; +}; - let sum = 0; - let weight = 2; +const nubankDigits: BankAccountDigits = (_agency, account) => { + const base = account.replace(/^0+(?=\d)/, ""); + + let checksum = 0; - for (let i = account.length - 1; i >= 0; i--) { - const digitValue = account.charCodeAt(i) - 48; - sum += digitValue * weight; - weight = weight === 7 ? 2 : weight + 1; + for (let i = base.length - 1, position = 1; i >= 0; i--, position++) { + const permuted = VERHOEFF_PERMUTATION[position % 8][base.charCodeAt(i) - 48]; + checksum = VERHOEFF_MULTIPLICATION[checksum][permuted]; } - const remainder = sum % 11; - const calculatedDigit = remainder === 0 || remainder === 1 ? 0 : 11 - remainder; + return [String(VERHOEFF_INVERSE[checksum])]; +}; + +const itauDigits: BankAccountDigits = (agency, account) => [String(mod10(agency + account))]; + +const hsbcDigits: BankAccountDigits = (agency, account) => { + const remainder = + generateChecksum({ base: agency + account, weight: HSBC_AGENCY_ACCOUNT_WEIGHTS }) % 11; - return String(calculatedDigit) === digit; + return [String(remainder === 10 ? 0 : remainder)]; }; -const validateSantander = (params: IsValidBankAccountOptions): boolean => { - const { agency, account, digit } = params; +const citibankDigits: BankAccountDigits = (_agency, account) => { + const remainder = generateChecksum({ base: account, weight: CITIBANK_ACCOUNT_WEIGHTS }) % 11; - if (agency.length !== 4) return false; - if (account.length !== 8) return false; - if (digit.length !== 1) return false; + return [String(remainder <= 1 ? 0 : 11 - remainder)]; +}; - const calculatedDigit = mod11(account); - const digitValue = calculatedDigit > 9 ? 0 : calculatedDigit; +const BANK_RULES: Record = { + "001": { + minAgencyLength: 4, + maxAgencyLength: 5, + minAccountLength: 8, + maxAccountLength: 10, + digits: bancoDoBrasilDigits, + }, + "033": { + minAgencyLength: 4, + maxAgencyLength: 4, + minAccountLength: 8, + maxAccountLength: 8, + digits: santanderDigits, + }, + "041": { + minAgencyLength: 4, + maxAgencyLength: 4, + minAccountLength: 9, + maxAccountLength: 9, + digits: banrisulDigits, + }, + "104": { + minAgencyLength: 4, + maxAgencyLength: 4, + minAccountLength: 11, + maxAccountLength: 11, + digits: caixaDigits, + }, + "237": { + minAgencyLength: 4, + maxAgencyLength: 4, + minAccountLength: 7, + maxAccountLength: 7, + digits: bradescoDigits, + }, + "260": { + minAgencyLength: 4, + maxAgencyLength: 4, + minAccountLength: 5, + maxAccountLength: 13, + digits: nubankDigits, + }, + "341": { + minAgencyLength: 4, + maxAgencyLength: 4, + minAccountLength: 5, + maxAccountLength: 5, + digits: itauDigits, + }, + "399": { + minAgencyLength: 4, + maxAgencyLength: 4, + minAccountLength: 6, + maxAccountLength: 6, + digits: hsbcDigits, + }, + "745": { + minAgencyLength: 4, + maxAgencyLength: 4, + minAccountLength: 10, + maxAccountLength: 10, + digits: citibankDigits, + }, +}; - return String(digitValue) === digit; +const STRUCTURE_ONLY_RULE: BankAccountRule = { + minAgencyLength: 1, + maxAgencyLength: 5, + minAccountLength: 1, + maxAccountLength: 13, + digits: null, }; -const validateCaixa = (params: IsValidBankAccountOptions): boolean => { - const { agency, account, digit } = params; +const isListedBankCode = (bankCode: string): boolean => { + for (let i = 0; i < COMPE_CODES.length; i += 3) { + if (COMPE_CODES.startsWith(bankCode, i)) return true; + } - if (agency.length !== 4) return false; - if (account.length !== 11) return false; - if (digit.length !== 1) return false; + return false; +}; + +const findRule = (bankCode: string): BankAccountRule | null => { + if (Object.hasOwn(BANK_RULES, bankCode)) return BANK_RULES[bankCode]; - const accountWithoutDigit = account.substring(0, 10); - const calculatedDigit = mod11(accountWithoutDigit); - const digitValue = calculatedDigit > 9 ? 0 : calculatedDigit; + if (STRUCTURE_ONLY_BANK_CODES.includes(bankCode)) return STRUCTURE_ONLY_RULE; - return String(digitValue) === digit; + return null; }; -const validateGeneric = (params: IsValidBankAccountOptions): boolean => { - const { account, digit } = params; +const validateWithRule = ( + rule: BankAccountRule, + agency: string, + account: string, + digit: string, +): boolean => { + if (agency.length < rule.minAgencyLength || agency.length > rule.maxAgencyLength) return false; + if (account.length < rule.minAccountLength || account.length > rule.maxAccountLength) + return false; + if (digit.length !== 1) return false; - if (digit.length < 1 || digit.length > 2) return false; + if (rule.digits === null) return sanitizeToDigits(digit).length === 1; - const mod11Result = mod11(account); - if (mod11Result <= 9 && String(mod11Result) === digit) { - return true; - } + return rule.digits(agency, account).includes(digit); +}; + +const validateGeneric = (account: string, digit: string): boolean => { + if (digit.length === 2) { + const first = mod10(account); + const second = mod11(`${account}${first}`, { variant: "bank" }); - const mod10Result = mod10(account); - if (String(mod10Result) === digit) { - return true; + return `${first}${second === 10 ? 0 : second}` === digit; } - return false; + return ( + String(mod10(account)) === digit || + String(mod11(account)) === digit || + String(mod11(account, { variant: "bank" })) === digit + ); }; -const VALIDATORS: Record boolean> = { - "001": validateBancoDoBrasil, - "341": validateItau, - "237": validateBradesco, - "033": validateSantander, - "104": validateCaixa, -}; +const sanitizeCheckDigit = (value: string): string => value.toUpperCase().replace(/[^\dPX]/g, ""); /** - * Validates if a Brazilian bank account is valid. - * Supports specific validation algorithms for major banks: - * - Banco do Brasil (001) - * - Itaú (341) - * - Bradesco (237) - * - Santander (033) - * - Caixa Econômica Federal (104) + * 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: + * 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). * - * For other banks, uses generic mod10/mod11 validation. + * Banks validated by structure only, because they publish no check digit rule: + * 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), Modal (746), Sicredi (748) and Sicoob (756). + * For those the agency and account only need to match the documented digit lengths. + * + * Every other bank of the list falls back to a generic modulus 10 and modulus 11 check. * * @param {IsValidBankAccountOptions} params - The bank account parameters. - * @param {string} params.bankCode - The bank code (3 digits). + * @param {string} params.bankCode - The bank code (3 digits), as published by Banco Central. * @param {string} params.agency - The agency number (1-5 digits). - * @param {string} params.account - The account number (1-13 digits). - * @param {string} params.digit - The verification digit (1-2 digits). + * @param {string} params.account - The account number (1-13 digits). For Caixa, operação + conta. + * @param {string} params.digit - The verification digit (1-2 digits, or "X" for Banco do Brasil and "P" for Bradesco). * @returns {boolean} True if the bank account is valid, false otherwise. * * @example * ```typescript - * isValidBankAccount({ - * bankCode: "001", - * agency: "1234", - * account: "12345678", - * digit: "5" - * }); // true (if valid Banco do Brasil account) - * - * isValidBankAccount({ - * bankCode: "341", - * agency: "1234", - * account: "12345", - * digit: "6" - * }); // true (if valid Itaú account) + * isValidBankAccount({ bankCode: "001", agency: "1584", account: "00210169", digit: "6" }); // true + * isValidBankAccount({ bankCode: "041", agency: "2664", account: "358507670", digit: "6" }); // true + * isValidBankAccount({ bankCode: "260", agency: "0001", account: "5216125", digit: "0" }); // true + * isValidBankAccount({ bankCode: "999", agency: "1234", account: "123456", digit: "6" }); // false * ``` + * + * Only bank codes present in the bundled Banco Central participant table are accepted; that table is + * regenerated weekly by the datasets workflow, so a bank created after the release becomes valid + * on the next release. + * + * @see Official: https://www.bcb.gov.br/pom/spb/estatistica/port/ParticipantesSTRport.csv + * @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 + * @see Based on: https://github.com/luizalabs/heimdall/blob/main/heimdall_valid_bank/calculate_number_account.py + * @see Based on: https://github.com/Xerpa/bran_checker/tree/master/lib/banks */ export const isValidBankAccount = (params: IsValidBankAccountOptions): boolean => { + if (isNullish(params) || typeof params !== "object") return false; + const { bankCode, agency, account, digit } = params; if ( @@ -191,19 +293,18 @@ export const isValidBankAccount = (params: IsValidBankAccountOptions): boolean = const bankCodeDigits = sanitizeToDigits(bankCode); const agencyDigits = sanitizeToDigits(agency); const accountDigits = sanitizeToDigits(account); - const digitDigits = sanitizeToDigits(digit); + const checkDigit = sanitizeCheckDigit(digit); if (bankCodeDigits.length !== 3) return false; if (agencyDigits.length === 0 || agencyDigits.length > 5) return false; if (accountDigits.length === 0 || accountDigits.length > 13) return false; - if (digitDigits.length === 0 || digitDigits.length > 2) return false; + if (checkDigit.length === 0 || checkDigit.length > 2) return false; + + if (!isListedBankCode(bankCodeDigits)) return false; + + const rule = findRule(bankCodeDigits); - const validator = bankCodeDigits in VALIDATORS ? VALIDATORS[bankCodeDigits] : validateGeneric; + if (rule !== null) return validateWithRule(rule, agencyDigits, accountDigits, checkDigit); - return validator({ - bankCode: bankCodeDigits, - agency: agencyDigits, - account: accountDigits, - digit: digitDigits, - }); + return validateGeneric(accountDigits, checkDigit); }; From 82df2d9c7c8b07cdf83ee7b2f94aaf3e199e65e5 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:32:08 -0300 Subject: [PATCH 3/7] feat(phone): add isValidServicePhone and an accept/auto-mask upgrade to isValidPhone/formatPhone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add isValidServicePhone for Brazilian non-geographic numbers (Códigos Não Geográficos 0300/0500/0800/0900 and abbreviated 300X/400X). isValidPhone gains options.accept: PhoneType[] (default ["mobile","landline"]). formatPhone gains "e164", "international" and "service" masks, and "auto" now also detects a country code and service numbers instead of only counting digits. All phone validators/formatters accept and strip a Brazilian country code (+55, 0055 or bare 55) via the new shared normalizePhone/isValidDDD internals. fix: isValidLandlinePhone/generatePhone accept first digit 6 for landlines (Res. Anatel 749/2022 opened the 6XXX-XXXX range). Type the shared area-code table as a readonly tuple so isValidDdd can narrow against it. --- src/_internals/constants/area-codes.ts | 156 ++++++++++-------- src/_internals/constants/phone.ts | 19 +++ src/_internals/constants/service-phone.ts | 121 ++++++++++++++ .../is-valid-ddd/is-valid-ddd.test.ts | 12 ++ src/_internals/is-valid-ddd/is-valid-ddd.ts | 19 +++ .../normalize-phone/normalize-phone.test.ts | 32 ++++ .../normalize-phone/normalize-phone.ts | 43 +++++ .../strip-phone-country-code.test.ts | 23 +++ .../strip-phone-country-code.ts | 27 +++ src/format-phone/constants.ts | 23 +++ src/format-phone/format-phone.test.ts | 98 +++++++++++ src/format-phone/format-phone.ts | 130 +++++++++++++-- src/format-phone/index.ts | 1 - src/generate-phone/generate-phone.test.ts | 29 ++++ src/generate-phone/generate-phone.ts | 60 ++++++- src/is-valid-landline-phone/constants.ts | 3 +- .../is-valid-landline-phone.test.ts | 35 ++++ .../is-valid-landline-phone.ts | 26 +-- src/is-valid-mobile-phone/constants.ts | 1 - .../is-valid-mobile-phone.test.ts | 28 ++++ .../is-valid-mobile-phone.ts | 43 ++--- src/is-valid-phone/constants.ts | 5 +- src/is-valid-phone/is-valid-phone.test.ts | 54 ++++++ src/is-valid-phone/is-valid-phone.ts | 43 ++++- .../is-valid-service-phone.test.ts | 105 ++++++++++++ .../is-valid-service-phone.ts | 66 ++++++++ src/parse-phone/constants.ts | 1 - src/parse-phone/parse-phone.test.ts | 38 ++++- src/parse-phone/parse-phone.ts | 24 ++- 29 files changed, 1129 insertions(+), 136 deletions(-) create mode 100644 src/_internals/constants/phone.ts create mode 100644 src/_internals/constants/service-phone.ts create mode 100644 src/_internals/is-valid-ddd/is-valid-ddd.test.ts create mode 100644 src/_internals/is-valid-ddd/is-valid-ddd.ts create mode 100644 src/_internals/normalize-phone/normalize-phone.test.ts create mode 100644 src/_internals/normalize-phone/normalize-phone.ts create mode 100644 src/_internals/strip-phone-country-code/strip-phone-country-code.test.ts create mode 100644 src/_internals/strip-phone-country-code/strip-phone-country-code.ts create mode 100644 src/format-phone/constants.ts delete mode 100644 src/format-phone/index.ts create mode 100644 src/is-valid-service-phone/is-valid-service-phone.test.ts create mode 100644 src/is-valid-service-phone/is-valid-service-phone.ts delete mode 100644 src/parse-phone/constants.ts diff --git a/src/_internals/constants/area-codes.ts b/src/_internals/constants/area-codes.ts index 26fa9630..223fc955 100644 --- a/src/_internals/constants/area-codes.ts +++ b/src/_internals/constants/area-codes.ts @@ -1,72 +1,88 @@ +import type { StateCode } from "./states"; + /** - * Valid Brazilian DDD (area codes) by state. + * Brazilian DDD (area code) data under the Plano Geral de Numeração. `VALID_AREA_CODES` is kept + * as a bare array of the 67 valid codes for its existing importers; `AREA_CODE_STATES` is a + * second, richer literal mapping every one of those same 67 codes to its state (UF), verified + * one by one against the ANATEL numbering plan reflected by the BrasilAPI DDD dataset. + * + * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2010/167-resolucao-553 + * (Resolução Anatel 553/2010, Plano Geral de Numeração) + * @see Based on: https://brasilapi.com.br/docs#tag/DDD BrasilAPI DDD endpoint (`GET + * /api/ddd/v1/{ddd}`), used to verify the code-to-state mapping. */ -export const VALID_AREA_CODES = [ - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, // SP - 21, - 22, - 24, // RJ - 27, - 28, // ES - 31, - 32, - 33, - 34, - 35, - 37, - 38, // MG - 41, - 42, - 43, - 44, - 45, - 46, // PR - 47, - 48, - 49, // SC - 51, - 53, - 54, - 55, // RS - 61, // DF - 62, - 64, // GO - 63, // TO - 65, - 66, // MT - 67, // MS - 68, // AC - 69, // RO - 71, - 73, - 74, - 75, - 77, // BA - 79, // SE - 81, - 87, // PE - 82, // AL - 83, // PB - 84, // RN - 85, - 88, // CE - 86, - 89, // PI - 91, - 93, - 94, // PA - 92, - 97, // AM - 95, // RR - 96, // AP - 98, - 99, // MA -] as const; +export const VALID_AREA_CODES: readonly number[] = [ + 11, 12, 13, 14, 15, 16, 17, 18, 19, 21, 22, 24, 27, 28, 31, 32, 33, 34, 35, 37, 38, 41, 42, 43, + 44, 45, 46, 47, 48, 49, 51, 53, 54, 55, 61, 62, 64, 63, 65, 66, 67, 68, 69, 71, 73, 74, 75, 77, + 79, 81, 87, 82, 83, 84, 85, 88, 86, 89, 91, 93, 94, 92, 97, 95, 96, 98, 99, +]; + +export const AREA_CODE_STATES: Record = { + 11: "SP", + 12: "SP", + 13: "SP", + 14: "SP", + 15: "SP", + 16: "SP", + 17: "SP", + 18: "SP", + 19: "SP", + 21: "RJ", + 22: "RJ", + 24: "RJ", + 27: "ES", + 28: "ES", + 31: "MG", + 32: "MG", + 33: "MG", + 34: "MG", + 35: "MG", + 37: "MG", + 38: "MG", + 41: "PR", + 42: "PR", + 43: "PR", + 44: "PR", + 45: "PR", + 46: "PR", + 47: "SC", + 48: "SC", + 49: "SC", + 51: "RS", + 53: "RS", + 54: "RS", + 55: "RS", + 61: "DF", + 62: "GO", + 63: "TO", + 64: "GO", + 65: "MT", + 66: "MT", + 67: "MS", + 68: "AC", + 69: "RO", + 71: "BA", + 73: "BA", + 74: "BA", + 75: "BA", + 77: "BA", + 79: "SE", + 81: "PE", + 82: "AL", + 83: "PB", + 84: "RN", + 85: "CE", + 86: "PI", + 87: "PE", + 88: "CE", + 89: "PI", + 91: "PA", + 92: "AM", + 93: "PA", + 94: "PA", + 95: "RR", + 96: "AP", + 97: "AM", + 98: "MA", + 99: "MA", +}; diff --git a/src/_internals/constants/phone.ts b/src/_internals/constants/phone.ts new file mode 100644 index 00000000..52d49b76 --- /dev/null +++ b/src/_internals/constants/phone.ts @@ -0,0 +1,19 @@ +/** + * Brazilian phone numbering: `55` is the E.164 country code, `00` the international + * prefix dialed from Brazil, and a national number is DDD + 8 or 9 digits. + * + * @see Official: https://www.itu.int/dms_pub/itu-t/opb/sp/T-SP-E.164C-2011-PDF-E.pdf + */ + +export const PHONE_COUNTRY_CODE = "55"; + +export const PHONE_INTERNATIONAL_PREFIX = "00"; + +export const PHONE_COUNTRY_CODE_PREFIXES = [ + `${PHONE_INTERNATIONAL_PREFIX}${PHONE_COUNTRY_CODE}`, + PHONE_COUNTRY_CODE, +]; + +export const PHONE_NATIONAL_MIN_LENGTH = 10; + +export const PHONE_NATIONAL_MAX_LENGTH = 11; diff --git a/src/_internals/constants/service-phone.ts b/src/_internals/constants/service-phone.ts new file mode 100644 index 00000000..2bb01f63 --- /dev/null +++ b/src/_internals/constants/service-phone.ts @@ -0,0 +1,121 @@ +/** + * Brazilian non-geographic and service phone numbering. + * + * The Regulamento de Numeração dos Serviços de Telecomunicações (Resolução Anatel nº 749/2022, + * which replaced the revoked Resolução nº 553/2010) defines two separate families, plus a third + * one that is only a market convention: + * + * - **Código Não Geográfico (CNG)**, art. 18: a 10-digit code in the `300`, `303`, `500`, `800` + * or `900` series, dialed with the Prefixo Nacional `0` in front (art. 28), so 11 digits in + * 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), + * 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, so the codes below are the + * consolidated list Anatel publishes, not the full `100`-`199` range. `112` and `911` are + * mobile-only aliases of `190` and are listed by Anatel alongside the `1XX` codes. + * - **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 + * in many DDDs at once and points at a single customer, marketed as "Número Único". Anatel + * neither names them nor publishes an allocated list, so the roots below are the conventional + * ones rather than an official allocation. + * + * Display formatting is convention too: no Anatel document specifies one. `0800 123 4567` (4-3-4) + * 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 + */ + +export const SERVICE_PHONE_NON_GEOGRAPHIC_PREFIXES = [ + "0300", + "0303", + "0500", + "0800", + "0900", +] as const; + +export const SERVICE_PHONE_NON_GEOGRAPHIC_PREFIX_LENGTH = 4; + +export const SERVICE_PHONE_NON_GEOGRAPHIC_LENGTH = 11; + +export const SERVICE_PHONE_ABBREVIATED_ROOTS = ["300", "400"] as const; + +export const SERVICE_PHONE_ABBREVIATED_ROOT_LENGTH = 3; + +export const SERVICE_PHONE_ABBREVIATED_LENGTH = 8; + +export const SERVICE_PHONE_UTILITY_LENGTH = 3; + +export const SERVICE_PHONE_UTILITY_CODES = [ + "100", + "102", + "103", + "104", + "105", + "106", + "111", + "112", + "115", + "116", + "117", + "118", + "121", + "123", + "125", + "127", + "128", + "129", + "130", + "132", + "133", + "134", + "135", + "136", + "138", + "142", + "145", + "146", + "147", + "148", + "150", + "151", + "152", + "153", + "154", + "155", + "156", + "157", + "158", + "159", + "160", + "161", + "162", + "163", + "164", + "165", + "166", + "167", + "168", + "174", + "180", + "181", + "185", + "188", + "190", + "191", + "192", + "193", + "194", + "195", + "196", + "197", + "198", + "199", + "911", +] as const; diff --git a/src/_internals/is-valid-ddd/is-valid-ddd.test.ts b/src/_internals/is-valid-ddd/is-valid-ddd.test.ts new file mode 100644 index 00000000..64c6dfe6 --- /dev/null +++ b/src/_internals/is-valid-ddd/is-valid-ddd.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "../test/runtime"; +import { isValidDDD } from "./is-valid-ddd"; + +describe("isValidDDD", () => { + test("should return true for a valid area code", () => { + expect(isValidDDD("11987654321")).toBe(true); + }); + + test("should return false for an invalid area code", () => { + expect(isValidDDD("00987654321")).toBe(false); + }); +}); diff --git a/src/_internals/is-valid-ddd/is-valid-ddd.ts b/src/_internals/is-valid-ddd/is-valid-ddd.ts new file mode 100644 index 00000000..f34d3944 --- /dev/null +++ b/src/_internals/is-valid-ddd/is-valid-ddd.ts @@ -0,0 +1,19 @@ +import { VALID_AREA_CODES } from "../constants/area-codes"; + +/** + * Checks whether the first two digits of a sanitized Brazilian phone number form a valid DDD + * (area code). + * + * @param {string} value - The sanitized (digits-only) phone number. + * @returns {boolean} True if the first two digits are a valid Brazilian area code. + * + * @example + * ```typescript + * isValidDDD("11987654321"); // true + * isValidDDD("00987654321"); // false + * ``` + */ +export const isValidDDD = (value: string): boolean => { + const ddd = (value.charCodeAt(0) - 48) * 10 + (value.charCodeAt(1) - 48); + return VALID_AREA_CODES.includes(ddd); +}; diff --git a/src/_internals/normalize-phone/normalize-phone.test.ts b/src/_internals/normalize-phone/normalize-phone.test.ts new file mode 100644 index 00000000..a6760667 --- /dev/null +++ b/src/_internals/normalize-phone/normalize-phone.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "../test/runtime"; +import { normalizePhone } from "./normalize-phone"; + +describe("normalizePhone", () => { + test("should keep a national number untouched", () => { + expect(normalizePhone("11987654321")).toBe("11987654321"); + expect(normalizePhone("1130000000")).toBe("1130000000"); + }); + + test("should remove the country code when the rest is a national number", () => { + expect(normalizePhone("+55 (11) 98765-4321")).toBe("11987654321"); + expect(normalizePhone("5511987654321")).toBe("11987654321"); + expect(normalizePhone("551130000000")).toBe("1130000000"); + expect(normalizePhone("005511987654321")).toBe("11987654321"); + expect(normalizePhone("+55 55 98765-4321")).toBe("55987654321"); + }); + + test("should keep the leading 55 when it is an area code", () => { + expect(normalizePhone("55987654321")).toBe("55987654321"); + expect(normalizePhone("5533334444")).toBe("5533334444"); + }); + + test("should keep the digits when the remainder is not a national number", () => { + expect(normalizePhone("55123")).toBe("55123"); + expect(normalizePhone("551198765432112345")).toBe("551198765432112345"); + }); + + test("should return an empty string when there are no digits", () => { + expect(normalizePhone("")).toBe(""); + expect(normalizePhone("abc")).toBe(""); + }); +}); diff --git a/src/_internals/normalize-phone/normalize-phone.ts b/src/_internals/normalize-phone/normalize-phone.ts new file mode 100644 index 00000000..f908308d --- /dev/null +++ b/src/_internals/normalize-phone/normalize-phone.ts @@ -0,0 +1,43 @@ +import { + PHONE_COUNTRY_CODE_PREFIXES, + PHONE_NATIONAL_MAX_LENGTH, + PHONE_NATIONAL_MIN_LENGTH, +} from "../constants/phone"; +import { sanitizeToDigits } from "../sanitize-to-digits/sanitize-to-digits"; + +const isNationalLength = (value: string): boolean => + value.length === PHONE_NATIONAL_MIN_LENGTH || value.length === PHONE_NATIONAL_MAX_LENGTH; + +/** + * Sanitizes a phone value to digits and removes the Brazilian country code when, and only + * when, what remains is a plausible national number. + * + * The country code is dropped if the digits start with `0055` or `55` **and** the remaining + * digits are exactly 10 or 11 long (DDD plus an 8 or 9 digit subscriber number). Otherwise + * the digits are returned untouched, which keeps numbers from the `55` area code (RS) intact: + * `"55987654321"` leaves 9 digits behind, so its leading `55` is read as the DDD, not as the + * country code. + * + * @param {string|number} value - The phone value to normalize. + * @returns {string} The digits of the national number, without the country code. + * + * @example + * ```typescript + * normalizePhone("+55 (11) 98765-4321"); // "11987654321" + * normalizePhone("005511987654321"); // "11987654321" + * normalizePhone("55987654321"); // "55987654321" (DDD 55, not a country code) + * ``` + */ +export const normalizePhone = (value: string | number): string => { + const digits = sanitizeToDigits(value); + + for (const prefix of PHONE_COUNTRY_CODE_PREFIXES) { + if (!digits.startsWith(prefix)) continue; + + const national = digits.slice(prefix.length); + + if (isNationalLength(national)) return national; + } + + return digits; +}; diff --git a/src/_internals/strip-phone-country-code/strip-phone-country-code.test.ts b/src/_internals/strip-phone-country-code/strip-phone-country-code.test.ts new file mode 100644 index 00000000..9d01fcee --- /dev/null +++ b/src/_internals/strip-phone-country-code/strip-phone-country-code.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "../test/runtime"; +import { stripPhoneCountryCode } from "./strip-phone-country-code"; + +describe("stripPhoneCountryCode", () => { + test("should drop an explicit +55 or 0055 prefix", () => { + expect(stripPhoneCountryCode("+55 0800 123 4567")).toBe("08001234567"); + expect(stripPhoneCountryCode("+5511987654321")).toBe("11987654321"); + expect(stripPhoneCountryCode("0055 4004-1234")).toBe("40041234"); + expect(stripPhoneCountryCode(" + 55 190")).toBe("190"); + }); + + test("should keep a bare leading 55, which is also a DDD", () => { + expect(stripPhoneCountryCode("55 3333-4444")).toBe("5533334444"); + expect(stripPhoneCountryCode("5511987654321")).toBe("5511987654321"); + expect(stripPhoneCountryCode(5_533_334_444)).toBe("5533334444"); + }); + + test("should only sanitize a value without a prefix", () => { + expect(stripPhoneCountryCode("(11) 98765-4321")).toBe("11987654321"); + expect(stripPhoneCountryCode(190)).toBe("190"); + expect(stripPhoneCountryCode("")).toBe(""); + }); +}); diff --git a/src/_internals/strip-phone-country-code/strip-phone-country-code.ts b/src/_internals/strip-phone-country-code/strip-phone-country-code.ts new file mode 100644 index 00000000..51670dc2 --- /dev/null +++ b/src/_internals/strip-phone-country-code/strip-phone-country-code.ts @@ -0,0 +1,27 @@ +import { sanitizeToDigits } from "../sanitize-to-digits/sanitize-to-digits"; + +const EXPLICIT_COUNTRY_CODE_REGEX = /^\s*(?:\+|00)\s*55/; + +/** + * Returns the digits of a phone value without an explicit Brazilian country code written as + * `+55` or `0055`. A bare leading `55` is kept, since it is also the DDD of Santa Maria, RS, + * and only the national length can tell the two apart (see `normalizePhone`). + * + * @param {string|number} value - The phone value, masked or not. + * @returns {string} The digits, without the explicit `+55`/`0055` prefix. + * + * @example + * ```typescript + * stripPhoneCountryCode("+55 0800 123 4567"); // "08001234567" + * stripPhoneCountryCode("0055 4004-1234"); // "40041234" + * stripPhoneCountryCode("55 3333-4444"); // "5533334444" + * ``` + */ +export const stripPhoneCountryCode = (value: string | number): string => { + // Stryker disable next-line ConditionalExpression: a number cannot carry a "+" or "00" prefix, so running it through the regex changes nothing. + if (typeof value !== "string") return sanitizeToDigits(value); + + const match = EXPLICIT_COUNTRY_CODE_REGEX.exec(value); + + return sanitizeToDigits(match ? value.slice(match[0].length) : value); +}; diff --git a/src/format-phone/constants.ts b/src/format-phone/constants.ts new file mode 100644 index 00000000..e24aafb8 --- /dev/null +++ b/src/format-phone/constants.ts @@ -0,0 +1,23 @@ +export type NationalMask = "sn" | "nanp"; + +export const LENGTH: Record = { + sn: 9, + nanp: 11, +}; + +export const MASK: Record = { + sn: "00000-0000", + nanp: "(00) 00000-0000", +}; + +export const INTERNATIONAL_PREFIX = "+55"; + +export const INTERNATIONAL_MASK = { + landline: "00 0000-0000", + mobile: "00 00000-0000", +}; + +export const SERVICE_MASK = { + abbreviated: "0000-0000", + nonGeographic: "0000 000 0000", +}; diff --git a/src/format-phone/format-phone.test.ts b/src/format-phone/format-phone.test.ts index e0815f76..2b6daa4f 100644 --- a/src/format-phone/format-phone.test.ts +++ b/src/format-phone/format-phone.test.ts @@ -2,6 +2,13 @@ import { describe, expect, it } from "../_internals/test/runtime"; import { formatPhone } from "./format-phone"; describe("formatPhone", () => { + it("should format a service number written with an explicit country code", () => { + expect(formatPhone("005540041234", { mask: "e164" })).toBe("4004-1234"); + expect(formatPhone("+55 0800 123 4567", { mask: "auto" })).toBe("0800 123 4567"); + expect(formatPhone("+55 190", { mask: "service" })).toBe("190"); + expect(formatPhone("55 4004-1234", { mask: "e164" })).toBe("+555540041234"); + }); + it("should sn format phone", () => { expect(formatPhone("")).toBe(""); expect(formatPhone("9")).toBe("9"); @@ -44,4 +51,95 @@ describe("formatPhone", () => { expect(formatPhone("1198888777", { mask: "auto" })).toBe("(11) 98888-777"); expect(formatPhone("11988887777", { mask: "auto" })).toBe("(11) 98888-7777"); }); + + it("should e164 format phone", () => { + expect(formatPhone("", { mask: "e164" })).toBe(""); + expect(formatPhone("11988887777", { mask: "e164" })).toBe("+5511988887777"); + expect(formatPhone("(11) 98888-7777", { mask: "e164" })).toBe("+5511988887777"); + expect(formatPhone("1130000000", { mask: "e164" })).toBe("+551130000000"); + expect(formatPhone("+55 11 98888-7777", { mask: "e164" })).toBe("+5511988887777"); + expect(formatPhone("005511988887777", { mask: "e164" })).toBe("+5511988887777"); + expect(formatPhone("5511988887777", { mask: "e164" })).toBe("+5511988887777"); + expect(formatPhone("0800 123 4567", { mask: "e164" })).toBe("0800 123 4567"); + expect(formatPhone("+55 0800 123 4567", { mask: "e164" })).toBe("0800 123 4567"); + expect(formatPhone("+55 0800 123 4567", { mask: "international" })).toBe("0800 123 4567"); + expect(formatPhone("55988887777", { mask: "e164" })).toBe("+5555988887777"); + expect(formatPhone(11988887777, { mask: "e164" })).toBe("+5511988887777"); + }); + + it("should international format phone", () => { + expect(formatPhone("", { mask: "international" })).toBe(""); + expect(formatPhone("11988887777", { mask: "international" })).toBe("+55 11 98888-7777"); + expect(formatPhone("(11) 98888-7777", { mask: "international" })).toBe("+55 11 98888-7777"); + expect(formatPhone("1130000000", { mask: "international" })).toBe("+55 11 3000-0000"); + expect(formatPhone("+55 (11) 98888-7777", { mask: "international" })).toBe("+55 11 98888-7777"); + expect(formatPhone("005511988887777", { mask: "international" })).toBe("+55 11 98888-7777"); + expect(formatPhone("55988887777", { mask: "international" })).toBe("+55 55 98888-7777"); + }); + + it("should service format phone", () => { + expect(formatPhone("", { mask: "service" })).toBe(""); + expect(formatPhone("08001234567", { mask: "service" })).toBe("0800 123 4567"); + expect(formatPhone("0800 123 4567", { mask: "service" })).toBe("0800 123 4567"); + expect(formatPhone("03001234567", { mask: "service" })).toBe("0300 123 4567"); + expect(formatPhone("03031234567", { mask: "service" })).toBe("0303 123 4567"); + expect(formatPhone("05001234567", { mask: "service" })).toBe("0500 123 4567"); + expect(formatPhone("09001234567", { mask: "service" })).toBe("0900 123 4567"); + expect(formatPhone("40041234", { mask: "service" })).toBe("4004-1234"); + expect(formatPhone("30031234", { mask: "service" })).toBe("3003-1234"); + expect(formatPhone("190", { mask: "service" })).toBe("190"); + }); + + it("should service format phone while it is being typed", () => { + expect(formatPhone("0", { mask: "service" })).toBe("0"); + expect(formatPhone("0800", { mask: "service" })).toBe("0800"); + expect(formatPhone("08001", { mask: "service" })).toBe("0800 1"); + expect(formatPhone("0800123", { mask: "service" })).toBe("0800 123"); + expect(formatPhone("08001234", { mask: "service" })).toBe("0800 123 4"); + expect(formatPhone("4004", { mask: "service" })).toBe("4004"); + expect(formatPhone("40041", { mask: "service" })).toBe("4004-1"); + }); + + it("should detect a country code under the auto mask", () => { + expect(formatPhone("+55 11 98888-7777", { mask: "auto" })).toBe("+55 11 98888-7777"); + expect(formatPhone("+5511988887777", { mask: "auto" })).toBe("+55 11 98888-7777"); + expect(formatPhone("005511988887777", { mask: "auto" })).toBe("+55 11 98888-7777"); + expect(formatPhone("+55 11 3000-0000", { mask: "auto" })).toBe("+55 11 3000-0000"); + }); + + it("should detect a service number under the auto mask", () => { + expect(formatPhone("08001234567", { mask: "auto" })).toBe("0800 123 4567"); + expect(formatPhone("03001234567", { mask: "auto" })).toBe("0300 123 4567"); + expect(formatPhone("40041234", { mask: "auto" })).toBe("4004-1234"); + expect(formatPhone("30031234", { mask: "auto" })).toBe("3003-1234"); + }); + + it("should keep reading the DDD from a bare 55 area code under the auto mask", () => { + expect(formatPhone("55988887777", { mask: "auto" })).toBe("(55) 98888-7777"); + }); + + it("should format international and service numbers when asked explicitly", () => { + expect(formatPhone("+55 11 98888-7777", { mask: "international" })).toBe("+55 11 98888-7777"); + expect(formatPhone("+5511988887777", { mask: "international" })).toBe("+55 11 98888-7777"); + expect(formatPhone("005511988887777", { mask: "international" })).toBe("+55 11 98888-7777"); + expect(formatPhone("+55 11 3000-0000", { mask: "international" })).toBe("+55 11 3000-0000"); + expect(formatPhone("08001234567", { mask: "service" })).toBe("0800 123 4567"); + expect(formatPhone("40041234", { mask: "service" })).toBe("4004-1234"); + }); + + it("should keep international masks on service numbers", () => { + expect(formatPhone("08001234567", { mask: "e164" })).toBe("0800 123 4567"); + expect(formatPhone("40041234", { mask: "international" })).toBe("4004-1234"); + }); + + it("should return an empty string for nullish values", () => { + // @ts-expect-error + expect(formatPhone(null)).toBe(""); + // @ts-expect-error + expect(formatPhone(undefined)).toBe(""); + // @ts-expect-error + expect(formatPhone(null, { mask: "e164" })).toBe(""); + // @ts-expect-error + expect(formatPhone(undefined, { mask: "service" })).toBe(""); + }); }); diff --git a/src/format-phone/format-phone.ts b/src/format-phone/format-phone.ts index bdb8aa8e..aea249d8 100644 --- a/src/format-phone/format-phone.ts +++ b/src/format-phone/format-phone.ts @@ -1,37 +1,141 @@ +import { PHONE_NATIONAL_MIN_LENGTH } from "../_internals/constants/phone"; +import { + SERVICE_PHONE_ABBREVIATED_ROOT_LENGTH, + SERVICE_PHONE_ABBREVIATED_ROOTS, + SERVICE_PHONE_NON_GEOGRAPHIC_PREFIXES, +} from "../_internals/constants/service-phone"; import { format } from "../_internals/format/format"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { normalizePhone } from "../_internals/normalize-phone/normalize-phone"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { stripPhoneCountryCode } from "../_internals/strip-phone-country-code/strip-phone-country-code"; +import { isValidServicePhone } from "../is-valid-service-phone/is-valid-service-phone"; +import { + INTERNATIONAL_MASK, + INTERNATIONAL_PREFIX, + LENGTH, + MASK, + type NationalMask, + SERVICE_MASK, +} from "./constants"; -type Mask = "sn" | "nanp"; +export type PhoneMask = "auto" | "e164" | "international" | "service" | NationalMask; export type FormatPhoneOptions = { - mask?: "auto" | Mask; + /** Which mask to apply, or `"auto"` to pick one from the value (default: `"sn"`). */ + mask?: PhoneMask; }; -const LENGTH: Record = { - sn: 9, - nanp: 11, +const matchesPrefix = (digits: string, prefixes: readonly string[]): boolean => + prefixes.some((prefix) => + digits.length < prefix.length ? prefix.startsWith(digits) : digits.startsWith(prefix), + ); + +const formatService = (digits: string): string => { + if (matchesPrefix(digits, SERVICE_PHONE_NON_GEOGRAPHIC_PREFIXES)) { + return format({ value: digits, pattern: SERVICE_MASK.nonGeographic }); + } + + if ( + matchesPrefix( + digits.slice(0, SERVICE_PHONE_ABBREVIATED_ROOT_LENGTH), + SERVICE_PHONE_ABBREVIATED_ROOTS, + ) + ) { + return format({ value: digits, pattern: SERVICE_MASK.abbreviated }); + } + + return digits; }; -const MASK: Record = { - sn: "00000-0000", - nanp: "(00) 00000-0000", +const formatInternational = (national: string): string => { + if (!national) return ""; + + const pattern = + national.length > PHONE_NATIONAL_MIN_LENGTH + ? INTERNATIONAL_MASK.mobile + : INTERNATIONAL_MASK.landline; + + return `${INTERNATIONAL_PREFIX} ${format({ value: national, pattern })}`; +}; + +const formatE164 = (national: string): string => + national ? `${INTERNATIONAL_PREFIX}${national}` : ""; + +const resolveAutoMask = (digits: string, serviceDigits: string): Exclude => { + if (isValidServicePhone(serviceDigits)) return "service"; + + if (normalizePhone(digits) !== digits) return "international"; + + return digits.length > LENGTH.sn ? "nanp" : "sn"; }; /** * Formats a phone number according to Brazilian phone number patterns. * + * `options.mask` accepts: + * - `"sn"` (default): Brazilian subscriber number only, e.g. `"98765-4321"` (9 digits, no DDD). + * With a DDD present in `value`, `"sn"` **truncates** it, e.g. `formatPhone("11987654321")` + * (with `mask` omitted) returns `"11987-6543"`, silently dropping the last digit, because + * only the first 9 digits are used and the DDD's 2 digits are consumed as if they were part + * of the subscriber number. + * - `"nanp"`: `"(00) 00000-0000"`, i.e. DDD + subscriber number (11 digits). + * - `"auto"`: picks a mask from `value`. A leading Brazilian country code (`+55`, `0055` or a + * bare `55` followed by 10 or 11 digits) selects `"international"`; a service number selects + * `"service"`; otherwise the digit count decides, `"nanp"` when `value` has more digits than + * a bare subscriber number (9) and `"sn"` when it does not. + * - `"e164"`: the ITU-T E.164 form, `"+5511987654321"`, no separators. + * - `"international"`: the way a Brazilian number is printed for foreign callers, + * `"+55 11 98765-4321"` (or `"+55 11 3000-0000"` for a landline). + * - `"service"`: service numbers, `"0800 123 4567"` for the Códigos Não Geográficos (`0300`, + * `0303`, `0500`, `0800`, `0900`) and `"4004-1234"` for the abbreviated `300X`/`400X` ones. + * Anatel specifies no display format for either, so these are the conventional groupings. + * + * `"e164"` and `"international"` drop the country code from `value` first, under the rule + * documented in `parsePhone`. A service number has no E.164 form, it is not reachable from + * abroad, so both international masks fall back to the `"service"` presentation for it, which + * is how such numbers are printed in Brazil. + * + * If `value` includes a DDD (area code), pass `{ mask: "auto" }` (or `"nanp"`) explicitly, + * do not rely on the default, since the default `"sn"` mask assumes no DDD is present. + * * @param {string|number} value - The phone number to format, either as a string or a number. - * @param {Object} options - Optional formatting options. - * @param {string} options.mask - The mask to apply for formatting the phone 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"`). * @returns {string} The formatted phone number as a string. + * + * @example + * ```typescript + * formatPhone("987654321"); // "98765-4321" (default "sn", no DDD) + * formatPhone("11987654321", { mask: "auto" }); // "(11) 98765-4321" + * formatPhone("5511987654321", { mask: "auto" }); // "+55 11 98765-4321" + * formatPhone("08001234567", { mask: "auto" }); // "0800 123 4567" + * formatPhone("11987654321", { mask: "e164" }); // "+5511987654321" + * formatPhone("11987654321", { mask: "international" }); // "+55 11 98765-4321" + * formatPhone("40041234", { mask: "service" }); // "4004-1234" + * formatPhone("11987654321"); // "11987-6543" (BEWARE: default "sn" truncates a DDD-prefixed number) + * ``` + * + * @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 formatPhone = (value: string | number, options?: FormatPhoneOptions): string => { - let mask = options?.mask ?? "sn"; + if (isNullish(value)) return ""; const enhancedValue = sanitizeToDigits(value); - if (mask === "auto") { - mask = enhancedValue.length > LENGTH.sn ? "nanp" : "sn"; + const serviceDigits = stripPhoneCountryCode(value); + const requested = options?.mask ?? "sn"; + const mask = requested === "auto" ? resolveAutoMask(enhancedValue, serviceDigits) : requested; + + if (mask === "service") return formatService(serviceDigits); + + if (mask === "e164" || mask === "international") { + if (isValidServicePhone(serviceDigits)) return formatService(serviceDigits); + + const national = normalizePhone(enhancedValue); + + return mask === "e164" ? formatE164(national) : formatInternational(national); } return format({ value: enhancedValue, pattern: MASK[mask] }); diff --git a/src/format-phone/index.ts b/src/format-phone/index.ts deleted file mode 100644 index 981f534d..00000000 --- a/src/format-phone/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { type FormatPhoneOptions, formatPhone } from "./format-phone"; diff --git a/src/generate-phone/generate-phone.test.ts b/src/generate-phone/generate-phone.test.ts index d9e20e4c..6d2f2842 100644 --- a/src/generate-phone/generate-phone.test.ts +++ b/src/generate-phone/generate-phone.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "../_internals/test/runtime"; import { isValidLandlinePhone } from "../is-valid-landline-phone/is-valid-landline-phone"; import { isValidMobilePhone } from "../is-valid-mobile-phone/is-valid-mobile-phone"; import { isValidPhone } from "../is-valid-phone/is-valid-phone"; +import { isValidServicePhone } from "../is-valid-service-phone/is-valid-service-phone"; import { generatePhone } from "./generate-phone"; describe("generatePhone", () => { @@ -13,7 +14,35 @@ describe("generatePhone", () => { expect(isValidLandlinePhone(generatePhone("landline"))).toBe(true); }); + it("should generate a valid service phone", () => { + expect(isValidServicePhone(generatePhone("service"))).toBe(true); + }); + it("should generate a valid phone when type is omitted", () => { expect(isValidPhone(generatePhone())).toBe(true); }); + + it("should generate 200 valid phone numbers for every type", () => { + for (let i = 0; i < 200; i++) { + expect(isValidMobilePhone(generatePhone("mobile"), { version: 2 })).toBe(true); + expect(isValidLandlinePhone(generatePhone("landline"))).toBe(true); + expect(isValidServicePhone(generatePhone("service"))).toBe(true); + expect(isValidPhone(generatePhone("service"), { accept: ["service"] })).toBe(true); + expect(isValidPhone(generatePhone())).toBe(true); + } + }); + + it("should not generate a service phone when type is omitted", () => { + for (let i = 0; i < 200; i++) { + expect(isValidServicePhone(generatePhone())).toBe(false); + } + }); + + it("should generate service phones that survive a format round-trip", () => { + for (let i = 0; i < 200; i++) { + const phone = generatePhone("service"); + + expect(isValidPhone(phone, { accept: ["mobile", "landline", "service"] })).toBe(true); + } + }); }); diff --git a/src/generate-phone/generate-phone.ts b/src/generate-phone/generate-phone.ts index 9fa2ee49..e65401fe 100644 --- a/src/generate-phone/generate-phone.ts +++ b/src/generate-phone/generate-phone.ts @@ -1,21 +1,73 @@ import { VALID_AREA_CODES } from "../_internals/constants/area-codes"; +import { + SERVICE_PHONE_ABBREVIATED_LENGTH, + SERVICE_PHONE_ABBREVIATED_ROOT_LENGTH, + SERVICE_PHONE_ABBREVIATED_ROOTS, + SERVICE_PHONE_NON_GEOGRAPHIC_LENGTH, + SERVICE_PHONE_NON_GEOGRAPHIC_PREFIX_LENGTH, + SERVICE_PHONE_NON_GEOGRAPHIC_PREFIXES, +} from "../_internals/constants/service-phone"; import { generateRandomNumber } from "../_internals/generate-random-number/generate-random-number"; -export type GeneratePhoneType = "mobile" | "landline"; +export type GeneratePhoneType = "mobile" | "landline" | "service"; -const randomAreaCode = (): string => - VALID_AREA_CODES[Math.floor(Math.random() * VALID_AREA_CODES.length)].toString(); +const randomFrom = (list: readonly Item[]): Item => + list[Math.floor(Math.random() * list.length)]; +const randomAreaCode = (): string => randomFrom(VALID_AREA_CODES).toString(); + +const randomServicePhone = (): string => { + if (Math.random() >= 0.5) { + const prefix = randomFrom(SERVICE_PHONE_NON_GEOGRAPHIC_PREFIXES); + const rest = SERVICE_PHONE_NON_GEOGRAPHIC_LENGTH - SERVICE_PHONE_NON_GEOGRAPHIC_PREFIX_LENGTH; + + return `${prefix}${generateRandomNumber(rest)}`; + } + + const root = randomFrom(SERVICE_PHONE_ABBREVIATED_ROOTS); + const rest = SERVICE_PHONE_ABBREVIATED_LENGTH - SERVICE_PHONE_ABBREVIATED_ROOT_LENGTH; + + return `${root}${generateRandomNumber(rest)}`; +}; + +/** + * Generates a random, structurally-valid Brazilian phone number (DDD + subscriber number, + * no formatting/mask applied, see `formatPhone` to format the result). + * + * Uses `Math.random()` internally, so it is not cryptographically secure, do not use for security purposes. + * + * @param {GeneratePhoneType} [type] - `"mobile"` (9-digit number starting with 9), + * `"landline"` (8-digit number starting with 2-6) or `"service"` (a non-geographic number, + * either an 11-digit `0X00` one or an 8-digit `300X`/`400X` one, with no DDD). When omitted, + * randomly generates a mobile or a landline, never a service number, since those are not + * accepted by `isValidPhone` unless asked for. + * @returns {string} A randomly generated phone number as a string of digits (DDD included, + * except for service numbers, which have none). + * + * @example + * ```typescript + * generatePhone("mobile"); // e.g. "11987654321" + * generatePhone("landline"); // e.g. "1132345678" + * generatePhone("service"); // e.g. "08001234567" or "40041234" + * generatePhone(); // randomly mobile or landline + * ``` + * + * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 + */ export const generatePhone = (type?: GeneratePhoneType): string => { const areaCode = randomAreaCode(); if (type === "landline") { - return `${areaCode}${2 + Math.floor(Math.random() * 4)}${generateRandomNumber(7)}`; + return `${areaCode}${2 + Math.floor(Math.random() * 5)}${generateRandomNumber(7)}`; } if (type === "mobile") { return `${areaCode}9${generateRandomNumber(8)}`; } + if (type === "service") { + return randomServicePhone(); + } + return Math.random() >= 0.5 ? generatePhone("mobile") : generatePhone("landline"); }; diff --git a/src/is-valid-landline-phone/constants.ts b/src/is-valid-landline-phone/constants.ts index 2572c933..9f680b87 100644 --- a/src/is-valid-landline-phone/constants.ts +++ b/src/is-valid-landline-phone/constants.ts @@ -1,2 +1 @@ -export const PHONE_MIN_LENGTH = 10; -export const LANDLINE_VALID_FIRST_NUMBERS = [2, 3, 4, 5]; +export const LANDLINE_VALID_FIRST_NUMBERS = [2, 3, 4, 5, 6]; diff --git a/src/is-valid-landline-phone/is-valid-landline-phone.test.ts b/src/is-valid-landline-phone/is-valid-landline-phone.test.ts index a2369033..730b2d4d 100644 --- a/src/is-valid-landline-phone/is-valid-landline-phone.test.ts +++ b/src/is-valid-landline-phone/is-valid-landline-phone.test.ts @@ -14,6 +14,23 @@ describe("isValidLandlinePhone", () => { test("when length is invalid", () => { expect(isValidLandlinePhone("113000000")).toBe(false); }); + + test("when it is null, undefined or a number", () => { + // @ts-expect-error + expect(isValidLandlinePhone(null)).toBe(false); + // @ts-expect-error + expect(isValidLandlinePhone(undefined)).toBe(false); + // @ts-expect-error + expect(isValidLandlinePhone(1130000000)).toBe(false); + }); + + test("when the country code leaves an invalid number", () => { + expect(isValidLandlinePhone("+55 11 98765-4321")).toBe(false); + }); + + test("when the DDD is not a valid area code", () => { + expect(isValidLandlinePhone("0030000000")).toBe(false); + }); }); describe("should return true", () => { @@ -21,5 +38,23 @@ describe("isValidLandlinePhone", () => { expect(isValidLandlinePhone("(11) 3000-0000")).toBe(true); expect(isValidLandlinePhone("1130000000")).toBe(true); }); + + test("when it carries the country code", () => { + expect(isValidLandlinePhone("+551130000000")).toBe(true); + expect(isValidLandlinePhone("+55 11 3000-0000")).toBe(true); + expect(isValidLandlinePhone("+55 (11) 3000-0000")).toBe(true); + expect(isValidLandlinePhone("0055 11 3000-0000")).toBe(true); + expect(isValidLandlinePhone("551130000000")).toBe(true); + }); + + test("when the area code is 55", () => { + expect(isValidLandlinePhone("5530000000")).toBe(true); + expect(isValidLandlinePhone("+55 55 3000-0000")).toBe(true); + }); + }); + test("should accept landlines whose first digit is 6 (Anatel Res. 749/2022, art. 11)", () => { + expect(isValidLandlinePhone("1162654321")).toBe(true); + expect(isValidLandlinePhone("(11) 6265-4321")).toBe(true); + expect(isValidLandlinePhone("1172654321")).toBe(false); }); }); 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 deed19c8..8c3be050 100644 --- a/src/is-valid-landline-phone/is-valid-landline-phone.ts +++ b/src/is-valid-landline-phone/is-valid-landline-phone.ts @@ -1,13 +1,7 @@ -import { VALID_AREA_CODES } from "../_internals/constants/area-codes"; -import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -import { LANDLINE_VALID_FIRST_NUMBERS, PHONE_MIN_LENGTH } from "./constants"; - -const AREA_CODE_SET = new Set(VALID_AREA_CODES); - -const isValidDDD = (value: string): boolean => { - const ddd = (value.charCodeAt(0) - 48) * 10 + (value.charCodeAt(1) - 48); - return AREA_CODE_SET.has(ddd as (typeof VALID_AREA_CODES)[number]); -}; +import { PHONE_NATIONAL_MIN_LENGTH } from "../_internals/constants/phone"; +import { isValidDDD } from "../_internals/is-valid-ddd/is-valid-ddd"; +import { normalizePhone } from "../_internals/normalize-phone/normalize-phone"; +import { LANDLINE_VALID_FIRST_NUMBERS } from "./constants"; const isValidLandlineFirstNumber = (value: string): boolean => { const firstDigit = value.charCodeAt(2) - 48; @@ -17,6 +11,9 @@ const isValidLandlineFirstNumber = (value: string): boolean => { /** * Validates if a phone number is a valid Brazilian landline phone. * + * A Brazilian country code (`+55`, `0055` or a bare `55`) is accepted and removed before + * validation, under the rule documented in `parsePhone`. + * * @param {string} value - The phone number to validate. * @returns {boolean} True if the phone number is a valid landline phone, false otherwise. * @@ -24,15 +21,18 @@ const isValidLandlineFirstNumber = (value: string): boolean => { * ```typescript * isValidLandlinePhone("(11) 3000-0000"); // true * isValidLandlinePhone("1130000000"); // true + * isValidLandlinePhone("+55 11 3000-0000"); // true * isValidLandlinePhone("11987654321"); // false (mobile) * ``` + * + * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 */ export const isValidLandlinePhone = (value: string): boolean => { - if (!value || typeof value !== "string") return false; + if (typeof value !== "string" || value === "") return false; - const digits = sanitizeToDigits(value); + const digits = normalizePhone(value); - if (digits.length !== PHONE_MIN_LENGTH) return false; + if (digits.length !== PHONE_NATIONAL_MIN_LENGTH) return false; if (!isValidDDD(digits)) return false; diff --git a/src/is-valid-mobile-phone/constants.ts b/src/is-valid-mobile-phone/constants.ts index a36578e7..8e85f36f 100644 --- a/src/is-valid-mobile-phone/constants.ts +++ b/src/is-valid-mobile-phone/constants.ts @@ -1,3 +1,2 @@ -export const PHONE_MAX_LENGTH = 11; export const MOBILE_VALID_FIRST_NUMBERS_V1 = [6, 7, 8, 9]; export const MOBILE_VALID_FIRST_NUMBERS_V2 = [9]; 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 1545dcf2..dba44483 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 @@ -14,6 +14,20 @@ describe("isValidMobilePhone", () => { test("when length is invalid", () => { expect(isValidMobilePhone("1198765432")).toBe(false); }); + + test("when it is null, undefined or a number", () => { + // @ts-expect-error + expect(isValidMobilePhone(null)).toBe(false); + // @ts-expect-error + expect(isValidMobilePhone(undefined)).toBe(false); + // @ts-expect-error + expect(isValidMobilePhone(11987654321)).toBe(false); + }); + + test("when the country code leaves an invalid number", () => { + expect(isValidMobilePhone("+55 11 3000-0000")).toBe(false); + expect(isValidMobilePhone("+1 415 555 2671")).toBe(false); + }); }); describe("should return true", () => { @@ -25,5 +39,19 @@ describe("isValidMobilePhone", () => { test("when is a valid mobile phone version 1", () => { expect(isValidMobilePhone("11712345678", { version: 1 })).toBe(true); }); + + test("when it carries the country code", () => { + expect(isValidMobilePhone("+5511987654321")).toBe(true); + expect(isValidMobilePhone("+55 11 98765-4321")).toBe(true); + expect(isValidMobilePhone("+55 (11) 98765-4321")).toBe(true); + expect(isValidMobilePhone("0055 11 98765-4321")).toBe(true); + expect(isValidMobilePhone("5511987654321")).toBe(true); + expect(isValidMobilePhone("+55 11 98765-4321", { version: 2 })).toBe(true); + }); + + test("when the area code is 55", () => { + expect(isValidMobilePhone("55987654321")).toBe(true); + expect(isValidMobilePhone("+55 55 98765-4321")).toBe(true); + }); }); }); 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 7707faf2..b4d7de01 100644 --- a/src/is-valid-mobile-phone/is-valid-mobile-phone.ts +++ b/src/is-valid-mobile-phone/is-valid-mobile-phone.ts @@ -1,24 +1,16 @@ -import { VALID_AREA_CODES } from "../_internals/constants/area-codes"; -import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -import { - MOBILE_VALID_FIRST_NUMBERS_V1, - MOBILE_VALID_FIRST_NUMBERS_V2, - PHONE_MAX_LENGTH, -} from "./constants"; +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_VALID_FIRST_NUMBERS_V1, MOBILE_VALID_FIRST_NUMBERS_V2 } from "./constants"; -export type PhoneVersion = 1 | 2; +export type { PhoneVersion }; export type IsValidMobilePhoneOptions = { + /** Numbering rule to enforce: `1` the pre-2016 8 digit rule, `2` the 9 digit one (default: `2`). */ version?: PhoneVersion; }; -const AREA_CODE_SET = new Set(VALID_AREA_CODES); - -const isValidDDD = (value: string): boolean => { - const ddd = (value.charCodeAt(0) - 48) * 10 + (value.charCodeAt(1) - 48); - return AREA_CODE_SET.has(ddd as (typeof VALID_AREA_CODES)[number]); -}; - const isValidMobileFirstNumber = (value: string, version?: PhoneVersion): boolean => { const firstDigit = value.charCodeAt(2) - 48; @@ -32,8 +24,17 @@ const isValidMobileFirstNumber = (value: string, version?: PhoneVersion): boolea /** * Validates if a phone number is a valid Brazilian mobile phone. * + * 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 9. + * * @param {string} value - The phone number to validate. - * @param {PhoneOptions} options - Optional validation options. + * @param {IsValidMobilePhoneOptions} options - Optional validation options. + * @param {1|2} options.version - The mobile numbering rule to enforce (see above). Defaults to 1. * @returns {boolean} True if the phone number is a valid mobile phone, false otherwise. * * @example @@ -41,14 +42,18 @@ const isValidMobileFirstNumber = (value: string, version?: PhoneVersion): boolea * isValidMobilePhone("(11) 98765-4321"); // true (accepts both v1 and v2) * isValidMobilePhone("11987654321", { version: 2 }); // true * isValidMobilePhone("11712345678", { version: 1 }); // true + * isValidMobilePhone("11712345678", { version: 2 }); // false (v2 requires 9 as the first digit) + * isValidMobilePhone("+55 11 98765-4321"); // true * ``` + * + * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 */ export const isValidMobilePhone = (value: string, options?: IsValidMobilePhoneOptions): boolean => { - if (!value || typeof value !== "string") return false; + if (typeof value !== "string" || value === "") return false; - const digits = sanitizeToDigits(value); + const digits = normalizePhone(value); - if (digits.length !== PHONE_MAX_LENGTH) return false; + if (digits.length !== PHONE_NATIONAL_MAX_LENGTH) return false; if (!isValidDDD(digits)) return false; diff --git a/src/is-valid-phone/constants.ts b/src/is-valid-phone/constants.ts index cdfbd3a5..1d58a6ea 100644 --- a/src/is-valid-phone/constants.ts +++ b/src/is-valid-phone/constants.ts @@ -1,2 +1,3 @@ -export const PHONE_MIN_LENGTH = 10; -export const PHONE_MAX_LENGTH = 11; +import type { PhoneType } from "./is-valid-phone"; + +export const DEFAULT_ACCEPT: PhoneType[] = ["mobile", "landline"]; diff --git a/src/is-valid-phone/is-valid-phone.test.ts b/src/is-valid-phone/is-valid-phone.test.ts index fec5b6d0..6b0c7b88 100644 --- a/src/is-valid-phone/is-valid-phone.test.ts +++ b/src/is-valid-phone/is-valid-phone.test.ts @@ -2,6 +2,14 @@ import { describe, expect, test } from "../_internals/test/runtime"; import { isValidPhone } from "./is-valid-phone"; describe("isValidPhone", () => { + describe("service numbers written with a country code", () => { + test("should accept an explicit country code before a service number", () => { + expect(isValidPhone("+55 0800 123 4567", { accept: ["service"] })).toBe(true); + expect(isValidPhone("0055 4004-1234", { accept: ["service"] })).toBe(true); + expect(isValidPhone("+55 0800 123 4567")).toBe(false); + }); + }); + describe("should return false", () => { test("when it is an empty string", () => { expect(isValidPhone("")).toBe(false); @@ -12,6 +20,13 @@ describe("isValidPhone", () => { expect(isValidPhone(null)).toBe(false); }); + test("when it is undefined or a number", () => { + // @ts-expect-error + expect(isValidPhone(undefined)).toBe(false); + // @ts-expect-error + expect(isValidPhone(11987654321)).toBe(false); + }); + test("when length is invalid", () => { expect(isValidPhone("123")).toBe(false); }); @@ -19,6 +34,24 @@ describe("isValidPhone", () => { test("when DDD is invalid", () => { expect(isValidPhone("00999999999")).toBe(false); }); + + test("when it is a service phone and accept is left to its default", () => { + expect(isValidPhone("08001234567")).toBe(false); + expect(isValidPhone("40041234")).toBe(false); + }); + + test("when accept is empty", () => { + expect(isValidPhone("(11) 98765-4321", { accept: [] })).toBe(false); + expect(isValidPhone("1130000000", { accept: [] })).toBe(false); + expect(isValidPhone("08001234567", { accept: [] })).toBe(false); + }); + + test("when the kind is not accepted", () => { + expect(isValidPhone("11987654321", { accept: ["landline"] })).toBe(false); + expect(isValidPhone("1130000000", { accept: ["mobile"] })).toBe(false); + expect(isValidPhone("11987654321", { accept: ["service"] })).toBe(false); + expect(isValidPhone("08001234567", { accept: ["mobile", "landline"] })).toBe(false); + }); }); describe("should return true", () => { @@ -35,5 +68,26 @@ describe("isValidPhone", () => { test("when is a valid mobile phone version 1", () => { expect(isValidPhone("11712345678", { version: 1 })).toBe(true); }); + + test("when it carries the country code", () => { + expect(isValidPhone("+5511987654321")).toBe(true); + expect(isValidPhone("+55 11 98765-4321")).toBe(true); + expect(isValidPhone("+55 (11) 98765-4321")).toBe(true); + expect(isValidPhone("0055 11 98765-4321")).toBe(true); + expect(isValidPhone("5511987654321")).toBe(true); + expect(isValidPhone("+55 (11) 3000-0000")).toBe(true); + expect(isValidPhone("+55 11 98765-4321", { version: 2 })).toBe(true); + }); + + test("when the kind is accepted", () => { + expect(isValidPhone("11987654321", { accept: ["mobile"] })).toBe(true); + expect(isValidPhone("1130000000", { accept: ["landline"] })).toBe(true); + expect(isValidPhone("08001234567", { accept: ["service"] })).toBe(true); + expect(isValidPhone("40041234", { accept: ["service"] })).toBe(true); + expect(isValidPhone("190", { accept: ["service"] })).toBe(true); + expect(isValidPhone("0800 123 4567", { accept: ["service"] })).toBe(true); + expect(isValidPhone("11987654321", { accept: ["mobile", "landline", "service"] })).toBe(true); + expect(isValidPhone("08001234567", { accept: ["mobile", "landline", "service"] })).toBe(true); + }); }); }); diff --git a/src/is-valid-phone/is-valid-phone.ts b/src/is-valid-phone/is-valid-phone.ts index 969dc7ce..bb42a25a 100644 --- a/src/is-valid-phone/is-valid-phone.ts +++ b/src/is-valid-phone/is-valid-phone.ts @@ -1,19 +1,39 @@ -import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { + PHONE_NATIONAL_MAX_LENGTH, + PHONE_NATIONAL_MIN_LENGTH, +} from "../_internals/constants/phone"; +import { normalizePhone } from "../_internals/normalize-phone/normalize-phone"; +import { stripPhoneCountryCode } from "../_internals/strip-phone-country-code/strip-phone-country-code"; import { isValidLandlinePhone } from "../is-valid-landline-phone/is-valid-landline-phone"; import { isValidMobilePhone } from "../is-valid-mobile-phone/is-valid-mobile-phone"; -import { PHONE_MAX_LENGTH, PHONE_MIN_LENGTH } from "./constants"; +import { isValidServicePhone } from "../is-valid-service-phone/is-valid-service-phone"; +import { DEFAULT_ACCEPT } from "./constants"; export type PhoneVersion = 1 | 2; +export type PhoneType = "mobile" | "landline" | "service"; + export type IsValidPhoneOptions = { + /** Mobile numbering rule to enforce, see `isValidMobilePhone` (default: `2`). */ version?: PhoneVersion; + /** Kinds of number that count as valid (default: `["mobile", "landline"]`). */ + accept?: PhoneType[]; }; /** * Validates a Brazilian phone number. * + * A Brazilian country code (`+55`, `0055` or a bare `55`) is accepted and removed before + * validation, under the rule documented in `parsePhone`. + * + * `options.accept` picks which kinds of number count as valid and defaults to + * `["mobile", "landline"]`, i.e. geographic numbers only. Add `"service"` to also accept the + * non-geographic numbers recognised by `isValidServicePhone`; pass `[]` to accept none. + * * @param {string} value - The phone number to validate. * @param {IsValidPhoneOptions} options - Optional validation options. + * @param {1|2} options.version - The mobile numbering rule to enforce, see `isValidMobilePhone`. + * @param {PhoneType[]} options.accept - The kinds of number to accept (default: `["mobile", "landline"]`). * @returns {boolean} True if the phone number is valid, false otherwise. * * @example @@ -21,18 +41,29 @@ export type IsValidPhoneOptions = { * isValidPhone("(11) 98765-4321"); // true * isValidPhone("11987654321", { version: 2 }); // true * isValidPhone("1130000000"); // true (landline) + * isValidPhone("+55 11 98765-4321"); // true + * isValidPhone("08001234567"); // false (service numbers are not accepted by default) + * isValidPhone("08001234567", { accept: ["service"] }); // true + * isValidPhone("11987654321", { accept: [] }); // false * ``` + * + * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 */ export const isValidPhone = (value: string, options?: IsValidPhoneOptions): boolean => { - if (!value || typeof value !== "string") return false; + if (typeof value !== "string" || value === "") return false; + + const requested = options?.accept; + const accept: PhoneType[] = Array.isArray(requested) ? requested : DEFAULT_ACCEPT; + + if (accept.includes("service") && isValidServicePhone(stripPhoneCountryCode(value))) return true; - const digits = sanitizeToDigits(value); + const digits = normalizePhone(value); - if (digits.length === PHONE_MIN_LENGTH) { + if (accept.includes("landline") && digits.length === PHONE_NATIONAL_MIN_LENGTH) { return isValidLandlinePhone(value); } - if (digits.length === PHONE_MAX_LENGTH) { + if (accept.includes("mobile") && digits.length === PHONE_NATIONAL_MAX_LENGTH) { return isValidMobilePhone(value, options); } 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 new file mode 100644 index 00000000..1c57b343 --- /dev/null +++ b/src/is-valid-service-phone/is-valid-service-phone.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "../_internals/test/runtime"; +import { isValidServicePhone } from "./is-valid-service-phone"; + +describe("isValidServicePhone", () => { + describe("should return false", () => { + test("when it is an empty string", () => { + expect(isValidServicePhone("")).toBe(false); + }); + + test("when it is null, undefined or a number", () => { + // @ts-expect-error + expect(isValidServicePhone(null)).toBe(false); + // @ts-expect-error + expect(isValidServicePhone(undefined)).toBe(false); + // @ts-expect-error + expect(isValidServicePhone(8001234567)).toBe(false); + }); + + test("when it is a geographic number", () => { + expect(isValidServicePhone("11987654321")).toBe(false); + expect(isValidServicePhone("1130000000")).toBe(false); + }); + + test("when the non-geographic prefix is unknown", () => { + expect(isValidServicePhone("01001234567")).toBe(false); + expect(isValidServicePhone("02001234567")).toBe(false); + expect(isValidServicePhone("04001234567")).toBe(false); + expect(isValidServicePhone("06001234567")).toBe(false); + expect(isValidServicePhone("07001234567")).toBe(false); + expect(isValidServicePhone("08101234567")).toBe(false); + expect(isValidServicePhone("08011234567")).toBe(false); + }); + + test("when the non-geographic length is wrong", () => { + expect(isValidServicePhone("0800123456")).toBe(false); + expect(isValidServicePhone("080012345678")).toBe(false); + }); + + test("when the abbreviated root is unknown", () => { + expect(isValidServicePhone("40201234")).toBe(false); + expect(isValidServicePhone("31031234")).toBe(false); + expect(isValidServicePhone("50041234")).toBe(false); + }); + + test("when the abbreviated length is wrong", () => { + expect(isValidServicePhone("4004123")).toBe(false); + expect(isValidServicePhone("400412345")).toBe(false); + }); + + test("when the utility code was never designated", () => { + expect(isValidServicePhone("101")).toBe(false); + expect(isValidServicePhone("110")).toBe(false); + expect(isValidServicePhone("189")).toBe(false); + expect(isValidServicePhone("200")).toBe(false); + expect(isValidServicePhone("999")).toBe(false); + }); + }); + + describe("should return true", () => { + test("for every non-geographic prefix", () => { + expect(isValidServicePhone("03001234567")).toBe(true); + expect(isValidServicePhone("03031234567")).toBe(true); + expect(isValidServicePhone("05001234567")).toBe(true); + expect(isValidServicePhone("08001234567")).toBe(true); + expect(isValidServicePhone("09001234567")).toBe(true); + }); + + test("for a formatted non-geographic number", () => { + expect(isValidServicePhone("0800 123 4567")).toBe(true); + expect(isValidServicePhone("0800-123-4567")).toBe(true); + expect(isValidServicePhone("0300 123 4567")).toBe(true); + }); + + test("for every abbreviated root", () => { + expect(isValidServicePhone("30001234")).toBe(true); + expect(isValidServicePhone("30031234")).toBe(true); + expect(isValidServicePhone("30091234")).toBe(true); + expect(isValidServicePhone("40001234")).toBe(true); + expect(isValidServicePhone("40021234")).toBe(true); + expect(isValidServicePhone("40041234")).toBe(true); + expect(isValidServicePhone("40091234")).toBe(true); + }); + + test("for a formatted abbreviated number", () => { + expect(isValidServicePhone("4004-1234")).toBe(true); + expect(isValidServicePhone("3003 1234")).toBe(true); + }); + + test("for the public utility codes", () => { + expect(isValidServicePhone("100")).toBe(true); + expect(isValidServicePhone("102")).toBe(true); + expect(isValidServicePhone("112")).toBe(true); + expect(isValidServicePhone("136")).toBe(true); + expect(isValidServicePhone("156")).toBe(true); + expect(isValidServicePhone("180")).toBe(true); + expect(isValidServicePhone("181")).toBe(true); + expect(isValidServicePhone("188")).toBe(true); + expect(isValidServicePhone("190")).toBe(true); + expect(isValidServicePhone("191")).toBe(true); + expect(isValidServicePhone("192")).toBe(true); + expect(isValidServicePhone("193")).toBe(true); + expect(isValidServicePhone("199")).toBe(true); + }); + }); +}); diff --git a/src/is-valid-service-phone/is-valid-service-phone.ts b/src/is-valid-service-phone/is-valid-service-phone.ts new file mode 100644 index 00000000..a153b640 --- /dev/null +++ b/src/is-valid-service-phone/is-valid-service-phone.ts @@ -0,0 +1,66 @@ +import { + SERVICE_PHONE_ABBREVIATED_LENGTH, + SERVICE_PHONE_ABBREVIATED_ROOT_LENGTH, + SERVICE_PHONE_ABBREVIATED_ROOTS, + SERVICE_PHONE_NON_GEOGRAPHIC_LENGTH, + SERVICE_PHONE_NON_GEOGRAPHIC_PREFIX_LENGTH, + SERVICE_PHONE_NON_GEOGRAPHIC_PREFIXES, + SERVICE_PHONE_UTILITY_CODES, + SERVICE_PHONE_UTILITY_LENGTH, +} from "../_internals/constants/service-phone"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; + +const NON_GEOGRAPHIC_PREFIXES: readonly string[] = SERVICE_PHONE_NON_GEOGRAPHIC_PREFIXES; + +const ABBREVIATED_ROOTS: readonly string[] = SERVICE_PHONE_ABBREVIATED_ROOTS; + +const UTILITY_CODES: readonly string[] = SERVICE_PHONE_UTILITY_CODES; + +/** + * Validates if a phone number is a valid Brazilian service number. + * + * 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); + * - the abbreviated `300X` and `400X` numbers, followed by 4 digits, e.g. `3003-1234`. Anatel + * publishes no allocation for these, so the accepted roots are the conventional ones; + * - the 3-digit Códigos de Acesso a Serviços de Utilidade Pública that Anatel has designated, + * e.g. `190` and `192`. Undesignated codes in the `1XX` range are rejected. + * + * 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. + * + * @param {string} value - The phone number to validate. + * @returns {boolean} True if the phone number is a valid service phone, false otherwise. + * + * @example + * ```typescript + * isValidServicePhone("0800 123 4567"); // true + * isValidServicePhone("4004-1234"); // true + * isValidServicePhone("190"); // true + * isValidServicePhone("11987654321"); // false (geographic number) + * ``` + * + * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 + */ +export const isValidServicePhone = (value: string): boolean => { + if (typeof value !== "string" || value === "") return false; + + const digits = sanitizeToDigits(value); + + if (digits.length === SERVICE_PHONE_NON_GEOGRAPHIC_LENGTH) { + return NON_GEOGRAPHIC_PREFIXES.includes( + digits.slice(0, SERVICE_PHONE_NON_GEOGRAPHIC_PREFIX_LENGTH), + ); + } + + if (digits.length === SERVICE_PHONE_ABBREVIATED_LENGTH) { + return ABBREVIATED_ROOTS.includes(digits.slice(0, SERVICE_PHONE_ABBREVIATED_ROOT_LENGTH)); + } + + if (digits.length === SERVICE_PHONE_UTILITY_LENGTH) { + return UTILITY_CODES.includes(digits); + } + + return false; +}; diff --git a/src/parse-phone/constants.ts b/src/parse-phone/constants.ts deleted file mode 100644 index 5720885c..00000000 --- a/src/parse-phone/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const LENGTH = 11; diff --git a/src/parse-phone/parse-phone.test.ts b/src/parse-phone/parse-phone.test.ts index 167ab9fe..9089720a 100644 --- a/src/parse-phone/parse-phone.test.ts +++ b/src/parse-phone/parse-phone.test.ts @@ -8,10 +8,46 @@ describe("parsePhone", () => { }); it("should remove non numeric characters", () => { - expect(parsePhone("+55 (11) 98888-7777")).toBe("55119888877"); + expect(parsePhone("+55 (11) 98888-7777")).toBe("11988887777"); }); it("should ignore digits after the phone length", () => { expect(parsePhone("11988887777123")).toBe("11988887777"); }); + + it("should remove the country code from every international notation", () => { + expect(parsePhone("+5511988887777")).toBe("11988887777"); + expect(parsePhone("+55 11 98888-7777")).toBe("11988887777"); + expect(parsePhone("5511988887777")).toBe("11988887777"); + expect(parsePhone("005511988887777")).toBe("11988887777"); + expect(parsePhone("+55 (11) 3000-0000")).toBe("1130000000"); + expect(parsePhone("551130000000")).toBe("1130000000"); + }); + + it("should keep a leading 55 that is an area code", () => { + expect(parsePhone("55988887777")).toBe("55988887777"); + expect(parsePhone("(55) 3000-0000")).toBe("5530000000"); + expect(parsePhone("+55 (55) 98888-7777")).toBe("55988887777"); + }); + + it("should keep the digits when the country code leaves an implausible number", () => { + expect(parsePhone("55123")).toBe("55123"); + }); + + it("should keep service numbers untouched", () => { + expect(parsePhone("0800 123 4567")).toBe("08001234567"); + expect(parsePhone("4004-1234")).toBe("40041234"); + expect(parsePhone("190")).toBe("190"); + }); + + it("should return an empty string for nullish values", () => { + // @ts-expect-error + expect(parsePhone(null)).toBe(""); + // @ts-expect-error + expect(parsePhone(undefined)).toBe(""); + }); + + it("should accept numbers", () => { + expect(parsePhone(11988887777)).toBe("11988887777"); + }); }); diff --git a/src/parse-phone/parse-phone.ts b/src/parse-phone/parse-phone.ts index f042a8c9..c5743a22 100644 --- a/src/parse-phone/parse-phone.ts +++ b/src/parse-phone/parse-phone.ts @@ -1,11 +1,29 @@ -import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -import { LENGTH } from "./constants"; +import { PHONE_NATIONAL_MAX_LENGTH } from "../_internals/constants/phone"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { normalizePhone } from "../_internals/normalize-phone/normalize-phone"; /** * Removes phone formatting characters, returns only digits, and caps the result to 11 digits. * + * A Brazilian country code is stripped first, under a single rule: the leading `0055` or `55` + * is removed **only when** the digits left behind are exactly 10 or 11 long, i.e. a plausible + * national number (DDD plus an 8 or 9 digit subscriber number). Any other input keeps its + * digits, so a number from the `55` area code survives: `"55987654321"` would leave only 9 + * 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. + * * @param {string|number} value - The phone value to be parsed. * @returns {string} The phone value without formatting. + * + * @example + * ```typescript + * parsePhone("(11) 98765-4321"); // "11987654321" + * parsePhone("+55 (11) 98765-4321"); // "11987654321" + * parsePhone("5511987654321"); // "11987654321" + * parsePhone("55987654321"); // "55987654321" (area code 55, country code kept out of it) + * ``` + * + * @see Official: https://www.itu.int/rec/T-REC-E.164 */ export const parsePhone = (value: string | number): string => - sanitizeToDigits(value).slice(0, LENGTH); + isNullish(value) ? "" : normalizePhone(value).slice(0, PHONE_NATIONAL_MAX_LENGTH); From 9a3c4c4b4bb53ca0b2903afb6f18dfba34355253 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:32:08 -0300 Subject: [PATCH 4/7] =?UTF-8?q?feat(voter-id):=20support=2013-digit=20S?= =?UTF-8?q?=C3=A3o=20Paulo/Minas=20Gerais=20voter=20ids?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some voter ids issued in São Paulo (UF 01) and Minas Gerais (UF 02) carry a 9 digit sequential number, so the whole id has 13 digits. isValidVoterId accepts them for those two states only; the check digits are computed from the first eight sequential digits, the ninth one is not part of the calculation, exactly as brutils does. formatVoterId uses the 0000 0000 0 00 00 grouping only for a 13 digit value whose UF is 01 or 02, and parseVoterId keeps up to 13 digits in the same case. Every other value behaves as in 2.3.0. --- .../calculate-voter-id-first-digit.test.ts | 31 +++++++ .../calculate-voter-id-first-digit.ts | 48 +++++++++++ .../calculate-voter-id-second-digit.test.ts | 12 +++ .../calculate-voter-id-second-digit.ts | 39 +++++++++ src/_internals/constants/voter-id.ts | 8 ++ src/format-voter-id/format-voter-id.test.ts | 14 ++++ src/format-voter-id/format-voter-id.ts | 39 ++++++++- .../generate-voter-id.test.ts | 9 +++ src/generate-voter-id/generate-voter-id.ts | 70 ++++++---------- .../is-valid-voter-id.test.ts | 57 +++++++++++++ src/is-valid-voter-id/is-valid-voter-id.ts | 81 ++++++++----------- src/parse-voter-id/constants.ts | 6 ++ src/parse-voter-id/parse-voter-id.test.ts | 16 +++- src/parse-voter-id/parse-voter-id.ts | 39 ++++++++- 14 files changed, 368 insertions(+), 101 deletions(-) create mode 100644 src/_internals/calculate-voter-id-first-digit/calculate-voter-id-first-digit.test.ts create mode 100644 src/_internals/calculate-voter-id-first-digit/calculate-voter-id-first-digit.ts create mode 100644 src/_internals/calculate-voter-id-second-digit/calculate-voter-id-second-digit.test.ts create mode 100644 src/_internals/calculate-voter-id-second-digit/calculate-voter-id-second-digit.ts create mode 100644 src/_internals/constants/voter-id.ts 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 new file mode 100644 index 00000000..6520d4bb --- /dev/null +++ b/src/_internals/calculate-voter-id-first-digit/calculate-voter-id-first-digit.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "../test/runtime"; +import { calculateVoterIdFirstDigit } from "./calculate-voter-id-first-digit"; + +describe("calculateVoterIdFirstDigit", () => { + test("should calculate the first digit for an 8-digit sequential number", () => { + expect( + calculateVoterIdFirstDigit({ sequentialNumber: "10238501", federativeUnion: "06" }), + ).toBe(7); + }); + + test("should calculate the first digit for a 9-digit sequential number (SP)", () => { + expect( + calculateVoterIdFirstDigit({ sequentialNumber: "123456788", federativeUnion: "01" }), + ).toBe(9); + }); + + test("should ignore the ninth sequential digit", () => { + expect( + calculateVoterIdFirstDigit({ sequentialNumber: "123456780", federativeUnion: "01" }), + ).toBe(9); + expect( + calculateVoterIdFirstDigit({ sequentialNumber: "123456783", federativeUnion: "01" }), + ).toBe(9); + }); + + test("should apply the SP/MG rule when the remainder is 0", () => { + expect( + calculateVoterIdFirstDigit({ sequentialNumber: "00000000", federativeUnion: "01" }), + ).toBe(1); + }); +}); 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 new file mode 100644 index 00000000..b2bbad73 --- /dev/null +++ b/src/_internals/calculate-voter-id-first-digit/calculate-voter-id-first-digit.ts @@ -0,0 +1,48 @@ +import { NINE_DIGIT_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). */ + sequentialNumber: string; + /** The 2 digit federative unit code of the voter ID. */ + federativeUnion: string; +}; + +/** + * 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). + * + * @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.federativeUnion - The 2-digit federative union code. + * @returns {number} The calculated first verification digit (0-9). + * + * @example + * ```typescript + * calculateVoterIdFirstDigit({ sequentialNumber: "10238501", federativeUnion: "06" }); // 7 + * ``` + */ +export const calculateVoterIdFirstDigit = ({ + sequentialNumber, + federativeUnion, +}: CalculateVoterIdFirstDigitParams): number => { + let sum = 0; + + for (let i = 0; i < SEQUENTIAL_LENGTH; i++) { + // Stryker disable next-line ArithmeticOperator: charCodeAt(i)+48 shifts each digit by 96; with weights 2..9 (summing to 44) the total shift is 96*44=4224=384*11, a multiple of 11, so the mod-11 result is unaffected. + sum += (sequentialNumber.charCodeAt(i) - 48) * (i + 2); + } + + const remainder = sum % 11; + + if (remainder === 0 && NINE_DIGIT_FEDERATIVE_UNION_CODES.includes(federativeUnion)) { + return 1; + } + + return remainder === 10 ? 0 : remainder; +}; diff --git a/src/_internals/calculate-voter-id-second-digit/calculate-voter-id-second-digit.test.ts b/src/_internals/calculate-voter-id-second-digit/calculate-voter-id-second-digit.test.ts new file mode 100644 index 00000000..d47784bc --- /dev/null +++ b/src/_internals/calculate-voter-id-second-digit/calculate-voter-id-second-digit.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "../test/runtime"; +import { calculateVoterIdSecondDigit } from "./calculate-voter-id-second-digit"; + +describe("calculateVoterIdSecondDigit", () => { + test("should calculate the second digit", () => { + expect(calculateVoterIdSecondDigit({ federativeUnion: "06", firstDigit: 7 })).toBe(1); + }); + + test("should apply the SP/MG rule when the remainder is 0", () => { + expect(calculateVoterIdSecondDigit({ federativeUnion: "01", firstDigit: 4 })).toBe(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 new file mode 100644 index 00000000..cc825576 --- /dev/null +++ b/src/_internals/calculate-voter-id-second-digit/calculate-voter-id-second-digit.ts @@ -0,0 +1,39 @@ +import { NINE_DIGIT_FEDERATIVE_UNION_CODES } from "../constants/voter-id"; + +export type CalculateVoterIdSecondDigitParams = { + /** The 2 digit federative unit code of the voter ID. */ + federativeUnion: string; + /** The first check digit, 0 to 9. */ + firstDigit: number; +}; + +/** + * Calculates the second verification digit of a Brazilian voter id (título de eleitor). + * + * @param {CalculateVoterIdSecondDigitParams} params - The calculation parameters. + * @param {string} params.federativeUnion - The 2-digit federative union code. + * @param {number} params.firstDigit - The previously calculated first verification digit. + * @returns {number} The calculated second verification digit (0-9). + * + * @example + * ```typescript + * calculateVoterIdSecondDigit({ federativeUnion: "06", firstDigit: 7 }); // 1 + * ``` + */ +export const calculateVoterIdSecondDigit = ({ + federativeUnion, + firstDigit, +}: CalculateVoterIdSecondDigitParams): number => { + const sum = + (federativeUnion.charCodeAt(0) - 48) * 7 + + (federativeUnion.charCodeAt(1) - 48) * 8 + + firstDigit * 9; + + const remainder = sum % 11; + + if (remainder === 0 && NINE_DIGIT_FEDERATIVE_UNION_CODES.includes(federativeUnion)) { + return 1; + } + + return remainder === 10 ? 0 : remainder; +}; diff --git a/src/_internals/constants/voter-id.ts b/src/_internals/constants/voter-id.ts new file mode 100644 index 00000000..ed350b9a --- /dev/null +++ b/src/_internals/constants/voter-id.ts @@ -0,0 +1,8 @@ +/** + * 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). + */ +export const NINE_DIGIT_FEDERATIVE_UNIONS = ["01", "02"] as const; + +export const NINE_DIGIT_FEDERATIVE_UNION_CODES: readonly string[] = NINE_DIGIT_FEDERATIVE_UNIONS; diff --git a/src/format-voter-id/format-voter-id.test.ts b/src/format-voter-id/format-voter-id.test.ts index 4237b03c..ae130141 100644 --- a/src/format-voter-id/format-voter-id.test.ts +++ b/src/format-voter-id/format-voter-id.test.ts @@ -17,4 +17,18 @@ describe("formatVoterId", () => { expect(formatVoterId("12345678012")).toBe("1234 5678 01 2"); 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 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"); + }); }); diff --git a/src/format-voter-id/format-voter-id.ts b/src/format-voter-id/format-voter-id.ts index df1a0f0e..5aca945f 100644 --- a/src/format-voter-id/format-voter-id.ts +++ b/src/format-voter-id/format-voter-id.ts @@ -1,5 +1,40 @@ +import { NINE_DIGIT_FEDERATIVE_UNION_CODES } from "../_internals/constants/voter-id"; import { format } from "../_internals/format/format"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -export const formatVoterId = (value: string | number): string => - format({ value: sanitizeToDigits(value), pattern: "0000 0000 00 00" }); +const PATTERN = "0000 0000 00 00"; + +const EXTENDED_PATTERN = "0000 0000 0 00 00"; + +const LENGTH = 12; + +/** + * Formats a Brazilian voter id (título de eleitor) for display. + * + * Uses the 12-digit grouping "0000 0000 00 00" by default. When the sanitized value has more + * than 12 digits (São Paulo/Minas Gerais voter ids may have a 9-digit sequential number) the + * 13-digit grouping "0000 0000 0 00 00" is used instead. + * + * @param {string|number} value - The voter id value to be formatted. + * @returns {string} The formatted voter id string. + * + * @example + * ```typescript + * formatVoterId("123456780124"); // "1234 5678 01 24" + * formatVoterId("1234567880191"); // "1234 5678 8 01 91" + * ``` + * + * @see Official: https://www.tse.jus.br/legislacao/compilada/res/2003/resolucao-no-21-538-de-14-de-outubro-de-2003 + */ +export const formatVoterId = (value: string | number): string => { + if (isNullish(value)) return ""; + + const digits = sanitizeToDigits(value); + const federativeUnion = digits.slice(9, 11); + const isExtended = + digits.length > LENGTH && NINE_DIGIT_FEDERATIVE_UNION_CODES.includes(federativeUnion); + const pattern = isExtended ? EXTENDED_PATTERN : PATTERN; + + return format({ value: digits, pattern }); +}; diff --git a/src/generate-voter-id/generate-voter-id.test.ts b/src/generate-voter-id/generate-voter-id.test.ts index e9825ccf..fed3ee11 100644 --- a/src/generate-voter-id/generate-voter-id.test.ts +++ b/src/generate-voter-id/generate-voter-id.test.ts @@ -12,4 +12,13 @@ describe("generateVoterId", () => { it("should generate voter id for a specific state", () => { expect(generateVoterId("SP").slice(8, 10)).toBe("01"); }); + + it("should fall back to the default UF instead of throwing for an unknown state", () => { + // @ts-expect-error + expect(() => generateVoterId("XX")).not.toThrow(); + // @ts-expect-error + const voterId = generateVoterId("XX"); + expect(voterId.slice(8, 10)).toBe("28"); + expect(isValidVoterId(voterId)).toBe(true); + }); }); diff --git a/src/generate-voter-id/generate-voter-id.ts b/src/generate-voter-id/generate-voter-id.ts index dbe1199f..fa3d9a1a 100644 --- a/src/generate-voter-id/generate-voter-id.ts +++ b/src/generate-voter-id/generate-voter-id.ts @@ -1,55 +1,33 @@ +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 type { StateCode } from "../_internals/constants/states"; import { generateRandomNumber } from "../_internals/generate-random-number/generate-random-number"; import { UF_TO_VOTER_ID_CODE } from "../is-valid-voter-id/constants"; -const calculateFirstDigit = ({ - sequentialNumber, - federativeUnion, -}: { - sequentialNumber: string; - federativeUnion: string; -}): number => { - let sum = 0; - - for (let i = 0; i < 8; i++) { - sum += (sequentialNumber.charCodeAt(i) - 48) * (i + 2); - } - - const remainder = sum % 11; - - if (remainder === 0 && (federativeUnion === "01" || federativeUnion === "02")) { - return 1; - } - - return remainder === 10 ? 0 : remainder; -}; - -const calculateSecondDigit = ({ - federativeUnion, - firstDigit, -}: { - federativeUnion: string; - firstDigit: number; -}): number => { - const sum = - (federativeUnion.charCodeAt(0) - 48) * 7 + - (federativeUnion.charCodeAt(1) - 48) * 8 + - firstDigit * 9; - - const remainder = sum % 11; - - if ((federativeUnion === "01" || federativeUnion === "02") && remainder === 0) { - return 1; - } - - return remainder === 10 ? 0 : remainder; -}; - +/** + * Generates a valid random Brazilian voter id (título de eleitor). + * + * Uses `Math.random()` internally, so it is not cryptographically secure, do not use for security purposes. + * + * @param {StateCode | "ZZ"} state - Optional. The Brazilian state code to generate a voter id + * for, or `"ZZ"` for a voter id issued abroad. Defaults to `"ZZ"` when omitted or unknown. + * @returns {string} A valid 12-digit voter id string without formatting. + * + * @example + * ```typescript + * generateVoterId(); // "123456782897" (abroad, UF "28") + * generateVoterId("SP"); // "123456780191" (UF "01") + * generateVoterId("XX" as StateCode); // falls back to "ZZ" instead of throwing + * ``` + * + * @see Official: https://www.tse.jus.br/legislacao/compilada/res/2003/resolucao-no-21-538-de-14-de-outubro-de-2003 + * @see Based on: https://siga0984.wordpress.com/2019/05/01/algoritmos-validacao-de-titulo-de-eleitor/ + */ export const generateVoterId = (state: StateCode | "ZZ" = "ZZ"): string => { - const federativeUnion = UF_TO_VOTER_ID_CODE[state]; + const federativeUnion = UF_TO_VOTER_ID_CODE[state] ?? UF_TO_VOTER_ID_CODE.ZZ; const sequentialNumber = generateRandomNumber(8); - const digit1 = calculateFirstDigit({ sequentialNumber, federativeUnion }); - const digit2 = calculateSecondDigit({ federativeUnion, firstDigit: digit1 }); + const digit1 = calculateVoterIdFirstDigit({ sequentialNumber, federativeUnion }); + const digit2 = calculateVoterIdSecondDigit({ federativeUnion, firstDigit: digit1 }); return `${sequentialNumber}${federativeUnion}${digit1}${digit2}`; }; 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 b5dfa0d0..a026a9e7 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 @@ -10,4 +10,61 @@ describe("isValidVoterId", () => { false, ); }); + + it("should validate a real 12-digit voter id", () => { + 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 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 return false for null, undefined, a number or an empty string", () => { + // @ts-expect-error + expect(isValidVoterId(null)).toBe(false); + // @ts-expect-error + expect(isValidVoterId(undefined)).toBe(false); + // @ts-expect-error + expect(isValidVoterId(123456780124)).toBe(false); + expect(isValidVoterId("")).toBe(false); + }); }); 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 30227d0a..ffecfc0d 100644 --- a/src/is-valid-voter-id/is-valid-voter-id.ts +++ b/src/is-valid-voter-id/is-valid-voter-id.ts @@ -1,63 +1,48 @@ +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_UNIONS } from "../_internals/constants/voter-id"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; const isValidLength = (value: string): boolean => { if (value.length === 12) return true; const federativeUnion = value.slice(-4, -2); - return value.length === 13 && (federativeUnion === "01" || federativeUnion === "02"); -}; - -const calculateFirstDigit = ({ - sequentialNumber, - federativeUnion, -}: { - sequentialNumber: string; - federativeUnion: string; -}): number => { - let sum = 0; - - for (let i = 0; i < 8; i++) { - sum += (sequentialNumber.charCodeAt(i) - 48) * (i + 2); - } - - const remainder = sum % 11; - - if (remainder === 0 && (federativeUnion === "01" || federativeUnion === "02")) { - return 1; - } - - return remainder === 10 ? 0 : remainder; -}; - -const calculateSecondDigit = ({ - federativeUnion, - firstDigit, -}: { - federativeUnion: string; - firstDigit: number; -}): number => { - const sum = - (federativeUnion.charCodeAt(0) - 48) * 7 + - (federativeUnion.charCodeAt(1) - 48) * 8 + - firstDigit * 9; - - const remainder = sum % 11; - - if ((federativeUnion === "01" || federativeUnion === "02") && remainder === 0) { - return 1; - } - - return remainder === 10 ? 0 : remainder; + return ( + value.length === 13 && + (NINE_DIGIT_FEDERATIVE_UNIONS as readonly string[]).includes(federativeUnion) + ); }; +/** + * 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. + * + * @param {string} value - The voter id value to be validated. + * @returns {boolean} True if the voter id is valid, false otherwise. + * + * @example + * ```typescript + * isValidVoterId("102385010671"); // true (12 digits) + * isValidVoterId("1234567880191"); // true (13 digits, São Paulo) + * isValidVoterId("123456780124"); // false (invalid checksum) + * ``` + * + * @see Official: https://www.tse.jus.br/legislacao/compilada/res/2003/resolucao-no-21-538-de-14-de-outubro-de-2003 + * @see Based on: https://siga0984.wordpress.com/2019/05/01/algoritmos-validacao-de-titulo-de-eleitor/ + * @see Based on: https://github.com/brazilian-utils/brutils-python/blob/main/brutils/voter_id.py (13-digit São Paulo and Minas Gerais ids) + */ export const isValidVoterId = (value: string): boolean => { - if (!value || typeof value !== "string") return false; + if (typeof value !== "string" || value === "") return false; const digits = sanitizeToDigits(value); if (!isValidLength(digits)) return false; - const sequentialNumber = digits.slice(0, 8); + // 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); @@ -65,8 +50,8 @@ export const isValidVoterId = (value: string): boolean => { if (!Number.isInteger(ufCode) || ufCode < 1 || ufCode > 28) return false; - const digit1 = calculateFirstDigit({ sequentialNumber, federativeUnion }); - const digit2 = calculateSecondDigit({ federativeUnion, firstDigit: digit1 }); + const digit1 = calculateVoterIdFirstDigit({ sequentialNumber, federativeUnion }); + const digit2 = calculateVoterIdSecondDigit({ federativeUnion, firstDigit: digit1 }); return verifier === `${digit1}${digit2}`; }; diff --git a/src/parse-voter-id/constants.ts b/src/parse-voter-id/constants.ts index 9a6bb7ff..23ace8cc 100644 --- a/src/parse-voter-id/constants.ts +++ b/src/parse-voter-id/constants.ts @@ -1 +1,7 @@ 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 b20883d7..e031adcb 100644 --- a/src/parse-voter-id/parse-voter-id.test.ts +++ b/src/parse-voter-id/parse-voter-id.test.ts @@ -6,7 +6,19 @@ describe("parseVoterId", () => { expect(parseVoterId("1234 5678 01 24")).toBe("123456780124"); }); - it("should ignore digits after the voter id length", () => { - expect(parseVoterId("12345678012499")).toBe("123456780124"); + it("should ignore digits after the voter id length (non SP/MG)", () => { + 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 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"); }); }); diff --git a/src/parse-voter-id/parse-voter-id.ts b/src/parse-voter-id/parse-voter-id.ts index 928901d1..b99eb83e 100644 --- a/src/parse-voter-id/parse-voter-id.ts +++ b/src/parse-voter-id/parse-voter-id.ts @@ -1,5 +1,38 @@ +import { NINE_DIGIT_FEDERATIVE_UNIONS } from "../_internals/constants/voter-id"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -import { LENGTH } from "./constants"; +import { EXTENDED_LENGTH, LENGTH } from "./constants"; -export const parseVoterId = (value: string | number): string => - sanitizeToDigits(value).slice(0, LENGTH); +/** + * 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. + * + * @param {string|number} value - The voter id value to be parsed. + * @returns {string} The voter id value without formatting. + * + * @example + * ```typescript + * parseVoterId("1234 5678 01 24"); // "123456780124" + * parseVoterId("1234 5678 8 01 91"); // "1234567880191" + * ``` + * + * @see Official: https://www.tse.jus.br/legislacao/compilada/res/2003/resolucao-no-21-538-de-14-de-outubro-de-2003 + */ +export const parseVoterId = (value: string | number): string => { + if (isNullish(value)) return ""; + + const digits = sanitizeToDigits(value); + + const federativeUnion = digits.slice(9, 11); + + const maxLength = + digits.length > LENGTH && + (NINE_DIGIT_FEDERATIVE_UNIONS as readonly string[]).includes(federativeUnion) + ? EXTENDED_LENGTH + : LENGTH; + + return digits.slice(0, maxLength); +}; From 65c118c338dcae8798c6519344f02105023f648d Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:32:08 -0300 Subject: [PATCH 5/7] feat(cnpj): add an obfuscate option to formatCnpj formatCnpj(value, { obfuscate: true }) -> "**.345.678/0001-**", the gov.br / Receita Federal display convention; applies to both versions. fix: isValidCnpj("q0slfmbd7vx439", { version: 2 }) -> true (was false, lowercase alphanumeric CNPJ rejected); accept whitespace and common separators. fix: generateCnpj generates alphanumeric CNPJs using the full A-Z alphabet. --- src/_internals/constants/cnpj.ts | 6 +++ src/format-cnpj/constants.ts | 9 +++- src/format-cnpj/format-cnpj.test.ts | 27 ++++++++++- src/format-cnpj/format-cnpj.ts | 25 ++++++++--- src/generate-cnpj/constants.ts | 1 - src/generate-cnpj/generate-cnpj.test.ts | 40 ++++++++++------- src/generate-cnpj/generate-cnpj.ts | 30 ++++++++----- src/is-valid-cnpj/constants.ts | 2 - src/is-valid-cnpj/is-valid-cnpj.test.ts | 34 +++++++++++--- src/is-valid-cnpj/is-valid-cnpj.ts | 59 ++++++++++++++++--------- src/parse-cnpj/parse-cnpj.ts | 24 +++++++--- 11 files changed, 185 insertions(+), 72 deletions(-) create mode 100644 src/_internals/constants/cnpj.ts delete mode 100644 src/generate-cnpj/constants.ts diff --git a/src/_internals/constants/cnpj.ts b/src/_internals/constants/cnpj.ts new file mode 100644 index 00000000..3b1de61c --- /dev/null +++ b/src/_internals/constants/cnpj.ts @@ -0,0 +1,6 @@ +/** Characters of a CNPJ (numeric or alphanumeric). */ +export const CNPJ_LENGTH = 14; + +export const CNPJ_FIRST_DIGIT_WEIGHTS = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]; + +export const CNPJ_SECOND_DIGIT_WEIGHTS = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]; diff --git a/src/format-cnpj/constants.ts b/src/format-cnpj/constants.ts index a86e9378..bfcc8191 100644 --- a/src/format-cnpj/constants.ts +++ b/src/format-cnpj/constants.ts @@ -1 +1,8 @@ -export const LENGTH = 14; +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. + */ +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 0b7c99ae..13850a4f 100644 --- a/src/format-cnpj/format-cnpj.test.ts +++ b/src/format-cnpj/format-cnpj.test.ts @@ -1,5 +1,5 @@ +import { CNPJ_LENGTH } from "../_internals/constants/cnpj"; import { describe, expect, it } from "../_internals/test/runtime"; -import { LENGTH } from "./constants"; import { formatCnpj } from "./format-cnpj"; describe("formatCnpj", () => { @@ -73,7 +73,7 @@ describe("formatCnpj", () => { expect(formatCnpj(46843485000186, { pad: true })).toBe("46.843.485/0001-86"); }); - it(`should NOT add digits after the CNPJ length (${LENGTH})`, () => { + it(`should NOT add digits after the CNPJ length (${CNPJ_LENGTH})`, () => { expect(formatCnpj("468434850001860000000000")).toBe("46.843.485/0001-86"); }); @@ -108,4 +108,27 @@ describe("formatCnpj", () => { expect(formatCnpj("12.ABC.345/01DE-35", { version: 2 })).toBe("12.ABC.345/01DE-35"); expect(formatCnpj("12OUT345000199", { version: 2 })).toBe("12.OUT.345/0001-99"); }); + + it("should hide the first 2 digits and the 2 check digits when obfuscate is true", () => { + expect(formatCnpj("46843485000186", { obfuscate: true })).toBe("**.843.485/0001-**"); + expect(formatCnpj(46843485000186, { obfuscate: true })).toBe("**.843.485/0001-**"); + }); + + it("should pad before obfuscating", () => { + expect(formatCnpj("4", { pad: true, obfuscate: true })).toBe("**.000.000/0000-**"); + }); + + it("should apply the same positions to the alphanumeric CNPJ (version 2)", () => { + expect(formatCnpj("q0SLFMBD7VX439", { version: 2, obfuscate: true })).toBe( + "**.SLF.MBD/7VX4-**", + ); + }); + + it("should behave exactly as without the option when obfuscate is false or absent", () => { + expect(formatCnpj("46843485000186", { obfuscate: false })).toBe("46.843.485/0001-86"); + expect(formatCnpj("46843485000186")).toBe("46.843.485/0001-86"); + expect(formatCnpj("q0SLFMBD7VX439", { version: 2, obfuscate: false })).toBe( + "Q0.SLF.MBD/7VX4-39", + ); + }); }); diff --git a/src/format-cnpj/format-cnpj.ts b/src/format-cnpj/format-cnpj.ts index 9c2cec4c..a1b5422a 100644 --- a/src/format-cnpj/format-cnpj.ts +++ b/src/format-cnpj/format-cnpj.ts @@ -1,8 +1,15 @@ import { type FormatParams, format } from "../_internals/format/format"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToAlphanumeric } from "../_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { OBFUSCATED_PATTERN, PATTERN } from "./constants"; -export type FormatCnpjOptions = Pick & { version?: 1 | 2 }; +export type FormatCnpjOptions = Pick & { + /** Which CNPJ format to read: `1` numeric only, `2` alphanumeric (default: `1`). */ + version?: 1 | 2; + /** Whether to hide the first 2 digits and the 2 check digits with `*` (default: `false`). */ + obfuscate?: boolean; +}; const sanitize = (value: string | number, version?: FormatCnpjOptions["version"]) => { if (version === 2) { @@ -16,9 +23,10 @@ const sanitize = (value: string | number, version?: FormatCnpjOptions["version"] * Formats a given CNPJ (Cadastro Nacional da Pessoa Jurídica) value according to the specified options. * * @param {string|number} value - The CNPJ value to be formatted. It can be a string or a number. - * @param {Object} options - Optional configuration for formatting the CNPJ. + * @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. * @param {1|2} options.version - The version of the CNPJ to be sanitized. + * @param {boolean} options.obfuscate - If true, hides the first 2 digits and the 2 check digits. * @returns {string} The formatted CNPJ string in the pattern "00.000.000/0000-00". * * @example @@ -28,11 +36,18 @@ const sanitize = (value: string | number, version?: FormatCnpjOptions["version"] * formatCnpj("12345678000195", { pad: true }); // "12.345.678/0001-95" * 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-**" * ``` + * + * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/cnpj + * @see Official: https://www.gov.br/receitafederal/pt-br/acesso-a-informacao/acoes-e-programas/programas-e-atividades/cnpj-alfanumerico */ -export const formatCnpj = (value: string | number, options?: FormatCnpjOptions): string => - format({ +export const formatCnpj = (value: string | number, options?: FormatCnpjOptions): string => { + if (isNullish(value)) return ""; + + return format({ pad: options?.pad, value: sanitize(value, options?.version), - pattern: "00.000.000/0000-00", + pattern: options?.obfuscate ? OBFUSCATED_PATTERN : PATTERN, }); +}; diff --git a/src/generate-cnpj/constants.ts b/src/generate-cnpj/constants.ts deleted file mode 100644 index a86e9378..00000000 --- a/src/generate-cnpj/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const LENGTH = 14; diff --git a/src/generate-cnpj/generate-cnpj.test.ts b/src/generate-cnpj/generate-cnpj.test.ts index 8c9504f5..c77a9e0f 100644 --- a/src/generate-cnpj/generate-cnpj.test.ts +++ b/src/generate-cnpj/generate-cnpj.test.ts @@ -1,33 +1,48 @@ +import { CNPJ_LENGTH } from "../_internals/constants/cnpj"; import { describe, expect, test } from "../_internals/test/runtime"; import { isValidCnpj } from "../is-valid-cnpj/is-valid-cnpj"; -import { LENGTH } from "./constants"; import { generateCnpj } from "./generate-cnpj"; describe("generateCnpj", () => { describe("version 1 (numeric)", () => { test("should generate a valid numeric CNPJ", () => { const cnpj = generateCnpj(1); - expect(cnpj).toHaveLength(LENGTH); + expect(cnpj).toHaveLength(CNPJ_LENGTH); expect(/^\d+$/.test(cnpj)).toBe(true); expect(isValidCnpj(cnpj)).toBe(true); }); test("should generate a valid numeric CNPJ by default", () => { const cnpj = generateCnpj(); - expect(cnpj).toHaveLength(LENGTH); + expect(cnpj).toHaveLength(CNPJ_LENGTH); expect(/^\d+$/.test(cnpj)).toBe(true); expect(isValidCnpj(cnpj)).toBe(true); }); - test("should generate different numeric CNPJs on multiple calls", () => { + test("should regenerate the base when it comes out with repeated digits", () => { + const digits = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2]; + const originalRandom = Math.random; + let call = 0; + + Math.random = () => (digits[call++] + 0.5) / 10; + + try { + const cnpj = generateCnpj(1); + + expect(cnpj.slice(0, 12)).toBe("123456789012"); + expect(isValidCnpj(cnpj)).toBe(true); + } finally { + Math.random = originalRandom; + } + }); + + test("should generate different numeric CNPJs on multiple calls, retrying more draws on the rare chance of a collision", () => { const cnpj1 = generateCnpj(1); const cnpj2 = generateCnpj(1); const cnpj3 = generateCnpj(1); - // Very unlikely but possible to generate same CNPJ const allSame = cnpj1 === cnpj2 && cnpj2 === cnpj3; if (allSame) { - // If all same, generate more to verify randomness const set = new Set([cnpj1, generateCnpj(1), generateCnpj(1)]); expect(set.size).toBeGreaterThan(1); } @@ -36,7 +51,6 @@ describe("generateCnpj", () => { test("should generate valid numeric CNPJs that pass validation with formatting", () => { for (let i = 0; i < 10; i++) { const cnpj = generateCnpj(1); - // Add formatting and validate const formatted = `${cnpj.slice(0, 2)}.${cnpj.slice(2, 5)}.${cnpj.slice(5, 8)}/${cnpj.slice(8, 12)}-${cnpj.slice(12)}`; expect(isValidCnpj(formatted)).toBe(true); } @@ -46,20 +60,18 @@ describe("generateCnpj", () => { describe("version 2 (alphanumeric)", () => { test("should generate a valid alphanumeric CNPJ", () => { const cnpj = generateCnpj(2); - expect(cnpj).toHaveLength(LENGTH); + expect(cnpj).toHaveLength(CNPJ_LENGTH); expect(/^[0-9A-Z]+$/.test(cnpj)).toBe(true); expect(isValidCnpj(cnpj, { version: 2 })).toBe(true); }); - test("should generate different alphanumeric CNPJs on multiple calls", () => { + test("should generate different alphanumeric CNPJs on multiple calls, retrying more draws on the rare chance of a collision", () => { const cnpj1 = generateCnpj(2); const cnpj2 = generateCnpj(2); const cnpj3 = generateCnpj(2); - // Very unlikely but possible to generate same CNPJ const allSame = cnpj1 === cnpj2 && cnpj2 === cnpj3; if (allSame) { - // If all same, generate more to verify randomness const set = new Set([cnpj1, generateCnpj(2), generateCnpj(2)]); expect(set.size).toBeGreaterThan(1); } @@ -68,22 +80,18 @@ describe("generateCnpj", () => { test("should generate valid alphanumeric CNPJs that pass validation with formatting", () => { for (let i = 0; i < 10; i++) { const cnpj = generateCnpj(2); - // Add formatting and validate const formatted = `${cnpj.slice(0, 2)}.${cnpj.slice(2, 5)}.${cnpj.slice(5, 8)}/${cnpj.slice(8, 12)}-${cnpj.slice(12)}`; expect(isValidCnpj(formatted, { version: 2 })).toBe(true); } }); - test("should generate alphanumeric CNPJs spanning the full A-Z alphabet", () => { + 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(); for (let i = 0; i < 1000; i++) { for (const char of generateCnpj(2)) { usedChars.add(char); } } - // E, O, T and U were previously excluded by a restricted alphabet, even - // though isValidCnpj accepts them: the RFB alphanumeric CNPJ uses the full - // [A-Z0-9] base (official example "12.ABC.345/01DE-35" contains an "E"). for (const char of ["E", "O", "T", "U"]) { expect(usedChars.has(char)).toBe(true); } diff --git a/src/generate-cnpj/generate-cnpj.ts b/src/generate-cnpj/generate-cnpj.ts index 589912b3..087c4732 100644 --- a/src/generate-cnpj/generate-cnpj.ts +++ b/src/generate-cnpj/generate-cnpj.ts @@ -1,12 +1,10 @@ +import { CNPJ_FIRST_DIGIT_WEIGHTS, CNPJ_SECOND_DIGIT_WEIGHTS } from "../_internals/constants/cnpj"; import { generateChecksum } from "../_internals/generate-checksum/generate-checksum"; import { generateRandomNumber } from "../_internals/generate-random-number/generate-random-number"; +import { isRepeatedDigits } from "../_internals/is-repeated-digits/is-repeated-digits"; const BASE_LENGTH = 12; -const FIRST_CHECK_DIGIT_WEIGHTS = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]; - -const SECOND_CHECK_DIGIT_WEIGHTS = [6, ...FIRST_CHECK_DIGIT_WEIGHTS]; - const VALID_CNPJ_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; const generateRandomCnpjChar = (): string => @@ -20,6 +18,14 @@ const generateAlphanumericCnpjBase = (): string => { return base; }; +const generateNonRepeatedBase = (generate: () => string): string => { + let base = generate(); + while (isRepeatedDigits(base)) { + base = generate(); + } + return base; +}; + const charToCnpjValue = (char: string): number => char.charCodeAt(0) - 48; const generateAlphanumericChecksum = (cnpj: string, weights: number[]): number => { @@ -41,18 +47,18 @@ const calculateAlphanumericCheckDigit = (base: string, weights: number[]): strin }; const generateNumericCnpj = (): string => { - const base = generateRandomNumber(BASE_LENGTH); - const firstCheckDigit = calculateCheckDigit(base, FIRST_CHECK_DIGIT_WEIGHTS); - const secondCheckDigit = calculateCheckDigit(base + firstCheckDigit, SECOND_CHECK_DIGIT_WEIGHTS); + const base = generateNonRepeatedBase(() => generateRandomNumber(BASE_LENGTH)); + const firstCheckDigit = calculateCheckDigit(base, CNPJ_FIRST_DIGIT_WEIGHTS); + const secondCheckDigit = calculateCheckDigit(base + firstCheckDigit, CNPJ_SECOND_DIGIT_WEIGHTS); return base + firstCheckDigit + secondCheckDigit; }; const generateAlphanumericCnpj = (): string => { - const base = generateAlphanumericCnpjBase(); - const firstCheckDigit = calculateAlphanumericCheckDigit(base, FIRST_CHECK_DIGIT_WEIGHTS); + const base = generateNonRepeatedBase(generateAlphanumericCnpjBase); + const firstCheckDigit = calculateAlphanumericCheckDigit(base, CNPJ_FIRST_DIGIT_WEIGHTS); const secondCheckDigit = calculateAlphanumericCheckDigit( base + firstCheckDigit, - SECOND_CHECK_DIGIT_WEIGHTS, + CNPJ_SECOND_DIGIT_WEIGHTS, ); return base + firstCheckDigit + secondCheckDigit; }; @@ -60,6 +66,8 @@ const generateAlphanumericCnpj = (): string => { /** * Generates a valid random CNPJ (Cadastro Nacional da Pessoa Jurídica). * + * Uses `Math.random()` internally, so it is not cryptographically secure, do not use for security purposes. + * * @param {1 | 2} version - The version of the CNPJ to be generated. * @returns {string} A valid 14-digit CNPJ string without formatting. * @@ -68,6 +76,8 @@ const generateAlphanumericCnpj = (): string => { * generateCnpj(); // "12345678000195" * generateCnpj(2); // "Q0SLFMBD7VX439" * ``` + * + * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/cnpj */ export const generateCnpj = (version?: 1 | 2): string => { const versionToUse = version ?? 1; diff --git a/src/is-valid-cnpj/constants.ts b/src/is-valid-cnpj/constants.ts index 05b1f0fb..d5312c4a 100644 --- a/src/is-valid-cnpj/constants.ts +++ b/src/is-valid-cnpj/constants.ts @@ -10,5 +10,3 @@ export const RESERVED_NUMBERS = [ "88888888888888", "99999999999999", ]; - -export const LENGTH = 14; diff --git a/src/is-valid-cnpj/is-valid-cnpj.test.ts b/src/is-valid-cnpj/is-valid-cnpj.test.ts index f2dd4f5c..a5956ffb 100644 --- a/src/is-valid-cnpj/is-valid-cnpj.test.ts +++ b/src/is-valid-cnpj/is-valid-cnpj.test.ts @@ -1,6 +1,7 @@ +import { CNPJ_LENGTH } from "../_internals/constants/cnpj"; import { describe, expect, test } from "../_internals/test/runtime"; import { generateCnpj } from "../generate-cnpj/generate-cnpj"; -import { LENGTH, RESERVED_NUMBERS } from "./constants"; +import { RESERVED_NUMBERS } from "./constants"; import { isValidCnpj } from "./is-valid-cnpj"; describe("isValidCnpj", () => { @@ -42,7 +43,7 @@ describe("isValidCnpj", () => { expect(isValidCnpj([])).toBe(false); }); - test(`when dont match with CNPJ length (${LENGTH})`, () => { + test(`when dont match with CNPJ length (${CNPJ_LENGTH})`, () => { expect(isValidCnpj("12312312312")).toBe(false); }); @@ -58,10 +59,23 @@ describe("isValidCnpj", () => { expect(isValidCnpj("11257245286531")).toBe(false); }); - test("when is an invalid alphanumeric CNPJ", () => { - expect(isValidCnpj("12.ABC.345/01DE-99")).toBe(false); // Invalid DV - expect(isValidCnpj("AB.1C2.D3E/4F5G-3")).toBe(false); // Too short - expect(isValidCnpj("AB.1C2.D3E/4F5G-356")).toBe(false); // Too long + test("when an alphanumeric CNPJ has an invalid check digit", () => { + expect(isValidCnpj("12.ABC.345/01DE-99")).toBe(false); + }); + + test("when an alphanumeric CNPJ is too short", () => { + expect(isValidCnpj("AB.1C2.D3E/4F5G-3")).toBe(false); + }); + + test("when an alphanumeric CNPJ is too long", () => { + expect(isValidCnpj("AB.1C2.D3E/4F5G-356")).toBe(false); + }); + + test("should return false quickly for a 1MB garbage string", () => { + const garbage = "a".repeat(1_000_000); + const start = Date.now(); + expect(isValidCnpj(garbage)).toBe(false); + expect(Date.now() - start).toBeLessThan(1000); }); }); @@ -74,6 +88,14 @@ describe("isValidCnpj", () => { expect(isValidCnpj("60.391.947/0001-00")).toBe(true); }); + test("when is a CNPJ valid with a whitespace mask", () => { + expect(isValidCnpj("11 222 333 0001 81")).toBe(true); + }); + + test("when is a lowercase alphanumeric CNPJ", () => { + expect(isValidCnpj("q0slfmbd7vx439", { version: 2 })).toBe(true); + }); + for (let i = 0; i < 100; i++) { const version = ((i % 2) + 1) as 1 | 2; const cnpj = generateCnpj(version); diff --git a/src/is-valid-cnpj/is-valid-cnpj.ts b/src/is-valid-cnpj/is-valid-cnpj.ts index 9eb90b5b..09c65341 100644 --- a/src/is-valid-cnpj/is-valid-cnpj.ts +++ b/src/is-valid-cnpj/is-valid-cnpj.ts @@ -1,19 +1,26 @@ +import { + CNPJ_FIRST_DIGIT_WEIGHTS, + CNPJ_LENGTH, + CNPJ_SECOND_DIGIT_WEIGHTS, +} from "../_internals/constants/cnpj"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -import { LENGTH, RESERVED_NUMBERS } from "./constants"; +import { RESERVED_NUMBERS } from "./constants"; -const RESERVED_SET = new Set(RESERVED_NUMBERS); - -const WEIGHTS_1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]; - -const WEIGHTS_2 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]; +export type IsValidCnpjOptions = { + /** Which CNPJ format to accept: `1` numeric only, `2` alphanumeric (default: `1`). */ + version?: 1 | 2; +}; -const FORMAT_REGEX = /^[0-9A-Z]{2}\.?[0-9A-Z]{3}\.?[0-9A-Z]{3}\/?[0-9A-Z]{4}-?[0-9]{2}$/; +const FORMAT_REGEX = + /^[0-9A-Z]{2}[\s.\-/]*[0-9A-Z]{3}[\s.\-/]*[0-9A-Z]{3}[\s.\-/]*[0-9A-Z]{4}[\s.\-/]*[0-9]{2}$/; -const NUMERIC_FORMAT_REGEX = /^\d{2}\.?\d{3}\.?\d{3}\/?\d{4}-?\d{2}$/; +const NUMERIC_FORMAT_REGEX = /^\d{2}[\s.\-/]*\d{3}[\s.\-/]*\d{3}[\s.\-/]*\d{4}[\s.\-/]*\d{2}$/; const cleanCnpj = (cnpj: string): string => { let result = ""; for (let i = 0; i < cnpj.length; i++) { + if (result.length > CNPJ_LENGTH) break; + const char = cnpj[i]; if ( (char >= "0" && char <= "9") || @@ -27,19 +34,17 @@ const cleanCnpj = (cnpj: string): string => { }; const isValidChecksum = (cnpj: string): boolean => { - // First digit (index 12) let sum = 0; for (let i = 0; i < 12; i++) { - sum += (cnpj.charCodeAt(i) - 48) * WEIGHTS_1[i]; + sum += (cnpj.charCodeAt(i) - 48) * CNPJ_FIRST_DIGIT_WEIGHTS[i]; } let mod = sum % 11; const expected1 = mod < 2 ? 48 : 48 + 11 - mod; if (cnpj.charCodeAt(12) !== expected1) return false; - // Second digit (index 13) sum = 0; for (let i = 0; i < 13; i++) { - sum += (cnpj.charCodeAt(i) - 48) * WEIGHTS_2[i]; + sum += (cnpj.charCodeAt(i) - 48) * CNPJ_SECOND_DIGIT_WEIGHTS[i]; } mod = sum % 11; const expected2 = mod < 2 ? 48 : 48 + 11 - mod; @@ -49,11 +54,12 @@ const isValidChecksum = (cnpj: string): boolean => { /** * Validates if a CNPJ (Cadastro Nacional da Pessoa Jurídica) is valid. * Supports both numeric (version 1) and alphanumeric (version 2) CNPJ formats. + * Accepts the usual mask characters (`.`, `-`, `/`) and whitespace around and between groups. * * @param {string} cnpj - The CNPJ value to be validated. - * @param {{version?: 1|2}} [options] - Optional options: - * version = 1 -> validate numeric-only format (default) - * version = 2 -> validate both numeric and alphanumeric formats + * @param {IsValidCnpjOptions} [options] - Optional options. + * @param {1|2} [options.version] - `1` validates the numeric-only format (the default), + * `2` validates both the numeric and the alphanumeric formats. * @returns {boolean} True if the CNPJ is valid, false otherwise. * * @example @@ -61,20 +67,27 @@ const isValidChecksum = (cnpj: string): boolean => { * // Version 1 (numeric - default) * isValidCnpj("12.345.678/0001-95"); // true * isValidCnpj("12345678000195"); // true + * isValidCnpj("12 345 678 0001 95"); // true (whitespace mask) * isValidCnpj("00000000000000"); // false (reserved number) * isValidCnpj("12345678000190"); // false (invalid checksum) * * // 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) * ``` + * + * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/cnpj + * @see Official: https://www.gov.br/receitafederal/pt-br/acesso-a-informacao/acoes-e-programas/programas-e-atividades/cnpj-alfanumerico */ -export const isValidCnpj = (cnpj: string, options?: { version?: 1 | 2 }): boolean => { - if (!cnpj || typeof cnpj !== "string") return false; +export const isValidCnpj = (cnpj: string, options?: IsValidCnpjOptions): boolean => { + if (typeof cnpj !== "string" || cnpj === "") return false; const cleaned = cleanCnpj(cnpj); - if (cleaned.length !== LENGTH) return false; + if (cleaned.length !== CNPJ_LENGTH) return false; + + const trimmed = cnpj.trim(); const version = options?.version ?? 1; @@ -82,11 +95,11 @@ export const isValidCnpj = (cnpj: string, options?: { version?: 1 | 2 }): boolea let hasLetter = false; if (version !== 1) { - for (let i = 0; i < LENGTH; i++) { + for (let i = 0; i < CNPJ_LENGTH; i++) { const code = cleaned.charCodeAt(i); if (code < 48 || code > 57) { isNumeric = false; - if (code >= 65 && code <= 90) hasLetter = true; + hasLetter = true; } } } @@ -95,9 +108,11 @@ export const isValidCnpj = (cnpj: string, options?: { version?: 1 | 2 }): boolea const numeric = sanitizeToDigits(cnpj); return ( - NUMERIC_FORMAT_REGEX.test(cnpj) && !RESERVED_SET.has(numeric) && isValidChecksum(numeric) + NUMERIC_FORMAT_REGEX.test(trimmed) && + !RESERVED_NUMBERS.includes(numeric) && + isValidChecksum(numeric) ); } - return hasLetter && FORMAT_REGEX.test(cnpj) && isValidChecksum(cleaned); + return hasLetter && FORMAT_REGEX.test(trimmed.toUpperCase()) && isValidChecksum(cleaned); }; diff --git a/src/parse-cnpj/parse-cnpj.ts b/src/parse-cnpj/parse-cnpj.ts index 3e00d08e..55b39048 100644 --- a/src/parse-cnpj/parse-cnpj.ts +++ b/src/parse-cnpj/parse-cnpj.ts @@ -1,8 +1,11 @@ +import { CNPJ_LENGTH } from "../_internals/constants/cnpj"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToAlphanumeric } from "../_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -import { LENGTH } from "../format-cnpj/constants"; import type { FormatCnpjOptions } from "../format-cnpj/format-cnpj"; +export type ParseCnpjOptions = Pick; + const sanitize = (value: string | number, version?: FormatCnpjOptions["version"]): string => { if (version === 2) { return sanitizeToAlphanumeric(value); @@ -15,11 +18,18 @@ const sanitize = (value: string | number, version?: FormatCnpjOptions["version"] * Removes CNPJ formatting characters and returns a normalized value. * * @param {string|number} value - The CNPJ value to be parsed. - * @param {Object} options - Optional parsing options. - * @param {1|2} options.version - The CNPJ version to normalize. + * @param {ParseCnpjOptions} [options] - Optional parsing options. + * @param {1|2} [options.version] - The CNPJ version to normalize. * @returns {string} The CNPJ value without formatting. + * + * @example + * ```typescript + * parseCnpj("11.222.333/0001-81"); // "11222333000181" + * parseCnpj("12.ABC.345/01DE-35", { version: 2 }); // "12ABC34501DE35" + * ``` + * + * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/cnpj + * @see Official: https://www.gov.br/receitafederal/pt-br/acesso-a-informacao/acoes-e-programas/programas-e-atividades/cnpj-alfanumerico */ -export const parseCnpj = ( - value: string | number, - options?: Pick, -): string => sanitize(value, options?.version).slice(0, LENGTH); +export const parseCnpj = (value: string | number, options?: ParseCnpjOptions): string => + isNullish(value) ? "" : sanitize(value, options?.version).slice(0, CNPJ_LENGTH); From b6150aba86e1277ffc7ae8c69cb52039bf21c6b6 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:32:08 -0300 Subject: [PATCH 6/7] feat(cpf): add an obfuscate option to formatCpf formatCpf(value, { obfuscate: true }) -> "***.456.789-**", the gov.br / Receita Federal display convention. fix: isValidCpf accepts whitespace and the common separators between groups. fix: generateCpf retries when the generated base would be a repeated-digit sequence. --- src/format-cpf/constants.ts | 8 +++++++- src/format-cpf/format-cpf.test.ts | 24 ++++++++++++++++++++++-- src/format-cpf/format-cpf.ts | 23 ++++++++++++++++++----- src/generate-cpf/constants.ts | 1 - src/generate-cpf/generate-cpf.test.ts | 25 +++++++++++++++++++++---- src/generate-cpf/generate-cpf.ts | 16 ++++++++++++---- src/is-valid-cpf/constants.ts | 2 -- src/is-valid-cpf/is-valid-cpf.test.ts | 14 ++++++++++++-- src/is-valid-cpf/is-valid-cpf.ts | 22 ++++++++++++---------- 9 files changed, 104 insertions(+), 31 deletions(-) diff --git a/src/format-cpf/constants.ts b/src/format-cpf/constants.ts index 5720885c..fed79edb 100644 --- a/src/format-cpf/constants.ts +++ b/src/format-cpf/constants.ts @@ -1 +1,7 @@ -export const LENGTH = 11; +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-**". + */ +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 c031aa95..1835d484 100644 --- a/src/format-cpf/format-cpf.test.ts +++ b/src/format-cpf/format-cpf.test.ts @@ -1,5 +1,5 @@ +import { CPF_LENGTH } from "../_internals/constants/cpf"; import { describe, expect, it } from "../_internals/test/runtime"; -import { LENGTH } from "./constants"; import { formatCpf } from "./format-cpf"; describe("formatCpf", () => { @@ -61,11 +61,31 @@ describe("formatCpf", () => { expect(formatCpf(94389575104, { pad: true })).toBe("943.895.751-04"); }); - it(`should NOT add digits after the CPF length (${LENGTH})`, () => { + it(`should NOT add digits after the CPF length (${CPF_LENGTH})`, () => { expect(formatCpf("94389575104000000")).toBe("943.895.751-04"); }); it("should remove all non numeric characters", () => { expect(formatCpf("943.?ABC895.751-04abc")).toBe("943.895.751-04"); }); + + it("should hide the first 3 digits and the 2 check digits when obfuscate is true", () => { + expect(formatCpf("94389575104", { obfuscate: true })).toBe("***.895.751-**"); + expect(formatCpf(94389575104, { obfuscate: true })).toBe("***.895.751-**"); + }); + + it("should pad before obfuscating", () => { + expect(formatCpf("9", { pad: true, obfuscate: true })).toBe("***.000.000-**"); + expect(formatCpf("943", { pad: true, obfuscate: true })).toBe("***.000.009-**"); + }); + + it("should obfuscate a short, unpadded value as far as it goes", () => { + expect(formatCpf("9438", { obfuscate: true })).toBe("***.8"); + }); + + it("should behave exactly as without the option when obfuscate is false or absent", () => { + expect(formatCpf("94389575104", { obfuscate: false })).toBe("943.895.751-04"); + expect(formatCpf("94389575104")).toBe("943.895.751-04"); + expect(formatCpf("943", { pad: true, obfuscate: false })).toBe("000.000.009-43"); + }); }); diff --git a/src/format-cpf/format-cpf.ts b/src/format-cpf/format-cpf.ts index f98e72b2..e6f6d6be 100644 --- a/src/format-cpf/format-cpf.ts +++ b/src/format-cpf/format-cpf.ts @@ -1,13 +1,20 @@ import { type FormatParams, format } from "../_internals/format/format"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { OBFUSCATED_PATTERN, PATTERN } from "./constants"; + +export type FormatCpfOptions = Pick & { + /** Whether to hide the first 3 digits and the 2 check digits with `*` (default: `false`). */ + obfuscate?: boolean; +}; -export type FormatCpfOptions = Pick; /** * Formats a given CPF (Cadastro de Pessoas Físicas) value according to the Brazilian standard. * * @param {string|number} value - The CPF value to be formatted. It can be a string or a number. - * @param {Object} options - Optional formatting options. + * @param {FormatCpfOptions} [options] - Optional formatting options. * @param {boolean} options.pad - If true, the value will be padded with leading zeros if necessary. + * @param {boolean} options.obfuscate - If true, hides the first 3 digits and the 2 check digits. * @returns {string} The formatted CPF string in the pattern "000.000.000-00". * * @example @@ -15,11 +22,17 @@ export type FormatCpfOptions = Pick; * formatCpf("12345678909"); // "123.456.789-09" * formatCpf(12345678909); // "123.456.789-09" * formatCpf("123456789", { pad: true }); // "001.234.567-89" + * formatCpf("12345678909", { obfuscate: true }); // "***.456.789-**" * ``` + * + * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/meu-cpf */ -export const formatCpf = (value: string | number, options?: FormatCpfOptions): string => - format({ +export const formatCpf = (value: string | number, options?: FormatCpfOptions): string => { + if (isNullish(value)) return ""; + + return format({ pad: options?.pad, value: sanitizeToDigits(value), - pattern: "000.000.000-00", + pattern: options?.obfuscate ? OBFUSCATED_PATTERN : PATTERN, }); +}; diff --git a/src/generate-cpf/constants.ts b/src/generate-cpf/constants.ts index d2c000dc..0a52f8c9 100644 --- a/src/generate-cpf/constants.ts +++ b/src/generate-cpf/constants.ts @@ -1,6 +1,5 @@ import type { StateCode } from "../_internals/constants/states"; -export const LENGTH = 11; export const BASE_LENGTH = 8; export const STATE_CODES: Record = { diff --git a/src/generate-cpf/generate-cpf.test.ts b/src/generate-cpf/generate-cpf.test.ts index 828fa81e..2281b507 100644 --- a/src/generate-cpf/generate-cpf.test.ts +++ b/src/generate-cpf/generate-cpf.test.ts @@ -1,12 +1,12 @@ +import { CPF_LENGTH } from "../_internals/constants/cpf"; import { DATA } from "../_internals/constants/states"; import { describe, expect, test } from "../_internals/test/runtime"; import { isValidCpf } from "../is-valid-cpf/is-valid-cpf"; -import { LENGTH } from "./constants"; import { generateCpf } from "./generate-cpf"; describe("generateCpf", () => { - test(`should have the right length without mask (${LENGTH})`, () => { - expect(generateCpf().length).toBe(LENGTH); + test(`should have the right length without mask (${CPF_LENGTH})`, () => { + expect(generateCpf().length).toBe(CPF_LENGTH); }); test("should return valid CPF", () => { @@ -15,12 +15,29 @@ describe("generateCpf", () => { } }); + test("should regenerate the base when it comes out with repeated digits", () => { + const digits = [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + const originalRandom = Math.random; + let call = 0; + + Math.random = () => (digits[call++] + 0.5) / 10; + + try { + const cpf = generateCpf(); + + expect(cpf.slice(0, 9)).toBe("123456789"); + expect(isValidCpf(cpf)).toBe(true); + } finally { + Math.random = originalRandom; + } + }); + describe("should return a valid CPF for each brazilian state with initials", () => { for (const state of DATA) { test(state.code, () => { const cpf = generateCpf(state.code); expect(isValidCpf(cpf)).toBe(true); - expect(cpf.length).toBe(LENGTH); + expect(cpf.length).toBe(CPF_LENGTH); }); } }); diff --git a/src/generate-cpf/generate-cpf.ts b/src/generate-cpf/generate-cpf.ts index 83ceebd0..9b1cb840 100644 --- a/src/generate-cpf/generate-cpf.ts +++ b/src/generate-cpf/generate-cpf.ts @@ -1,12 +1,11 @@ import type { StateCode } from "../_internals/constants/states"; import { generateChecksum } from "../_internals/generate-checksum/generate-checksum"; import { generateRandomNumber } from "../_internals/generate-random-number/generate-random-number"; +import { isRepeatedDigits } from "../_internals/is-repeated-digits/is-repeated-digits"; import { BASE_LENGTH, STATE_CODES } from "./constants"; -const VALID_STATE_CODES = new Set(Object.keys(STATE_CODES)); - const getStateCode = (state?: StateCode): string => { - if (state && VALID_STATE_CODES.has(state)) return STATE_CODES[state]; + if (state && Object.hasOwn(STATE_CODES, state)) return STATE_CODES[state]; return generateRandomNumber(1); }; @@ -18,6 +17,8 @@ const calculateCheckDigit = (base: string, weight: number): string => { /** * Generates a valid random CPF (Cadastro de Pessoas Físicas). * + * Uses `Math.random()` internally, so it is not cryptographically secure, do not use for security purposes. + * * @param {StateCode} state - Optional. The Brazilian state code to generate a CPF for. * @returns {string} A valid 11-digit CPF string without formatting. * @@ -26,9 +27,16 @@ const calculateCheckDigit = (base: string, weight: number): string => { * generateCpf(); // "12345678909" * generateCpf("SP"); // "12345678909" (with SP state code in 9th digit) * ``` + * + * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/meu-cpf */ export const generateCpf = (state?: StateCode): string => { - const base = generateRandomNumber(BASE_LENGTH) + getStateCode(state); + let base = generateRandomNumber(BASE_LENGTH) + getStateCode(state); + + while (isRepeatedDigits(base)) { + base = generateRandomNumber(BASE_LENGTH) + getStateCode(state); + } + const firstCheckDigit = calculateCheckDigit(base, 10); const secondCheckDigit = calculateCheckDigit(base + firstCheckDigit, 11); return base + firstCheckDigit + secondCheckDigit; diff --git a/src/is-valid-cpf/constants.ts b/src/is-valid-cpf/constants.ts index 571f25d0..3be42751 100644 --- a/src/is-valid-cpf/constants.ts +++ b/src/is-valid-cpf/constants.ts @@ -1,5 +1,3 @@ -export const LENGTH = 11; - export const RESERVED_NUMBERS = [ "00000000000", "11111111111", diff --git a/src/is-valid-cpf/is-valid-cpf.test.ts b/src/is-valid-cpf/is-valid-cpf.test.ts index e1868e1a..5e74076a 100644 --- a/src/is-valid-cpf/is-valid-cpf.test.ts +++ b/src/is-valid-cpf/is-valid-cpf.test.ts @@ -1,6 +1,7 @@ +import { CPF_LENGTH } from "../_internals/constants/cpf"; import { describe, expect, test } from "../_internals/test/runtime"; import { generateCpf } from "../generate-cpf/generate-cpf"; -import { LENGTH, RESERVED_NUMBERS } from "./constants"; +import { RESERVED_NUMBERS } from "./constants"; import { isValidCpf } from "./is-valid-cpf"; describe("isValidCpf", () => { @@ -42,7 +43,7 @@ describe("isValidCpf", () => { expect(isValidCpf([])).toBe(false); }); - test(`when dont match with CPF length (${LENGTH})`, () => { + test(`when dont match with CPF length (${CPF_LENGTH})`, () => { expect(isValidCpf("123456")).toBe(false); }); @@ -68,6 +69,15 @@ describe("isValidCpf", () => { expect(isValidCpf("962.718.458-60")).toBe(true); }); + test("when is a CPF valid with a whitespace mask", () => { + expect(isValidCpf("123 456 789 09")).toBe(true); + }); + + test("when is a CPF valid with leading/trailing whitespace", () => { + expect(isValidCpf(" 12345678909")).toBe(true); + expect(isValidCpf("12345678909 ")).toBe(true); + }); + test("should return true for randomly generated CPFs", () => { for (let i = 0; i < 100; i++) { expect(isValidCpf(generateCpf())).toBe(true); diff --git a/src/is-valid-cpf/is-valid-cpf.ts b/src/is-valid-cpf/is-valid-cpf.ts index e475a944..0e6879d1 100644 --- a/src/is-valid-cpf/is-valid-cpf.ts +++ b/src/is-valid-cpf/is-valid-cpf.ts @@ -1,12 +1,10 @@ +import { CPF_LENGTH } from "../_internals/constants/cpf"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -import { LENGTH, RESERVED_NUMBERS } from "./constants"; +import { RESERVED_NUMBERS } from "./constants"; -const RESERVED_SET = new Set(RESERVED_NUMBERS); - -const FORMAT_REGEX = /^\d{3}\.?\d{3}\.?\d{3}-?\d{2}$/; +const FORMAT_REGEX = /^\d{3}[\s.\-/]*\d{3}[\s.\-/]*\d{3}[\s.\-/]*\d{2}$/; const isValidChecksum = (cpf: string): boolean => { - // First digit (index 9) - weights from 10 to 2 let sum = 0; for (let i = 0; i < 9; i++) { sum += (cpf.charCodeAt(i) - 48) * (10 - i); @@ -15,7 +13,6 @@ const isValidChecksum = (cpf: string): boolean => { const expected1 = mod < 2 ? 48 : 48 + 11 - mod; if (cpf.charCodeAt(9) !== expected1) return false; - // Second digit (index 10) - weights from 11 to 2 sum = 0; for (let i = 0; i < 10; i++) { sum += (cpf.charCodeAt(i) - 48) * (11 - i); @@ -27,6 +24,7 @@ const isValidChecksum = (cpf: string): boolean => { /** * Validates if a CPF (Cadastro de Pessoas Físicas) is valid. + * Accepts the usual mask characters (`.`, `-`) and whitespace around and between groups. * * @param {string} cpf - The CPF value to be validated. * @returns {boolean} True if the CPF is valid, false otherwise. @@ -35,20 +33,24 @@ const isValidChecksum = (cpf: string): boolean => { * ```typescript * isValidCpf("123.456.789-09"); // true * isValidCpf("12345678909"); // true + * isValidCpf("123 456 789 09"); // true (whitespace mask) + * isValidCpf(" 12345678909"); // true (leading whitespace) * isValidCpf("00000000000"); // false (reserved number) * isValidCpf("12345678900"); // false (invalid checksum) * ``` + * + * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/meu-cpf */ export const isValidCpf = (cpf: string): boolean => { - if (!cpf || typeof cpf !== "string") return false; + if (typeof cpf !== "string" || cpf === "") return false; const digits = sanitizeToDigits(cpf); - if (digits.length !== LENGTH) return false; + if (digits.length !== CPF_LENGTH) return false; - if (!FORMAT_REGEX.test(cpf)) return false; + if (!FORMAT_REGEX.test(cpf.trim())) return false; - if (RESERVED_SET.has(digits)) return false; + if (RESERVED_NUMBERS.includes(digits)) return false; return isValidChecksum(digits); }; From 2687731e2ced8803ebd3ae1d71f4420e526eee9f Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:32:08 -0300 Subject: [PATCH 7/7] feat(municipality): add overloaded return types to getMunicipality [name, uf] for { code }, string for { municipalityName, uf }. fix: resolve to null when code is not exactly 7 digits, without making a request. --- src/get-municipality/get-municipality.test.ts | 155 +++++++++++++++--- src/get-municipality/get-municipality.ts | 110 ++++++------- 2 files changed, 185 insertions(+), 80 deletions(-) diff --git a/src/get-municipality/get-municipality.test.ts b/src/get-municipality/get-municipality.test.ts index b78500dd..122a26f1 100644 --- a/src/get-municipality/get-municipality.test.ts +++ b/src/get-municipality/get-municipality.test.ts @@ -1,40 +1,147 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "../_internals/test/runtime"; +import { describe, expect, it } from "../_internals/test/runtime"; +import type { GetMunicipalityByNameOptions } from "./get-municipality"; import { getMunicipality } from "./get-municipality"; describe("getMunicipality", () => { - const fetchMock = vi.fn(); - const originalFetch = globalThis.fetch; + it("should get municipality code by name", async () => { + await expect(getMunicipality({ municipalityName: "Sao Paulo", uf: "sp" })).resolves.toBe( + "3550308", + ); + }); - beforeEach(() => { - globalThis.fetch = fetchMock as unknown as typeof fetch; - fetchMock.mockClear(); + it("should get municipality name and UF by code", async () => { + await expect(getMunicipality({ code: "3550308" })).resolves.toEqual(["São Paulo", "SP"]); }); - afterEach(() => { - globalThis.fetch = originalFetch; - vi.restoreAllMocks(); + it("should match the municipality name ignoring accents and casing", async () => { + await expect(getMunicipality({ municipalityName: "SÃO PAULO", uf: "SP" })).resolves.toBe( + "3550308", + ); + await expect(getMunicipality({ municipalityName: "sao PAULO", uf: "SP" })).resolves.toBe( + "3550308", + ); }); - it("should get municipality code by name", async () => { - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => [{ id: 3550308, nome: "São Paulo" }], + it("should match a municipality whose own name carries accents regardless of query accents", async () => { + await expect(getMunicipality({ municipalityName: "Ceara-Mirim", uf: "RN" })).resolves.toBe( + "2402600", + ); + await expect(getMunicipality({ code: "2402600" })).resolves.toEqual(["Ceará-Mirim", "RN"]); + }); + + it("should resolve a known Boa Esperança do Norte/MT lookup", async () => { + await expect(getMunicipality({ code: "5101837" })).resolves.toEqual([ + "Boa Esperança do Norte", + "MT", + ]); + await expect( + getMunicipality({ municipalityName: "Boa Esperanca do Norte", uf: "MT" }), + ).resolves.toBe("5101837"); + }); + + describe("code validation", () => { + it("should return null for an empty code", async () => { + await expect(getMunicipality({ code: "" })).resolves.toBeNull(); }); - await expect(getMunicipality({ municipalityName: "Sao Paulo", uf: "sp" })).resolves.toBe( - "3550308", - ); + it("should return null for a path-traversal code", async () => { + await expect( + getMunicipality({ code: "../../../v1/localidades/estados" }), + ).resolves.toBeNull(); + }); + + it("should return null for a code with query-string injection", async () => { + await expect(getMunicipality({ code: "3550308?x=1" })).resolves.toBeNull(); + }); + + it("should return null for a non-string code", async () => { + // @ts-expect-error + await expect(getMunicipality({ code: null })).resolves.toBeNull(); + // @ts-expect-error + await expect(getMunicipality({ code: 3550308 })).resolves.toBeNull(); + }); + + it("should return null for a code with the wrong number of digits", async () => { + await expect(getMunicipality({ code: "123" })).resolves.toBeNull(); + await expect(getMunicipality({ code: "12345678" })).resolves.toBeNull(); + }); + + it("should return null for an unknown 7 digit code", async () => { + await expect(getMunicipality({ code: "0000000" })).resolves.toBeNull(); + }); }); - it("should get municipality name and UF by code", async () => { - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - microrregiao: { mesorregiao: { UF: { sigla: "SP" } } }, - nome: "São Paulo", - }), + describe("options validation (non-object input)", () => { + it("should return null for null", async () => { + // @ts-expect-error + await expect(getMunicipality(null)).resolves.toBeNull(); }); - await expect(getMunicipality({ code: "3550308" })).resolves.toEqual(["São Paulo", "SP"]); + it("should return null for undefined", async () => { + // @ts-expect-error + await expect(getMunicipality(undefined)).resolves.toBeNull(); + }); + + it("should return null for a primitive", async () => { + // @ts-expect-error + await expect(getMunicipality("3550308")).resolves.toBeNull(); + // @ts-expect-error + await expect(getMunicipality(123)).resolves.toBeNull(); + // @ts-expect-error + await expect(getMunicipality(true)).resolves.toBeNull(); + }); + + it("should return null for an array", async () => { + // @ts-expect-error + await expect(getMunicipality([])).resolves.toBeNull(); + }); + }); + + describe("municipality name lookup validation", () => { + it("should return null for an empty municipality name", async () => { + await expect(getMunicipality({ municipalityName: "", uf: "SP" })).resolves.toBeNull(); + }); + + it("should return null for a non-string municipality name", async () => { + // @ts-expect-error + await expect(getMunicipality({ municipalityName: null, uf: "SP" })).resolves.toBeNull(); + }); + + it("should return null for a non-string uf", async () => { + // @ts-expect-error + const options: GetMunicipalityByNameOptions = { municipalityName: "São Paulo", uf: null }; + + await expect(getMunicipality(options)).resolves.toBeNull(); + }); + + it("should return null for a malformed UF (digits)", async () => { + await expect( + getMunicipality({ municipalityName: "São Paulo", uf: "123" }), + ).resolves.toBeNull(); + }); + + it("should return null for a malformed UF (wrong length)", async () => { + await expect( + getMunicipality({ municipalityName: "São Paulo", uf: "XXX" }), + ).resolves.toBeNull(); + }); + + it("should return null for an unknown UF", async () => { + await expect( + getMunicipality({ municipalityName: "São Paulo", uf: "ZZ" }), + ).resolves.toBeNull(); + }); + + it("should return null when the municipality is not found in the given state", async () => { + await expect( + getMunicipality({ municipalityName: "Cidade Inexistente", uf: "SP" }), + ).resolves.toBeNull(); + }); + + it("should return null when the municipality exists but in a different state", async () => { + await expect( + getMunicipality({ municipalityName: "São Paulo", uf: "RJ" }), + ).resolves.toBeNull(); + }); }); }); diff --git a/src/get-municipality/get-municipality.ts b/src/get-municipality/get-municipality.ts index d3eaf783..72f9d579 100644 --- a/src/get-municipality/get-municipality.ts +++ b/src/get-municipality/get-municipality.ts @@ -1,92 +1,90 @@ -import { fetchWithRetry } from "../_internals/fetch-with-retry/fetch-with-retry"; - -type MunicipalityByCodeResponse = { - nome?: string; - microrregiao?: { - mesorregiao?: { - UF?: { - sigla?: string; - }; - }; - }; -}; - -type MunicipalityByNameResponse = { - id?: number; - nome?: string; -}; +import { DATA as CITIES_DATA } from "../_internals/constants/cities"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { removeAccents } from "../remove-accents/remove-accents"; export type GetMunicipalityByCodeOptions = { + /** The 7 digit IBGE municipality code. */ code: string; }; export type GetMunicipalityByNameOptions = { + /** The municipality name, accents and casing ignored. */ municipalityName: string; + /** The two letter state code the municipality belongs to, e.g. "SP". */ uf: string; }; export type GetMunicipalityOptions = GetMunicipalityByCodeOptions | GetMunicipalityByNameOptions; -const normalizeText = (value: string): string => - value - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") - .replace(/\s+/g, " ") - .trim() - .toUpperCase(); - -const getMunicipalityByCode = async (code: string): Promise<[string, string] | null> => { - let response: Response; - try { - response = await fetchWithRetry( - `https://servicodados.ibge.gov.br/api/v1/localidades/municipios/${code}`, - ); - } catch { - return null; - } +let codeIndex: Map | undefined; + +const normalizeName = (value: string): string => removeAccents(value).trim().toUpperCase(); - if (!response.ok) return null; +const getMunicipalityByCode = (code: string): [string, string] | null => { + if (typeof code !== "string" || !/^\d{7}$/.test(code)) return null; - const data = (await response.json()) as MunicipalityByCodeResponse; - const name = data.nome; - const uf = data.microrregiao?.mesorregiao?.UF?.sigla; + if (!codeIndex) { + codeIndex = new Map(); - if (!name || !uf) return null; + for (const [stateCode, municipalities] of Object.entries(CITIES_DATA)) { + for (const [name, ibgeCode] of municipalities) { + codeIndex.set(ibgeCode, [name, stateCode]); + } + } + } - return [name, uf]; + return codeIndex.get(code) ?? null; }; -const getMunicipalityCodeByName = async ({ +const getMunicipalityCodeByName = ({ municipalityName, uf, -}: GetMunicipalityByNameOptions): Promise => { - if (!municipalityName || typeof municipalityName !== "string") return null; +}: GetMunicipalityByNameOptions): string | null => { + if (typeof municipalityName !== "string" || municipalityName === "") return null; + if (typeof uf !== "string") return null; const normalizedUf = uf.trim().toUpperCase(); if (!/^[A-Z]{2}$/.test(normalizedUf)) return null; - let response: Response; - try { - response = await fetchWithRetry( - `https://servicodados.ibge.gov.br/api/v1/localidades/estados/${normalizedUf}/municipios`, - ); - } catch { - return null; - } + const stateEntry = Object.entries(CITIES_DATA).find(([code]) => code === normalizedUf); - if (!response.ok) return null; + if (!stateEntry) return null; - const data = (await response.json()) as MunicipalityByNameResponse[]; - const normalizedName = normalizeText(municipalityName); - const municipality = data.find((item) => normalizeText(item.nome ?? "") === normalizedName); + const normalizedName = normalizeName(municipalityName); + const match = stateEntry[1].find(([name]) => normalizeName(name) === normalizedName); - return municipality?.id?.toString() ?? null; + return match ? match[1] : null; }; +/** + * Looks a Brazilian municipality up in the offline IBGE "localidades" dataset. + * + * Given a `code` it resolves the municipality name and its UF; given a `municipalityName` + * and a `uf` it resolves the IBGE code. The name lookup ignores accents and casing. + * Validation failures and unknown municipalities are reported as `null`. + * + * @param {GetMunicipalityOptions} options - Either `{ code }` or `{ municipalityName, uf }`. + * @returns {Promise<[string, string] | string | null>} The `[name, uf]` pair when looking up + * by code, the IBGE code when looking up by name, or null when the municipality is unknown + * (this includes `options` itself being missing or not an object, e.g. `null`, `undefined`, + * an array or a primitive). + * + * @example + * ```typescript + * await getMunicipality({ code: "3550308" }); // ["São Paulo", "SP"] + * await getMunicipality({ municipalityName: "sao paulo", uf: "sp" }); // "3550308" + * ``` + * + * @see Official: https://servicodados.ibge.gov.br/api/docs/localidades + */ export const getMunicipality = async ( options: GetMunicipalityOptions, ): Promise<[string, string] | null | string> => { + if (isNullish(options) || typeof options !== "object" || Array.isArray(options)) { + return null; + } + if ("code" in options) { return getMunicipalityByCode(options.code); }