diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index 1d56b36b0..4c3cb3d7d 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -721,11 +721,14 @@ Fonte: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legis Formata um número de telefone de acordo com os padrões brasileiros. Se `value` incluir o DDD, informe `{ mask: 'auto' }` ou `'nanp'`: a máscara padrão `"sn"` assume que não há DDD e o trunca. -- **Opções** (`FormatPhoneOptions`): `mask` (`PhoneMask`, padrão `"sn"`) escolhe um dos padrões abaixo. Uma `mask` desconhecida recai para `"sn"`. +- **Opções** (`FormatPhoneOptions`): `mask` (`PhoneMask`, padrão `"sn"`) escolhe um dos padrões abaixo. Uma `mask` desconhecida recai para `"sn"`. `obfuscate` (padrão `false`) esconde o número do assinante em todas as máscaras. - `"sn"`: apenas o número assinante, 9 dígitos. `"nanp"`: DDD mais número assinante, 11 dígitos para celular e 10 para fixo; outros tamanhos mantêm o agrupamento de 11 dígitos. - `"e164"` e `"international"` removem antes o código de país, como `parsePhone`, e recaem para `"service"` para um número de serviço. - `"service"`: os Códigos Não Geográficos (`0800 123 4567`) e os números abreviados `300X`/`400X` (`4004-1234`). - `"auto"`: `"service"` para um número de serviço, `"international"` quando `value` traz código de país, senão `"nanp"` para mais de 9 dígitos, ou `"sn"`. +- O `obfuscate` mantém 2 dígitos, a contagem que a conta gov.br usa para o celular cadastrado, e mantém o prefixo que indica uma região ou um serviço, e não um assinante: o DDD, o código do tipo `0800` e a raiz `300X`/`400X`. +- Os 2 dígitos são os últimos que cabem na própria máscara, então na máscara padrão `"sn"` um valor com DDD é truncado antes, igual ao que acontece sem `obfuscate`, e o par visível é o 8º e o 9º dígito, e não os 2 últimos de `value`. +- Um código de utilidade pública de 3 dígitos (`190`) não identifica ninguém e é devolvido como está; num valor que a máscara `"service"` não reconhece cada dígito vira um `*`, o que esconde os dígitos, mas não quantos eram. Os padrões ofuscados têm um número fixo de posições, então em `"e164"` o que passa do 11º dígito nacional é descartado. ```javascript import { formatPhone } from '@brazilian-utils/brazilian-utils'; @@ -741,10 +744,19 @@ formatPhone('08001234567', { mask: 'service' }); // 0800 123 4567 formatPhone('40041234', { mask: 'service' }); // 4004-1234 formatPhone('+5511987654321', { mask: 'auto' }); // +55 11 98765-4321 ("auto" detecta o prefixo +55 e escolhe "international") formatPhone('5508001234567', { mask: 'auto' }); // 0800 123 4567 ("auto" lê o número 0800, não um +55 08) +formatPhone('987654321', { obfuscate: true }); // *****-**21 +formatPhone('11987654321', { mask: 'auto', obfuscate: true }); // (11) *****-**21 +formatPhone('1130000000', { mask: 'auto', obfuscate: true }); // (11) ****-**00 +formatPhone('+5511987654321', { mask: 'auto', obfuscate: true }); // +55 11 *****-**21 +formatPhone('11987654321', { mask: 'e164', obfuscate: true }); // +5511*******21 +formatPhone('08001234567', { mask: 'service', obfuscate: true }); // 0800 *** **67 +formatPhone('40041234', { mask: 'service', obfuscate: true }); // 4004-**34 +formatPhone('11988887766', { mask: 'service', obfuscate: true }); // *********** (não é número de serviço) +formatPhone('11987654321', { obfuscate: true }); // *****-**43 (CUIDADO: a "sn" trunca antes, então "43", e não "21") formatPhone('11900000000'); // 11900-0000 (CUIDADO: a máscara padrão "sn" trunca um número com DDD) ``` -Fonte: [ITU-T E.164](https://www.itu.int/rec/T-REC-E.164), [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749). +Fonte: [ITU-T E.164](https://www.itu.int/rec/T-REC-E.164), [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749), [conta gov.br](https://acesso.gov.br/faq/_perguntasdafaq/formarrecuperarconta.html) para quantos dígitos de um celular ficam visíveis. ### parsePhone @@ -1009,13 +1021,16 @@ isValidPis('12056412547'); // false Formata um PIS. -- **Opções** (`FormatPisOptions`): `pad` completa o valor com zeros à esquerda até 11 dígitos antes de aplicar a máscara (padrão `false`). +- **Opções** (`FormatPisOptions`): `pad` completa o valor com zeros à esquerda até 11 dígitos antes de aplicar a máscara (padrão `false`); `obfuscate` esconde os 3 primeiros dígitos e o dígito verificador. +- O `obfuscate` é aplicado depois do `pad`. +- Nenhuma autoridade publica uma regra de mascaramento para o PIS, então o `obfuscate` usa a que a Lei nº 12.309/2010, art. 87, § 5º define para o CPF ("ocultar os três primeiros dígitos e os dois dígitos verificadores"), um número com a mesma estrutura. ```javascript import { formatPis } from '@brazilian-utils/brazilian-utils'; formatPis('12345678901'); // 123.45678.90-1 formatPis('123456789', { pad: true }); // 001.23456.78-9 +formatPis('12345678901', { obfuscate: true }); // ***.45678.90-* ``` ### parsePis @@ -1038,6 +1053,8 @@ import { generatePis } from '@brazilian-utils/brazilian-utils'; generatePis(); // '91077906857' ``` +Fonte: [Lei nº 12.309/2010, art. 87, § 5º](https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm), a regra de mascaramento do CPF que o `obfuscate` toma emprestada. + ## Processo jurídico ### isValidProcessoJuridico @@ -1992,13 +2009,16 @@ Fonte: [Resolução CONTRAN nº 886/2021, art. 4º](https://www.gov.br/transport Formata uma CNH. -- **Opções** (`FormatCnhOptions`): `pad` completa o valor com zeros à esquerda até os 11 dígitos antes de aplicar a máscara (padrão `false`). +- **Opções** (`FormatCnhOptions`): `pad` completa o valor com zeros à esquerda até os 11 dígitos antes de aplicar a máscara (padrão `false`); `obfuscate` esconde os 3 primeiros dígitos e os 2 dígitos verificadores. +- O `obfuscate` é aplicado depois do `pad`. +- Nenhuma autoridade publica uma regra de mascaramento para a CNH, então o `obfuscate` usa a que a Lei nº 12.309/2010, art. 87, § 5º define para o CPF ("ocultar os três primeiros dígitos e os dois dígitos verificadores"), um número com a mesma estrutura. ```javascript import { formatCnh } from '@brazilian-utils/brazilian-utils'; formatCnh('02650306461'); // 026503064-61 formatCnh('2650306461', { pad: true }); // 026503064-61 +formatCnh('02650306461', { obfuscate: true }); // ***503064-** ``` ### parseCnh @@ -2021,6 +2041,8 @@ import { generateCnh } from '@brazilian-utils/brazilian-utils'; generateCnh(); // '02650306461' ``` +Fonte: [Lei nº 12.309/2010, art. 87, § 5º](https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm), a regra de mascaramento do CPF que o `obfuscate` toma emprestada. + ## Natureza jurídica ### isValidLegalNature @@ -2185,13 +2207,16 @@ Fonte: [Resolução TSE nº 23.659/2021, art. 36](https://www.tse.jus.br/legisla Formata um título de eleitor com o agrupamento de 12 dígitos `0000 0000 00 00`. +- **Opções** (`FormatVoterIdOptions`): `obfuscate` esconde os 3 primeiros dígitos e os 2 dígitos verificadores, deixando visível o código da unidade federativa. - O agrupamento de 13 dígitos `0000 0000 0 00 00` só é usado quando o valor tem mais de 12 dígitos e o código da UF (o 10º e o 11º dígitos) é `01` ou `02`. - Os dígitos além da última posição do padrão são descartados. +- Nenhuma autoridade publica uma regra de mascaramento para o título de eleitor, então o `obfuscate` usa a que a Lei nº 12.309/2010, art. 87, § 5º define para o CPF ("ocultar os três primeiros dígitos e os dois dígitos verificadores"), um número com a mesma estrutura. ```javascript import { formatVoterId } from '@brazilian-utils/brazilian-utils'; formatVoterId('123456780175'); // '1234 5678 01 75' +formatVoterId('123456780175', { obfuscate: true }); // '***4 5678 01 **' formatVoterId('1234567880191'); // '1234 5678 8 01 91' (título de 13 dígitos SP/MG) ``` @@ -2221,6 +2246,8 @@ generateVoterId('SP'); // título de eleitor aleatório válido de São Paulo generateVoterId('XX'); // usa "ZZ" em vez de lançar erro ``` +Fonte: [Lei nº 12.309/2010, art. 87, § 5º](https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm), a regra de mascaramento do CPF que o `obfuscate` toma emprestada. + ## CNS ### isValidCns diff --git a/docs/utilities.md b/docs/utilities.md index 8bba70b00..842e477c0 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -721,11 +721,14 @@ Source: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legi Format a phone number according to Brazilian patterns. If `value` includes a DDD, pass `{ mask: 'auto' }` or `'nanp'`: the default `"sn"` mask assumes no DDD and truncates one. -- **Options** (`FormatPhoneOptions`): `mask` (`PhoneMask`, default `"sn"`) picks one of the patterns below. An unknown `mask` falls back to `"sn"`. +- **Options** (`FormatPhoneOptions`): `mask` (`PhoneMask`, default `"sn"`) picks one of the patterns below. An unknown `mask` falls back to `"sn"`. `obfuscate` (default `false`) hides the subscriber number under every mask. - `"sn"`: subscriber number only, 9 digits. `"nanp"`: DDD plus subscriber number, 11 digits for a mobile and 10 for a landline; any other length keeps the 11 digit grouping. - `"e164"` and `"international"` drop the country code first, as `parsePhone` does, and fall back to `"service"` for a service number. - `"service"`: the Códigos Não Geográficos (`0800 123 4567`) and the abbreviated `300X`/`400X` numbers (`4004-1234`). - `"auto"`: `"service"` for a service number, `"international"` when `value` carries a country code, otherwise `"nanp"` for more than 9 digits, else `"sn"`. +- `obfuscate` keeps 2 digits, the count the gov.br account shows for a registered mobile, and keeps the prefix that names a region or a service instead of a subscriber: the DDD, the `0800`-like code and the `300X`/`400X` root. +- The 2 digits are the last ones the mask itself has room for, so under the default `"sn"` a DDD-prefixed value is truncated first, exactly as it is without `obfuscate`, and the visible pair is the 8th and 9th digit rather than the last 2 of `value`. +- A 3 digit public utility code (`190`) identifies no one and is returned as it is; a value the `"service"` mask does not recognize has every digit replaced by a `*`, which hides the digits but not how many there were. The obfuscated patterns have a fixed number of slots, so under `"e164"` anything past the 11th national digit is dropped. ```javascript import { formatPhone } from '@brazilian-utils/brazilian-utils'; @@ -741,10 +744,19 @@ formatPhone('08001234567', { mask: 'service' }); // 0800 123 4567 formatPhone('40041234', { mask: 'service' }); // 4004-1234 formatPhone('+5511987654321', { mask: 'auto' }); // +55 11 98765-4321 ("auto" detects the +55 prefix and picks "international") formatPhone('5508001234567', { mask: 'auto' }); // 0800 123 4567 ("auto" reads the 0800 number, not a +55 08 one) +formatPhone('987654321', { obfuscate: true }); // *****-**21 +formatPhone('11987654321', { mask: 'auto', obfuscate: true }); // (11) *****-**21 +formatPhone('1130000000', { mask: 'auto', obfuscate: true }); // (11) ****-**00 +formatPhone('+5511987654321', { mask: 'auto', obfuscate: true }); // +55 11 *****-**21 +formatPhone('11987654321', { mask: 'e164', obfuscate: true }); // +5511*******21 +formatPhone('08001234567', { mask: 'service', obfuscate: true }); // 0800 *** **67 +formatPhone('40041234', { mask: 'service', obfuscate: true }); // 4004-**34 +formatPhone('11988887766', { mask: 'service', obfuscate: true }); // *********** (not a service number) +formatPhone('11987654321', { obfuscate: true }); // *****-**43 (BEWARE: "sn" truncates first, so "43", not "21") formatPhone('11900000000'); // 11900-0000 (BEWARE: default "sn" truncates a DDD-prefixed number) ``` -Source: [ITU-T E.164](https://www.itu.int/rec/T-REC-E.164), [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749). +Source: [ITU-T E.164](https://www.itu.int/rec/T-REC-E.164), [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749), [conta gov.br](https://acesso.gov.br/faq/_perguntasdafaq/formarrecuperarconta.html) for how many digits of a mobile stay visible. ### parsePhone @@ -1009,13 +1021,16 @@ isValidPis('12056412547'); // false Format a PIS. -- **Options** (`FormatPisOptions`): `pad` left-pads the value with zeros to 11 digits before masking (default `false`). +- **Options** (`FormatPisOptions`): `pad` left-pads the value with zeros to 11 digits before masking (default `false`); `obfuscate` hides the first 3 digits and the check digit. +- `obfuscate` is applied after `pad`. +- No authority publishes a masking rule for the PIS, so `obfuscate` applies the one Lei nº 12.309/2010, art. 87, § 5º sets for the CPF ("ocultar os três primeiros dígitos e os dois dígitos verificadores"), a number with the same structure. ```javascript import { formatPis } from '@brazilian-utils/brazilian-utils'; formatPis('12345678901'); // 123.45678.90-1 formatPis('123456789', { pad: true }); // 001.23456.78-9 +formatPis('12345678901', { obfuscate: true }); // ***.45678.90-* ``` ### parsePis @@ -1038,6 +1053,8 @@ import { generatePis } from '@brazilian-utils/brazilian-utils'; generatePis(); // '91077906857' ``` +Source: [Lei nº 12.309/2010, art. 87, § 5º](https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm), the CPF masking rule `obfuscate` borrows. + ## Processo jurídico ### isValidProcessoJuridico @@ -1992,13 +2009,16 @@ Source: [Resolução CONTRAN nº 886/2021, art. 4º](https://www.gov.br/transpor Format a CNH. -- **Options** (`FormatCnhOptions`): `pad` left-pads the value with zeros to the full 11 digits before masking (default `false`). +- **Options** (`FormatCnhOptions`): `pad` left-pads the value with zeros to the full 11 digits before masking (default `false`); `obfuscate` hides the first 3 digits and the 2 check digits. +- `obfuscate` is applied after `pad`. +- No authority publishes a masking rule for the CNH, so `obfuscate` applies the one Lei nº 12.309/2010, art. 87, § 5º sets for the CPF ("ocultar os três primeiros dígitos e os dois dígitos verificadores"), a number with the same structure. ```javascript import { formatCnh } from '@brazilian-utils/brazilian-utils'; formatCnh('02650306461'); // 026503064-61 formatCnh('2650306461', { pad: true }); // 026503064-61 +formatCnh('02650306461', { obfuscate: true }); // ***503064-** ``` ### parseCnh @@ -2021,6 +2041,8 @@ import { generateCnh } from '@brazilian-utils/brazilian-utils'; generateCnh(); // '02650306461' ``` +Source: [Lei nº 12.309/2010, art. 87, § 5º](https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm), the CPF masking rule `obfuscate` borrows. + ## Legal nature ### isValidLegalNature @@ -2185,13 +2207,16 @@ Source: [Resolução TSE nº 23.659/2021, art. 36](https://www.tse.jus.br/legisl Format a voter ID number with the 12-digit grouping `0000 0000 00 00`. +- **Options** (`FormatVoterIdOptions`): `obfuscate` hides the first 3 digits and the 2 check digits, leaving the federative union code visible. - The 13-digit grouping `0000 0000 0 00 00` is used only when the value has more than 12 digits and its UF code (the 10th and 11th digits) is `01` or `02`. - Digits past the last slot of the pattern are dropped. +- No authority publishes a masking rule for the voter ID, so `obfuscate` applies the one Lei nº 12.309/2010, art. 87, § 5º sets for the CPF ("ocultar os três primeiros dígitos e os dois dígitos verificadores"), a number with the same structure. ```javascript import { formatVoterId } from '@brazilian-utils/brazilian-utils'; formatVoterId('123456780175'); // '1234 5678 01 75' +formatVoterId('123456780175', { obfuscate: true }); // '***4 5678 01 **' formatVoterId('1234567880191'); // '1234 5678 8 01 91' (13-digit SP/MG voter id) ``` @@ -2221,6 +2246,8 @@ generateVoterId('SP'); // valid random voter ID for Sao Paulo generateVoterId('XX'); // falls back to "ZZ" instead of throwing ``` +Source: [Lei nº 12.309/2010, art. 87, § 5º](https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm), the CPF masking rule `obfuscate` borrows. + ## CNS ### isValidCns diff --git a/reports/api/brazilian-utils.api.md b/reports/api/brazilian-utils.api.md index d24a1ffb0..1c86f12e4 100644 --- a/reports/api/brazilian-utils.api.md +++ b/reports/api/brazilian-utils.api.md @@ -214,6 +214,7 @@ export const formatCnh: (value: string | number, options?: FormatCnhOptions) => // @public export type FormatCnhOptions = { pad?: boolean; + obfuscate?: boolean; }; // @public @@ -305,6 +306,7 @@ export const formatPhone: (value: string | number, options?: FormatPhoneOptions) // @public export type FormatPhoneOptions = { mask?: PhoneMask; + obfuscate?: boolean; }; // @public @@ -313,6 +315,7 @@ export const formatPis: (value: string | number, options?: FormatPisOptions) => // @public export type FormatPisOptions = { pad?: boolean; + obfuscate?: boolean; }; // @public @@ -332,7 +335,12 @@ export type FormatSuframaOptions = { }; // @public -export const formatVoterId: (value: string | number) => string; +export const formatVoterId: (value: string | number, options?: FormatVoterIdOptions) => string; + +// @public +export type FormatVoterIdOptions = { + obfuscate?: boolean; +}; // @public export const generateBoleto: (params?: GenerateBoletoParams) => string; diff --git a/scripts/llms.ts b/scripts/llms.ts index 6b0ea9ce9..b49545ac3 100644 --- a/scripts/llms.ts +++ b/scripts/llms.ts @@ -57,15 +57,16 @@ function slugify(heading: string): string { const ABBREVIATION_PLACEHOLDER = String.fromCharCode(1); /** - * Extracts the first sentence of a paragraph, treating `e.g.`/`i.e.` as - * abbreviations rather than sentence boundaries. + * Extracts the first sentence of a paragraph, treating `e.g.`, `i.e.` and `art.` (the way a + * Brazilian law article is cited, as in "LGPD, art. 6º III") as abbreviations rather than + * sentence boundaries. * @param {string} paragraph - The paragraph to extract the first sentence from. * @returns {string} The first sentence of `paragraph`. */ function firstSentence(paragraph: string): string { const withoutLinks = paragraph.replaceAll(/\[([^\]]+)\]\([^)]+\)/g, "$1"); const protectedText = withoutLinks.replaceAll( - /\b(e\.g|i\.e)\./gi, + /\b(e\.g|i\.e|art)\./gi, (_match, abbr: string) => `${abbr}${ABBREVIATION_PLACEHOLDER}`, ); const match = /[\s\S]*?[.!?](?=\s|$)/.exec(protectedText); diff --git a/src/format-cnh/constants.ts b/src/format-cnh/constants.ts new file mode 100644 index 000000000..7bef84033 --- /dev/null +++ b/src/format-cnh/constants.ts @@ -0,0 +1,9 @@ +export const PATTERN = "000000000-00"; + +/** + * No authority publishes a masking rule for the CNH registry number, so this applies the rule + * Lei nº 12.309/2010, art. 87, § 5º, sets for the CPF ("ocultar os três primeiros dígitos e os + * dois dígitos verificadores") to a number with the same structure: the first 3 digits and the + * 2 check digits are hidden, e.g. "***456789-**". + */ +export const OBFUSCATED_PATTERN = "***000000-**"; diff --git a/src/format-cnh/format-cnh.test.ts b/src/format-cnh/format-cnh.test.ts index 51e0c9e5d..728495434 100644 --- a/src/format-cnh/format-cnh.test.ts +++ b/src/format-cnh/format-cnh.test.ts @@ -36,6 +36,21 @@ describe("formatCnh", () => { expect(formatCnh()).toBe(""); }); + it("should hide the first 3 digits and the 2 check digits when obfuscate is truthy", () => { + expect(formatCnh("98765432119", { obfuscate: true })).toBe("***654321-**"); + expect(formatCnh(98_765_432_119, { obfuscate: true })).toBe("***654321-**"); + expect(formatCnh("9876", { obfuscate: true })).toBe("***6"); + expect(formatCnh("9876", { pad: true, obfuscate: true })).toBe("***000098-**"); + // @ts-expect-error: intentionally not a boolean + expect(formatCnh("98765432119", { obfuscate: "yes" })).toBe("***654321-**"); + }); + + it("should behave exactly as without the option when obfuscate is falsy", () => { + expect(formatCnh("98765432119", { obfuscate: false })).toBe("987654321-19"); + // @ts-expect-error: intentionally not a boolean + expect(formatCnh("9876", { pad: true, obfuscate: "" })).toBe("000000098-76"); + }); + describe("properties", () => { const upToACnh = digitsUpTo(11); @@ -47,6 +62,14 @@ describe("formatCnh", () => { expectMatchesPattern(formatCnh, /^\d{9}-\d{2}$/, digits(11)); }); + test("should produce the documented obfuscated shape for a full CNH", () => { + expectMatchesPattern( + (value: string) => formatCnh(value, { obfuscate: true }), + /^\*{3}\d{6}-\*{2}$/, + digits(11), + ); + }); + test("should left pad a shorter value up to the CNH length", () => { expectPadsToLength(formatCnh, parseCnh, upToACnh, 11); }); @@ -64,7 +87,8 @@ describe("formatCnh types", () => { expectTypeOf(formatCnh).returns.toEqualTypeOf(); }); - test("should type the pad option as an optional boolean", () => { + test("should type the pad and obfuscate options as optional booleans", () => { expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); }); }); diff --git a/src/format-cnh/format-cnh.ts b/src/format-cnh/format-cnh.ts index 2086a9f8c..188cddd6c 100644 --- a/src/format-cnh/format-cnh.ts +++ b/src/format-cnh/format-cnh.ts @@ -1,11 +1,14 @@ 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 { OBFUSCATED_PATTERN, PATTERN } from "./constants"; /** Options of `formatCnh`. */ export type FormatCnhOptions = { /** Whether to left pad the value with zeros up to the number of slots in the pattern (default: `false`). */ pad?: boolean; + /** Whether to hide the first 3 digits and the 2 check digits with `*` (default: `false`, read for truthiness like `pad`). */ + obfuscate?: boolean; }; /** @@ -14,19 +17,29 @@ export type FormatCnhOptions = { * @param {string|number} value - The CNH number to be formatted. * @param {FormatCnhOptions} [options] - Optional options. * @param {boolean} [options.pad] - Whether to pad the value with leading zeros. + * @param {boolean} [options.obfuscate] - If truthy, hides the first 3 digits and the 2 check + * digits. Read for truthiness, the way `pad` is, so a non-boolean such as `1` obfuscates too. * @returns {string} The formatted CNH, or an empty string when there is nothing to format. * * @example * ```typescript * formatCnh("12345678900"); // "123456789-00" * formatCnh("8900", { pad: true }); // "000000089-00" + * formatCnh("12345678900", { obfuscate: true }); // "***456789-**" * ``` * * Resolução CONTRAN nº 886/2021, art. 4º I, defines the CNH registry number as 9 characters plus * 2 security check digits, which is the layout this mask reproduces; no official text publishes * the check-digit weights used to compute them. * + * No authority publishes a masking rule for the CNH either, so `obfuscate` applies the one Lei nº + * 12.309/2010, art. 87, § 5º, sets for the CPF, a number with the same structure: the first 3 + * digits and the 2 check digits are hidden. + * * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/Resolucao8862021F.pdf + * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm + * Lei nº 12.309/2010, art. 87, § 5º: "ocultar os três primeiros dígitos e os dois dígitos + * verificadores do CPF", the rule `obfuscate` borrows. */ export const formatCnh = (value: string | number, options?: FormatCnhOptions): string => isNullish(value) @@ -34,5 +47,5 @@ export const formatCnh = (value: string | number, options?: FormatCnhOptions): s : format({ pad: options?.pad, value: sanitizeToDigits(value), - pattern: "000000000-00", + pattern: (options?.obfuscate ?? false) ? OBFUSCATED_PATTERN : PATTERN, }); diff --git a/src/format-phone/constants.ts b/src/format-phone/constants.ts index c332d91a1..b6a339330 100644 --- a/src/format-phone/constants.ts +++ b/src/format-phone/constants.ts @@ -20,21 +20,76 @@ export const DEFAULT_MASK = "sn"; /** Length of a bare Brazilian subscriber number, the boundary the `"auto"` mask reads. */ export const SN_LENGTH = 9; -export const MASK: Record = { - sn: "00000-0000", - nanp: "(00) 00000-0000", +/** The patterns `formatPhone` reads, one set for the plain output and one for the obfuscated. */ +export type PhoneMasks = { + /** The patterns of the `"sn"` and `"nanp"` masks. */ + national: Record; + /** The `"nanp"` pattern of a 10 digit landline. */ + nanpLandline: string; + /** The patterns of the `"international"` mask, without the `+55` prefix. */ + international: PhoneLineMasks; + /** The patterns of the `"service"` mask. */ + service: PhoneServiceMasks; }; -export const NANP_LANDLINE_MASK = "(00) 0000-0000"; +/** One pattern per kind of line, picked by the number of digits. */ +export type PhoneLineMasks = { + /** DDD + 8 digits. */ + landline: string; + /** DDD + 9 digits. */ + mobile: string; +}; -export const INTERNATIONAL_PREFIX = "+55"; +/** One pattern per family of service number. */ +export type PhoneServiceMasks = { + /** The `300X`/`400X` numbers. */ + abbreviated: string; + /** The Códigos Não Geográficos (`0300`, `0303`, `0500`, `0800`, `0900`). */ + nonGeographic: string; +}; -export const INTERNATIONAL_MASK = { - landline: "00 0000-0000", - mobile: "00 00000-0000", +export const MASKS: PhoneMasks = { + national: { + sn: "00000-0000", + nanp: "(00) 00000-0000", + }, + nanpLandline: "(00) 0000-0000", + international: { + landline: "00 0000-0000", + mobile: "00 00000-0000", + }, + service: { + abbreviated: "0000-0000", + nonGeographic: "0000 000 0000", + }, }; -export const SERVICE_MASK = { - abbreviated: "0000-0000", - nonGeographic: "0000 000 0000", +/** + * The gov.br account (acesso.gov.br) shows the registered mobile as "*********00": only the last + * 2 digits stay visible. These patterns keep that count and also keep the prefix that names a + * region or a service instead of a subscriber, the DDD, the `0800`-like code or the `300X`/`400X` + * root, e.g. "(11) *****-**21", "+55 11 ****-**00", "0800 *** **67" and "4004-**34". + */ +export const OBFUSCATED_MASKS: PhoneMasks = { + national: { + sn: "*****-**00", + nanp: "(00) *****-**00", + }, + nanpLandline: "(00) ****-**00", + international: { + landline: "00 ****-**00", + mobile: "00 *****-**00", + }, + service: { + abbreviated: "0000-**00", + nonGeographic: "0000 *** **00", + }, }; + +/** The obfuscated `"e164"` patterns, without the `+55` prefix: the plain output has no pattern. */ +export const OBFUSCATED_E164_MASK: PhoneLineMasks = { + landline: "00******00", + mobile: "00*******00", +}; + +export const INTERNATIONAL_PREFIX = "+55"; diff --git a/src/format-phone/format-phone.test.ts b/src/format-phone/format-phone.test.ts index 2f42af556..dd437c68e 100644 --- a/src/format-phone/format-phone.test.ts +++ b/src/format-phone/format-phone.test.ts @@ -167,6 +167,91 @@ describe("formatPhone", () => { expect(formatPhone("11988887777", { mask: "bogus" })).toBe("11988-8877"); }); + it("should hide the subscriber number except its last 2 digits under the national masks", () => { + expect(formatPhone("988887766", { obfuscate: true })).toBe("*****-**66"); + expect(formatPhone(988_887_766, { obfuscate: true })).toBe("*****-**66"); + expect(formatPhone("11988887766", { mask: "nanp", obfuscate: true })).toBe("(11) *****-**66"); + expect(formatPhone("1130001234", { mask: "nanp", obfuscate: true })).toBe("(11) ****-**34"); + expect(formatPhone("11988887766", { mask: "auto", obfuscate: true })).toBe("(11) *****-**66"); + expect(formatPhone("1130001234", { mask: "auto", obfuscate: true })).toBe("(11) ****-**34"); + expect(formatPhone("988887766", { mask: "auto", obfuscate: true })).toBe("*****-**66"); + }); + + it("should truncate a DDD-prefixed value under the default mask before obfuscating it", () => { + expect(formatPhone("11988887766", { obfuscate: true })).toBe("*****-**77"); + expect(formatPhone("11988887766", { mask: "sn", obfuscate: true })).toBe("*****-**77"); + expect(formatPhone("1130001234", { mask: "sn", obfuscate: true })).toBe("*****-**23"); + }); + + it("should keep the country code and the DDD when obfuscating the international masks", () => { + expect(formatPhone("11988887766", { mask: "international", obfuscate: true })).toBe( + "+55 11 *****-**66", + ); + expect(formatPhone("+55 11 3000-1234", { mask: "international", obfuscate: true })).toBe( + "+55 11 ****-**34", + ); + expect(formatPhone("5511988887766", { mask: "auto", obfuscate: true })).toBe( + "+55 11 *****-**66", + ); + expect(formatPhone("11988887766", { mask: "e164", obfuscate: true })).toBe("+5511*******66"); + expect(formatPhone("1130001234", { mask: "e164", obfuscate: true })).toBe("+5511******34"); + }); + + it("should keep the service prefix and the last 2 digits when obfuscating a service number", () => { + expect(formatPhone("08001234567", { mask: "service", obfuscate: true })).toBe("0800 *** **67"); + expect(formatPhone("03031234567", { mask: "auto", obfuscate: true })).toBe("0303 *** **67"); + expect(formatPhone("40041234", { mask: "service", obfuscate: true })).toBe("4004-**34"); + expect(formatPhone("30031234", { mask: "auto", obfuscate: true })).toBe("3003-**34"); + expect(formatPhone("08001234567", { mask: "e164", obfuscate: true })).toBe("0800 *** **67"); + expect(formatPhone("40041234", { mask: "international", obfuscate: true })).toBe("4004-**34"); + }); + + it("should return a public utility code whole when obfuscating", () => { + expect(formatPhone("190", { mask: "service", obfuscate: true })).toBe("190"); + expect(formatPhone("190", { mask: "auto", obfuscate: true })).toBe("190"); + }); + + it("should hide entirely a value the service mask does not recognize when obfuscating", () => { + expect(formatPhone("11988887766", { mask: "service", obfuscate: true })).toBe("***********"); + expect(formatPhone("08", { mask: "service", obfuscate: true })).toBe("**"); + expect(formatPhone("11988887766", { mask: "service" })).toBe("11988887766"); + }); + + it("should obfuscate a partial value as far as it goes", () => { + expect(formatPhone("1198", { mask: "nanp", obfuscate: true })).toBe("(11) **"); + expect(formatPhone("11988", { mask: "international", obfuscate: true })).toBe("+55 11 ***"); + expect(formatPhone("0800123", { mask: "service", obfuscate: true })).toBe("0800 ***"); + expect(formatPhone("", { mask: "e164", obfuscate: true })).toBe(""); + expect(formatPhone("", { mask: "international", obfuscate: true })).toBe(""); + expect(formatPhone("", { mask: "service", obfuscate: true })).toBe(""); + }); + + it("should drop what does not fit the 11 national digits when obfuscating the e164 mask", () => { + expect(formatPhone("119888877660000", { mask: "e164", obfuscate: true })).toBe( + "+5511*******66", + ); + expect(formatPhone("119888877660000", { mask: "e164" })).toBe("+55119888877660000"); + }); + + it("should obfuscate on any truthy obfuscate value", () => { + // @ts-expect-error: intentionally not a boolean + expect(formatPhone("11988887766", { mask: "nanp", obfuscate: 1 })).toBe("(11) *****-**66"); + // @ts-expect-error: intentionally not a boolean + expect(formatPhone("11988887766", { mask: "e164", obfuscate: "yes" })).toBe("+5511*******66"); + }); + + it("should behave exactly as without the option when obfuscate is falsy or absent", () => { + expect(formatPhone("11988887766", { mask: "nanp", obfuscate: false })).toBe("(11) 98888-7766"); + expect(formatPhone("11988887766", { mask: "e164", obfuscate: false })).toBe("+5511988887766"); + expect(formatPhone("08001234567", { mask: "service", obfuscate: false })).toBe("0800 123 4567"); + // @ts-expect-error: intentionally not a boolean + expect(formatPhone("11988887766", { mask: "international", obfuscate: 0 })).toBe( + "+55 11 98888-7766", + ); + // @ts-expect-error: intentionally not a boolean + expect(formatPhone("988887766", { obfuscate: null })).toBe("98888-7766"); + }); + it("should return an empty string for nullish values", () => { // @ts-expect-error: intentionally invalid input expect(formatPhone(null)).toBe(""); @@ -219,14 +304,39 @@ describe("formatPhone", () => { ); }); - test("should never throw and always return the phone number as a string", () => { + test("should show only the DDD and the last 2 digits of a generated number when obfuscating", () => { fc.assert( - fc.property(fc.string({ unit: "grapheme" }), fc.integer(), (text, number) => { - expect(typeof formatPhone(text)).toBe("string"); - expect(typeof formatPhone(number)).toBe("string"); + fc.property(fc.gen(), fc.constantFrom(...geographic), (g, type) => { + const phone = g(phones, type); + const visible = `${phone.slice(0, 2)}${phone.slice(-2)}`; + const obfuscated = formatPhone(phone, { mask: "auto", obfuscate: true }); + + expect(obfuscated).toMatch(/^\(\d{2}\) \*{4,5}-\*{2}\d{2}$/); + expect(obfuscated.replaceAll(/\D/g, "")).toBe(visible); + expect(obfuscated).toHaveLength(formatPhone(phone, { mask: "auto" }).length); + expect(formatPhone(phone, { mask: "e164", obfuscate: true })).toMatch( + /^\+55\d{2}\*{6,7}\d{2}$/, + ); }), ); }); + + test("should never throw and always return the phone number as a string", () => { + fc.assert( + fc.property( + fc.string({ unit: "grapheme" }), + fc.integer(), + fc.anything(), + (text, number, options) => { + expect(typeof formatPhone(text)).toBe("string"); + expect(typeof formatPhone(number)).toBe("string"); + expect(typeof formatPhone(text, { mask: "auto", obfuscate: true })).toBe("string"); + // @ts-expect-error: intentionally invalid options + expect(typeof formatPhone(text, options)).toBe("string"); + }, + ), + ); + }); }); }); @@ -243,4 +353,8 @@ describe("formatPhone types", () => { "auto" | "e164" | "international" | "service" | "sn" | "nanp" >(); }); + + test("should type the obfuscate option as an optional boolean", () => { + expectTypeOf().toEqualTypeOf(); + }); }); diff --git a/src/format-phone/format-phone.ts b/src/format-phone/format-phone.ts index 92bf9ac1b..121b7282f 100644 --- a/src/format-phone/format-phone.ts +++ b/src/format-phone/format-phone.ts @@ -10,13 +10,14 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d import { isValidServicePhone } from "../is-valid-service-phone/is-valid-service-phone"; import { DEFAULT_MASK, - INTERNATIONAL_MASK, INTERNATIONAL_PREFIX, - MASK, - NANP_LANDLINE_MASK, + MASKS, type NationalMask, + OBFUSCATED_E164_MASK, + OBFUSCATED_MASKS, PHONE_MASKS, - SERVICE_MASK, + type PhoneLineMasks, + type PhoneMasks, SN_LENGTH, } from "./constants"; @@ -27,6 +28,8 @@ export type PhoneMask = "auto" | "e164" | "international" | "service" | "sn" | " export type FormatPhoneOptions = { /** Which mask to apply, or `"auto"` to pick one from the value (default: `"sn"`). */ mask?: PhoneMask; + /** Whether to hide the subscriber number with `*`, except the last 2 digits the mask has room for (default: `false`, read for truthiness). */ + obfuscate?: boolean; }; const matchesPrefix = (digits: string, prefixes: readonly string[]): boolean => @@ -37,37 +40,53 @@ const matchesPrefix = (digits: string, prefixes: readonly string[]): boolean => * below, so it matches none of them and is returned as it came, which is exactly what both * masks would print for it anyway (their first separator only appears once the value is longer * than the prefix that selects the mask). + * + * Under `obfuscate` such a value has no prefix that tells which digits are safe to show, so + * every one of its digits becomes a `*` and only how many there were stays visible, unless it + * is one of the 3 digit public utility codes (`190`), which identify no subscriber. * @param {string} digits - The digits of a service number. + * @param {boolean} obfuscate - Whether to hide the digits after the prefix, except the last 2. * @returns {string} The digits under the mask of their service number family. */ -const formatService = (digits: string): string => { +const formatService = (digits: string, obfuscate: boolean): string => { + const masks = obfuscate ? OBFUSCATED_MASKS.service : MASKS.service; + if (matchesPrefix(digits, SERVICE_PHONE_NON_GEOGRAPHIC_PREFIXES)) { - return format({ value: digits, pattern: SERVICE_MASK.nonGeographic }); + return format({ value: digits, pattern: masks.nonGeographic }); } if (matchesPrefix(digits, SERVICE_PHONE_ABBREVIATED_ROOTS)) { - return format({ value: digits, pattern: SERVICE_MASK.abbreviated }); + return format({ value: digits, pattern: masks.abbreviated }); } - return digits; + return obfuscate && !isValidServicePhone(digits) ? "*".repeat(digits.length) : digits; }; -const formatInternational = (national: string): string => { +const resolveLinePattern = (national: string, masks: PhoneLineMasks): string => + national.length > PHONE_NATIONAL_MIN_LENGTH ? masks.mobile : masks.landline; + +const formatInternational = (national: string, masks: PhoneMasks): string => { if (!national) return ""; - const pattern = - national.length > PHONE_NATIONAL_MIN_LENGTH - ? INTERNATIONAL_MASK.mobile - : INTERNATIONAL_MASK.landline; + const pattern = resolveLinePattern(national, masks.international); return `${INTERNATIONAL_PREFIX} ${format({ value: national, pattern })}`; }; -const formatE164 = (national: string): string => - national ? `${INTERNATIONAL_PREFIX}${national}` : ""; +const formatE164 = (national: string, obfuscate: boolean): string => { + if (!national) return ""; + + if (!obfuscate) return `${INTERNATIONAL_PREFIX}${national}`; -const resolveNationalPattern = (digits: string, mask: NationalMask): string => - mask === "nanp" && digits.length === PHONE_NATIONAL_MIN_LENGTH ? NANP_LANDLINE_MASK : MASK[mask]; + const pattern = resolveLinePattern(national, OBFUSCATED_E164_MASK); + + return `${INTERNATIONAL_PREFIX}${format({ value: national, pattern })}`; +}; + +const resolveNationalPattern = (digits: string, mask: NationalMask, masks: PhoneMasks): string => + mask === "nanp" && digits.length === PHONE_NATIONAL_MIN_LENGTH + ? masks.nanpLandline + : masks.national[mask]; const resolveAutoMask = (digits: string, serviceDigits: string): Exclude => { if (isValidServicePhone(serviceDigits)) return "service"; @@ -112,9 +131,24 @@ const isPhoneMask = (value: unknown): value is PhoneMask => PHONE_MASKS.has(valu * do not rely on the default, since the default `"sn"` mask assumes no DDD is present. A `mask` * outside the union falls back to the default `"sn"` instead of throwing. * + * `options.obfuscate` hides the subscriber number under every mask, for the places where a + * number is shown to someone who should only recognize it (LGPD, art. 6º III, necessidade). The + * gov.br account shows the registered mobile as `"*********00"`, only the last 2 digits, and + * this keeps that count. The 2 digits are the last ones the mask itself has room for, so under + * the default `"sn"` a DDD-prefixed value is truncated first, exactly as it is without + * `obfuscate`, and the visible pair is the 8th and 9th digit rather than the last 2 of `value`. + * The prefix that names a region or a service instead of a subscriber also stays: the DDD, the + * `0800`-like code and the `300X`/`400X` root. A 3 digit public utility code (`190`) identifies + * no one and is returned as it is, and a value the `"service"` mask does not recognize has + * every digit replaced by a `*`, which hides the digits but not how many there were. The + * patterns have a fixed number of slots, so under `"e164"` anything past the 11th national + * digit is dropped. + * * @param {string|number} value - The phone number to format, either as a string or a number. * @param {FormatPhoneOptions} [options] - Optional formatting options. * @param {"auto"|"sn"|"nanp"|"e164"|"international"|"service"} options.mask - The mask to apply for formatting the phone number (default: `"sn"`). + * @param {boolean} options.obfuscate - If truthy, hides the subscriber number except the last 2 + * digits the mask has room for. Read for truthiness, so a non-boolean such as `1` obfuscates too. * @returns {string} The formatted phone number as a string. * * @example @@ -128,11 +162,23 @@ const isPhoneMask = (value: unknown): value is PhoneMask => PHONE_MASKS.has(valu * formatPhone("11987654321", { mask: "e164" }); // "+5511987654321" * formatPhone("11987654321", { mask: "international" }); // "+55 11 98765-4321" * formatPhone("40041234", { mask: "service" }); // "4004-1234" + * formatPhone("987654321", { obfuscate: true }); // "*****-**21" + * formatPhone("11987654321", { mask: "auto", obfuscate: true }); // "(11) *****-**21" + * formatPhone("1130000000", { mask: "auto", obfuscate: true }); // "(11) ****-**00" + * formatPhone("5511987654321", { mask: "auto", obfuscate: true }); // "+55 11 *****-**21" + * formatPhone("11987654321", { mask: "e164", obfuscate: true }); // "+5511*******21" + * formatPhone("08001234567", { mask: "auto", obfuscate: true }); // "0800 *** **67" + * formatPhone("40041234", { mask: "service", obfuscate: true }); // "4004-**34" + * formatPhone("11988887766", { mask: "service", obfuscate: true }); // "***********" (not a service number) * formatPhone("11987654321"); // "11987-6543" (BEWARE: default "sn" truncates a DDD-prefixed number) + * formatPhone("11987654321", { obfuscate: true }); // "*****-**43" (BEWARE: truncated too, so "43", not "21") * ``` * * @see Official: https://www.itu.int/rec/T-REC-E.164 * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 + * @see Official: https://acesso.gov.br/faq/_perguntasdafaq/formarrecuperarconta.html + * The gov.br account FAQ, whose "Recuperar senha com celular" screen shows the registered mobile + * as `"*********00"`, the convention `obfuscate` follows for the number of visible digits. */ export const formatPhone = (value: string | number, options?: FormatPhoneOptions): string => { const enhancedValue = sanitizeToDigits(value); @@ -142,18 +188,21 @@ export const formatPhone = (value: string | number, options?: FormatPhoneOptions const requested: PhoneMask = isPhoneMask(givenMask) ? givenMask : DEFAULT_MASK; const mask = requested === "auto" ? resolveAutoMask(enhancedValue, serviceDigits) : requested; - if (mask === "service") return formatService(serviceDigits); + const obfuscate = Boolean(options?.obfuscate); + const masks = obfuscate ? OBFUSCATED_MASKS : MASKS; + + if (mask === "service") return formatService(serviceDigits, obfuscate); if (mask === "e164" || mask === "international") { - if (isValidServicePhone(serviceDigits)) return formatService(serviceDigits); + if (isValidServicePhone(serviceDigits)) return formatService(serviceDigits, obfuscate); const national = normalizePhone(enhancedValue); - return mask === "e164" ? formatE164(national) : formatInternational(national); + return mask === "e164" ? formatE164(national, obfuscate) : formatInternational(national, masks); } return format({ value: enhancedValue, - pattern: resolveNationalPattern(enhancedValue, mask), + pattern: resolveNationalPattern(enhancedValue, mask, masks), }); }; diff --git a/src/format-pis/constants.ts b/src/format-pis/constants.ts new file mode 100644 index 000000000..8dcfcca4b --- /dev/null +++ b/src/format-pis/constants.ts @@ -0,0 +1,9 @@ +export const PATTERN = "000.00000.00-0"; + +/** + * No authority publishes a masking rule for the PIS/PASEP/NIS, so this applies the rule Lei + * nº 12.309/2010, art. 87, § 5º, sets for the CPF ("ocultar os três primeiros dígitos e os dois + * dígitos verificadores") to a number with the same structure: the first 3 digits and the + * single check digit are hidden, e.g. "***.45678.90-*". + */ +export const OBFUSCATED_PATTERN = "***.00000.00-*"; diff --git a/src/format-pis/format-pis.test.ts b/src/format-pis/format-pis.test.ts index 188b3d52c..261b7238e 100644 --- a/src/format-pis/format-pis.test.ts +++ b/src/format-pis/format-pis.test.ts @@ -91,6 +91,21 @@ describe("formatPis", () => { expect(formatPis()).toBe(""); }); + it("should hide the first 3 digits and the check digit when obfuscate is truthy", () => { + expect(formatPis("12056412547", { obfuscate: true })).toBe("***.56412.54-*"); + expect(formatPis(12_056_412_547, { obfuscate: true })).toBe("***.56412.54-*"); + expect(formatPis("1205", { obfuscate: true })).toBe("***.5"); + expect(formatPis("1205", { pad: true, obfuscate: true })).toBe("***.00001.20-*"); + // @ts-expect-error: intentionally not a boolean + expect(formatPis("12056412547", { obfuscate: 1 })).toBe("***.56412.54-*"); + }); + + it("should behave exactly as without the option when obfuscate is falsy", () => { + expect(formatPis("12056412547", { obfuscate: false })).toBe("120.56412.54-7"); + // @ts-expect-error: intentionally not a boolean + expect(formatPis("1205", { pad: true, obfuscate: 0 })).toBe("000.00001.20-5"); + }); + describe("properties", () => { const upToAPis = digitsUpTo(11); @@ -102,6 +117,14 @@ describe("formatPis", () => { expectMatchesPattern(formatPis, /^\d{3}\.\d{5}\.\d{2}-\d$/, digits(11)); }); + test("should produce the documented obfuscated shape for a full PIS", () => { + expectMatchesPattern( + (value: string) => formatPis(value, { obfuscate: true }), + /^\*{3}\.\d{5}\.\d{2}-\*$/, + digits(11), + ); + }); + test("should left pad a shorter value up to the PIS length", () => { expectPadsToLength(formatPis, parsePis, upToAPis, PIS_LENGTH); }); @@ -119,7 +142,8 @@ describe("formatPis types", () => { expectTypeOf(formatPis).returns.toEqualTypeOf(); }); - test("should type the pad option as an optional boolean", () => { + test("should type the pad and obfuscate options as optional booleans", () => { expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); }); }); diff --git a/src/format-pis/format-pis.ts b/src/format-pis/format-pis.ts index 822241d6f..1d7026695 100644 --- a/src/format-pis/format-pis.ts +++ b/src/format-pis/format-pis.ts @@ -1,11 +1,14 @@ 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 { OBFUSCATED_PATTERN, PATTERN } from "./constants"; /** Options of `formatPis`. */ export type FormatPisOptions = { /** Whether to left pad the value with zeros up to the number of slots in the pattern (default: `false`). */ pad?: boolean; + /** Whether to hide the first 3 digits and the check digit with `*` (default: `false`, read for truthiness like `pad`). */ + obfuscate?: boolean; }; /** @@ -14,6 +17,8 @@ export type FormatPisOptions = { * @param {string|number} value - The PIS number to be formatted. It can be a string or a number. * @param {FormatPisOptions} [options] - Optional formatting options. * @param {boolean} options.pad - If true, pads the value with leading zeros if necessary. + * @param {boolean} options.obfuscate - If truthy, hides the first 3 digits and the check digit. + * Read for truthiness, the way `pad` is, so a non-boolean such as `1` obfuscates too. * @returns {string} The formatted PIS number as a string. * * @example @@ -21,11 +26,19 @@ export type FormatPisOptions = { * formatPis("12345678901"); // "123.45678.90-1" * formatPis(12345678901); // "123.45678.90-1" * formatPis("123456789", { pad: true }); // "001.23456.78-9" + * formatPis("12345678901", { obfuscate: true }); // "***.45678.90-*" * ``` * + * No authority publishes a masking rule for the PIS, so `obfuscate` applies the one Lei nº + * 12.309/2010, art. 87, § 5º, sets for the CPF, a number with the same structure: the first 3 + * digits and the check digit are hidden. + * * @see Official: https://www.gov.br/inss/pt-br/direitos-e-deveres/inscricao-e-contribuicao/inscricao * @see Official: https://www.gov.br/esocial/pt-br/documentacao-tecnica/manuais/mos-manual-de-orientacao-do-esocial-vs-2-4.pdf * @see Official: https://www.sirc.gov.br/wp-content/uploads/manual_sirc_recomendacoes_tecnicas_v7.pdf + * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm + * Lei nº 12.309/2010, art. 87, § 5º: "ocultar os três primeiros dígitos e os dois dígitos + * verificadores do CPF", the rule `obfuscate` borrows. * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/pis.py */ export const formatPis = (value: string | number, options?: FormatPisOptions): string => @@ -34,5 +47,5 @@ export const formatPis = (value: string | number, options?: FormatPisOptions): s : format({ pad: options?.pad, value: sanitizeToDigits(value), - pattern: "000.00000.00-0", + pattern: (options?.obfuscate ?? false) ? OBFUSCATED_PATTERN : PATTERN, }); diff --git a/src/format-voter-id/constants.ts b/src/format-voter-id/constants.ts new file mode 100644 index 000000000..8e3519d0b --- /dev/null +++ b/src/format-voter-id/constants.ts @@ -0,0 +1,16 @@ +export const PATTERNS = { + standard: "0000 0000 00 00", + extended: "0000 0000 0 00 00", +}; + +/** + * No authority publishes a masking rule for the título de eleitor, so this applies the rule Lei + * nº 12.309/2010, art. 87, § 5º, sets for the CPF ("ocultar os três primeiros dígitos e os dois + * dígitos verificadores") to a number with the same structure: the first 3 digits of the + * sequential number and the 2 check digits are hidden, e.g. "***4 5678 01 **". The federative + * union code stays visible. + */ +export const OBFUSCATED_PATTERNS = { + standard: "***0 0000 00 **", + extended: "***0 0000 0 00 **", +}; diff --git a/src/format-voter-id/format-voter-id.test.ts b/src/format-voter-id/format-voter-id.test.ts index b7e6ae193..11d9a9d45 100644 --- a/src/format-voter-id/format-voter-id.test.ts +++ b/src/format-voter-id/format-voter-id.test.ts @@ -3,7 +3,7 @@ import * as fc from "fast-check"; import { anyValue, digits, digitsUpTo } from "../_internals/test/arbitraries"; import { expectAlwaysReturnsType } from "../_internals/test/properties"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; -import { formatVoterId } from "./format-voter-id"; +import { type FormatVoterIdOptions, formatVoterId } from "./format-voter-id"; describe("formatVoterId", () => { it("should format voter ids", () => { @@ -43,6 +43,23 @@ describe("formatVoterId", () => { expect(formatVoterId("123456788019")).toBe("1234 5678 80 19"); }); + it("should hide the first 3 digits and the 2 check digits when obfuscate is truthy", () => { + expect(formatVoterId("123456780124", { obfuscate: true })).toBe("***4 5678 01 **"); + expect(formatVoterId(123_456_780_124, { obfuscate: true })).toBe("***4 5678 01 **"); + expect(formatVoterId("1234567880191", { obfuscate: true })).toBe("***4 5678 8 01 **"); + expect(formatVoterId("1234567880399", { obfuscate: true })).toBe("***4 5678 80 **"); + expect(formatVoterId("12345", { obfuscate: true })).toBe("***4 5"); + // @ts-expect-error: intentionally not a boolean + expect(formatVoterId("123456780124", { obfuscate: 1 })).toBe("***4 5678 01 **"); + }); + + it("should behave exactly as without the option when obfuscate is falsy", () => { + expect(formatVoterId("123456780124", { obfuscate: false })).toBe("1234 5678 01 24"); + expect(formatVoterId("1234567880191", {})).toBe("1234 5678 8 01 91"); + // @ts-expect-error: intentionally not a boolean + expect(formatVoterId("1234567880191", { obfuscate: 0 })).toBe("1234 5678 8 01 91"); + }); + it("should return an empty string for null or undefined", () => { // @ts-expect-error: intentionally invalid input expect(formatVoterId(null)).toBe(""); @@ -64,8 +81,14 @@ describe("formatVoterId", () => { test("should use the grouping documented for each of the two lengths", () => { fc.assert( fc.property(digits(12), digits(9), fc.constantFrom("01", "02"), (short, sequential, uf) => { + const extended = `${sequential}${uf}00`; + expect(formatVoterId(short)).toMatch(/^\d{4} \d{4} \d{2} \d{2}$/); - expect(formatVoterId(`${sequential}${uf}00`)).toMatch(/^\d{4} \d{4} \d \d{2} \d{2}$/); + expect(formatVoterId(extended)).toMatch(/^\d{4} \d{4} \d \d{2} \d{2}$/); + expect(formatVoterId(short, { obfuscate: true })).toMatch(/^\*{3}\d \d{4} \d{2} \*{2}$/); + expect(formatVoterId(extended, { obfuscate: true })).toMatch( + /^\*{3}\d \d{4} \d \d{2} \*{2}$/, + ); }), ); }); @@ -85,8 +108,13 @@ describe("formatVoterId", () => { }); describe("formatVoterId types", () => { - test("should take a string or number value and return a string", () => { + test("should take a string or number value and options and return a string", () => { expectTypeOf(formatVoterId).parameter(0).toEqualTypeOf(); + expectTypeOf(formatVoterId).parameter(1).toEqualTypeOf(); expectTypeOf(formatVoterId).returns.toEqualTypeOf(); }); + + test("should type the obfuscate option as an optional boolean", () => { + expectTypeOf().toEqualTypeOf(); + }); }); diff --git a/src/format-voter-id/format-voter-id.ts b/src/format-voter-id/format-voter-id.ts index e6054adb3..7ab0ce13a 100644 --- a/src/format-voter-id/format-voter-id.ts +++ b/src/format-voter-id/format-voter-id.ts @@ -1,13 +1,16 @@ import { NINE_DIGIT_FEDERATIVE_UNION_CODES } from "../_internals/constants/voter-id"; import { format } from "../_internals/format/format"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; - -const PATTERN = "0000 0000 00 00"; - -const EXTENDED_PATTERN = "0000 0000 0 00 00"; +import { OBFUSCATED_PATTERNS, PATTERNS } from "./constants"; const LENGTH = 12; +/** Options of `formatVoterId`. */ +export type FormatVoterIdOptions = { + /** Whether to hide the first 3 digits and the 2 check digits with `*` (default: `false`, read for truthiness). */ + obfuscate?: boolean; +}; + /** * Formats a Brazilian voter id (título de eleitor) for display. * @@ -17,14 +20,23 @@ const LENGTH = 12; * the two states whose voter ids may carry a 9-digit sequential number. * * @param {string|number} value - The voter id value to be formatted. + * @param {FormatVoterIdOptions} [options] - Optional formatting options. + * @param {boolean} [options.obfuscate] - If truthy, hides the first 3 digits and the 2 check + * digits. Read for truthiness, so a non-boolean such as `1` obfuscates too. * @returns {string} The formatted voter id string. * * @example * ```typescript * formatVoterId("123456780124"); // "1234 5678 01 24" * formatVoterId("1234567880191"); // "1234 5678 8 01 91" + * formatVoterId("123456780124", { obfuscate: true }); // "***4 5678 01 **" + * formatVoterId("1234567880191", { obfuscate: true }); // "***4 5678 8 01 **" * ``` * + * No authority publishes a masking rule for the voter id, so `obfuscate` applies the one Lei nº + * 12.309/2010, art. 87, § 5º, sets for the CPF, a number with the same structure: the first 3 + * digits and the 2 check digits are hidden, and the federative union code stays visible. + * * The 13-digit São Paulo/Minas Gerais grouping is brutils parity, not published by the TSE. A * 14-or-more-digit input is read the same way as a 13-digit one: it is grouped as a São Paulo or * Minas Gerais id whenever its 10th and 11th digits are "01"/"02". Both patterns have a fixed @@ -36,14 +48,17 @@ const LENGTH = 12; * client, so it has to be opened in a browser. * * @see Official: https://www.tse.jus.br/legislacao/compilada/res/2021/resolucao-no-23-659-de-26-de-outubro-de-2021 + * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm + * Lei nº 12.309/2010, art. 87, § 5º: "ocultar os três primeiros dígitos e os dois dígitos + * verificadores do CPF", the rule `obfuscate` borrows. * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/voter_id.py */ -export const formatVoterId = (value: string | number): string => { +export const formatVoterId = (value: string | number, options?: FormatVoterIdOptions): string => { const digits = sanitizeToDigits(value); const federativeUnion = digits.slice(9, 11); const isExtended = digits.length > LENGTH && NINE_DIGIT_FEDERATIVE_UNION_CODES.includes(federativeUnion); - const pattern = isExtended ? EXTENDED_PATTERN : PATTERN; + const patterns = (options?.obfuscate ?? false) ? OBFUSCATED_PATTERNS : PATTERNS; - return format({ value: digits, pattern }); + return format({ value: digits, pattern: isExtended ? patterns.extended : patterns.standard }); }; diff --git a/src/index.test.ts b/src/index.test.ts index 136f06759..63934009b 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -36,6 +36,7 @@ import { type FormatPisOptions, type FormatProcessoJuridicoOptions, type FormatSuframaOptions, + type FormatVoterIdOptions, type GenerateBoletoParams, type GenerateCnpjParams, type GenerateLicensePlateFormat, @@ -341,6 +342,7 @@ describe("Public API", () => { FormatPisOptions: FormatPisOptions; FormatProcessoJuridicoOptions: FormatProcessoJuridicoOptions; FormatSuframaOptions: FormatSuframaOptions; + FormatVoterIdOptions: FormatVoterIdOptions; GenerateBoletoParams: GenerateBoletoParams; GenerateCnpjParams: GenerateCnpjParams; GenerateLicensePlateFormat: GenerateLicensePlateFormat; diff --git a/src/index.ts b/src/index.ts index 4e3f7e32a..d865a49ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,7 +54,7 @@ export { formatProcessoJuridico, } from "./format-processo-juridico/format-processo-juridico"; export { type FormatSuframaOptions, formatSuframa } from "./format-suframa/format-suframa"; -export { formatVoterId } from "./format-voter-id/format-voter-id"; +export { type FormatVoterIdOptions, formatVoterId } from "./format-voter-id/format-voter-id"; export { type GenerateBoletoParams, generateBoleto } from "./generate-boleto/generate-boleto"; export { generateCep } from "./generate-cep/generate-cep"; export { generateCnh } from "./generate-cnh/generate-cnh";