diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index a4adb4a72..e67670f41 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -73,6 +73,37 @@ generateCpf('SP'); // o 9º dígito é 8, o código da região fiscal de SP generateCpf('MG'); // o 9º dígito é 6, o código da região fiscal de MG ``` +### getCpfInfo + +Lê os campos que um CPF codifica, como um `CpfInfo`: a `base` de 8 dígitos, o dígito `fiscalRegion` (o 9º dígito, a Região Fiscal da Receita Federal em que o CPF foi inscrito, `"1"` a `"9"` e `"0"` para a 10ª), os `states` dessa região (`StateCode[]`, ordenados pelo nome do estado) e os 2 `checkDigits`. Aceita a mesma entrada com ou sem máscara que o `isValidCpf` e retorna `null` para tudo que não for um CPF válido. A região é a do endereço informado na primeira inscrição: ela não diz onde o titular nasceu, onde mora hoje nem onde pediu o número, e uma região com mais de um estado não diz qual deles foi. + +| `fiscalRegion` | `states` | +| --- | --- | +| `"1"` | DF, GO, MT, MS, TO | +| `"2"` | AC, AP, AM, PA, RO, RR | +| `"3"` | CE, MA, PI | +| `"4"` | AL, PB, PE, RN | +| `"5"` | BA, SE | +| `"6"` | MG | +| `"7"` | ES, RJ | +| `"8"` | SP | +| `"9"` | PR, SC | +| `"0"` | RS | + +```javascript +import { getCpfInfo } from '@brazilian-utils/brazilian-utils'; + +getCpfInfo('123.456.789-09'); +// { +// base: '12345678', +// fiscalRegion: '9', +// states: ['PR', 'SC'], +// checkDigits: '09', +// } + +getCpfInfo('12345678900'); // null (dígitos verificadores inválidos) +``` + Fonte: [Receita Federal, "Cadastros: CPF e CNPJ"](https://www.gov.br/receitafederal/pt-br/assuntos/educacao-fiscal/educacao_fiscal/folhetos-orientativos/cadastros-dig.pdf). ## CNPJ diff --git a/docs/utilities.md b/docs/utilities.md index ad699c341..563acf8f7 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -73,6 +73,37 @@ generateCpf('SP'); // the 9th digit is 8, the SP região fiscal code generateCpf('MG'); // the 9th digit is 6, the MG região fiscal code ``` +### getCpfInfo + +Read the fields a CPF encodes, as a `CpfInfo`: the 8 digit `base`, the `fiscalRegion` digit (the 9th digit, the Região Fiscal of the Receita Federal the CPF was registered in, `"1"` to `"9"` and `"0"` for the 10ª), the `states` of that region (`StateCode[]`, sorted by state name) and the 2 `checkDigits`. Accepts the same masked or unmasked input as `isValidCpf` and returns `null` for anything that is not a valid CPF. The region is the one of the address given at the first registration: it says nothing about where the holder was born, lives today or asked for the number, and a region with more than one state does not tell which of them it was. + +| `fiscalRegion` | `states` | +| --- | --- | +| `"1"` | DF, GO, MT, MS, TO | +| `"2"` | AC, AP, AM, PA, RO, RR | +| `"3"` | CE, MA, PI | +| `"4"` | AL, PB, PE, RN | +| `"5"` | BA, SE | +| `"6"` | MG | +| `"7"` | ES, RJ | +| `"8"` | SP | +| `"9"` | PR, SC | +| `"0"` | RS | + +```javascript +import { getCpfInfo } from '@brazilian-utils/brazilian-utils'; + +getCpfInfo('123.456.789-09'); +// { +// base: '12345678', +// fiscalRegion: '9', +// states: ['PR', 'SC'], +// checkDigits: '09', +// } + +getCpfInfo('12345678900'); // null (invalid check digits) +``` + Source: [Receita Federal, "Cadastros: CPF e CNPJ"](https://www.gov.br/receitafederal/pt-br/assuntos/educacao-fiscal/educacao_fiscal/folhetos-orientativos/cadastros-dig.pdf). ## CNPJ diff --git a/jsr.json b/jsr.json index 4b41130dd..9c7925fa5 100644 --- a/jsr.json +++ b/jsr.json @@ -60,6 +60,7 @@ "./get-cfop": "./src/get-cfop/get-cfop.ts", "./get-cities": "./src/get-cities/get-cities.ts", "./get-cnae": "./src/get-cnae/get-cnae.ts", + "./get-cpf-info": "./src/get-cpf-info/get-cpf-info.ts", "./get-format-license-plate": "./src/get-format-license-plate/get-format-license-plate.ts", "./get-holidays": "./src/get-holidays/get-holidays.ts", "./get-iban-info": "./src/get-iban-info/get-iban-info.ts", diff --git a/reports/api/brazilian-utils.api.md b/reports/api/brazilian-utils.api.md index 75b328bda..1ccd42087 100644 --- a/reports/api/brazilian-utils.api.md +++ b/reports/api/brazilian-utils.api.md @@ -137,6 +137,14 @@ export type ConvertNumberToWordsOptions = { gender?: NumberToWordsGender; }; +// @public +export type CpfInfo = { + base: string; + fiscalRegion: string; + states: StateCode[]; + checkDigits: string; +}; + // @public export const differenceInBusinessDays: (laterDate: Date, earlierDate: Date, options?: BusinessDayOptions) => number | null; @@ -488,6 +496,9 @@ export const getCities: (state?: StateCode) => string[]; // @public export const getCnae: (value: string | number) => Cnae | null; +// @public +export const getCpfInfo: (value: string) => CpfInfo | null; + // @public export const getFormatLicensePlate: (value: string) => LicensePlateFormat | null; diff --git a/src/_internals/constants/cpf.ts b/src/_internals/constants/cpf.ts index 1c7a36256..86e43b10a 100644 --- a/src/_internals/constants/cpf.ts +++ b/src/_internals/constants/cpf.ts @@ -1,2 +1,45 @@ +import { type StateCode } from "./states"; + /** Digits of a CPF. */ export const CPF_LENGTH = 11; + +/** Digits of a CPF ahead of the região fiscal digit. */ +export const CPF_BASE_LENGTH = 8; + +/** + * The região fiscal digit (the 9th digit of a CPF) of each state, as listed by the Receita + * Federal in the folheto "Cadastros: CPF e CNPJ": 1 for DF, GO, MT, MS and TO; 2 for PA, AM, AC, + * AP, RO and RR; 3 for CE, MA and PI; 4 for PE, RN, PB and AL; 5 for BA and SE; 6 for MG; 7 for + * RJ and ES; 8 for SP; 9 for PR and SC; 0 for RS. + * + * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/educacao-fiscal/educacao_fiscal/folhetos-orientativos/cadastros-dig.pdf + */ +export const CPF_FISCAL_REGION_BY_STATE: Record = { + AC: "2", + AL: "4", + AP: "2", + AM: "2", + BA: "5", + CE: "3", + DF: "1", + ES: "7", + GO: "1", + MA: "3", + MT: "1", + MS: "1", + MG: "6", + PR: "9", + PB: "4", + PA: "2", + PE: "4", + PI: "3", + RN: "4", + RS: "0", + RJ: "7", + RO: "2", + RR: "2", + SC: "9", + SE: "5", + SP: "8", + TO: "1", +}; diff --git a/src/generate-cpf/constants.ts b/src/generate-cpf/constants.ts deleted file mode 100644 index 758b1f5f1..000000000 --- a/src/generate-cpf/constants.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { type StateCode } from "../_internals/constants/states"; - -export const BASE_LENGTH = 8; - -export const STATE_CODES: Record = { - AC: "2", - AL: "4", - AP: "2", - AM: "2", - BA: "5", - CE: "3", - DF: "1", - ES: "7", - GO: "1", - MA: "3", - MT: "1", - MS: "1", - MG: "6", - PR: "9", - PB: "4", - PA: "2", - PE: "4", - PI: "3", - RN: "4", - RS: "0", - RJ: "7", - RO: "2", - RR: "2", - SC: "9", - SE: "5", - SP: "8", - TO: "1", -} as const; diff --git a/src/generate-cpf/generate-cpf.test.ts b/src/generate-cpf/generate-cpf.test.ts index 63beca5b9..fa44665d0 100644 --- a/src/generate-cpf/generate-cpf.test.ts +++ b/src/generate-cpf/generate-cpf.test.ts @@ -1,11 +1,10 @@ import * as fc from "fast-check"; -import { CPF_LENGTH } from "../_internals/constants/cpf"; +import { CPF_FISCAL_REGION_BY_STATE, CPF_LENGTH } from "../_internals/constants/cpf"; import { DATA, type StateCode } from "../_internals/constants/states"; import { PROTOTYPE_KEYS } from "../_internals/test/arbitraries"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; import { isValidCpf } from "../is-valid-cpf/is-valid-cpf"; -import { STATE_CODES } from "./constants"; import { generateCpf } from "./generate-cpf"; describe("generateCpf", () => { @@ -50,15 +49,15 @@ describe("generateCpf", () => { } }); - test("should embed the literal STATE_CODES digit at the 9th position, not a random one", () => { + test("should embed the literal CPF_FISCAL_REGION_BY_STATE digit at the 9th position, not a random one", () => { for (let i = 0; i < 20; i++) { - expect(generateCpf("SP")[8]).toBe(STATE_CODES.SP); + expect(generateCpf("SP")[8]).toBe(CPF_FISCAL_REGION_BY_STATE.SP); } }); test("should embed the 1st região fiscal digit for the states the Receita Federal groups there", () => { - expect(STATE_CODES.MS).toBe("1"); - expect(STATE_CODES.MT).toBe("1"); + expect(CPF_FISCAL_REGION_BY_STATE.MS).toBe("1"); + expect(CPF_FISCAL_REGION_BY_STATE.MT).toBe("1"); expect(generateCpf("MS")[8]).toBe("1"); expect(generateCpf("MT")[8]).toBe("1"); }); @@ -102,7 +101,7 @@ describe("generateCpf", () => { const cpf = generateCpf(state); expect(cpf).toHaveLength(CPF_LENGTH); - expect(cpf[8]).toBe(STATE_CODES[state]); + expect(cpf[8]).toBe(CPF_FISCAL_REGION_BY_STATE[state]); expect(isValidCpf(cpf)).toBe(true); }), ); diff --git a/src/generate-cpf/generate-cpf.ts b/src/generate-cpf/generate-cpf.ts index 2d9b8798a..525362a25 100644 --- a/src/generate-cpf/generate-cpf.ts +++ b/src/generate-cpf/generate-cpf.ts @@ -1,8 +1,8 @@ import { calculateCpfCheckDigit } from "../_internals/calculate-cpf-check-digit/calculate-cpf-check-digit"; +import { CPF_BASE_LENGTH, CPF_FISCAL_REGION_BY_STATE } from "../_internals/constants/cpf"; import { type StateCode } from "../_internals/constants/states"; 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"; export type { StateCode } from "../_internals/constants/states"; @@ -16,7 +16,10 @@ export type { StateCode } from "../_internals/constants/states"; * @returns {string} The região fiscal digit of that state, or a random digit. */ const getStateCode = (state?: StateCode): string => { - if (typeof state === "string" && Object.hasOwn(STATE_CODES, state)) return STATE_CODES[state]; + if (typeof state === "string" && Object.hasOwn(CPF_FISCAL_REGION_BY_STATE, state)) { + return CPF_FISCAL_REGION_BY_STATE[state]; + } + return generateRandomNumber(1); }; @@ -52,10 +55,10 @@ const getStateCode = (state?: StateCode): string => { * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/cpf.py */ export const generateCpf = (state?: StateCode): string => { - let base = generateRandomNumber(BASE_LENGTH) + getStateCode(state); + let base = generateRandomNumber(CPF_BASE_LENGTH) + getStateCode(state); while (isRepeatedDigits(base)) { - base = generateRandomNumber(BASE_LENGTH) + getStateCode(state); + base = generateRandomNumber(CPF_BASE_LENGTH) + getStateCode(state); } const firstCheckDigit = String(calculateCpfCheckDigit(base)); diff --git a/src/get-cpf-info/get-cpf-info.test.ts b/src/get-cpf-info/get-cpf-info.test.ts new file mode 100644 index 000000000..7e79b0c29 --- /dev/null +++ b/src/get-cpf-info/get-cpf-info.test.ts @@ -0,0 +1,193 @@ +import * as fc from "fast-check"; + +import { type StateCode } from "../_internals/constants/states"; +import { anyGarbage, anyText, anyValue, stateCodes } from "../_internals/test/arbitraries"; +import { expectNeverThrows } from "../_internals/test/properties"; +import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; +import { formatCpf } from "../format-cpf/format-cpf"; +import { generateCpf } from "../generate-cpf/generate-cpf"; +import { isValidCpf } from "../is-valid-cpf/is-valid-cpf"; +import { type CpfInfo, getCpfInfo } from "./get-cpf-info"; + +const REGIONS: [string, CpfInfo][] = [ + [ + "40152673113", + { + base: "40152673", + fiscalRegion: "1", + states: ["DF", "GO", "MT", "MS", "TO"], + checkDigits: "13", + }, + ], + [ + "73091462200", + { + base: "73091462", + fiscalRegion: "2", + states: ["AC", "AP", "AM", "PA", "RO", "RR"], + checkDigits: "00", + }, + ], + [ + "20581746317", + { base: "20581746", fiscalRegion: "3", states: ["CE", "MA", "PI"], checkDigits: "17" }, + ], + [ + "91827364483", + { base: "91827364", fiscalRegion: "4", states: ["AL", "PB", "PE", "RN"], checkDigits: "83" }, + ], + ["56473829598", { base: "56473829", fiscalRegion: "5", states: ["BA", "SE"], checkDigits: "98" }], + ["37192048631", { base: "37192048", fiscalRegion: "6", states: ["MG"], checkDigits: "31" }], + ["84620513717", { base: "84620513", fiscalRegion: "7", states: ["ES", "RJ"], checkDigits: "17" }], + ["15937264819", { base: "15937264", fiscalRegion: "8", states: ["SP"], checkDigits: "19" }], + ["60248175920", { base: "60248175", fiscalRegion: "9", states: ["PR", "SC"], checkDigits: "20" }], + ["48301692065", { base: "48301692", fiscalRegion: "0", states: ["RS"], checkDigits: "65" }], +]; + +describe("getCpfInfo", () => { + describe("should return the fields of the CPF", () => { + for (const [cpf, expected] of REGIONS) { + test(`for ${cpf}, of the região fiscal ${expected.fiscalRegion}`, () => { + expect(getCpfInfo(cpf)).toEqual(expected); + }); + } + + test("for the worked example of the e-Financeira manual (280012389-38)", () => { + expect(getCpfInfo("280012389-38")).toEqual({ + base: "28001238", + fiscalRegion: "9", + states: ["PR", "SC"], + checkDigits: "38", + }); + }); + + test("for a masked value", () => { + expect(getCpfInfo("123.456.789-09")).toEqual({ + base: "12345678", + fiscalRegion: "9", + states: ["PR", "SC"], + checkDigits: "09", + }); + }); + + test("for a value with whitespace around and between the groups", () => { + expect(getCpfInfo(" 111 444 777 35\n")).toEqual({ + base: "11144477", + fiscalRegion: "7", + states: ["ES", "RJ"], + checkDigits: "35", + }); + }); + + test("with a states list of its own on every call", () => { + const first = getCpfInfo("12345678909"); + + first?.states.push("SP"); + + expect(getCpfInfo("12345678909")?.states).toEqual(["PR", "SC"]); + }); + }); + + describe("should return null", () => { + test("when the check digits do not match", () => { + expect(getCpfInfo("12345678900")).toBeNull(); + }); + + test("when every digit is the same", () => { + expect(getCpfInfo("00000000000")).toBeNull(); + expect(getCpfInfo("111.111.111-11")).toBeNull(); + }); + + test("when it is shorter than 11 digits", () => { + expect(getCpfInfo("1234567890")).toBeNull(); + }); + + test("when it is longer than 11 digits", () => { + expect(getCpfInfo("123456789090")).toBeNull(); + }); + + test("when it carries a character outside the mask", () => { + expect(getCpfInfo("123.456.789-09a")).toBeNull(); + expect(getCpfInfo("123_456_789_09")).toBeNull(); + }); + + test("when it is an empty string", () => { + expect(getCpfInfo("")).toBeNull(); + }); + + test("when it is null", () => { + // @ts-expect-error: intentionally invalid input + expect(getCpfInfo(null)).toBeNull(); + }); + + test("when it is undefined", () => { + // @ts-expect-error: intentionally invalid input + expect(getCpfInfo()).toBeNull(); + }); + + test("when it is a number", () => { + // @ts-expect-error: intentionally invalid input + expect(getCpfInfo(12_345_678_909)).toBeNull(); + }); + + test("when it is an object, a null prototype one included", () => { + // @ts-expect-error: intentionally invalid input + expect(getCpfInfo({})).toBeNull(); + expect(getCpfInfo(Object.create(null))).toBeNull(); + // @ts-expect-error: intentionally invalid input + expect(getCpfInfo(["12345678909"])).toBeNull(); + }); + + test("when it is a key of the prototype chain", () => { + expect(getCpfInfo("__proto__")).toBeNull(); + expect(getCpfInfo("constructor")).toBeNull(); + }); + }); + + describe("properties", () => { + test("should split a CPF into fields that spell it back, masked or not", () => { + fc.assert( + fc.property(fc.boolean(), (masked) => { + const cpf = generateCpf(); + const parsed = getCpfInfo(masked ? formatCpf(cpf) : cpf); + + expect(`${parsed?.base}${parsed?.fiscalRegion}${parsed?.checkDigits}`).toBe(cpf); + }), + ); + }); + + test("should list the state a CPF was generated for", () => { + fc.assert( + fc.property(stateCodes, (state) => { + expect(getCpfInfo(generateCpf(state))?.states).toContain(state); + }), + ); + }); + + test("should return a value exactly when the CPF is valid", () => { + fc.assert( + fc.property(anyText, (value) => { + expect(getCpfInfo(value) !== null).toBe(isValidCpf(value)); + }), + ); + }); + + test("should never throw", () => { + expectNeverThrows(getCpfInfo, anyValue); + expectNeverThrows(getCpfInfo, anyGarbage); + }); + }); +}); + +describe("getCpfInfo types", () => { + test("should take a string and return a CpfInfo or null", () => { + expectTypeOf(getCpfInfo).parameter(0).toEqualTypeOf(); + expectTypeOf(getCpfInfo).returns.toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<{ + base: string; + fiscalRegion: string; + states: StateCode[]; + checkDigits: string; + }>(); + }); +}); diff --git a/src/get-cpf-info/get-cpf-info.ts b/src/get-cpf-info/get-cpf-info.ts new file mode 100644 index 000000000..eacd0a20e --- /dev/null +++ b/src/get-cpf-info/get-cpf-info.ts @@ -0,0 +1,76 @@ +import { CPF_BASE_LENGTH, CPF_FISCAL_REGION_BY_STATE } from "../_internals/constants/cpf"; +import { STATE_CODES } from "../_internals/constants/state-codes"; +import { type StateCode } from "../_internals/constants/states"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { isValidCpf } from "../is-valid-cpf/is-valid-cpf"; + +export type { StateCode } from "../_internals/constants/states"; + +/** The fields `getCpfInfo` reads out of a CPF. */ +export type CpfInfo = { + /** The first 8 digits, the ones ahead of the Região Fiscal digit. */ + base: string; + /** + * The 9th digit, the Região Fiscal of the Receita Federal the CPF was registered in: `"1"` to + * `"9"` for the 1ª to the 9ª Região Fiscal and `"0"` for the 10ª. + */ + fiscalRegion: string; + /** The two letter codes of the states of that Região Fiscal, sorted by state name. */ + states: StateCode[]; + /** The 2 check digits. */ + checkDigits: string; +}; + +const CHECK_DIGITS_START = CPF_BASE_LENGTH + 1; + +/** + * Reads the fields a CPF (Cadastro de Pessoas Físicas) encodes: the 8 digit base, the Região + * Fiscal digit with the states it covers, and the 2 check digits. + * + * The Receita Federal is split into ten Regiões Fiscais, and the 9th digit of a CPF is the one of + * the address given when the number was first registered. It says nothing about where its holder + * was born, lives today or asked for the number, and a region with more than one state does not + * tell which of them it was. + * + * Accepts the same input forms as `isValidCpf`, masked or not, with whitespace around and between + * the groups, and returns `null` whenever `isValidCpf` would return `false`. + * + * @param {string} value - The CPF to be read. + * @returns {CpfInfo|null} The fields of the CPF, or `null` when it is not a valid CPF. + * + * @example + * ```typescript + * getCpfInfo("123.456.789-09"); + * // { + * // base: "12345678", + * // fiscalRegion: "9", + * // states: ["PR", "SC"], + * // checkDigits: "09", + * // } + * + * getCpfInfo("12345678909"); // same result (no mask) + * getCpfInfo("12345678900"); // null (invalid check digits) + * getCpfInfo("00000000000"); // null (reserved number) + * ``` + * + * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/educacao-fiscal/educacao_fiscal/folhetos-orientativos/cadastros-dig.pdf + * Folheto "Cadastros: CPF e CNPJ" of the Receita Federal: the 9th digit is the Região Fiscal, and + * the states of each of the ten regions. + * @see Official: https://www.gov.br/receitafederal/pt-br/composicao/srrf + * Superintendências Regionais da Receita Federal: the states under each of the 1ª to the 10ª + * Região Fiscal, the 10ª (Rio Grande do Sul) being the digit 0. + * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/meu-cpf + */ +export const getCpfInfo = (value: string): CpfInfo | null => { + if (!isValidCpf(value)) return null; + + const digits = sanitizeToDigits(value); + const fiscalRegion = digits.charAt(CPF_BASE_LENGTH); + + return { + base: digits.slice(0, CPF_BASE_LENGTH), + fiscalRegion, + states: STATE_CODES.filter((state) => CPF_FISCAL_REGION_BY_STATE[state] === fiscalRegion), + checkDigits: digits.slice(CHECK_DIGITS_START), + }; +}; diff --git a/src/index.test.ts b/src/index.test.ts index 877a4bdfd..61ad45293 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -15,6 +15,7 @@ import { type Cnae, type ConvertDateToWordsOptions, type ConvertNumberToWordsOptions, + type CpfInfo, type FormatBoletoOptions, type FormatCaepfOptions, type FormatCeiOptions, @@ -171,6 +172,7 @@ const PUBLIC = [ "getCfop", "getCities", "getCnae", + "getCpfInfo", "getFormatLicensePlate", "getHolidays", "getIbanInfo", @@ -303,6 +305,7 @@ describe("Public API", () => { Cnae: Cnae; ConvertDateToWordsOptions: ConvertDateToWordsOptions; ConvertNumberToWordsOptions: ConvertNumberToWordsOptions; + CpfInfo: CpfInfo; FormatBoletoOptions: FormatBoletoOptions; FormatCaepfOptions: FormatCaepfOptions; FormatCeiOptions: FormatCeiOptions; diff --git a/src/index.ts b/src/index.ts index a9e85d45f..68c6b5693 100644 --- a/src/index.ts +++ b/src/index.ts @@ -114,6 +114,7 @@ export { export { type Cfop, getCfop } from "./get-cfop/get-cfop"; export { getCities } from "./get-cities/get-cities"; export { type Cnae, getCnae } from "./get-cnae/get-cnae"; +export { type CpfInfo, getCpfInfo } from "./get-cpf-info/get-cpf-info"; export { getFormatLicensePlate, type LicensePlateFormat,