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/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/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/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/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/_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/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/_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/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/_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/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/_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-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/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/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/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/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-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/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/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/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/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/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/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/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); } 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); }; 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/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/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); }; 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/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-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, + ); +}; 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); 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); 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); +};