Skip to content
185 changes: 185 additions & 0 deletions scripts/banks.ts
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() });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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,
);
Comment thread
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(
Comment thread
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);
});
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);
});
});
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;
};
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);
});
});
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;
};
Loading
Loading