-
Notifications
You must be signed in to change notification settings - Fork 129
[2.4.0 stack 4/18] Features on existing utils: boleto, banks, phone, voter id, obfuscate, municipality #510
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
6bde780
feat(boleto): support boleto de arrecadação and a referenceDate option
hyanmandian ac2e84d
feat(banks): add getBanks, getBankByCode and getBankByIspb; validate …
hyanmandian 82df2d9
feat(phone): add isValidServicePhone and an accept/auto-mask upgrade …
hyanmandian 9a3c4c4
feat(voter-id): support 13-digit São Paulo/Minas Gerais voter ids
hyanmandian 65c118c
feat(cnpj): add an obfuscate option to formatCnpj
hyanmandian b6150ab
feat(cpf): add an obfuscate option to formatCpf
hyanmandian 2687731
feat(municipality): add overloaded return types to getMunicipality
hyanmandian File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<BankRow[]> => { | ||
| 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<BankRow[]> => { | ||
| 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<string, BankRow>(); | ||
|
|
||
| for (const bank of banks) { | ||
| uniqueBanks.set(bank.code, bank); | ||
| } | ||
|
|
||
| const sorted = [...uniqueBanks.values()].sort((bankA, bankB) => | ||
| bankA.code > bankB.code ? 1 : -1, | ||
| ); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| if (sorted.length === 0) { | ||
| throw new Error("Refusing to write an empty bank dataset"); | ||
| } | ||
|
|
||
| console.log(`Generated ${sorted.length} banks from ${source}`); | ||
|
|
||
| await writeFile( | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| 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); | ||
| }); | ||
31 changes: 31 additions & 0 deletions
31
src/_internals/calculate-voter-id-first-digit/calculate-voter-id-first-digit.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); |
48 changes: 48 additions & 0 deletions
48
src/_internals/calculate-voter-id-first-digit/calculate-voter-id-first-digit.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| }; |
12 changes: 12 additions & 0 deletions
12
src/_internals/calculate-voter-id-second-digit/calculate-voter-id-second-digit.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); |
39 changes: 39 additions & 0 deletions
39
src/_internals/calculate-voter-id-second-digit/calculate-voter-id-second-digit.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| }; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.