diff --git a/src/_internals/calculate-cnh-first-verifier/calculate-cnh-first-verifier.test.ts b/src/_internals/calculate-cnh-first-verifier/calculate-cnh-first-verifier.test.ts new file mode 100644 index 00000000..f7f4b612 --- /dev/null +++ b/src/_internals/calculate-cnh-first-verifier/calculate-cnh-first-verifier.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "../test/runtime"; +import { calculateCnhFirstVerifier } from "./calculate-cnh-first-verifier"; + +describe("calculateCnhFirstVerifier", () => { + test("should calculate the first verifier without decrement", () => { + expect(calculateCnhFirstVerifier("000000001")).toEqual({ firstVerifier: 1, decrement: 0 }); + }); + + test("should calculate the first verifier with decrement when remainder is 10 or more", () => { + expect(calculateCnhFirstVerifier("000000093")).toEqual({ firstVerifier: 0, decrement: 2 }); + }); +}); diff --git a/src/_internals/calculate-cnh-first-verifier/calculate-cnh-first-verifier.ts b/src/_internals/calculate-cnh-first-verifier/calculate-cnh-first-verifier.ts new file mode 100644 index 00000000..276a0c0a --- /dev/null +++ b/src/_internals/calculate-cnh-first-verifier/calculate-cnh-first-verifier.ts @@ -0,0 +1,33 @@ +export type CnhFirstVerifier = { + /** The first check digit, 0 to 9. */ + firstVerifier: number; + /** The correction to apply to the second verifier, 0 or 2. */ + decrement: number; +}; + +/** + * Calculates the first verification digit of a Brazilian CNH (Carteira Nacional de Habilitação) + * from its 9-digit base number. + * + * @param {string} base - The 9-digit CNH base number. + * @returns {CnhFirstVerifier} The first verification digit and the decrement that must be + * applied when calculating the second verification digit. + * + * @example + * ```typescript + * calculateCnhFirstVerifier("000000093"); // { firstVerifier: 0, decrement: 2 } + * ``` + */ +export const calculateCnhFirstVerifier = (base: string): CnhFirstVerifier => { + let sum = 0; + + for (let i = 0; i < 9; i++) { + sum += (base.charCodeAt(i) - 48) * (9 - i); + } + + const remainder = sum % 11; + + if (remainder >= 10) return { firstVerifier: 0, decrement: 2 }; + + return { firstVerifier: remainder, decrement: 0 }; +}; diff --git a/src/_internals/calculate-cnh-second-verifier/calculate-cnh-second-verifier.test.ts b/src/_internals/calculate-cnh-second-verifier/calculate-cnh-second-verifier.test.ts new file mode 100644 index 00000000..e7add1b2 --- /dev/null +++ b/src/_internals/calculate-cnh-second-verifier/calculate-cnh-second-verifier.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "../test/runtime"; +import { calculateCnhSecondVerifier } from "./calculate-cnh-second-verifier"; + +describe("calculateCnhSecondVerifier", () => { + test("should calculate the second verifier", () => { + expect(calculateCnhSecondVerifier({ base: "000000001", decrement: 0 })).toBe(9); + }); + + test("should wrap around when the decrement makes the result negative", () => { + expect(calculateCnhSecondVerifier({ base: "000000093", decrement: 2 })).toBe(9); + }); +}); diff --git a/src/_internals/calculate-cnh-second-verifier/calculate-cnh-second-verifier.ts b/src/_internals/calculate-cnh-second-verifier/calculate-cnh-second-verifier.ts new file mode 100644 index 00000000..30580384 --- /dev/null +++ b/src/_internals/calculate-cnh-second-verifier/calculate-cnh-second-verifier.ts @@ -0,0 +1,39 @@ +export type CalculateCnhSecondVerifierParams = { + /** The 9 digit CNH registry number. */ + base: string; + /** The correction the first verifier reported, 0 or 2. */ + decrement: number; +}; + +/** + * Calculates the second verification digit of a Brazilian CNH (Carteira Nacional de Habilitação) + * from its 9-digit base number and the decrement produced by `calculateCnhFirstVerifier`. + * + * @param {CalculateCnhSecondVerifierParams} params - The calculation parameters. + * @param {string} params.base - The 9-digit CNH base number. + * @param {number} params.decrement - The decrement calculated alongside the first verification digit. + * @returns {number} The calculated second verification digit (0-9). + * + * @example + * ```typescript + * calculateCnhSecondVerifier({ base: "000000093", decrement: 2 }); // 9 + * ``` + */ +export const calculateCnhSecondVerifier = ({ + base, + decrement, +}: CalculateCnhSecondVerifierParams): number => { + let sum = 0; + + for (let i = 0; i < 9; i++) { + sum += (base.charCodeAt(i) - 48) * (i + 1); + } + + let secondVerifier = (sum % 11) - decrement; + + if (secondVerifier < 0) secondVerifier += 11; + + if (secondVerifier >= 10) secondVerifier = 0; + + return secondVerifier; +}; diff --git a/src/_internals/constants/cep.ts b/src/_internals/constants/cep.ts new file mode 100644 index 00000000..edbcb55a --- /dev/null +++ b/src/_internals/constants/cep.ts @@ -0,0 +1,2 @@ +/** Digits of a CEP. */ +export const CEP_LENGTH = 8; diff --git a/src/_internals/constants/cpf.ts b/src/_internals/constants/cpf.ts new file mode 100644 index 00000000..1c7a3625 --- /dev/null +++ b/src/_internals/constants/cpf.ts @@ -0,0 +1,2 @@ +/** Digits of a CPF. */ +export const CPF_LENGTH = 11; diff --git a/src/_internals/constants/pis.ts b/src/_internals/constants/pis.ts new file mode 100644 index 00000000..f60e1326 --- /dev/null +++ b/src/_internals/constants/pis.ts @@ -0,0 +1,7 @@ +/** Digits of a PIS (Programa de Integração Social) number. */ +export const PIS_LENGTH = 11; + +/** + * Weights used to calculate the PIS (Programa de Integração Social) check digit. + */ +export const PIS_WEIGHTS = [3, 2, 9, 8, 7, 6, 5, 4, 3, 2]; diff --git a/src/_internals/constants/processo-juridico.ts b/src/_internals/constants/processo-juridico.ts new file mode 100644 index 00000000..e96ff847 --- /dev/null +++ b/src/_internals/constants/processo-juridico.ts @@ -0,0 +1,2 @@ +/** Digits of a processo jurídico number (`NNNNNNNDDAAAAJTROOOO`, Resolução CNJ nº 65/2008). */ +export const PROCESSO_JURIDICO_LENGTH = 20; diff --git a/src/_internals/fetch-with-retry/fetch-with-retry.test.ts b/src/_internals/fetch-with-retry/fetch-with-retry.test.ts index 86c30a67..0ebec72c 100644 --- a/src/_internals/fetch-with-retry/fetch-with-retry.test.ts +++ b/src/_internals/fetch-with-retry/fetch-with-retry.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "../test/runtime"; -import { fetchWithRetry, isRetryableFetchError } from "./fetch-with-retry"; +import { fetchWithRetry } from "./fetch-with-retry"; describe("fetchWithRetry", () => { const originalFetch = globalThis.fetch; @@ -26,7 +26,7 @@ describe("fetchWithRetry", () => { status: 200, }); - globalThis.fetch = fetchMock as unknown as typeof fetch; + globalThis.fetch = fetchMock; const response = await fetchWithRetry("https://example.com", { retries: 1, retryDelayMs: 0 }); @@ -34,11 +34,23 @@ describe("fetchWithRetry", () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + it("does not retry when the cause object has no code", async () => { + const error = Object.assign(new TypeError("random failure"), { cause: {} }); + const fetchMock = vi.fn().mockRejectedValue(error); + + globalThis.fetch = fetchMock; + + await expect( + fetchWithRetry("https://example.com", { retries: 3, retryDelayMs: 0 }), + ).rejects.toThrow(error); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it("does not retry non-transient failures", async () => { const error = new Error("Invalid URL"); const fetchMock = vi.fn().mockRejectedValue(error); - globalThis.fetch = fetchMock as unknown as typeof fetch; + globalThis.fetch = fetchMock; await expect( fetchWithRetry("https://example.com", { retries: 3, retryDelayMs: 0 }), @@ -46,13 +58,53 @@ describe("fetchWithRetry", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); - it("detects retryable undici errors by code", () => { - expect( - isRetryableFetchError( - Object.assign(new TypeError("fetch failed"), { - cause: { code: "UND_ERR_SOCKET" }, - }), - ), - ).toBe(true); + it("retries when a top level error code is transient", async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(Object.assign(new Error("boom"), { code: "ECONNRESET" })) + .mockResolvedValueOnce({ ok: true, status: 200 }); + globalThis.fetch = fetchMock; + + await fetchWithRetry("https://example.com", { retryDelayMs: 0 }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("retries when the error message says fetch failed", async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new TypeError("fetch failed")) + .mockResolvedValueOnce({ ok: true, status: 200 }); + globalThis.fetch = fetchMock; + + await fetchWithRetry("https://example.com", { retryDelayMs: 0 }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("does not retry non-error rejections", async () => { + const fetchMock = vi.fn().mockRejectedValueOnce("fetch failed"); + globalThis.fetch = fetchMock; + + const rejection = await fetchWithRetry("https://example.com", { retryDelayMs: 0 }).then( + () => undefined, + (error: unknown) => error, + ); + + expect(rejection).toBe("fetch failed"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("throws without ever attempting the fetch when retries is negative", async () => { + const fetchMock = vi.fn(); + globalThis.fetch = fetchMock; + + const rejection = await fetchWithRetry("https://example.com", { retries: -1 }).then( + () => undefined, + (error: unknown) => error, + ); + + expect(rejection).toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(0); }); }); diff --git a/src/_internals/fetch-with-retry/fetch-with-retry.ts b/src/_internals/fetch-with-retry/fetch-with-retry.ts index fe5dbc65..cc166860 100644 --- a/src/_internals/fetch-with-retry/fetch-with-retry.ts +++ b/src/_internals/fetch-with-retry/fetch-with-retry.ts @@ -1,4 +1,13 @@ -const RETRYABLE_ERROR_CODES = new Set([ +import { isNullish } from "../is-nullish/is-nullish.ts"; + +export type FetchWithRetryOptions = RequestInit & { + /** How many times to retry a failed request (default: 2). */ + retries?: number; + /** Delay between retries, in milliseconds (default: 300). */ + retryDelayMs?: number; +}; + +const RETRYABLE_ERROR_CODES = [ "UND_ERR_SOCKET", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", @@ -8,18 +17,10 @@ const RETRYABLE_ERROR_CODES = new Set([ "EHOSTUNREACH", "ENETUNREACH", "ETIMEDOUT", -]); - -export type FetchWithRetryOptions = RequestInit & { - retries?: number; - retryDelayMs?: number; -}; - -const wait = (ms: number): Promise => - ms <= 0 ? Promise.resolve() : new Promise((resolve) => setTimeout(resolve, ms)); +]; const getErrorCode = (error: unknown): string | undefined => { - if (!error || typeof error !== "object") return undefined; + if (isNullish(error) || typeof error !== "object") return undefined; const code = "code" in error ? error.code : undefined; @@ -29,17 +30,17 @@ const getErrorCode = (error: unknown): string | undefined => { const cause = "cause" in error ? error.cause : undefined; - if (!cause || typeof cause !== "object") return undefined; + if (isNullish(cause) || typeof cause !== "object") return undefined; const causeCode = "code" in cause ? cause.code : undefined; return typeof causeCode === "string" ? causeCode : undefined; }; -export const isRetryableFetchError = (error: unknown): boolean => { +const isRetryableFetchError = (error: unknown): boolean => { const code = getErrorCode(error); - if (code && RETRYABLE_ERROR_CODES.has(code)) { + if (code && RETRYABLE_ERROR_CODES.includes(code)) { return true; } @@ -50,6 +51,25 @@ export const isRetryableFetchError = (error: unknown): boolean => { return error.message.toLowerCase().includes("fetch failed"); }; +const wait = (ms: number): Promise => + ms <= 0 ? Promise.resolve() : new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Performs a `fetch` retrying transient network failures with a linear backoff. + * + * Only failures accepted by `isRetryableFetchError` are retried; every other rejection is + * rethrown immediately. HTTP error statuses are not retried, since they resolve rather than + * reject. + * + * @param {string|URL|Request} input - The resource to fetch. + * @param {FetchWithRetryOptions} [options] - `fetch` init plus `retries` and `retryDelayMs`. + * @returns {Promise} The `fetch` response. + * + * @example + * ```typescript + * await fetchWithRetry("https://viacep.com.br/ws/01001000/json/", { retries: 1, retryDelayMs: 0 }); + * ``` + */ export const fetchWithRetry = async ( input: string | URL | Request, { retries = 2, retryDelayMs = 250, ...init }: FetchWithRetryOptions = {}, diff --git a/src/_internals/format/format.test.ts b/src/_internals/format/format.test.ts index 3cd2ed62..f8a573a0 100644 --- a/src/_internals/format/format.test.ts +++ b/src/_internals/format/format.test.ts @@ -36,4 +36,27 @@ describe("format", () => { const result = format({ value: "123456", pattern: "" }); expect(result).toBe(""); }); + + it("should replace the positions marked with * by *", () => { + expect(format({ value: "12345678901", pattern: "***.000.000-**" })).toBe("***.456.789-**"); + }); + + it("should leave separators untouched around the hidden positions", () => { + expect(format({ value: "12345678000195", pattern: "**.000.000/0000-**" })).toBe( + "**.345.678/0001-**", + ); + }); + + it("should copy every position when the pattern has no *", () => { + expect(format({ value: "12345678901", pattern: "000.000.000-00" })).toBe("123.456.789-01"); + }); + + it("should stop at the length of the value when the pattern has *", () => { + expect(format({ value: "123", pattern: "***.000.000-**" })).toBe("***"); + expect(format({ value: "", pattern: "***.000.000-**" })).toBe(""); + }); + + it("should count * as a slot when padding", () => { + expect(format({ value: "123", pattern: "***.000.000-**", pad: true })).toBe("***.000.001-**"); + }); }); diff --git a/src/_internals/format/format.ts b/src/_internals/format/format.ts index 818af83b..d3219e76 100644 --- a/src/_internals/format/format.ts +++ b/src/_internals/format/format.ts @@ -1,34 +1,51 @@ -export type FormatParams = { value: string; pattern: string; pad?: boolean }; +export type FormatParams = { + /** The raw value to format, already sanitized to the characters the pattern consumes. */ + value: string; + /** The pattern to format against: `0` copies one input character, `*` hides one, anything else is a literal separator. */ + pattern: string; + /** Whether to left pad the value with zeros up to the number of slots in the pattern (default: `false`). */ + pad?: boolean; +}; /** * Formats a given value according to a specified pattern. * - * @param {Object} params - The parameters for formatting. - * @param {boolean} params.pad - Whether to pad the value with leading zeros. + * The pattern is read character by character: + * - `0` consumes one character of `value` and copies it; + * - `*` consumes one character of `value` and emits `*` in its place, hiding it; + * - anything else is a literal separator, emitted only while `value` still has characters left. + * + * `pad` counts both `0` and `*` as slots, so a value shorter than the pattern is left padded + * with zeros before it is consumed. + * + * @param {FormatParams} params - The parameters for formatting. * @param {string} params.value - The value to be formatted. * @param {string} params.pattern - The pattern to format the value against. + * @param {boolean} [params.pad] - Whether to pad the value with leading zeros. * @returns {string} The formatted value. * * @example * ```typescript * format({ value: "123456", pattern: "000-000" }); // "123-456" - * format({ value: "123", pattern: "0000-000", pad: true }); // "0123-000" + * format({ value: "123", pattern: "0000-000", pad: true }); // "0000-123" + * format({ value: "12345678909", pattern: "***.000.000-**" }); // "***.456.789-**" * ``` */ export const format = ({ pad, value, pattern }: FormatParams): string => { let formatted = ""; - let digitIndex = 0; + let valueIndex = 0; if (pad) { - const separatorsLength = pattern.replace(/0/g, "").length; + const separatorsLength = pattern.replace(/[0*]/g, "").length; value = value.padStart(pattern.length - separatorsLength, "0"); } for (const char of pattern) { - if (char === "0") { - if (digitIndex >= value.length) break; - formatted += value[digitIndex++]; - } else if (digitIndex < value.length) { + if (char === "0" || char === "*") { + if (valueIndex >= value.length) break; + formatted += char === "*" ? "*" : value[valueIndex]; + valueIndex++; + } else if (valueIndex < value.length) { formatted += char; } } diff --git a/src/_internals/generate-checksum/generate-checksum.ts b/src/_internals/generate-checksum/generate-checksum.ts index 701ecf38..616f1b30 100644 --- a/src/_internals/generate-checksum/generate-checksum.ts +++ b/src/_internals/generate-checksum/generate-checksum.ts @@ -1,10 +1,28 @@ import { sanitizeToDigits } from "../sanitize-to-digits/sanitize-to-digits"; export interface GenerateChecksumParams { + /** The digits the checksum is computed over. */ base: string | number; + /** A starting weight that decreases along the digits, or the explicit weight of each digit. */ weight: number | number[]; } +/** + * Sums every digit of a base value multiplied by its weight, the shared first step of the + * modulus 11 check digits used by CPF, PIS and friends. + * + * @param {GenerateChecksumParams} params - The checksum parameters. + * @param {string|number} params.base - The value whose digits are summed. Non digits are ignored. + * @param {number|number[]} params.weight - Either the weight of the leftmost digit, decreasing + * by one towards the right, or one explicit weight per digit. + * @returns {number} The weighted sum of the digits. + * + * @example + * ```typescript + * generateChecksum({ base: "123456789", weight: 10 }); // 210 + * generateChecksum({ base: "123", weight: [1, 2, 3] }); // 14 + * ``` + */ export function generateChecksum({ base, weight }: GenerateChecksumParams): number { const digits = sanitizeToDigits(base); @@ -15,7 +33,7 @@ export function generateChecksum({ base, weight }: GenerateChecksumParams): numb if (typeof weight === "number") { let w = weight; for (let i = 0; i < len; i++, w--) { - const digit = digits.charCodeAt(i) - 48; // '0'.charCodeAt(0) === 48 + const digit = digits.charCodeAt(i) - 48; sum += digit * w; } } else { diff --git a/src/_internals/is-nullish/is-nullish.test.ts b/src/_internals/is-nullish/is-nullish.test.ts new file mode 100644 index 00000000..ef43b744 --- /dev/null +++ b/src/_internals/is-nullish/is-nullish.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "../test/runtime"; +import { isNullish } from "./is-nullish"; + +describe("isNullish", () => { + test("should return true for null and undefined", () => { + expect(isNullish(null)).toBe(true); + expect(isNullish(undefined)).toBe(true); + }); + + test("should return false for every other value, including falsy ones", () => { + expect(isNullish("")).toBe(false); + expect(isNullish(0)).toBe(false); + expect(isNullish(Number.NaN)).toBe(false); + expect(isNullish(false)).toBe(false); + expect(isNullish({})).toBe(false); + }); +}); diff --git a/src/_internals/is-nullish/is-nullish.ts b/src/_internals/is-nullish/is-nullish.ts new file mode 100644 index 00000000..b8634d8d --- /dev/null +++ b/src/_internals/is-nullish/is-nullish.ts @@ -0,0 +1,21 @@ +/** + * Checks whether a value is `null` or `undefined`. + * + * Public functions of this library must never throw on bad input, so every entry point + * guards its argument with this helper before handing it to a sanitizer and returns the + * empty value of its family instead (`""` for `format*`/`parse*`, `false` for `isValid*`, + * `null` where the family already uses `null` and `[]` for list getters). + * + * @param {unknown} value - The value to check. + * @returns {boolean} True when the value is `null` or `undefined`. + * + * @example + * ```typescript + * isNullish(null); // true + * isNullish(undefined); // true + * isNullish(""); // false + * isNullish(0); // false + * ``` + */ +export const isNullish = (value: unknown): value is null | undefined => + value === null || value === undefined; diff --git a/src/_internals/is-repeated-digits/is-repeated-digits.test.ts b/src/_internals/is-repeated-digits/is-repeated-digits.test.ts new file mode 100644 index 00000000..83a28697 --- /dev/null +++ b/src/_internals/is-repeated-digits/is-repeated-digits.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "../test/runtime"; +import { isRepeatedDigits } from "./is-repeated-digits"; + +describe("isRepeatedDigits", () => { + test("should return true when all characters are the same", () => { + expect(isRepeatedDigits("00000000000")).toBe(true); + expect(isRepeatedDigits("99999999999")).toBe(true); + expect(isRepeatedDigits("AAAAAAAAAAAA")).toBe(true); + }); + + test("should return false when characters differ", () => { + expect(isRepeatedDigits("12345678909")).toBe(false); + }); + + test("should return false for an empty string", () => { + expect(isRepeatedDigits("")).toBe(false); + }); +}); diff --git a/src/_internals/is-repeated-digits/is-repeated-digits.ts b/src/_internals/is-repeated-digits/is-repeated-digits.ts new file mode 100644 index 00000000..bb77a94a --- /dev/null +++ b/src/_internals/is-repeated-digits/is-repeated-digits.ts @@ -0,0 +1,15 @@ +/** + * Checks whether every character in a string is the same (e.g. "00000000000" or "11111111111"). + * + * @param {string} value - The string to check. + * @returns {boolean} True if the string is non-empty and all its characters are identical. + * + * @example + * ```typescript + * isRepeatedDigits("00000000000"); // true + * isRepeatedDigits("12345678909"); // false + * isRepeatedDigits(""); // false + * ``` + */ +export const isRepeatedDigits = (value: string): boolean => + value.length > 0 && value === value[0].repeat(value.length); diff --git a/src/_internals/mod11/mod11.test.ts b/src/_internals/mod11/mod11.test.ts index e67d5875..ddccda3c 100644 --- a/src/_internals/mod11/mod11.test.ts +++ b/src/_internals/mod11/mod11.test.ts @@ -1,26 +1,87 @@ import { describe, expect, test } from "../test/runtime"; import { mod11 } from "./mod11"; +const mod11Arrecadacao = (value: string) => mod11(value, { variant: "arrecadacao" }); + +const mod11Bank = (value: string, maxWeight?: number) => + mod11(value, { variant: "bank", maxWeight }); + describe("mod11", () => { - test("should calculate correct check digit", () => { - // Test with known values - expect(mod11("0019")).toBeGreaterThanOrEqual(1); - expect(mod11("0019")).toBeLessThanOrEqual(11); + describe("default variant (boleto)", () => { + test("should return a digit between 1 and 9 for any input", () => { + const values = ["0019", "123", "0019000009011497186016852452211467586000010265"]; + + for (const value of values) { + expect(mod11(value)).toBeGreaterThanOrEqual(1); + expect(mod11(value)).toBeLessThanOrEqual(9); + } + }); + + test("should return 1 when the weighted sum leaves remainder 0 (sum 0 for '0')", () => { + expect(mod11("0")).toBe(1); + }); + + test("should return 1 when the weighted sum leaves remainder 10 ('19' = 1*3 + 9*2 = 21)", () => { + expect(mod11("19")).toBe(1); + }); + + test("should compute the DV geral of a real boleto barcode with its 5th position removed", () => { + expect(mod11("0019758600001026560000001149718606852452211")).toBe(6); + }); }); - test("should return 1 when mod is 0 or 1", () => { - // This would need a specific test case that results in mod 0 or 1 - // For now, just verify the function works - expect(mod11("123")).toBeGreaterThanOrEqual(1); - expect(mod11("123")).toBeLessThanOrEqual(11); + describe("arrecadacao variant", () => { + test("should reproduce the FEBRABAN field example (§09): sum 176, remainder 0 gives 0", () => { + expect(mod11Arrecadacao("01230067896")).toBe(0); + }); + + test("should reproduce the FEBRABAN DV geral example (§10): sum 705, remainder 1 gives 0", () => { + expect(mod11Arrecadacao("8220000215048200974123220154098290108605940")).toBe(0); + }); + + test("should return 0 when the remainder is 0", () => { + expect(mod11Arrecadacao("0")).toBe(0); + }); + + test("should return 1 when the remainder is 10 ('19' = 21, '5' = 10)", () => { + expect(mod11Arrecadacao("19")).toBe(1); + expect(mod11Arrecadacao("5")).toBe(1); + }); + + test("should return 11 minus the remainder for the other cases ('1' -> 9, '2' -> 7)", () => { + expect(mod11Arrecadacao("1")).toBe(9); + expect(mod11Arrecadacao("2")).toBe(7); + }); + + test("should map remainder 0 to 0 while the boleto variant maps it to 1", () => { + expect(mod11Arrecadacao("0")).toBe(0); + expect(mod11("0")).toBe(1); + }); }); - test("should return valid check digit range", () => { - const values = ["123", "0019000009011497186016852452211467586000010265", "0019"]; - for (const value of values) { - const result = mod11(value); - expect(result).toBeGreaterThanOrEqual(1); - expect(result).toBeLessThanOrEqual(11); - } + describe("bank variant", () => { + test("should compute a Banco do Brasil account digit (00210169 -> sum 60, remainder 5, digit 6)", () => { + expect(mod11Bank("00210169")).toBe(6); + }); + + test("should return 0 when the remainder is 0 (10089939 -> sum 165)", () => { + expect(mod11Bank("10089939")).toBe(0); + }); + + test("should return 10 when the remainder is 1, which banks render as 'X' or 'P'", () => { + expect(mod11Bank("10089934")).toBe(10); + expect(mod11Bank("00189062")).toBe(10); + }); + + test("should honor a custom max weight (Bradesco wraps the weights at 7)", () => { + expect(mod11Bank("0238069", 7)).toBe(2); + expect(mod11Bank("0301357", 7)).toBe(10); + expect(mod11Bank("0325620", 7)).toBe(0); + expect(mod11Bank("0284025", 7)).toBe(1); + }); + + test("should not fall back to the boleto digit 1 for remainder 0", () => { + expect(mod11Bank("10089939")).not.toBe(1); + }); }); }); diff --git a/src/_internals/mod11/mod11.ts b/src/_internals/mod11/mod11.ts index 0213832f..3fe51bb7 100644 --- a/src/_internals/mod11/mod11.ts +++ b/src/_internals/mod11/mod11.ts @@ -1,22 +1,51 @@ +export type Mod11Variant = "boleto" | "arrecadacao" | "bank"; + +export type Mod11Options = { + /** Which modulo 11 rule to apply (default: `"boleto"`). */ + variant?: Mod11Variant; + /** Highest weight of the cycling weight sequence (default: 9). */ + maxWeight?: number; +}; + +const REMAINDER_OVERRIDES: Record> = { + boleto: { 0: 1, 1: 1 }, + arrecadacao: { 0: 0, 1: 0, 10: 1 }, + bank: { 0: 0 }, +}; + +const DEFAULT_MAX_WEIGHT = 9; + /** - * Calculates the modulus 11 check digit for a given string. + * Calculates a modulus 11 check digit for a given string of digits. * - * @param {string} str - The string to calculate the check digit for. - * @returns {number} The calculated check digit (1-11). + * @param {string} value - The digits to calculate the check digit for. + * @param {Mod11Options} [options] - Optional options. + * @param {Mod11Variant} [options.variant] - The remainder mapping to apply. Defaults to `"boleto"`. + * @param {number} [options.maxWeight] - The highest multiplier before wrapping back to 2. Defaults to 9. + * @returns {number} The calculated check digit: 1-9 for `"boleto"`, 0-9 for `"arrecadacao"` and + * 0-10 for `"bank"` (where 10 is the bank specific exceptional digit). * * @example * ```typescript - * mod11("0019000009011497186016852452211467586000010265"); // 6 + * mod11("0019758600001026560000001149718606852452211"); // 6 (DV geral of a boleto barcode) + * mod11("01230067896", { variant: "arrecadacao" }); // 0 + * mod11("00210169", { variant: "bank" }); // 6 (Banco do Brasil, conta 00210169-6) + * mod11("0238069", { variant: "bank", maxWeight: 7 }); // 2 (Bradesco, conta 0238069-2) * ``` */ -export const mod11 = (str: string): number => { +export const mod11 = (value: string, options?: Mod11Options): number => { + const maxWeight = options?.maxWeight ?? DEFAULT_MAX_WEIGHT; + const overrides = REMAINDER_OVERRIDES[options?.variant ?? "boleto"]; + let weight = 2; let sum = 0; - for (let i = str.length - 1; i >= 0; i--) { - const digit = str.charCodeAt(i) - 48; - sum += digit * weight; - weight = weight < 9 ? weight + 1 : 2; + + for (let i = value.length - 1; i >= 0; i--) { + sum += (value.charCodeAt(i) - 48) * weight; + weight = weight < maxWeight ? weight + 1 : 2; } - const mod = sum % 11; - return mod === 0 || mod === 1 ? 1 : 11 - mod; + + const remainder = sum % 11; + + return remainder in overrides ? overrides[remainder] : 11 - remainder; }; diff --git a/src/format-cnh/format-cnh.ts b/src/format-cnh/format-cnh.ts index 5b51bfd0..1e520368 100644 --- a/src/format-cnh/format-cnh.ts +++ b/src/format-cnh/format-cnh.ts @@ -1,11 +1,30 @@ 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"; export type FormatCnhOptions = Pick; +/** + * Formats a Brazilian CNH (Carteira Nacional de Habilitação) number. + * + * @param {string|number} value - The CNH number to be formatted. + * @param {FormatCnhOptions} [options] - Optional options. + * @param {boolean} [options.pad] - Whether to pad the value with leading zeros. + * @returns {string} The formatted CNH, or an empty string when there is nothing to format. + * + * @example + * ```typescript + * formatCnh("12345678900"); // "123456789-00" + * formatCnh("8900", { pad: true }); // "000000089-00" + * ``` + * + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l9503compilado.htm + */ export const formatCnh = (value: string | number, options?: FormatCnhOptions): string => - format({ - pad: options?.pad, - value: sanitizeToDigits(value), - pattern: "000000000-00", - }); + isNullish(value) + ? "" + : format({ + pad: options?.pad, + value: sanitizeToDigits(value), + pattern: "000000000-00", + }); diff --git a/src/format-pis/constants.ts b/src/format-pis/constants.ts deleted file mode 100644 index 5720885c..00000000 --- a/src/format-pis/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const LENGTH = 11; diff --git a/src/format-pis/format-pis.test.ts b/src/format-pis/format-pis.test.ts index 774bd8fc..5e8be073 100644 --- a/src/format-pis/format-pis.test.ts +++ b/src/format-pis/format-pis.test.ts @@ -1,5 +1,5 @@ +import { PIS_LENGTH } from "../_internals/constants/pis"; import { describe, expect, it } from "../_internals/test/runtime"; -import { LENGTH } from "./constants"; import { formatPis } from "./format-pis"; describe("formatPis", () => { @@ -70,7 +70,7 @@ describe("formatPis", () => { expect(formatPis(100.100000001)).toBe("100.10000.00-0"); }); - it(`should NOT add digits after the PIS length (${LENGTH})`, () => { + it(`should NOT add digits after the PIS length (${PIS_LENGTH})`, () => { expect(formatPis("0000000000000")).toBe("000.00000.00-0"); expect(formatPis("00000000000000")).toBe("000.00000.00-0"); expect(formatPis("000000000000000")).toBe("000.00000.00-0"); diff --git a/src/format-pis/format-pis.ts b/src/format-pis/format-pis.ts index fc3e1f57..cad6ac88 100644 --- a/src/format-pis/format-pis.ts +++ b/src/format-pis/format-pis.ts @@ -1,4 +1,5 @@ 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"; export type FormatPisOptions = Pick; @@ -7,7 +8,7 @@ export type FormatPisOptions = Pick; * Formats a PIS (Programa de Integração Social) number according to the specified pattern. * * @param {string|number} value - The PIS number to be formatted. It can be a string or a number. - * @param {Object} options - Optional formatting options. + * @param {FormatPisOptions} [options] - Optional formatting options. * @param {boolean} options.pad - If true, pads the value with leading zeros if necessary. * @returns {string} The formatted PIS number as a string. * @@ -17,10 +18,14 @@ export type FormatPisOptions = Pick; * formatPis(12345678901); // "123.45678.90-1" * formatPis("123456789", { pad: true }); // "001.23456.78-9" * ``` + * + * @see Official: https://www.gov.br/inss/pt-br/direitos-e-deveres/inscricao-e-contribuicao/inscricao */ export const formatPis = (value: string | number, options?: FormatPisOptions): string => - format({ - pad: options?.pad, - value: sanitizeToDigits(value), - pattern: "000.00000.00-0", - }); + isNullish(value) + ? "" + : format({ + pad: options?.pad, + value: sanitizeToDigits(value), + pattern: "000.00000.00-0", + }); diff --git a/src/generate-cnh/generate-cnh.test.ts b/src/generate-cnh/generate-cnh.test.ts index 2eeeaaea..4a154630 100644 --- a/src/generate-cnh/generate-cnh.test.ts +++ b/src/generate-cnh/generate-cnh.test.ts @@ -1,11 +1,40 @@ +import { calculateCnhFirstVerifier } from "../_internals/calculate-cnh-first-verifier/calculate-cnh-first-verifier"; +import { calculateCnhSecondVerifier } from "../_internals/calculate-cnh-second-verifier/calculate-cnh-second-verifier"; import { describe, expect, it } from "../_internals/test/runtime"; import { isValidCnh } from "../is-valid-cnh/is-valid-cnh"; import { generateCnh } from "./generate-cnh"; describe("generateCnh", () => { it("should generate valid CNH values", () => { - for (let i = 0; i < 50; i++) { + for (let i = 0; i < 200; i++) { expect(isValidCnh(generateCnh())).toBe(true); } }); + + it("should cover the secondVerifier<0 branch with a known base", () => { + const base = "000000093"; + const { firstVerifier, decrement } = calculateCnhFirstVerifier(base); + const secondVerifier = calculateCnhSecondVerifier({ base, decrement }); + + expect(decrement).toBe(2); + expect(secondVerifier).toBe(9); + expect(isValidCnh(`${base}${firstVerifier}${secondVerifier}`)).toBe(true); + }); + + it("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 cnh = generateCnh(); + + expect(cnh.slice(0, 9)).toBe("123456789"); + expect(isValidCnh(cnh)).toBe(true); + } finally { + Math.random = originalRandom; + } + }); }); diff --git a/src/generate-cnh/generate-cnh.ts b/src/generate-cnh/generate-cnh.ts index db136016..60058f2c 100644 --- a/src/generate-cnh/generate-cnh.ts +++ b/src/generate-cnh/generate-cnh.ts @@ -1,51 +1,32 @@ +import { calculateCnhFirstVerifier } from "../_internals/calculate-cnh-first-verifier/calculate-cnh-first-verifier"; +import { calculateCnhSecondVerifier } from "../_internals/calculate-cnh-second-verifier/calculate-cnh-second-verifier"; import { generateRandomNumber } from "../_internals/generate-random-number/generate-random-number"; - -const calculateFirstVerifier = (base: string): { firstVerifier: number; decrement: number } => { - let sum = 0; - - for (let i = 0; i < 9; i++) { - sum += (base.charCodeAt(i) - 48) * (9 - i); - } - - const remainder = sum % 11; - - if (remainder >= 10) { - return { firstVerifier: 0, decrement: 2 }; - } - - return { firstVerifier: remainder, decrement: 0 }; -}; - -const calculateSecondVerifier = ({ - base, - decrement, -}: { - base: string; - decrement: number; -}): number => { - let sum = 0; - - for (let i = 0; i < 9; i++) { - sum += (base.charCodeAt(i) - 48) * (i + 1); - } - - let secondVerifier = (sum % 11) - decrement; - - if (secondVerifier < 0) secondVerifier += 11; - if (secondVerifier >= 10) secondVerifier = 0; - - return secondVerifier; -}; - +import { isRepeatedDigits } from "../_internals/is-repeated-digits/is-repeated-digits"; + +/** + * Generates a valid random CNH (Carteira Nacional de Habilitação, the Brazilian driver's license number). + * + * Uses `Math.random()` internally, so it is not cryptographically secure, do not use for security purposes. + * + * @returns {string} A valid 11-digit CNH string without formatting. + * + * @example + * ```typescript + * generateCnh(); // "00000000119" + * ``` + * + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l9503compilado.htm + * @see Based on: https://siga0984.wordpress.com/2019/05/01/algoritmos-validacao-de-cnh/ + */ export const generateCnh = (): string => { let base = generateRandomNumber(9); - while (/^(\d)\1+$/.test(base)) { + while (isRepeatedDigits(base)) { base = generateRandomNumber(9); } - const { firstVerifier, decrement } = calculateFirstVerifier(base); - const secondVerifier = calculateSecondVerifier({ base, decrement }); + const { firstVerifier, decrement } = calculateCnhFirstVerifier(base); + const secondVerifier = calculateCnhSecondVerifier({ base, decrement }); return `${base}${firstVerifier}${secondVerifier}`; }; diff --git a/src/is-valid-cnh/is-valid-cnh.test.ts b/src/is-valid-cnh/is-valid-cnh.test.ts index 05cb7522..2bbab71f 100644 --- a/src/is-valid-cnh/is-valid-cnh.test.ts +++ b/src/is-valid-cnh/is-valid-cnh.test.ts @@ -7,8 +7,24 @@ describe("isValidCnh", () => { expect(isValidCnh("000000001-19")).toBe(true); }); + it("should return true for a CNH that hits the secondVerifier<0 branch", () => { + expect(isValidCnh("00000009309")).toBe(true); + }); + it("should return false for invalid CNH", () => { expect(isValidCnh("12345678901")).toBe(false); expect(isValidCnh("11111111111")).toBe(false); }); + + it("should return false when the first verifier digit does not match", () => { + expect(isValidCnh("00000000129")).toBe(false); + }); + + it("should return false for falsy or non-string values", () => { + expect(isValidCnh("")).toBe(false); + // @ts-expect-error + expect(isValidCnh(null)).toBe(false); + // @ts-expect-error + expect(isValidCnh(undefined)).toBe(false); + }); }); diff --git a/src/is-valid-cnh/is-valid-cnh.ts b/src/is-valid-cnh/is-valid-cnh.ts index f97b36d5..3b73162b 100644 --- a/src/is-valid-cnh/is-valid-cnh.ts +++ b/src/is-valid-cnh/is-valid-cnh.ts @@ -1,38 +1,39 @@ +import { calculateCnhFirstVerifier } from "../_internals/calculate-cnh-first-verifier/calculate-cnh-first-verifier"; +import { calculateCnhSecondVerifier } from "../_internals/calculate-cnh-second-verifier/calculate-cnh-second-verifier"; +import { isRepeatedDigits } from "../_internals/is-repeated-digits/is-repeated-digits"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -const repeatedDigits = (value: string): boolean => value === value[0].repeat(value.length); - +/** + * Validates if a CNH (Carteira Nacional de Habilitação, the Brazilian driver's license number) is valid. + * + * @param {string} value - The CNH value to be validated. + * @returns {boolean} True if the CNH is valid, false otherwise. + * + * @example + * ```typescript + * isValidCnh("00000000119"); // true + * isValidCnh("000000001-19"); // true + * isValidCnh("11111111111"); // false (repeated digits) + * isValidCnh("12345678901"); // false (invalid checksum) + * ``` + * + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l9503compilado.htm + * @see Based on: https://siga0984.wordpress.com/2019/05/01/algoritmos-validacao-de-cnh/ + */ export const isValidCnh = (value: string): boolean => { - if (!value || typeof value !== "string") return false; + if (typeof value !== "string" || value === "") return false; const digits = sanitizeToDigits(value); - if (digits.length !== 11 || repeatedDigits(digits)) return false; - - let sum1 = 0; - for (let i = 0; i < 9; i++) { - sum1 += (digits.charCodeAt(i) - 48) * (9 - i); - } - - let digit1 = sum1 % 11; - let decrement = 0; - - if (digit1 >= 10) { - digit1 = 0; - decrement = 2; - } + if (digits.length !== 11 || isRepeatedDigits(digits)) return false; - if (digit1 !== digits.charCodeAt(9) - 48) return false; + const base = digits.slice(0, 9); - let sum2 = 0; - for (let i = 0; i < 9; i++) { - sum2 += (digits.charCodeAt(i) - 48) * (i + 1); - } + const { firstVerifier, decrement } = calculateCnhFirstVerifier(base); - let digit2 = (sum2 % 11) - decrement; + if (firstVerifier !== digits.charCodeAt(9) - 48) return false; - if (digit2 < 0) digit2 += 11; - if (digit2 >= 10) digit2 = 0; + const secondVerifier = calculateCnhSecondVerifier({ base, decrement }); - return digit2 === digits.charCodeAt(10) - 48; + return secondVerifier === digits.charCodeAt(10) - 48; }; diff --git a/src/is-valid-email/is-valid-email.ts b/src/is-valid-email/is-valid-email.ts index 1609871a..eb8cd462 100644 --- a/src/is-valid-email/is-valid-email.ts +++ b/src/is-valid-email/is-valid-email.ts @@ -1,7 +1,10 @@ +const EMAIL_REGEX = + /^(?!\.)(?!.*\.\.)([a-z0-9_'+\-.]*)[a-z0-9_+-]@([a-z0-9][a-z0-9-]*\.)+[a-z]{2,}$/i; + /** * Validates if an email address is valid. * - * @param {string} email - The email address to be validated. + * @param {string} value - The email address to be validated. * @returns {boolean} True if the email is valid, false otherwise. * * @example @@ -10,13 +13,11 @@ * isValidEmail("invalid.email"); // false * isValidEmail("test@domain.co.uk"); // true * ``` + * + * @see Official: https://www.rfc-editor.org/rfc/rfc5322 */ - -const EMAIL_REGEX = - /^(?!\.)(?!.*\.\.)([a-z0-9_'+\-.]*)[a-z0-9_+-]@([a-z0-9][a-z0-9-]*\.)+[a-z]{2,}$/i; - export const isValidEmail = (value: string): boolean => { - if (!value || typeof value !== "string") return false; + if (typeof value !== "string" || value === "") return false; return EMAIL_REGEX.test(value); }; diff --git a/src/is-valid-renavam/is-valid-renavam.test.ts b/src/is-valid-renavam/is-valid-renavam.test.ts index 17845369..1000f45d 100644 --- a/src/is-valid-renavam/is-valid-renavam.test.ts +++ b/src/is-valid-renavam/is-valid-renavam.test.ts @@ -46,15 +46,20 @@ describe("isValidRenavam", () => { expect(isValidRenavam("abcdefghij")).toBe(false); }); - test("when it is a RENAVAM with invalid checksum", () => { - expect(isValidRenavam("639884963")).toBe(false); // Last digit changed + test("when it is a RENAVAM with invalid checksum (639884963 has its last digit changed from the valid 639884962)", () => { + expect(isValidRenavam("639884963")).toBe(false); expect(isValidRenavam("12345678901")).toBe(false); }); - test("when it has mixed characters that result in invalid RENAVAM", () => { - // Mixed characters that sanitize to an invalid RENAVAM (invalid checksum) + test("when it has mixed characters that sanitize to an invalid RENAVAM (invalid checksum)", () => { expect(isValidRenavam("12345678901abc")).toBe(false); - expect(isValidRenavam("639884963xyz")).toBe(false); // Invalid checksum + expect(isValidRenavam("639884963xyz")).toBe(false); + }); + + test("when is a RENAVAM with invalid length: 8 digits (too short), 10 digits (invalid), or 12 digits (too long)", () => { + expect(isValidRenavam("12345678")).toBe(false); + expect(isValidRenavam("1234567890")).toBe(false); + expect(isValidRenavam("123456789012")).toBe(false); }); }); @@ -71,20 +76,7 @@ describe("isValidRenavam", () => { expect(isValidRenavam(639884962)).toBe(true); }); - test("when is a RENAVAM with invalid length", () => { - expect(isValidRenavam("12345678")).toBe(false); // 8 digits - too short - expect(isValidRenavam("1234567890")).toBe(false); // 10 digits - invalid length - expect(isValidRenavam("123456789012")).toBe(false); // 12 digits - too long - }); - - test("when is a RENAVAM valid with various formats", () => { - // Test with known valid RENAVAMs - expect(isValidRenavam("639884962")).toBe(true); - expect(isValidRenavam("00639884962")).toBe(true); - }); - - test("when is a RENAVAM valid with mixed characters that sanitize correctly", () => { - // Mixed characters that sanitize to a valid RENAVAM + test("when is a RENAVAM valid with mixed characters that sanitize to a valid RENAVAM", () => { expect(isValidRenavam("639884962abc")).toBe(true); }); }); diff --git a/src/is-valid-renavam/is-valid-renavam.ts b/src/is-valid-renavam/is-valid-renavam.ts index 8cb03cac..b7e95af5 100644 --- a/src/is-valid-renavam/is-valid-renavam.ts +++ b/src/is-valid-renavam/is-valid-renavam.ts @@ -2,13 +2,6 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d const RENAVAM_LENGTH = 11; -/** - * Pads a string with zeros on the left to reach the desired length. - * - * @param {string} input - The input string to pad. - * @param {number} padLength - The desired length after padding. - * @returns {string} The padded string. - */ const padLeft = (input: string, padLength: number): string => { const currentLength = input.length; if (currentLength >= padLength) return input; @@ -33,6 +26,8 @@ const padLeft = (input: string, padLength: number): string => { * isValidRenavam("00639884962"); // true (11 digits, new format) * isValidRenavam("12345678901"); // false (invalid checksum) * ``` + * + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l9503compilado.htm */ export const isValidRenavam = (renavam: string | number): boolean => { if (!renavam) return false; @@ -43,8 +38,6 @@ export const isValidRenavam = (renavam: string | number): boolean => { const paddedDigits = padLeft(digits, RENAVAM_LENGTH); - if (!/^\d{11}$/.test(paddedDigits)) return false; - const renavamWithoutDigit = paddedDigits.substring(0, 10); const reversedRenavam = renavamWithoutDigit.split("").reverse().join(""); @@ -52,25 +45,17 @@ export const isValidRenavam = (renavam: string | number): boolean => { let sum = 0; let multiplier = 2; for (let i = 0; i < 10; i++) { - const digit = Number.parseInt(reversedRenavam[i] ?? "0", 10); + const digit = Number.parseInt(reversedRenavam[i], 10); sum += digit * multiplier; - if (multiplier >= 9) { - multiplier = 2; - } else { - multiplier++; - } + multiplier = multiplier >= 9 ? 2 : multiplier + 1; } const mod11 = sum % 11; - let expectedDigit = 11 - mod11; - - if (expectedDigit >= 10) { - expectedDigit = 0; - } + const expectedDigit = mod11 <= 1 ? 0 : 11 - mod11; - const actualDigit = Number.parseInt(paddedDigits[10] ?? "0", 10); + const actualDigit = Number.parseInt(paddedDigits[10], 10); return expectedDigit === actualDigit; }; diff --git a/src/parse-cep/parse-cep.ts b/src/parse-cep/parse-cep.ts index d6f00d0a..afe04391 100644 --- a/src/parse-cep/parse-cep.ts +++ b/src/parse-cep/parse-cep.ts @@ -1,11 +1,19 @@ +import { CEP_LENGTH } from "../_internals/constants/cep"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -import { LENGTH } from "../format-cep/constants"; /** * Removes CEP formatting characters and returns only digits. * * @param {string|number} value - The CEP value to be parsed. * @returns {string} The CEP value without formatting. + * + * @example + * ```typescript + * parseCep("01310-930"); // "01310930" + * ``` + * + * @see Official: https://www.correios.com.br/enviar/precisa-de-ajuda/tudo-sobre-cep */ export const parseCep = (value: string | number): string => - sanitizeToDigits(value).slice(0, LENGTH); + isNullish(value) ? "" : sanitizeToDigits(value).slice(0, CEP_LENGTH); diff --git a/src/parse-cnh/parse-cnh.ts b/src/parse-cnh/parse-cnh.ts index 9454fffd..08dc3351 100644 --- a/src/parse-cnh/parse-cnh.ts +++ b/src/parse-cnh/parse-cnh.ts @@ -1,5 +1,19 @@ +import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; import { LENGTH } from "./constants"; +/** + * Removes CNH (Carteira Nacional de Habilitação) formatting characters and returns only digits. + * + * @param {string|number} value - The CNH to be parsed. + * @returns {string} Up to 11 digits, or an empty string when there is no digit at all. + * + * @example + * ```typescript + * parseCnh("123456789-00"); // "12345678900" + * ``` + * + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l9503compilado.htm + */ export const parseCnh = (value: string | number): string => - sanitizeToDigits(value).slice(0, LENGTH); + isNullish(value) ? "" : sanitizeToDigits(value).slice(0, LENGTH); diff --git a/src/parse-cpf/parse-cpf.ts b/src/parse-cpf/parse-cpf.ts index eb8a05ef..85c05188 100644 --- a/src/parse-cpf/parse-cpf.ts +++ b/src/parse-cpf/parse-cpf.ts @@ -1,11 +1,19 @@ +import { CPF_LENGTH } from "../_internals/constants/cpf"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -import { LENGTH } from "../format-cpf/constants"; /** * Removes CPF formatting characters and returns only digits. * * @param {string|number} value - The CPF value to be parsed. * @returns {string} The CPF value without formatting. + * + * @example + * ```typescript + * parseCpf("123.456.789-09"); // "12345678909" + * ``` + * + * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/meu-cpf */ export const parseCpf = (value: string | number): string => - sanitizeToDigits(value).slice(0, LENGTH); + isNullish(value) ? "" : sanitizeToDigits(value).slice(0, CPF_LENGTH); diff --git a/src/parse-legal-nature/parse-legal-nature.ts b/src/parse-legal-nature/parse-legal-nature.ts index 87fd603e..c607573f 100644 --- a/src/parse-legal-nature/parse-legal-nature.ts +++ b/src/parse-legal-nature/parse-legal-nature.ts @@ -1,5 +1,19 @@ +import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; import { LENGTH } from "./constants"; +/** + * Removes legal nature (natureza jurídica) formatting characters and returns only digits. + * + * @param {string|number} value - The legal nature code to be parsed. + * @returns {string} Up to 4 digits, or an empty string when there is no digit at all. + * + * @example + * ```typescript + * parseLegalNature("206-2"); // "2062" + * ``` + * + * @see Official: https://concla.ibge.gov.br/estrutura/natjur-estrutura/natureza-juridica-2021 + */ export const parseLegalNature = (value: string | number): string => - sanitizeToDigits(value).slice(0, LENGTH); + isNullish(value) ? "" : sanitizeToDigits(value).slice(0, LENGTH); diff --git a/src/parse-pis/parse-pis.ts b/src/parse-pis/parse-pis.ts index ac5d439b..7bea6698 100644 --- a/src/parse-pis/parse-pis.ts +++ b/src/parse-pis/parse-pis.ts @@ -1,11 +1,19 @@ +import { PIS_LENGTH } from "../_internals/constants/pis"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -import { LENGTH } from "../format-pis/constants"; /** * Removes PIS formatting characters and returns only digits. * * @param {string|number} value - The PIS value to be parsed. * @returns {string} The PIS value without formatting. + * + * @example + * ```typescript + * parsePis("120.12345.67-8"); // "12012345678" + * ``` + * + * @see Official: https://www.gov.br/inss/pt-br/direitos-e-deveres/inscricao-e-contribuicao/inscricao */ export const parsePis = (value: string | number): string => - sanitizeToDigits(value).slice(0, LENGTH); + isNullish(value) ? "" : sanitizeToDigits(value).slice(0, PIS_LENGTH); diff --git a/src/parse-processo-juridico/parse-processo-juridico.test.ts b/src/parse-processo-juridico/parse-processo-juridico.test.ts index 9b9035bf..26932a61 100644 --- a/src/parse-processo-juridico/parse-processo-juridico.test.ts +++ b/src/parse-processo-juridico/parse-processo-juridico.test.ts @@ -3,6 +3,10 @@ import { parseProcessoJuridico } from "./parse-processo-juridico"; describe("parseProcessoJuridico", () => { it("should remove processo juridico mask characters", () => { + expect(parseProcessoJuridico("0002080-25.2012.5.15.0049")).toBe("00020802520125150049"); + }); + + it("should also accept the legacy fused mask", () => { expect(parseProcessoJuridico("0002080-25.2012.515.0049")).toBe("00020802520125150049"); }); diff --git a/src/parse-processo-juridico/parse-processo-juridico.ts b/src/parse-processo-juridico/parse-processo-juridico.ts index 0adbc3be..205f7cc4 100644 --- a/src/parse-processo-juridico/parse-processo-juridico.ts +++ b/src/parse-processo-juridico/parse-processo-juridico.ts @@ -1,11 +1,19 @@ +import { PROCESSO_JURIDICO_LENGTH } from "../_internals/constants/processo-juridico"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -import { LENGTH } from "../format-processo-juridico/constants"; /** * Removes legal process formatting characters and returns only digits. * * @param {string|number} value - The legal process value to be parsed. * @returns {string} The legal process value without formatting. + * + * @example + * ```typescript + * parseProcessoJuridico("0002080-25.2026.5.15.0049"); // "00020802520265150049" + * ``` + * + * @see Official: https://atos.cnj.jus.br/atos/detalhar/119 Resolução CNJ nº 65/2008 */ export const parseProcessoJuridico = (value: string | number): string => - sanitizeToDigits(value).slice(0, LENGTH); + isNullish(value) ? "" : sanitizeToDigits(value).slice(0, PROCESSO_JURIDICO_LENGTH); diff --git a/src/remove-accents/remove-accents.test.ts b/src/remove-accents/remove-accents.test.ts new file mode 100644 index 00000000..1044c723 --- /dev/null +++ b/src/remove-accents/remove-accents.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "../_internals/test/runtime"; +import { removeAccents } from "./remove-accents"; + +describe("removeAccents", () => { + it("should remove an acute accent", () => { + expect(removeAccents("Piauí")).toBe("Piaui"); + }); + + it("should remove a circumflex accent", () => { + expect(removeAccents("Você")).toBe("Voce"); + }); + + it("should remove a tilde", () => { + expect(removeAccents("São Paulo")).toBe("Sao Paulo"); + }); + + it("should remove a grave accent", () => { + expect(removeAccents("à")).toBe("a"); + }); + + it("should remove a cedilla", () => { + expect(removeAccents("Açaí")).toBe("Acai"); + }); + + it("should remove multiple accents in the same word", () => { + expect(removeAccents("Ceará")).toBe("Ceara"); + }); + + it("should keep an already unaccented string unchanged", () => { + expect(removeAccents("Brasil")).toBe("Brasil"); + }); + + it("should keep casing, digits, spaces and punctuation untouched", () => { + expect(removeAccents("São Paulo, SP - 2024!")).toBe("Sao Paulo, SP - 2024!"); + }); + + it("should return an empty string when given an empty string", () => { + expect(removeAccents("")).toBe(""); + }); + + it("should return an empty string when given null", () => { + // @ts-expect-error + expect(removeAccents(null)).toBe(""); + }); + + it("should return an empty string when given undefined", () => { + // @ts-expect-error + expect(removeAccents(undefined)).toBe(""); + }); + + it("should return an empty string when given a number", () => { + // @ts-expect-error + expect(removeAccents(123)).toBe(""); + }); +}); diff --git a/src/remove-accents/remove-accents.ts b/src/remove-accents/remove-accents.ts new file mode 100644 index 00000000..1b6b9b2f --- /dev/null +++ b/src/remove-accents/remove-accents.ts @@ -0,0 +1,25 @@ +const COMBINING_MARKS_REGEX = /[\u0300-\u036f]/g; + +/** + * Removes diacritical marks (accents, tildes, cedillas) from a string, decomposing every + * accented character into its base letter plus combining marks (Unicode NFD) and then + * dropping the combining marks (Unicode block U+0300-U+036F). + * + * @param {string} value - The text to strip accents from. + * @returns {string} The text with every diacritical mark removed. `""` when `value` is not a + * non-empty string. + * + * @example + * ```typescript + * removeAccents("São Paulo"); // "Sao Paulo" + * removeAccents("Piauí"); // "Piaui" + * removeAccents("Ceará"); // "Ceara" + * removeAccents("Açaí"); // "Acai" + * removeAccents(""); // "" + * ``` + */ +export const removeAccents = (value: string): string => { + if (typeof value !== "string" || value === "") return ""; + + return value.normalize("NFD").replace(COMBINING_MARKS_REGEX, ""); +};