From d639a7a5e4a38fc7be026058fc60ec45a0da66de Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:32:08 -0300 Subject: [PATCH 01/10] feat(nfe-key): add formatNfeKey, isValidNfeKey and parseNfeKey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New util family for the 44-digit NFe (Nota Fiscal Eletrônica) access key, validated against the IBGE UF codes. --- src/_internals/constants/ibge-uf-codes.ts | 39 +++++++ src/_internals/constants/nfe-key.ts | 2 + src/format-nfe-key/constants.ts | 6 + src/format-nfe-key/format-nfe-key.test.ts | 46 ++++++++ src/format-nfe-key/format-nfe-key.ts | 23 ++++ src/is-valid-nfe-key/constants.ts | 2 + src/is-valid-nfe-key/is-valid-nfe-key.test.ts | 110 ++++++++++++++++++ src/is-valid-nfe-key/is-valid-nfe-key.ts | 72 ++++++++++++ src/parse-nfe-key/parse-nfe-key.test.ts | 91 +++++++++++++++ src/parse-nfe-key/parse-nfe-key.ts | 84 +++++++++++++ 10 files changed, 475 insertions(+) create mode 100644 src/_internals/constants/ibge-uf-codes.ts create mode 100644 src/_internals/constants/nfe-key.ts create mode 100644 src/format-nfe-key/constants.ts create mode 100644 src/format-nfe-key/format-nfe-key.test.ts create mode 100644 src/format-nfe-key/format-nfe-key.ts create mode 100644 src/is-valid-nfe-key/constants.ts create mode 100644 src/is-valid-nfe-key/is-valid-nfe-key.test.ts create mode 100644 src/is-valid-nfe-key/is-valid-nfe-key.ts create mode 100644 src/parse-nfe-key/parse-nfe-key.test.ts create mode 100644 src/parse-nfe-key/parse-nfe-key.ts diff --git a/src/_internals/constants/ibge-uf-codes.ts b/src/_internals/constants/ibge-uf-codes.ts new file mode 100644 index 00000000..99caf18f --- /dev/null +++ b/src/_internals/constants/ibge-uf-codes.ts @@ -0,0 +1,39 @@ +import type { StateCode } from "./states"; + +/** + * IBGE code of the Federative Unit ("cUF"), keyed by the 2 digit code found in the first + * field of every DF-e access key (chave de acesso): NF-e (modelo 55), NFC-e (modelo 65), + * CT-e (modelo 57) and MDF-e (modelo 58). + * + * @see Official: https://www.confaz.fazenda.gov.br/legislacao/arquivo-manuais/moc7-visao-geral.pdf + * (Manual de Orientação do Contribuinte, "chave de acesso" / "Tabela do IBGE"). + */ +export const IBGE_UF_CODES: Record = { + "11": "RO", + "12": "AC", + "13": "AM", + "14": "RR", + "15": "PA", + "16": "AP", + "17": "TO", + "21": "MA", + "22": "PI", + "23": "CE", + "24": "RN", + "25": "PB", + "26": "PE", + "27": "AL", + "28": "SE", + "29": "BA", + "31": "MG", + "32": "ES", + "33": "RJ", + "35": "SP", + "41": "PR", + "42": "SC", + "43": "RS", + "50": "MS", + "51": "MT", + "52": "GO", + "53": "DF", +}; diff --git a/src/_internals/constants/nfe-key.ts b/src/_internals/constants/nfe-key.ts new file mode 100644 index 00000000..d9b06eb8 --- /dev/null +++ b/src/_internals/constants/nfe-key.ts @@ -0,0 +1,2 @@ +/** Digits of a DF-e (NF-e, NFC-e, CT-e or MDF-e) access key (chave de acesso). */ +export const NFE_KEY_LENGTH = 44; diff --git a/src/format-nfe-key/constants.ts b/src/format-nfe-key/constants.ts new file mode 100644 index 00000000..2d5f8692 --- /dev/null +++ b/src/format-nfe-key/constants.ts @@ -0,0 +1,6 @@ +/** + * 11 groups of 4 digits, the common display form printed on the DANFE. Spelled out + * instead of built with `Array(11).fill(...).join(...)`: a top-level call cannot be + * proven pure by consumer bundlers and would pin this module into their output. + */ +export const PATTERN = "0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000"; diff --git a/src/format-nfe-key/format-nfe-key.test.ts b/src/format-nfe-key/format-nfe-key.test.ts new file mode 100644 index 00000000..d320e067 --- /dev/null +++ b/src/format-nfe-key/format-nfe-key.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "../_internals/test/runtime"; +import { formatNfeKey } from "./format-nfe-key"; + +const KEY = "35170458716523000119550010000000121000123458"; +const FORMATTED = "3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458"; + +describe("formatNfeKey", () => { + test("should format a full access key into groups of 4 digits", () => { + expect(formatNfeKey(KEY)).toBe(FORMATTED); + }); + + test("should format partial values as far as they go", () => { + expect(formatNfeKey("")).toBe(""); + expect(formatNfeKey("1")).toBe("1"); + expect(formatNfeKey("123")).toBe("123"); + expect(formatNfeKey("1234")).toBe("1234"); + expect(formatNfeKey("12345")).toBe("1234 5"); + }); + + test("should NOT add digits after the access key length (44)", () => { + expect(formatNfeKey(`${KEY}999999`)).toBe(FORMATTED); + }); + + test("should remove all non numeric characters, including the NFe prefix", () => { + expect(formatNfeKey(`NFe${KEY}`)).toBe(FORMATTED); + expect(formatNfeKey(FORMATTED)).toBe(FORMATTED); + }); + + test("should return an empty string for nullish input", () => { + // @ts-expect-error + expect(formatNfeKey(null)).toBe(""); + // @ts-expect-error + expect(formatNfeKey(undefined)).toBe(""); + }); + + test("should not throw for other bad input types", () => { + // @ts-expect-error + expect(formatNfeKey(123)).toBe("123"); + // @ts-expect-error + expect(formatNfeKey({})).toBe(""); + // @ts-expect-error + expect(formatNfeKey([])).toBe(""); + // @ts-expect-error + expect(formatNfeKey(true)).toBe(""); + }); +}); diff --git a/src/format-nfe-key/format-nfe-key.ts b/src/format-nfe-key/format-nfe-key.ts new file mode 100644 index 00000000..78c08078 --- /dev/null +++ b/src/format-nfe-key/format-nfe-key.ts @@ -0,0 +1,23 @@ +import { format } from "../_internals/format/format"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { PATTERN } from "./constants"; + +/** + * Formats a DF-e (NF-e, NFC-e, CT-e or MDF-e) access key (chave de acesso) into groups of 4 + * digits separated by spaces, the common display form printed on the DANFE. + * + * @param {string} value - The access key value to be formatted. + * @returns {string} The formatted access key, e.g. "3520 0612 3456 ...". + * + * @example + * ```typescript + * formatNfeKey("35170458716523000119550010000000121000123458"); + * // "3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458" + * ``` + * + * @see Official: https://www.confaz.fazenda.gov.br/legislacao/arquivo-manuais/moc7-visao-geral.pdf + * Manual de Orientação do Contribuinte (MOC) NF-e, "chave de acesso". + */ +export const formatNfeKey = (value: string): string => + isNullish(value) ? "" : format({ value: sanitizeToDigits(value), pattern: PATTERN }); diff --git a/src/is-valid-nfe-key/constants.ts b/src/is-valid-nfe-key/constants.ts new file mode 100644 index 00000000..5d3436f7 --- /dev/null +++ b/src/is-valid-nfe-key/constants.ts @@ -0,0 +1,2 @@ +/** Valid `mod` (modelo do documento) values shared by every DF-e access key. */ +export const VALID_MODELS = ["55", "57", "58", "65"] as const; diff --git a/src/is-valid-nfe-key/is-valid-nfe-key.test.ts b/src/is-valid-nfe-key/is-valid-nfe-key.test.ts new file mode 100644 index 00000000..778c6a85 --- /dev/null +++ b/src/is-valid-nfe-key/is-valid-nfe-key.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "../_internals/test/runtime"; +import { isValidNfeKey } from "./is-valid-nfe-key"; + +const VALID_A = "35120859597245000190550000000095831710040056"; +const VALID_B = "35170458716523000119550010000000121000123458"; +const VALID_C = "35170358716523000119550010000000301000000300"; +const VALID_D = "43160472202112000136550000000010571048440722"; +const INVALID_TYPE = "42100484684182000157550010000000020108042108"; + +describe("isValidNfeKey", () => { + describe("should return true", () => { + test("for a real NF-e access key without a mask, the br-validate-dfe-access-key README/tests example (SP)", () => { + expect(isValidNfeKey(VALID_A)).toBe(true); + }); + + test("for a real NF-e access key without a mask, the NFePHP `Keys::build` doc example (SP)", () => { + expect(isValidNfeKey(VALID_B)).toBe(true); + }); + + test("for a real NF-e access key without a mask, the NFePHP `Keys::isValid` doc example (SP)", () => { + expect(isValidNfeKey(VALID_C)).toBe(true); + }); + + test("for a real NF-e access key without a mask, the NFePHP sped-cte `$infNFe->chave` example (RS, NF-e referenced by a CT-e)", () => { + expect(isValidNfeKey(VALID_D)).toBe(true); + }); + + test("when it has the NFe prefix found in the XML Id attribute", () => { + expect(isValidNfeKey(`NFe${VALID_B}`)).toBe(true); + }); + + test("when it is grouped in spaces of 4 digits", () => { + expect(isValidNfeKey("3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458")).toBe(true); + }); + + test("when it has the NFe prefix and a whitespace mask combined", () => { + expect(isValidNfeKey("NFe 3512 0859 5972 4500 0190 5500 0000 0095 8317 1004 0056")).toBe( + true, + ); + }); + }); + + describe("should return false", () => { + test("when the document number (positions 26 to 34) is zero, even with a matching check digit", () => { + expect(isValidNfeKey("35170458716523000119550010000000001000123457")).toBe(false); + }); + + test("when it is null", () => { + // @ts-expect-error + expect(isValidNfeKey(null)).toBe(false); + }); + + test("when it is undefined", () => { + // @ts-expect-error + expect(isValidNfeKey(undefined)).toBe(false); + }); + + test("when it is a number", () => { + // @ts-expect-error + expect(isValidNfeKey(123)).toBe(false); + }); + + test("when it is a boolean", () => { + // @ts-expect-error + expect(isValidNfeKey(true)).toBe(false); + }); + + test("when it is an object or an array", () => { + // @ts-expect-error + expect(isValidNfeKey({})).toBe(false); + // @ts-expect-error + expect(isValidNfeKey([])).toBe(false); + }); + + test("when it is an empty string", () => { + expect(isValidNfeKey("")).toBe(false); + }); + + test("when it has letters mixed with the digits", () => { + expect(isValidNfeKey(`foo${VALID_B}bar`)).toBe(false); + }); + + test("when it does not have 44 digits", () => { + expect(isValidNfeKey(VALID_B.slice(0, 43))).toBe(false); + expect(isValidNfeKey(`${VALID_B}9`)).toBe(false); + }); + + test("when the cUF is not a valid IBGE UF code", () => { + expect(isValidNfeKey(`00${VALID_B.slice(2)}`)).toBe(false); + }); + + test("when the mod is not 55, 57, 58 or 65", () => { + expect(isValidNfeKey(`${VALID_B.slice(0, 20)}99${VALID_B.slice(22)}`)).toBe(false); + }); + + test("when the month is not between 01 and 12", () => { + expect(isValidNfeKey(`${VALID_B.slice(0, 4)}13${VALID_B.slice(6)}`)).toBe(false); + expect(isValidNfeKey(`${VALID_B.slice(0, 4)}00${VALID_B.slice(6)}`)).toBe(false); + }); + + test("when tpEmis is not between 1 and 9, using the br-validate-dfe-access-key doc example with a valid check digit but tpEmis '0'", () => { + expect(isValidNfeKey(INVALID_TYPE)).toBe(false); + }); + + test("when the check digit does not match", () => { + const brokenDv = `${VALID_B.slice(0, 43)}${VALID_B.at(-1) === "8" ? "7" : "8"}`; + expect(isValidNfeKey(brokenDv)).toBe(false); + }); + }); +}); diff --git a/src/is-valid-nfe-key/is-valid-nfe-key.ts b/src/is-valid-nfe-key/is-valid-nfe-key.ts new file mode 100644 index 00000000..0d2ab014 --- /dev/null +++ b/src/is-valid-nfe-key/is-valid-nfe-key.ts @@ -0,0 +1,72 @@ +import { IBGE_UF_CODES } from "../_internals/constants/ibge-uf-codes"; +import { NFE_KEY_LENGTH } from "../_internals/constants/nfe-key"; +import { mod11 } from "../_internals/mod11/mod11"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { VALID_MODELS } from "./constants"; + +const MODELS: readonly string[] = VALID_MODELS; + +const NUMBER_START = 25; +const NUMBER_END = 34; +const ABSENT_NUMBER = "000000000"; + +const FORMAT_REGEX = /^(?:nfe)?[\d\s]+$/i; + +/** + * Validates a DF-e (Documento Fiscal eletrônico) access key (chave de acesso). + * + * Covers every document that shares the same 44 digit layout: NF-e (modelo 55), NFC-e + * (modelo 65), CT-e (modelo 57) and MDF-e (modelo 58). Accepts whitespace between digit + * groups (the common display mask) and the `NFe` prefix found in the `Id` attribute of the + * document's XML (e.g. `Id="NFe3517...`), which is stripped before validation. + * + * The key is `cUF(2) AAMM(4) CNPJ/CPF(14) mod(2) serie(3) nNF(9) tpEmis(1) cNF(8) cDV(1)`. + * The check digit (`cDV`) is a modulus 11 over the first 43 digits, weights 2-9 cycling from + * the right, where a remainder of 0 or 1 maps to check digit 0. + * + * @param {string} value - The access key value to be validated. + * @returns {boolean} True if the access key is valid, false otherwise. + * + * @see Official: https://www.confaz.fazenda.gov.br/legislacao/arquivo-manuais/moc7-visao-geral.pdf + * Manual de Orientação do Contribuinte (MOC) NF-e, "chave de acesso". + * @see Based on: https://github.com/nfephp-org/sped-common/blob/master/src/Keys.php + * NFePHP `Keys::build`/`Keys::isValid` reference implementation. + * @see Based on: https://github.com/vmarchesin/br-validate-dfe-access-key + * Second reference implementation and source of additional test vectors. + * + * @example + * ```typescript + * isValidNfeKey("35170458716523000119550010000000121000123458"); // true (NF-e, SP) + * isValidNfeKey("NFe35170458716523000119550010000000121000123458"); // true (XML Id prefix) + * isValidNfeKey("3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458"); // true (masked) + * isValidNfeKey("99170458716523000119550010000000121000123458"); // false (invalid cUF) + * isValidNfeKey("35170458716523000119010010000000121000123450"); // false (invalid mod) + * ``` + */ +export const isValidNfeKey = (value: string): boolean => { + if (typeof value !== "string" || value === "") return false; + + if (!FORMAT_REGEX.test(value.trim())) return false; + + const digits = sanitizeToDigits(value); + + if (digits.length !== NFE_KEY_LENGTH) return false; + + if (!(digits.slice(0, 2) in IBGE_UF_CODES)) return false; + + const month = Number(digits.slice(4, 6)); + + if (month < 1 || month > 12) return false; + + if (!MODELS.includes(digits.slice(20, 22))) return false; + + if (digits.slice(NUMBER_START, NUMBER_END) === ABSENT_NUMBER) return false; + + const emissionType = Number(digits[34]); + + if (emissionType < 1 || emissionType > 9) return false; + + const checkDigit = Number(digits[43]); + + return mod11(digits.slice(0, 43), { variant: "arrecadacao" }) === checkDigit; +}; diff --git a/src/parse-nfe-key/parse-nfe-key.test.ts b/src/parse-nfe-key/parse-nfe-key.test.ts new file mode 100644 index 00000000..14d96978 --- /dev/null +++ b/src/parse-nfe-key/parse-nfe-key.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "../_internals/test/runtime"; +import { parseNfeKey } from "./parse-nfe-key"; + +const KEY_SP = "35170458716523000119550010000000121000123458"; +const KEY_RS = "43160472202112000136550000000010571048440722"; +const KEY_CPF_PADDED = "35170400040364478829550010000000121000123457"; + +describe("parseNfeKey", () => { + describe("should return null", () => { + test("when it is null", () => { + // @ts-expect-error + expect(parseNfeKey(null)).toBeNull(); + }); + + test("when it is undefined", () => { + // @ts-expect-error + expect(parseNfeKey(undefined)).toBeNull(); + }); + + test("when it is a number", () => { + // @ts-expect-error + expect(parseNfeKey(123)).toBeNull(); + }); + + test("when it is an empty string", () => { + expect(parseNfeKey("")).toBeNull(); + }); + + test("when the check digit does not match", () => { + expect(parseNfeKey(`${KEY_SP.slice(0, 43)}9`)).toBeNull(); + }); + + test("when the document number is zero", () => { + expect(parseNfeKey("35170458716523000119550010000000001000123457")).toBeNull(); + }); + + test("when the access key is otherwise invalid", () => { + expect(parseNfeKey("not-a-key")).toBeNull(); + }); + }); + + describe("should return the parsed access key", () => { + test("for a NF-e access key (SP), the NFePHP `Keys::build` doc example also used in is-valid-nfe-key.test.ts", () => { + expect(parseNfeKey(KEY_SP)).toEqual({ + state: "SP", + year: 2017, + month: 4, + taxId: "58716523000119", + model: "55", + series: 1, + number: 12, + emissionType: 1, + code: "00012345", + checkDigit: 8, + }); + }); + + test("for a NF-e access key (RS), the NFePHP sped-cte `$infNFe->chave` example (NF-e referenced by a CT-e)", () => { + expect(parseNfeKey(KEY_RS)).toEqual({ + state: "RS", + year: 2016, + month: 4, + taxId: "72202112000136", + model: "55", + series: 0, + number: 1057, + emissionType: 1, + code: "04844072", + checkDigit: 2, + }); + }); + + test("accepting the NFe XML prefix and a whitespace mask", () => { + expect(parseNfeKey(`NFe${KEY_SP}`)?.taxId).toBe("58716523000119"); + expect(parseNfeKey("3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458")?.number).toBe( + 12, + ); + }); + + test("keeping the left zero padding of a CPF issuer, using a synthetic key with an 11-digit CPF left-padded to 14 digits in the tax id field and the check digit recalculated", () => { + expect(parseNfeKey(KEY_CPF_PADDED)?.taxId).toBe("00040364478829"); + expect(parseNfeKey(KEY_CPF_PADDED)?.taxId).toHaveLength(14); + }); + + test("for every other DF-e model (CT-e, MDF-e, NFC-e), same shape as the SP key with the model field changed and the check digit recalculated", () => { + expect(parseNfeKey("35170458716523000119570010000000121000123455")?.model).toBe("57"); + expect(parseNfeKey("35170458716523000119580010000000121000123459")?.model).toBe("58"); + expect(parseNfeKey("35170458716523000119650010000000121000123450")?.model).toBe("65"); + }); + }); +}); diff --git a/src/parse-nfe-key/parse-nfe-key.ts b/src/parse-nfe-key/parse-nfe-key.ts new file mode 100644 index 00000000..9010f5d4 --- /dev/null +++ b/src/parse-nfe-key/parse-nfe-key.ts @@ -0,0 +1,84 @@ +import { IBGE_UF_CODES } from "../_internals/constants/ibge-uf-codes"; +import { NFE_KEY_LENGTH } from "../_internals/constants/nfe-key"; +import type { StateCode } from "../_internals/constants/states"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { isValidNfeKey } from "../is-valid-nfe-key/is-valid-nfe-key"; + +export type NfeKeyModel = "55" | "57" | "58" | "65"; + +const isNfeKeyModel = (value: string): value is NfeKeyModel => + value === "55" || value === "57" || value === "58" || value === "65"; + +export type NfeKey = { + /** Two letter code of the issuing state, read from the IBGE UF code. */ + state: StateCode; + /** Four digit issue year. */ + year: number; + /** Issue month, 1 to 12. */ + month: number; + /** The 14 digit CNPJ (or zero padded CPF) of the issuer. */ + taxId: string; + /** Document model: "55" NF-e, "57" CT-e, "58" MDF-e, "65" NFC-e. */ + model: NfeKeyModel; + /** Document series, 0 to 999. */ + series: number; + /** Document number, 1 to 999999999. */ + number: number; + /** Emission type code (tpEmis), 1 to 9. */ + emissionType: number; + /** The 8 digit numeric code (cNF) drawn by the issuer. */ + code: string; + /** The modulo 11 check digit of the key. */ + checkDigit: number; +}; + +/** + * Parses a DF-e (Documento Fiscal eletrônico) access key (chave de acesso) into its fields. + * + * Covers every document that shares the same 44 digit layout: NF-e (modelo 55), NFC-e + * (modelo 65), CT-e (modelo 57) and MDF-e (modelo 58). Accepts the same input forms as + * `isValidNfeKey` (whitespace mask, `NFe` XML `Id` prefix) and returns `null` when the key + * is not valid. + * + * @param {string} value - The access key value to be parsed. + * @returns {NfeKey | null} The parsed access key, or `null` when it is not valid. + * + * @see Official: https://www.confaz.fazenda.gov.br/legislacao/arquivo-manuais/moc7-visao-geral.pdf + * Manual de Orientação do Contribuinte (MOC) NF-e, "chave de acesso". + * @see Based on: https://github.com/nfephp-org/sped-common/blob/master/src/Keys.php + * NFePHP `Keys::build` reference implementation, source of the SP and RS test vectors. + * @see Based on: https://github.com/vmarchesin/br-validate-dfe-access-key + * Second reference implementation. + * + * @example + * ```typescript + * parseNfeKey("35170458716523000119550010000000121000123458"); + * // { state: "SP", year: 2017, month: 4, taxId: "58716523000119", model: "55", + * // series: 1, number: 12, emissionType: 1, code: "00012345", checkDigit: 8 } + * + * parseNfeKey("invalid"); // null + * ``` + */ +export const parseNfeKey = (value: string): NfeKey | null => { + if (!isValidNfeKey(value)) return null; + + const digits = sanitizeToDigits(value).slice(0, NFE_KEY_LENGTH); + + const model = digits.slice(20, 22); + + /* v8 ignore next */ + if (!isNfeKeyModel(model)) return null; + + return { + state: IBGE_UF_CODES[digits.slice(0, 2)], + year: 2000 + Number(digits.slice(2, 4)), + month: Number(digits.slice(4, 6)), + taxId: digits.slice(6, 20), + model, + series: Number(digits.slice(22, 25)), + number: Number(digits.slice(25, 34)), + emissionType: Number(digits[34]), + code: digits.slice(35, 43), + checkDigit: Number(digits[43]), + }; +}; From 088861e277cca1bf90627cf230258994f2dbd403 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:32:08 -0300 Subject: [PATCH 02/10] feat(pix): add generatePixPayload, isValidPixPayload, isValidPixKey, parsePixPayload and parsePixKey New util family for Pix BR Code (EMV/TLV) payload generation/parsing and Pix key validation/parsing (CPF/CNPJ/email/phone/random key). Renamed from the original generatePix/isValidPix/parsePix names to the *PixPayload family to read clearly next to the *PixKey utils. Adds shared crc16-ccitt, format-tlv/parse-tlv internals. --- src/_internals/constants/pix.ts | 78 ++++ .../crc16-ccitt/crc16-ccitt.test.ts | 46 +++ src/_internals/crc16-ccitt/crc16-ccitt.ts | 40 ++ src/_internals/format-tlv/format-tlv.test.ts | 25 ++ src/_internals/format-tlv/format-tlv.ts | 26 ++ .../is-valid-pix-url/is-valid-pix-url.test.ts | 51 +++ .../is-valid-pix-url/is-valid-pix-url.ts | 15 + src/_internals/parse-tlv/parse-tlv.test.ts | 48 +++ src/_internals/parse-tlv/parse-tlv.ts | 47 +++ .../sanitize-to-ascii.test.ts | 29 ++ .../sanitize-to-ascii/sanitize-to-ascii.ts | 27 ++ src/generate-pix-payload/constants.ts | 10 + .../generate-pix-payload.test.ts | 369 ++++++++++++++++++ .../generate-pix-payload.ts | 210 ++++++++++ src/is-valid-pix-key/is-valid-pix-key.test.ts | 98 +++++ src/is-valid-pix-key/is-valid-pix-key.ts | 43 ++ .../is-valid-pix-payload.test.ts | 190 +++++++++ .../is-valid-pix-payload.ts | 36 ++ src/parse-pix-key/constants.ts | 16 + src/parse-pix-key/parse-pix-key.test.ts | 262 +++++++++++++ src/parse-pix-key/parse-pix-key.ts | 91 +++++ .../parse-pix-payload.test.ts | 242 ++++++++++++ src/parse-pix-payload/parse-pix-payload.ts | 214 ++++++++++ 23 files changed, 2213 insertions(+) create mode 100644 src/_internals/constants/pix.ts create mode 100644 src/_internals/crc16-ccitt/crc16-ccitt.test.ts create mode 100644 src/_internals/crc16-ccitt/crc16-ccitt.ts create mode 100644 src/_internals/format-tlv/format-tlv.test.ts create mode 100644 src/_internals/format-tlv/format-tlv.ts create mode 100644 src/_internals/is-valid-pix-url/is-valid-pix-url.test.ts create mode 100644 src/_internals/is-valid-pix-url/is-valid-pix-url.ts create mode 100644 src/_internals/parse-tlv/parse-tlv.test.ts create mode 100644 src/_internals/parse-tlv/parse-tlv.ts create mode 100644 src/_internals/sanitize-to-ascii/sanitize-to-ascii.test.ts create mode 100644 src/_internals/sanitize-to-ascii/sanitize-to-ascii.ts create mode 100644 src/generate-pix-payload/constants.ts create mode 100644 src/generate-pix-payload/generate-pix-payload.test.ts create mode 100644 src/generate-pix-payload/generate-pix-payload.ts create mode 100644 src/is-valid-pix-key/is-valid-pix-key.test.ts create mode 100644 src/is-valid-pix-key/is-valid-pix-key.ts create mode 100644 src/is-valid-pix-payload/is-valid-pix-payload.test.ts create mode 100644 src/is-valid-pix-payload/is-valid-pix-payload.ts create mode 100644 src/parse-pix-key/constants.ts create mode 100644 src/parse-pix-key/parse-pix-key.test.ts create mode 100644 src/parse-pix-key/parse-pix-key.ts create mode 100644 src/parse-pix-payload/parse-pix-payload.test.ts create mode 100644 src/parse-pix-payload/parse-pix-payload.ts diff --git a/src/_internals/constants/pix.ts b/src/_internals/constants/pix.ts new file mode 100644 index 00000000..4570f9bb --- /dev/null +++ b/src/_internals/constants/pix.ts @@ -0,0 +1,78 @@ +/** + * BR Code (EMV® QRCPS-MPM) field identifiers and Pix specific limits shared by the Pix + * utilities. + * + * The payload is a flat list of TLV objects: a 2 digit ID, a 2 digit length and a value of + * exactly that length. The Pix arrangement lives in one of the "Merchant Account Information" + * templates (IDs 26 to 51), the one whose GUI (sub-object `00`) is `br.gov.bcb.pix`. + * + * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/II_ManualdePadroesparaIniciacaodoPix.pdf + */ + +export const PIX_GUI = "br.gov.bcb.pix"; + +export const PIX_PAYLOAD_FORMAT_INDICATOR_ID = "00"; + +export const PIX_PAYLOAD_FORMAT_INDICATOR = "01"; + +export const PIX_POINT_OF_INITIATION_ID = "01"; + +export const PIX_STATIC_POINT_OF_INITIATION = "11"; + +export const PIX_DYNAMIC_POINT_OF_INITIATION = "12"; + +export const PIX_MERCHANT_ACCOUNT_INFORMATION_ID = "26"; + +export const PIX_MERCHANT_ACCOUNT_INFORMATION_FIRST_ID = 26; + +export const PIX_MERCHANT_ACCOUNT_INFORMATION_LAST_ID = 51; + +export const PIX_MERCHANT_ACCOUNT_INFORMATION_MAX_LENGTH = 99; + +export const PIX_GUI_ID = "00"; + +export const PIX_KEY_ID = "01"; + +export const PIX_DESCRIPTION_ID = "02"; + +export const PIX_URL_ID = "25"; + +export const PIX_MERCHANT_CATEGORY_CODE_ID = "52"; + +export const PIX_MERCHANT_CATEGORY_CODE = "0000"; + +export const PIX_TRANSACTION_CURRENCY_ID = "53"; + +export const PIX_TRANSACTION_CURRENCY = "986"; + +export const PIX_TRANSACTION_AMOUNT_ID = "54"; + +export const PIX_TRANSACTION_AMOUNT_MAX_LENGTH = 13; + +export const PIX_COUNTRY_CODE_ID = "58"; + +export const PIX_COUNTRY_CODE = "BR"; + +export const PIX_MERCHANT_NAME_ID = "59"; + +export const PIX_MERCHANT_NAME_MAX_LENGTH = 25; + +export const PIX_MERCHANT_CITY_ID = "60"; + +export const PIX_MERCHANT_CITY_MAX_LENGTH = 15; + +export const PIX_ADDITIONAL_DATA_ID = "62"; + +export const PIX_TXID_ID = "05"; + +export const PIX_ABSENT_TXID = "***"; + +export const PIX_CRC_TAG = "6304"; + +export const PIX_CRC_LENGTH = 4; + +export const PIX_KEY_MAX_LENGTH = 77; + +export const PIX_URL_MAX_LENGTH = 77; + +export const PIX_DESCRIPTION_MAX_LENGTH = 72; diff --git a/src/_internals/crc16-ccitt/crc16-ccitt.test.ts b/src/_internals/crc16-ccitt/crc16-ccitt.test.ts new file mode 100644 index 00000000..8b21760c --- /dev/null +++ b/src/_internals/crc16-ccitt/crc16-ccitt.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "../test/runtime"; +import { crc16Ccitt } from "./crc16-ccitt"; + +describe("crc16Ccitt", () => { + test("should match the CRC-16/CCITT-FALSE check value", () => { + expect(crc16Ccitt("123456789")).toBe("29B1"); + }); + + test("should return the initial value for an empty string", () => { + expect(crc16Ccitt("")).toBe("FFFF"); + }); + + test("should always return four uppercase hexadecimal digits", () => { + for (let index = 0; index < 500; index++) { + expect(crc16Ccitt(`payload-${index}`)).toMatch(/^[0-9A-F]{4}$/); + } + }); + + test("should match the static QR Code example of the Bacen manual", () => { + expect( + crc16Ccitt( + "00020126580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-4266554400005204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***6304", + ), + ).toBe("1D3D"); + }); + + test("should match the dynamic QR Code example of the Bacen manual", () => { + expect( + crc16Ccitt( + "00020101021226700014br.gov.bcb.pix2548pix.example.com/8b3da2f39a4140d1a91abd93113bd4415204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***6304", + ), + ).toBe("64E4"); + }); + + test("should match the BR Code manual example", () => { + expect( + crc16Ccitt( + "00020104141234567890123426580014BR.GOV.BCB.PIX0136123e4567-e12b-12d1-a456-42665544000027300012BR.COM.OUTRO011001234567895204000053039865406123.455802BR5917NOME DO RECEBEDOR6008BRASILIA61087007490062190515RP12345678-201980390012BR.COM.OUTRO01190123.ABCD.3456.WXYZ6304", + ), + ).toBe("AD38"); + }); + + test("should change when the payload changes", () => { + expect(crc16Ccitt("A")).not.toBe(crc16Ccitt("B")); + }); +}); diff --git a/src/_internals/crc16-ccitt/crc16-ccitt.ts b/src/_internals/crc16-ccitt/crc16-ccitt.ts new file mode 100644 index 00000000..cd858355 --- /dev/null +++ b/src/_internals/crc16-ccitt/crc16-ccitt.ts @@ -0,0 +1,40 @@ +const POLYNOMIAL = 0x1021; + +const INITIAL_VALUE = 0xffff; + +const MASK = 0xffff; + +const HEX_LENGTH = 4; + +/** + * Calculates the CRC-16/CCITT-FALSE checksum of a string and returns it as four uppercase + * hexadecimal digits. + * + * The variant is the one required by the BR Code standard: polynomial `0x1021`, initial value + * `0xFFFF`, no input or output reflection and no final xor. The bytes fed to the checksum are + * the UTF-8 encoding of the string, which for an ASCII BR Code payload is the payload itself. + * + * @param {string} value - The string to checksum. + * @returns {string} The checksum as four uppercase hexadecimal digits. + * + * @example + * ```typescript + * crc16Ccitt("123456789"); // "29B1" + * crc16Ccitt(""); // "FFFF" + * ``` + */ +export const crc16Ccitt = (value: string): string => { + const bytes = new TextEncoder().encode(value); + + let crc = INITIAL_VALUE; + + for (let index = 0; index < bytes.length; index++) { + crc ^= bytes[index] << 8; + + for (let bit = 0; bit < 8; bit++) { + crc = (crc & 0x8000) === 0 ? (crc << 1) & MASK : ((crc << 1) ^ POLYNOMIAL) & MASK; + } + } + + return crc.toString(16).toUpperCase().padStart(HEX_LENGTH, "0"); +}; diff --git a/src/_internals/format-tlv/format-tlv.test.ts b/src/_internals/format-tlv/format-tlv.test.ts new file mode 100644 index 00000000..9d410551 --- /dev/null +++ b/src/_internals/format-tlv/format-tlv.test.ts @@ -0,0 +1,25 @@ +import { parseTlv } from "../parse-tlv/parse-tlv"; +import { describe, expect, test } from "../test/runtime"; +import { formatTlv } from "./format-tlv"; + +describe("formatTlv", () => { + test("should pad the length to two digits", () => { + expect(formatTlv({ id: "00", value: "01" })).toBe("000201"); + }); + + test("should keep a two digit length as is", () => { + expect(formatTlv({ id: "59", value: "NOME DO RECEBEDOR" })).toBe("5917NOME DO RECEBEDOR"); + }); + + test("should serialize an empty value", () => { + expect(formatTlv({ id: "62", value: "" })).toBe("6200"); + }); + + test("should round-trip through parseTlv", () => { + for (let length = 0; length <= 99; length++) { + const value = "x".repeat(length); + + expect(parseTlv(formatTlv({ id: "26", value }))).toEqual({ "26": value }); + } + }); +}); diff --git a/src/_internals/format-tlv/format-tlv.ts b/src/_internals/format-tlv/format-tlv.ts new file mode 100644 index 00000000..21b2648f --- /dev/null +++ b/src/_internals/format-tlv/format-tlv.ts @@ -0,0 +1,26 @@ +export type FormatTlvParams = { + /** The two digit object ID. */ + id: string; + /** The value of the object, whose length is written in front of it. */ + value: string; +}; + +const LENGTH_SEGMENT_LENGTH = 2; + +/** + * Serializes one EMV® style TLV (tag-length-value) object: the ID, the value length written as + * two digits and the value itself. + * + * @param {FormatTlvParams} params - The object to serialize. + * @param {string} params.id - The 2 digit object ID. + * @param {string} params.value - The object value, at most 99 characters long. + * @returns {string} The serialized object. + * + * @example + * ```typescript + * formatTlv({ id: "00", value: "01" }); // "000201" + * formatTlv({ id: "58", value: "BR" }); // "5802BR" + * ``` + */ +export const formatTlv = ({ id, value }: FormatTlvParams): string => + `${id}${value.length.toString().padStart(LENGTH_SEGMENT_LENGTH, "0")}${value}`; diff --git a/src/_internals/is-valid-pix-url/is-valid-pix-url.test.ts b/src/_internals/is-valid-pix-url/is-valid-pix-url.test.ts new file mode 100644 index 00000000..c4ffff85 --- /dev/null +++ b/src/_internals/is-valid-pix-url/is-valid-pix-url.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "../test/runtime"; +import { isValidPixUrl } from "./is-valid-pix-url"; + +describe("isValidPixUrl", () => { + describe("should return true", () => { + test("for a host with a path", () => { + expect(isValidPixUrl("pix.example.com/qr/v2/1234")).toBe(true); + }); + + test("for a bare host", () => { + expect(isValidPixUrl("pix.example.com")).toBe(true); + }); + + test("for a host with a trailing slash and upper case letters", () => { + expect(isValidPixUrl("PIX.Example.com/")).toBe(true); + }); + + test("for a path with the URL unreserved and sub-delimiter characters", () => { + expect(isValidPixUrl("pix.example.com/a-b_c.d~e%20f!$&'()*+,;=:@")).toBe(true); + }); + }); + + describe("should return false", () => { + test("for an empty string", () => { + expect(isValidPixUrl("")).toBe(false); + }); + + test("when it carries a scheme", () => { + expect(isValidPixUrl("https://pix.example.com/x")).toBe(false); + }); + + test("when it contains whitespace", () => { + expect(isValidPixUrl("pix example.com/x")).toBe(false); + expect(isValidPixUrl("pix.example.com/x y")).toBe(false); + }); + + test("when the host has no dot", () => { + expect(isValidPixUrl("localhost/x")).toBe(false); + }); + + test("when a host label starts or ends with a hyphen", () => { + expect(isValidPixUrl("-pix.example.com")).toBe(false); + expect(isValidPixUrl("pix-.example.com")).toBe(false); + }); + + test("when the path carries characters outside the allowed sets", () => { + expect(isValidPixUrl("pix.example.com/")).toBe(false); + expect(isValidPixUrl("pix.example.com/x?y=1")).toBe(false); + }); + }); +}); diff --git a/src/_internals/is-valid-pix-url/is-valid-pix-url.ts b/src/_internals/is-valid-pix-url/is-valid-pix-url.ts new file mode 100644 index 00000000..59448200 --- /dev/null +++ b/src/_internals/is-valid-pix-url/is-valid-pix-url.ts @@ -0,0 +1,15 @@ +const HOST_LABEL = "[a-z0-9](?:[a-z0-9-]*[a-z0-9])?"; + +const PIX_URL_REGEX = new RegExp( + `^${HOST_LABEL}(?:\\.${HOST_LABEL})+(?:/[a-z0-9._~%!$&'()*+,;=:@-]*)*$`, + "i", +); + +/** + * Checks whether a value is a Pix PSP location, the value of field 26-25 of a dynamic BR Code: + * a host name with at least one dot, optionally followed by a path, written without a scheme, + * whitespace or characters outside the URL unreserved and sub-delimiter sets. + * + * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/II_ManualdePadroesparaIniciacaodoPix.pdf + */ +export const isValidPixUrl = (value: string): boolean => PIX_URL_REGEX.test(value); diff --git a/src/_internals/parse-tlv/parse-tlv.test.ts b/src/_internals/parse-tlv/parse-tlv.test.ts new file mode 100644 index 00000000..f7ed40ed --- /dev/null +++ b/src/_internals/parse-tlv/parse-tlv.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "../test/runtime"; +import { parseTlv } from "./parse-tlv"; + +describe("parseTlv", () => { + describe("should return the objects", () => { + test("when the string holds a single object", () => { + expect(parseTlv("000201")).toEqual({ "00": "01" }); + }); + + test("when the string holds several objects", () => { + expect(parseTlv("00020153039865802BR")).toEqual({ + "00": "01", + "53": "986", + "58": "BR", + }); + }); + + test("when an object has an empty value", () => { + expect(parseTlv("0000")).toEqual({ "00": "" }); + }); + + test("when the string is empty", () => { + expect(parseTlv("")).toEqual({}); + }); + + test("when an id repeats, keeping the last one", () => { + expect(parseTlv("0001A0001B")).toEqual({ "00": "B" }); + }); + }); + + describe("should return null", () => { + test("when a value runs past the end of the string", () => { + expect(parseTlv("0003ab")).toBeNull(); + }); + + test("when an id is not made of two digits", () => { + expect(parseTlv("0A0201")).toBeNull(); + }); + + test("when a length is not made of two digits", () => { + expect(parseTlv("00A201")).toBeNull(); + }); + + test("when the string is too short to hold an object", () => { + expect(parseTlv("00")).toBeNull(); + }); + }); +}); diff --git a/src/_internals/parse-tlv/parse-tlv.ts b/src/_internals/parse-tlv/parse-tlv.ts new file mode 100644 index 00000000..9eca603c --- /dev/null +++ b/src/_internals/parse-tlv/parse-tlv.ts @@ -0,0 +1,47 @@ +export type TlvFields = Record; + +const SEGMENT_LENGTH = 2; + +const SEGMENT_REGEX = /^\d{2}$/; + +/** + * Parses an EMV® style TLV (tag-length-value) string into its objects. + * + * Every object is a 2 digit ID, a 2 digit length and a value of exactly that many characters, + * laid out back to back. Parsing stops with `null` as soon as the string stops being + * well-formed, i.e. when an ID or a length is not made of two digits or when a value runs past + * the end of the string. Repeated IDs are not expected at the root of a BR Code; when they do + * occur, the last one wins. + * + * @param {string} value - The TLV string to parse. + * @returns {TlvFields|null} The objects keyed by ID, or `null` when the string is malformed. + * + * @example + * ```typescript + * parseTlv("0002015303986"); // { "00": "01", "53": "986" } + * parseTlv("00020153039865802BR"); // { "00": "01", "53": "986", "58": "BR" } + * parseTlv("0003ab"); // null, the value is shorter than its declared length + * ``` + */ +export const parseTlv = (value: string): TlvFields | null => { + const fields: TlvFields = {}; + + let index = 0; + + while (index < value.length) { + const id = value.slice(index, index + SEGMENT_LENGTH); + const length = value.slice(index + SEGMENT_LENGTH, index + SEGMENT_LENGTH * 2); + + if (!SEGMENT_REGEX.test(id) || !SEGMENT_REGEX.test(length)) return null; + + const start = index + SEGMENT_LENGTH * 2; + const end = start + Number(length); + + if (end > value.length) return null; + + fields[id] = value.slice(start, end); + index = end; + } + + return fields; +}; diff --git a/src/_internals/sanitize-to-ascii/sanitize-to-ascii.test.ts b/src/_internals/sanitize-to-ascii/sanitize-to-ascii.test.ts new file mode 100644 index 00000000..c2ce1589 --- /dev/null +++ b/src/_internals/sanitize-to-ascii/sanitize-to-ascii.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "../test/runtime"; +import { sanitizeToAscii } from "./sanitize-to-ascii"; + +describe("sanitizeToAscii", () => { + test("should drop diacritics", () => { + expect(sanitizeToAscii("São Paulo")).toBe("Sao Paulo"); + expect(sanitizeToAscii("BRASÍLIA")).toBe("BRASILIA"); + expect(sanitizeToAscii("José Antônio Nuñez")).toBe("Jose Antonio Nunez"); + }); + + test("should drop characters outside printable ASCII", () => { + expect(sanitizeToAscii("Loja 💸 Feliz")).toBe("Loja Feliz"); + expect(sanitizeToAscii(`a${String.fromCharCode(0)}b`)).toBe("ab"); + }); + + test("should collapse whitespace and trim", () => { + expect(sanitizeToAscii(" Fulano de \n Tal ")).toBe("Fulano de Tal"); + }); + + test("should keep printable ASCII untouched", () => { + expect(sanitizeToAscii("Fulano de Tal")).toBe("Fulano de Tal"); + expect(sanitizeToAscii("ACME LTDA. #1")).toBe("ACME LTDA. #1"); + }); + + test("should return an empty string when nothing survives", () => { + expect(sanitizeToAscii(" ")).toBe(""); + expect(sanitizeToAscii("💸")).toBe(""); + }); +}); diff --git a/src/_internals/sanitize-to-ascii/sanitize-to-ascii.ts b/src/_internals/sanitize-to-ascii/sanitize-to-ascii.ts new file mode 100644 index 00000000..d819f71b --- /dev/null +++ b/src/_internals/sanitize-to-ascii/sanitize-to-ascii.ts @@ -0,0 +1,27 @@ +const COMBINING_MARKS_REGEX = /[\u0300-\u036f]/g; + +const NON_PRINTABLE_ASCII_REGEX = /[^\u0020-\u007e]/g; + +const WHITESPACE_REGEX = /\s+/g; + +/** + * Folds a string down to printable ASCII: accented letters lose their diacritics, anything + * still outside the printable ASCII range is dropped and runs of whitespace collapse into a + * single space. + * + * @param {string} value - The value to fold. + * @returns {string} The trimmed, printable ASCII form of the value. + * + * @example + * ```typescript + * sanitizeToAscii("São Paulo"); // "Sao Paulo" + * sanitizeToAscii(" Fulano de Tal "); // "Fulano de Tal" + * ``` + */ +export const sanitizeToAscii = (value: string): string => + value + .normalize("NFD") + .replace(COMBINING_MARKS_REGEX, "") + .replace(NON_PRINTABLE_ASCII_REGEX, "") + .replace(WHITESPACE_REGEX, " ") + .trim(); diff --git a/src/generate-pix-payload/constants.ts b/src/generate-pix-payload/constants.ts new file mode 100644 index 00000000..e26df6c9 --- /dev/null +++ b/src/generate-pix-payload/constants.ts @@ -0,0 +1,10 @@ +export const AMOUNT_DECIMAL_PLACES = 2; + +/** + * How many characters one TLV object spends besides its value: the 2 digit ID plus the 2 digit + * length. + */ +export const TLV_OVERHEAD = 4; + +/** The characters the Pix manual allows in a `txid`, capped at the 25 the BR Code holds. */ +export const TXID_REGEX = /^[A-Za-z0-9]{1,25}$/; diff --git a/src/generate-pix-payload/generate-pix-payload.test.ts b/src/generate-pix-payload/generate-pix-payload.test.ts new file mode 100644 index 00000000..c0a668e7 --- /dev/null +++ b/src/generate-pix-payload/generate-pix-payload.test.ts @@ -0,0 +1,369 @@ +import { crc16Ccitt } from "../_internals/crc16-ccitt/crc16-ccitt"; +import { describe, expect, test } from "../_internals/test/runtime"; +import { generateCnpj } from "../generate-cnpj/generate-cnpj"; +import { generateCpf } from "../generate-cpf/generate-cpf"; +import { isValidPixPayload } from "../is-valid-pix-payload/is-valid-pix-payload"; +import { parsePixPayload } from "../parse-pix-payload/parse-pix-payload"; +import { generatePixPayload } from "./generate-pix-payload"; + +const BASE = { + key: "123e4567-e12b-12d1-a456-426655440000", + merchantName: "Fulano de Tal", + merchantCity: "BRASILIA", +}; + +const EVP = "71c7d9be-4b85-4e43-9f1c-1f3b8b4e9a2d"; + +describe("generatePixPayload", () => { + describe("should return null", () => { + test("when it is null", () => { + // @ts-expect-error + expect(generatePixPayload(null)).toBeNull(); + }); + + test("when it is undefined", () => { + // @ts-expect-error + expect(generatePixPayload(undefined)).toBeNull(); + }); + + test("when it is not an object", () => { + // @ts-expect-error + expect(generatePixPayload("12345678909")).toBeNull(); + // @ts-expect-error + expect(generatePixPayload(123)).toBeNull(); + // @ts-expect-error + expect(generatePixPayload(true)).toBeNull(); + }); + + test("when the key is invalid", () => { + expect( + generatePixPayload({ + key: "11257245286", + merchantName: "Fulano", + merchantCity: "Brasilia", + }), + ).toBeNull(); + }); + + test("when neither key nor url is given", () => { + expect(generatePixPayload({ merchantName: "Fulano", merchantCity: "Brasilia" })).toBeNull(); + }); + + test("when both key and url are given", () => { + expect( + generatePixPayload({ + key: EVP, + url: "pix.example.com/qr/v2/1234", + merchantName: "Fulano", + merchantCity: "Brasilia", + }), + ).toBeNull(); + }); + + test("when url is not a string", () => { + expect( + // @ts-expect-error + generatePixPayload({ url: 123, merchantName: "Fulano", merchantCity: "Brasilia" }), + ).toBeNull(); + }); + + test("when url is an empty string", () => { + expect( + generatePixPayload({ url: "", merchantName: "Fulano", merchantCity: "Brasilia" }), + ).toBeNull(); + }); + + test("when url is longer than 77 characters", () => { + expect( + generatePixPayload({ + url: `pix.example.com/${"a".repeat(65)}`, + merchantName: "Fulano", + merchantCity: "Brasilia", + }), + ).toBeNull(); + }); + + test("when url is not a PSP location: scheme, whitespace, no dot in the host, or characters outside the URL sets", () => { + for (const url of [ + "https://pix.example.com/x", + "pix example.com/x", + "localhost/x", + "pix.example.com/", + ]) { + expect( + generatePixPayload({ url, merchantName: "Fulano", merchantCity: "Brasilia" }), + ).toBeNull(); + } + }); + + test("when the amount rounds to 0.00", () => { + expect( + generatePixPayload({ + key: "fulano@example.com", + merchantName: "Fulano", + merchantCity: "Brasilia", + amount: 0.001, + }), + ).toBeNull(); + }); + + test("when a dynamic payload (url) also carries an amount or a txid", () => { + expect( + generatePixPayload({ + url: "pix.example.com/qr/v2/1234", + merchantName: "Fulano", + merchantCity: "Brasilia", + amount: 10, + }), + ).toBeNull(); + expect( + generatePixPayload({ + url: "pix.example.com/qr/v2/1234", + merchantName: "Fulano", + merchantCity: "Brasilia", + txid: "ABC123", + }), + ).toBeNull(); + }); + + test("when the merchant name is missing or empty after folding", () => { + // @ts-expect-error + expect(generatePixPayload({ key: EVP, merchantCity: "Brasilia" })).toBeNull(); + expect( + generatePixPayload({ key: EVP, merchantName: " ", merchantCity: "Brasilia" }), + ).toBeNull(); + expect( + generatePixPayload({ key: EVP, merchantName: "💸", merchantCity: "Brasilia" }), + ).toBeNull(); + }); + + test("when the merchant city is missing or empty after folding", () => { + // @ts-expect-error + expect(generatePixPayload({ key: EVP, merchantName: "Fulano" })).toBeNull(); + expect( + generatePixPayload({ key: EVP, merchantName: "Fulano", merchantCity: " " }), + ).toBeNull(); + }); + + test("when the amount is not a positive finite number", () => { + expect(generatePixPayload({ ...BASE, amount: 0 })).toBeNull(); + expect(generatePixPayload({ ...BASE, amount: -1 })).toBeNull(); + expect(generatePixPayload({ ...BASE, amount: Number.NaN })).toBeNull(); + expect(generatePixPayload({ ...BASE, amount: Number.POSITIVE_INFINITY })).toBeNull(); + // @ts-expect-error + expect(generatePixPayload({ ...BASE, amount: "10" })).toBeNull(); + }); + + test("when the amount does not fit in 13 characters", () => { + expect(generatePixPayload({ ...BASE, amount: 12_345_678_901_2 })).toBeNull(); + }); + + test("when the txid is not alphanumeric or is too long", () => { + expect(generatePixPayload({ ...BASE, txid: "Um-Id-Qualquer" })).toBeNull(); + expect(generatePixPayload({ ...BASE, txid: "" })).toBeNull(); + expect(generatePixPayload({ ...BASE, txid: "a".repeat(26) })).toBeNull(); + // @ts-expect-error + expect(generatePixPayload({ ...BASE, txid: 123 })).toBeNull(); + }); + }); + + describe("should generate a valid payload", () => { + test("matching the static example of the Bacen manual", () => { + expect(generatePixPayload(BASE)).toBe( + "00020126580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-4266554400005204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***63041D3D", + ); + }); + + test("that isValidPixPayload accepts", () => { + expect(isValidPixPayload(generatePixPayload(BASE) ?? "")).toBe(true); + }); + + test("whose CRC covers the payload up to and including 6304", () => { + const payload = generatePixPayload(BASE) ?? ""; + + expect(payload.slice(-4)).toBe(crc16Ccitt(payload.slice(0, -4))); + }); + + test("with the amount formatted with two decimal places", () => { + expect(generatePixPayload({ ...BASE, amount: 10 })).toContain("540510.00"); + expect(generatePixPayload({ ...BASE, amount: 123.456 })).toContain("5406123.46"); + expect(generatePixPayload({ ...BASE, amount: 0.01 })).toContain("54040.01"); + }); + + test("with *** as the txid when it is omitted", () => { + expect(generatePixPayload(BASE)).toContain("62070503***"); + }); + + test("with the txid when it is given", () => { + expect(generatePixPayload({ ...BASE, txid: "RP123456782019" })).toContain( + "62180514RP123456782019", + ); + }); + }); + + describe("should generate a dynamic payload when url is given", () => { + const DYNAMIC_BASE = { + url: "pix.example.com/qr/v2/1234", + merchantName: "Fulano de Tal", + merchantCity: "Brasilia", + }; + + test("with the point of initiation method set to dynamic (12)", () => { + expect(generatePixPayload(DYNAMIC_BASE)).toContain("010212"); + }); + + test("with the url in the merchant account information as sub-object 25", () => { + expect(generatePixPayload(DYNAMIC_BASE)).toContain("2526pix.example.com/qr/v2/1234"); + }); + + test("that isValidPixPayload accepts", () => { + expect(isValidPixPayload(generatePixPayload(DYNAMIC_BASE) ?? "")).toBe(true); + }); + + test("that parsePixPayload parses back with pointOfInitiation dynamic and no key", () => { + expect(parsePixPayload(generatePixPayload(DYNAMIC_BASE) ?? "")).toEqual({ + url: "pix.example.com/qr/v2/1234", + merchantName: "Fulano de Tal", + merchantCity: "Brasilia", + pointOfInitiation: "dynamic", + }); + }); + + test("accepting a url of exactly 77 characters", () => { + const url = `pix.example.com/${"a".repeat(61)}`; + + expect(url.length).toBe(77); + + const payload = generatePixPayload({ ...DYNAMIC_BASE, url }); + + expect(payload).not.toBeNull(); + expect(parsePixPayload(payload ?? "")?.url).toBe(url); + }); + }); + + describe("should normalize its parameters", () => { + test("folding accents out of the merchant name and city", () => { + expect( + parsePixPayload(generatePixPayload({ ...BASE, merchantCity: "Brasília" }) ?? ""), + ).toMatchObject({ + merchantCity: "Brasilia", + }); + expect( + parsePixPayload(generatePixPayload({ ...BASE, merchantName: "José Antônio" }) ?? ""), + ).toMatchObject({ + merchantName: "Jose Antonio", + }); + }); + + test("truncating the merchant name to 25 characters", () => { + const pix = parsePixPayload( + generatePixPayload({ ...BASE, merchantName: "A".repeat(40) }) ?? "", + ); + + expect(pix?.merchantName).toBe("A".repeat(25)); + }); + + test("truncating the merchant city to 15 characters", () => { + const pix = parsePixPayload( + generatePixPayload({ ...BASE, merchantCity: "B".repeat(40) }) ?? "", + ); + + expect(pix?.merchantCity).toBe("B".repeat(15)); + }); + + test("normalizing the key to its DICT canonical form", () => { + expect( + parsePixPayload(generatePixPayload({ ...BASE, key: "123.456.789-09" }) ?? "")?.key, + ).toBe("12345678909"); + expect( + parsePixPayload(generatePixPayload({ ...BASE, key: "(11) 98765-4321" }) ?? "")?.key, + ).toBe("+5511987654321"); + expect( + parsePixPayload(generatePixPayload({ ...BASE, key: " Fulano@Example.COM " }) ?? "")?.key, + ).toBe("fulano@example.com"); + expect( + parsePixPayload(generatePixPayload({ ...BASE, key: EVP.toUpperCase() }) ?? "")?.key, + ).toBe(EVP); + }); + + test("truncating the description to what the 99 character template leaves", () => { + const payload = generatePixPayload({ + ...BASE, + key: "12345678909", + description: "y".repeat(90), + }); + + expect(parsePixPayload(payload ?? "")?.description).toBe("y".repeat(62)); + }); + + test("truncating the description to what a phone key leaves", () => { + const payload = generatePixPayload({ + ...BASE, + key: "1130000000", + description: "y".repeat(90), + }); + + expect(parsePixPayload(payload ?? "")?.description).toBe("y".repeat(60)); + }); + + test("leaving room for the description on a long key", () => { + const key = `${"a".repeat(56)}@example.com`; + const payload = generatePixPayload({ ...BASE, key, description: "z".repeat(30) }) ?? ""; + + expect(parsePixPayload(payload)?.description).toBe("z".repeat(5)); + }); + + test("dropping a description that does not fit at all", () => { + const key = `${"a".repeat(65)}@example.com`; + const payload = generatePixPayload({ ...BASE, key, description: "z".repeat(30) }) ?? ""; + + expect(parsePixPayload(payload)).not.toHaveProperty("description"); + }); + }); + + describe("should round-trip", () => { + test("through isValidPixPayload and parsePixPayload for randomized CPF keys", () => { + for (let index = 0; index < 200; index++) { + const params = { + key: generateCpf(), + merchantName: "Fulano de Tal", + merchantCity: "Brasilia", + amount: Number(((index + 1) / 100).toFixed(2)), + txid: `TX${index}`, + }; + const payload = generatePixPayload(params) ?? ""; + + expect(isValidPixPayload(payload)).toBe(true); + expect(parsePixPayload(payload)).toEqual(params); + } + }); + + test("through isValidPixPayload and parsePixPayload for randomized CNPJ keys", () => { + for (let index = 0; index < 200; index++) { + const params = { + key: generateCnpj(), + merchantName: "Loja Exemplo", + merchantCity: "Sao Paulo", + }; + const payload = generatePixPayload(params) ?? ""; + + expect(isValidPixPayload(payload)).toBe(true); + expect(parsePixPayload(payload)).toEqual(params); + } + }); + + test("through isValidPixPayload and parsePixPayload for randomized dynamic urls", () => { + for (let index = 0; index < 200; index++) { + const params = { + url: `pix.example.com/qr/v2/${index}`, + merchantName: "Fulano de Tal", + merchantCity: "Brasilia", + }; + const payload = generatePixPayload(params) ?? ""; + + expect(isValidPixPayload(payload)).toBe(true); + expect(parsePixPayload(payload)).toEqual({ ...params, pointOfInitiation: "dynamic" }); + } + }); + }); +}); diff --git a/src/generate-pix-payload/generate-pix-payload.ts b/src/generate-pix-payload/generate-pix-payload.ts new file mode 100644 index 00000000..b62e04d7 --- /dev/null +++ b/src/generate-pix-payload/generate-pix-payload.ts @@ -0,0 +1,210 @@ +import { + PIX_ABSENT_TXID, + PIX_ADDITIONAL_DATA_ID, + PIX_COUNTRY_CODE, + PIX_COUNTRY_CODE_ID, + PIX_CRC_TAG, + PIX_DESCRIPTION_ID, + PIX_DESCRIPTION_MAX_LENGTH, + PIX_DYNAMIC_POINT_OF_INITIATION, + PIX_GUI, + PIX_GUI_ID, + PIX_KEY_ID, + PIX_MERCHANT_ACCOUNT_INFORMATION_ID, + PIX_MERCHANT_ACCOUNT_INFORMATION_MAX_LENGTH, + PIX_MERCHANT_CATEGORY_CODE, + PIX_MERCHANT_CATEGORY_CODE_ID, + PIX_MERCHANT_CITY_ID, + PIX_MERCHANT_CITY_MAX_LENGTH, + PIX_MERCHANT_NAME_ID, + PIX_MERCHANT_NAME_MAX_LENGTH, + PIX_PAYLOAD_FORMAT_INDICATOR, + PIX_PAYLOAD_FORMAT_INDICATOR_ID, + PIX_POINT_OF_INITIATION_ID, + PIX_TRANSACTION_AMOUNT_ID, + PIX_TRANSACTION_AMOUNT_MAX_LENGTH, + PIX_TRANSACTION_CURRENCY, + PIX_TRANSACTION_CURRENCY_ID, + PIX_TXID_ID, + PIX_URL_ID, + PIX_URL_MAX_LENGTH, +} from "../_internals/constants/pix"; +import { crc16Ccitt } from "../_internals/crc16-ccitt/crc16-ccitt"; +import { formatTlv } from "../_internals/format-tlv/format-tlv"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { isValidPixUrl } from "../_internals/is-valid-pix-url/is-valid-pix-url"; +import { sanitizeToAscii } from "../_internals/sanitize-to-ascii/sanitize-to-ascii"; +import { parsePixKey } from "../parse-pix-key/parse-pix-key"; +import { AMOUNT_DECIMAL_PLACES, TLV_OVERHEAD, TXID_REGEX } from "./constants"; + +export type GeneratePixPayloadParams = { + /** The Pix key of the receiver, in any accepted form. Required unless `url` is given. */ + key?: string; + /** + * The PSP location of a dynamic payload (Bacen field 26-25), without a URL scheme, e.g. + * `"pix.example.com/qr/v2/1234"`. When given, the payload is generated as dynamic + * (`pointOfInitiation` `"12"`) and carries this URL instead of a key. Required unless `key` + * is given; giving both `key` and `url` is invalid, just like giving neither. + */ + url?: string; + /** Name of the receiver, folded to ASCII and truncated to 25 characters. */ + merchantName: string; + /** City of the receiver, folded to ASCII and truncated to 15 characters. */ + merchantCity: string; + /** Amount in BRL. Omit it to let the payer type it. Not allowed together with `url`: a dynamic BR Code takes its amount from the PSP location. */ + amount?: number; + /** Transaction ID, 1 to 25 characters of `[A-Za-z0-9]` (default: the absent marker `***`). Not allowed together with `url`. */ + txid?: string; + /** Free text shown to the payer, folded to ASCII and truncated to what the template holds. */ + description?: string; +}; + +const toAsciiField = (value: unknown, maxLength: number): string => + typeof value === "string" ? sanitizeToAscii(value).slice(0, maxLength).trim() : ""; + +/** + * Generates the payload of a Pix BR Code, the string behind a Pix QR Code and behind "Pix + * copia e cola". + * + * Exactly one of `params.key` or `params.url` must be given: `null` is returned when both are + * given and when neither is given, since only one of them can occupy the "Merchant Account + * Information" template at a time. + * + * When `params.key` is given, it is normalized to its DICT canonical form by `parsePixKey` and + * the payload is static: the "Point of Initiation Method" object is left out, so the payload + * may be paid more than once, as in the example of the Bacen manual. + * + * When `params.url` is given instead, the payload is dynamic per the Manual de Padrões para + * Iniciação do Pix: the URL takes the key's place in the "Merchant Account Information" + * template (sub-object `25` instead of `01`) and the "Point of Initiation Method" object (`01`) + * is set to `"12"`. `params.url` must be at most 77 characters, the length that keeps the + * template within its 99 character limit together with the `br.gov.bcb.pix` GUI. `parsePixPayload` + * already parses both shapes, so `parsePixPayload(generatePixPayload({ url, ... }))` round-trips. + * + * The merchant name, the merchant city and the description are folded to printable ASCII + * (accents are dropped) and truncated to the lengths the BR Code allows, the description to + * whatever is left of the 99 characters the "Merchant Account Information" template holds. + * + * @param {GeneratePixPayloadParams} params - The parameters of the payload. + * @param {string} [params.key] - The Pix key of the receiver. Required unless `url` is given. + * @param {string} [params.url] - The PSP location of a dynamic payload. Required unless `key` + * is given. + * @param {string} params.merchantName - The name of the receiver. + * @param {string} params.merchantCity - The city of the receiver. + * @param {number} [params.amount] - The amount in BRL. Omit it to let the payer type it. + * @param {string} [params.txid] - The transaction ID, 1 to 25 characters of `[A-Za-z0-9]`. + * @param {string} [params.description] - The free text shown to the payer. + * @returns {string|null} The BR Code payload, or `null` when the parameters are invalid. + * + * @example + * ```typescript + * generatePixPayload({ + * key: "123.456.789-09", + * merchantName: "Fulano de Tal", + * merchantCity: "Brasília", + * amount: 123.45, + * }); + * // "00020126330014br.gov.bcb.pix0111123456789095204000053039865406123.455802BR..." + * + * generatePixPayload({ + * url: "pix.example.com/qr/v2/1234", + * merchantName: "Fulano de Tal", + * merchantCity: "Brasília", + * }); + * // "00020101021226480014br.gov.bcb.pix2526pix.example.com/qr/v2/12345204000053039865802BR5913Fulano de Tal6008Brasilia62070503***6304FC66" + * + * generatePixPayload({ merchantName: "Fulano", merchantCity: "Brasília" }); // null (neither key nor url) + * generatePixPayload({ key: "123.456.789-09", url: "pix.example.com/qr/v2/1234", merchantName: "Fulano", merchantCity: "Brasília" }); // null (both key and url) + * ``` + * + * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/II_ManualdePadroesparaIniciacaodoPix.pdf + * @see Based on: https://github.com/bacen/pix-api Pix (SPI) OpenAPI spec. + * @see Based on: https://github.com/bacen/pix-dict-api DICT OpenAPI spec. + */ +export const generatePixPayload = (params: GeneratePixPayloadParams): string | null => { + if (isNullish(params) || typeof params !== "object") return null; + + const { key: keyInput, url: urlInput } = params; + + if ((keyInput !== undefined) === (urlInput !== undefined)) return null; + + let identifierId: string; + let identifierValue: string; + let pointOfInitiation: string | undefined; + + if (keyInput !== undefined) { + const key = parsePixKey(keyInput); + + if (!key) return null; + + identifierId = PIX_KEY_ID; + identifierValue = key.value; + } else { + const url = urlInput; + + if (typeof url !== "string" || url.length > PIX_URL_MAX_LENGTH || !isValidPixUrl(url)) + return null; + + identifierId = PIX_URL_ID; + identifierValue = url; + pointOfInitiation = PIX_DYNAMIC_POINT_OF_INITIATION; + } + + const merchantName = toAsciiField(params.merchantName, PIX_MERCHANT_NAME_MAX_LENGTH); + + if (!merchantName) return null; + + const merchantCity = toAsciiField(params.merchantCity, PIX_MERCHANT_CITY_MAX_LENGTH); + + if (!merchantCity) return null; + + const { amount, txid } = params; + + if (pointOfInitiation !== undefined && (amount !== undefined || txid !== undefined)) return null; + + if (amount !== undefined && (!Number.isFinite(amount) || amount <= 0)) return null; + + const formattedAmount = amount === undefined ? "" : amount.toFixed(AMOUNT_DECIMAL_PLACES); + + if (formattedAmount.length > PIX_TRANSACTION_AMOUNT_MAX_LENGTH) return null; + + if (amount !== undefined && Number(formattedAmount) === 0) return null; + + if (txid !== undefined && (typeof txid !== "string" || !TXID_REGEX.test(txid))) return null; + + const gui = formatTlv({ id: PIX_GUI_ID, value: PIX_GUI }); + const identifierObject = formatTlv({ id: identifierId, value: identifierValue }); + const descriptionRoom = Math.min( + PIX_DESCRIPTION_MAX_LENGTH, + PIX_MERCHANT_ACCOUNT_INFORMATION_MAX_LENGTH - + gui.length - + identifierObject.length - + TLV_OVERHEAD, + ); + const description = toAsciiField(params.description, Math.max(descriptionRoom, 0)); + + const merchantAccountInformation = + gui + + identifierObject + + (description ? formatTlv({ id: PIX_DESCRIPTION_ID, value: description }) : ""); + + const payload = + formatTlv({ id: PIX_PAYLOAD_FORMAT_INDICATOR_ID, value: PIX_PAYLOAD_FORMAT_INDICATOR }) + + (pointOfInitiation + ? formatTlv({ id: PIX_POINT_OF_INITIATION_ID, value: pointOfInitiation }) + : "") + + formatTlv({ id: PIX_MERCHANT_ACCOUNT_INFORMATION_ID, value: merchantAccountInformation }) + + formatTlv({ id: PIX_MERCHANT_CATEGORY_CODE_ID, value: PIX_MERCHANT_CATEGORY_CODE }) + + formatTlv({ id: PIX_TRANSACTION_CURRENCY_ID, value: PIX_TRANSACTION_CURRENCY }) + + (formattedAmount ? formatTlv({ id: PIX_TRANSACTION_AMOUNT_ID, value: formattedAmount }) : "") + + formatTlv({ id: PIX_COUNTRY_CODE_ID, value: PIX_COUNTRY_CODE }) + + formatTlv({ id: PIX_MERCHANT_NAME_ID, value: merchantName }) + + formatTlv({ id: PIX_MERCHANT_CITY_ID, value: merchantCity }) + + formatTlv({ + id: PIX_ADDITIONAL_DATA_ID, + value: formatTlv({ id: PIX_TXID_ID, value: txid ?? PIX_ABSENT_TXID }), + }) + + PIX_CRC_TAG; + + return payload + crc16Ccitt(payload); +}; diff --git a/src/is-valid-pix-key/is-valid-pix-key.test.ts b/src/is-valid-pix-key/is-valid-pix-key.test.ts new file mode 100644 index 00000000..8c1122bb --- /dev/null +++ b/src/is-valid-pix-key/is-valid-pix-key.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "../_internals/test/runtime"; +import { generateCnpj } from "../generate-cnpj/generate-cnpj"; +import { generateCpf } from "../generate-cpf/generate-cpf"; +import { isValidPixKey } from "./is-valid-pix-key"; + +describe("isValidPixKey", () => { + describe("should return false", () => { + test("when it is an empty or blank string", () => { + expect(isValidPixKey("")).toBe(false); + expect(isValidPixKey(" ")).toBe(false); + }); + + test("when it is null", () => { + // @ts-expect-error + expect(isValidPixKey(null)).toBe(false); + }); + + test("when it is undefined", () => { + // @ts-expect-error + expect(isValidPixKey(undefined)).toBe(false); + }); + + test("when it is a number", () => { + // @ts-expect-error + expect(isValidPixKey(12345678909)).toBe(false); + }); + + test("when it is a boolean, an object or an array", () => { + // @ts-expect-error + expect(isValidPixKey(true)).toBe(false); + // @ts-expect-error + expect(isValidPixKey({})).toBe(false); + // @ts-expect-error + expect(isValidPixKey([])).toBe(false); + }); + + test("when it is not a key of any accepted kind", () => { + expect(isValidPixKey("chave pix")).toBe(false); + expect(isValidPixKey("11257245286")).toBe(false); + expect(isValidPixKey("fulano@example")).toBe(false); + }); + }); + + describe("should return true", () => { + test("for a CPF", () => { + expect(isValidPixKey("123.456.789-09")).toBe(true); + expect(isValidPixKey("40364478829")).toBe(true); + }); + + test("for a CNPJ", () => { + expect(isValidPixKey("00.038.166/0001-05")).toBe(true); + expect(isValidPixKey("12ABC34501DE35")).toBe(true); + }); + + test("for an e-mail", () => { + expect(isValidPixKey("fulano_da_silva.recebedor@example.com")).toBe(true); + }); + + test("for a phone", () => { + expect(isValidPixKey("+5561912345678")).toBe(true); + expect(isValidPixKey("(11) 98765-4321")).toBe(true); + }); + + test("for a random key", () => { + expect(isValidPixKey("71c7d9be-4b85-4e43-9f1c-1f3b8b4e9a2d")).toBe(true); + expect(isValidPixKey("123e4567-e12b-12d1-a456-426655440000")).toBe(true); + }); + + test("for randomized documents", () => { + for (let index = 0; index < 200; index++) { + expect(isValidPixKey(generateCpf())).toBe(true); + expect(isValidPixKey(generateCnpj())).toBe(true); + } + }); + }); + + describe("should honour options.accept", () => { + test("accepting only the listed kinds", () => { + expect(isValidPixKey("123.456.789-09", { accept: ["cpf"] })).toBe(true); + expect(isValidPixKey("123.456.789-09", { accept: ["email", "evp"] })).toBe(false); + expect(isValidPixKey("fulano@example.com", { accept: ["email", "evp"] })).toBe(true); + expect(isValidPixKey("+5511987654321", { accept: ["phone"] })).toBe(true); + expect(isValidPixKey("00038166000105", { accept: ["cnpj"] })).toBe(true); + }); + + test("accepting nothing for an empty list", () => { + expect(isValidPixKey("123.456.789-09", { accept: [] })).toBe(false); + }); + + test("accepting every kind when the option is absent or not a list", () => { + expect(isValidPixKey("123.456.789-09", {})).toBe(true); + // @ts-expect-error + expect(isValidPixKey("123.456.789-09", { accept: "cpf" })).toBe(true); + // @ts-expect-error + expect(isValidPixKey("123.456.789-09", null)).toBe(true); + }); + }); +}); diff --git a/src/is-valid-pix-key/is-valid-pix-key.ts b/src/is-valid-pix-key/is-valid-pix-key.ts new file mode 100644 index 00000000..78cfef87 --- /dev/null +++ b/src/is-valid-pix-key/is-valid-pix-key.ts @@ -0,0 +1,43 @@ +import { type PixKeyType, parsePixKey } from "../parse-pix-key/parse-pix-key"; + +export type IsValidPixKeyOptions = { + /** Kinds of Pix key that count as valid (default: all of them). */ + accept?: PixKeyType[]; +}; + +/** + * Validates a Pix key (chave Pix) against the DICT key formats. + * + * A value is valid when `parsePixKey` recognizes it as a CPF, a CNPJ, an e-mail address, a + * Brazilian phone number or a random key (EVP), and when that kind is listed in + * `options.accept`. + * + * @param {string} value - The Pix key to validate. + * @param {IsValidPixKeyOptions} [options] - Optional validation options. + * @param {PixKeyType[]} [options.accept] - The kinds of key to accept. Defaults to all of them. + * @returns {boolean} True if the value is a valid Pix key, false otherwise. + * + * @example + * ```typescript + * isValidPixKey("123.456.789-09"); // true + * isValidPixKey("fulano@example.com"); // true + * isValidPixKey("(11) 98765-4321"); // true + * isValidPixKey("71c7d9be-4b85-4e43-9f1c-1f3b8b4e9a2d"); // true + * isValidPixKey("123.456.789-09", { accept: ["email", "evp"] }); // false + * isValidPixKey("not a key"); // false + * ``` + * + * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/II_ManualdePadroesparaIniciacaodoPix.pdf + * @see Based on: https://github.com/bacen/pix-dict-api DICT (Diretório de Identificadores de + * Contas Transacionais) OpenAPI spec, key format reference. + * @see Based on: https://github.com/bacen/pix-api Pix (SPI) OpenAPI spec. + */ +export const isValidPixKey = (value: string, options?: IsValidPixKeyOptions): boolean => { + const key = parsePixKey(value); + + if (!key) return false; + + const accept = options?.accept; + + return Array.isArray(accept) ? accept.includes(key.type) : true; +}; diff --git a/src/is-valid-pix-payload/is-valid-pix-payload.test.ts b/src/is-valid-pix-payload/is-valid-pix-payload.test.ts new file mode 100644 index 00000000..8bf7d273 --- /dev/null +++ b/src/is-valid-pix-payload/is-valid-pix-payload.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, test } from "../_internals/test/runtime"; +import { isValidPixPayload } from "./is-valid-pix-payload"; + +const BACEN_STATIC = + "00020126580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-4266554400005204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***63041D3D"; + +const BACEN_DYNAMIC = + "00020101021226700014br.gov.bcb.pix2548pix.example.com/8b3da2f39a4140d1a91abd93113bd4415204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***630464E4"; + +const BACEN_COMPOSITE = + "00020101021226700014br.gov.bcb.pix2548pix.example.com/8b3da2f39a4140d1a91abd93113bd4415204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***80740014br.gov.bcb.pix2552pix.example.com/rec/2353c790eefb11eaadc10242ac1200026304FB42"; + +const BRCODE_MANUAL = + "00020104141234567890123426580014BR.GOV.BCB.PIX0136123e4567-e12b-12d1-a456-42665544000027300012BR.COM.OUTRO011001234567895204000053039865406123.455802BR5917NOME DO RECEBEDOR6008BRASILIA61087007490062190515RP12345678-201980390012BR.COM.OUTRO01190123.ABCD.3456.WXYZ6304AD38"; + +const COMMUNITY_STATIC = + "00020126580014br.gov.bcb.pix0136bee05743-4291-4f3c-9259-595df1307ba1520400005303986540510.005802BR5914Alexandre Lima6019Presidente Prudente62180514Um-Id-Qualquer6304D475"; + +describe("isValidPixPayload", () => { + describe("should return true", () => { + test("for the static QR Code example in the Bacen 'Manual de Padrões para Iniciação do Pix'", () => { + expect(isValidPixPayload(BACEN_STATIC)).toBe(true); + }); + + test("for the dynamic QR Code example in the Bacen 'Manual de Padrões para Iniciação do Pix'", () => { + expect(isValidPixPayload(BACEN_DYNAMIC)).toBe(true); + }); + + test("for the composite QR Code example in the Bacen 'Manual de Padrões para Iniciação do Pix'", () => { + expect(isValidPixPayload(BACEN_COMPOSITE)).toBe(true); + }); + + test("for the multi-arrangement payload from the 'Manual do BR Code' §2.2", () => { + expect(isValidPixPayload(BRCODE_MANUAL)).toBe(true); + }); + + test("for a widely published community payload with an amount and a txid", () => { + expect(isValidPixPayload(COMMUNITY_STATIC)).toBe(true); + }); + + test("when the payload is surrounded by whitespace", () => { + expect(isValidPixPayload(` ${BACEN_STATIC}\n`)).toBe(true); + }); + + test("when the CRC is written in lowercase", () => { + expect(isValidPixPayload(BACEN_STATIC.replace(/1D3D$/, "1d3d"))).toBe(true); + }); + + test("when the additional data template is absent", () => { + expect( + isValidPixPayload( + "00020126580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-4266554400005204000053039865802BR5913Fulano de Tal6008BRASILIA6304740C", + ), + ).toBe(true); + }); + }); + + describe("should return false", () => { + test("when it is an empty or blank string", () => { + expect(isValidPixPayload("")).toBe(false); + expect(isValidPixPayload(" ")).toBe(false); + }); + + test("when it is null", () => { + // @ts-expect-error + expect(isValidPixPayload(null)).toBe(false); + }); + + test("when it is undefined", () => { + // @ts-expect-error + expect(isValidPixPayload(undefined)).toBe(false); + }); + + test("when it is a number", () => { + // @ts-expect-error + expect(isValidPixPayload(20250101)).toBe(false); + }); + + test("when it is a boolean, an object or an array", () => { + // @ts-expect-error + expect(isValidPixPayload(true)).toBe(false); + // @ts-expect-error + expect(isValidPixPayload({})).toBe(false); + // @ts-expect-error + expect(isValidPixPayload([])).toBe(false); + }); + + test("when the CRC does not match", () => { + expect(isValidPixPayload(BACEN_STATIC.replace(/1D3D$/, "1D3E"))).toBe(false); + }); + + test("when the CRC is not hexadecimal", () => { + expect(isValidPixPayload(BACEN_STATIC.replace(/1D3D$/, "ZZZZ"))).toBe(false); + }); + + test("when the payload does not end with the CRC object", () => { + expect(isValidPixPayload(BACEN_STATIC.slice(0, -8))).toBe(false); + }); + + test("when the TLV structure is malformed", () => { + expect(isValidPixPayload("00020126990014br.gov.bcb.pix6304BEFF")).toBe(false); + expect(isValidPixPayload("000X016304EAB2")).toBe(false); + }); + + test("when the payload format indicator is not 01", () => { + expect( + isValidPixPayload( + "00020226580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-4266554400005204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***6304BAA3", + ), + ).toBe(false); + }); + + test("when the point of initiation method is neither 11 nor 12", () => { + expect( + isValidPixPayload( + "00020101021326580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-4266554400005204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***63047DC6", + ), + ).toBe(false); + }); + + test("when the currency is not 986", () => { + expect( + isValidPixPayload( + "00020126580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-4266554400005204000053038405802BR5913Fulano de Tal6008BRASILIA62070503***63040C88", + ), + ).toBe(false); + }); + + test("when the country code is not BR", () => { + expect( + isValidPixPayload( + "00020126580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-4266554400005204000053039865802AR5913Fulano de Tal6008BRASILIA62070503***6304F417", + ), + ).toBe(false); + }); + + test("when the merchant category code is missing", () => { + expect( + isValidPixPayload( + "00020126580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-42665544000053039865802BR5913Fulano de Tal6008BRASILIA62070503***630405E3", + ), + ).toBe(false); + }); + + test("when the merchant name is missing", () => { + expect( + isValidPixPayload( + "00020126580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-4266554400005204000053039865802BR6008BRASILIA62070503***630452B8", + ), + ).toBe(false); + }); + + test("when the merchant city is missing", () => { + expect( + isValidPixPayload( + "00020126580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-4266554400005204000053039865802BR5913Fulano de Tal62070503***63047718", + ), + ).toBe(false); + }); + + test("when the GUI is not br.gov.bcb.pix", () => { + expect( + isValidPixPayload( + "00020126560012br.com.outro0136123e4567-e12b-12d1-a456-4266554400005204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***63045803", + ), + ).toBe(false); + }); + + test("when the merchant account information holds neither a key nor a URL", () => { + expect( + isValidPixPayload( + "00020126180014br.gov.bcb.pix5204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***6304A335", + ), + ).toBe(false); + }); + + test("when the amount is not a number", () => { + expect( + isValidPixPayload( + "00020126580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-42665544000052040000530398654061R3.455802BR5913Fulano de Tal6008BRASILIA62070503***63049FEF", + ), + ).toBe(false); + }); + + test("when it is a boleto or free text", () => { + expect(isValidPixPayload("10491443385511900000200000000141325230000093423")).toBe(false); + expect(isValidPixPayload("pix copia e cola")).toBe(false); + }); + }); +}); diff --git a/src/is-valid-pix-payload/is-valid-pix-payload.ts b/src/is-valid-pix-payload/is-valid-pix-payload.ts new file mode 100644 index 00000000..37b09cad --- /dev/null +++ b/src/is-valid-pix-payload/is-valid-pix-payload.ts @@ -0,0 +1,36 @@ +import { parsePixPayload } from "../parse-pix-payload/parse-pix-payload"; + +/** + * Validates a Pix BR Code payload, the string behind a Pix QR Code and behind "Pix copia e + * cola". + * + * The payload is valid when its TLV (tag-length-value) structure is well-formed, when the + * mandatory objects are present and well-formed (payload format indicator `01`, merchant + * category code, currency `986`, country `BR`, merchant name and merchant city), when one of + * the "Merchant Account Information" templates (IDs 26 to 51) carries the `br.gov.bcb.pix` GUI + * together with a key (static QR Code) or a URL (dynamic QR Code), and when the CRC-16 matches + * the rest of the payload. + * + * The key itself is not checked against the DICT formats: the manual states a static QR Code + * can be generated with a key that is not (or is no longer) registered, so use `isValidPixKey` + * when that matters. + * + * @param {string} value - The BR Code payload to validate. + * @returns {boolean} True if the payload is a valid Pix BR Code, false otherwise. + * + * @example + * ```typescript + * isValidPixPayload( + * "00020126580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-426655440000" + + * "5204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***63041D3D", + * ); // true + * + * isValidPixPayload("00020126580014br.gov.bcb.pix..."); // false (broken CRC) + * ``` + * + * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/spb_docs/ManualBRCode.pdf + * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/II_ManualdePadroesparaIniciacaodoPix.pdf + * @see Based on: https://github.com/bacen/pix-api Pix (SPI) OpenAPI spec. + * @see Based on: https://github.com/bacen/pix-dict-api DICT OpenAPI spec. + */ +export const isValidPixPayload = (value: string): boolean => parsePixPayload(value) !== null; diff --git a/src/parse-pix-key/constants.ts b/src/parse-pix-key/constants.ts new file mode 100644 index 00000000..c9d7c1a1 --- /dev/null +++ b/src/parse-pix-key/constants.ts @@ -0,0 +1,16 @@ +export const EMAIL_MAX_LENGTH = 77; + +/** + * A DICT random key (EVP) is a lowercase UUID written with its punctuation. The DICT issues + * version 4 UUIDs, but neither the registered pattern nor the example of the manual + * (`123e4567-e12b-12d1-a456-426655440000`, whose version nibble is `1`) constrains the + * version, so the version and variant nibbles are not enforced. + */ +export const EVP_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Marks of a value written as a phone number rather than as a document: an explicit + * international prefix (`+55` or `0055`) or a DDD wrapped in parentheses. A CPF mask uses only + * dots and a dash, so it never matches. + */ +export const PHONE_HINT_REGEX = /^(?:\+|00)\s*55|[()]/; diff --git a/src/parse-pix-key/parse-pix-key.test.ts b/src/parse-pix-key/parse-pix-key.test.ts new file mode 100644 index 00000000..4760f61a --- /dev/null +++ b/src/parse-pix-key/parse-pix-key.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, test } from "../_internals/test/runtime"; +import { generateCnpj } from "../generate-cnpj/generate-cnpj"; +import { generateCpf } from "../generate-cpf/generate-cpf"; +import { generatePhone } from "../generate-phone/generate-phone"; +import { parsePixKey } from "./parse-pix-key"; + +const AMBIGUOUS = "51998259765"; + +describe("parsePixKey", () => { + describe("should return null", () => { + test("when it is an empty or blank string", () => { + expect(parsePixKey("")).toBeNull(); + expect(parsePixKey(" ")).toBeNull(); + }); + + test("when it is null", () => { + // @ts-expect-error + expect(parsePixKey(null)).toBeNull(); + }); + + test("when it is undefined", () => { + // @ts-expect-error + expect(parsePixKey(undefined)).toBeNull(); + }); + + test("when it is a number", () => { + // @ts-expect-error + expect(parsePixKey(12345678909)).toBeNull(); + }); + + test("when it is a boolean, an object or an array", () => { + // @ts-expect-error + expect(parsePixKey(true)).toBeNull(); + // @ts-expect-error + expect(parsePixKey({})).toBeNull(); + // @ts-expect-error + expect(parsePixKey([])).toBeNull(); + }); + + test("when it is an invalid CPF", () => { + expect(parsePixKey("11257245286")).toBeNull(); + }); + + test("when it is an invalid CNPJ", () => { + expect(parsePixKey("11222333000182")).toBeNull(); + }); + + test("when it is an invalid e-mail", () => { + expect(parsePixKey("fulano@")).toBeNull(); + expect(parsePixKey("@example.com")).toBeNull(); + expect(parsePixKey("fulano@example")).toBeNull(); + }); + + test("when the e-mail is longer than 77 characters", () => { + expect(parsePixKey(`${"a".repeat(66)}@example.com`)).toBeNull(); + }); + + test("when the random key is not a UUID", () => { + expect(parsePixKey("71c7d9be4b854e439f1c1f3b8b4e9a2d")).toBeNull(); + expect(parsePixKey("71c7d9be-4b85-4e43-9f1c-1f3b8b4e9a2")).toBeNull(); + expect(parsePixKey("71c7d9be-4b85-4e43-9f1c-1f3b8b4e9azz")).toBeNull(); + }); + + test("when the phone has an invalid area code", () => { + expect(parsePixKey("(00) 98765-4321")).toBeNull(); + }); + + test("when it is free text", () => { + expect(parsePixKey("chave pix")).toBeNull(); + expect(parsePixKey("---")).toBeNull(); + }); + }); + + describe("should return a CPF", () => { + test("when it is masked", () => { + expect(parsePixKey("123.456.789-09")).toEqual({ type: "cpf", value: "12345678909" }); + }); + + test("when it is unmasked", () => { + expect(parsePixKey("40364478829")).toEqual({ type: "cpf", value: "40364478829" }); + }); + + test("when surrounded by whitespace", () => { + expect(parsePixKey(" 40364478829 ")).toEqual({ type: "cpf", value: "40364478829" }); + }); + }); + + describe("should return a CNPJ", () => { + test("when it is masked", () => { + expect(parsePixKey("00.038.166/0001-05")).toEqual({ + type: "cnpj", + value: "00038166000105", + }); + }); + + test("when it is unmasked", () => { + expect(parsePixKey("00038166000105")).toEqual({ + type: "cnpj", + value: "00038166000105", + }); + }); + + test("when it is the alphanumeric format of the manual", () => { + expect(parsePixKey("12ABC34501DE35")).toEqual({ type: "cnpj", value: "12ABC34501DE35" }); + expect(parsePixKey("12.abc.345/01de-35")).toEqual({ + type: "cnpj", + value: "12ABC34501DE35", + }); + }); + }); + + describe("should resolve the CNPJ and phone ambiguity", () => { + test("should read a valid CNPJ as a CNPJ even when it starts with 0055", () => { + expect(parsePixKey("00551760871813")).toEqual({ type: "cnpj", value: "00551760871813" }); + expect(parsePixKey("00.551.760/8718-13")).toEqual({ type: "cnpj", value: "00551760871813" }); + }); + + test("should still read a 0055 prefixed value that is not a valid CNPJ as a phone", () => { + expect(parsePixKey("00551133334444")).toEqual({ type: "phone", value: "+551133334444" }); + }); + }); + + describe("should return an e-mail", () => { + test("when it is the example of the manual", () => { + expect(parsePixKey("fulano_da_silva.recebedor@example.com")).toEqual({ + type: "email", + value: "fulano_da_silva.recebedor@example.com", + }); + }); + + test("when it is uppercased or padded", () => { + expect(parsePixKey(" Fulano@Example.COM ")).toEqual({ + type: "email", + value: "fulano@example.com", + }); + }); + + test("when it is exactly 77 characters long", () => { + const email = `${"a".repeat(65)}@example.com`; + + expect(email).toHaveLength(77); + expect(parsePixKey(email)).toEqual({ type: "email", value: email }); + }); + }); + + describe("should return a phone", () => { + test("when it is the example of the manual", () => { + expect(parsePixKey("+5561912345678")).toEqual({ + type: "phone", + value: "+5561912345678", + }); + }); + + test("when it is masked", () => { + expect(parsePixKey("(11) 98765-4321")).toEqual({ + type: "phone", + value: "+5511987654321", + }); + }); + + test("when it is bare", () => { + expect(parsePixKey("11987654321")).toEqual({ type: "phone", value: "+5511987654321" }); + }); + + test("when it carries the country code in every accepted form", () => { + expect(parsePixKey("+55 11 98765-4321")).toEqual({ + type: "phone", + value: "+5511987654321", + }); + expect(parsePixKey("005511987654321")).toEqual({ + type: "phone", + value: "+5511987654321", + }); + expect(parsePixKey("5511987654321")).toEqual({ + type: "phone", + value: "+5511987654321", + }); + }); + + test("when it is a landline", () => { + expect(parsePixKey("(11) 3000-0000")).toEqual({ type: "phone", value: "+551130000000" }); + }); + + test("and never exceed the 14 characters of the E.164 form", () => { + for (let index = 0; index < 200; index++) { + const key = parsePixKey(`+55${generatePhone()}`); + + expect(key?.type).toBe("phone"); + expect(key?.value.length).toBeLessThanOrEqual(14); + } + }); + }); + + describe("should return a random key", () => { + test("when it is a lowercase UUID version 4", () => { + expect(parsePixKey("71c7d9be-4b85-4e43-9f1c-1f3b8b4e9a2d")).toEqual({ + type: "evp", + value: "71c7d9be-4b85-4e43-9f1c-1f3b8b4e9a2d", + }); + }); + + test("when it is uppercased, lowercasing it", () => { + expect(parsePixKey("71C7D9BE-4B85-4E43-9F1C-1F3B8B4E9A2D")).toEqual({ + type: "evp", + value: "71c7d9be-4b85-4e43-9f1c-1f3b8b4e9a2d", + }); + }); + + test("when it is the example of the manual, whose version nibble is not 4", () => { + expect(parsePixKey("123e4567-e12b-12d1-a456-426655440000")).toEqual({ + type: "evp", + value: "123e4567-e12b-12d1-a456-426655440000", + }); + }); + }); + + describe("should resolve the CPF and phone ambiguity", () => { + test("preferring the CPF when the value is valid as both", () => { + expect(parsePixKey(AMBIGUOUS)).toEqual({ type: "cpf", value: AMBIGUOUS }); + }); + + test("preferring the phone when it starts with the country code", () => { + expect(parsePixKey(`+55${AMBIGUOUS}`)).toEqual({ + type: "phone", + value: `+55${AMBIGUOUS}`, + }); + expect(parsePixKey(`0055${AMBIGUOUS}`)).toEqual({ + type: "phone", + value: `+55${AMBIGUOUS}`, + }); + }); + + test("preferring the phone when the DDD is written between parentheses", () => { + expect(parsePixKey("(51) 99825-9765")).toEqual({ + type: "phone", + value: `+55${AMBIGUOUS}`, + }); + }); + + test("keeping the CPF when it is written with its own mask", () => { + expect(parsePixKey("519.982.597-65")).toEqual({ type: "cpf", value: AMBIGUOUS }); + }); + }); + + describe("should normalize randomized keys", () => { + test("for CPFs", () => { + for (let index = 0; index < 200; index++) { + const cpf = generateCpf(); + + expect(parsePixKey(cpf)?.value).toBe(cpf); + } + }); + + test("for CNPJs", () => { + for (let index = 0; index < 200; index++) { + const cnpj = generateCnpj(); + + expect(parsePixKey(cnpj)).toEqual({ type: "cnpj", value: cnpj }); + } + }); + }); +}); diff --git a/src/parse-pix-key/parse-pix-key.ts b/src/parse-pix-key/parse-pix-key.ts new file mode 100644 index 00000000..1047bce3 --- /dev/null +++ b/src/parse-pix-key/parse-pix-key.ts @@ -0,0 +1,91 @@ +import { CPF_LENGTH } from "../_internals/constants/cpf"; +import { PHONE_COUNTRY_CODE } from "../_internals/constants/phone"; +import { normalizePhone } from "../_internals/normalize-phone/normalize-phone"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { isValidCnpj } from "../is-valid-cnpj/is-valid-cnpj"; +import { isValidCpf } from "../is-valid-cpf/is-valid-cpf"; +import { isValidEmail } from "../is-valid-email/is-valid-email"; +import { isValidPhone } from "../is-valid-phone/is-valid-phone"; +import { parseCnpj } from "../parse-cnpj/parse-cnpj"; +import { EMAIL_MAX_LENGTH, EVP_REGEX, PHONE_HINT_REGEX } from "./constants"; + +export type PixKeyType = "cpf" | "cnpj" | "email" | "phone" | "evp"; + +export type PixKey = { + /** Which kind of Pix key the value was recognized as. */ + type: PixKeyType; + /** The key in the canonical DICT form for its kind. */ + value: string; +}; + +/** + * Identifies a Pix key and normalizes it to the canonical form the DICT expects inside a BR + * Code. + * + * The canonical forms are the ones listed in "Formatação das chaves do DICT no BR Code": + * - `cpf`: 11 digits, no mask; + * - `cnpj`: 14 characters, no mask, uppercase for the alphanumeric format; + * - `email`: trimmed and lowercased, at most 77 characters; + * - `phone`: E.164, `+55` followed by the DDD and the subscriber number, so at most 14 + * characters. Masked, bare and `+55` prefixed inputs are all accepted; + * - `evp`: the random key, a lowercase UUID version 4. + * + * A value with a valid CNPJ check digit is read as a CNPJ, even when it starts with `0055` + * (a phone key inside a BR Code always carries the `+55` prefix). An 11 digit value can be + * read both as a CPF and as a mobile phone number: when it is valid as both, it is read as a + * CPF, unless it was written as a phone number, i.e. unless it starts with `+55`/`0055` or + * wraps its DDD in parentheses. + * + * @param {string} value - The Pix key to be parsed. + * @returns {PixKey|null} The normalized key, or `null` when the value is not a valid Pix key. + * + * @example + * ```typescript + * parsePixKey("123.456.789-09"); // { type: "cpf", value: "12345678909" } + * parsePixKey("Fulano@Example.COM "); // { type: "email", value: "fulano@example.com" } + * parsePixKey("(11) 98765-4321"); // { type: "phone", value: "+5511987654321" } + * parsePixKey("71C7D9BE-4B85-4E43-9F1C-1F3B8B4E9A2D"); + * // { type: "evp", value: "71c7d9be-4b85-4e43-9f1c-1f3b8b4e9a2d" } + * parsePixKey("51998259765"); // { type: "cpf", value: "51998259765" } (also a valid phone) + * parsePixKey("+5551998259765"); // { type: "phone", value: "+5551998259765" } + * ``` + * + * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/II_ManualdePadroesparaIniciacaodoPix.pdf + * @see Based on: https://github.com/bacen/pix-dict-api DICT (Diretório de Identificadores de + * Contas Transacionais) OpenAPI spec, key format reference. + * @see Based on: https://github.com/bacen/pix-api Pix (SPI) OpenAPI spec. + */ +export const parsePixKey = (value: string): PixKey | null => { + if (typeof value !== "string") return null; + + const trimmed = value.trim(); + + if (!trimmed) return null; + + if (EVP_REGEX.test(trimmed)) return { type: "evp", value: trimmed.toLowerCase() }; + + if (trimmed.includes("@")) { + const email = trimmed.toLowerCase(); + + return isValidEmail(email) && email.length <= EMAIL_MAX_LENGTH + ? { type: "email", value: email } + : null; + } + + const national = normalizePhone(trimmed); + const phone: PixKey | null = isValidPhone(national) + ? { type: "phone", value: `+${PHONE_COUNTRY_CODE}${national}` } + : null; + + if (isValidCnpj(trimmed, { version: 2 })) { + return { type: "cnpj", value: parseCnpj(trimmed, { version: 2 }) }; + } + + if (phone && PHONE_HINT_REGEX.test(trimmed)) return phone; + + const digits = sanitizeToDigits(trimmed); + + if (digits.length === CPF_LENGTH && isValidCpf(digits)) return { type: "cpf", value: digits }; + + return phone; +}; diff --git a/src/parse-pix-payload/parse-pix-payload.test.ts b/src/parse-pix-payload/parse-pix-payload.test.ts new file mode 100644 index 00000000..5b78d8b5 --- /dev/null +++ b/src/parse-pix-payload/parse-pix-payload.test.ts @@ -0,0 +1,242 @@ +import { crc16Ccitt } from "../_internals/crc16-ccitt/crc16-ccitt"; +import { describe, expect, test } from "../_internals/test/runtime"; +import { generateCpf } from "../generate-cpf/generate-cpf"; +import { generatePixPayload } from "../generate-pix-payload/generate-pix-payload"; +import { parsePixPayload } from "./parse-pix-payload"; + +const BACEN_STATIC = + "00020126580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-4266554400005204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***63041D3D"; + +const BACEN_DYNAMIC = + "00020101021226700014br.gov.bcb.pix2548pix.example.com/8b3da2f39a4140d1a91abd93113bd4415204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***630464E4"; + +const BACEN_COMPOSITE = + "00020101021226700014br.gov.bcb.pix2548pix.example.com/8b3da2f39a4140d1a91abd93113bd4415204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***80740014br.gov.bcb.pix2552pix.example.com/rec/2353c790eefb11eaadc10242ac1200026304FB42"; + +const BRCODE_MANUAL = + "00020104141234567890123426580014BR.GOV.BCB.PIX0136123e4567-e12b-12d1-a456-42665544000027300012BR.COM.OUTRO011001234567895204000053039865406123.455802BR5917NOME DO RECEBEDOR6008BRASILIA61087007490062190515RP12345678-201980390012BR.COM.OUTRO01190123.ABCD.3456.WXYZ6304AD38"; + +const COMMUNITY_STATIC = + "00020126580014br.gov.bcb.pix0136bee05743-4291-4f3c-9259-595df1307ba1520400005303986540510.005802BR5914Alexandre Lima6019Presidente Prudente62180514Um-Id-Qualquer6304D475"; + +const STATIC_POINT_OF_INITIATION = + "00020101021126580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-4266554400005204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***630448CD"; + +const tlv = (id: string, value: string): string => + `${id}${value.length.toString().padStart(2, "0")}${value}`; + +const buildPayload = (merchantAccountInformation: string, additionalData?: string): string => { + const withoutCrc = + tlv("00", "01") + + tlv("26", merchantAccountInformation) + + tlv("52", "0000") + + tlv("53", "986") + + tlv("58", "BR") + + tlv("59", "Fulano de Tal") + + tlv("60", "BRASILIA") + + (additionalData !== undefined ? tlv("62", additionalData) : "") + + "6304"; + + return withoutCrc + crc16Ccitt(withoutCrc); +}; + +describe("parsePixPayload", () => { + describe("should return null", () => { + test("when it is an empty or blank string", () => { + expect(parsePixPayload("")).toBeNull(); + expect(parsePixPayload(" ")).toBeNull(); + }); + + test("when it is null", () => { + // @ts-expect-error + expect(parsePixPayload(null)).toBeNull(); + }); + + test("when it is undefined", () => { + // @ts-expect-error + expect(parsePixPayload(undefined)).toBeNull(); + }); + + test("when it is a number", () => { + // @ts-expect-error + expect(parsePixPayload(20250101)).toBeNull(); + }); + + test("when it is a boolean, an object or an array", () => { + // @ts-expect-error + expect(parsePixPayload(true)).toBeNull(); + // @ts-expect-error + expect(parsePixPayload({})).toBeNull(); + // @ts-expect-error + expect(parsePixPayload([])).toBeNull(); + }); + + test("when the CRC does not match", () => { + expect(parsePixPayload(BACEN_STATIC.replace(/1D3D$/, "1D3E"))).toBeNull(); + }); + + test("when it is free text", () => { + expect(parsePixPayload("pix copia e cola")).toBeNull(); + }); + + test("when the key object is present but empty", () => { + const merchantAccountInformation = tlv("00", "br.gov.bcb.pix") + tlv("01", ""); + + expect(parsePixPayload(buildPayload(merchantAccountInformation))).toBeNull(); + }); + + test("when the url object is present but empty", () => { + const merchantAccountInformation = tlv("00", "br.gov.bcb.pix") + tlv("25", ""); + + expect(parsePixPayload(buildPayload(merchantAccountInformation))).toBeNull(); + }); + + test("when the merchant account information carries both a key and a url", () => { + expect( + parsePixPayload( + "00020101021226500014br.gov.bcb.pix0107a@b.com2517pix.example.com/x5204000053039865802BR5901A6001B62070503***63049A4B", + ), + ).toBeNull(); + }); + + test("when the url is not a PSP location (scheme, whitespace, host without a dot)", () => { + expect( + parsePixPayload( + "00020101021226470014br.gov.bcb.pix2525https://pix.example.com/x5204000053039865802BR5901A6001B62070503***6304F843", + ), + ).toBeNull(); + expect( + parsePixPayload( + "00020101021226390014br.gov.bcb.pix2517pix example.com/x5204000053039865802BR5901A6001B62070503***6304C8E4", + ), + ).toBeNull(); + expect( + parsePixPayload( + "00020101021226330014br.gov.bcb.pix2511localhost/x5204000053039865802BR5901A6001B62070503***630494D9", + ), + ).toBeNull(); + }); + + test("when the additional data template is malformed", () => { + const merchantAccountInformation = tlv("00", "br.gov.bcb.pix") + tlv("01", "some-key"); + + expect(parsePixPayload(buildPayload(merchantAccountInformation, "9"))).toBeNull(); + }); + }); + + describe("should parse a static payload", () => { + test("should ignore the transaction amount and the txid of a dynamic payload, which belong to the PSP location", () => { + expect( + parsePixPayload( + "00020101021226480014br.gov.bcb.pix2526pix.example.com/qr/v2/123452040000530398654041.005802BR5901A6001B62100506ABC1236304C7F9", + ), + ).toEqual({ + url: "pix.example.com/qr/v2/1234", + merchantName: "A", + merchantCity: "B", + pointOfInitiation: "dynamic", + }); + }); + + test("from the static QR Code example in the Bacen 'Manual de Padrões para Iniciação do Pix'", () => { + expect(parsePixPayload(BACEN_STATIC)).toEqual({ + key: "123e4567-e12b-12d1-a456-426655440000", + merchantName: "Fulano de Tal", + merchantCity: "BRASILIA", + }); + }); + + test("dropping the *** placeholder of an absent txid", () => { + expect(parsePixPayload(BACEN_STATIC)).not.toHaveProperty("txid"); + }); + + test("with an amount and a txid, as in a widely published community example", () => { + expect(parsePixPayload(COMMUNITY_STATIC)).toEqual({ + key: "bee05743-4291-4f3c-9259-595df1307ba1", + merchantName: "Alexandre Lima", + merchantCity: "Presidente Prudente", + amount: 10, + txid: "Um-Id-Qualquer", + }); + }); + + test("picking the Pix arrangement out of the multi-arrangement payload from the 'Manual do BR Code' §2.2", () => { + expect(parsePixPayload(BRCODE_MANUAL)).toEqual({ + key: "123e4567-e12b-12d1-a456-426655440000", + merchantName: "NOME DO RECEBEDOR", + merchantCity: "BRASILIA", + amount: 123.45, + txid: "RP12345678-2019", + }); + }); + + test("with a description", () => { + const payload = generatePixPayload({ + key: "12345678909", + merchantName: "Fulano de Tal", + merchantCity: "Brasilia", + description: "Pedido 42", + }); + + expect(parsePixPayload(payload ?? "")).toEqual({ + key: "12345678909", + description: "Pedido 42", + merchantName: "Fulano de Tal", + merchantCity: "Brasilia", + }); + }); + }); + + describe("should parse a dynamic payload", () => { + test("from the dynamic QR Code example in the Bacen 'Manual de Padrões para Iniciação do Pix'", () => { + expect(parsePixPayload(BACEN_DYNAMIC)).toEqual({ + url: "pix.example.com/8b3da2f39a4140d1a91abd93113bd441", + merchantName: "Fulano de Tal", + merchantCity: "BRASILIA", + pointOfInitiation: "dynamic", + }); + }); + + test("picking the Pix arrangement out of the composite QR Code example in the Bacen manual", () => { + expect(parsePixPayload(BACEN_COMPOSITE)).toEqual({ + url: "pix.example.com/8b3da2f39a4140d1a91abd93113bd441", + merchantName: "Fulano de Tal", + merchantCity: "BRASILIA", + pointOfInitiation: "dynamic", + }); + }); + + test("reading the point of initiation method 11 as static, per the Bacen static example with it made explicit", () => { + expect(parsePixPayload(STATIC_POINT_OF_INITIATION)?.pointOfInitiation).toBe("static"); + }); + }); + + describe("should round-trip with generatePixPayload", () => { + test("for a payload with every field", () => { + const pix = { + key: "12345678909", + description: "Pedido 42", + merchantName: "Fulano de Tal", + merchantCity: "Brasilia", + amount: 123.45, + txid: "RP123456782019", + }; + + expect(parsePixPayload(generatePixPayload(pix) ?? "")).toEqual(pix); + }); + + test("for randomized CPF keys", () => { + for (let index = 0; index < 200; index++) { + const pix = { + key: generateCpf(), + merchantName: "Fulano de Tal", + merchantCity: "Brasilia", + amount: Number(((index + 1) / 100).toFixed(2)), + txid: `TX${index}`, + }; + + expect(parsePixPayload(generatePixPayload(pix) ?? "")).toEqual(pix); + } + }); + }); +}); diff --git a/src/parse-pix-payload/parse-pix-payload.ts b/src/parse-pix-payload/parse-pix-payload.ts new file mode 100644 index 00000000..6601c4eb --- /dev/null +++ b/src/parse-pix-payload/parse-pix-payload.ts @@ -0,0 +1,214 @@ +import { + PIX_ABSENT_TXID, + PIX_ADDITIONAL_DATA_ID, + PIX_COUNTRY_CODE, + PIX_COUNTRY_CODE_ID, + PIX_CRC_LENGTH, + PIX_CRC_TAG, + PIX_DESCRIPTION_ID, + PIX_DYNAMIC_POINT_OF_INITIATION, + PIX_GUI, + PIX_GUI_ID, + PIX_KEY_ID, + PIX_MERCHANT_ACCOUNT_INFORMATION_FIRST_ID, + PIX_MERCHANT_ACCOUNT_INFORMATION_LAST_ID, + PIX_MERCHANT_CATEGORY_CODE_ID, + PIX_MERCHANT_CITY_ID, + PIX_MERCHANT_NAME_ID, + PIX_PAYLOAD_FORMAT_INDICATOR, + PIX_PAYLOAD_FORMAT_INDICATOR_ID, + PIX_POINT_OF_INITIATION_ID, + PIX_STATIC_POINT_OF_INITIATION, + PIX_TRANSACTION_AMOUNT_ID, + PIX_TRANSACTION_AMOUNT_MAX_LENGTH, + PIX_TRANSACTION_CURRENCY, + PIX_TRANSACTION_CURRENCY_ID, + PIX_TXID_ID, + PIX_URL_ID, +} from "../_internals/constants/pix"; +import { crc16Ccitt } from "../_internals/crc16-ccitt/crc16-ccitt"; +import { isValidPixUrl } from "../_internals/is-valid-pix-url/is-valid-pix-url"; +import { type TlvFields, parseTlv } from "../_internals/parse-tlv/parse-tlv"; + +export type PixPointOfInitiation = "static" | "dynamic"; + +export type PixPayload = { + /** The Pix key of the receiver, present in a static payload. */ + key?: string; + /** URL of the dynamic payload, present instead of `key` in a dynamic one. */ + url?: string; + /** Free text the receiver wrote for the payer. */ + description?: string; + /** Name of the receiver, at most 25 ASCII characters. */ + merchantName: string; + /** City of the receiver, at most 15 ASCII characters. */ + merchantCity: string; + /** Amount in BRL, absent when the payer types it. */ + amount?: number; + /** Transaction ID, absent when the payload carries the `***` marker. */ + txid?: string; + /** Whether the payload may be paid once ("dynamic") or many times ("static"). */ + pointOfInitiation?: PixPointOfInitiation; +}; + +const CRC_VALUE_REGEX = /^[0-9a-f]{4}$/i; + +const AMOUNT_REGEX = /^\d+(?:\.\d{1,2})?$/; + +const CRC_TAG_LENGTH = PIX_CRC_TAG.length + PIX_CRC_LENGTH; + +const findMerchantAccountInformation = (fields: TlvFields): TlvFields | null => { + for ( + let id = PIX_MERCHANT_ACCOUNT_INFORMATION_FIRST_ID; + id <= PIX_MERCHANT_ACCOUNT_INFORMATION_LAST_ID; + id++ + ) { + const template = fields[id.toString()]; + + if (template === undefined) continue; + + const objects = parseTlv(template); + + if (objects?.[PIX_GUI_ID]?.toLowerCase() === PIX_GUI) return objects; + } + + return null; +}; + +const isValidCrc = (payload: string): boolean => { + const checksum = payload.slice(-PIX_CRC_LENGTH); + + if (payload.slice(-CRC_TAG_LENGTH, -PIX_CRC_LENGTH) !== PIX_CRC_TAG) return false; + if (!CRC_VALUE_REGEX.test(checksum)) return false; + + return crc16Ccitt(payload.slice(0, -PIX_CRC_LENGTH)) === checksum.toUpperCase(); +}; + +/** + * Parses a Pix BR Code payload, the string behind a Pix QR Code and behind "Pix copia e cola". + * + * The payload is rejected when its TLV (tag-length-value) structure is malformed, when the CRC + * does not match, when a mandatory object is missing or malformed, or when none of the + * "Merchant Account Information" templates (IDs 26 to 51) carries the `br.gov.bcb.pix` GUI + * together with either a key (static) or a URL (dynamic). + * + * The Pix key itself is not validated: the manual states a static QR Code can be generated + * with a key that no longer exists in the DICT, so key ownership is only settled at payment + * time. The "Additional Data Field Template" (ID 62) is mandatory in the BR Code table but + * optional in the EMV® specification it refers to, so it is accepted when absent. The lengths + * the manual reserves for the merchant name (25), the merchant city (15) and the `txid` (25) + * are generator side limits, enforced by `generatePixPayload`; payloads in the wild routinely + * overrun them, so they are not enforced here. + * + * The merchant account information must carry exactly one of a Pix key (26-01) or a PSP + * location (26-25); the location is checked with the same host and path rule + * `generatePixPayload` applies. In a dynamic payload the transaction amount (54) and the + * `txid` (62-05) are ignored, as the manual mandates, because the PSP location is the source + * of truth for both. + * + * @param {string} value - The BR Code payload to be parsed. + * @returns {PixPayload|null} The Pix data of the payload, or `null` when it is not a valid Pix + * BR Code. + * + * @example + * ```typescript + * parsePixPayload( + * "00020126580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-426655440000" + + * "5204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***63041D3D", + * ); + * // { + * // key: "123e4567-e12b-12d1-a456-426655440000", + * // merchantName: "Fulano de Tal", + * // merchantCity: "BRASILIA", + * // } + * ``` + * + * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/II_ManualdePadroesparaIniciacaodoPix.pdf + * @see Based on: https://github.com/bacen/pix-api Pix (SPI) OpenAPI spec. + * @see Based on: https://github.com/bacen/pix-dict-api DICT OpenAPI spec. + */ +export const parsePixPayload = (value: string): PixPayload | null => { + if (typeof value !== "string") return null; + + const payload = value.trim(); + + if (payload.length <= CRC_TAG_LENGTH || !isValidCrc(payload)) return null; + + const fields = parseTlv(payload); + + if (!fields) return null; + + if (fields[PIX_PAYLOAD_FORMAT_INDICATOR_ID] !== PIX_PAYLOAD_FORMAT_INDICATOR) return null; + + const pointOfInitiation = fields[PIX_POINT_OF_INITIATION_ID]; + + if ( + pointOfInitiation !== undefined && + pointOfInitiation !== PIX_STATIC_POINT_OF_INITIATION && + pointOfInitiation !== PIX_DYNAMIC_POINT_OF_INITIATION + ) { + return null; + } + + if (fields[PIX_MERCHANT_CATEGORY_CODE_ID] === undefined) return null; + if (fields[PIX_TRANSACTION_CURRENCY_ID] !== PIX_TRANSACTION_CURRENCY) return null; + if (fields[PIX_COUNTRY_CODE_ID]?.toUpperCase() !== PIX_COUNTRY_CODE) return null; + + const merchantName = fields[PIX_MERCHANT_NAME_ID]; + + if (!merchantName) return null; + + const merchantCity = fields[PIX_MERCHANT_CITY_ID]; + + if (!merchantCity) return null; + + const amount = fields[PIX_TRANSACTION_AMOUNT_ID]; + + if ( + amount !== undefined && + (!AMOUNT_REGEX.test(amount) || amount.length > PIX_TRANSACTION_AMOUNT_MAX_LENGTH) + ) { + return null; + } + + const merchantAccountInformation = findMerchantAccountInformation(fields); + + if (!merchantAccountInformation) return null; + + const key = merchantAccountInformation[PIX_KEY_ID]; + const url = merchantAccountInformation[PIX_URL_ID]; + const description = merchantAccountInformation[PIX_DESCRIPTION_ID]; + + if ((key === undefined) === (url === undefined)) return null; + if (key !== undefined && !key) return null; + if (url !== undefined && !isValidPixUrl(url)) return null; + + const additionalData = fields[PIX_ADDITIONAL_DATA_ID]; + + let txid: string | undefined; + + if (additionalData !== undefined) { + const objects = parseTlv(additionalData); + + if (!objects) return null; + + txid = objects[PIX_TXID_ID]; + } + + const pix: PixPayload = { merchantName, merchantCity }; + + if (key !== undefined) pix.key = key; + if (url !== undefined) pix.url = url; + if (description !== undefined) pix.description = description; + const isDynamic = pointOfInitiation === PIX_DYNAMIC_POINT_OF_INITIATION; + + if (amount !== undefined && !isDynamic) pix.amount = Number(amount); + if (txid !== undefined && txid !== PIX_ABSENT_TXID && !isDynamic) pix.txid = txid; + + if (pointOfInitiation !== undefined) { + pix.pointOfInitiation = + pointOfInitiation === PIX_DYNAMIC_POINT_OF_INITIATION ? "dynamic" : "static"; + } + + return pix; +}; From bed7b3f9cfababf6bc8e8c2b59dad5a91e2c0b14 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:32:08 -0300 Subject: [PATCH 03/10] feat(municipality): add getMunicipalities and getMunicipalityByCode (offline IBGE data) Both resolve against the bundled IBGE dataset, with no network request (unlike getMunicipality, which always calls the IBGE API). --- .../get-municipalities.test.ts | 95 +++++++++++++++++++ src/get-municipalities/get-municipalities.ts | 40 ++++++++ .../get-municipality-by-code.test.ts | 70 ++++++++++++++ .../get-municipality-by-code.ts | 40 ++++++++ 4 files changed, 245 insertions(+) create mode 100644 src/get-municipalities/get-municipalities.test.ts create mode 100644 src/get-municipalities/get-municipalities.ts create mode 100644 src/get-municipality-by-code/get-municipality-by-code.test.ts create mode 100644 src/get-municipality-by-code/get-municipality-by-code.ts diff --git a/src/get-municipalities/get-municipalities.test.ts b/src/get-municipalities/get-municipalities.test.ts new file mode 100644 index 00000000..9371c64a --- /dev/null +++ b/src/get-municipalities/get-municipalities.test.ts @@ -0,0 +1,95 @@ +import { DATA } from "../_internals/constants/cities"; +import { describe, expect, it } from "../_internals/test/runtime"; +import { getStates } from "../get-states/get-states"; +import { getMunicipalities } from "./get-municipalities"; + +const NUMBER_OF_BRAZILIAN_MUNICIPALITIES = 5571; + +const KNOWN_STATE_MUNICIPALITY_COUNTS: Record = { + MG: 853, + MT: 142, + RS: 497, + SP: 645, +}; + +describe("getMunicipalities", () => { + it("should return every municipality when no state is given", () => { + expect(getMunicipalities().length).toBe(NUMBER_OF_BRAZILIAN_MUNICIPALITIES); + }); + + it("should sort the combined list with the pt-BR comparator", () => { + const municipalities = getMunicipalities(); + const names = municipalities.map((municipality) => municipality.name); + const sortedNames = [...names].sort((a, b) => a.localeCompare(b, "pt-BR")); + + expect(names).toEqual(sortedNames); + }); + + it("should return municipality objects shaped as { code, name, stateCode }", () => { + const saoPaulo = getMunicipalities("SP").find( + (municipality) => municipality.name === "São Paulo", + ); + + expect(saoPaulo).toEqual({ code: "3550308", name: "São Paulo", stateCode: "SP" }); + }); + + it("should filter municipalities by state", () => { + for (const [stateCode, expectedCount] of Object.entries(KNOWN_STATE_MUNICIPALITY_COUNTS)) { + expect(getMunicipalities(stateCode).length).toBe(expectedCount); + } + }); + + it("should include Boa Esperança do Norte/MT", () => { + const municipalities = getMunicipalities("MT"); + + expect(municipalities).toContainEqual({ + code: "5101837", + name: "Boa Esperança do Norte", + stateCode: "MT", + }); + }); + + it("should return an empty array for an unknown state", () => { + expect(getMunicipalities("ZZ")).toEqual([]); + }); + + it("should return an empty array for inherited Object property names instead of throwing", () => { + expect(getMunicipalities("toString")).toEqual([]); + expect(getMunicipalities("constructor")).toEqual([]); + }); + + it("should return a fresh copy so mutating the result does not affect subsequent calls", () => { + const all = getMunicipalities(); + all.push({ code: "0000000", name: "MUTATED", stateCode: "SP" }); + + expect(getMunicipalities().length).toBe(NUMBER_OF_BRAZILIAN_MUNICIPALITIES); + + const spMunicipalities = getMunicipalities("SP"); + spMunicipalities[0].name = "MUTATED"; + + expect(getMunicipalities("SP")[0].name).not.toBe("MUTATED"); + }); + + describe("data integrity (IBGE, https://servicodados.ibge.gov.br/api/docs/localidades)", () => { + it(`should total exactly ${NUMBER_OF_BRAZILIAN_MUNICIPALITIES} municipalities across all states`, () => { + const total = Object.values(DATA).reduce( + (sum, municipalities) => sum + municipalities.length, + 0, + ); + + expect(total).toBe(NUMBER_OF_BRAZILIAN_MUNICIPALITIES); + }); + + for (const { code } of getStates()) { + it(`should return municipalities matching DATA for state ${code}`, () => { + const expected = DATA[code].map(([name, municipalityCode]) => ({ + code: municipalityCode, + name, + stateCode: code, + })); + + expect(getMunicipalities(code)).toEqual(expected); + }); + } + }); +}); diff --git a/src/get-municipalities/get-municipalities.ts b/src/get-municipalities/get-municipalities.ts new file mode 100644 index 00000000..0e7272ad --- /dev/null +++ b/src/get-municipalities/get-municipalities.ts @@ -0,0 +1,40 @@ +import { DATA as CITIES_DATA, type Municipality } from "../_internals/constants/cities"; +import type { StateCode } from "../_internals/constants/states"; +import { getStates } from "../get-states/get-states"; + +const buildMunicipalities = (stateCode: StateCode): Municipality[] => + CITIES_DATA[stateCode].map(([name, code]) => ({ code, name, stateCode })); + +/** + * Returns Brazilian municipalities published by the IBGE, optionally filtered by state. + * + * If `stateCode` is provided, only municipalities of that state are returned. If it is + * omitted, every municipality of every state is returned, sorted with `localeCompare` in the + * "pt-BR" locale so accented names land where a Brazilian reader expects them. + * + * @param {string} [stateCode] - The two letter code of the Brazilian state to filter by. + * @returns {Municipality[]} A fresh array of fresh `Municipality` objects. Empty when + * `stateCode` is not a known state. + * + * @example + * ```typescript + * getMunicipalities("SP")[0]; // { code: "3500105", name: "Adamantina", stateCode: "SP" } + * getMunicipalities().length; // every municipality of every state + * getMunicipalities("ZZ"); // [] + * ``` + * + * @see Official: https://servicodados.ibge.gov.br/api/docs/localidades + */ +export const getMunicipalities = (stateCode?: string): Municipality[] => { + if (stateCode === undefined) { + return getStates() + .flatMap((state) => buildMunicipalities(state.code)) + .sort((a, b) => a.name.localeCompare(b.name, "pt-BR")); + } + + const state = getStates().find((candidate) => candidate.code === stateCode); + + if (!state) return []; + + return buildMunicipalities(state.code); +}; diff --git a/src/get-municipality-by-code/get-municipality-by-code.test.ts b/src/get-municipality-by-code/get-municipality-by-code.test.ts new file mode 100644 index 00000000..c158c93d --- /dev/null +++ b/src/get-municipality-by-code/get-municipality-by-code.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "../_internals/test/runtime"; +import { getMunicipalityByCode } from "./get-municipality-by-code"; + +describe("getMunicipalityByCode", () => { + it("should return the municipality for a known code (string)", () => { + expect(getMunicipalityByCode("3550308")).toEqual({ + code: "3550308", + name: "São Paulo", + stateCode: "SP", + }); + }); + + it("should return the municipality for a known code (number)", () => { + expect(getMunicipalityByCode(3550308)).toEqual({ + code: "3550308", + name: "São Paulo", + stateCode: "SP", + }); + }); + + it("should resolve Boa Esperança do Norte/MT", () => { + expect(getMunicipalityByCode("5101837")).toEqual({ + code: "5101837", + name: "Boa Esperança do Norte", + stateCode: "MT", + }); + }); + + it("should return a fresh object so mutating the result does not affect subsequent calls", () => { + const municipality = getMunicipalityByCode("3550308"); + + if (municipality) municipality.name = "MUTATED"; + + expect(getMunicipalityByCode("3550308")?.name).toBe("São Paulo"); + }); + + it("should return null for an unknown 7 digit code", () => { + expect(getMunicipalityByCode("0000000")).toBeNull(); + }); + + it("should return null for a code with the wrong number of digits", () => { + expect(getMunicipalityByCode("123")).toBeNull(); + expect(getMunicipalityByCode("12345678")).toBeNull(); + }); + + it("should return null for an empty string", () => { + expect(getMunicipalityByCode("")).toBeNull(); + }); + + it("should return null for a non-string, non-number value", () => { + // @ts-expect-error + expect(getMunicipalityByCode(null)).toBeNull(); + // @ts-expect-error + expect(getMunicipalityByCode(undefined)).toBeNull(); + // @ts-expect-error + expect(getMunicipalityByCode(true)).toBeNull(); + // @ts-expect-error + expect(getMunicipalityByCode({})).toBeNull(); + // @ts-expect-error + expect(getMunicipalityByCode([])).toBeNull(); + }); + + it("should ignore non-digit characters before validating the length", () => { + expect(getMunicipalityByCode("355-030-8")).toEqual({ + code: "3550308", + name: "São Paulo", + stateCode: "SP", + }); + }); +}); diff --git a/src/get-municipality-by-code/get-municipality-by-code.ts b/src/get-municipality-by-code/get-municipality-by-code.ts new file mode 100644 index 00000000..7b107b2e --- /dev/null +++ b/src/get-municipality-by-code/get-municipality-by-code.ts @@ -0,0 +1,40 @@ +import { DATA as CITIES_DATA, type Municipality } from "../_internals/constants/cities"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { getStates } from "../get-states/get-states"; + +const CODE_LENGTH = 7; + +/** + * Looks up a Brazilian municipality by its 7 digit IBGE code, published by the IBGE. + * + * @param {string|number} code - The 7 digit IBGE municipality code, as a string or a number. + * @returns {Municipality|null} A fresh copy of the matching municipality, or `null` when + * `code` is not a 7 digit code or does not match any known municipality. + * + * @example + * ```typescript + * getMunicipalityByCode("3550308"); // { code: "3550308", name: "São Paulo", stateCode: "SP" } + * getMunicipalityByCode(3550308); // { code: "3550308", name: "São Paulo", stateCode: "SP" } + * getMunicipalityByCode("0000000"); // null + * ``` + * + * @see Official: https://servicodados.ibge.gov.br/api/docs/localidades + */ +export const getMunicipalityByCode = (code: string | number): Municipality | null => { + if (isNullish(code) || (typeof code !== "string" && typeof code !== "number")) return null; + + const digits = sanitizeToDigits(code); + + if (digits.length !== CODE_LENGTH) return null; + + for (const state of getStates()) { + const match = CITIES_DATA[state.code].find( + ([, municipalityCode]) => municipalityCode === digits, + ); + + if (match) return { code: digits, name: match[0], stateCode: state.code }; + } + + return null; +}; From cc3ce8af6dbc0130763e1b09ac38ecd490417c2d Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:32:08 -0300 Subject: [PATCH 04/10] feat(states): add getStateByIbgeCode, getStateCodeByName, getStateNameByCode and getTimezoneByState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 3 lookups resolve a UF by IBGE code / name / code, accent- and case-insensitive. getTimezoneByState resolves the IANA timezone(s) for a state (Brasília, Amazonas, Acre and Fernando de Noronha all differ from the rest of the country). --- .../get-state-by-ibge-code.test.ts | 70 +++++++++++ .../get-state-by-ibge-code.ts | 42 +++++++ .../get-state-code-by-name.test.ts | 65 ++++++++++ .../get-state-code-by-name.ts | 34 ++++++ .../get-state-name-by-code.test.ts | 52 ++++++++ .../get-state-name-by-code.ts | 33 +++++ src/get-timezone-by-state/constants.ts | 42 +++++++ .../get-timezone-by-state.test.ts | 113 ++++++++++++++++++ .../get-timezone-by-state.ts | 37 ++++++ 9 files changed, 488 insertions(+) create mode 100644 src/get-state-by-ibge-code/get-state-by-ibge-code.test.ts create mode 100644 src/get-state-by-ibge-code/get-state-by-ibge-code.ts create mode 100644 src/get-state-code-by-name/get-state-code-by-name.test.ts create mode 100644 src/get-state-code-by-name/get-state-code-by-name.ts create mode 100644 src/get-state-name-by-code/get-state-name-by-code.test.ts create mode 100644 src/get-state-name-by-code/get-state-name-by-code.ts create mode 100644 src/get-timezone-by-state/constants.ts create mode 100644 src/get-timezone-by-state/get-timezone-by-state.test.ts create mode 100644 src/get-timezone-by-state/get-timezone-by-state.ts diff --git a/src/get-state-by-ibge-code/get-state-by-ibge-code.test.ts b/src/get-state-by-ibge-code/get-state-by-ibge-code.test.ts new file mode 100644 index 00000000..762bf5c2 --- /dev/null +++ b/src/get-state-by-ibge-code/get-state-by-ibge-code.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "../_internals/test/runtime"; +import { getStateByIbgeCode } from "./get-state-by-ibge-code"; + +describe("getStateByIbgeCode", () => { + it("should return São Paulo for the string code 35, the cUF used in NF-e access keys (MOC 7)", () => { + expect(getStateByIbgeCode("35")).toEqual({ + code: "SP", + name: "São Paulo", + regionCode: "SE", + regionName: "Sudeste", + ibgeCode: 35, + }); + }); + + it("should return São Paulo for the number code 35", () => { + expect(getStateByIbgeCode(35)).toEqual({ + code: "SP", + name: "São Paulo", + regionCode: "SE", + regionName: "Sudeste", + ibgeCode: 35, + }); + }); + + it("should return Rondônia for the code 11, the first cUF in the IBGE table", () => { + expect(getStateByIbgeCode("11")?.code).toBe("RO"); + }); + + it("should return Distrito Federal for the code 53, the last cUF in the IBGE table", () => { + expect(getStateByIbgeCode("53")?.code).toBe("DF"); + }); + + it("should return a fresh copy that does not mutate the underlying constant", () => { + const state = getStateByIbgeCode("35"); + if (state) Object.assign(state, { name: "X" }); + + expect(getStateByIbgeCode("35")?.name).toBe("São Paulo"); + }); + + it("should strip a leading zero before matching", () => { + expect(getStateByIbgeCode("035")?.code).toBe("SP"); + }); + + it("should return null for a code with no matching state", () => { + expect(getStateByIbgeCode("00")).toBeNull(); + expect(getStateByIbgeCode("99")).toBeNull(); + }); + + it("should return null for an empty string", () => { + expect(getStateByIbgeCode("")).toBeNull(); + }); + + it("should return null for whitespace only", () => { + expect(getStateByIbgeCode(" ")).toBeNull(); + }); + + it("should return null for null", () => { + // @ts-expect-error + expect(getStateByIbgeCode(null)).toBeNull(); + }); + + it("should return null for undefined", () => { + // @ts-expect-error + expect(getStateByIbgeCode(undefined)).toBeNull(); + }); + + it("should ignore non-digit characters around the code", () => { + expect(getStateByIbgeCode(" 35 ")?.code).toBe("SP"); + }); +}); diff --git a/src/get-state-by-ibge-code/get-state-by-ibge-code.ts b/src/get-state-by-ibge-code/get-state-by-ibge-code.ts new file mode 100644 index 00000000..b6956b94 --- /dev/null +++ b/src/get-state-by-ibge-code/get-state-by-ibge-code.ts @@ -0,0 +1,42 @@ +import { DATA, type State } from "../_internals/constants/states"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; + +/** + * Retrieves the Brazilian state whose 2-digit IBGE code ("cUF", the Código da Unidade da + * Federação) matches the given value. + * + * The IBGE code is the same 2-digit UF code found in the first field of every DF-e access key + * (chave de acesso) issued for NF-e, NFC-e, CT-e and MDF-e documents. + * + * @param {string|number} code - The 2-digit IBGE UF code. Accepts a string or a number, with + * any non-digit characters stripped before matching. + * @returns {State|null} The matching `State` object, or `null` when `code` is not a known + * IBGE UF code. + * + * @see Official: https://servicodados.ibge.gov.br/api/v1/localidades/estados (IBGE Localidades API, field `id`) + * @see Official: https://www.confaz.fazenda.gov.br/legislacao/arquivo-manuais/moc7-visao-geral.pdf + * (Manual de Orientação do Contribuinte, "chave de acesso" / "Tabela do IBGE") + * + * @example + * ```typescript + * getStateByIbgeCode("35"); // { code: "SP", name: "São Paulo", regionCode: "SE", regionName: "Sudeste", ibgeCode: 35 } + * getStateByIbgeCode(35); // { code: "SP", name: "São Paulo", regionCode: "SE", regionName: "Sudeste", ibgeCode: 35 } + * getStateByIbgeCode("11"); // { code: "RO", name: "Rondônia", regionCode: "N", regionName: "Norte", ibgeCode: 11 } + * getStateByIbgeCode("00"); // null + * getStateByIbgeCode(""); // null + * ``` + */ +export const getStateByIbgeCode = (code: string | number): State | null => { + if (isNullish(code)) return null; + + const digits = sanitizeToDigits(code); + + if (digits === "") return null; + + const numericCode = Number(digits); + + const state = DATA.find((entry) => entry.ibgeCode === numericCode); + + return state ? { ...state } : null; +}; diff --git a/src/get-state-code-by-name/get-state-code-by-name.test.ts b/src/get-state-code-by-name/get-state-code-by-name.test.ts new file mode 100644 index 00000000..8c5b963f --- /dev/null +++ b/src/get-state-code-by-name/get-state-code-by-name.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "../_internals/test/runtime"; +import { getStateCodeByName } from "./get-state-code-by-name"; + +describe("getStateCodeByName", () => { + it("should return SP for the exact published name", () => { + expect(getStateCodeByName("São Paulo")).toBe("SP"); + }); + + it("should be accent-insensitive", () => { + expect(getStateCodeByName("Sao Paulo")).toBe("SP"); + }); + + it("should be case-insensitive", () => { + expect(getStateCodeByName("sao paulo")).toBe("SP"); + expect(getStateCodeByName("SAO PAULO")).toBe("SP"); + }); + + it("should trim leading and trailing whitespace", () => { + expect(getStateCodeByName(" São Paulo ")).toBe("SP"); + }); + + it("should combine accent removal, casing and trimming together", () => { + expect(getStateCodeByName(" sao PAULO ")).toBe("SP"); + }); + + it("should resolve a multi-word name with accents, the Ceará example", () => { + expect(getStateCodeByName("ceara")).toBe("CE"); + }); + + it("should resolve a name containing 'do'/'de' particles, the Rio Grande do Sul example", () => { + expect(getStateCodeByName("rio grande do sul")).toBe("RS"); + }); + + it("should distinguish Rio Grande do Norte from Rio Grande do Sul", () => { + expect(getStateCodeByName("Rio Grande do Norte")).toBe("RN"); + expect(getStateCodeByName("Rio Grande do Sul")).toBe("RS"); + }); + + it("should return null for a name that matches no state", () => { + expect(getStateCodeByName("Neverland")).toBeNull(); + }); + + it("should return null for an empty string", () => { + expect(getStateCodeByName("")).toBeNull(); + }); + + it("should return null for whitespace only", () => { + expect(getStateCodeByName(" ")).toBeNull(); + }); + + it("should return null for null", () => { + // @ts-expect-error + expect(getStateCodeByName(null)).toBeNull(); + }); + + it("should return null for undefined", () => { + // @ts-expect-error + expect(getStateCodeByName(undefined)).toBeNull(); + }); + + it("should return null for a number", () => { + // @ts-expect-error + expect(getStateCodeByName(35)).toBeNull(); + }); +}); diff --git a/src/get-state-code-by-name/get-state-code-by-name.ts b/src/get-state-code-by-name/get-state-code-by-name.ts new file mode 100644 index 00000000..7177dff7 --- /dev/null +++ b/src/get-state-code-by-name/get-state-code-by-name.ts @@ -0,0 +1,34 @@ +import { DATA, type StateCode } from "../_internals/constants/states"; +import { removeAccents } from "../remove-accents/remove-accents"; + +/** + * Retrieves the two-letter code (sigla) of a Brazilian state given its full name. + * + * The match is accent-insensitive, case-insensitive and ignores leading/trailing whitespace, + * so `" são paulo "`, `"Sao Paulo"` and `"SÃO PAULO"` all resolve to `"SP"`. + * + * @param {string} name - The full name of the state. + * @returns {StateCode|null} The two-letter state code, or `null` when `name` does not match + * any Brazilian state. + * + * @see Official: https://servicodados.ibge.gov.br/api/v1/localidades/estados (IBGE Localidades API) + * + * @example + * ```typescript + * getStateCodeByName("São Paulo"); // "SP" + * getStateCodeByName("sao paulo"); // "SP" + * getStateCodeByName(" Rio de Janeiro "); // "RJ" + * getStateCodeByName("Neverland"); // null + * ``` + */ +export const getStateCodeByName = (name: string): StateCode | null => { + if (typeof name !== "string") return null; + + const normalized = removeAccents(name).trim().toLowerCase(); + + if (normalized === "") return null; + + const state = DATA.find((entry) => removeAccents(entry.name).toLowerCase() === normalized); + + return state ? state.code : null; +}; diff --git a/src/get-state-name-by-code/get-state-name-by-code.test.ts b/src/get-state-name-by-code/get-state-name-by-code.test.ts new file mode 100644 index 00000000..e9f7ff6b --- /dev/null +++ b/src/get-state-name-by-code/get-state-name-by-code.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "../_internals/test/runtime"; +import { getStateNameByCode } from "./get-state-name-by-code"; + +describe("getStateNameByCode", () => { + it("should return the full name for an uppercase code", () => { + expect(getStateNameByCode("SP")).toBe("São Paulo"); + }); + + it("should be case-insensitive", () => { + expect(getStateNameByCode("sp")).toBe("São Paulo"); + expect(getStateNameByCode("Sp")).toBe("São Paulo"); + }); + + it("should trim leading and trailing whitespace", () => { + expect(getStateNameByCode(" RJ ")).toBe("Rio de Janeiro"); + }); + + it("should combine casing and trimming together", () => { + expect(getStateNameByCode(" rj ")).toBe("Rio de Janeiro"); + }); + + it("should resolve the Distrito Federal code", () => { + expect(getStateNameByCode("DF")).toBe("Distrito Federal"); + }); + + it("should return null for a code that matches no state", () => { + expect(getStateNameByCode("ZZ")).toBeNull(); + }); + + it("should return null for an empty string", () => { + expect(getStateNameByCode("")).toBeNull(); + }); + + it("should return null for whitespace only", () => { + expect(getStateNameByCode(" ")).toBeNull(); + }); + + it("should return null for null", () => { + // @ts-expect-error + expect(getStateNameByCode(null)).toBeNull(); + }); + + it("should return null for undefined", () => { + // @ts-expect-error + expect(getStateNameByCode(undefined)).toBeNull(); + }); + + it("should return null for a number", () => { + // @ts-expect-error + expect(getStateNameByCode(11)).toBeNull(); + }); +}); diff --git a/src/get-state-name-by-code/get-state-name-by-code.ts b/src/get-state-name-by-code/get-state-name-by-code.ts new file mode 100644 index 00000000..3ec31a05 --- /dev/null +++ b/src/get-state-name-by-code/get-state-name-by-code.ts @@ -0,0 +1,33 @@ +import { DATA, type StateName } from "../_internals/constants/states"; + +/** + * Retrieves the full name of a Brazilian state given its two-letter code (sigla). + * + * The match is case-insensitive and ignores leading/trailing whitespace, so `"sp"`, `"SP"` + * and `" Sp "` all resolve to `"São Paulo"`. + * + * @param {string} code - The two-letter state code. + * @returns {StateName|null} The full state name, or `null` when `code` does not match any + * Brazilian state. + * + * @see Official: https://servicodados.ibge.gov.br/api/v1/localidades/estados (IBGE Localidades API) + * + * @example + * ```typescript + * getStateNameByCode("SP"); // "São Paulo" + * getStateNameByCode("sp"); // "São Paulo" + * getStateNameByCode(" Rj "); // "Rio de Janeiro" + * getStateNameByCode("ZZ"); // null + * ``` + */ +export const getStateNameByCode = (code: string): StateName | null => { + if (typeof code !== "string") return null; + + const normalized = code.trim().toUpperCase(); + + if (normalized === "") return null; + + const state = DATA.find((entry) => entry.code === normalized); + + return state ? state.name : null; +}; diff --git a/src/get-timezone-by-state/constants.ts b/src/get-timezone-by-state/constants.ts new file mode 100644 index 00000000..f226011b --- /dev/null +++ b/src/get-timezone-by-state/constants.ts @@ -0,0 +1,42 @@ +/** + * IANA time zone database (tzdata) name for each Brazilian state, chosen as the zone of the + * state capital per the official `zone1970.tab` comments (some tzdata zones span more than + * one state, e.g. `America/Sao_Paulo` also covers DF, GO, MG, ES, RJ, PR, SC and RS, and + * `America/Fortaleza` also covers MA, PI, RN and PB besides CE). Pernambuco maps to + * `America/Recife`, not `America/Noronha`: Fernando de Noronha is an archipelago district of + * PE, not a state of its own, and its distinct UTC-02:00 offset is out of scope here. + * + * @see Official: https://raw.githubusercontent.com/eggert/tz/main/zone1970.tab (IANA tz + * database, `BR` rows) + * @see Based on: https://en.wikipedia.org/wiki/Time_in_Brazil Used to confirm the state + * coverage of each zone. + */ +export const STATE_TIMEZONES: Record = { + AC: "America/Rio_Branco", + AL: "America/Maceio", + AM: "America/Manaus", + AP: "America/Belem", + BA: "America/Bahia", + CE: "America/Fortaleza", + DF: "America/Sao_Paulo", + ES: "America/Sao_Paulo", + GO: "America/Sao_Paulo", + MA: "America/Fortaleza", + MG: "America/Sao_Paulo", + MS: "America/Campo_Grande", + MT: "America/Cuiaba", + PA: "America/Belem", + PB: "America/Fortaleza", + PE: "America/Recife", + PI: "America/Fortaleza", + PR: "America/Sao_Paulo", + RJ: "America/Sao_Paulo", + RN: "America/Fortaleza", + RO: "America/Porto_Velho", + RR: "America/Boa_Vista", + RS: "America/Sao_Paulo", + SC: "America/Sao_Paulo", + SE: "America/Maceio", + SP: "America/Sao_Paulo", + TO: "America/Araguaina", +}; diff --git a/src/get-timezone-by-state/get-timezone-by-state.test.ts b/src/get-timezone-by-state/get-timezone-by-state.test.ts new file mode 100644 index 00000000..d75edb2a --- /dev/null +++ b/src/get-timezone-by-state/get-timezone-by-state.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "../_internals/test/runtime"; +import { getTimezoneByState } from "./get-timezone-by-state"; + +describe("getTimezoneByState", () => { + it("should return America/Sao_Paulo for SP", () => { + expect(getTimezoneByState("SP")).toBe("America/Sao_Paulo"); + }); + + it("should return America/Manaus for AM, the tzdata BR row for Amazonas (east)", () => { + expect(getTimezoneByState("AM")).toBe("America/Manaus"); + }); + + it("should return America/Rio_Branco for AC", () => { + expect(getTimezoneByState("AC")).toBe("America/Rio_Branco"); + }); + + it("should return America/Recife for PE, not America/Noronha (Fernando de Noronha is a district of PE, not a state)", () => { + expect(getTimezoneByState("PE")).toBe("America/Recife"); + }); + + it("should return America/Belem for AP, per the tzdata BR row 'Pará (east), Amapá'", () => { + expect(getTimezoneByState("AP")).toBe("America/Belem"); + }); + + it("should return America/Belem for PA", () => { + expect(getTimezoneByState("PA")).toBe("America/Belem"); + }); + + it("should return America/Fortaleza for CE, MA, PI, RN and PB, the tzdata BR row 'Brazil (northeast: MA, PI, CE, RN, PB)'", () => { + expect(getTimezoneByState("CE")).toBe("America/Fortaleza"); + expect(getTimezoneByState("MA")).toBe("America/Fortaleza"); + expect(getTimezoneByState("PI")).toBe("America/Fortaleza"); + expect(getTimezoneByState("RN")).toBe("America/Fortaleza"); + expect(getTimezoneByState("PB")).toBe("America/Fortaleza"); + }); + + it("should return America/Sao_Paulo for every southeast/south/center-west state sharing that zone", () => { + expect(getTimezoneByState("DF")).toBe("America/Sao_Paulo"); + expect(getTimezoneByState("GO")).toBe("America/Sao_Paulo"); + expect(getTimezoneByState("MG")).toBe("America/Sao_Paulo"); + expect(getTimezoneByState("ES")).toBe("America/Sao_Paulo"); + expect(getTimezoneByState("RJ")).toBe("America/Sao_Paulo"); + expect(getTimezoneByState("PR")).toBe("America/Sao_Paulo"); + expect(getTimezoneByState("SC")).toBe("America/Sao_Paulo"); + expect(getTimezoneByState("RS")).toBe("America/Sao_Paulo"); + }); + + it("should return America/Maceio for AL and SE", () => { + expect(getTimezoneByState("AL")).toBe("America/Maceio"); + expect(getTimezoneByState("SE")).toBe("America/Maceio"); + }); + + it("should return America/Bahia for BA", () => { + expect(getTimezoneByState("BA")).toBe("America/Bahia"); + }); + + it("should return America/Cuiaba for MT", () => { + expect(getTimezoneByState("MT")).toBe("America/Cuiaba"); + }); + + it("should return America/Campo_Grande for MS", () => { + expect(getTimezoneByState("MS")).toBe("America/Campo_Grande"); + }); + + it("should return America/Porto_Velho for RO", () => { + expect(getTimezoneByState("RO")).toBe("America/Porto_Velho"); + }); + + it("should return America/Boa_Vista for RR", () => { + expect(getTimezoneByState("RR")).toBe("America/Boa_Vista"); + }); + + it("should return America/Araguaina for TO", () => { + expect(getTimezoneByState("TO")).toBe("America/Araguaina"); + }); + + it("should be case-insensitive", () => { + expect(getTimezoneByState("sp")).toBe("America/Sao_Paulo"); + }); + + it("should trim leading and trailing whitespace", () => { + expect(getTimezoneByState(" SP ")).toBe("America/Sao_Paulo"); + }); + + it("should return null for a code that matches no state", () => { + expect(getTimezoneByState("ZZ")).toBeNull(); + }); + + it("should return null for an empty string", () => { + expect(getTimezoneByState("")).toBeNull(); + }); + + it("should return null for null", () => { + // @ts-expect-error + expect(getTimezoneByState(null)).toBeNull(); + }); + + it("should return null for undefined", () => { + // @ts-expect-error + expect(getTimezoneByState(undefined)).toBeNull(); + }); + + it("should return null for a number", () => { + // @ts-expect-error + expect(getTimezoneByState(35)).toBeNull(); + }); + + it("should return null for names inherited from Object.prototype", () => { + expect(getTimezoneByState("constructor")).toBeNull(); + expect(getTimezoneByState("toString")).toBeNull(); + expect(getTimezoneByState("__proto__")).toBeNull(); + }); +}); diff --git a/src/get-timezone-by-state/get-timezone-by-state.ts b/src/get-timezone-by-state/get-timezone-by-state.ts new file mode 100644 index 00000000..f56648be --- /dev/null +++ b/src/get-timezone-by-state/get-timezone-by-state.ts @@ -0,0 +1,37 @@ +import { STATE_TIMEZONES } from "./constants"; + +/** + * Retrieves the IANA time zone database name (tzdata zone) for a Brazilian state, chosen as + * the zone of the state capital. The match is case-insensitive and ignores leading/trailing + * whitespace. + * + * Some tzdata zones cover more than one state: `America/Sao_Paulo` also covers DF, GO, MG, ES, + * RJ, PR, SC and RS besides SP, and `America/Fortaleza` also covers MA, PI, RN and PB besides + * CE. Pernambuco resolves to `America/Recife`, not `America/Noronha`: Fernando de Noronha is an + * archipelago district of PE, not a state of its own. + * + * @param {string} stateCode - The two-letter state code (sigla). + * @returns {string|null} The IANA time zone name, or `null` when `stateCode` does not match + * any Brazilian state. + * + * @see Official: https://raw.githubusercontent.com/eggert/tz/main/zone1970.tab (IANA tz + * database, `BR` rows) + * @see Based on: https://en.wikipedia.org/wiki/Time_in_Brazil Used to confirm the state + * coverage of each zone. + * + * @example + * ```typescript + * getTimezoneByState("SP"); // "America/Sao_Paulo" + * getTimezoneByState("am"); // "America/Manaus" + * getTimezoneByState("AC"); // "America/Rio_Branco" + * getTimezoneByState("PE"); // "America/Recife" + * getTimezoneByState("ZZ"); // null + * ``` + */ +export const getTimezoneByState = (stateCode: string): string | null => { + if (typeof stateCode !== "string") return null; + + const normalized = stateCode.trim().toUpperCase(); + + return Object.hasOwn(STATE_TIMEZONES, normalized) ? STATE_TIMEZONES[normalized] : null; +}; From 748d9b8b477cb1af6016970f66d2bca65cf6cdbc Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:32:08 -0300 Subject: [PATCH 05/10] feat(area-code): add getAreaCodeInfo and getAreaCodesByState getAreaCodeInfo(ddd) resolves a DDD to its state and region; getAreaCodesByState(uf) does the reverse lookup. Both are backed by a new richer AREA_CODE_STATES table alongside the existing VALID_AREA_CODES. --- .../get-area-code-info.test.ts | 97 +++++++++++++++++++ src/get-area-code-info/get-area-code-info.ts | 57 +++++++++++ .../get-area-codes-by-state.test.ts | 60 ++++++++++++ .../get-area-codes-by-state.ts | 39 ++++++++ 4 files changed, 253 insertions(+) create mode 100644 src/get-area-code-info/get-area-code-info.test.ts create mode 100644 src/get-area-code-info/get-area-code-info.ts create mode 100644 src/get-area-codes-by-state/get-area-codes-by-state.test.ts create mode 100644 src/get-area-codes-by-state/get-area-codes-by-state.ts diff --git a/src/get-area-code-info/get-area-code-info.test.ts b/src/get-area-code-info/get-area-code-info.test.ts new file mode 100644 index 00000000..b65d6dfa --- /dev/null +++ b/src/get-area-code-info/get-area-code-info.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "../_internals/test/runtime"; +import { getAreaCodeInfo } from "./get-area-code-info"; + +describe("getAreaCodeInfo", () => { + it("should resolve DDD 11 to São Paulo, Sudeste, from a string", () => { + expect(getAreaCodeInfo("11")).toEqual({ + areaCode: 11, + stateCode: "SP", + stateName: "São Paulo", + region: "Sudeste", + }); + }); + + it("should resolve DDD 11 to São Paulo, Sudeste, from a number", () => { + expect(getAreaCodeInfo(11)).toEqual({ + areaCode: 11, + stateCode: "SP", + stateName: "São Paulo", + region: "Sudeste", + }); + }); + + it("should resolve DDD 21 to Rio de Janeiro, per the Anatel Plano Geral de Numeração", () => { + expect(getAreaCodeInfo("21")?.stateCode).toBe("RJ"); + }); + + it("should resolve DDD 68 to Acre, Norte", () => { + expect(getAreaCodeInfo("68")).toEqual({ + areaCode: 68, + stateCode: "AC", + stateName: "Acre", + region: "Norte", + }); + }); + + it("should resolve DDD 61 to Distrito Federal, Centro-Oeste", () => { + expect(getAreaCodeInfo("61")).toEqual({ + areaCode: 61, + stateCode: "DF", + stateName: "Distrito Federal", + region: "Centro-Oeste", + }); + }); + + it("should resolve every one of the 67 valid DDDs to a state (Anatel Plano Geral de Numeração)", () => { + const ddds = [ + 11, 12, 13, 14, 15, 16, 17, 18, 19, 21, 22, 24, 27, 28, 31, 32, 33, 34, 35, 37, 38, 41, 42, + 43, 44, 45, 46, 47, 48, 49, 51, 53, 54, 55, 61, 62, 63, 64, 65, 66, 67, 68, 69, 71, 73, 74, + 75, 77, 79, 81, 82, 83, 84, 85, 86, 87, 88, 89, 91, 92, 93, 94, 95, 96, 97, 98, 99, + ]; + + for (const ddd of ddds) { + expect(getAreaCodeInfo(ddd)?.areaCode).toBe(ddd); + } + + expect(ddds.length).toBe(67); + }); + + it("should map DDD 41, 42 (Ponta Grossa and Guarapuava), 43, 44, 45 and 46 to Paraná and 47, 48 and 49 to Santa Catarina", () => { + for (const ddd of ["41", "42", "43", "44", "45", "46"]) { + expect(getAreaCodeInfo(ddd)?.stateCode).toBe("PR"); + } + for (const ddd of ["47", "48", "49"]) { + expect(getAreaCodeInfo(ddd)?.stateCode).toBe("SC"); + } + }); + + it("should ignore non-digit characters around the DDD", () => { + expect(getAreaCodeInfo(" 11 ")?.stateCode).toBe("SP"); + }); + + it("should ignore a parentheses mask around the DDD", () => { + expect(getAreaCodeInfo("(11)")?.stateCode).toBe("SP"); + }); + + it("should return null for a DDD that does not exist, such as 00", () => { + expect(getAreaCodeInfo("00")).toBeNull(); + }); + + it("should return null for a DDD that does not exist, such as 20", () => { + expect(getAreaCodeInfo("20")).toBeNull(); + }); + + it("should return null for an empty string", () => { + expect(getAreaCodeInfo("")).toBeNull(); + }); + + it("should return null for null", () => { + // @ts-expect-error + expect(getAreaCodeInfo(null)).toBeNull(); + }); + + it("should return null for undefined", () => { + // @ts-expect-error + expect(getAreaCodeInfo(undefined)).toBeNull(); + }); +}); diff --git a/src/get-area-code-info/get-area-code-info.ts b/src/get-area-code-info/get-area-code-info.ts new file mode 100644 index 00000000..37883a36 --- /dev/null +++ b/src/get-area-code-info/get-area-code-info.ts @@ -0,0 +1,57 @@ +import { AREA_CODE_STATES } from "../_internals/constants/area-codes"; +import { DATA, type State, type StateCode, type StateName } from "../_internals/constants/states"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; + +export type AreaCodeInfo = { + /** The DDD (area code) as a number, e.g. `11`. */ + areaCode: number; + /** The two-letter code of the state the DDD belongs to, e.g. `"SP"`. */ + stateCode: StateCode; + /** The full name of the state the DDD belongs to, e.g. `"São Paulo"`. */ + stateName: StateName; + /** The full name of the region the state belongs to, e.g. `"Sudeste"`. */ + region: State["regionName"]; +}; + +/** + * Retrieves the state (and its region) a Brazilian DDD (area code) belongs to. + * + * @param {string|number} areaCode - The DDD to look up. Accepts a string or a number, with any + * non-digit characters stripped before matching. + * @returns {AreaCodeInfo|null} The area code info, or `null` when `areaCode` is not one of the + * 67 DDDs in use under the Plano Geral de Numeração. + * + * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2010/167-resolucao-553 + * (Resolução Anatel 553/2010, Plano Geral de Numeração) + * @see Based on: https://brasilapi.com.br/docs#tag/DDD BrasilAPI DDD endpoint, used to verify + * the code-to-state mapping. + * + * @example + * ```typescript + * getAreaCodeInfo("11"); // { areaCode: 11, stateCode: "SP", stateName: "São Paulo", region: "Sudeste" } + * getAreaCodeInfo(21); // { areaCode: 21, stateCode: "RJ", stateName: "Rio de Janeiro", region: "Sudeste" } + * getAreaCodeInfo("68"); // { areaCode: 68, stateCode: "AC", stateName: "Acre", region: "Norte" } + * getAreaCodeInfo("00"); // null + * ``` + */ +export const getAreaCodeInfo = (areaCode: string | number): AreaCodeInfo | null => { + if (isNullish(areaCode)) return null; + + const digits = sanitizeToDigits(areaCode); + + if (digits === "") return null; + + const numericAreaCode = Number(digits); + + if (!(numericAreaCode in AREA_CODE_STATES)) return null; + + const stateCode = AREA_CODE_STATES[numericAreaCode]; + + const statesByCode: Record = {}; + for (const entry of DATA) statesByCode[entry.code] = entry; + + const state = statesByCode[stateCode]; + + return { areaCode: numericAreaCode, stateCode, stateName: state.name, region: state.regionName }; +}; diff --git a/src/get-area-codes-by-state/get-area-codes-by-state.test.ts b/src/get-area-codes-by-state/get-area-codes-by-state.test.ts new file mode 100644 index 00000000..c1a83a0c --- /dev/null +++ b/src/get-area-codes-by-state/get-area-codes-by-state.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "../_internals/test/runtime"; +import { getAreaCodesByState } from "./get-area-codes-by-state"; + +describe("getAreaCodesByState", () => { + test("should return the ascending list of DDDs for a state with several DDDs", () => { + expect(getAreaCodesByState("SP")).toEqual([11, 12, 13, 14, 15, 16, 17, 18, 19]); + }); + + test("should return a single element list for a state with one DDD", () => { + expect(getAreaCodesByState("AC")).toEqual([68]); + }); + + test("should be case-insensitive", () => { + expect(getAreaCodesByState("sp")).toEqual([11, 12, 13, 14, 15, 16, 17, 18, 19]); + expect(getAreaCodesByState("Sp")).toEqual([11, 12, 13, 14, 15, 16, 17, 18, 19]); + }); + + test("should trim surrounding whitespace", () => { + expect(getAreaCodesByState(" SP ")).toEqual([11, 12, 13, 14, 15, 16, 17, 18, 19]); + }); + + test("should return DDDs out of numeric order in the source table sorted ascending", () => { + expect(getAreaCodesByState("PE")).toEqual([81, 87]); + }); + + test("should return a fresh array on every call", () => { + const first = getAreaCodesByState("AC"); + first.push(999); + expect(getAreaCodesByState("AC")).toEqual([68]); + }); + + describe("should return an empty array", () => { + test("when the state code does not match any Brazilian state", () => { + expect(getAreaCodesByState("XX")).toEqual([]); + }); + + test("when it is an empty string", () => { + expect(getAreaCodesByState("")).toEqual([]); + }); + + test("when it is a blank string", () => { + expect(getAreaCodesByState(" ")).toEqual([]); + }); + + test("when it is null", () => { + // @ts-expect-error + expect(getAreaCodesByState(null)).toEqual([]); + }); + + test("when it is undefined", () => { + // @ts-expect-error + expect(getAreaCodesByState(undefined)).toEqual([]); + }); + + test("when it is a number", () => { + // @ts-expect-error + expect(getAreaCodesByState(11)).toEqual([]); + }); + }); +}); diff --git a/src/get-area-codes-by-state/get-area-codes-by-state.ts b/src/get-area-codes-by-state/get-area-codes-by-state.ts new file mode 100644 index 00000000..244944cd --- /dev/null +++ b/src/get-area-codes-by-state/get-area-codes-by-state.ts @@ -0,0 +1,39 @@ +import { AREA_CODE_STATES } from "../_internals/constants/area-codes"; + +/** + * Retrieves every DDD (area code) that belongs to a given Brazilian state, under the Plano + * Geral de Numeração. + * + * The match is case-insensitive, so `"sp"` and `"SP"` both resolve to the same list. The + * result is sorted in ascending order and is a fresh array on every call. + * + * @param {string} stateCode - The two-letter code (sigla) of the state. + * @returns {number[]} The DDDs of the state, sorted ascending, or an empty array when + * `stateCode` does not match any Brazilian state. + * + * @example + * ```typescript + * getAreaCodesByState("SP"); // [11, 12, 13, 14, 15, 16, 17, 18, 19] + * getAreaCodesByState("sp"); // [11, 12, 13, 14, 15, 16, 17, 18, 19] + * getAreaCodesByState("AC"); // [68] + * getAreaCodesByState("XX"); // [] + * ``` + * + * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2010/167-resolucao-553 + * (Resolução Anatel 553/2010, Plano Geral de Numeração) + */ +export const getAreaCodesByState = (stateCode: string): number[] => { + if (typeof stateCode !== "string") return []; + + const normalized = stateCode.trim().toUpperCase(); + + if (normalized === "") return []; + + const areaCodes: number[] = []; + + for (const [areaCode, code] of Object.entries(AREA_CODE_STATES)) { + if (code === normalized) areaCodes.push(Number(areaCode)); + } + + return areaCodes.sort((a, b) => a - b); +}; From 3a54ca0687e14619b6858f158d4f4c37e1f2992c Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:32:09 -0300 Subject: [PATCH 06/10] feat(number-to-words): add convertNumberToWords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spells an integer out in Portuguese, e.g. convertNumberToWords(1523) -> "mil, quinhentos e vinte e três". Backed by the new shared numberToWords and applyWordsCase internals, reused by convertCurrencyToWords/convertDateToWords. --- .../apply-words-case/apply-words-case.ts | 27 + src/_internals/constants/number-words.ts | 122 ++++ .../number-to-words/number-to-words.test.ts | 128 ++++ .../number-to-words/number-to-words.ts | 154 +++++ .../convert-number-to-words.test.ts | 627 ++++++++++++++++++ .../convert-number-to-words.ts | 60 ++ 6 files changed, 1118 insertions(+) create mode 100644 src/_internals/apply-words-case/apply-words-case.ts create mode 100644 src/_internals/constants/number-words.ts create mode 100644 src/_internals/number-to-words/number-to-words.test.ts create mode 100644 src/_internals/number-to-words/number-to-words.ts create mode 100644 src/convert-number-to-words/convert-number-to-words.test.ts create mode 100644 src/convert-number-to-words/convert-number-to-words.ts diff --git a/src/_internals/apply-words-case/apply-words-case.ts b/src/_internals/apply-words-case/apply-words-case.ts new file mode 100644 index 00000000..f17887b5 --- /dev/null +++ b/src/_internals/apply-words-case/apply-words-case.ts @@ -0,0 +1,27 @@ +import type { WordsCase } from "../number-to-words/number-to-words"; + +/** + * Applies a `WordsCase` to a "por extenso" string already written out in lowercase. + * + * `"sentence"` capitalizes only the first letter; `"upper"` uppercases the whole string with + * `toLocaleUpperCase("pt-BR")`, which keeps accents intact ("três" -> "TRÊS"). Any value other + * than `"sentence"` or `"upper"` (including `"lower"`, `undefined` or an invalid value) returns + * `text` unchanged, since it is already written in lowercase. + * + * @param {string} text - The lowercase "por extenso" string to transform. + * @param {WordsCase} [wordsCase] - The case to apply. Defaults to `"lower"` (no change). + * @returns {string} `text` with the requested case applied. + * + * @example + * ```typescript + * applyWordsCase("três reais"); // "três reais" + * applyWordsCase("três reais", "sentence"); // "Três reais" + * applyWordsCase("três reais", "upper"); // "TRÊS REAIS" + * ``` + */ +export const applyWordsCase = (text: string, wordsCase?: WordsCase): string => { + if (wordsCase === "upper") return text.toLocaleUpperCase("pt-BR"); + if (wordsCase === "sentence") return text.charAt(0).toLocaleUpperCase("pt-BR") + text.slice(1); + + return text; +}; diff --git a/src/_internals/constants/number-words.ts b/src/_internals/constants/number-words.ts new file mode 100644 index 00000000..000b4a91 --- /dev/null +++ b/src/_internals/constants/number-words.ts @@ -0,0 +1,122 @@ +/** + * Portuguese (pt-BR) number-to-words tables, shared by `numberToWords` and by every public + * "por extenso" formatter (`convertNumberToWords`, `convertCurrencyToWords`, `convertDateToWords`). + * + * @see https://github.com/brazilian-utils/python/blob/main/brutils/currency.py + * "catorze" (not "quatorze") is used for 14, matching num2words pt_BR and brutils. + */ + +export const ZERO_WORD = "zero"; + +export const UNITS: readonly string[] = [ + "zero", + "um", + "dois", + "três", + "quatro", + "cinco", + "seis", + "sete", + "oito", + "nove", + "dez", + "onze", + "doze", + "treze", + "catorze", + "quinze", + "dezesseis", + "dezessete", + "dezoito", + "dezenove", +]; + +export const UNITS_FEMININE_OVERRIDES: Record = { + 1: "uma", + 2: "duas", +}; + +export const TENS: readonly string[] = [ + "", + "", + "vinte", + "trinta", + "quarenta", + "cinquenta", + "sessenta", + "setenta", + "oitenta", + "noventa", +]; + +export const HUNDRED_EXACT = "cem"; + +export const HUNDREDS_MASCULINE: readonly string[] = [ + "", + "cento", + "duzentos", + "trezentos", + "quatrocentos", + "quinhentos", + "seiscentos", + "setecentos", + "oitocentos", + "novecentos", +]; + +export const HUNDREDS_FEMININE: readonly string[] = [ + "", + "cento", + "duzentas", + "trezentas", + "quatrocentas", + "quinhentas", + "seiscentas", + "setecentas", + "oitocentas", + "novecentas", +]; + +export type NumberScaleWord = { + /** Word used for a group whose value is exactly 1 (e.g. `"mil"`, `"milhão"`). */ + singular: string; + /** Word used for a group whose value is 0 or 2-999 (e.g. `"mil"`, `"milhões"`). */ + plural: string; +}; + +export const SCALE_WORDS: readonly NumberScaleWord[] = [ + { singular: "", plural: "" }, + { singular: "mil", plural: "mil" }, + { singular: "milhão", plural: "milhões" }, + { singular: "bilhão", plural: "bilhões" }, + { singular: "trilhão", plural: "trilhões" }, +]; + +export const MONTH_NAMES: readonly string[] = [ + "janeiro", + "fevereiro", + "março", + "abril", + "maio", + "junho", + "julho", + "agosto", + "setembro", + "outubro", + "novembro", + "dezembro", +]; + +/** + * Portuguese (pt-BR) weekday names, indexed like `Date#getDay`/`Date#getUTCDay` + * (0 = domingo, ..., 6 = sábado), used by `convertDateToWords`'s `weekday` option. + */ +export const WEEKDAY_NAMES: readonly string[] = [ + "domingo", + "segunda-feira", + "terça-feira", + "quarta-feira", + "quinta-feira", + "sexta-feira", + "sábado", +]; diff --git a/src/_internals/number-to-words/number-to-words.test.ts b/src/_internals/number-to-words/number-to-words.test.ts new file mode 100644 index 00000000..1337bdf4 --- /dev/null +++ b/src/_internals/number-to-words/number-to-words.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from "../test/runtime"; +import { NUMBER_TO_WORDS_MAX_VALUE, numberToWords } from "./number-to-words"; + +describe("numberToWords", () => { + test("should return 'zero' for 0", () => { + expect(numberToWords(0)).toBe("zero"); + }); + + test("should return 'um' for 1", () => { + expect(numberToWords(1)).toBe("um"); + }); + + test("should convert every teen number (10-19)", () => { + expect(numberToWords(10)).toBe("dez"); + expect(numberToWords(11)).toBe("onze"); + expect(numberToWords(12)).toBe("doze"); + expect(numberToWords(13)).toBe("treze"); + expect(numberToWords(14)).toBe("catorze"); + expect(numberToWords(15)).toBe("quinze"); + expect(numberToWords(16)).toBe("dezesseis"); + expect(numberToWords(17)).toBe("dezessete"); + expect(numberToWords(18)).toBe("dezoito"); + expect(numberToWords(19)).toBe("dezenove"); + }); + + test("should join tens and units with 'e' (21 -> num2words pt_BR 'vinte e um')", () => { + expect(numberToWords(21)).toBe("vinte e um"); + }); + + test("should return 'cem' for the exact hundred (100)", () => { + expect(numberToWords(100)).toBe("cem"); + }); + + test("should return 'cento e um' for 101 (num2words pt_BR)", () => { + expect(numberToWords(101)).toBe("cento e um"); + }); + + test("should return 'duzentos' for the exact round hundred (200)", () => { + expect(numberToWords(200)).toBe("duzentos"); + }); + + test("should return 'mil' alone for 1000, never 'um mil'", () => { + expect(numberToWords(1000)).toBe("mil"); + }); + + test("should join 'mil' and a unit with 'e' (1001 -> 'mil e um')", () => { + expect(numberToWords(1001)).toBe("mil e um"); + }); + + test("should join 'mil' and a round hundred with 'e' (1100 -> 'mil e cem')", () => { + expect(numberToWords(1100)).toBe("mil e cem"); + }); + + test("should separate 'mil' from a non round last group with a comma (1235 -> num2words pt_BR 'mil, duzentos e trinta e cinco')", () => { + expect(numberToWords(1235)).toBe("mil, duzentos e trinta e cinco"); + }); + + test("should return 'dois mil' for 2000 (masculine default)", () => { + expect(numberToWords(2000)).toBe("dois mil"); + }); + + test("should return 'um milhão' for 1000000, never 'um milhão e zero'", () => { + expect(numberToWords(1_000_000)).toBe("um milhão"); + }); + + test("should pluralize to 'milhões' for 2000000", () => { + expect(numberToWords(2_000_000)).toBe("dois milhões"); + }); + + test("should join 'um milhão' and a trailing unit with 'e' (1000001)", () => { + expect(numberToWords(1_000_001)).toBe("um milhão e um"); + }); + + test("should convert the maximum supported value (999 trillion, num2words pt_BR)", () => { + expect(numberToWords(NUMBER_TO_WORDS_MAX_VALUE)).toBe( + "novecentos e noventa e nove trilhões, novecentos e noventa e nove bilhões, " + + "novecentos e noventa e nove milhões, novecentos e noventa e nove mil, " + + "novecentos e noventa e nove", + ); + }); + + test("should convert a value spanning billions, millions and thousands (999999999999, num2words pt_BR)", () => { + expect(numberToWords(999_999_999_999)).toBe( + "novecentos e noventa e nove bilhões, novecentos e noventa e nove milhões, " + + "novecentos e noventa e nove mil, novecentos e noventa e nove", + ); + }); + + test("should skip a zero intermediate group (1000230 -> no 'zero mil')", () => { + expect(numberToWords(1_000_230)).toBe("um milhão, duzentos e trinta"); + }); + + test("should separate an intermediate group below 100 with a comma, reserving 'e' for the last group (1045678; num2words pt_BR differs here only because its post-processing rewrites ' e ' before a hundreds word)", () => { + expect(numberToWords(1_045_678)).toBe( + "um milhão, quarenta e cinco mil, seiscentos e setenta e oito", + ); + }); + + describe("gender agreement", () => { + test("should return 'uma' and 'duas' for 1 and 2 when feminine", () => { + expect(numberToWords(1, { gender: "feminine" })).toBe("uma"); + expect(numberToWords(2, { gender: "feminine" })).toBe("duas"); + }); + + test("should return the '-entas' hundreds form when feminine", () => { + expect(numberToWords(200, { gender: "feminine" })).toBe("duzentas"); + expect(numberToWords(202, { gender: "feminine" })).toBe("duzentas e duas"); + }); + + test("should keep 'cem'/'cento' invariant regardless of gender", () => { + expect(numberToWords(100, { gender: "feminine" })).toBe("cem"); + expect(numberToWords(101, { gender: "feminine" })).toBe("cento e uma"); + }); + + test("should agree the thousands multiplier with the feminine gender (2000 -> 'duas mil')", () => { + expect(numberToWords(2000, { gender: "feminine" })).toBe("duas mil"); + }); + + test("should agree the hundreds of the thousands group with the feminine gender (200000 -> 'duzentas mil')", () => { + expect(numberToWords(200_000, { gender: "feminine" })).toBe("duzentas mil"); + expect(numberToWords(100_000, { gender: "feminine" })).toBe("cem mil"); + }); + + test("should keep the million multiplier masculine regardless of gender (it agrees with 'milhão')", () => { + expect(numberToWords(2_000_000, { gender: "feminine" })).toBe("dois milhões"); + }); + }); +}); diff --git a/src/_internals/number-to-words/number-to-words.ts b/src/_internals/number-to-words/number-to-words.ts new file mode 100644 index 00000000..9ecb74a6 --- /dev/null +++ b/src/_internals/number-to-words/number-to-words.ts @@ -0,0 +1,154 @@ +import { + HUNDRED_EXACT, + HUNDREDS_FEMININE, + HUNDREDS_MASCULINE, + SCALE_WORDS, + TENS, + UNITS, + UNITS_FEMININE_OVERRIDES, + ZERO_WORD, +} from "../constants/number-words"; + +export type NumberToWordsGender = "masculine" | "feminine"; + +/** + * Letter case applied to the final "por extenso" string of `convertNumberToWords`, + * `convertCurrencyToWords` and `convertDateToWords`. `"lower"` leaves the string as produced + * (every word already lowercase); `"sentence"` capitalizes only its first letter; `"upper"` + * uppercases the whole string with the "pt-BR" locale, which keeps accents intact + * ("três" -> "TRÊS", "março" -> "MARÇO"). Defaults to `"lower"`; any other value is ignored and + * `"lower"` is used instead. + */ +export type WordsCase = "lower" | "sentence" | "upper"; + +export type NumberToWordsOptions = { + /** Grammatical gender used to agree "um/dois" and the 100-999 group ("duzentos/duzentas", etc.) with the noun the number qualifies. Only the thousands group and the final 0-999 group are affected: the multiplier of "milhão/bilhão/trilhão" always agrees with those (masculine) nouns. Defaults to `"masculine"`. */ + gender?: NumberToWordsGender; +}; + +/** + * The largest absolute value `numberToWords` converts: 999 trillion, 999 billion, 999 million, + * 999 thousand and 999 (999999999999999), the highest value expressible with the "trilhão" + * scale word before a new scale word would be required. + */ +export const NUMBER_TO_WORDS_MAX_VALUE = 999_999_999_999_999; + +const unitWord = (digit: number, gender?: NumberToWordsGender): string => + gender === "feminine" && digit in UNITS_FEMININE_OVERRIDES + ? UNITS_FEMININE_OVERRIDES[digit] + : UNITS[digit]; + +const groupToWords = (value: number, gender?: NumberToWordsGender): string => { + const hundredsDigit = Math.floor(value / 100); + const remainder = value % 100; + const segments: string[] = []; + + if (hundredsDigit > 0) { + segments.push( + value === 100 + ? HUNDRED_EXACT + : (gender === "feminine" ? HUNDREDS_FEMININE : HUNDREDS_MASCULINE)[hundredsDigit], + ); + } + + if (remainder > 0) { + if (remainder < 20) { + segments.push(unitWord(remainder, gender)); + } else { + const tensDigit = Math.floor(remainder / 10); + const unitsDigit = remainder % 10; + segments.push( + unitsDigit > 0 ? `${TENS[tensDigit]} e ${unitWord(unitsDigit, gender)}` : TENS[tensDigit], + ); + } + } + + return segments.join(" e "); +}; + +const isRoundHundred = (value: number): boolean => value % 100 === 0; + +/** + * Converts a non-negative integer into its Brazilian Portuguese cardinal number words + * ("por extenso"), e.g. `1235` becomes `"mil, duzentos e trinta e cinco"`. + * + * This is the shared engine behind every "por extenso" formatter of this library + * (`convertNumberToWords`, `convertCurrencyToWords`, `convertDateToWords`): it only converts, it + * never validates or sanitizes its input, so callers must pass a finite, non-negative integer + * within `[0, NUMBER_TO_WORDS_MAX_VALUE]`. Grouping uses commas between groups and "e" is used + * instead of a comma right before the last group when that group is below 100 or is a round + * hundred (100, 200, ..., 900), matching how the value would be written by hand + * (e.g. `1200` -> `"mil e duzentos"`, `1235` -> `"mil, duzentos e trinta e cinco"`). The "e" + * connector is therefore reserved for the last group: an intermediate group below 100 still takes + * a comma (`1045678` -> `"um milhão, quarenta e cinco mil, seiscentos e setenta e oito"`). This is + * the one place where the output deviates from `num2words`' pt_BR locale, which writes + * `"um milhão e quarenta e cinco mil, ..."` there because its post-processing only rewrites " e " + * into "," when the next word is a hundreds word, making an intermediate group's punctuation + * depend on the group that follows it. Every published `brutils` example is reproduced exactly. + * + * @param {number} value - A non-negative integer in `[0, NUMBER_TO_WORDS_MAX_VALUE]`. + * @param {NumberToWordsOptions} [options] - Optional conversion options. + * @param {NumberToWordsGender} [options.gender] - Grammatical gender for "um/dois" and the hundreds group. Defaults to `"masculine"`. + * @returns {string} The cardinal number written out in Portuguese. + * + * @example + * ```typescript + * numberToWords(0); // "zero" + * numberToWords(21); // "vinte e um" + * numberToWords(100); // "cem" + * numberToWords(1100); // "mil e cem" + * numberToWords(1235); // "mil, duzentos e trinta e cinco" + * numberToWords(2000000); // "dois milhões" + * numberToWords(2, { gender: "feminine" }); // "duas" + * numberToWords(2000, { gender: "feminine" }); // "duas mil" + * ``` + * + * @see https://github.com/brazilian-utils/python/blob/main/brutils/currency.py + */ +export const numberToWords = (value: number, options?: NumberToWordsOptions): string => { + if (value === 0) return ZERO_WORD; + + const gender = options?.gender; + const groups: number[] = []; + let remaining = value; + + while (remaining > 0) { + groups.unshift(remaining % 1000); + remaining = Math.floor(remaining / 1000); + } + + const highestScale = groups.length - 1; + let lastNonZeroIndex = -1; + for (let i = 0; i < groups.length; i++) { + if (groups[i] > 0) lastNonZeroIndex = i; + } + + let result = ""; + + groups.forEach((groupValue, index) => { + if (groupValue === 0) return; + + const scale = highestScale - index; + const scaleWord = SCALE_WORDS[scale]; + const groupGender = scale >= 2 ? undefined : gender; + + const groupText = + scale === 1 && groupValue === 1 + ? scaleWord.singular + : scale === 0 + ? groupToWords(groupValue, groupGender) + : `${groupToWords(groupValue, groupGender)} ${groupValue === 1 ? scaleWord.singular : scaleWord.plural}`; + + if (result === "") { + result = groupText; + return; + } + + const connector = + index === lastNonZeroIndex && (groupValue < 100 || isRoundHundred(groupValue)) ? " e " : ", "; + + result += connector + groupText; + }); + + return result; +}; diff --git a/src/convert-number-to-words/convert-number-to-words.test.ts b/src/convert-number-to-words/convert-number-to-words.test.ts new file mode 100644 index 00000000..88e19fc1 --- /dev/null +++ b/src/convert-number-to-words/convert-number-to-words.test.ts @@ -0,0 +1,627 @@ +import { NUMBER_TO_WORDS_MAX_VALUE } from "../_internals/number-to-words/number-to-words"; +import { describe, expect, test } from "../_internals/test/runtime"; +import { convertNumberToWords } from "./convert-number-to-words"; + +describe("convertNumberToWords", () => { + test("should return 'zero' for 0", () => { + expect(convertNumberToWords(0)).toBe("zero"); + }); + + test("should return 'um' for 1", () => { + expect(convertNumberToWords(1)).toBe("um"); + }); + + test("should return 'cem' for 100 and 'cento e um' for 101", () => { + expect(convertNumberToWords(100)).toBe("cem"); + expect(convertNumberToWords(101)).toBe("cento e um"); + }); + + test("should return 'mil' alone for 1000, never 'um mil'", () => { + expect(convertNumberToWords(1000)).toBe("mil"); + }); + + test("should return 'um milhão' for 1000000, never 'um milhão e zero'", () => { + expect(convertNumberToWords(1_000_000)).toBe("um milhão"); + }); + + test("should convert the maximum supported value (999999999999999, 999 trillion)", () => { + expect(convertNumberToWords(NUMBER_TO_WORDS_MAX_VALUE)).toBe( + "novecentos e noventa e nove trilhões, novecentos e noventa e nove bilhões, " + + "novecentos e noventa e nove milhões, novecentos e noventa e nove mil, " + + "novecentos e noventa e nove", + ); + }); + + test("should return '' above the maximum supported value", () => { + expect(convertNumberToWords(NUMBER_TO_WORDS_MAX_VALUE + 1)).toBe(""); + }); + + test("should return '' below the negative of the maximum supported value", () => { + expect(convertNumberToWords(-NUMBER_TO_WORDS_MAX_VALUE - 1)).toBe(""); + }); + + test("should not prefix 'menos' for negative zero", () => { + expect(convertNumberToWords(-0)).toBe("zero"); + }); + + describe("invalid input", () => { + test("should return '' for NaN", () => { + expect(convertNumberToWords(Number.NaN)).toBe(""); + }); + + test("should return '' for Infinity and -Infinity", () => { + expect(convertNumberToWords(Number.POSITIVE_INFINITY)).toBe(""); + expect(convertNumberToWords(Number.NEGATIVE_INFINITY)).toBe(""); + }); + + test("should return '' for a non-number value", () => { + // @ts-expect-error + expect(convertNumberToWords("123")).toBe(""); + // @ts-expect-error + expect(convertNumberToWords(null)).toBe(""); + // @ts-expect-error + expect(convertNumberToWords(undefined)).toBe(""); + }); + }); + + describe("non-integer values", () => { + test("should truncate toward zero before converting", () => { + expect(convertNumberToWords(12.9)).toBe("doze"); + expect(convertNumberToWords(-12.9)).toBe("menos doze"); + }); + }); + + describe("case option", () => { + test("should keep the result lowercase by default", () => { + expect(convertNumberToWords(123)).toBe("cento e vinte e três"); + }); + + test("should keep the result lowercase for 'lower'", () => { + expect(convertNumberToWords(123, { case: "lower" })).toBe("cento e vinte e três"); + }); + + test("should capitalize only the first letter for 'sentence'", () => { + expect(convertNumberToWords(123, { case: "sentence" })).toBe("Cento e vinte e três"); + expect(convertNumberToWords(3, { case: "sentence" })).toBe("Três"); + }); + + test("should uppercase everything for 'upper', keeping accents", () => { + expect(convertNumberToWords(3, { case: "upper" })).toBe("TRÊS"); + expect(convertNumberToWords(50, { case: "upper" })).toBe("CINQUENTA"); + expect(convertNumberToWords(-3, { case: "upper" })).toBe("MENOS TRÊS"); + }); + + test("should ignore an invalid case value and fall back to 'lower'", () => { + // @ts-expect-error + expect(convertNumberToWords(123, { case: "invalid" })).toBe("cento e vinte e três"); + }); + }); + + describe("literal case tables", () => { + test("should match a hand-written word for every integer from 0 to 200 (masculine)", () => { + const cases: Array<[number, string]> = [ + [0, "zero"], + [1, "um"], + [2, "dois"], + [3, "três"], + [4, "quatro"], + [5, "cinco"], + [6, "seis"], + [7, "sete"], + [8, "oito"], + [9, "nove"], + [10, "dez"], + [11, "onze"], + [12, "doze"], + [13, "treze"], + [14, "catorze"], + [15, "quinze"], + [16, "dezesseis"], + [17, "dezessete"], + [18, "dezoito"], + [19, "dezenove"], + [20, "vinte"], + [21, "vinte e um"], + [22, "vinte e dois"], + [23, "vinte e três"], + [24, "vinte e quatro"], + [25, "vinte e cinco"], + [26, "vinte e seis"], + [27, "vinte e sete"], + [28, "vinte e oito"], + [29, "vinte e nove"], + [30, "trinta"], + [31, "trinta e um"], + [32, "trinta e dois"], + [33, "trinta e três"], + [34, "trinta e quatro"], + [35, "trinta e cinco"], + [36, "trinta e seis"], + [37, "trinta e sete"], + [38, "trinta e oito"], + [39, "trinta e nove"], + [40, "quarenta"], + [41, "quarenta e um"], + [42, "quarenta e dois"], + [43, "quarenta e três"], + [44, "quarenta e quatro"], + [45, "quarenta e cinco"], + [46, "quarenta e seis"], + [47, "quarenta e sete"], + [48, "quarenta e oito"], + [49, "quarenta e nove"], + [50, "cinquenta"], + [51, "cinquenta e um"], + [52, "cinquenta e dois"], + [53, "cinquenta e três"], + [54, "cinquenta e quatro"], + [55, "cinquenta e cinco"], + [56, "cinquenta e seis"], + [57, "cinquenta e sete"], + [58, "cinquenta e oito"], + [59, "cinquenta e nove"], + [60, "sessenta"], + [61, "sessenta e um"], + [62, "sessenta e dois"], + [63, "sessenta e três"], + [64, "sessenta e quatro"], + [65, "sessenta e cinco"], + [66, "sessenta e seis"], + [67, "sessenta e sete"], + [68, "sessenta e oito"], + [69, "sessenta e nove"], + [70, "setenta"], + [71, "setenta e um"], + [72, "setenta e dois"], + [73, "setenta e três"], + [74, "setenta e quatro"], + [75, "setenta e cinco"], + [76, "setenta e seis"], + [77, "setenta e sete"], + [78, "setenta e oito"], + [79, "setenta e nove"], + [80, "oitenta"], + [81, "oitenta e um"], + [82, "oitenta e dois"], + [83, "oitenta e três"], + [84, "oitenta e quatro"], + [85, "oitenta e cinco"], + [86, "oitenta e seis"], + [87, "oitenta e sete"], + [88, "oitenta e oito"], + [89, "oitenta e nove"], + [90, "noventa"], + [91, "noventa e um"], + [92, "noventa e dois"], + [93, "noventa e três"], + [94, "noventa e quatro"], + [95, "noventa e cinco"], + [96, "noventa e seis"], + [97, "noventa e sete"], + [98, "noventa e oito"], + [99, "noventa e nove"], + [100, "cem"], + [101, "cento e um"], + [102, "cento e dois"], + [103, "cento e três"], + [104, "cento e quatro"], + [105, "cento e cinco"], + [106, "cento e seis"], + [107, "cento e sete"], + [108, "cento e oito"], + [109, "cento e nove"], + [110, "cento e dez"], + [111, "cento e onze"], + [112, "cento e doze"], + [113, "cento e treze"], + [114, "cento e catorze"], + [115, "cento e quinze"], + [116, "cento e dezesseis"], + [117, "cento e dezessete"], + [118, "cento e dezoito"], + [119, "cento e dezenove"], + [120, "cento e vinte"], + [121, "cento e vinte e um"], + [122, "cento e vinte e dois"], + [123, "cento e vinte e três"], + [124, "cento e vinte e quatro"], + [125, "cento e vinte e cinco"], + [126, "cento e vinte e seis"], + [127, "cento e vinte e sete"], + [128, "cento e vinte e oito"], + [129, "cento e vinte e nove"], + [130, "cento e trinta"], + [131, "cento e trinta e um"], + [132, "cento e trinta e dois"], + [133, "cento e trinta e três"], + [134, "cento e trinta e quatro"], + [135, "cento e trinta e cinco"], + [136, "cento e trinta e seis"], + [137, "cento e trinta e sete"], + [138, "cento e trinta e oito"], + [139, "cento e trinta e nove"], + [140, "cento e quarenta"], + [141, "cento e quarenta e um"], + [142, "cento e quarenta e dois"], + [143, "cento e quarenta e três"], + [144, "cento e quarenta e quatro"], + [145, "cento e quarenta e cinco"], + [146, "cento e quarenta e seis"], + [147, "cento e quarenta e sete"], + [148, "cento e quarenta e oito"], + [149, "cento e quarenta e nove"], + [150, "cento e cinquenta"], + [151, "cento e cinquenta e um"], + [152, "cento e cinquenta e dois"], + [153, "cento e cinquenta e três"], + [154, "cento e cinquenta e quatro"], + [155, "cento e cinquenta e cinco"], + [156, "cento e cinquenta e seis"], + [157, "cento e cinquenta e sete"], + [158, "cento e cinquenta e oito"], + [159, "cento e cinquenta e nove"], + [160, "cento e sessenta"], + [161, "cento e sessenta e um"], + [162, "cento e sessenta e dois"], + [163, "cento e sessenta e três"], + [164, "cento e sessenta e quatro"], + [165, "cento e sessenta e cinco"], + [166, "cento e sessenta e seis"], + [167, "cento e sessenta e sete"], + [168, "cento e sessenta e oito"], + [169, "cento e sessenta e nove"], + [170, "cento e setenta"], + [171, "cento e setenta e um"], + [172, "cento e setenta e dois"], + [173, "cento e setenta e três"], + [174, "cento e setenta e quatro"], + [175, "cento e setenta e cinco"], + [176, "cento e setenta e seis"], + [177, "cento e setenta e sete"], + [178, "cento e setenta e oito"], + [179, "cento e setenta e nove"], + [180, "cento e oitenta"], + [181, "cento e oitenta e um"], + [182, "cento e oitenta e dois"], + [183, "cento e oitenta e três"], + [184, "cento e oitenta e quatro"], + [185, "cento e oitenta e cinco"], + [186, "cento e oitenta e seis"], + [187, "cento e oitenta e sete"], + [188, "cento e oitenta e oito"], + [189, "cento e oitenta e nove"], + [190, "cento e noventa"], + [191, "cento e noventa e um"], + [192, "cento e noventa e dois"], + [193, "cento e noventa e três"], + [194, "cento e noventa e quatro"], + [195, "cento e noventa e cinco"], + [196, "cento e noventa e seis"], + [197, "cento e noventa e sete"], + [198, "cento e noventa e oito"], + [199, "cento e noventa e nove"], + [200, "duzentos"], + ]; + const failures: Array<{ value: number; actual: string; expected: string }> = []; + + for (const [value, expected] of cases) { + const actual = convertNumberToWords(value); + if (actual !== expected) failures.push({ value, actual, expected }); + } + + expect(failures).toEqual([]); + }); + + test("should match a hand-written word for every round hundred and the hundred that follows it", () => { + const cases: Array<[number, string]> = [ + [100, "cem"], + [101, "cento e um"], + [200, "duzentos"], + [201, "duzentos e um"], + [300, "trezentos"], + [301, "trezentos e um"], + [400, "quatrocentos"], + [401, "quatrocentos e um"], + [500, "quinhentos"], + [501, "quinhentos e um"], + [600, "seiscentos"], + [601, "seiscentos e um"], + [700, "setecentos"], + [701, "setecentos e um"], + [800, "oitocentos"], + [801, "oitocentos e um"], + [900, "novecentos"], + [901, "novecentos e um"], + [999, "novecentos e noventa e nove"], + ]; + const failures: Array<{ value: number; actual: string; expected: string }> = []; + + for (const [value, expected] of cases) { + const actual = convertNumberToWords(value); + if (actual !== expected) failures.push({ value, actual, expected }); + } + + expect(failures).toEqual([]); + }); + + test("should match a hand-written word at every ten/hundred/thousand/scale boundary", () => { + const cases: Array<[number, string]> = [ + [999, "novecentos e noventa e nove"], + [1000, "mil"], + [1001, "mil e um"], + [1021, "mil e vinte e um"], + [1100, "mil e cem"], + [1101, "mil, cento e um"], + [1200, "mil e duzentos"], + [1235, "mil, duzentos e trinta e cinco"], + [1999, "mil, novecentos e noventa e nove"], + [2000, "dois mil"], + [2001, "dois mil e um"], + [5000, "cinco mil"], + [9999, "nove mil, novecentos e noventa e nove"], + [10000, "dez mil"], + [21000, "vinte e um mil"], + [100000, "cem mil"], + [101000, "cento e um mil"], + [200000, "duzentos mil"], + [300000, "trezentos mil"], + [999999, "novecentos e noventa e nove mil, novecentos e noventa e nove"], + [1000000, "um milhão"], + [1000001, "um milhão e um"], + [1000100, "um milhão e cem"], + [1000230, "um milhão, duzentos e trinta"], + [1045678, "um milhão, quarenta e cinco mil, seiscentos e setenta e oito"], + [1100000, "um milhão e cem mil"], + [1200000, "um milhão e duzentos mil"], + [1230000, "um milhão, duzentos e trinta mil"], + [1230045, "um milhão, duzentos e trinta mil e quarenta e cinco"], + [1230456, "um milhão, duzentos e trinta mil, quatrocentos e cinquenta e seis"], + [2000000, "dois milhões"], + [1000000000, "um bilhão"], + [1000000001, "um bilhão e um"], + [2000000000, "dois bilhões"], + [ + 1234567890, + "um bilhão, duzentos e trinta e quatro milhões, quinhentos e sessenta e sete mil, oitocentos e noventa", + ], + [ + 999999999999, + "novecentos e noventa e nove bilhões, novecentos e noventa e nove milhões, novecentos e noventa e nove mil, novecentos e noventa e nove", + ], + [1000000000000, "um trilhão"], + [2000000000000, "dois trilhões"], + [ + 999999999999999, + "novecentos e noventa e nove trilhões, novecentos e noventa e nove bilhões, novecentos e noventa e nove milhões, novecentos e noventa e nove mil, novecentos e noventa e nove", + ], + ]; + const failures: Array<{ value: number; actual: string; expected: string }> = []; + + for (const [value, expected] of cases) { + const actual = convertNumberToWords(value); + if (actual !== expected) failures.push({ value, actual, expected }); + } + + expect(failures).toEqual([]); + }); + + test("should prefix 'menos' to a hand-written word for every integer from -1 to -100", () => { + const cases: Array<[number, string]> = [ + [-1, "menos um"], + [-2, "menos dois"], + [-3, "menos três"], + [-4, "menos quatro"], + [-5, "menos cinco"], + [-6, "menos seis"], + [-7, "menos sete"], + [-8, "menos oito"], + [-9, "menos nove"], + [-10, "menos dez"], + [-11, "menos onze"], + [-12, "menos doze"], + [-13, "menos treze"], + [-14, "menos catorze"], + [-15, "menos quinze"], + [-16, "menos dezesseis"], + [-17, "menos dezessete"], + [-18, "menos dezoito"], + [-19, "menos dezenove"], + [-20, "menos vinte"], + [-21, "menos vinte e um"], + [-22, "menos vinte e dois"], + [-23, "menos vinte e três"], + [-24, "menos vinte e quatro"], + [-25, "menos vinte e cinco"], + [-26, "menos vinte e seis"], + [-27, "menos vinte e sete"], + [-28, "menos vinte e oito"], + [-29, "menos vinte e nove"], + [-30, "menos trinta"], + [-31, "menos trinta e um"], + [-32, "menos trinta e dois"], + [-33, "menos trinta e três"], + [-34, "menos trinta e quatro"], + [-35, "menos trinta e cinco"], + [-36, "menos trinta e seis"], + [-37, "menos trinta e sete"], + [-38, "menos trinta e oito"], + [-39, "menos trinta e nove"], + [-40, "menos quarenta"], + [-41, "menos quarenta e um"], + [-42, "menos quarenta e dois"], + [-43, "menos quarenta e três"], + [-44, "menos quarenta e quatro"], + [-45, "menos quarenta e cinco"], + [-46, "menos quarenta e seis"], + [-47, "menos quarenta e sete"], + [-48, "menos quarenta e oito"], + [-49, "menos quarenta e nove"], + [-50, "menos cinquenta"], + [-51, "menos cinquenta e um"], + [-52, "menos cinquenta e dois"], + [-53, "menos cinquenta e três"], + [-54, "menos cinquenta e quatro"], + [-55, "menos cinquenta e cinco"], + [-56, "menos cinquenta e seis"], + [-57, "menos cinquenta e sete"], + [-58, "menos cinquenta e oito"], + [-59, "menos cinquenta e nove"], + [-60, "menos sessenta"], + [-61, "menos sessenta e um"], + [-62, "menos sessenta e dois"], + [-63, "menos sessenta e três"], + [-64, "menos sessenta e quatro"], + [-65, "menos sessenta e cinco"], + [-66, "menos sessenta e seis"], + [-67, "menos sessenta e sete"], + [-68, "menos sessenta e oito"], + [-69, "menos sessenta e nove"], + [-70, "menos setenta"], + [-71, "menos setenta e um"], + [-72, "menos setenta e dois"], + [-73, "menos setenta e três"], + [-74, "menos setenta e quatro"], + [-75, "menos setenta e cinco"], + [-76, "menos setenta e seis"], + [-77, "menos setenta e sete"], + [-78, "menos setenta e oito"], + [-79, "menos setenta e nove"], + [-80, "menos oitenta"], + [-81, "menos oitenta e um"], + [-82, "menos oitenta e dois"], + [-83, "menos oitenta e três"], + [-84, "menos oitenta e quatro"], + [-85, "menos oitenta e cinco"], + [-86, "menos oitenta e seis"], + [-87, "menos oitenta e sete"], + [-88, "menos oitenta e oito"], + [-89, "menos oitenta e nove"], + [-90, "menos noventa"], + [-91, "menos noventa e um"], + [-92, "menos noventa e dois"], + [-93, "menos noventa e três"], + [-94, "menos noventa e quatro"], + [-95, "menos noventa e cinco"], + [-96, "menos noventa e seis"], + [-97, "menos noventa e sete"], + [-98, "menos noventa e oito"], + [-99, "menos noventa e nove"], + [-100, "menos cem"], + ]; + const failures: Array<{ value: number; actual: string; expected: string }> = []; + + for (const [value, expected] of cases) { + const actual = convertNumberToWords(value); + if (actual !== expected) failures.push({ value, actual, expected }); + } + + expect(failures).toEqual([]); + }); + + test("should prefix 'menos' to a hand-written word at negative scale boundaries", () => { + const cases: Array<[number, string]> = [ + [-200, "menos duzentos"], + [-999, "menos novecentos e noventa e nove"], + [-1000, "menos mil"], + [-1001, "menos mil e um"], + [-2000, "menos dois mil"], + [-1000000, "menos um milhão"], + [ + -999999999999999, + "menos novecentos e noventa e nove trilhões, novecentos e noventa e nove bilhões, novecentos e noventa e nove milhões, novecentos e noventa e nove mil, novecentos e noventa e nove", + ], + ]; + const failures: Array<{ value: number; actual: string; expected: string }> = []; + + for (const [value, expected] of cases) { + const actual = convertNumberToWords(value); + if (actual !== expected) failures.push({ value, actual, expected }); + } + + expect(failures).toEqual([]); + }); + + test("should match a hand-written feminine word for every integer from 0 to 30", () => { + const cases: Array<[number, string]> = [ + [0, "zero"], + [1, "uma"], + [2, "duas"], + [3, "três"], + [4, "quatro"], + [5, "cinco"], + [6, "seis"], + [7, "sete"], + [8, "oito"], + [9, "nove"], + [10, "dez"], + [11, "onze"], + [12, "doze"], + [13, "treze"], + [14, "catorze"], + [15, "quinze"], + [16, "dezesseis"], + [17, "dezessete"], + [18, "dezoito"], + [19, "dezenove"], + [20, "vinte"], + [21, "vinte e uma"], + [22, "vinte e duas"], + [23, "vinte e três"], + [24, "vinte e quatro"], + [25, "vinte e cinco"], + [26, "vinte e seis"], + [27, "vinte e sete"], + [28, "vinte e oito"], + [29, "vinte e nove"], + [30, "trinta"], + ]; + const failures: Array<{ value: number; actual: string; expected: string }> = []; + + for (const [value, expected] of cases) { + const actual = convertNumberToWords(value, { gender: "feminine" }); + if (actual !== expected) failures.push({ value, actual, expected }); + } + + expect(failures).toEqual([]); + }); + + test("should match a hand-written feminine word at hundred/thousand/million boundaries", () => { + const cases: Array<[number, string]> = [ + [100, "cem"], + [101, "cento e uma"], + [200, "duzentas"], + [201, "duzentas e uma"], + [300, "trezentas"], + [400, "quatrocentas"], + [500, "quinhentas"], + [600, "seiscentas"], + [700, "setecentas"], + [800, "oitocentas"], + [900, "novecentas"], + [1000, "mil"], + [1001, "mil e uma"], + [1100, "mil e cem"], + [1101, "mil, cento e uma"], + [2000, "duas mil"], + [2002, "duas mil e duas"], + [3000, "três mil"], + [21000, "vinte e uma mil"], + [100000, "cem mil"], + [200000, "duzentas mil"], + [300000, "trezentas mil"], + [1000000, "um milhão"], + [1000001, "um milhão e uma"], + [2000000, "dois milhões"], + [2000002, "dois milhões e duas"], + ]; + const failures: Array<{ value: number; actual: string; expected: string }> = []; + + for (const [value, expected] of cases) { + const actual = convertNumberToWords(value, { gender: "feminine" }); + if (actual !== expected) failures.push({ value, actual, expected }); + } + + expect(failures).toEqual([]); + }); + }); +}); diff --git a/src/convert-number-to-words/convert-number-to-words.ts b/src/convert-number-to-words/convert-number-to-words.ts new file mode 100644 index 00000000..3117772c --- /dev/null +++ b/src/convert-number-to-words/convert-number-to-words.ts @@ -0,0 +1,60 @@ +import { applyWordsCase } from "../_internals/apply-words-case/apply-words-case"; +import { + NUMBER_TO_WORDS_MAX_VALUE, + type NumberToWordsGender, + numberToWords, + type WordsCase, +} from "../_internals/number-to-words/number-to-words"; + +export type ConvertNumberToWordsOptions = { + /** Grammatical gender used to agree "um/dois" and the hundreds group ("duzentos/duzentas", etc.) with the noun the number qualifies. Defaults to `"masculine"`. */ + gender?: NumberToWordsGender; + /** Letter case applied to the result: `"lower"` (unchanged), `"sentence"` (capitalizes only the first letter) or `"upper"` (uppercases everything, keeping accents). Defaults to `"lower"`; an invalid value is ignored and `"lower"` is used instead. */ + case?: WordsCase; +}; + +/** + * Formats an integer as its Brazilian Portuguese cardinal number words ("por extenso"), + * e.g. `1235` becomes `"mil, duzentos e trinta e cinco"`. + * + * Only integers from `-999999999999999` to `999999999999999` (999 trillion in absolute value, + * the highest value expressible with the "trilhão" scale word) are supported; anything outside + * that range, `NaN` or a non-finite value (`Infinity`/`-Infinity`) returns `""`. A non-integer + * `value` is truncated toward zero before conversion (`12.9` behaves like `12`); this function + * only writes out whole numbers, it never spells out a decimal part (use + * `convertCurrencyToWords` for a monetary amount with cents). + * + * @param {number} value - The integer to convert. + * @param {ConvertNumberToWordsOptions} [options] - Optional formatting options. + * @param {NumberToWordsGender} [options.gender] - Grammatical gender for "um/dois" and the hundreds group. Defaults to `"masculine"`. + * @param {WordsCase} [options.case] - Letter case applied to the result. Defaults to `"lower"`. + * @returns {string} The cardinal number written out in Portuguese, or `""` for invalid input. + * + * @example + * ```typescript + * convertNumberToWords(123); // "cento e vinte e três" + * convertNumberToWords(1001); // "mil e um" + * convertNumberToWords(2000000); // "dois milhões" + * convertNumberToWords(-42); // "menos quarenta e dois" + * convertNumberToWords(2, { gender: "feminine" }); // "duas" + * convertNumberToWords(3, { case: "upper" }); // "TRÊS" + * convertNumberToWords(NaN); // "" + * ``` + * + * @see https://github.com/brazilian-utils/python/blob/main/brutils/currency.py + */ +export const convertNumberToWords = ( + value: number, + options?: ConvertNumberToWordsOptions, +): string => { + if (typeof value !== "number" || !Number.isFinite(value)) return ""; + + const truncated = Math.trunc(value); + + if (Math.abs(truncated) > NUMBER_TO_WORDS_MAX_VALUE) return ""; + + const words = numberToWords(Math.abs(truncated), { gender: options?.gender }); + const result = truncated < 0 ? `menos ${words}` : words; + + return applyWordsCase(result, options?.case); +}; From e24e4461d1ca8503cd4f7ee63f967c7d575f8219 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:32:09 -0300 Subject: [PATCH 07/10] feat(currency-to-words): add convertCurrencyToWords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spells a BRL amount out in Portuguese, e.g. convertCurrencyToWords(1523.45) -> "mil, quinhentos e vinte e três reais e quarenta e cinco centavos". --- .../convert-currency-to-words.test.ts | 404 ++++++++++++++++++ .../convert-currency-to-words.ts | 86 ++++ 2 files changed, 490 insertions(+) create mode 100644 src/convert-currency-to-words/convert-currency-to-words.test.ts create mode 100644 src/convert-currency-to-words/convert-currency-to-words.ts diff --git a/src/convert-currency-to-words/convert-currency-to-words.test.ts b/src/convert-currency-to-words/convert-currency-to-words.test.ts new file mode 100644 index 00000000..d05aa89a --- /dev/null +++ b/src/convert-currency-to-words/convert-currency-to-words.test.ts @@ -0,0 +1,404 @@ +import { NUMBER_TO_WORDS_MAX_VALUE } from "../_internals/number-to-words/number-to-words"; +import { describe, expect, test } from "../_internals/test/runtime"; +import { convertCurrencyToWords } from "./convert-currency-to-words"; + +describe("convertCurrencyToWords", () => { + test("should return 'zero reais' for 0", () => { + expect(convertCurrencyToWords(0)).toBe("zero reais"); + }); + + test("should return 'um centavo' for 0.01", () => { + expect(convertCurrencyToWords(0.01)).toBe("um centavo"); + }); + + test("should return 'um real' for 1.00", () => { + expect(convertCurrencyToWords(1.0)).toBe("um real"); + }); + + test("should return 'um real e um centavo' for 1.01", () => { + expect(convertCurrencyToWords(1.01)).toBe("um real e um centavo"); + }); + + test("should insert 'de' before 'reais' for a round million (1000000.00, brutils 'convert_real_to_text')", () => { + expect(convertCurrencyToWords(1000000.0)).toBe("um milhão de reais"); + }); + + test("should pluralize the 'de' connector for two round million (2000000.00)", () => { + expect(convertCurrencyToWords(2000000.0)).toBe("dois milhões de reais"); + }); + + test("should join reais and centavos with 'e' (1523.45, brutils 'convert_real_to_text' example)", () => { + expect(convertCurrencyToWords(1523.45)).toBe( + "mil, quinhentos e vinte e três reais e quarenta e cinco centavos", + ); + }); + + test("should not insert 'de' when a mil/hundred group follows the million group", () => { + expect(convertCurrencyToWords(1000230.0)).toBe("um milhão, duzentos e trinta reais"); + }); + + test("should return only the centavos when the reais part is zero", () => { + expect(convertCurrencyToWords(0.5)).toBe("cinquenta centavos"); + }); + + test("should return only the reais when the centavos part is zero", () => { + expect(convertCurrencyToWords(100.0)).toBe("cem reais"); + }); + + test("should truncate (not round) to 2 decimal places", () => { + expect(convertCurrencyToWords(1.999)).toBe("um real e noventa e nove centavos"); + }); + + test("should prefix negative amounts with 'menos'", () => { + expect(convertCurrencyToWords(-5.5)).toBe("menos cinco reais e cinquenta centavos"); + expect(convertCurrencyToWords(-0.01)).toBe("menos um centavo"); + }); + + test("should return '' when the reais part exceeds the maximum supported value", () => { + expect(convertCurrencyToWords(NUMBER_TO_WORDS_MAX_VALUE + 1)).toBe(""); + }); + + describe("invalid input", () => { + test("should return '' for NaN", () => { + expect(convertCurrencyToWords(Number.NaN)).toBe(""); + }); + + test("should return '' for Infinity and -Infinity", () => { + expect(convertCurrencyToWords(Number.POSITIVE_INFINITY)).toBe(""); + expect(convertCurrencyToWords(Number.NEGATIVE_INFINITY)).toBe(""); + }); + + test("should return '' for a non-number value", () => { + // @ts-expect-error + expect(convertCurrencyToWords("1523.45")).toBe(""); + // @ts-expect-error + expect(convertCurrencyToWords(null)).toBe(""); + // @ts-expect-error + expect(convertCurrencyToWords(undefined)).toBe(""); + }); + }); + + describe("zero amounts", () => { + test("should not prefix 'menos' when a negative amount truncates to nothing", () => { + expect(convertCurrencyToWords(-0.001)).toBe("zero reais"); + expect(convertCurrencyToWords(-0.009)).toBe("zero reais"); + }); + + test("should return 'zero reais' for negative zero", () => { + expect(convertCurrencyToWords(-0)).toBe("zero reais"); + }); + + test("should return 'zero reais' for an amount below one centavo", () => { + expect(convertCurrencyToWords(0.004)).toBe("zero reais"); + }); + }); + + describe("amounts too large to carry cents", () => { + test("should read an amount above Number.MAX_SAFE_INTEGER cents as whole reais", () => { + expect(convertCurrencyToWords(100_000_000_000_000.02)).toBe("cem trilhões de reais"); + }); + + test("should still report cents just below that limit", () => { + expect(convertCurrencyToWords(9_007_199_254_740.99)).toContain("noventa e nove centavos"); + }); + }); + + describe("case option", () => { + test("should keep the result lowercase by default", () => { + expect(convertCurrencyToWords(1000)).toBe("mil reais"); + }); + + test("should keep the result lowercase for 'lower'", () => { + expect(convertCurrencyToWords(1000, { case: "lower" })).toBe("mil reais"); + }); + + test("should capitalize only the first letter for 'sentence'", () => { + expect(convertCurrencyToWords(1000, { case: "sentence" })).toBe("Mil reais"); + expect(convertCurrencyToWords(0, { case: "sentence" })).toBe("Zero reais"); + }); + + test("should uppercase everything for 'upper', keeping accents", () => { + expect(convertCurrencyToWords(1000, { case: "upper" })).toBe("MIL REAIS"); + expect(convertCurrencyToWords(1523.45, { case: "upper" })).toBe( + "MIL, QUINHENTOS E VINTE E TRÊS REAIS E QUARENTA E CINCO CENTAVOS", + ); + expect(convertCurrencyToWords(-5.5, { case: "upper" })).toBe( + "MENOS CINCO REAIS E CINQUENTA CENTAVOS", + ); + }); + + test("should ignore an invalid case value and fall back to 'lower'", () => { + // @ts-expect-error + expect(convertCurrencyToWords(1000, { case: "invalid" })).toBe("mil reais"); + }); + }); + + describe("literal case tables", () => { + test("should match a hand-written string for every amount from R$ 0.00 to R$ 1.49, cent by cent", () => { + const cases: Array<[number, string]> = [ + [0, "zero reais"], + [1, "um centavo"], + [2, "dois centavos"], + [3, "três centavos"], + [4, "quatro centavos"], + [5, "cinco centavos"], + [6, "seis centavos"], + [7, "sete centavos"], + [8, "oito centavos"], + [9, "nove centavos"], + [10, "dez centavos"], + [11, "onze centavos"], + [12, "doze centavos"], + [13, "treze centavos"], + [14, "catorze centavos"], + [15, "quinze centavos"], + [16, "dezesseis centavos"], + [17, "dezessete centavos"], + [18, "dezoito centavos"], + [19, "dezenove centavos"], + [20, "vinte centavos"], + [21, "vinte e um centavos"], + [22, "vinte e dois centavos"], + [23, "vinte e três centavos"], + [24, "vinte e quatro centavos"], + [25, "vinte e cinco centavos"], + [26, "vinte e seis centavos"], + [27, "vinte e sete centavos"], + [28, "vinte e oito centavos"], + [29, "vinte e nove centavos"], + [30, "trinta centavos"], + [31, "trinta e um centavos"], + [32, "trinta e dois centavos"], + [33, "trinta e três centavos"], + [34, "trinta e quatro centavos"], + [35, "trinta e cinco centavos"], + [36, "trinta e seis centavos"], + [37, "trinta e sete centavos"], + [38, "trinta e oito centavos"], + [39, "trinta e nove centavos"], + [40, "quarenta centavos"], + [41, "quarenta e um centavos"], + [42, "quarenta e dois centavos"], + [43, "quarenta e três centavos"], + [44, "quarenta e quatro centavos"], + [45, "quarenta e cinco centavos"], + [46, "quarenta e seis centavos"], + [47, "quarenta e sete centavos"], + [48, "quarenta e oito centavos"], + [49, "quarenta e nove centavos"], + [50, "cinquenta centavos"], + [51, "cinquenta e um centavos"], + [52, "cinquenta e dois centavos"], + [53, "cinquenta e três centavos"], + [54, "cinquenta e quatro centavos"], + [55, "cinquenta e cinco centavos"], + [56, "cinquenta e seis centavos"], + [57, "cinquenta e sete centavos"], + [58, "cinquenta e oito centavos"], + [59, "cinquenta e nove centavos"], + [60, "sessenta centavos"], + [61, "sessenta e um centavos"], + [62, "sessenta e dois centavos"], + [63, "sessenta e três centavos"], + [64, "sessenta e quatro centavos"], + [65, "sessenta e cinco centavos"], + [66, "sessenta e seis centavos"], + [67, "sessenta e sete centavos"], + [68, "sessenta e oito centavos"], + [69, "sessenta e nove centavos"], + [70, "setenta centavos"], + [71, "setenta e um centavos"], + [72, "setenta e dois centavos"], + [73, "setenta e três centavos"], + [74, "setenta e quatro centavos"], + [75, "setenta e cinco centavos"], + [76, "setenta e seis centavos"], + [77, "setenta e sete centavos"], + [78, "setenta e oito centavos"], + [79, "setenta e nove centavos"], + [80, "oitenta centavos"], + [81, "oitenta e um centavos"], + [82, "oitenta e dois centavos"], + [83, "oitenta e três centavos"], + [84, "oitenta e quatro centavos"], + [85, "oitenta e cinco centavos"], + [86, "oitenta e seis centavos"], + [87, "oitenta e sete centavos"], + [88, "oitenta e oito centavos"], + [89, "oitenta e nove centavos"], + [90, "noventa centavos"], + [91, "noventa e um centavos"], + [92, "noventa e dois centavos"], + [93, "noventa e três centavos"], + [94, "noventa e quatro centavos"], + [95, "noventa e cinco centavos"], + [96, "noventa e seis centavos"], + [97, "noventa e sete centavos"], + [98, "noventa e oito centavos"], + [99, "noventa e nove centavos"], + [100, "um real"], + [101, "um real e um centavo"], + [102, "um real e dois centavos"], + [103, "um real e três centavos"], + [104, "um real e quatro centavos"], + [105, "um real e cinco centavos"], + [106, "um real e seis centavos"], + [107, "um real e sete centavos"], + [108, "um real e oito centavos"], + [109, "um real e nove centavos"], + [110, "um real e dez centavos"], + [111, "um real e onze centavos"], + [112, "um real e doze centavos"], + [113, "um real e treze centavos"], + [114, "um real e catorze centavos"], + [115, "um real e quinze centavos"], + [116, "um real e dezesseis centavos"], + [117, "um real e dezessete centavos"], + [118, "um real e dezoito centavos"], + [119, "um real e dezenove centavos"], + [120, "um real e vinte centavos"], + [121, "um real e vinte e um centavos"], + [122, "um real e vinte e dois centavos"], + [123, "um real e vinte e três centavos"], + [124, "um real e vinte e quatro centavos"], + [125, "um real e vinte e cinco centavos"], + [126, "um real e vinte e seis centavos"], + [127, "um real e vinte e sete centavos"], + [128, "um real e vinte e oito centavos"], + [129, "um real e vinte e nove centavos"], + [130, "um real e trinta centavos"], + [131, "um real e trinta e um centavos"], + [132, "um real e trinta e dois centavos"], + [133, "um real e trinta e três centavos"], + [134, "um real e trinta e quatro centavos"], + [135, "um real e trinta e cinco centavos"], + [136, "um real e trinta e seis centavos"], + [137, "um real e trinta e sete centavos"], + [138, "um real e trinta e oito centavos"], + [139, "um real e trinta e nove centavos"], + [140, "um real e quarenta centavos"], + [141, "um real e quarenta e um centavos"], + [142, "um real e quarenta e dois centavos"], + [143, "um real e quarenta e três centavos"], + [144, "um real e quarenta e quatro centavos"], + [145, "um real e quarenta e cinco centavos"], + [146, "um real e quarenta e seis centavos"], + [147, "um real e quarenta e sete centavos"], + [148, "um real e quarenta e oito centavos"], + [149, "um real e quarenta e nove centavos"], + ]; + const failures: Array<{ cents: number; actual: string; expected: string }> = []; + + for (const [cents, expected] of cases) { + const actual = convertCurrencyToWords(cents / 100); + if (actual !== expected) failures.push({ cents, actual, expected }); + } + + expect(failures).toEqual([]); + }); + + test("should match a hand-written string at reais boundaries, scale words and truncation cases", () => { + const cases: Array<[number, string]> = [ + [1000, "mil reais"], + [1000.01, "mil reais e um centavo"], + [1101, "mil, cento e um reais"], + [1101.01, "mil, cento e um reais e um centavo"], + [1523.45, "mil, quinhentos e vinte e três reais e quarenta e cinco centavos"], + [1000000, "um milhão de reais"], + [1000000.01, "um milhão de reais e um centavo"], + [2000000, "dois milhões de reais"], + [1000001, "um milhão e um reais"], + [ + 999999999999999, + "novecentos e noventa e nove trilhões, novecentos e noventa e nove bilhões, novecentos e noventa e nove milhões, novecentos e noventa e nove mil, novecentos e noventa e nove reais", + ], + [1.999, "um real e noventa e nove centavos"], + [100.5, "cem reais e cinquenta centavos"], + [2, "dois reais"], + [10.5, "dez reais e cinquenta centavos"], + [999999, "novecentos e noventa e nove mil, novecentos e noventa e nove reais"], + [100, "cem reais"], + [1000000000, "um bilhão de reais"], + [2000000000, "dois bilhões de reais"], + [1000000000000, "um trilhão de reais"], + [2000000000000, "dois trilhões de reais"], + ]; + const failures: Array<{ value: number; actual: string; expected: string }> = []; + + for (const [value, expected] of cases) { + const actual = convertCurrencyToWords(value); + if (actual !== expected) failures.push({ value, actual, expected }); + } + + expect(failures).toEqual([]); + }); + + test("should reproduce every published brutils 'convert_real_to_text' example (tests/test_currency.py, lowercase here because brutils capitalizes and this library leaves casing to the caller)", () => { + const cases: Array<[number, string]> = [ + [0, "zero reais"], + [0.01, "um centavo"], + [0.5, "cinquenta centavos"], + [1, "um real"], + [-50.25, "menos cinquenta reais e vinte e cinco centavos"], + [1523.45, "mil, quinhentos e vinte e três reais e quarenta e cinco centavos"], + [1000000, "um milhão de reais"], + [2000000, "dois milhões de reais"], + [1000000000, "um bilhão de reais"], + [2000000000, "dois bilhões de reais"], + [1000000000000, "um trilhão de reais"], + [2000000000000, "dois trilhões de reais"], + [1000000.45, "um milhão de reais e quarenta e cinco centavos"], + [2000000000.99, "dois bilhões de reais e noventa e nove centavos"], + [ + 1234567890.5, + "um bilhão, duzentos e trinta e quatro milhões, quinhentos e sessenta e sete mil, oitocentos e noventa reais e cinquenta centavos", + ], + [0.001, "zero reais"], + [0.009, "zero reais"], + [-1000000, "menos um milhão de reais"], + [-2000000.5, "menos dois milhões de reais e cinquenta centavos"], + [1000000000.01, "um bilhão de reais e um centavo"], + [1000000000.99, "um bilhão de reais e noventa e nove centavos"], + [ + 999999999999.99, + "novecentos e noventa e nove bilhões, novecentos e noventa e nove milhões, novecentos e noventa e nove mil, novecentos e noventa e nove reais e noventa e nove centavos", + ], + [1000000000000.01, "um trilhão de reais e um centavo"], + [1000000000000.99, "um trilhão de reais e noventa e nove centavos"], + [ + 9999999999999.99, + "nove trilhões, novecentos e noventa e nove bilhões, novecentos e noventa e nove milhões, novecentos e noventa e nove mil, novecentos e noventa e nove reais e noventa e nove centavos", + ], + ]; + const failures: Array<{ value: number; actual: string; expected: string }> = []; + + for (const [value, expected] of cases) { + const actual = convertCurrencyToWords(value); + if (actual !== expected) failures.push({ value, actual, expected }); + } + + expect(failures).toEqual([]); + }); + + test("should prefix 'menos' to a hand-written string for negative amounts", () => { + const cases: Array<[number, string]> = [ + [-0.01, "menos um centavo"], + [-1, "menos um real"], + [-1.5, "menos um real e cinquenta centavos"], + [-5.5, "menos cinco reais e cinquenta centavos"], + [-100, "menos cem reais"], + [-1000000, "menos um milhão de reais"], + [-0.001, "zero reais"], + [-0.009, "zero reais"], + ]; + const failures: Array<{ value: number; actual: string; expected: string }> = []; + + for (const [value, expected] of cases) { + const actual = convertCurrencyToWords(value); + if (actual !== expected) failures.push({ value, actual, expected }); + } + + expect(failures).toEqual([]); + }); + }); +}); diff --git a/src/convert-currency-to-words/convert-currency-to-words.ts b/src/convert-currency-to-words/convert-currency-to-words.ts new file mode 100644 index 00000000..ee5954d6 --- /dev/null +++ b/src/convert-currency-to-words/convert-currency-to-words.ts @@ -0,0 +1,86 @@ +import { applyWordsCase } from "../_internals/apply-words-case/apply-words-case"; +import { + NUMBER_TO_WORDS_MAX_VALUE, + numberToWords, + type WordsCase, +} from "../_internals/number-to-words/number-to-words"; + +export type ConvertCurrencyToWordsOptions = { + /** Letter case applied to the result: `"lower"` (unchanged), `"sentence"` (capitalizes only the first letter) or `"upper"` (uppercases everything, keeping accents). Defaults to `"lower"`; an invalid value is ignored and `"lower"` is used instead. */ + case?: WordsCase; +}; + +const MILLION_SCALE_SUFFIXES = ["lhão", "lhões"]; + +const endsInMillionScale = (words: string): boolean => + MILLION_SCALE_SUFFIXES.some((suffix) => words.endsWith(suffix)); + +/** + * Formats a monetary amount in Brazilian Reais as its "por extenso" textual representation, + * the style used to write out the amount by hand on cheques and contracts, e.g. `1523.45` + * becomes `"mil, quinhentos e vinte e três reais e quarenta e cinco centavos"`. + * + * `value` is truncated (not rounded) to 2 decimal places before conversion, matching + * `brutils`' `convert_real_to_text`. The singular noun is used for exactly 1 ("um real", + * "um centavo") and "de" is inserted before "reais" when the amount is a round million, + * billion or trillion of reais ("um milhão de reais", "dois milhões de reais"). An amount that + * truncates to nothing becomes `"zero reais"`, with no "menos" prefix even when `value` is + * negative (`-0.001` is not a debt of anything); any other negative amount is prefixed with + * "menos". `NaN`/non-finite values and amounts whose reais exceed `NUMBER_TO_WORDS_MAX_VALUE` + * (999 trillion) return `""`. Above `Number.MAX_SAFE_INTEGER / 100` reais (about 90 trillion) a + * double cannot carry cents at all, so the amount is read as a whole number of reais instead of + * reporting cents that the input never held. + * + * @param {number} value - The monetary amount to convert, in reais (e.g. `1523.45` for R$ 1.523,45). + * @param {ConvertCurrencyToWordsOptions} [options] - Optional formatting options. + * @param {WordsCase} [options.case] - Letter case applied to the result. Defaults to `"lower"`. + * @returns {string} The amount written out in Portuguese, or `""` for invalid input. + * + * @example + * ```typescript + * convertCurrencyToWords(1523.45); // "mil, quinhentos e vinte e três reais e quarenta e cinco centavos" + * convertCurrencyToWords(1); // "um real" + * convertCurrencyToWords(0.01); // "um centavo" + * convertCurrencyToWords(1000000); // "um milhão de reais" + * convertCurrencyToWords(0); // "zero reais" + * convertCurrencyToWords(-5.5); // "menos cinco reais e cinquenta centavos" + * convertCurrencyToWords(1000, { case: "upper" }); // "MIL REAIS" + * ``` + * + * @see https://github.com/brazilian-utils/python/blob/main/brutils/currency.py + */ +export const convertCurrencyToWords = ( + value: number, + options?: ConvertCurrencyToWordsOptions, +): string => { + if (typeof value !== "number" || !Number.isFinite(value)) return ""; + + const absolute = Math.abs(value); + const hasExactCents = absolute * 100 <= Number.MAX_SAFE_INTEGER; + const totalCents = hasExactCents ? Math.trunc(Number((absolute * 100).toFixed(6))) : 0; + + const reais = hasExactCents ? Math.floor(totalCents / 100) : Math.trunc(absolute); + const centavos = hasExactCents ? totalCents % 100 : 0; + + if (reais > NUMBER_TO_WORDS_MAX_VALUE) return ""; + + const parts: string[] = []; + + if (reais > 0) { + const reaisWords = numberToWords(reais); + const connector = endsInMillionScale(reaisWords) ? "de " : ""; + parts.push(`${reaisWords} ${connector}${reais === 1 ? "real" : "reais"}`); + } + + if (centavos > 0) { + const centavosText = `${numberToWords(centavos)} ${centavos === 1 ? "centavo" : "centavos"}`; + parts.push(reais > 0 ? `e ${centavosText}` : centavosText); + } + + if (reais === 0 && centavos === 0) return applyWordsCase("zero reais", options?.case); + + const joined = parts.join(" "); + const result = value < 0 ? `menos ${joined}` : joined; + + return applyWordsCase(result, options?.case); +}; From b9b3a6397b663c2e9f1de880d30b7e42890a350e Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:32:09 -0300 Subject: [PATCH 08/10] feat(date-to-words): add convertDateToWords Spells a date out in Portuguese, e.g. convertDateToWords("2024-01-01") -> "primeiro de janeiro de dois mil e vinte e quatro". --- .../convert-date-to-words.test.ts | 433 ++++++++++++++++++ .../convert-date-to-words.ts | 122 +++++ 2 files changed, 555 insertions(+) create mode 100644 src/convert-date-to-words/convert-date-to-words.test.ts create mode 100644 src/convert-date-to-words/convert-date-to-words.ts diff --git a/src/convert-date-to-words/convert-date-to-words.test.ts b/src/convert-date-to-words/convert-date-to-words.test.ts new file mode 100644 index 00000000..e32281ac --- /dev/null +++ b/src/convert-date-to-words/convert-date-to-words.test.ts @@ -0,0 +1,433 @@ +import { MONTH_NAMES, WEEKDAY_NAMES } from "../_internals/constants/number-words"; +import { describe, expect, test } from "../_internals/test/runtime"; +import { convertDateToWords } from "./convert-date-to-words"; + +describe("convertDateToWords", () => { + test("should return 'primeiro' for day 1", () => { + expect(convertDateToWords("01/01/2024")).toBe( + "primeiro de janeiro de dois mil e vinte e quatro", + ); + }); + + test("should return the cardinal number for day 2", () => { + expect(convertDateToWords("02/01/2024")).toBe("dois de janeiro de dois mil e vinte e quatro"); + }); + + test("should accept a 'dd/mm/yyyy' string", () => { + expect(convertDateToWords("25/12/2024")).toBe( + "vinte e cinco de dezembro de dois mil e vinte e quatro", + ); + }); + + test("should accept an ISO 'yyyy-mm-dd' string", () => { + expect(convertDateToWords("2024-12-25")).toBe( + "vinte e cinco de dezembro de dois mil e vinte e quatro", + ); + }); + + test("should accept a Date read by its local calendar date", () => { + expect(convertDateToWords(new Date(2024, 0, 1))).toBe( + "primeiro de janeiro de dois mil e vinte e quatro", + ); + expect(convertDateToWords(new Date(2024, 11, 25))).toBe( + "vinte e cinco de dezembro de dois mil e vinte e quatro", + ); + }); + + test("should reject February 29th on a non-leap year", () => { + expect(convertDateToWords("29/02/2023")).toBe(""); + }); + + test("should reject a day that does not exist in the given month", () => { + expect(convertDateToWords("31/04/2024")).toBe(""); + }); + + test("should reject an out of range month", () => { + expect(convertDateToWords("15/13/2024")).toBe(""); + expect(convertDateToWords("15/00/2024")).toBe(""); + }); + + test("should reject an out of range day", () => { + expect(convertDateToWords("00/01/2024")).toBe(""); + expect(convertDateToWords("32/01/2024")).toBe(""); + }); + + describe("case option", () => { + test("should keep the result lowercase by default", () => { + expect(convertDateToWords("01/01/2024")).toBe( + "primeiro de janeiro de dois mil e vinte e quatro", + ); + }); + + test("should keep the result lowercase for 'lower'", () => { + expect(convertDateToWords("01/01/2024", { case: "lower" })).toBe( + "primeiro de janeiro de dois mil e vinte e quatro", + ); + }); + + test("should capitalize only the first letter for 'sentence'", () => { + expect(convertDateToWords("01/01/2024", { case: "sentence" })).toBe( + "Primeiro de janeiro de dois mil e vinte e quatro", + ); + expect(convertDateToWords("10/05/1999", { case: "sentence" })).toBe( + "Dez de maio de mil novecentos e noventa e nove", + ); + }); + + test("should uppercase everything for 'upper', keeping accents", () => { + expect(convertDateToWords("02/03/2024", { case: "upper" })).toBe( + "DOIS DE MARÇO DE DOIS MIL E VINTE E QUATRO", + ); + }); + + test("should ignore an invalid case value and fall back to 'lower'", () => { + expect( + // @ts-expect-error + convertDateToWords("01/01/2024", { case: "invalid" }), + ).toBe("primeiro de janeiro de dois mil e vinte e quatro"); + }); + + test("should write only the month name and leave day/year as digits for 'month'", () => { + expect(convertDateToWords("02/03/2024", { style: "month" })).toBe("2 de março de 2024"); + }); + + test("should write day 1 as 'primeiro' in 'full' style and as '1º' in 'month' style", () => { + expect(convertDateToWords("01/01/2024", { style: "full" })).toBe( + "primeiro de janeiro de dois mil e vinte e quatro", + ); + expect(convertDateToWords("01/01/2024", { style: "month" })).toBe("1º de janeiro de 2024"); + }); + + test("should ignore an invalid style value and fall back to 'full'", () => { + expect( + // @ts-expect-error + convertDateToWords("02/03/2024", { style: "invalid" }), + ).toBe("dois de março de dois mil e vinte e quatro"); + }); + + test("should match a hand-written string for every month in both styles", () => { + const cases: Array<[string, string, string]> = [ + ["02/01/2024", "dois de janeiro de dois mil e vinte e quatro", "2 de janeiro de 2024"], + ["02/02/2024", "dois de fevereiro de dois mil e vinte e quatro", "2 de fevereiro de 2024"], + ["02/03/2024", "dois de março de dois mil e vinte e quatro", "2 de março de 2024"], + ["02/04/2024", "dois de abril de dois mil e vinte e quatro", "2 de abril de 2024"], + ["02/05/2024", "dois de maio de dois mil e vinte e quatro", "2 de maio de 2024"], + ["02/06/2024", "dois de junho de dois mil e vinte e quatro", "2 de junho de 2024"], + ["02/07/2024", "dois de julho de dois mil e vinte e quatro", "2 de julho de 2024"], + ["02/08/2024", "dois de agosto de dois mil e vinte e quatro", "2 de agosto de 2024"], + ["02/09/2024", "dois de setembro de dois mil e vinte e quatro", "2 de setembro de 2024"], + ["02/10/2024", "dois de outubro de dois mil e vinte e quatro", "2 de outubro de 2024"], + ["02/11/2024", "dois de novembro de dois mil e vinte e quatro", "2 de novembro de 2024"], + ["02/12/2024", "dois de dezembro de dois mil e vinte e quatro", "2 de dezembro de 2024"], + ]; + const failures: Array<{ + input: string; + actualFull: string; + expectedFull: string; + actualMonth: string; + expectedMonth: string; + }> = []; + + for (const [input, expectedFull, expectedMonth] of cases) { + const actualFull = convertDateToWords(input, { style: "full" }); + const actualMonth = convertDateToWords(input, { style: "month" }); + if (actualFull !== expectedFull || actualMonth !== expectedMonth) { + failures.push({ input, actualFull, expectedFull, actualMonth, expectedMonth }); + } + } + + expect(failures).toEqual([]); + }); + }); + + describe("weekday option", () => { + test("should not prefix a weekday by default", () => { + expect(convertDateToWords("02/03/2024")).toBe("dois de março de dois mil e vinte e quatro"); + }); + + test("should list every weekday name in order (Date#getDay indexing)", () => { + expect(WEEKDAY_NAMES).toEqual([ + "domingo", + "segunda-feira", + "terça-feira", + "quarta-feira", + "quinta-feira", + "sexta-feira", + "sábado", + ]); + }); + + test("should prefix the pt-BR weekday and a comma for 7 consecutive known dates", () => { + const cases: Array<[string, string]> = [ + ["03/03/2024", "domingo, três de março de dois mil e vinte e quatro"], + ["04/03/2024", "segunda-feira, quatro de março de dois mil e vinte e quatro"], + ["05/03/2024", "terça-feira, cinco de março de dois mil e vinte e quatro"], + ["06/03/2024", "quarta-feira, seis de março de dois mil e vinte e quatro"], + ["07/03/2024", "quinta-feira, sete de março de dois mil e vinte e quatro"], + ["08/03/2024", "sexta-feira, oito de março de dois mil e vinte e quatro"], + ["02/03/2024", "sábado, dois de março de dois mil e vinte e quatro"], + ]; + const failures: Array<{ input: string; actual: string; expected: string }> = []; + + for (const [input, expected] of cases) { + const actual = convertDateToWords(input, { weekday: true }); + if (actual !== expected) failures.push({ input, actual, expected }); + } + + expect(failures).toEqual([]); + }); + + test("should compute the weekday from a Date's local calendar date", () => { + expect(convertDateToWords(new Date(2024, 2, 2), { weekday: true })).toBe( + "sábado, dois de março de dois mil e vinte e quatro", + ); + }); + + test("should combine with 'month' style", () => { + expect(convertDateToWords("01/01/2024", { weekday: true, style: "month" })).toBe( + "segunda-feira, 1º de janeiro de 2024", + ); + }); + + test("should combine with the 'case' option", () => { + expect(convertDateToWords("02/03/2024", { weekday: true, case: "sentence" })).toBe( + "Sábado, dois de março de dois mil e vinte e quatro", + ); + expect(convertDateToWords("02/03/2024", { weekday: true, case: "upper" })).toBe( + "SÁBADO, DOIS DE MARÇO DE DOIS MIL E VINTE E QUATRO", + ); + }); + }); + + describe("invalid input", () => { + test("should return '' for an invalid Date", () => { + expect(convertDateToWords(new Date("invalid"))).toBe(""); + }); + + test("should return '' for a malformed string", () => { + expect(convertDateToWords("2024/01/01")).toBe(""); + expect(convertDateToWords("01-01-2024")).toBe(""); + expect(convertDateToWords("not a date")).toBe(""); + expect(convertDateToWords("")).toBe(""); + }); + + test("should return '' for a non-Date/non-string value", () => { + // @ts-expect-error + expect(convertDateToWords(null)).toBe(""); + // @ts-expect-error + expect(convertDateToWords(undefined)).toBe(""); + // @ts-expect-error + expect(convertDateToWords(20240101)).toBe(""); + }); + }); + + describe("years outside the calendar", () => { + test("should return '' for year zero, which has no year to write out", () => { + expect(convertDateToWords("01/01/0000")).toBe(""); + expect(convertDateToWords("0000-01-01")).toBe(""); + }); + + test("should return '' for a Date with a year before year 1 instead of a truncated string", () => { + const beforeYearOne = new Date(2000, 0, 1); + beforeYearOne.setFullYear(-500); + + expect(convertDateToWords(beforeYearOne)).toBe(""); + }); + }); + + describe("leap years of the proleptic Gregorian calendar", () => { + test("should accept February 29th on a year divisible by 400", () => { + expect(convertDateToWords("29/02/2000")).toBe("vinte e nove de fevereiro de dois mil"); + expect(convertDateToWords("29/02/1600")).toBe( + "vinte e nove de fevereiro de mil e seiscentos", + ); + }); + + test("should reject February 29th on a century that is not divisible by 400", () => { + expect(convertDateToWords("29/02/1900")).toBe(""); + expect(convertDateToWords("29/02/2100")).toBe(""); + expect(convertDateToWords("29/02/1800")).toBe(""); + }); + + test("should accept February 29th on a year of the first century divisible by 4", () => { + expect(convertDateToWords("29/02/0004")).toBe("vinte e nove de fevereiro de quatro"); + expect(convertDateToWords("29/02/0096")).toBe("vinte e nove de fevereiro de noventa e seis"); + }); + + test("should reject February 29th on a year of the first century not divisible by 4", () => { + expect(convertDateToWords("29/02/0003")).toBe(""); + expect(convertDateToWords("29/02/0100")).toBe(""); + }); + }); + + test("should list every month name in order", () => { + expect(MONTH_NAMES).toEqual([ + "janeiro", + "fevereiro", + "março", + "abril", + "maio", + "junho", + "julho", + "agosto", + "setembro", + "outubro", + "novembro", + "dezembro", + ]); + }); + + describe("literal case tables", () => { + test("should match a hand-written string for the 1st and the 15th of every month", () => { + const cases: Array<[string, string]> = [ + ["01/01/2024", "primeiro de janeiro de dois mil e vinte e quatro"], + ["15/01/2024", "quinze de janeiro de dois mil e vinte e quatro"], + ["01/02/2024", "primeiro de fevereiro de dois mil e vinte e quatro"], + ["15/02/2024", "quinze de fevereiro de dois mil e vinte e quatro"], + ["01/03/2024", "primeiro de março de dois mil e vinte e quatro"], + ["15/03/2024", "quinze de março de dois mil e vinte e quatro"], + ["01/04/2024", "primeiro de abril de dois mil e vinte e quatro"], + ["15/04/2024", "quinze de abril de dois mil e vinte e quatro"], + ["01/05/2024", "primeiro de maio de dois mil e vinte e quatro"], + ["15/05/2024", "quinze de maio de dois mil e vinte e quatro"], + ["01/06/2024", "primeiro de junho de dois mil e vinte e quatro"], + ["15/06/2024", "quinze de junho de dois mil e vinte e quatro"], + ["01/07/2024", "primeiro de julho de dois mil e vinte e quatro"], + ["15/07/2024", "quinze de julho de dois mil e vinte e quatro"], + ["01/08/2024", "primeiro de agosto de dois mil e vinte e quatro"], + ["15/08/2024", "quinze de agosto de dois mil e vinte e quatro"], + ["01/09/2024", "primeiro de setembro de dois mil e vinte e quatro"], + ["15/09/2024", "quinze de setembro de dois mil e vinte e quatro"], + ["01/10/2024", "primeiro de outubro de dois mil e vinte e quatro"], + ["15/10/2024", "quinze de outubro de dois mil e vinte e quatro"], + ["01/11/2024", "primeiro de novembro de dois mil e vinte e quatro"], + ["15/11/2024", "quinze de novembro de dois mil e vinte e quatro"], + ["01/12/2024", "primeiro de dezembro de dois mil e vinte e quatro"], + ["15/12/2024", "quinze de dezembro de dois mil e vinte e quatro"], + ]; + const failures: Array<{ input: string; actual: string; expected: string }> = []; + + for (const [input, expected] of cases) { + const actual = convertDateToWords(input); + if (actual !== expected) failures.push({ input, actual, expected }); + } + + expect(failures).toEqual([]); + }); + + test("should match a hand-written string for every day of a 31 day month", () => { + const cases: Array<[string, string]> = [ + ["01/03/2024", "primeiro de março de dois mil e vinte e quatro"], + ["02/03/2024", "dois de março de dois mil e vinte e quatro"], + ["03/03/2024", "três de março de dois mil e vinte e quatro"], + ["04/03/2024", "quatro de março de dois mil e vinte e quatro"], + ["05/03/2024", "cinco de março de dois mil e vinte e quatro"], + ["06/03/2024", "seis de março de dois mil e vinte e quatro"], + ["07/03/2024", "sete de março de dois mil e vinte e quatro"], + ["08/03/2024", "oito de março de dois mil e vinte e quatro"], + ["09/03/2024", "nove de março de dois mil e vinte e quatro"], + ["10/03/2024", "dez de março de dois mil e vinte e quatro"], + ["11/03/2024", "onze de março de dois mil e vinte e quatro"], + ["12/03/2024", "doze de março de dois mil e vinte e quatro"], + ["13/03/2024", "treze de março de dois mil e vinte e quatro"], + ["14/03/2024", "catorze de março de dois mil e vinte e quatro"], + ["15/03/2024", "quinze de março de dois mil e vinte e quatro"], + ["16/03/2024", "dezesseis de março de dois mil e vinte e quatro"], + ["17/03/2024", "dezessete de março de dois mil e vinte e quatro"], + ["18/03/2024", "dezoito de março de dois mil e vinte e quatro"], + ["19/03/2024", "dezenove de março de dois mil e vinte e quatro"], + ["20/03/2024", "vinte de março de dois mil e vinte e quatro"], + ["21/03/2024", "vinte e um de março de dois mil e vinte e quatro"], + ["22/03/2024", "vinte e dois de março de dois mil e vinte e quatro"], + ["23/03/2024", "vinte e três de março de dois mil e vinte e quatro"], + ["24/03/2024", "vinte e quatro de março de dois mil e vinte e quatro"], + ["25/03/2024", "vinte e cinco de março de dois mil e vinte e quatro"], + ["26/03/2024", "vinte e seis de março de dois mil e vinte e quatro"], + ["27/03/2024", "vinte e sete de março de dois mil e vinte e quatro"], + ["28/03/2024", "vinte e oito de março de dois mil e vinte e quatro"], + ["29/03/2024", "vinte e nove de março de dois mil e vinte e quatro"], + ["30/03/2024", "trinta de março de dois mil e vinte e quatro"], + ["31/03/2024", "trinta e um de março de dois mil e vinte e quatro"], + ]; + const failures: Array<{ input: string; actual: string; expected: string }> = []; + + for (const [input, expected] of cases) { + const actual = convertDateToWords(input); + if (actual !== expected) failures.push({ input, actual, expected }); + } + + expect(failures).toEqual([]); + }); + + test("should reproduce every published brutils 'convert_date_to_text' example (tests/test_date_utils.py, lowercase here because brutils always capitalizes and this library exposes that as case: 'sentence')", () => { + const cases: Array<[string, string]> = [ + ["15/08/2024", "quinze de agosto de dois mil e vinte e quatro"], + ["01/01/2000", "primeiro de janeiro de dois mil"], + ["31/12/1999", "trinta e um de dezembro de mil novecentos e noventa e nove"], + ["29/02/2020", "vinte e nove de fevereiro de dois mil e vinte"], + ["01/01/1900", "primeiro de janeiro de mil e novecentos"], + ]; + const failures: Array<{ input: string; actual: string; expected: string }> = []; + + for (const [input, expected] of cases) { + const actual = convertDateToWords(input); + if (actual !== expected) failures.push({ input, actual, expected }); + } + + expect(failures).toEqual([]); + }); + + test("should match a hand-written string for day 31 and for the leap day", () => { + const cases: Array<[string, string]> = [ + ["31/01/2024", "trinta e um de janeiro de dois mil e vinte e quatro"], + ["29/02/2024", "vinte e nove de fevereiro de dois mil e vinte e quatro"], + ]; + const failures: Array<{ input: string; actual: string; expected: string }> = []; + + for (const [input, expected] of cases) { + const actual = convertDateToWords(input); + if (actual !== expected) failures.push({ input, actual, expected }); + } + + expect(failures).toEqual([]); + }); + + test("should render the year without the thousands comma for 1900, 1999, 2000, 2001, 2024 and 2100", () => { + const cases: Array<[string, string]> = [ + ["01/01/1101", "primeiro de janeiro de mil cento e um"], + ["01/01/1200", "primeiro de janeiro de mil e duzentos"], + ["01/01/1500", "primeiro de janeiro de mil e quinhentos"], + ["01/01/1900", "primeiro de janeiro de mil e novecentos"], + ["01/01/1999", "primeiro de janeiro de mil novecentos e noventa e nove"], + ["01/01/2000", "primeiro de janeiro de dois mil"], + ["01/01/2001", "primeiro de janeiro de dois mil e um"], + ["01/01/2024", "primeiro de janeiro de dois mil e vinte e quatro"], + ["01/01/2100", "primeiro de janeiro de dois mil e cem"], + ["10/05/1999", "dez de maio de mil novecentos e noventa e nove"], + ]; + const failures: Array<{ input: string; actual: string; expected: string }> = []; + + for (const [input, expected] of cases) { + const actual = convertDateToWords(input); + if (actual !== expected) failures.push({ input, actual, expected }); + } + + expect(failures).toEqual([]); + }); + + test("should give the same hand-written result for the 'dd/mm/yyyy' and the ISO form", () => { + const cases: Array<[string, string]> = [ + ["2024-01-02", "dois de janeiro de dois mil e vinte e quatro"], + ["1999-05-10", "dez de maio de mil novecentos e noventa e nove"], + ]; + const failures: Array<{ input: string; actual: string; expected: string }> = []; + + for (const [input, expected] of cases) { + const actual = convertDateToWords(input); + if (actual !== expected) failures.push({ input, actual, expected }); + } + + expect(failures).toEqual([]); + }); + }); +}); diff --git a/src/convert-date-to-words/convert-date-to-words.ts b/src/convert-date-to-words/convert-date-to-words.ts new file mode 100644 index 00000000..97193edf --- /dev/null +++ b/src/convert-date-to-words/convert-date-to-words.ts @@ -0,0 +1,122 @@ +import { applyWordsCase } from "../_internals/apply-words-case/apply-words-case"; +import { MONTH_NAMES, WEEKDAY_NAMES } from "../_internals/constants/number-words"; +import { numberToWords, type WordsCase } from "../_internals/number-to-words/number-to-words"; + +export type ConvertDateToWordsOptions = { + /** Letter case applied to the result: `"lower"` (unchanged), `"sentence"` (capitalizes only the first letter) or `"upper"` (uppercases everything, keeping accents). Defaults to `"lower"`; an invalid value is ignored and `"lower"` is used instead. */ + case?: WordsCase; + /** Output style: `"full"` spells out the day, month and year (`"dois de março de dois mil e vinte e quatro"`); `"month"` spells out only the month name and leaves the day and year as digits (`"2 de março de 2024"`, day 1 as `"1º"`). Defaults to `"full"`; an invalid value is ignored and `"full"` is used instead. */ + style?: "full" | "month"; + /** Prefixes the pt-BR weekday name (lowercase) followed by a comma, e.g. `"sábado, dois de março de dois mil e vinte e quatro"`. The weekday is derived from the resolved calendar date (the `Date`'s local calendar date, or the parsed civil date for a string). Defaults to `false`. */ + weekday?: boolean; +}; + +const BR_DATE_REGEX = /^(\d{2})\/(\d{2})\/(\d{4})$/; +const ISO_DATE_REGEX = /^(\d{4})-(\d{2})-(\d{2})$/; + +const MONTH_LENGTHS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + +const isLeapYear = (year: number): boolean => + year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + +const daysInMonth = (year: number, month: number): number => + month === 2 && isLeapYear(year) ? 29 : MONTH_LENGTHS[month - 1]; + +const getWeekdayIndex = (year: number, month: number, day: number): number => { + const date = new Date(0); + date.setUTCFullYear(year, month - 1, day); + return date.getUTCDay(); +}; + +/** + * Formats a date as its Brazilian Portuguese "por extenso" textual representation, e.g. + * `"01/01/2024"` becomes `"primeiro de janeiro de dois mil e vinte e quatro"`. + * + * `value` can be a `Date` (read by its **local calendar date**, i.e. `getFullYear`/`getMonth`/ + * `getDate`, not its underlying UTC instant, the same convention used by `isHoliday`) or a + * string in `"dd/mm/yyyy"` or ISO `"yyyy-mm-dd"` format, both parsed as plain calendar dates + * with no timezone conversion. With the default `"full"` `options.style`, day 1 is written as + * "primeiro" and every other day uses the cardinal number; with `"month"`, only the month name + * is spelled out and the day/year are written as digits (day 1 as `"1º"`). Month names are + * lowercase. In `"full"` style the year is written out as a cardinal number without the + * thousands comma that `convertNumberToWords`/`convertCurrencyToWords` use (`1999` reads as + * `"mil novecentos e noventa e nove"`, not `"mil, novecentos e noventa e nove"`), matching how a + * date is read aloud. `options.weekday` prefixes the pt-BR weekday name (lowercase) followed by + * a comma. February 29th is accepted on the leap years of the proleptic Gregorian calendar + * (divisible by 4, except centuries that are not divisible by 400). Returns `""` when `value` is + * not one of those forms, is an invalid `Date`, names a day/month that does not exist (e.g. + * `"31/04/2024"` or `"29/02/2023"`), or falls before year 1, which has no year to write out. + * + * @param {Date|string} value - The date to convert: a `Date`, `"dd/mm/yyyy"` or ISO `"yyyy-mm-dd"`. + * @param {ConvertDateToWordsOptions} [options] - Optional formatting options. + * @param {WordsCase} [options.case] - Letter case applied to the result. Defaults to `"lower"`. + * @param {"full"|"month"} [options.style] - Output style. Defaults to `"full"`. + * @param {boolean} [options.weekday] - Prefixes the pt-BR weekday name and a comma. Defaults to `false`. + * @returns {string} The date written out in Portuguese, or `""` for invalid input. + * + * @example + * ```typescript + * convertDateToWords("01/01/2024"); // "primeiro de janeiro de dois mil e vinte e quatro" + * convertDateToWords("2024-01-02"); // "dois de janeiro de dois mil e vinte e quatro" + * convertDateToWords(new Date(2024, 0, 1)); // "primeiro de janeiro de dois mil e vinte e quatro" + * convertDateToWords("01/01/2024", { case: "sentence" }); // "Primeiro de janeiro de dois mil e vinte e quatro" + * convertDateToWords("02/03/2024", { style: "month" }); // "2 de março de 2024" + * convertDateToWords("01/01/2024", { style: "month" }); // "1º de janeiro de 2024" + * convertDateToWords("02/03/2024", { weekday: true }); // "sábado, dois de março de dois mil e vinte e quatro" + * convertDateToWords("10/05/1999"); // "dez de maio de mil novecentos e noventa e nove" + * convertDateToWords("31/04/2024"); // "" (April has 30 days) + * convertDateToWords("invalid"); // "" + * ``` + * + * @see https://github.com/brazilian-utils/python/blob/main/brutils/date_utils.py + */ +export const convertDateToWords = ( + value: Date | string, + options?: ConvertDateToWordsOptions, +): string => { + let year: number; + let month: number; + let day: number; + + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) return ""; + + year = value.getFullYear(); + month = value.getMonth() + 1; + day = value.getDate(); + } else if (typeof value === "string") { + const brMatch = BR_DATE_REGEX.exec(value); + const isoMatch = ISO_DATE_REGEX.exec(value); + + if (brMatch) { + day = Number(brMatch[1]); + month = Number(brMatch[2]); + year = Number(brMatch[3]); + } else if (isoMatch) { + year = Number(isoMatch[1]); + month = Number(isoMatch[2]); + day = Number(isoMatch[3]); + } else { + return ""; + } + } else { + return ""; + } + + if (year < 1) return ""; + if (month < 1 || month > 12) return ""; + if (day < 1 || day > daysInMonth(year, month)) return ""; + + const monthName = MONTH_NAMES[month - 1]; + const isMonthStyle = options?.style === "month"; + + const dateWords = isMonthStyle + ? `${day === 1 ? "1º" : day} de ${monthName} de ${year}` + : `${day === 1 ? "primeiro" : numberToWords(day)} de ${monthName} de ${numberToWords(year).replaceAll(", ", " ")}`; + + const result = options?.weekday + ? `${WEEKDAY_NAMES[getWeekdayIndex(year, month, day)]}, ${dateWords}` + : dateWords; + + return applyWordsCase(result, options?.case); +}; From d054ce62e18713827483cdcee9604ba4373dd56c Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:32:09 -0300 Subject: [PATCH 09/10] feat(cns): add isValidCns and formatCns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validates the Cartão Nacional de Saúde (15 digits): definitive numbers (starting 1/2) use a mod11 check shared with PIS; provisional numbers (starting 7/8/9) use a weighted sum that must be a multiple of 11. --- src/_internals/constants/cns.ts | 17 +++++ src/format-cns/format-cns.test.ts | 37 +++++++++++ src/format-cns/format-cns.ts | 32 +++++++++ src/is-valid-cns/is-valid-cns.test.ts | 96 +++++++++++++++++++++++++++ src/is-valid-cns/is-valid-cns.ts | 70 +++++++++++++++++++ 5 files changed, 252 insertions(+) create mode 100644 src/_internals/constants/cns.ts create mode 100644 src/format-cns/format-cns.test.ts create mode 100644 src/format-cns/format-cns.ts create mode 100644 src/is-valid-cns/is-valid-cns.test.ts create mode 100644 src/is-valid-cns/is-valid-cns.ts diff --git a/src/_internals/constants/cns.ts b/src/_internals/constants/cns.ts new file mode 100644 index 00000000..049941b5 --- /dev/null +++ b/src/_internals/constants/cns.ts @@ -0,0 +1,17 @@ +/** + * CNS (Cartão Nacional de Saúde) structural constants, shared by `isValidCns` and `formatCns`. + * + * @see Official: https://rni-docs.anvisa.gov.br/docs/regras_gerais/validacoes/validacaoCNS/ + */ + +/** Total digits of a CNS number. */ +export const CNS_LENGTH = 15; + +/** Digits of the PIS/PASEP/NIS derived base embedded in a definitive CNS (starts with 1 or 2). */ +export const CNS_DEFINITIVE_BASE_LENGTH = 11; + +/** Suffix between the base and the check digit of a definitive CNS whose raw check digit is not 10. */ +export const CNS_DEFINITIVE_SUFFIX = "000"; + +/** Suffix used when the raw check digit is 10: the weighted sum is raised by 2 and the digit recomputed. */ +export const CNS_DEFINITIVE_ADJUSTED_SUFFIX = "001"; diff --git a/src/format-cns/format-cns.test.ts b/src/format-cns/format-cns.test.ts new file mode 100644 index 00000000..f155599b --- /dev/null +++ b/src/format-cns/format-cns.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "../_internals/test/runtime"; +import { formatCns } from "./format-cns"; + +describe("formatCns", () => { + it("should format a CNS with the 3-4-4-4 space mask", () => { + expect(formatCns("")).toBe(""); + expect(formatCns("1")).toBe("1"); + expect(formatCns("12")).toBe("12"); + expect(formatCns("123")).toBe("123"); + expect(formatCns("1234")).toBe("123 4"); + expect(formatCns("123456789010001")).toBe("123 4567 8901 0001"); + }); + + it("should format a number CNS with the space mask", () => { + expect(formatCns(123456789010001)).toBe("123 4567 8901 0001"); + }); + + it("should pad the value with leading zeros when pad is true", () => { + expect(formatCns("", { pad: true })).toBe("000 0000 0000 0000"); + expect(formatCns("89010001", { pad: true })).toBe("000 0000 8901 0001"); + }); + + it("should not add digits after the CNS length (15)", () => { + expect(formatCns("123456789010001999")).toBe("123 4567 8901 0001"); + }); + + it("should remove all non numeric characters", () => { + expect(formatCns("123.456.789-01/0001")).toBe("123 4567 8901 0001"); + }); + + it("should return an empty string when the value is null or undefined", () => { + // @ts-expect-error + expect(formatCns(null)).toBe(""); + // @ts-expect-error + expect(formatCns(undefined)).toBe(""); + }); +}); diff --git a/src/format-cns/format-cns.ts b/src/format-cns/format-cns.ts new file mode 100644 index 00000000..0c3d224a --- /dev/null +++ b/src/format-cns/format-cns.ts @@ -0,0 +1,32 @@ +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 FormatCnsOptions = Pick; + +/** + * Formats a CNS (Cartão Nacional de Saúde) number into the common display groups of 3-4-4-4 + * digits separated by spaces. + * + * @param {string|number} value - The CNS value to be formatted. It can be a string or a number. + * @param {FormatCnsOptions} [options] - Optional formatting options. + * @param {boolean} options.pad - If true, pads the value with leading zeros if necessary. + * @returns {string} The formatted CNS string in the pattern "000 0000 0000 0000". + * + * @example + * ```typescript + * formatCns("123456789010001"); // "123 4567 8901 0001" + * formatCns(123456789010001); // "123 4567 8901 0001" + * formatCns("89010001", { pad: true }); // "000 0000 8901 0001" + * ``` + * + * @see Official: https://rni-docs.anvisa.gov.br/docs/regras_gerais/validacoes/validacaoCNS/ + */ +export const formatCns = (value: string | number, options?: FormatCnsOptions): string => + isNullish(value) + ? "" + : format({ + pad: options?.pad, + value: sanitizeToDigits(value), + pattern: "000 0000 0000 0000", + }); diff --git a/src/is-valid-cns/is-valid-cns.test.ts b/src/is-valid-cns/is-valid-cns.test.ts new file mode 100644 index 00000000..d712dfc0 --- /dev/null +++ b/src/is-valid-cns/is-valid-cns.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "../_internals/test/runtime"; +import { isValidCns } from "./is-valid-cns"; + +describe("isValidCns", () => { + describe("should return false", () => { + test("when it is null", () => { + // @ts-expect-error + expect(isValidCns(null)).toBe(false); + }); + + test("when it is undefined", () => { + // @ts-expect-error + expect(isValidCns(undefined)).toBe(false); + }); + + test("when it is a boolean", () => { + // @ts-expect-error + expect(isValidCns(true)).toBe(false); + }); + + test("when it is an object", () => { + // @ts-expect-error + expect(isValidCns({})).toBe(false); + }); + + test("when it is an array", () => { + // @ts-expect-error + expect(isValidCns([])).toBe(false); + }); + + test("when it is an empty string", () => { + expect(isValidCns("")).toBe(false); + }); + + test("when it does not have 15 digits", () => { + expect(isValidCns("12345678901")).toBe(false); + }); + + test("when the first digit is not 1, 2, 7, 8 or 9", () => { + expect(isValidCns("312345678901234")).toBe(false); + expect(isValidCns("012345678901234")).toBe(false); + expect(isValidCns("612345678901234")).toBe(false); + }); + + test("when a definitive card has the wrong check digit", () => { + expect(isValidCns("123456789010001")).toBe(false); + }); + + test("when a definitive card carries the 001 suffix without needing the +2 adjustment", () => { + expect(isValidCns("123456789010010")).toBe(false); + }); + + test("when a definitive card whose raw check digit is 10 (base 10000000006) keeps the 000 suffix", () => { + expect(isValidCns("100000000060000")).toBe(false); + expect(isValidCns("100000000060008")).toBe(false); + }); + + test("when a provisional card's weighted sum is not a multiple of 11", () => { + expect(isValidCns("700000000000001")).toBe(false); + }); + }); + + describe("should return true", () => { + test("for a definitive CNS whose raw check digit does not need the +2 adjustment (base 12345678901, weighted sum 440, suffix 000, digit 0)", () => { + expect(isValidCns("123456789010000")).toBe(true); + }); + + test("for a definitive CNS starting with 2 (base 20000000001, weighted sum 35, digit 9)", () => { + expect(isValidCns("200000000010009")).toBe(true); + }); + + test("for a definitive CNS as a number", () => { + expect(isValidCns(123456789010000)).toBe(true); + }); + + test("for a definitive CNS with a whitespace mask", () => { + expect(isValidCns("123 4567 8901 0000")).toBe(true); + }); + + test("for a definitive CNS whose raw check digit is 10 (base 10000000006, weighted sum 45): sum raised to 47, digit 8, suffix 001", () => { + expect(isValidCns("100000000060018")).toBe(true); + }); + + test("for a provisional CNS starting with 7", () => { + expect(isValidCns("700000000000005")).toBe(true); + }); + + test("for a provisional CNS starting with 8", () => { + expect(isValidCns("800000000000001")).toBe(true); + }); + + test("for a provisional CNS starting with 9", () => { + expect(isValidCns("900000000000008")).toBe(true); + }); + }); +}); diff --git a/src/is-valid-cns/is-valid-cns.ts b/src/is-valid-cns/is-valid-cns.ts new file mode 100644 index 00000000..c6dd0c7b --- /dev/null +++ b/src/is-valid-cns/is-valid-cns.ts @@ -0,0 +1,70 @@ +import { + CNS_DEFINITIVE_ADJUSTED_SUFFIX, + CNS_DEFINITIVE_BASE_LENGTH, + CNS_DEFINITIVE_SUFFIX, + CNS_LENGTH, +} from "../_internals/constants/cns"; +import { generateChecksum } from "../_internals/generate-checksum/generate-checksum"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; + +const DEFINITIVE_FIRST_DIGIT_REGEX = /^[12]/; +const PROVISIONAL_FIRST_DIGIT_REGEX = /^[789]/; + +const isValidDefinitive = (digits: string): boolean => { + const base = digits.slice(0, CNS_DEFINITIVE_BASE_LENGTH); + const sum = generateChecksum({ base, weight: 15 }); + const rawCheckDigit = 11 - (sum % 11); + + if (rawCheckDigit === 10) { + const checkDigit = 11 - ((sum + 2) % 11); + + return digits === `${base}${CNS_DEFINITIVE_ADJUSTED_SUFFIX}${checkDigit}`; + } + + const checkDigit = rawCheckDigit === 11 ? 0 : rawCheckDigit; + + return digits === `${base}${CNS_DEFINITIVE_SUFFIX}${checkDigit}`; +}; + +const isValidProvisional = (digits: string): boolean => + generateChecksum({ base: digits, weight: 15 }) % 11 === 0; + +/** + * Validates a CNS (Cartão Nacional de Saúde) number, the unique identifier of a SUS + * (Sistema Único de Saúde) user, health professional or health facility. + * + * Definitive cards (starting with 1 or 2) are laid out as an 11 digit PIS/PASEP/NIS derived + * base, a 3 digit suffix and a check digit. The check digit is 11 minus the remainder of the + * base's weighted sum (weights 15 down to 5) divided by 11, with 11 mapped to 0. When that + * raw digit is 10, DATASUS raises the weighted sum by 2, recomputes the digit and marks the + * card with the suffix `"001"` instead of `"000"`. Provisional cards (starting with 7, 8 or 9) + * are validated by a single weighted sum (weights 15 down to 1 over all 15 digits) that must + * be a multiple of 11. + * + * @param {string|number} value - The CNS value to be validated. + * @returns {boolean} True if the CNS is valid, false otherwise. + * + * @example + * ```typescript + * isValidCns("123456789010000"); // true (definitive, suffix 000) + * isValidCns("100000000060018"); // true (definitive, raw check digit 10, suffix 001) + * isValidCns("700000000000005"); // true (provisional) + * isValidCns("123456789010001"); // false (wrong check digit) + * isValidCns("12345678901"); // false (wrong length) + * ``` + * + * @see Official: https://rni-docs.anvisa.gov.br/docs/regras_gerais/validacoes/validacaoCNS/ + */ +export const isValidCns = (value: string | number): boolean => { + if (typeof value !== "string" && typeof value !== "number") return false; + + const digits = sanitizeToDigits(value); + + if (digits.length !== CNS_LENGTH) return false; + + if (DEFINITIVE_FIRST_DIGIT_REGEX.test(digits)) return isValidDefinitive(digits); + + if (PROVISIONAL_FIRST_DIGIT_REGEX.test(digits)) return isValidProvisional(digits); + + return false; +}; From db5a602fe4040c073caa35bf932f71d6b7218836 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:32:09 -0300 Subject: [PATCH 10/10] feat(certidao): add formatCertidao, isValidCertidao and parseCertidao MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validates the 32-digit matrícula of a birth/marriage/death certidão, a 2-stage mod11 checksum per Provimento CNJ 46/2015. --- src/_internals/constants/certidao.ts | 22 +++ src/format-certidao/format-certidao.test.ts | 66 ++++++++ src/format-certidao/format-certidao.ts | 44 ++++++ .../is-valid-certidao.test.ts | 149 ++++++++++++++++++ src/is-valid-certidao/is-valid-certidao.ts | 96 +++++++++++ src/parse-certidao/constants.ts | 23 +++ src/parse-certidao/parse-certidao.test.ts | 111 +++++++++++++ src/parse-certidao/parse-certidao.ts | 78 +++++++++ 8 files changed, 589 insertions(+) create mode 100644 src/_internals/constants/certidao.ts create mode 100644 src/format-certidao/format-certidao.test.ts create mode 100644 src/format-certidao/format-certidao.ts create mode 100644 src/is-valid-certidao/is-valid-certidao.test.ts create mode 100644 src/is-valid-certidao/is-valid-certidao.ts create mode 100644 src/parse-certidao/constants.ts create mode 100644 src/parse-certidao/parse-certidao.test.ts create mode 100644 src/parse-certidao/parse-certidao.ts diff --git a/src/_internals/constants/certidao.ts b/src/_internals/constants/certidao.ts new file mode 100644 index 00000000..9236c671 --- /dev/null +++ b/src/_internals/constants/certidao.ts @@ -0,0 +1,22 @@ +/** + * Layout of the matrícula of a certidão de registro civil, 32 digits grouped as + * 6 (CNS da serventia) + 2 (acervo) + 2 (serviço) + 4 (ano) + 1 (tipo do livro) + 5 (livro) + + * 3 (folha) + 7 (termo) + 2 (dígitos verificadores). + * + * @see Official: Provimento CNJ 46/2015, art. 1º and Anexo (Código Nacional de Serventias). + * @see Based on: http://ghiorzi.org/DVnew.htm Worked example of the two check digits + * (sums 288 and 309). + * @see Based on: https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts + * Reference implementation, and the source of the matrículas used as test vectors. + * @see Based on: https://github.com/geekcom/validator-docs/blob/master/src/validator-docs/Rules/Certidao.php + * Third reference implementation agreeing on the weights and on the remainder of 10 read as 1. + */ + +export const CERTIDAO_LENGTH = 32; + +export const CERTIDAO_BASE_LENGTH = 30; + +export const CERTIDAO_PATTERN = "000000 00 00 0000 0 00000 000 0000000 00"; + +export const CERTIDAO_FORMAT_REGEX = + /^\d{6}[\s.\-/]*\d{2}[\s.\-/]*\d{2}[\s.\-/]*\d{4}[\s.\-/]*\d[\s.\-/]*\d{5}[\s.\-/]*\d{3}[\s.\-/]*\d{7}[\s.\-/]*\d{2}$/; diff --git a/src/format-certidao/format-certidao.test.ts b/src/format-certidao/format-certidao.test.ts new file mode 100644 index 00000000..7185a9f8 --- /dev/null +++ b/src/format-certidao/format-certidao.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "../_internals/test/runtime"; +import { formatCertidao } from "./format-certidao"; + +describe("formatCertidao", () => { + describe("should return an empty string", () => { + test("when it is null", () => { + // @ts-expect-error + expect(formatCertidao(null)).toBe(""); + }); + + test("when it is undefined", () => { + // @ts-expect-error + expect(formatCertidao(undefined)).toBe(""); + }); + + test("when it is an empty string", () => { + expect(formatCertidao("")).toBe(""); + }); + }); + + describe("should return the matrícula in the printed mask", () => { + test("for the 32 digits of the ghiorzi.org/DVnew.htm worked example", () => { + expect(formatCertidao("10453901552013100012021000012321")).toBe( + "104539 01 55 2013 1 00012 021 0000123 21", + ); + }); + + test("for a value already carrying the dotted mask of the Provimento", () => { + expect(formatCertidao("104539.01.55.2013.1.00012.021.0000123-21")).toBe( + "104539 01 55 2013 1 00012 021 0000123 21", + ); + }); + + test("for 094300 01 55 2010 1 00020 112 0000120-87 (klawdyo/validation-br certidao.spec.ts)", () => { + expect(formatCertidao("09430001552010100020112000012087")).toBe( + "094300 01 55 2010 1 00020 112 0000120 87", + ); + }); + }); + + describe("should return a partial mask", () => { + test("when the value has fewer than 32 digits", () => { + expect(formatCertidao("10453901")).toBe("104539 01"); + }); + + test("when the value has more than 32 digits, dropping the excess", () => { + expect(formatCertidao("1045390155201310001202100001232199")).toBe( + "104539 01 55 2013 1 00012 021 0000123 21", + ); + }); + }); + + describe("should left pad the value", () => { + test("when options.pad is true", () => { + expect(formatCertidao("1552010100020112000012087", { pad: true })).toBe( + "000000 01 55 2010 1 00020 112 0000120 87", + ); + }); + }); + + describe("should accept a number", () => { + test("for a value short enough to be an exact integer", () => { + expect(formatCertidao(104539015520)).toBe("104539 01 55 20"); + }); + }); +}); diff --git a/src/format-certidao/format-certidao.ts b/src/format-certidao/format-certidao.ts new file mode 100644 index 00000000..63501d41 --- /dev/null +++ b/src/format-certidao/format-certidao.ts @@ -0,0 +1,44 @@ +import { CERTIDAO_PATTERN } from "../_internals/constants/certidao"; +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 FormatCertidaoOptions = Pick; + +/** + * Formats the matrícula of a certidão de registro civil into the printed mask of the + * Provimento, the 32 digits grouped as 6 2 2 4 1 5 3 7 2 and separated by spaces. + * + * @param {string|number} value - The matrícula value to be formatted. It can be a string or a number. + * @param {FormatCertidaoOptions} [options] - Optional formatting options. + * @param {boolean} options.pad - If true, pads the value with leading zeros if necessary. + * @returns {string} The formatted matrícula in the pattern "000000 00 00 0000 0 00000 000 0000000 00". + * + * @example + * ```typescript + * formatCertidao("10453901552013100012021000012321"); + * // "104539 01 55 2013 1 00012 021 0000123 21" + * + * formatCertidao("104539.01.55.2013.1.00012.021.0000123-21"); + * // "104539 01 55 2013 1 00012 021 0000123 21" + * + * formatCertidao("1552010100020112000012087", { pad: true }); + * // "000000 01 55 2010 1 00020 112 0000120 87" + * ``` + * + * @see Official: Provimento CNJ 46/2015, art. 1º and Anexo (Código Nacional de Serventias). + * @see Based on: http://ghiorzi.org/DVnew.htm Worked example of the two check digits + * (sums 288 and 309). + * @see Based on: https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts + * Reference implementation, and the source of the matrículas used as test vectors. + * @see Based on: https://github.com/geekcom/validator-docs/blob/master/src/validator-docs/Rules/Certidao.php + * Third reference implementation agreeing on the weights and on the remainder of 10 read as 1. + */ +export const formatCertidao = (value: string | number, options?: FormatCertidaoOptions): string => + isNullish(value) + ? "" + : format({ + pad: options?.pad, + value: sanitizeToDigits(value), + pattern: CERTIDAO_PATTERN, + }); diff --git a/src/is-valid-certidao/is-valid-certidao.test.ts b/src/is-valid-certidao/is-valid-certidao.test.ts new file mode 100644 index 00000000..d6dd0f94 --- /dev/null +++ b/src/is-valid-certidao/is-valid-certidao.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test } from "../_internals/test/runtime"; +import { isValidCertidao } from "./is-valid-certidao"; + +describe("isValidCertidao", () => { + describe("should return false", () => { + test("when it is null", () => { + // @ts-expect-error + expect(isValidCertidao(null)).toBe(false); + }); + + test("when it is undefined", () => { + // @ts-expect-error + expect(isValidCertidao(undefined)).toBe(false); + }); + + test("when it is an array", () => { + // @ts-expect-error + expect(isValidCertidao([])).toBe(false); + }); + + test("when it is an empty string", () => { + expect(isValidCertidao("")).toBe(false); + }); + + test("when it does not have 32 digits", () => { + expect(isValidCertidao("104539015520131000120210000123")).toBe(false); + expect(isValidCertidao("104539015520131000120210000123210")).toBe(false); + }); + + test("when it has 32 digits but an unsupported separator", () => { + expect(isValidCertidao("104539#01#55#2013#1#00012#021#0000123#21")).toBe(false); + }); + + test("when it has 32 digits grouped outside the 6-2-2-4-1-5-3-7-2 mask", () => { + expect(isValidCertidao("1045.3901.5520.1310.0012.0210.0001.2321")).toBe(false); + }); + + test("when it contains letters", () => { + expect(isValidCertidao("A04539 01 55 2013 1 00012 021 0000123 21")).toBe(false); + }); + + test("when the check digits do not match (the ghiorzi.org example with 22)", () => { + expect(isValidCertidao("10453901552013100012021000012322")).toBe(false); + }); + + test("when only the second check digit is wrong (the ghiorzi.org example with 20)", () => { + expect(isValidCertidao("10453901552013100012021000012320")).toBe(false); + }); + + test("when the check digits are 99 (klawdyo/validation-br certidao.spec.ts invalid case)", () => { + expect(isValidCertidao("12345601552023100001001000000199")).toBe(false); + }); + + test("when it is a number, which cannot carry the 32 significant digits of a matrícula", () => { + expect(isValidCertidao(1045390155)).toBe(false); + }); + }); + + describe("should return true", () => { + test("for 104539.01.55.2013.1.00012.021.0000123-21, the worked example of ghiorzi.org/DVnew.htm", () => { + expect(isValidCertidao("104539 01 55 2013 1 00012 021 0000123 21")).toBe(true); + expect(isValidCertidao("10453901552013100012021000012321")).toBe(true); + }); + + test("for 131128 01 55 2010 1 00014 192 0006001 00 (klawdyo/validation-br certidao.spec.ts)", () => { + expect(isValidCertidao("131128 01 55 2010 1 00014 192 0006001 00")).toBe(true); + }); + + test("for 094003 01 55 2011 1 00110 002 0051917 43 (klawdyo/validation-br certidao.spec.ts)", () => { + expect(isValidCertidao("094003 01 55 2011 1 00110 002 0051917 43")).toBe(true); + }); + + test("for 094003 01 55 2010 1 00109 151 0051816 26 (klawdyo/validation-br certidao.spec.ts)", () => { + expect(isValidCertidao("094003 01 55 2010 1 00109 151 0051816 26")).toBe(true); + }); + + test("for 094300 01 55 2010 1 00020 112 0000120-87 with a dash before the check digits (klawdyo/validation-br certidao.spec.ts)", () => { + expect(isValidCertidao("094300 01 55 2010 1 00020 112 0000120-87")).toBe(true); + }); + + test("for 094946 01 55 2011 1 00241 196 0099147 54 (klawdyo/validation-br certidao.spec.ts)", () => { + expect(isValidCertidao("094946 01 55 2011 1 00241 196 0099147 54")).toBe(true); + }); + + test("for 001234 01 55 2026 1 00567 078 0099999 92 (klawdyo/validation-br certidao.spec.ts)", () => { + expect(isValidCertidao("001234 01 55 2026 1 00567 078 0099999 92")).toBe(true); + }); + + test("for a matrícula whose first modulus 11 remainder is 10 and is read as 1", () => { + expect(isValidCertidao("82668301552015209245842999011418")).toBe(true); + }); + + test("for a matrícula whose second modulus 11 remainder is 10 and is read as 1", () => { + expect(isValidCertidao("79975401552015772710866666109571")).toBe(true); + }); + + test("for the dotted mask of the Provimento", () => { + expect(isValidCertidao("104539.01.55.2013.1.00012.021.0000123-21")).toBe(true); + }); + }); + + describe("options.accept", () => { + test("should return true when the book type is in the accepted list", () => { + expect( + isValidCertidao("104539 01 55 2013 1 00012 021 0000123 21", { accept: ["birth"] }), + ).toBe(true); + }); + + test("should return true when the book type is one of several accepted types", () => { + expect( + isValidCertidao("104539 01 55 2013 1 00012 021 0000123 21", { + accept: ["death", "birth"], + }), + ).toBe(true); + }); + + test("should return false when the book type is not in the accepted list", () => { + expect( + isValidCertidao("104539 01 55 2013 1 00012 021 0000123 21", { accept: ["death"] }), + ).toBe(false); + }); + + test("should return false when the accepted list is empty", () => { + expect(isValidCertidao("104539 01 55 2013 1 00012 021 0000123 21", { accept: [] })).toBe( + false, + ); + }); + + test("should return true for an interdiction act (book code 9) when accepted", () => { + expect( + isValidCertidao("10453901552013900012021000012398", { accept: ["interdiction"] }), + ).toBe(true); + }); + + test("should return true when the check digits match and accept is not given, book code 0", () => { + expect(isValidCertidao("10453901552013000012021000012387")).toBe(true); + }); + + test("should return false when the book code is 0, outside the nine books of the Provimento, and accept is given", () => { + expect(isValidCertidao("10453901552013000012021000012387", { accept: ["birth"] })).toBe( + false, + ); + }); + + test("should return false when the matrícula itself is invalid, regardless of accept", () => { + expect(isValidCertidao("123456", { accept: ["birth"] })).toBe(false); + }); + }); +}); diff --git a/src/is-valid-certidao/is-valid-certidao.ts b/src/is-valid-certidao/is-valid-certidao.ts new file mode 100644 index 00000000..abeb098f --- /dev/null +++ b/src/is-valid-certidao/is-valid-certidao.ts @@ -0,0 +1,96 @@ +import { + CERTIDAO_BASE_LENGTH, + CERTIDAO_FORMAT_REGEX, + CERTIDAO_LENGTH, +} from "../_internals/constants/certidao"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { CERTIDAO_TYPES } from "../parse-certidao/constants"; +import type { CertidaoType } from "../parse-certidao/parse-certidao"; + +export type IsValidCertidaoOptions = { + /** Kinds of certidão (book types) that count as valid (default: all of them). */ + accept?: CertidaoType[]; +}; + +const getCheckDigit = (value: string): number => { + let weight = CERTIDAO_LENGTH - value.length; + let sum = 0; + + for (let i = 0; i < value.length; i++) { + sum += (value.charCodeAt(i) - 48) * weight; + weight = weight < 10 ? weight + 1 : 0; + } + + const remainder = sum % 11; + + return remainder === 10 ? 1 : remainder; +}; + +/** + * Validates the matrícula of a certidão de registro civil (nascimento, casamento, óbito and the + * other acts kept by a serventia de registro civil das pessoas naturais). + * + * The matrícula has 32 digits laid out as 6 (CNS da serventia) + 2 (acervo) + 2 (serviço) + + * 4 (ano) + 1 (tipo do livro) + 5 (livro) + 3 (folha) + 7 (termo) + 2 (dígitos verificadores), + * printed as "000000 00 00 0000 0 00000 000 0000000 00". Both check digits are modulus 11: the + * first weights the 30 base digits by 2, 3, ... 10, 0, 1, 2, ... restarting the cycle every 11 + * digits, the second weights the 31 digits that include the first check digit by 1, 2, ... 10, + * 0, 1, ... In both passes the check digit is the remainder itself, with a remainder of 10 read + * as 1. + * + * `options.accept` restricts which of the nine books (see `CertidaoType`, reused from + * `parseCertidao`) count as valid: when given, the book-type digit (fifteenth position of the + * matrícula) must map to one of the listed types, so a matrícula whose digit is `0` or greater + * than `9` (not one of the nine defined books) is also rejected. When omitted, every book type + * is accepted and the digit is not otherwise checked, matching the previous behavior. + * + * @param {string|number} value - The matrícula value to be validated. + * @param {IsValidCertidaoOptions} [options] - Optional validation options. + * @param {CertidaoType[]} [options.accept] - The book types to accept. Defaults to all of them. + * @returns {boolean} True if the matrícula is valid, false otherwise. + * + * @example + * ```typescript + * isValidCertidao("104539 01 55 2013 1 00012 021 0000123 21"); // true + * isValidCertidao("09430001552010100020112000012087"); // true + * isValidCertidao("104539 01 55 2013 1 00012 021 0000123 22"); // false (invalid check digits) + * isValidCertidao("123456"); // false (wrong length) + * isValidCertidao("104539 01 55 2013 1 00012 021 0000123 21", { accept: ["birth"] }); // true + * isValidCertidao("104539 01 55 2013 1 00012 021 0000123 21", { accept: ["death"] }); // false + * ``` + * + * @see Official: Provimento CNJ 46/2015, art. 1º and Anexo (Código Nacional de Serventias). + * @see Based on: http://ghiorzi.org/DVnew.htm Worked example of the two check digits + * (sums 288 and 309). + * @see Based on: https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts + * Reference implementation, and the source of the matrículas used as test vectors. + * @see Based on: https://github.com/geekcom/validator-docs/blob/master/src/validator-docs/Rules/Certidao.php + * Third reference implementation agreeing on the weights and on the remainder of 10 read as 1. + */ +export const isValidCertidao = ( + value: string | number, + options?: IsValidCertidaoOptions, +): boolean => { + if (typeof value !== "string" && typeof value !== "number") return false; + + const digits = sanitizeToDigits(value); + + if (digits.length !== CERTIDAO_LENGTH) return false; + + if (!CERTIDAO_FORMAT_REGEX.test(String(value).trim())) return false; + + const base = digits.slice(0, CERTIDAO_BASE_LENGTH); + const first = getCheckDigit(base); + const second = getCheckDigit(`${base}${first}`); + + if (digits.slice(CERTIDAO_BASE_LENGTH) !== `${first}${second}`) return false; + + const accept = options?.accept; + + if (!Array.isArray(accept)) return true; + + const typeCode = digits.charCodeAt(14) - 48; + const type: CertidaoType | undefined = CERTIDAO_TYPES[typeCode - 1]; + + return type !== undefined && accept.includes(type); +}; diff --git a/src/parse-certidao/constants.ts b/src/parse-certidao/constants.ts new file mode 100644 index 00000000..02b77f21 --- /dev/null +++ b/src/parse-certidao/constants.ts @@ -0,0 +1,23 @@ +/** + * The nine books (tipo do livro) a matrícula de registro civil can point to, in the order of + * the codes 1 to 9: Livro A (nascimento), Livro B (casamento), Livro B Auxiliar (casamento + * religioso com efeito civil), Livro C (óbito), Livro C Auxiliar (natimorto), Livro D + * (proclamas), Livro E (demais atos), Livro E desdobrado para emancipações and Livro E + * desdobrado para interdições. + * + * @see Official: Provimento CNJ 46/2015, art. 1º and Anexo (Código Nacional de Serventias). + * @see Based on: http://ghiorzi.org/DVnew.htm Description of the nine books and their codes. + * @see Based on: https://github.com/Casilhero/brazilian-validators/blob/main/src/Support/CertidaoInfo.php + * Reference implementation agreeing on the same nine books, in the same order. + */ +export const CERTIDAO_TYPES = [ + "birth", + "marriage", + "religious-marriage", + "death", + "stillbirth", + "banns", + "other", + "emancipation", + "interdiction", +] as const; diff --git a/src/parse-certidao/parse-certidao.test.ts b/src/parse-certidao/parse-certidao.test.ts new file mode 100644 index 00000000..5d57f518 --- /dev/null +++ b/src/parse-certidao/parse-certidao.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "../_internals/test/runtime"; +import { parseCertidao } from "./parse-certidao"; + +describe("parseCertidao", () => { + describe("should return null", () => { + test("when it is null", () => { + // @ts-expect-error + expect(parseCertidao(null)).toBeNull(); + }); + + test("when it is undefined", () => { + // @ts-expect-error + expect(parseCertidao(undefined)).toBeNull(); + }); + + test("when it is an empty string", () => { + expect(parseCertidao("")).toBeNull(); + }); + + test("when the check digits do not match", () => { + expect(parseCertidao("10453901552013100012021000012322")).toBeNull(); + }); + + test("when the matrícula is otherwise invalid", () => { + expect(parseCertidao("not-a-matricula")).toBeNull(); + }); + + test("when the book code is 0, outside the nine books of the Provimento", () => { + expect(parseCertidao("10453901552013000012021000012387")).toBeNull(); + }); + }); + + describe("should return the parsed matrícula", () => { + test("for 104539.01.55.2013.1.00012.021.0000123-21, the worked example of ghiorzi.org/DVnew.htm", () => { + expect(parseCertidao("104539 01 55 2013 1 00012 021 0000123 21")).toEqual({ + registryCns: "104539", + acervo: "01", + service: "55", + year: 2013, + type: "birth", + typeCode: 1, + book: "00012", + page: "021", + term: "0000123", + checkDigits: "21", + }); + }); + + test("for 094300 01 55 2010 1 00020 112 0000120-87 (klawdyo/validation-br certidao.spec.ts)", () => { + expect(parseCertidao("094300 01 55 2010 1 00020 112 0000120-87")).toEqual({ + registryCns: "094300", + acervo: "01", + service: "55", + year: 2010, + type: "birth", + typeCode: 1, + book: "00020", + page: "112", + term: "0000120", + checkDigits: "87", + }); + }); + + test("for a marriage act, book code 2", () => { + expect(parseCertidao("10453901552013200012021000012376")?.type).toBe("marriage"); + }); + + test("for a religious marriage with civil effect, book code 3", () => { + expect(parseCertidao("10453901552013300012021000012310")?.type).toBe("religious-marriage"); + }); + + test("for a death act, book code 4", () => { + expect(parseCertidao("10453901552013400012021000012365")?.type).toBe("death"); + }); + + test("for a stillbirth act, book code 5", () => { + expect(parseCertidao("10453901552013500012021000012301")?.type).toBe("stillbirth"); + }); + + test("for a proclamas act, book code 6", () => { + expect(parseCertidao("10453901552013600012021000012354")?.type).toBe("banns"); + }); + + test("for the other acts of Livro E, book code 7", () => { + expect(parseCertidao("10453901552013700012021000012315")?.type).toBe("other"); + }); + + test("for an emancipation act, book code 8", () => { + expect(parseCertidao("10453901552013800012021000012343")?.type).toBe("emancipation"); + }); + + test("for an interdiction act, book code 9", () => { + expect(parseCertidao("10453901552013900012021000012398")?.type).toBe("interdiction"); + }); + + test("for a matrícula whose first modulus 11 remainder is 10 (826683 01 55 2015 2 09245 842 9990114 18)", () => { + expect(parseCertidao("82668301552015209245842999011418")).toEqual({ + registryCns: "826683", + acervo: "01", + service: "55", + year: 2015, + type: "marriage", + typeCode: 2, + book: "09245", + page: "842", + term: "9990114", + checkDigits: "18", + }); + }); + }); +}); diff --git a/src/parse-certidao/parse-certidao.ts b/src/parse-certidao/parse-certidao.ts new file mode 100644 index 00000000..88b9b181 --- /dev/null +++ b/src/parse-certidao/parse-certidao.ts @@ -0,0 +1,78 @@ +import { CERTIDAO_BASE_LENGTH } from "../_internals/constants/certidao"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { isValidCertidao } from "../is-valid-certidao/is-valid-certidao"; +import { CERTIDAO_TYPES } from "./constants"; + +export type CertidaoType = (typeof CERTIDAO_TYPES)[number]; + +export type Certidao = { + /** The 6 digit CNS (Código Nacional de Serventia) of the serventia that issued the act. */ + registryCns: string; + /** Acervo the book belongs to: "01" the serventia's own, "02" a collection it absorbed. */ + acervo: string; + /** Service rendered by the serventia, "55" for registro civil das pessoas naturais. */ + service: string; + /** Four digit year the act was recorded. */ + year: number; + /** The book the act belongs to, as an English name. */ + type: CertidaoType; + /** Raw book code, 1 to 9, as printed in the fifteenth position of the matrícula. */ + typeCode: number; + /** The 5 digit book (livro) number, zero padded. */ + book: string; + /** The 3 digit page (folha) number, zero padded. */ + page: string; + /** The 7 digit term (termo) number, zero padded. */ + term: string; + /** The 2 modulus 11 check digits of the matrícula. */ + checkDigits: string; +}; + +/** + * Parses the matrícula of a certidão de registro civil into its fields. + * + * Accepts the same input forms as `isValidCertidao` and returns `null` when the matrícula is + * not valid or when its book code is not one of the nine books defined by the Provimento, since + * an unknown book cannot be named. + * + * @param {string|number} value - The matrícula value to be parsed. + * @returns {Certidao | null} The parsed matrícula, or `null` when it is not valid. + * + * @example + * ```typescript + * parseCertidao("104539 01 55 2013 1 00012 021 0000123 21"); + * // { registryCns: "104539", acervo: "01", service: "55", year: 2013, type: "birth", + * // typeCode: 1, book: "00012", page: "021", term: "0000123", checkDigits: "21" } + * + * parseCertidao("invalid"); // null + * ``` + * + * @see Official: Provimento CNJ 46/2015, art. 1º and Anexo (Código Nacional de Serventias). + * @see Based on: http://ghiorzi.org/DVnew.htm Worked example of the two check digits + * (sums 288 and 309). + * @see Based on: https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts + * Reference implementation, and the source of the matrículas used as test vectors. + * @see Based on: https://github.com/geekcom/validator-docs/blob/master/src/validator-docs/Rules/Certidao.php + * Third reference implementation agreeing on the weights and on the remainder of 10 read as 1. + */ +export const parseCertidao = (value: string | number): Certidao | null => { + if (!isValidCertidao(value)) return null; + + const digits = sanitizeToDigits(value); + const typeCode = digits.charCodeAt(14) - 48; + + if (typeCode < 1 || typeCode > CERTIDAO_TYPES.length) return null; + + return { + registryCns: digits.slice(0, 6), + acervo: digits.slice(6, 8), + service: digits.slice(8, 10), + year: Number(digits.slice(10, 14)), + type: CERTIDAO_TYPES[typeCode - 1], + typeCode, + book: digits.slice(15, 20), + page: digits.slice(20, 23), + term: digits.slice(23, CERTIDAO_BASE_LENGTH), + checkDigits: digits.slice(CERTIDAO_BASE_LENGTH), + }; +};