From e76f0c3fbc305b0e971053d9c26e8a28e076425e Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:43:21 -0300 Subject: [PATCH 01/14] fix(pix): validate the key syntax, the location encoding and the payload pairing --- .../is-valid-pix-url/is-valid-pix-url.test.ts | 12 ++++ .../is-valid-pix-url/is-valid-pix-url.ts | 7 ++- src/generate-pix-payload/constants.ts | 8 +++ .../generate-pix-payload.test.ts | 5 ++ .../generate-pix-payload.ts | 11 +++- .../is-valid-pix-payload.test.ts | 24 +++++++ .../is-valid-pix-payload.ts | 6 +- src/parse-pix-key/constants.ts | 17 +++-- src/parse-pix-key/parse-pix-key.test.ts | 17 ++++- src/parse-pix-key/parse-pix-key.ts | 54 +++++++++++----- .../parse-pix-payload.test.ts | 63 +++++++++++++++++++ src/parse-pix-payload/parse-pix-payload.ts | 42 ++++++++++--- 12 files changed, 228 insertions(+), 38 deletions(-) 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 index c4ffff85..74743500 100644 --- 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 @@ -18,6 +18,11 @@ describe("isValidPixUrl", () => { 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); }); + + test("for a path with a percent-encoded octet, in either letter case", () => { + expect(isValidPixUrl("pix.example.com/%2F")).toBe(true); + expect(isValidPixUrl("pix.example.com/%2f")).toBe(true); + }); }); describe("should return false", () => { @@ -47,5 +52,12 @@ describe("isValidPixUrl", () => { expect(isValidPixUrl("pix.example.com/")).toBe(false); expect(isValidPixUrl("pix.example.com/x?y=1")).toBe(false); }); + + test("when a percent sign does not start a percent-encoded octet", () => { + expect(isValidPixUrl("pix.example.com/%ZZ")).toBe(false); + expect(isValidPixUrl("pix.example.com/%2")).toBe(false); + expect(isValidPixUrl("pix.example.com/%")).toBe(false); + expect(isValidPixUrl("pix.example.com/100%")).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 index 07dd4cbf..73eecd52 100644 --- a/src/_internals/is-valid-pix-url/is-valid-pix-url.ts +++ b/src/_internals/is-valid-pix-url/is-valid-pix-url.ts @@ -1,14 +1,17 @@ const HOST_LABEL = "[a-z0-9](?:[a-z0-9-]*[a-z0-9])?"; +const PATH_CHARACTER = "(?:%[0-9a-f]{2}|[a-z0-9._~!$&'()*+,;=:@-])"; + const PIX_URL_REGEX = new RegExp( - `^${HOST_LABEL}(?:\\.${HOST_LABEL})+(?:/[a-z0-9._~%!$&'()*+,;=:@-]*)*$`, + `^${HOST_LABEL}(?:\\.${HOST_LABEL})+(?:/${PATH_CHARACTER}*)*$`, "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. + * whitespace or characters outside the URL unreserved and sub-delimiter sets. A `%` is only + * accepted as the start of a percent-encoded octet (`%` followed by two hexadecimal digits). * * @param {string} value - The value to check. * @returns {boolean} True if `value` is a valid Pix PSP location. diff --git a/src/generate-pix-payload/constants.ts b/src/generate-pix-payload/constants.ts index e26df6c9..b839c411 100644 --- a/src/generate-pix-payload/constants.ts +++ b/src/generate-pix-payload/constants.ts @@ -1,5 +1,13 @@ export const AMOUNT_DECIMAL_PLACES = 2; +/** + * The shape the BR Code requires of a transaction amount. `Number#toFixed` falls back to + * exponential notation from 1e21 upwards (`(1e21).toFixed(2)` is `"1e+21"`), which is short + * enough to slip past the field length limit, so the formatted amount is matched against this + * before it is written into the payload. + */ +export const AMOUNT_REGEX = /^\d+\.\d{2}$/; + /** * How many characters one TLV object spends besides its value: the 2 digit ID plus the 2 digit * length. diff --git a/src/generate-pix-payload/generate-pix-payload.test.ts b/src/generate-pix-payload/generate-pix-payload.test.ts index eecc19e7..b37a5f9a 100644 --- a/src/generate-pix-payload/generate-pix-payload.test.ts +++ b/src/generate-pix-payload/generate-pix-payload.test.ts @@ -179,6 +179,11 @@ describe("generatePixPayload", () => { expect(generatePixPayload({ ...BASE, amount: 123_456_789_012 })).toBeNull(); }); + test("when the amount is so large that it formats in exponential notation", () => { + expect(generatePixPayload({ ...BASE, amount: 1e21 })).toBeNull(); + expect(generatePixPayload({ ...BASE, amount: 1.5e25 })).toBeNull(); + }); + test("but accept an amount whose formatted length is exactly 13 characters", () => { expect(generatePixPayload({ ...BASE, amount: 9_999_999_999.99 })).toContain( "54139999999999.99", diff --git a/src/generate-pix-payload/generate-pix-payload.ts b/src/generate-pix-payload/generate-pix-payload.ts index 6a6b7fab..05432ac3 100644 --- a/src/generate-pix-payload/generate-pix-payload.ts +++ b/src/generate-pix-payload/generate-pix-payload.ts @@ -35,7 +35,7 @@ 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"; +import { AMOUNT_DECIMAL_PLACES, AMOUNT_REGEX, TLV_OVERHEAD, TXID_REGEX } from "./constants"; /** The parameters `generatePixPayload` takes to build a Pix BR Code. */ export type GeneratePixPayloadParams = { @@ -100,11 +100,16 @@ const resolveFormattedAmount = ( ): string | null => { if (pointOfInitiation !== undefined && (amount !== undefined || txid !== undefined)) return null; - // Stryker disable next-line EqualityOperator: amount <= 0 differs from amount < 0 only at 0 (or -0), and both format to "0.00", which the "rounds to 0.00" check below always rejects anyway - if (amount !== undefined && (!Number.isFinite(amount) || amount <= 0)) return null; + // `Number.isFinite` is false for every value that is not a number, so this guard is what keeps + // `toFixed` below from being called on something that has no `toFixed`. A negative amount keeps + // its sign in `toFixed`, so the amount regex below turns it down, and an amount that is zero or + // rounds to zero is turned down by the `Number(formattedAmount)` check. + if (amount !== undefined && !Number.isFinite(amount)) return null; const formattedAmount = amount === undefined ? "" : amount.toFixed(AMOUNT_DECIMAL_PLACES); + if (amount !== undefined && !AMOUNT_REGEX.test(formattedAmount)) return null; + if (formattedAmount.length > PIX_TRANSACTION_AMOUNT_MAX_LENGTH) return null; if (amount !== undefined && Number(formattedAmount) === 0) return null; 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 index 020da73c..ddd7136e 100644 --- a/src/is-valid-pix-payload/is-valid-pix-payload.test.ts +++ b/src/is-valid-pix-payload/is-valid-pix-payload.test.ts @@ -122,6 +122,30 @@ describe("isValidPixPayload", () => { ).toBe(false); }); + test("when a key is announced as dynamic by the point of initiation method", () => { + expect( + isValidPixPayload( + "00020101021226330014br.gov.bcb.pix0111123456789095204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***63043CAC", + ), + ).toBe(false); + }); + + test("when a url is announced as static by the point of initiation method", () => { + expect( + isValidPixPayload( + "00020101021126480014br.gov.bcb.pix2526pix.example.com/qr/v2/12345204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***6304F299", + ), + ).toBe(false); + }); + + test("when a static payload states a transaction amount of zero", () => { + expect( + isValidPixPayload( + "00020126580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-42665544000052040000530398654040.005802BR5913Fulano de Tal6008BRASILIA62070503***63042451", + ), + ).toBe(false); + }); + test("when the currency is not 986", () => { expect( isValidPixPayload( diff --git a/src/is-valid-pix-payload/is-valid-pix-payload.ts b/src/is-valid-pix-payload/is-valid-pix-payload.ts index 1af45ec7..c08c56c1 100644 --- a/src/is-valid-pix-payload/is-valid-pix-payload.ts +++ b/src/is-valid-pix-payload/is-valid-pix-payload.ts @@ -9,7 +9,11 @@ import { parsePixPayload } from "../parse-pix-payload/parse-pix-payload"; * 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 rest of the payload. The "Point of Initiation Method" object (`01`) must agree with what + * that template carries: a key requires a static payload (`01` absent or `"11"`) and a URL + * requires a dynamic one (`01` set to `"12"`). A static payload that states a transaction + * amount (`54`) must state one greater than zero: `0.00` is reserved for the Pix Saque/Troco + * BR Code, which is out of scope here. * * 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` diff --git a/src/parse-pix-key/constants.ts b/src/parse-pix-key/constants.ts index 53291696..bd93b183 100644 --- a/src/parse-pix-key/constants.ts +++ b/src/parse-pix-key/constants.ts @@ -9,8 +9,17 @@ export const EMAIL_MAX_LENGTH = 77; 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. + * The characters a value written as a phone number may carry: digits, the `+` of the + * international prefix and the spaces, dots, hyphens and parentheses of the usual masks. + * Sanitizing to digits alone would read `"abc(11) 98765-4321xyz"` as a phone number, so the + * value is matched against this before it is sanitized. */ -export const PHONE_HINT_REGEX = /(?:^(?:\+|00)\s*55)|[()]/; +export const PHONE_SYNTAX_REGEX = /^[\d ()+.-]+$/; + +/** + * The forms a value written as a CPF may take: the bare 11 digits or the documented mask, with + * the dots and the hyphen optional and a space accepted wherever a separator goes. Sanitizing + * to digits alone would read `"abc123.456.789-09"` as a CPF, so the value is matched against + * this before it is sanitized. + */ +export const CPF_SYNTAX_REGEX = /^\d{3}[ .]?\d{3}[ .]?\d{3}[ -]?\d{2}$/; diff --git a/src/parse-pix-key/parse-pix-key.test.ts b/src/parse-pix-key/parse-pix-key.test.ts index 404a863e..10a50ae4 100644 --- a/src/parse-pix-key/parse-pix-key.test.ts +++ b/src/parse-pix-key/parse-pix-key.test.ts @@ -94,6 +94,21 @@ describe("parsePixKey", () => { expect(parsePixKey("chave pix")).toBeNull(); expect(parsePixKey("---")).toBeNull(); }); + + test("when a phone number is buried in surrounding text", () => { + expect(parsePixKey("abc(11) 98765-4321xyz")).toBeNull(); + expect(parsePixKey("tel: (11) 98765-4321")).toBeNull(); + }); + + test("when a CPF is buried in surrounding text", () => { + expect(parsePixKey("abc123.456.789-09")).toBeNull(); + expect(parsePixKey("CPF 123.456.789-09")).toBeNull(); + }); + + test("when a CPF is written with separators outside the documented positions", () => { + expect(parsePixKey("1.2.3.4.5.6.7.8.9.0.9")).toBeNull(); + expect(parsePixKey("123/456/789/09")).toBeNull(); + }); }); describe("should return a CPF", () => { @@ -220,7 +235,7 @@ describe("parsePixKey", () => { }); describe("should return a random key", () => { - test("when it is a lowercase UUID version 4", () => { + test("when it is a lowercase UUID", () => { expect(parsePixKey("71c7d9be-4b85-4e43-9f1c-1f3b8b4e9a2d")).toEqual({ type: "evp", value: "71c7d9be-4b85-4e43-9f1c-1f3b8b4e9a2d", diff --git a/src/parse-pix-key/parse-pix-key.ts b/src/parse-pix-key/parse-pix-key.ts index 4e83513f..5917d6c0 100644 --- a/src/parse-pix-key/parse-pix-key.ts +++ b/src/parse-pix-key/parse-pix-key.ts @@ -1,4 +1,3 @@ -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"; @@ -7,7 +6,7 @@ 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"; +import { CPF_SYNTAX_REGEX, EMAIL_MAX_LENGTH, EVP_REGEX, PHONE_SYNTAX_REGEX } from "./constants"; /** The kinds of Pix key `parsePixKey` recognizes. */ export type PixKeyType = "cpf" | "cnpj" | "email" | "phone" | "evp"; @@ -20,6 +19,23 @@ export type PixKey = { value: string; }; +/** + * Reads a value written as a phone number, i.e. one holding nothing but digits and the + * characters of the usual masks, as the E.164 mobile key of the DICT. + * + * @param {string} trimmed - The trimmed value to read. + * @returns {PixKey|null} The phone key, or `null` when the value is not a mobile number. + */ +const resolvePhoneKey = (trimmed: string): PixKey | null => { + if (!PHONE_SYNTAX_REGEX.test(trimmed)) return null; + + const national = normalizePhone(trimmed); + + return isValidPhone(national, { accept: ["mobile"] }) + ? { type: "phone", value: `+${PHONE_COUNTRY_CODE}${national}` } + : null; +}; + /** * Identifies a Pix key and normalizes it to the canonical form the DICT expects inside a BR * Code. @@ -32,13 +48,23 @@ export type PixKey = { * characters. The manual registers a "número de telefone celular", so only mobile numbers * are recognized; a landline is not a Pix key. Masked, bare and `+55` prefixed inputs are * all accepted; - * - `evp`: the random key, a lowercase UUID version 4. + * - `evp`: the random key, a lowercase UUID written with its punctuation (8-4-4-4-12 + * hexadecimal digits). The DICT issues version 4 UUIDs, but neither the pattern the manual + * registers nor its own example (`123e4567-e12b-12d1-a456-426655440000`, whose version + * nibble is `1`) constrains the version, so the version and variant nibbles are not enforced. + * + * The CPF and the phone number are recognized by the way they are written, not only by the + * digits they carry: a value is read as a CPF when it is the bare 11 digits or the documented + * mask, and as a phone number when it holds nothing but digits, spaces and the `+`, `-`, `(`, + * `)` and `.` of the usual masks. Surrounding text is not stripped away, so + * `"abc123.456.789-09"` is not a CPF key. * * 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. + * CPF, unless it was written as a phone number. A `+55`/`0055` prefix or a DDD between + * parentheses falls outside the CPF forms above, so a value written that way is never read as + * a CPF, even when its digits carry a valid CPF check digit. * * @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. @@ -64,7 +90,7 @@ export const parsePixKey = (value: string): PixKey | null => { const trimmed = value.trim(); - // Stryker disable next-line ConditionalExpression: an empty trimmed value never matches the EVP regex, never contains "@", normalizes to no valid phone, is never a valid CNPJ, and has no CPF-length digits, so every branch below already falls through to null on its own + // Stryker disable next-line ConditionalExpression: an empty trimmed value never matches the EVP regex, never contains "@", is never a valid CNPJ, matches neither the CPF nor the phone syntax, so every branch below already falls through to null on its own if (!trimmed) return null; if (EVP_REGEX.test(trimmed)) return { type: "evp", value: trimmed.toLowerCase() }; @@ -77,21 +103,15 @@ export const parsePixKey = (value: string): PixKey | null => { : null; } - const national = normalizePhone(trimmed); - const phone: PixKey | null = isValidPhone(national, { accept: ["mobile"] }) - ? { 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; + if (CPF_SYNTAX_REGEX.test(trimmed)) { + const digits = sanitizeToDigits(trimmed); - const digits = sanitizeToDigits(trimmed); - - // Stryker disable next-line ConditionalExpression: isValidCpf already rejects any digits whose length is not CPF_LENGTH on its own, so this length check can never change the outcome - if (digits.length === CPF_LENGTH && isValidCpf(digits)) return { type: "cpf", value: digits }; + if (isValidCpf(digits)) return { type: "cpf", value: digits }; + } - return phone; + return resolvePhoneKey(trimmed); }; diff --git a/src/parse-pix-payload/parse-pix-payload.test.ts b/src/parse-pix-payload/parse-pix-payload.test.ts index f5db93c1..4239afd3 100644 --- a/src/parse-pix-payload/parse-pix-payload.test.ts +++ b/src/parse-pix-payload/parse-pix-payload.test.ts @@ -21,12 +21,24 @@ const BRCODE_MANUAL = const COMMUNITY_STATIC = "00020126580014br.gov.bcb.pix0136bee05743-4291-4f3c-9259-595df1307ba1520400005303986540510.005802BR5914Alexandre Lima6019Presidente Prudente62180514Um-Id-Qualquer6304D475"; +const KEY_ANNOUNCED_AS_DYNAMIC = + "00020101021226330014br.gov.bcb.pix0111123456789095204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***63043CAC"; + +const URL_ANNOUNCED_AS_STATIC = + "00020101021126480014br.gov.bcb.pix2526pix.example.com/qr/v2/12345204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***6304F299"; + +const URL_WITHOUT_POINT_OF_INITIATION = + "00020126480014br.gov.bcb.pix2526pix.example.com/qr/v2/12345204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***63041420"; + 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 hasValidCrc = (payload: string): boolean => + crc16Ccitt(payload.slice(0, -4)) === payload.slice(-4); + const buildPayload = (merchantAccountInformation: string, additionalData?: string): string => { const withoutCrc = [ tlv("00", "01"), @@ -106,6 +118,15 @@ const buildPayloadWithAmount = (amount: string): string => { return withoutCrc + crc16Ccitt(withoutCrc); }; +const DYNAMIC_URL = "pix.example.com/qr/v2/1234"; + +const buildDynamicPayloadWithAmount = (amount: string): string => { + const location = tlv("00", "br.gov.bcb.pix") + tlv("25", DYNAMIC_URL); + const withoutCrc = `${tlv("00", "01")}${tlv("01", "12")}${tlv("26", location)}${tlv("52", "0000")}${tlv("53", "986")}${tlv("54", amount)}${tlv("58", "BR")}${tlv("59", "Fulano de Tal")}${tlv("60", "BRASILIA")}6304`; + + return withoutCrc + crc16Ccitt(withoutCrc); +}; + const buildPayloadWithMerchantName = (merchantName: string): string => { const withoutCrc = [ tlv("00", "01"), @@ -204,6 +225,21 @@ describe("parsePixPayload", () => { ).toBeNull(); }); + test("when a key is announced as dynamic by the point of initiation method", () => { + expect(hasValidCrc(KEY_ANNOUNCED_AS_DYNAMIC)).toBe(true); + expect(parsePixPayload(KEY_ANNOUNCED_AS_DYNAMIC)).toBeNull(); + }); + + test("when a url is announced as static by the point of initiation method", () => { + expect(hasValidCrc(URL_ANNOUNCED_AS_STATIC)).toBe(true); + expect(parsePixPayload(URL_ANNOUNCED_AS_STATIC)).toBeNull(); + }); + + test("when a url carries no point of initiation method at all", () => { + expect(hasValidCrc(URL_WITHOUT_POINT_OF_INITIATION)).toBe(true); + expect(parsePixPayload(URL_WITHOUT_POINT_OF_INITIATION)).toBeNull(); + }); + test("when the additional data template is malformed", () => { const merchantAccountInformation = tlv("00", "br.gov.bcb.pix") + tlv("01", "some-key"); @@ -232,6 +268,24 @@ describe("parsePixPayload", () => { expect(parsePixPayload(buildPayloadWithAmount("99999999999.99"))).toBeNull(); }); + test("when the transaction amount is not written as a plain decimal number", () => { + expect(parsePixPayload(buildPayloadWithAmount("+1.00"))).toBeNull(); + expect(parsePixPayload(buildPayloadWithAmount(" 1.00"))).toBeNull(); + expect(parsePixPayload(buildPayloadWithAmount("1.00x"))).toBeNull(); + expect(parsePixPayload(buildPayloadWithAmount("abc"))).toBeNull(); + }); + + test("when the transaction amount states more than two decimal places", () => { + expect(parsePixPayload(buildPayloadWithAmount("1.234"))).toBeNull(); + }); + + test("when a static payload states a transaction amount of zero", () => { + expect(hasValidCrc(buildPayloadWithAmount("0.00"))).toBe(true); + expect(parsePixPayload(buildPayloadWithAmount("0.00"))).toBeNull(); + expect(parsePixPayload(buildPayloadWithAmount("0"))).toBeNull(); + expect(parsePixPayload(buildPayloadWithAmount("0.0"))).toBeNull(); + }); + test("when the merchant name is present but empty", () => { expect(parsePixPayload(buildPayloadWithMerchantName(""))).toBeNull(); }); @@ -255,6 +309,15 @@ describe("parsePixPayload", () => { }); }); + test("should accept a transaction amount of zero in a dynamic payload, whose amount the PSP location settles", () => { + expect(parsePixPayload(buildDynamicPayloadWithAmount("0.00"))).toEqual({ + url: DYNAMIC_URL, + merchantName: "Fulano de Tal", + merchantCity: "BRASILIA", + 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", diff --git a/src/parse-pix-payload/parse-pix-payload.ts b/src/parse-pix-payload/parse-pix-payload.ts index b62107fb..e563e71f 100644 --- a/src/parse-pix-payload/parse-pix-payload.ts +++ b/src/parse-pix-payload/parse-pix-payload.ts @@ -103,9 +103,13 @@ const resolvePointOfInitiation = (fields: TlvFields): string | undefined | null return pointOfInitiation; }; -const isValidAmount = (amount: string | undefined): boolean => - amount === undefined || - (AMOUNT_REGEX.test(amount) && amount.length <= PIX_TRANSACTION_AMOUNT_MAX_LENGTH); +const isValidAmount = (amount: string | undefined, isDynamic: boolean): boolean => { + if (amount === undefined) return true; + + if (!AMOUNT_REGEX.test(amount) || amount.length > PIX_TRANSACTION_AMOUNT_MAX_LENGTH) return false; + + return isDynamic || Number(amount) > 0; +}; type MerchantKeyInfo = { key?: string; @@ -129,6 +133,14 @@ const resolveMerchantKeyInfo = (fields: TlvFields): MerchantKeyInfo | null => { return { key, url, description }; }; +const isConsistentPointOfInitiation = ( + { url }: MerchantKeyInfo, + pointOfInitiation: string | undefined, +): boolean => + url === undefined + ? pointOfInitiation !== PIX_DYNAMIC_POINT_OF_INITIATION + : pointOfInitiation === PIX_DYNAMIC_POINT_OF_INITIATION; + const resolveTxid = (fields: TlvFields): string | undefined | null => { const additionalData = fields[PIX_ADDITIONAL_DATA_ID]; @@ -197,9 +209,17 @@ const buildPixPayload = ( * * 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. + * `generatePixPayload` applies. The "Point of Initiation Method" object (`01`) must agree with + * it: a key belongs to a static payload, so `01` is absent or `"11"`, and a PSP location + * belongs to a dynamic one, so `01` is `"12"`. Any other pairing (a key announced as dynamic, + * a location announced as static) is rejected. 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. + * + * A static payload that carries the transaction amount (54) must state an amount greater than + * zero: the only BR Code the manual writes with `54` set to `0.00` is a Pix Saque/Troco one, + * which announces the withdrawal agent in a template this parser does not read, so a static + * `"0"`/`"0.00"` is rejected rather than reported as a free amount of nothing. * * @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 @@ -253,14 +273,16 @@ export const parsePixPayload = (value: string): PixPayload | null => { if (merchantCity === undefined || merchantCity === "") return null; - const amount = fields[PIX_TRANSACTION_AMOUNT_ID]; - - if (!isValidAmount(amount)) return null; - const merchantKeyInfo = resolveMerchantKeyInfo(fields); if (!merchantKeyInfo) return null; + if (!isConsistentPointOfInitiation(merchantKeyInfo, pointOfInitiation)) return null; + + const amount = fields[PIX_TRANSACTION_AMOUNT_ID]; + + if (!isValidAmount(amount, merchantKeyInfo.url !== undefined)) return null; + const txid = resolveTxid(fields); if (txid === null) return null; From 31bbcb8e86b79d18edd3181c7dd5b8fd832c9fe9 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:43:21 -0300 Subject: [PATCH 02/14] fix(pix): keep the word boundary when a merchant name carries non-ASCII whitespace --- .../sanitize-to-ascii/sanitize-to-ascii.test.ts | 9 +++++++++ src/_internals/sanitize-to-ascii/sanitize-to-ascii.ts | 11 +++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/_internals/sanitize-to-ascii/sanitize-to-ascii.test.ts b/src/_internals/sanitize-to-ascii/sanitize-to-ascii.test.ts index c2ce1589..1537833a 100644 --- a/src/_internals/sanitize-to-ascii/sanitize-to-ascii.test.ts +++ b/src/_internals/sanitize-to-ascii/sanitize-to-ascii.test.ts @@ -17,6 +17,15 @@ describe("sanitizeToAscii", () => { expect(sanitizeToAscii(" Fulano de \n Tal ")).toBe("Fulano de Tal"); }); + test("should turn a non-breaking space into a plain space instead of dropping it", () => { + expect(sanitizeToAscii("Fulano\u00A0de\u00A0Tal")).toBe("Fulano de Tal"); + expect(sanitizeToAscii("\u00A0 Fulano \u00A0\u00A0 de Tal \u00A0")).toBe("Fulano de Tal"); + }); + + test("should turn the other Unicode spaces into a plain space too", () => { + expect(sanitizeToAscii("Fulano\u2003de\u3000Tal")).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"); diff --git a/src/_internals/sanitize-to-ascii/sanitize-to-ascii.ts b/src/_internals/sanitize-to-ascii/sanitize-to-ascii.ts index 3d366acf..ec5fea35 100644 --- a/src/_internals/sanitize-to-ascii/sanitize-to-ascii.ts +++ b/src/_internals/sanitize-to-ascii/sanitize-to-ascii.ts @@ -1,14 +1,20 @@ const COMBINING_MARKS_REGEX = /[\u0300-\u036F]/g; +const WHITESPACE_REGEX = /\s/g; + const NON_PRINTABLE_ASCII_REGEX = /[^\u0020-\u007E]/g; -const WHITESPACE_REGEX = /\s+/g; +const SPACE_RUN_REGEX = / {2,}/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. * + * Whitespace is normalized before the non-ASCII characters are dropped, so a non-breaking + * space (or any other Unicode space) still separates the words around it instead of vanishing + * and gluing them together. + * * @param {string} value - The value to fold. * @returns {string} The trimmed, printable ASCII form of the value. * @@ -22,6 +28,7 @@ export const sanitizeToAscii = (value: string): string => value .normalize("NFD") .replace(COMBINING_MARKS_REGEX, "") - .replace(NON_PRINTABLE_ASCII_REGEX, "") .replace(WHITESPACE_REGEX, " ") + .replace(NON_PRINTABLE_ASCII_REGEX, "") + .replace(SPACE_RUN_REGEX, " ") .trim(); From 1fbcc089b3f6e9122abde5661d79c00aeaabdd86 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:43:21 -0300 Subject: [PATCH 03/14] fix(currency-to-words): never round a sub-cent amount up to the next cent --- .../convert-currency-to-words.test.ts | 17 +++++++++++++++ .../convert-currency-to-words.ts | 21 ++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) 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 index ab493294..cbd4ec87 100644 --- a/src/convert-currency-to-words/convert-currency-to-words.test.ts +++ b/src/convert-currency-to-words/convert-currency-to-words.test.ts @@ -123,6 +123,23 @@ describe("convertCurrencyToWords", () => { }); }); + describe("floating point precision", () => { + test("should not carry an amount over a cent boundary while truncating it", () => { + expect(convertCurrencyToWords(0.009999999)).toBe("zero reais"); + expect(convertCurrencyToWords(1.999999999)).toBe("um real e noventa e nove centavos"); + }); + + test("should absorb the noise of scaling a two decimal amount to cents", () => { + expect(convertCurrencyToWords(1.15)).toBe("um real e quinze centavos"); + expect(convertCurrencyToWords(0.29)).toBe("vinte e nove centavos"); + expect(convertCurrencyToWords(19.99)).toBe("dezenove reais e noventa e nove centavos"); + }); + + test("should absorb the noise of an amount that is itself the sum of two floats", () => { + expect(convertCurrencyToWords(0.1 + 0.2)).toBe("trinta centavos"); + }); + }); + describe("case option", () => { test("should keep the result lowercase by default", () => { expect(convertCurrencyToWords(1000)).toBe("mil reais"); diff --git a/src/convert-currency-to-words/convert-currency-to-words.ts b/src/convert-currency-to-words/convert-currency-to-words.ts index 4d49bf2e..c1d0a35e 100644 --- a/src/convert-currency-to-words/convert-currency-to-words.ts +++ b/src/convert-currency-to-words/convert-currency-to-words.ts @@ -13,6 +13,25 @@ export type ConvertCurrencyToWordsOptions = { const MILLION_SCALE_SUFFIXES = ["lhão", "lhões"]; +/** + * Scales an amount to whole cents, truncating it, without letting the floating point noise of + * the multiplication decide the result. `absolute * 100` lands a hair off the integer it + * should be (`1.15 * 100` is `114.99999999999999`, `0.57 * 100` is `56.99999999999999`), so a + * scaled value within one double rounding error of an integer is read as that integer. + * An amount that is genuinely below the next cent sits much further away than that + * (`1.999999999 * 100` is `199.9999999`) and is truncated, as it must be. + * + * @param {number} absolute - The absolute amount in reais. + * @returns {number} The amount truncated to whole cents. + */ +const toCents = (absolute: number): number => { + const scaled = absolute * 100; + const rounded = Math.round(scaled); + + // Stryker disable next-line EqualityOperator: `<` is equivalent, the two sides are never equal. Writing scaled as m * 2 ** (k - 52) with 2 ** k <= scaled < 2 ** (k + 1) and m its 53 bit significand, both scaled and rounded are multiples of the ulp 2 ** (k - 52), so the difference is j * 2 ** (k - 52) for an integer j, while Number.EPSILON * scaled is exactly m * 2 ** (k - 104): equality asks for m === j * 2 ** 52, and m < 2 ** 53 leaves only m === 2 ** 52, i.e. scaled a power of two. A power of two of at least 1 is an integer, whose difference is 0, and one below 1 rounds to 0 or to 1 at a distance of at least 0.25, never one ulp. The only case where both sides are 0 is scaled === 0, where rounded and Math.trunc(scaled) are both 0 anyway + return Math.abs(scaled - rounded) <= Number.EPSILON * scaled ? rounded : Math.trunc(scaled); +}; + const endsInMillionScale = (words: string): boolean => MILLION_SCALE_SUFFIXES.some((suffix) => words.endsWith(suffix)); @@ -58,7 +77,7 @@ export const convertCurrencyToWords = ( 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 totalCents = hasExactCents ? toCents(absolute) : 0; const reais = hasExactCents ? Math.floor(totalCents / 100) : Math.trunc(absolute); const centavos = hasExactCents ? totalCents % 100 : 0; From e17e55a84cde49fe7915bb49b072847f2c91f9d9 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:43:22 -0300 Subject: [PATCH 04/14] feat(area-code): list every state a DDD serves (42, 47, 49 and 61 span two states) --- src/_internals/constants/area-codes.ts | 39 +++++++++++++-- .../get-area-code-info.test.ts | 47 +++++++++++++++++-- src/get-area-code-info/get-area-code-info.ts | 45 +++++++++++++----- .../get-area-codes-by-state.test.ts | 30 +++++++++++- .../get-area-codes-by-state.ts | 25 ++++++++-- 5 files changed, 161 insertions(+), 25 deletions(-) diff --git a/src/_internals/constants/area-codes.ts b/src/_internals/constants/area-codes.ts index 6cda176f..530d85d2 100644 --- a/src/_internals/constants/area-codes.ts +++ b/src/_internals/constants/area-codes.ts @@ -3,15 +3,18 @@ import { type StateCode } from "./states"; /** * Brazilian DDD (area code) data under the Plano Geral de Numeração. `VALID_AREA_CODES` is kept * as a bare array of the 67 valid codes for its existing importers; `AREA_CODE_STATES` is a - * second, richer literal mapping every one of those same 67 codes to its state (UF), verified - * one by one against the ANATEL numbering plan reflected by the BrasilAPI DDD dataset. + * second, richer literal mapping every one of those same 67 codes to the state (UF) that holds + * all but a handful of its municipalities, and `AREA_CODE_SECONDARY_STATES` carries the other + * states the four cross-border codes also serve. * * Resolução Anatel nº 749/2022, art. 15, defines the Código Nacional (area code); the gov.br - * page below lists the codes actually allocated. The BrasilAPI DDD endpoint (`GET - * /api/ddd/v1/{ddd}`) was used to verify the code-to-state mapping. + * page below lists the codes actually allocated and links, under "POR MUNICÍPIO", to the Anexo + * of Resolução Anatel nº 263/2001, which gives the Código Nacional of every municipality. That + * Anexo was parsed to derive both tables. * * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 * @see Official: https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais + * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2001/383-resolucao-263 * @see Based on: https://brasilapi.com.br/docs#tag/DDD */ export const VALID_AREA_CODES: readonly number[] = [ @@ -89,3 +92,31 @@ export const AREA_CODE_STATES: Record = { 98: "MA", 99: "MA", }; + +/** + * The other states a DDD serves, besides the primary state `AREA_CODE_STATES` gives it. Four + * Códigos Nacionais straddle a state border: + * + * - 61 serves the Distrito Federal and the Goiás municipalities of the Entorno do Distrito + * Federal: Águas Lindas de Goiás, Cabeceiras, Cidade Ocidental, Cristalina, Formosa, + * Luziânia, Novo Gama, Padre Bernardo, Planaltina, Santo Antônio do Descoberto, Valparaíso + * de Goiás and Vila Boa. + * - 42 serves Paraná and Porto União (SC), across the river from União da Vitória (PR). + * - 47 serves Santa Catarina and Rio Negro (PR), across the river from Mafra (SC). + * - 49 serves Santa Catarina and Barracão (PR), on the border with Dionísio Cerqueira (SC). + * + * Derived from the Anexo of Resolução Anatel nº 263/2001, which lists the Código Nacional of + * every municipality, as later amended by Resolução nº 580/2012 (Vila Boa, 62 to 61), + * Resolução nº 644/2014 (Porto União, 49 to 42) and Resolução nº 701/2018 (Rio Negro, 41 to + * 47, and Barracão, 46 to 49). No other Código Nacional in that Anexo covers more than one + * state. + * + * @see Official: https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais + * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2001/383-resolucao-263 + */ +export const AREA_CODE_SECONDARY_STATES: Record = { + 42: ["SC"], + 47: ["PR"], + 49: ["PR"], + 61: ["GO"], +}; 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 index da452f30..a1b1ce1e 100644 --- a/src/get-area-code-info/get-area-code-info.test.ts +++ b/src/get-area-code-info/get-area-code-info.test.ts @@ -15,6 +15,7 @@ describe("getAreaCodeInfo", () => { stateCode: "SP", stateName: "São Paulo", region: "Sudeste", + stateCodes: ["SP"], }); }); @@ -24,6 +25,7 @@ describe("getAreaCodeInfo", () => { stateCode: "SP", stateName: "São Paulo", region: "Sudeste", + stateCodes: ["SP"], }); }); @@ -37,18 +39,34 @@ describe("getAreaCodeInfo", () => { stateCode: "AC", stateName: "Acre", region: "Norte", + stateCodes: ["AC"], }); }); - it("should resolve DDD 61 to Distrito Federal, Centro-Oeste", () => { + it("should resolve DDD 61 to Distrito Federal, Centro-Oeste, and list Goiás as a second state", () => { expect(getAreaCodeInfo("61")).toEqual({ areaCode: 61, stateCode: "DF", stateName: "Distrito Federal", region: "Centro-Oeste", + stateCodes: ["DF", "GO"], }); }); + it("should keep DDD 61 singular in stateCode, since the Entorno is the exception", () => { + expect(getAreaCodeInfo(61)?.stateCode).toBe("DF"); + }); + + it("should list both states of the three other border DDDs, 42, 47 and 49", () => { + expect(getAreaCodeInfo(42)?.stateCodes).toEqual(["PR", "SC"]); + expect(getAreaCodeInfo(47)?.stateCodes).toEqual(["SC", "PR"]); + expect(getAreaCodeInfo(49)?.stateCodes).toEqual(["SC", "PR"]); + }); + + it("should list a single state for a DDD that does not cross a border", () => { + expect(getAreaCodeInfo(62)?.stateCodes).toEqual(["GO"]); + }); + 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, @@ -92,6 +110,14 @@ describe("getAreaCodeInfo", () => { expect(getAreaCodeInfo("")).toBeNull(); }); + it("should return null for a negative number, not read it as the DDD 11", () => { + expect(getAreaCodeInfo(-11)).toBeNull(); + }); + + it("should return null for a fractional number, not read it as the DDD 11", () => { + expect(getAreaCodeInfo(1.1)).toBeNull(); + }); + it("should return null for null", () => { // @ts-expect-error: intentionally invalid input expect(getAreaCodeInfo(null)).toBeNull(); @@ -109,13 +135,27 @@ describe("getAreaCodeInfo", () => { expectNeverThrows(getAreaCodeInfo, anyGarbage); }); - test("should resolve every valid DDD back to a state that lists it", () => { + test("should resolve every valid DDD back to every state that lists it", () => { fc.assert( fc.property(areaCodeArbitrary, (areaCode) => { const info = getAreaCodeInfo(areaCode); expect(info).not.toBeNull(); - expect(getAreaCodesByState(info?.stateCode ?? "")).toContain(areaCode); + expect(info?.stateCodes[0]).toBe(info?.stateCode); + + for (const stateCode of info?.stateCodes ?? []) { + expect(getAreaCodesByState(stateCode)).toContain(areaCode); + } + }), + ); + }); + + test("should never repeat a state in stateCodes", () => { + fc.assert( + fc.property(areaCodeArbitrary, (areaCode) => { + const stateCodes = getAreaCodeInfo(areaCode)?.stateCodes ?? []; + + expect(new Set(stateCodes).size).toBe(stateCodes.length); }), ); }); @@ -139,6 +179,7 @@ describe("getAreaCodeInfo types", () => { stateCode: StateCode; stateName: StateName; region: "Norte" | "Nordeste" | "Centro-Oeste" | "Sudeste" | "Sul"; + stateCodes: StateCode[]; }>(); }); }); diff --git a/src/get-area-code-info/get-area-code-info.ts b/src/get-area-code-info/get-area-code-info.ts index fa293c67..aa2440ee 100644 --- a/src/get-area-code-info/get-area-code-info.ts +++ b/src/get-area-code-info/get-area-code-info.ts @@ -1,6 +1,6 @@ -import { AREA_CODE_STATES } from "../_internals/constants/area-codes"; +import { AREA_CODE_SECONDARY_STATES, 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 { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; /** The state, and the region it belongs to, that `getAreaCodeInfo` returns for a DDD. */ @@ -13,34 +13,51 @@ export type AreaCodeInfo = { stateName: StateName; /** The full name of the region the state belongs to, e.g. `"Sudeste"`. */ region: State["regionName"]; + /** + * Every state the DDD serves, the primary `stateCode` first, e.g. `["SP"]` for 11 and + * `["DF", "GO"]` for 61. + */ + stateCodes: StateCode[]; }; /** * 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. + * `stateCode` is always a single state: the one that holds all but a handful of the DDD's + * municipalities. Four DDDs straddle a state border, and for those `stateCodes` lists the + * other states too. DDD 61 is the widest of them, serving the Distrito Federal and the twelve + * Goiás municipalities of the Entorno do Distrito Federal, so its `stateCode` is `"DF"` and + * its `stateCodes` is `["DF", "GO"]`. The other three are 42 (`["PR", "SC"]`, for Porto + * União), 47 (`["SC", "PR"]`, for Rio Negro) and 49 (`["SC", "PR"]`, for Barracão). + * + * A `areaCode` given as a number must be a non-negative integer: a sign and a decimal point + * are not digits, so `-11` and `1.1` are rejected instead of being read as `11`. + * + * @param {string|number} areaCode - The DDD to look up. Accepts a string or a non-negative + * integer 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. * * Resolução Anatel nº 749/2022, art. 15, defines the Código Nacional (area code); the gov.br - * page below lists the codes actually allocated. The BrasilAPI DDD endpoint was used to verify - * the code-to-state mapping. + * page below lists the codes actually allocated and links to the Anexo of Resolução Anatel + * nº 263/2001, which gives the Código Nacional of every municipality. * * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 * @see Official: https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais + * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2001/383-resolucao-263 * @see Based on: https://brasilapi.com.br/docs#tag/DDD * * @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("11"); // { areaCode: 11, stateCode: "SP", stateName: "São Paulo", region: "Sudeste", stateCodes: ["SP"] } + * getAreaCodeInfo(21); // { areaCode: 21, stateCode: "RJ", stateName: "Rio de Janeiro", region: "Sudeste", stateCodes: ["RJ"] } + * getAreaCodeInfo("61"); // { areaCode: 61, stateCode: "DF", stateName: "Distrito Federal", region: "Centro-Oeste", stateCodes: ["DF", "GO"] } * getAreaCodeInfo("00"); // null + * getAreaCodeInfo(-11); // null * ``` */ export const getAreaCodeInfo = (areaCode: string | number): AreaCodeInfo | null => { - if (isNullish(areaCode)) return null; + if (!isLookupCode(areaCode)) return null; const digits = sanitizeToDigits(areaCode); @@ -55,5 +72,11 @@ export const getAreaCodeInfo = (areaCode: string | number): AreaCodeInfo | null const state = statesByCode[stateCode]; - return { areaCode: numericAreaCode, stateCode, stateName: state.name, region: state.regionName }; + return { + areaCode: numericAreaCode, + stateCode, + stateName: state.name, + region: state.regionName, + stateCodes: [stateCode, ...(AREA_CODE_SECONDARY_STATES[numericAreaCode] ?? [])], + }; }; 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 index 80d636ed..a5ca5c55 100644 --- 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 @@ -28,6 +28,20 @@ describe("getAreaCodesByState", () => { expect(getAreaCodesByState("PE")).toEqual([81, 87]); }); + test("should list DDD 61 for Goiás, which the Entorno do Distrito Federal shares with the DF", () => { + expect(getAreaCodesByState("GO")).toContain(61); + expect(getAreaCodesByState("GO")).toEqual([61, 62, 64]); + }); + + test("should list only DDD 61 for the Distrito Federal", () => { + expect(getAreaCodesByState("DF")).toEqual([61]); + }); + + test("should list the border DDDs 42, 47 and 49 under both of their states", () => { + expect(getAreaCodesByState("SC")).toEqual([42, 47, 48, 49]); + expect(getAreaCodesByState("PR")).toEqual([41, 42, 43, 44, 45, 46, 47, 49]); + }); + test("should return a fresh array on every call", () => { const first = getAreaCodesByState("AC"); first.push(999); @@ -68,7 +82,7 @@ describe("getAreaCodesByState", () => { expectNeverThrows(getAreaCodesByState, fc.anything()); }); - test("should return every DDD sorted ascending, each resolving back to the same state", () => { + test("should return every DDD sorted ascending, each listing the state back", () => { fc.assert( fc.property(stateCodes, (stateCode) => { const areaCodes = getAreaCodesByState(stateCode); @@ -77,7 +91,19 @@ describe("getAreaCodesByState", () => { expect(areaCodes).toEqual(sorted); for (const areaCode of areaCodes) { - expect(getAreaCodeInfo(areaCode)?.stateCode).toBe(stateCode); + expect(getAreaCodeInfo(areaCode)?.stateCodes).toContain(stateCode); + } + }), + ); + }); + + test("should return the primary state of every DDD it does not share with another state", () => { + fc.assert( + fc.property(stateCodes, (stateCode) => { + for (const areaCode of getAreaCodesByState(stateCode)) { + const info = getAreaCodeInfo(areaCode); + + if (info?.stateCodes.length === 1) expect(info.stateCode).toBe(stateCode); } }), ); 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 index 9200b7d2..04a8f88c 100644 --- 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 @@ -1,12 +1,18 @@ -import { AREA_CODE_STATES } from "../_internals/constants/area-codes"; +import { AREA_CODE_SECONDARY_STATES, 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. + * Retrieves every DDD (area code) that serves 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. * + * A DDD that straddles a state border is listed under every state it serves, so DDD 61 comes + * back for both `"DF"` and `"GO"`: it serves the Distrito Federal and the twelve Goiás + * municipalities of the Entorno do Distrito Federal. The other three are 42, shared by Paraná + * and Porto União (SC), 47, shared by Santa Catarina and Rio Negro (PR), and 49, shared by + * Santa Catarina and Barracão (PR). + * * @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. @@ -16,14 +22,18 @@ import { AREA_CODE_STATES } from "../_internals/constants/area-codes"; * 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("DF"); // [61] + * getAreaCodesByState("GO"); // [61, 62, 64] * getAreaCodesByState("XX"); // [] * ``` * * Resolução Anatel nº 749/2022, art. 15, defines the Código Nacional (area code); the gov.br - * page below lists the codes actually allocated. + * page below lists the codes actually allocated and links to the Anexo of Resolução Anatel + * nº 263/2001, which gives the Código Nacional of every municipality. * * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 * @see Official: https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais + * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2001/383-resolucao-263 */ export const getAreaCodesByState = (stateCode: string): number[] => { if (typeof stateCode !== "string") return []; @@ -33,7 +43,12 @@ export const getAreaCodesByState = (stateCode: string): number[] => { const areaCodes: number[] = []; for (const [areaCode, code] of Object.entries(AREA_CODE_STATES)) { - if (code === normalized) areaCodes.push(Number(areaCode)); + const secondaryStates = AREA_CODE_SECONDARY_STATES[Number(areaCode)]; + const states = secondaryStates === undefined ? [code] : [code, ...secondaryStates]; + + if (states.some((state) => state === normalized)) { + areaCodes.push(Number(areaCode)); + } } return areaCodes; From 9cb73d0a528332c481fb74dfc3a61565c3d17ed7 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:43:22 -0300 Subject: [PATCH 05/14] fix: reject negative, fractional and unsafe numbers in every code lookup --- scripts/cbo.ts | 6 +++ scripts/cnae.ts | 6 +++ src/_internals/constants/cbo.ts | 6 +++ src/_internals/constants/cnae.ts | 6 +++ .../is-lookup-code/is-lookup-code.test.ts | 43 +++++++++++++++++++ .../is-lookup-code/is-lookup-code.ts | 29 +++++++++++++ src/get-bank-by-code/get-bank-by-code.ts | 7 +-- src/get-bank-by-ispb/get-bank-by-ispb.ts | 6 +-- src/get-cbo/get-cbo.test.ts | 11 +++++ src/get-cbo/get-cbo.ts | 23 +++++++--- src/get-cnae/get-cnae.test.ts | 11 +++++ src/get-cnae/get-cnae.ts | 24 ++++++++--- .../get-municipality-by-code.test.ts | 14 ++++++ .../get-municipality-by-code.ts | 7 ++- src/get-municipality/get-municipality.test.ts | 14 ++++++ src/get-municipality/get-municipality.ts | 12 ++++-- .../get-state-by-ibge-code.test.ts | 8 ++++ .../get-state-by-ibge-code.ts | 12 ++++-- src/is-valid-cbo/is-valid-cbo.test.ts | 18 ++++++++ src/is-valid-cbo/is-valid-cbo.ts | 7 +++ src/is-valid-cnae/is-valid-cnae.test.ts | 17 ++++++++ src/is-valid-cnae/is-valid-cnae.ts | 7 +++ .../is-valid-credit-card.test.ts | 13 ++++++ .../is-valid-credit-card.ts | 9 +++- 24 files changed, 286 insertions(+), 30 deletions(-) create mode 100644 src/_internals/is-lookup-code/is-lookup-code.test.ts create mode 100644 src/_internals/is-lookup-code/is-lookup-code.ts diff --git a/scripts/cbo.ts b/scripts/cbo.ts index 81b6ca75..a0767571 100644 --- a/scripts/cbo.ts +++ b/scripts/cbo.ts @@ -63,6 +63,12 @@ const main = async (): Promise => { * @see Official: http://www.mtecbo.gov.br/cbosite/pages/downloads.jsf */ export const CBO_TITLES: Record = ${JSON.stringify(sorted)}; + +/** + * Shape a CBO code has to be written in: the 6 digits, optionally split into the printed + * groups of 4 and 2 by whitespace or the usual mask characters. + */ +export const CBO_FORMAT_REGEX = /^\\d{4}[\\s.\\-/]*\\d{2}$/; `, ); }; diff --git a/scripts/cnae.ts b/scripts/cnae.ts index 53b5c474..9ea398a7 100644 --- a/scripts/cnae.ts +++ b/scripts/cnae.ts @@ -55,6 +55,12 @@ const main = async (): Promise => { * @see Official: https://concla.ibge.gov.br/classificacoes/por-tema/atividades-economicas/classificacao-nacional-de-atividades-economicas */ export const CNAE_SUBCLASSES: Record = ${JSON.stringify(data)}; + +/** + * Shape a CNAE subclass code has to be written in: the 7 digits, optionally split into the + * printed \`NNNN-N/NN\` groups by whitespace or the usual mask characters. + */ +export const CNAE_FORMAT_REGEX = /^\\d{4}[\\s.\\-/]*\\d[\\s.\\-/]*\\d{2}$/; `, ); }; diff --git a/src/_internals/constants/cbo.ts b/src/_internals/constants/cbo.ts index c10017ca..35f57dd0 100644 --- a/src/_internals/constants/cbo.ts +++ b/src/_internals/constants/cbo.ts @@ -2575,3 +2575,9 @@ export const CBO_TITLES: Record = { "031205": "Cabo Bombeiro Militar", "031210": "Soldado Bombeiro Militar", }; + +/** + * Shape a CBO code has to be written in: the 6 digits, optionally split into the printed + * groups of 4 and 2 by whitespace or the usual mask characters. + */ +export const CBO_FORMAT_REGEX = /^\d{4}[\s.\-/]*\d{2}$/; diff --git a/src/_internals/constants/cnae.ts b/src/_internals/constants/cnae.ts index 718c2240..41027437 100644 --- a/src/_internals/constants/cnae.ts +++ b/src/_internals/constants/cnae.ts @@ -1518,3 +1518,9 @@ export const CNAE_SUBCLASSES: Record = { "0990402": "ATIVIDADES DE APOIO À EXTRAÇÃO DE MINERAIS METÁLICOS NÃO FERROSOS", "0990403": "ATIVIDADES DE APOIO À EXTRAÇÃO DE MINERAIS NÃO METÁLICOS", }; + +/** + * Shape a CNAE subclass code has to be written in: the 7 digits, optionally split into the + * printed `NNNN-N/NN` groups by whitespace or the usual mask characters. + */ +export const CNAE_FORMAT_REGEX = /^\d{4}[\s.\-/]*\d[\s.\-/]*\d{2}$/; diff --git a/src/_internals/is-lookup-code/is-lookup-code.test.ts b/src/_internals/is-lookup-code/is-lookup-code.test.ts new file mode 100644 index 00000000..795adc85 --- /dev/null +++ b/src/_internals/is-lookup-code/is-lookup-code.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "../test/runtime"; +import { isLookupCode } from "./is-lookup-code"; + +describe("isLookupCode", () => { + test("should return true for any string, whatever it holds", () => { + expect(isLookupCode("abc")).toBe(true); + expect(isLookupCode("")).toBe(true); + expect(isLookupCode("3550308")).toBe(true); + }); + + test("should return true for a non-negative integer number, zero included", () => { + expect(isLookupCode(0)).toBe(true); + expect(isLookupCode(1)).toBe(true); + expect(isLookupCode(3_550_308)).toBe(true); + }); + + test("should return false for a negative or fractional number", () => { + expect(isLookupCode(-1)).toBe(false); + expect(isLookupCode(1.5)).toBe(false); + }); + + test("should return false for a number past the safe integer range, whose digits are lost", () => { + expect(isLookupCode(2 ** 53)).toBe(false); + expect(isLookupCode(Number.MAX_VALUE)).toBe(false); + }); + + test("should return false for a number that is not finite", () => { + expect(isLookupCode(Number.NaN)).toBe(false); + expect(isLookupCode(Number.POSITIVE_INFINITY)).toBe(false); + }); + + test("should return false for null and undefined", () => { + const isLookupCodeWithoutArgument = isLookupCode as unknown as () => boolean; + + expect(isLookupCode(null)).toBe(false); + expect(isLookupCodeWithoutArgument()).toBe(false); + }); + + test("should return false for an object", () => { + expect(isLookupCode({})).toBe(false); + expect(isLookupCode(Object.create(null))).toBe(false); + }); +}); diff --git a/src/_internals/is-lookup-code/is-lookup-code.ts b/src/_internals/is-lookup-code/is-lookup-code.ts new file mode 100644 index 00000000..b542e0c3 --- /dev/null +++ b/src/_internals/is-lookup-code/is-lookup-code.ts @@ -0,0 +1,29 @@ +/** + * Checks whether a value may be handed to `sanitizeToDigits` and used as a lookup code. + * + * A string always may: whatever it holds, the sanitizer reduces it to its digits and the + * lookup either matches or misses. A number only may when it is a non-negative safe integer, + * because a minus sign and a decimal point are not digits and the sanitizer drops them + * silently: `-11` and `1.1` would both be read as the code `11`, and `-3550308` and `355030.8` + * as the code `3550308`. A number past `Number.MAX_SAFE_INTEGER` has already lost digits by + * the time it arrives, so it is rejected rather than sanitized into a code it never was. + * + * `Number.isSafeInteger` never coerces its argument, so it already rejects every value that is + * not a number; the conversion below only hands TypeScript a number to compare against zero. + * + * @param {unknown} value - The value to check. + * @returns {boolean} True when the value is a string, or a number that is a non-negative safe + * integer. + * + * @example + * ```typescript + * isLookupCode("3550308"); // true + * isLookupCode(3550308); // true + * isLookupCode(-11); // false + * isLookupCode(1.1); // false + * isLookupCode(2 ** 53); // false + * isLookupCode(null); // false + * ``` + */ +export const isLookupCode = (value: unknown): value is string | number => + typeof value === "string" || (Number.isSafeInteger(value) && Number(value) >= 0); diff --git a/src/get-bank-by-code/get-bank-by-code.ts b/src/get-bank-by-code/get-bank-by-code.ts index 6de94036..add89935 100644 --- a/src/get-bank-by-code/get-bank-by-code.ts +++ b/src/get-bank-by-code/get-bank-by-code.ts @@ -1,5 +1,5 @@ import { BANKS, type Bank } from "../_internals/constants/banks"; -import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; const CODE_LENGTH = 3; @@ -23,10 +23,7 @@ const CODE_LENGTH = 3; * generator (`scripts/banks.ts`) when the Bacen CSV request fails. */ export const getBankByCode = (code: string | number): Bank | null => { - if (isNullish(code) || (typeof code !== "string" && typeof code !== "number")) return null; - - // Stryker disable next-line EqualityOperator: no institution has COMPE code 000, so 0 and a negative number both resolve to null. - if (typeof code === "number" && (!Number.isInteger(code) || code < 0)) return null; + if (!isLookupCode(code)) return null; const digits = sanitizeToDigits(code); diff --git a/src/get-bank-by-ispb/get-bank-by-ispb.ts b/src/get-bank-by-ispb/get-bank-by-ispb.ts index bb22af74..cefd3156 100644 --- a/src/get-bank-by-ispb/get-bank-by-ispb.ts +++ b/src/get-bank-by-ispb/get-bank-by-ispb.ts @@ -1,5 +1,5 @@ import { BANKS, type Bank } from "../_internals/constants/banks"; -import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; const ISPB_LENGTH = 8; @@ -27,9 +27,7 @@ const ISPB_LENGTH = 8; * generator (`scripts/banks.ts`) when the Bacen CSV request fails. */ export const getBankByIspb = (value: string | number): Bank | null => { - if (isNullish(value) || (typeof value !== "string" && typeof value !== "number")) return null; - - if (typeof value === "number" && (!Number.isInteger(value) || value < 0)) return null; + if (!isLookupCode(value)) return null; const digits = sanitizeToDigits(value); diff --git a/src/get-cbo/get-cbo.test.ts b/src/get-cbo/get-cbo.test.ts index ce8c3e95..3512e909 100644 --- a/src/get-cbo/get-cbo.test.ts +++ b/src/get-cbo/get-cbo.test.ts @@ -64,6 +64,17 @@ describe("getCbo", () => { expect(getCbo(" ")).toBeNull(); }); + it("should return null for a string that is not written in a documented form", () => { + expect(getCbo("2124abc05")).toBeNull(); + expect(getCbo("21-2405")).toBeNull(); + }); + + it("should return null for a number that is not a non-negative safe integer", () => { + expect(getCbo(-212_405)).toBeNull(); + expect(getCbo(2124.05)).toBeNull(); + expect(getCbo(2 ** 53)).toBeNull(); + }); + describe("properties", () => { const codeArbitrary = fc.constantFrom(...Object.keys(CBO_TITLES)); diff --git a/src/get-cbo/get-cbo.ts b/src/get-cbo/get-cbo.ts index 237b7bec..617c4edb 100644 --- a/src/get-cbo/get-cbo.ts +++ b/src/get-cbo/get-cbo.ts @@ -1,7 +1,9 @@ -import { CBO_TITLES } from "../_internals/constants/cbo"; -import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { CBO_FORMAT_REGEX, CBO_TITLES } from "../_internals/constants/cbo"; +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +const CBO_LENGTH = 6; + /** * A CBO (Classificação Brasileira de Ocupações) occupation. */ @@ -16,6 +18,13 @@ export type Cbo = { * Looks a CBO (Classificação Brasileira de Ocupações) code up in the official CBO 2002 * table. * + * A string is only read as a code when it is written in one of the documented forms: the 6 + * digits, or the `NNNN-NN` mask, with the usual separators between the groups and optional + * surrounding whitespace. Anything else (`"2124abc05"`) is rejected instead of having its + * digits picked out. A number is only read as a code when it is a non-negative safe integer, + * since a sign, a decimal point or a rounded magnitude would otherwise be read as a code the + * caller never wrote. + * * @param {string|number} value - The CBO code to look up, with or without the hyphen * mask, e.g. `"2124-05"`, `"212405"` or `212405`. * @returns {Cbo|null} The matching occupation, or null when the code is unknown or invalid. @@ -25,6 +34,8 @@ export type Cbo = { * getCbo("2124-05"); // { code: "212405", title: "Analista de desenvolvimento de sistemas" } * getCbo(10205); // { code: "010205", title: "Oficial da Aeronáutica" } (a number is padded to 6 digits) * getCbo("999999"); // null + * getCbo("2124abc05"); // null (not a documented form) + * getCbo(-212405); // null (not a non-negative safe integer) * ``` * * @see Official: http://www.mtecbo.gov.br/cbosite/pages/downloads.jsf @@ -32,11 +43,13 @@ export type Cbo = { * Community mirror of the official table used to build `CBO_TITLES`. */ export const getCbo = (value: string | number): Cbo | null => { - if (isNullish(value)) return null; + if (!isLookupCode(value)) return null; + + const code = typeof value === "number" ? String(value).padStart(CBO_LENGTH, "0") : value.trim(); - const digits = - typeof value === "number" ? String(value).padStart(6, "0") : sanitizeToDigits(value); + if (!CBO_FORMAT_REGEX.test(code)) return null; + const digits = sanitizeToDigits(code); const title = CBO_TITLES[digits]; if (title === undefined) return null; diff --git a/src/get-cnae/get-cnae.test.ts b/src/get-cnae/get-cnae.test.ts index 2a987fed..cf8c4af6 100644 --- a/src/get-cnae/get-cnae.test.ts +++ b/src/get-cnae/get-cnae.test.ts @@ -53,6 +53,17 @@ describe("getCnae", () => { expect(getCnae("")).toBeNull(); }); + it("should return null for a string that is not written in a documented form", () => { + expect(getCnae("0111abc301")).toBeNull(); + expect(getCnae("62-01501")).toBeNull(); + }); + + it("should return null for a number that is not a non-negative safe integer", () => { + expect(getCnae(-111_301)).toBeNull(); + expect(getCnae(6201.501)).toBeNull(); + expect(getCnae(2 ** 53)).toBeNull(); + }); + it("should return null for null and undefined", () => { // @ts-expect-error not a string or number expect(getCnae(null)).toBeNull(); diff --git a/src/get-cnae/get-cnae.ts b/src/get-cnae/get-cnae.ts index ecf4c498..cd7c4d96 100644 --- a/src/get-cnae/get-cnae.ts +++ b/src/get-cnae/get-cnae.ts @@ -1,8 +1,10 @@ -import { CNAE_SUBCLASSES } from "../_internals/constants/cnae"; -import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { CNAE_FORMAT_REGEX, CNAE_SUBCLASSES } from "../_internals/constants/cnae"; +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; import { formatCnae } from "../format-cnae/format-cnae"; +const CNAE_LENGTH = 7; + /** * A CNAE (Classificação Nacional de Atividades Econômicas) subclass. */ @@ -17,6 +19,13 @@ export type Cnae = { * Looks a CNAE (Classificação Nacional de Atividades Econômicas) subclass code up in the * official CNAE 2.3 table. * + * A string is only read as a code when it is written in one of the documented forms: the 7 + * digits, or the `NNNN-N/NN` mask, with the usual separators between the groups and optional + * surrounding whitespace. Anything else (`"0111abc301"`) is rejected instead of having its + * digits picked out. A number is only read as a code when it is a non-negative safe integer, + * since a sign, a decimal point or a rounded magnitude would otherwise be read as a code the + * caller never wrote. + * * @param {string|number} value - The CNAE code to look up, with or without the * `NNNN-N/NN` mask. * @returns {Cnae|null} The matching subclass, or null when the code is unknown or invalid. @@ -26,16 +35,21 @@ export type Cnae = { * getCnae("6201501"); // { code: "6201-5/01", description: "DESENVOLVIMENTO DE PROGRAMAS DE COMPUTADOR SOB ENCOMENDA" } * getCnae(111301); // { code: "0111-3/01", description: "CULTIVO DE ARROZ" } (a number is padded to 7 digits) * getCnae("0000000"); // null + * getCnae("0111abc301"); // null (not a documented form) + * getCnae(-111301); // null (not a non-negative safe integer) * ``` * * @see Official: https://servicodados.ibge.gov.br/api/v2/cnae/subclasses */ export const getCnae = (value: string | number): Cnae | null => { - if (isNullish(value)) return null; + if (!isLookupCode(value)) return null; + + const subclass = + typeof value === "number" ? String(value).padStart(CNAE_LENGTH, "0") : value.trim(); - const digits = - typeof value === "number" ? String(value).padStart(7, "0") : sanitizeToDigits(value); + if (!CNAE_FORMAT_REGEX.test(subclass)) return null; + const digits = sanitizeToDigits(subclass); const description = CNAE_SUBCLASSES[digits]; if (description === undefined) return null; 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 index faa1a4e8..f0853a18 100644 --- a/src/get-municipality-by-code/get-municipality-by-code.test.ts +++ b/src/get-municipality-by-code/get-municipality-by-code.test.ts @@ -71,6 +71,20 @@ describe("getMunicipalityByCode", () => { expect(getMunicipalityByCode([])).toBeNull(); }); + it("should return null for a negative number, instead of dropping its sign", () => { + expect(getMunicipalityByCode(-3_550_308)).toBeNull(); + }); + + it("should return null for a fractional number, instead of dropping its decimal point", () => { + expect(getMunicipalityByCode(355_030.8)).toBeNull(); + expect(getMunicipalityByCode(3_550_308.5)).toBeNull(); + }); + + it("should return null for a non-finite number", () => { + expect(getMunicipalityByCode(Number.NaN)).toBeNull(); + expect(getMunicipalityByCode(Number.POSITIVE_INFINITY)).toBeNull(); + }); + it("should ignore non-digit characters before validating the length", () => { expect(getMunicipalityByCode("355-030-8")).toEqual({ code: "3550308", diff --git a/src/get-municipality-by-code/get-municipality-by-code.ts b/src/get-municipality-by-code/get-municipality-by-code.ts index 1b32c590..0fe0a02f 100644 --- a/src/get-municipality-by-code/get-municipality-by-code.ts +++ b/src/get-municipality-by-code/get-municipality-by-code.ts @@ -1,11 +1,14 @@ import { DATA as CITIES_DATA, type Municipality } from "../_internals/constants/cities"; -import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; import { getStates } from "../get-states/get-states"; /** * Looks up a Brazilian municipality by its 7 digit IBGE code, published by the IBGE. * + * A `code` given as a number must be a non-negative integer: a sign and a decimal point are + * not digits, so `-3550308` and `355030.8` are rejected instead of being read as `3550308`. + * * @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. @@ -20,7 +23,7 @@ import { getStates } from "../get-states/get-states"; * @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; + if (!isLookupCode(code)) return null; const digits = sanitizeToDigits(code); diff --git a/src/get-municipality/get-municipality.test.ts b/src/get-municipality/get-municipality.test.ts index a8e0cb75..6f969b19 100644 --- a/src/get-municipality/get-municipality.test.ts +++ b/src/get-municipality/get-municipality.test.ts @@ -88,6 +88,20 @@ describe("getMunicipality", () => { await expect(getMunicipality({ code: 0 })).resolves.toBeNull(); }); + it("should return null for a negative number, instead of dropping its sign", async () => { + await expect(getMunicipality({ code: -3_550_308 })).resolves.toBeNull(); + }); + + it("should return null for a fractional number, instead of dropping its decimal point", async () => { + await expect(getMunicipality({ code: 355_030.8 })).resolves.toBeNull(); + await expect(getMunicipality({ code: 3_550_308.5 })).resolves.toBeNull(); + }); + + it("should return null for a non-finite number", async () => { + await expect(getMunicipality({ code: Number.NaN })).resolves.toBeNull(); + await expect(getMunicipality({ code: Number.POSITIVE_INFINITY })).resolves.toBeNull(); + }); + it("should return null for a code with the wrong number of digits", async () => { await expect(getMunicipality({ code: "123" })).resolves.toBeNull(); await expect(getMunicipality({ code: "12345678" })).resolves.toBeNull(); diff --git a/src/get-municipality/get-municipality.ts b/src/get-municipality/get-municipality.ts index 91863129..ff56f74c 100644 --- a/src/get-municipality/get-municipality.ts +++ b/src/get-municipality/get-municipality.ts @@ -1,4 +1,5 @@ import { DATA as CITIES_DATA } from "../_internals/constants/cities"; +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; import { removeAccents } from "../remove-accents/remove-accents"; @@ -29,6 +30,8 @@ let codeIndex: Map | undefined; const normalizeName = (value: string): string => removeAccents(value).trim().toUpperCase(); const getMunicipalityByCode = (code: string | number): [string, string] | null => { + if (!isLookupCode(code)) return null; + if (!codeIndex) { codeIndex = new Map(); @@ -39,10 +42,9 @@ const getMunicipalityByCode = (code: string | number): [string, string] | null = } } - if (typeof code !== "string" && typeof code !== "number") return null; - // `Map#get` never throws and simply misses for a key of the wrong shape (a malformed, too - // short or too long code), so there is no need to pre-validate `code` any further. + // short or too long code), so only the sign and the decimal point of a numeric `code`, which + // `sanitizeToDigits` would silently drop, have to be pre-validated above. return codeIndex.get(sanitizeToDigits(code)) ?? null; }; @@ -74,7 +76,9 @@ const getMunicipalityCodeByName = ({ * * Given a `code` it resolves the municipality name and its UF; given a `municipalityName` * and a `uf` it resolves the IBGE code. The name lookup ignores accents and casing. - * Validation failures and unknown municipalities are reported as `null`. + * Validation failures and unknown municipalities are reported as `null`. A `code` given as a + * number must be a non-negative integer: a sign and a decimal point are not digits, so + * `-3550308` and `355030.8` are rejected instead of being read as `3550308`. * * @param {GetMunicipalityOptions} options - Either `{ code }` or `{ municipalityName, uf }`. * @returns {Promise<[string, string] | string | null>} The `[name, uf]` pair when looking up 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 index c8dfd23e..38ccafc0 100644 --- 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 @@ -55,6 +55,14 @@ describe("getStateByIbgeCode", () => { expect(getStateByIbgeCode("")).toBeNull(); }); + it("should return null for a negative number, not read it as the cUF 35", () => { + expect(getStateByIbgeCode(-35)).toBeNull(); + }); + + it("should return null for a fractional number, not read it as the cUF 35", () => { + expect(getStateByIbgeCode(3.5)).toBeNull(); + }); + it("should return null for whitespace only", () => { expect(getStateByIbgeCode(" ")).toBeNull(); }); 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 index cc3fe7ce..42d30a4e 100644 --- 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 @@ -1,5 +1,5 @@ import { DATA, type State } from "../_internals/constants/states"; -import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; /** @@ -9,8 +9,11 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d * 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. + * A `code` given as a number must be a non-negative integer: a sign and a decimal point are + * not digits, so `-35` and `3.5` are rejected instead of being read as `35`. + * + * @param {string|number} code - The 2-digit IBGE UF code. Accepts a string or a non-negative + * integer 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. * @@ -25,10 +28,11 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d * getStateByIbgeCode("11"); // { code: "RO", name: "Rondônia", regionCode: "N", regionName: "Norte", ibgeCode: 11 } * getStateByIbgeCode("00"); // null * getStateByIbgeCode(""); // null + * getStateByIbgeCode(-35); // null * ``` */ export const getStateByIbgeCode = (code: string | number): State | null => { - if (isNullish(code)) return null; + if (!isLookupCode(code)) return null; const digits = sanitizeToDigits(code); diff --git a/src/is-valid-cbo/is-valid-cbo.test.ts b/src/is-valid-cbo/is-valid-cbo.test.ts index 468d7c2a..a155360c 100644 --- a/src/is-valid-cbo/is-valid-cbo.test.ts +++ b/src/is-valid-cbo/is-valid-cbo.test.ts @@ -57,6 +57,24 @@ describe("isValidCbo", () => { expect(isValidCbo("abcdef")).toBe(false); }); + it("should accept the separators the mask uses between the two groups", () => { + expect(isValidCbo("2124 05")).toBe(true); + expect(isValidCbo("2124.05")).toBe(true); + expect(isValidCbo("2124/05")).toBe(true); + }); + + it("should return false for a string that is not written in a documented form", () => { + expect(isValidCbo("2124abc05")).toBe(false); + expect(isValidCbo("21-2405")).toBe(false); + expect(isValidCbo("+212405")).toBe(false); + }); + + it("should return false for a number that is not a non-negative safe integer", () => { + expect(isValidCbo(-212_405)).toBe(false); + expect(isValidCbo(2124.05)).toBe(false); + expect(isValidCbo(2 ** 53)).toBe(false); + }); + describe("properties", () => { const codeArbitrary = fc.constantFrom(...Object.keys(CBO_TITLES)); diff --git a/src/is-valid-cbo/is-valid-cbo.ts b/src/is-valid-cbo/is-valid-cbo.ts index 7b008a70..db634cf1 100644 --- a/src/is-valid-cbo/is-valid-cbo.ts +++ b/src/is-valid-cbo/is-valid-cbo.ts @@ -4,6 +4,11 @@ import { getCbo } from "../get-cbo/get-cbo"; * Validates if a CBO (Classificação Brasileira de Ocupações) code exists in the official * CBO 2002 table. * + * A string is only read as a code when it is written in one of the documented forms: the 6 + * digits, or the `NNNN-NN` mask, with the usual separators between the groups and optional + * surrounding whitespace. A number is only read as a code when it is a non-negative safe + * integer. + * * @param {string|number} value - The CBO code to be validated, with or without the hyphen * mask, e.g. `"2124-05"`, `"212405"` or `212405`. * @returns {boolean} True when the code is a known 6 digit occupation code, false otherwise. @@ -15,6 +20,8 @@ import { getCbo } from "../get-cbo/get-cbo"; * isValidCbo(212405); // true * isValidCbo(10205); // true (a number is padded to 6 digits, so this is "010205") * isValidCbo("999999"); // false + * isValidCbo("2124abc05"); // false (not a documented form) + * isValidCbo(-212405); // false (not a non-negative safe integer) * ``` * * @see Official: http://www.mtecbo.gov.br/cbosite/pages/downloads.jsf diff --git a/src/is-valid-cnae/is-valid-cnae.test.ts b/src/is-valid-cnae/is-valid-cnae.test.ts index 16926071..41b7b305 100644 --- a/src/is-valid-cnae/is-valid-cnae.test.ts +++ b/src/is-valid-cnae/is-valid-cnae.test.ts @@ -57,6 +57,23 @@ describe("isValidCnae", () => { expect(isValidCnae("abcdefg")).toBe(false); }); + it("should accept the separators the mask uses between the three groups", () => { + expect(isValidCnae("6201 5 01")).toBe(true); + expect(isValidCnae("6201.5.01")).toBe(true); + }); + + it("should return false for a string that is not written in a documented form", () => { + expect(isValidCnae("0111abc301")).toBe(false); + expect(isValidCnae("62-01501")).toBe(false); + expect(isValidCnae("+6201501")).toBe(false); + }); + + it("should return false for a number that is not a non-negative safe integer", () => { + expect(isValidCnae(-111_301)).toBe(false); + expect(isValidCnae(6201.501)).toBe(false); + expect(isValidCnae(2 ** 53)).toBe(false); + }); + describe("properties", () => { const codeArbitrary = fc.constantFrom(...Object.keys(CNAE_SUBCLASSES)); diff --git a/src/is-valid-cnae/is-valid-cnae.ts b/src/is-valid-cnae/is-valid-cnae.ts index ae052d77..e6c6b566 100644 --- a/src/is-valid-cnae/is-valid-cnae.ts +++ b/src/is-valid-cnae/is-valid-cnae.ts @@ -4,6 +4,11 @@ import { getCnae } from "../get-cnae/get-cnae"; * Validates if a CNAE (Classificação Nacional de Atividades Econômicas) subclass code * exists in the official CNAE 2.3 table. * + * A string is only read as a code when it is written in one of the documented forms: the 7 + * digits, or the `NNNN-N/NN` mask, with the usual separators between the groups and optional + * surrounding whitespace. A number is only read as a code when it is a non-negative safe + * integer. + * * @param {string|number} value - The CNAE code to be validated, with or without the * `NNNN-N/NN` mask, e.g. `"6201-5/01"`, `"6201501"` or `6201501`. * @returns {boolean} True when the code is a known 7 digit subclass, false otherwise. @@ -15,6 +20,8 @@ import { getCnae } from "../get-cnae/get-cnae"; * isValidCnae(6201501); // true * isValidCnae(111301); // true (a number is padded to 7 digits, so this is "0111301") * isValidCnae("0000000"); // false + * isValidCnae("0111abc301"); // false (not a documented form) + * isValidCnae(-111301); // false (not a non-negative safe integer) * ``` * * @see Official: https://servicodados.ibge.gov.br/api/v2/cnae/subclasses diff --git a/src/is-valid-credit-card/is-valid-credit-card.test.ts b/src/is-valid-credit-card/is-valid-credit-card.test.ts index 72dc7c85..ef4faca4 100644 --- a/src/is-valid-credit-card/is-valid-credit-card.test.ts +++ b/src/is-valid-credit-card/is-valid-credit-card.test.ts @@ -69,6 +69,19 @@ describe("isValidCreditCard", () => { expect(isValidCreditCard("")).toBe(false); }); + test("when it is a number above Number.MAX_SAFE_INTEGER", () => { + expect(isValidCreditCard(2 ** 53)).toBe(false); + expect(isValidCreditCard(Number("4111111111111111111"))).toBe(false); + }); + + test("when it is a negative number", () => { + expect(isValidCreditCard(-4_111_111_111_111_111)).toBe(false); + }); + + test("when it is a number that is not an integer", () => { + expect(isValidCreditCard(411_111_111_111_111.1)).toBe(false); + }); + test("when it contains only letters", () => { expect(isValidCreditCard("abcdabcdabcd")).toBe(false); }); diff --git a/src/is-valid-credit-card/is-valid-credit-card.ts b/src/is-valid-credit-card/is-valid-credit-card.ts index b5c50ab0..7909fb63 100644 --- a/src/is-valid-credit-card/is-valid-credit-card.ts +++ b/src/is-valid-credit-card/is-valid-credit-card.ts @@ -1,3 +1,4 @@ +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { mod10 } from "../_internals/mod10/mod10"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; import { MAX_LENGTH, MIN_LENGTH } from "./constants"; @@ -10,6 +11,11 @@ import { MAX_LENGTH, MIN_LENGTH } from "./constants"; * ISO/IEC 7812-1 caps the PAN at 19) and the Luhn check digit; it performs no brand detection * (Visa, Mastercard, Amex...), issuer range lookup or expiration/CVV checks. * + * A number is only accepted when it is a non-negative safe integer: a card number above + * `Number.MAX_SAFE_INTEGER` (2^53 - 1, 16 digits) has already been rounded to a different + * number by the time it arrives, and a negative one is not a PAN, so both are rejected rather + * than validated as digits the caller never wrote. Pass a longer PAN as a string. + * * @param {string|number} value - The card number to be validated. * @returns {boolean} True when `value` sanitizes to 12-19 digits ending in a valid Luhn check digit. * @@ -21,6 +27,7 @@ import { MAX_LENGTH, MIN_LENGTH } from "./constants"; * isValidCreditCard("4111 1111 1111 1111"); // true (spaced mask) * isValidCreditCard("4111111111111112"); // false (bad check digit) * isValidCreditCard("123456789"); // false (too short) + * isValidCreditCard(4111111111111111111); // false (above 2^53 - 1, pass it as a string) * ``` * * ISO/IEC 7812-1 (issuer identification numbers) caps the PAN at 19 digits but sets no @@ -29,7 +36,7 @@ import { MAX_LENGTH, MIN_LENGTH } from "./constants"; * @see Official: https://www.iso.org/standard/70484.html */ export const isValidCreditCard = (value: string | number): boolean => { - if (typeof value !== "string" && typeof value !== "number") return false; + if (!isLookupCode(value)) return false; const digits = sanitizeToDigits(value); From b8ded2e2d0f0830412cd5b878acf49729c1ed0b9 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:43:22 -0300 Subject: [PATCH 06/14] fix(certidao): require the registro civil service code 55 --- src/_internals/constants/certidao.ts | 7 +++++++ src/is-valid-certidao/is-valid-certidao.test.ts | 9 +++++++-- src/is-valid-certidao/is-valid-certidao.ts | 8 +++++++- src/parse-certidao/parse-certidao.test.ts | 6 +++++- src/parse-certidao/parse-certidao.ts | 5 +++-- 5 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/_internals/constants/certidao.ts b/src/_internals/constants/certidao.ts index a85ba8f2..f821a69e 100644 --- a/src/_internals/constants/certidao.ts +++ b/src/_internals/constants/certidao.ts @@ -21,6 +21,13 @@ export const CERTIDAO_LENGTH = 32; export const CERTIDAO_BASE_LENGTH = 30; +/** + * The only serviço code a matrícula de registro civil can carry, in the ninth and tenth + * positions: art. 473, III fixes it as "Código 55 (9º e 10º números da matrícula), que é o + * número relativo ao serviço de registro civil das pessoas naturais". + */ +export const CERTIDAO_SERVICE_CODE = "55"; + export const CERTIDAO_PATTERN = "000000 00 00 0000 0 00000 000 0000000 00"; export const CERTIDAO_FORMAT_REGEX = diff --git a/src/is-valid-certidao/is-valid-certidao.test.ts b/src/is-valid-certidao/is-valid-certidao.test.ts index 2f636a9e..66652fe2 100644 --- a/src/is-valid-certidao/is-valid-certidao.test.ts +++ b/src/is-valid-certidao/is-valid-certidao.test.ts @@ -68,6 +68,11 @@ describe("isValidCertidao", () => { test("when the book code is 0, outside the nine books, even with matching check digits", () => { expect(isValidCertidao("10453901552013000012021000012387")).toBe(false); }); + + test("when the serviço is not the 55 of art. 473, III, even with matching check digits", () => { + expect(isValidCertidao("09400301542011100110002005191744")).toBe(false); + expect(isValidCertidao("094003 01 56 2011 1 00110 002 0051917 42")).toBe(false); + }); }); describe("should return true", () => { @@ -182,10 +187,10 @@ describe("isValidCertidao", () => { }); describe("properties", () => { - const bases = fc.stringMatching(/^[0-9]{14}[1-9][0-9]{15}$/); + const bases = fc.stringMatching(/^[0-9]{8}55[0-9]{4}[1-9][0-9]{15}$/); const books = fc.tuple( - fc.stringMatching(/^[0-9]{14}$/), + fc.stringMatching(/^[0-9]{8}55[0-9]{4}$/), fc.integer({ min: 1, max: 9 }), fc.stringMatching(/^[0-9]{15}$/), ); diff --git a/src/is-valid-certidao/is-valid-certidao.ts b/src/is-valid-certidao/is-valid-certidao.ts index 7ff19502..f8ea9e54 100644 --- a/src/is-valid-certidao/is-valid-certidao.ts +++ b/src/is-valid-certidao/is-valid-certidao.ts @@ -2,6 +2,7 @@ import { CERTIDAO_BASE_LENGTH, CERTIDAO_FORMAT_REGEX, CERTIDAO_LENGTH, + CERTIDAO_SERVICE_CODE, } from "../_internals/constants/certidao"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; import { CERTIDAO_TYPES } from "../parse-certidao/constants"; @@ -33,7 +34,9 @@ const getCheckDigit = (value: string): number => { * * 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 + * printed as "000000 00 00 0000 0 00000 000 0000000 00". The serviço is fixed at `55`, the code + * art. 473, III assigns to the registro civil das pessoas naturais, so a matrícula carrying any + * other pair there is rejected. 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 @@ -58,6 +61,7 @@ const getCheckDigit = (value: string): number => { * 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("09400301542011100110002005191744"); // false (serviço is not 55) * 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 @@ -83,6 +87,8 @@ export const isValidCertidao = (value: string, options?: IsValidCertidaoOptions) if (!CERTIDAO_FORMAT_REGEX.test(value.trim())) return false; + if (digits.slice(8, 10) !== CERTIDAO_SERVICE_CODE) return false; + const base = digits.slice(0, CERTIDAO_BASE_LENGTH); const first = getCheckDigit(base); const second = getCheckDigit(`${base}${first}`); diff --git a/src/parse-certidao/parse-certidao.test.ts b/src/parse-certidao/parse-certidao.test.ts index 813507fe..984367f7 100644 --- a/src/parse-certidao/parse-certidao.test.ts +++ b/src/parse-certidao/parse-certidao.test.ts @@ -42,6 +42,10 @@ describe("parseCertidao", () => { expect(parseCertidao("10453901552013000012021000012387")).toBeNull(); }); + test("when the serviço is not the 55 of art. 473, III, even with matching check digits", () => { + expect(parseCertidao("09400301542011100110002005191744")).toBeNull(); + }); + test("when it is a number, which cannot carry the 32 significant digits of a matrícula", () => { // @ts-expect-error: intentionally invalid input expect(parseCertidao(1_045_390_155)).toBeNull(); @@ -131,7 +135,7 @@ describe("parseCertidao", () => { const parts = fc.tuple( fc.stringMatching(/^[0-9]{6}$/), fc.stringMatching(/^[0-9]{2}$/), - fc.stringMatching(/^[0-9]{2}$/), + fc.constant("55"), fc.integer({ min: 1000, max: 9999 }), fc.integer({ min: 1, max: 9 }), fc.stringMatching(/^[0-9]{5}$/), diff --git a/src/parse-certidao/parse-certidao.ts b/src/parse-certidao/parse-certidao.ts index 2f12e0ac..977e19d5 100644 --- a/src/parse-certidao/parse-certidao.ts +++ b/src/parse-certidao/parse-certidao.ts @@ -30,7 +30,7 @@ export type Certidao = { 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 rendered by the serventia, always "55", the registro civil das pessoas naturais. */ service: string; /** Four digit year the act was recorded. */ year: number; @@ -52,7 +52,8 @@ export type Certidao = { * 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, which includes a book code that is not one of the nine books defined by the + * not valid, which includes a serviço other than the `55` art. 473, III fixes for the registro + * civil das pessoas naturais, and a book code that is not one of the nine books defined by the * Provimento, since an unknown book cannot be named. * * Only a string is accepted: the 32 digits of a matrícula are more than a JavaScript number can From fe9e30fa25e197070779071dd6b8aa53500ccea0 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:43:22 -0300 Subject: [PATCH 07/14] feat(nfe-key): accept the CT-e OS model 67 --- src/format-nfe-key/format-nfe-key.ts | 4 ++-- src/is-valid-nfe-key/is-valid-nfe-key.test.ts | 7 ++++++- src/is-valid-nfe-key/is-valid-nfe-key.ts | 5 ++++- src/parse-nfe-key/constants.ts | 9 ++++++-- src/parse-nfe-key/parse-nfe-key.test.ts | 12 ++++++----- src/parse-nfe-key/parse-nfe-key.ts | 21 ++++++++++++------- 6 files changed, 40 insertions(+), 18 deletions(-) diff --git a/src/format-nfe-key/format-nfe-key.ts b/src/format-nfe-key/format-nfe-key.ts index 78c08078..ab942b29 100644 --- a/src/format-nfe-key/format-nfe-key.ts +++ b/src/format-nfe-key/format-nfe-key.ts @@ -4,8 +4,8 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d 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. + * Formats a DF-e (NF-e, NFC-e, CT-e, MDF-e or CT-e OS) 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 ...". 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 index e64a69ec..e6aa5ea0 100644 --- a/src/is-valid-nfe-key/is-valid-nfe-key.test.ts +++ b/src/is-valid-nfe-key/is-valid-nfe-key.test.ts @@ -99,7 +99,7 @@ describe("isValidNfeKey", () => { expect(isValidNfeKey(`00${VALID_B.slice(2)}`)).toBe(false); }); - test("when the mod is not 55, 57, 58 or 65", () => { + test("when the mod is not 55, 57, 58, 65 or 67", () => { expect(isValidNfeKey(`${VALID_B.slice(0, 20)}99${VALID_B.slice(22)}`)).toBe(false); }); @@ -158,6 +158,11 @@ describe("isValidNfeKey", () => { key: "35200600000000000000990010000000011000000003", expected: false, }, + { + name: "model 67, the CT-e OS of the Ajuste SINIEF 09/07", + key: "35170458716523000119670010000000121000123458", + expected: true, + }, { name: "tpEmis 9, the upper boundary", key: "35200600000000000000550010000000019000000003", diff --git a/src/is-valid-nfe-key/is-valid-nfe-key.ts b/src/is-valid-nfe-key/is-valid-nfe-key.ts index b0e0b9fc..2d07e9c5 100644 --- a/src/is-valid-nfe-key/is-valid-nfe-key.ts +++ b/src/is-valid-nfe-key/is-valid-nfe-key.ts @@ -4,7 +4,8 @@ import { parseNfeKey } from "../parse-nfe-key/parse-nfe-key"; * 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 + * (modelo 65), CT-e (modelo 57), MDF-e (modelo 58) and CT-e OS (modelo 67, the Conhecimento de + * Transporte Eletrônico para Outros Serviços). 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. * @@ -18,6 +19,8 @@ import { parseNfeKey } from "../parse-nfe-key/parse-nfe-key"; * * @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 Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/2007/aj_009_07 + * Ajuste SINIEF 09/07, cláusula primeira, § 3.º, II, "b": the CT-e OS, modelo 67. * @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 diff --git a/src/parse-nfe-key/constants.ts b/src/parse-nfe-key/constants.ts index ec8cc5e3..82beff4c 100644 --- a/src/parse-nfe-key/constants.ts +++ b/src/parse-nfe-key/constants.ts @@ -1,5 +1,10 @@ -/** Valid `mod` (modelo do documento) values shared by every DF-e access key. */ -export const VALID_MODELS = ["55", "57", "58", "65"] as const; +/** + * Valid `mod` (modelo do documento) values shared by every DF-e access key: 55 NF-e, 57 CT-e, + * 58 MDF-e, 65 NFC-e and 67 CT-e OS (Conhecimento de Transporte Eletrônico para Outros + * Serviços), the model the CT-e MOC assigns to the transporte de pessoas, valores e excesso de + * bagagem, which shares the same 44 digit key. + */ +export const VALID_MODELS = ["55", "57", "58", "65", "67"] as const; /** * The `tpEmis` (forma de emissão) codes the MOC assigns: 1 normal, 2 contingência FS-IA, diff --git a/src/parse-nfe-key/parse-nfe-key.test.ts b/src/parse-nfe-key/parse-nfe-key.test.ts index 9abbbe2a..b10b6a5b 100644 --- a/src/parse-nfe-key/parse-nfe-key.test.ts +++ b/src/parse-nfe-key/parse-nfe-key.test.ts @@ -3,7 +3,7 @@ import * as fc from "fast-check"; import { IBGE_UF_CODES } from "../_internals/constants/ibge-uf-codes"; import { type StateCode } from "../_internals/constants/states"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; -import { VALID_EMISSION_TYPES } from "./constants"; +import { VALID_EMISSION_TYPES, VALID_MODELS } from "./constants"; import { parseNfeKey, type NfeKey, type NfeKeyModel } from "./parse-nfe-key"; const KEY_SP = "35170458716523000119550010000000121000123458"; @@ -40,7 +40,7 @@ describe("parseNfeKey", () => { expect(parseNfeKey(`${KEY_SP.slice(0, 43)}9`)).toBeNull(); }); - test("when the model is not 55, 57, 58 or 65 (model 99 with a matching check digit)", () => { + test("when the model is not 55, 57, 58, 65 or 67 (model 99 with a matching check digit)", () => { expect(parseNfeKey("35170458716523000119990010000000121000123453")).toBeNull(); }); @@ -104,10 +104,11 @@ describe("parseNfeKey", () => { expect(parseNfeKey("35170458716523000119550010000000129000123453")?.emissionType).toBe(9); }); - 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", () => { + test("for every other DF-e model (CT-e, MDF-e, NFC-e, CT-e OS), 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"); + expect(parseNfeKey("35170458716523000119670010000000121000123458")?.model).toBe("67"); }); }); @@ -117,7 +118,7 @@ describe("parseNfeKey", () => { fc.stringMatching(/^[0-9]{2}$/), fc.integer({ min: 1, max: 12 }), fc.stringMatching(/^[0-9]{14}$/), - fc.constantFrom("55", "57", "58", "65"), + fc.constantFrom("55", "57", "58", "65", "67"), fc.stringMatching(/^[0-9]{3}$/), fc.integer({ min: 1, max: 999_999_999 }), fc.constantFrom(...VALID_EMISSION_TYPES), @@ -175,6 +176,7 @@ describe("parseNfeKey types", () => { code: string; checkDigit: number; }>(); - expectTypeOf().toEqualTypeOf<"55" | "57" | "58" | "65">(); + expectTypeOf().toEqualTypeOf<"55" | "57" | "58" | "65" | "67">(); + expectTypeOf().toEqualTypeOf<(typeof VALID_MODELS)[number]>(); }); }); diff --git a/src/parse-nfe-key/parse-nfe-key.ts b/src/parse-nfe-key/parse-nfe-key.ts index a580d987..b7f2ab05 100644 --- a/src/parse-nfe-key/parse-nfe-key.ts +++ b/src/parse-nfe-key/parse-nfe-key.ts @@ -12,8 +12,13 @@ import { VALID_MODELS, } from "./constants"; -/** The document models a DF-e access key can carry: `"55"` NF-e, `"57"` CT-e, `"58"` MDF-e and `"65"` NFC-e. */ -export type NfeKeyModel = "55" | "57" | "58" | "65"; +/** + * The document models a DF-e access key can carry: `"55"` NF-e, `"57"` CT-e, `"58"` MDF-e, + * `"65"` NFC-e and `"67"` CT-e OS. Spelled out instead of derived from `VALID_MODELS` because + * the allowlist is internal and API Extractor cannot name it in the public report; the type + * test of `parse-nfe-key.test.ts` pins the two together so they cannot drift apart. + */ +export type NfeKeyModel = "55" | "57" | "58" | "65" | "67"; /** The fields `parseNfeKey` reads out of a DF-e access key (chave de acesso). */ export type NfeKey = { @@ -25,7 +30,7 @@ export type NfeKey = { 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. */ + /** Document model: "55" NF-e, "57" CT-e, "58" MDF-e, "65" NFC-e, "67" CT-e OS. */ model: NfeKeyModel; /** Document series, 0 to 999. */ series: number; @@ -43,16 +48,18 @@ export type NfeKey = { * 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. The emission type (`tpEmis`) must be one of the codes the MOC assigns, 1 to 7 - * or 9; 8 is not assigned and is rejected. + * (modelo 65), CT-e (modelo 57), MDF-e (modelo 58) and CT-e OS (modelo 67). Accepts the same + * input forms as `isValidNfeKey` (whitespace mask, `NFe` XML `Id` prefix) and returns `null` + * when the key is not valid. The emission type (`tpEmis`) must be one of the codes the MOC + * assigns, 1 to 7 or 9; 8 is not assigned and is rejected. * * @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 Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/2007/aj_009_07 + * Ajuste SINIEF 09/07, cláusula primeira, § 3.º, II, "b": the CT-e OS, modelo 67. * @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 From 27d6664e9cdc70d1277f452cbb7ed69164fb0859 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:43:22 -0300 Subject: [PATCH 08/14] fix(cfop): drop the 23 group headings ending in 50 from the table --- scripts/cfop.ts | 8 ++++--- src/_internals/constants/cfop.ts | 29 ++++--------------------- src/get-cfop/get-cfop.test.ts | 21 ++++++++++++++++++ src/get-cfop/get-cfop.ts | 5 +++++ src/is-valid-cfop/is-valid-cfop.test.ts | 15 +++++++++++++ src/is-valid-cfop/is-valid-cfop.ts | 5 +++++ 6 files changed, 55 insertions(+), 28 deletions(-) diff --git a/scripts/cfop.ts b/scripts/cfop.ts index 2a543bbe..3fa95823 100644 --- a/scripts/cfop.ts +++ b/scripts/cfop.ts @@ -55,7 +55,7 @@ const main = async (): Promise => { if (code === undefined || description === undefined) continue; for (const [entryCode, entryDescription] of splitEmbeddedEntries(code, description)) { - if (entryCode.endsWith("00")) continue; + if (entryCode.endsWith("00") || entryCode.endsWith("50")) continue; data[entryCode] = entryDescription; } @@ -70,8 +70,10 @@ const main = async (): Promise => { `/** * CFOP (Código Fiscal de Operações e Prestações) table, indexed by the 4 digit code. * - * Group and subgroup headers (codes ending in "00", e.g. "1000", "1100") are section - * titles from the official nomenclature rather than operable codes, so they are excluded. + * Group and subgroup headers (codes ending in "00" or "50", e.g. "1000", "1100", "1150") + * are section titles from the official nomenclature rather than operable codes, so they + * are excluded: the Ajuste SINIEF 07/01 prints them in upper case with no "Classificam-se + * neste código" body, unlike the operable codes they head (1151, 1152, ...). * * Generated by \`node ./scripts/cfop.ts\`. Do not edit by hand. * diff --git a/src/_internals/constants/cfop.ts b/src/_internals/constants/cfop.ts index 68a9028c..c9342349 100644 --- a/src/_internals/constants/cfop.ts +++ b/src/_internals/constants/cfop.ts @@ -1,8 +1,10 @@ /** * CFOP (Código Fiscal de Operações e Prestações) table, indexed by the 4 digit code. * - * Group and subgroup headers (codes ending in "00", e.g. "1000", "1100") are section - * titles from the official nomenclature rather than operable codes, so they are excluded. + * Group and subgroup headers (codes ending in "00" or "50", e.g. "1000", "1100", "1150") + * are section titles from the official nomenclature rather than operable codes, so they + * are excluded: the Ajuste SINIEF 07/01 prints them in upper case with no "Classificam-se + * neste código" body, unlike the operable codes they head (1151, 1152, ...). * * Generated by `node ./scripts/cfop.ts`. Do not edit by hand. * @@ -30,7 +32,6 @@ export const CFOP_TABLE: Record = { "Industrialização efetuada por outra empresa quando a mercadoria remetida para utilização no processo de industrialização não transitou pelo estabelecimento adquirente da mercadoria", "1126": "compras para utilização na prestação de serviços sujeitas ao ICMS", "1128": "compras para utilização na prestação de serviços sujeitas ao ISSQN", - "1150": "TRANSFERÊNCIAS PARA INDUSTRIALIZAÇÃO, COMERCIALIZAÇÃO OU PRESTAÇÃO DE SERVIÇOS", "1151": "Transferência para industrialização ou produção rural", "1152": "Transferência para comercialização", "1153": "Transferência de energia elétrica para distribuição", @@ -46,7 +47,6 @@ export const CFOP_TABLE: Record = { "1207": "Anulação de valor relativo à venda de energia elétrica", "1208": "Devolução de produção do estabelecimento, remetida em transferência", "1209": "Devolução de mercadoria adquirida ou recebida de terceiros, remetida em transferência", - "1250": "COMPRAS DE ENERGIA ELÉTRICA", "1251": "Compra de energia elétrica para distribuição ou comercialização", "1252": "Compra de energia elétrica por estabelecimento industrial", "1253": "Compra de energia elétrica por estabelecimento comercial", @@ -62,7 +62,6 @@ export const CFOP_TABLE: Record = { "1305": "Aquisição de serviço de comunicação por estabelecimento de geradora ou de distribuidora de energia elétrica", "1306": "Aquisição de serviço de comunicação por estabelecimento de produtor rural", - "1350": "AQUISIÇÕES DE SERVIÇOS DE TRANSPORTE", "1351": "Aquisição de serviço de transporte para execução de serviço da mesma natureza", "1352": "Aquisição de serviço de transporte por estabelecimento industrial", "1353": "Aquisição de serviço de transporte por estabelecimento comercial", @@ -93,7 +92,6 @@ export const CFOP_TABLE: Record = { "Retorno de produção do estabelecimento, remetida para venda fora do estabelecimento em operação com produto sujeito ao regime de substituição tributária", "1415": "Retorno de mercadoria adquirida ou recebida de terceiros, remetida para venda fora do estabelecimento em operação com mercadoria sujeita ao regime de substituição tributária", - "1450": "SISTEMAS DE INTEGRAÇÃO", "1451": "Retorno de animal do estabelecimento produtor", "1452": "Retorno de insumo não utilizado na produção", "1501": "Entrada de mercadoria recebida com fim específico de exportação", @@ -105,7 +103,6 @@ export const CFOP_TABLE: Record = { "Entrada decorrente de devolução simbólica de mercadorias remetidas para formação de lote de exportação, de produtos industrializados ou produzidos pelo próprio estabelecimento", "1506": "Entrada decorrente de devolução simbólica de mercadorias, adquiridas ou recebidas de terceiros, remetidas para formação de lote de exportação", - "1550": "OPERAÇÕES COM BENS DE ATIVO IMOBILIZADO E MATERIAIS PARA USO OU CONSUMO", "1551": "Compra de bem para o ativo imobilizado", "1552": "Transferência de bem do ativo imobilizado", "1553": "Devolução de venda de bem do ativo imobilizado", @@ -188,7 +185,6 @@ export const CFOP_TABLE: Record = { "2125": "Industrialização efetuada por outra empresa quando a mercadoria remetida para utilização no processo de industrialização não transitou pelo estabelecimento adquirente da mercadoria", "2126": "Compra para utilização na prestação de serviço", - "2150": "TRANSFERÊNCIAS PARA INDUSTRIALIZAÇÃO, COMERCIALIZAÇÃO OU PRESTAÇÃO DE SERVIÇOS", "2151": "Transferência para industrialização ou produção rural", "2152": "Transferência para comercialização", "2153": "Transferência de energia elétrica para distribuição", @@ -204,7 +200,6 @@ export const CFOP_TABLE: Record = { "2207": "Anulação de valor relativo à venda de energia elétrica", "2208": "Devolução de produção do estabelecimento, remetida em transferência", "2209": "Devolução de mercadoria adquirida ou recebida de terceiros, remetida em transferência", - "2250": "COMPRAS DE ENERGIA ELÉTRICA", "2251": "Compra de energia elétrica para distribuição ou comercialização", "2252": "Compra de energia elétrica por estabelecimento industrial", "2253": "Compra de energia elétrica por estabelecimento comercial", @@ -257,7 +252,6 @@ export const CFOP_TABLE: Record = { "Entrada decorrente de devolução simbólica de mercadorias remetidas para formação de lote de exportação, de produtos industrializados ou produzidos pelo próprio estabelecimento", "2506": "Entrada decorrente de devolução simbólica de mercadorias, adquiridas ou recebidas de terceiros, remetidas para formação de lote de exportação", - "2550": "OPERAÇÕES COM BENS DE ATIVO IMOBILIZADO E MATERIAIS PARA USO OU CONSUMO", "2551": "Compra de bem para o ativo imobilizado", "2552": "Transferência de bem do ativo imobilizado", "2553": "Devolução de venda de bem do ativo imobilizado", @@ -324,10 +318,8 @@ export const CFOP_TABLE: Record = { "3206": "Anulação de valor relativo à prestação de serviço de transporte", "3207": "Anulação de valor relativo à venda de energia elétrica", "3211": 'Devolução de venda de produção do estabelecimento sob o regime de "drawback"', - "3250": "COMPRAS DE ENERGIA ELÉTRICA", "3251": "Compra de energia elétrica para distribuição ou comercialização", "3301": "Aquisição de serviço de comunicação para execução de serviço da mesma natureza", - "3350": "AQUISIÇÕES DE SERVIÇOS DE TRANSPORTE", "3351": "Aquisição de serviço de transporte para execução de serviço da mesma natureza", "3352": "Aquisição de serviço de transporte por estabelecimento industrial", "3353": "Aquisição de serviço de transporte por estabelecimento comercial", @@ -338,7 +330,6 @@ export const CFOP_TABLE: Record = { "3356": "Aquisição de serviço de transporte por estabelecimento de produtor rural", "3503": "Devolução de mercadoria exportada que tenha sido recebida com fim específico de exportação", - "3550": "OPERAÇÕES COM BENS DE ATIVO IMOBILIZADO E MATERIAIS PARA USO OU CONSUMO", "3551": "Compra de bem para o ativo imobilizado", "3553": "Devolução de venda de bem do ativo imobilizado", "3556": "Compra de material para uso ou consumo", @@ -383,7 +374,6 @@ export const CFOP_TABLE: Record = { "5124": "Industrialização efetuada para outra empresa", "5125": "Industrialização efetuada para outra empresa quando a mercadoria recebida para utilização no processo de industrialização não transitar pelo estabelecimento adquirente da mercadoria", - "5150": "TRANSFERÊNCIAS DE PRODUÇÃO PRÓPRIA OU DE TERCEIROS", "5151": "Transferência de produção do estabelecimento", "5152": "Transferência de mercadoria adquirida ou recebida de terceiros", "5153": "Transferência de energia elétrica", @@ -399,7 +389,6 @@ export const CFOP_TABLE: Record = { "Devolução de mercadoria recebida em transferência para industrialização ou produção rural", "5209": "Devolução de mercadoria recebida em transferência para comercialização", "5210": "Devolução de compra para utilização na prestação de serviço sujeitas ao ICMS ou ISSQN", - "5250": "VENDAS DE ENERGIA ELÉTRICA", "5251": "Venda de energia elétrica para distribuição ou comercialização", "5252": "Venda de energia elétrica para estabelecimento industrial", "5253": "Venda de energia elétrica para estabelecimento comercial", @@ -417,7 +406,6 @@ export const CFOP_TABLE: Record = { "Prestação de serviço de comunicação a estabelecimento de geradora ou de distribuidora de energia elétrica", "5306": "Prestação de serviço de comunicação a estabelecimento de produtor rural", "5307": "Prestação de serviço de comunicação a não contribuinte", - "5350": "PRESTAÇÕES DE SERVIÇOS DE TRANSPORTE", "5351": "Prestação de serviço de transporte para execução de serviço da mesma natureza", "5352": "Prestação de serviço de transporte a estabelecimento industrial", "5353": "Prestação de serviço de transporte a estabelecimento comercial", @@ -455,7 +443,6 @@ export const CFOP_TABLE: Record = { "Remessa de produção do estabelecimento para venda fora do estabelecimento em operação com produto sujeito ao regime de substituição tributária", "5415": "Remessa de mercadoria adquirida ou recebida de terceiros para venda fora do estabelecimento, em operação com mercadoria sujeita ao regime de substituição tributária", - "5450": "SISTEMAS DE INTEGRAÇÃO", "5451": "Remessa de animal e de insumo para estabelecimento produtor", "5501": "Remessa de produção do estabelecimento, com fim específico de exportação", "5502": @@ -465,7 +452,6 @@ export const CFOP_TABLE: Record = { "Remessa de mercadorias para formação de lote de exportação, de produtos industrializados ou produzidos pelo próprio estabelecimento", "5505": "Remessa de mercadorias, adquiridas ou recebidas de terceiros, para formação de lote de exportação", - "5550": "OPERAÇÕES COM BENS DE ATIVO IMOBILIZADO E MATERIAIS PARA USO OU CONSUMO", "5551": "Venda de bem do ativo imobilizado", "5552": "Transferência de bem do ativo imobilizado", "5553": "Devolução de compra de bem para o ativo imobilizado", @@ -586,7 +572,6 @@ export const CFOP_TABLE: Record = { "6124": "Industrialização efetuada para outra empresa", "6125": "Industrialização efetuada para outra empresa quando a mercadoria recebida para utilização no processo de industrialização não transitar pelo estabelecimento adquirente da mercadoria", - "6150": "TRANSFERÊNCIAS DE PRODUÇÃO PRÓPRIA OU DE TERCEIROS", "6151": "Transferência de produção do estabelecimento", "6152": "Transferência de mercadoria adquirida ou recebida de terceiros", "6153": "Transferência de energia elétrica", @@ -602,7 +587,6 @@ export const CFOP_TABLE: Record = { "Devolução de mercadoria recebida em transferência para industrialização ou produção rural", "6209": "Devolução de mercadoria recebida em transferência para comercialização", "6210": "Devolução de compra para utilização na prestação de serviço", - "6250": "VENDAS DE ENERGIA ELÉTRICA", "6251": "Venda de energia elétrica para distribuição ou comercialização", "6252": "Venda de energia elétrica para estabelecimento industrial", "6253": "Venda de energia elétrica para estabelecimento comercial", @@ -620,7 +604,6 @@ export const CFOP_TABLE: Record = { "Prestação de serviço de comunicação a estabelecimento de geradora ou de distribuidora de energia elétrica", "6306": "Prestação de serviço de comunicação a estabelecimento de produtor rural", "6307": "Prestação de serviço de comunicação a não contribuinte", - "6350": "PRESTAÇÕES DE SERVIÇOS DE TRANSPORTE", "6351": "Prestação de serviço de transporte para execução de serviço da mesma natureza", "6352": "Prestação de serviço de transporte a estabelecimento industrial", "6353": "Prestação de serviço de transporte a estabelecimento comercial", @@ -664,7 +647,6 @@ export const CFOP_TABLE: Record = { "Remessa de mercadorias para formação de lote de exportação, de produtos industrializados ou produzidos pelo próprio estabelecimento", "6505": "Remessa de mercadorias, adquiridas ou recebidas de terceiros, para formação de lote de exportação", - "6550": "OPERAÇÕES COM BENS DE ATIVO IMOBILIZADO E MATERIAIS PARA USO OU CONSUMO", "6551": "Venda de bem do ativo imobilizado", "6552": "Transferência de bem do ativo imobilizado", "6553": "Devolução de compra de bem para o ativo imobilizado", @@ -749,13 +731,10 @@ export const CFOP_TABLE: Record = { "7207": "Anulação de valor relativo à compra de energia elétrica", "7210": "Devolução de compra para utilização na prestação de serviço", "7211": 'Devolução de compras para industrialização sob o regime de drawback"', - "7250": "VENDAS DE ENERGIA ELÉTRICA", "7251": "Venda de energia elétrica para o exterior", "7301": "Prestação de serviço de comunicação para execução de serviço da mesma natureza", - "7350": "PRESTAÇÕES DE SERVIÇO DE TRANSPORTE", "7358": "Prestação de serviço de transporte", "7501": "Exportação de mercadorias recebidas com fim específico de exportação", - "7550": "OPERAÇÕES COM BENS DE ATIVO IMOBILIZADO E MATERIAIS PARA USO OU CONSUMO", "7551": "Venda de bem do ativo imobilizado", "7553": "Devolução de compra de bem para o ativo imobilizado", "7556": "Devolução de compra de material de uso ou consumo", diff --git a/src/get-cfop/get-cfop.test.ts b/src/get-cfop/get-cfop.test.ts index 297b4b43..12fe4859 100644 --- a/src/get-cfop/get-cfop.test.ts +++ b/src/get-cfop/get-cfop.test.ts @@ -61,6 +61,27 @@ describe("getCfop", () => { expect(getCfop("0000")).toBeNull(); }); + it("should return null for a group heading (a code ending in 00)", () => { + expect(getCfop("1100")).toBeNull(); + expect(getCfop("5300")).toBeNull(); + }); + + it("should return null for a subgroup heading (a code ending in 50)", () => { + expect(getCfop("1150")).toBeNull(); + expect(getCfop("5350")).toBeNull(); + }); + + it("should still resolve the operable codes a subgroup heading heads (1151 and 5351)", () => { + expect(getCfop("1151")).toEqual({ + code: "1151", + description: "Transferência para industrialização ou produção rural", + }); + expect(getCfop("5351")).toEqual({ + code: "5351", + description: "Prestação de serviço de transporte para execução de serviço da mesma natureza", + }); + }); + it("should return null for a code with a length different from 4", () => { expect(getCfop("510")).toBeNull(); }); diff --git a/src/get-cfop/get-cfop.ts b/src/get-cfop/get-cfop.ts index 008c88c4..ad0d7170 100644 --- a/src/get-cfop/get-cfop.ts +++ b/src/get-cfop/get-cfop.ts @@ -15,6 +15,10 @@ export type Cfop = { /** * Looks a CFOP (Código Fiscal de Operações e Prestações) code up in the official table. * + * Only operable codes are in the table: the group and subgroup headings of the official + * nomenclature, the codes ending in "00" and "50" (1000, 1100, 1150, 5350, ...), are section + * titles rather than codes a document can carry, so they give `null`. + * * @param {string|number} value - The CFOP code to look up. * @returns {Cfop|null} The matching CFOP entry, or null when the code is unknown or * invalid. @@ -23,6 +27,7 @@ export type Cfop = { * ```typescript * getCfop("5102"); // { code: "5102", description: "Venda de mercadoria adquirida ou recebida de terceiros" } * getCfop("0000"); // null + * getCfop("5350"); // null (a subgroup heading, not an operable code) * ``` * * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/2001/AJ_007_01 diff --git a/src/is-valid-cfop/is-valid-cfop.test.ts b/src/is-valid-cfop/is-valid-cfop.test.ts index 7591df70..9bf8228a 100644 --- a/src/is-valid-cfop/is-valid-cfop.test.ts +++ b/src/is-valid-cfop/is-valid-cfop.test.ts @@ -37,6 +37,21 @@ describe("isValidCfop", () => { expect(isValidCfop("0000")).toBe(false); }); + it("should return false for a group heading (a code ending in 00)", () => { + expect(isValidCfop("1100")).toBe(false); + expect(isValidCfop("5300")).toBe(false); + }); + + it("should return false for a subgroup heading (a code ending in 50)", () => { + expect(isValidCfop("1150")).toBe(false); + expect(isValidCfop("5350")).toBe(false); + }); + + it("should still accept the operable codes a subgroup heading heads (1151 and 5351)", () => { + expect(isValidCfop("1151")).toBe(true); + expect(isValidCfop("5351")).toBe(true); + }); + it("should return false for a code with a length different from 4", () => { expect(isValidCfop("510")).toBe(false); expect(isValidCfop("51020")).toBe(false); diff --git a/src/is-valid-cfop/is-valid-cfop.ts b/src/is-valid-cfop/is-valid-cfop.ts index 86fc6ca1..c1155962 100644 --- a/src/is-valid-cfop/is-valid-cfop.ts +++ b/src/is-valid-cfop/is-valid-cfop.ts @@ -6,6 +6,10 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d * Validates if a CFOP (Código Fiscal de Operações e Prestações) code exists in the * official table. * + * Only operable codes count: the group and subgroup headings of the official nomenclature, + * the codes ending in "00" and "50" (1000, 1100, 1150, 5350, ...), are section titles rather + * than codes a document can carry, so they are rejected. + * * @param {string|number} value - The CFOP code to be validated. * @returns {boolean} True when the code is a known 4 digit CFOP code, false otherwise. * @@ -14,6 +18,7 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d * isValidCfop("5102"); // true * isValidCfop(5102); // true * isValidCfop("0000"); // false + * isValidCfop("1150"); // false (a subgroup heading, not an operable code) * ``` * * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/2001/AJ_007_01 From dab1a9d620262b024160c0e001aab5919ce2a75a Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:43:22 -0300 Subject: [PATCH 09/14] fix(iban): accept only the printed IBAN characters before sanitizing --- src/_internals/constants/iban.ts | 7 +++++++ src/format-iban/format-iban.test.ts | 16 ++++++++++++++-- src/format-iban/format-iban.ts | 16 +++++++++++++--- src/is-valid-iban/is-valid-iban.test.ts | 14 ++++++++++++++ src/is-valid-iban/is-valid-iban.ts | 14 ++++++++++++-- src/parse-iban/parse-iban.test.ts | 5 +++++ src/parse-iban/parse-iban.ts | 4 +++- 7 files changed, 68 insertions(+), 8 deletions(-) diff --git a/src/_internals/constants/iban.ts b/src/_internals/constants/iban.ts index c8b736c9..53cb3056 100644 --- a/src/_internals/constants/iban.ts +++ b/src/_internals/constants/iban.ts @@ -13,3 +13,10 @@ export const BR_IBAN_LENGTH = 29; export const BR_IBAN_REGEX = /^BR\d{2}\d{8}\d{5}\d{10}[A-Z][A-Z0-9]$/; + +/** + * Shape an IBAN has to be written in: the ISO 13616 print format, letters and digits in + * groups separated by a single space. Any other character (a hyphen, a dot, a slash) makes + * the value something other than an IBAN, so it is rejected instead of stripped. + */ +export const IBAN_FORMAT_REGEX = /^[A-Za-z0-9]+(?: [A-Za-z0-9]+)*$/; diff --git a/src/format-iban/format-iban.test.ts b/src/format-iban/format-iban.test.ts index eda82079..c64d8fc6 100644 --- a/src/format-iban/format-iban.test.ts +++ b/src/format-iban/format-iban.test.ts @@ -26,12 +26,24 @@ describe("formatIban", () => { expect(formatIban("BR1500000000000010932840814P")).toBe("BR15 0000 0000 0000 1093 2840 814P"); }); - it("should remove non alphanumeric characters before grouping", () => { - expect(formatIban("BR15 0000-0000.0000/1093 2840 814P 2")).toBe( + it("should regroup a value that is already written in the print format", () => { + expect(formatIban("BR1500 000000000010 932840814P2")).toBe( "BR15 0000 0000 0000 1093 2840 814P 2", ); }); + it("should trim the surrounding whitespace", () => { + expect(formatIban(" BR15 0000 0000 0000 1093 2840 814P 2 ")).toBe( + "BR15 0000 0000 0000 1093 2840 814P 2", + ); + }); + + it("should return an empty string when a character outside the print format is present", () => { + expect(formatIban("BR1500000000000010932840814P-2")).toBe(""); + expect(formatIban("BR15 0000-0000.0000/1093 2840 814P 2")).toBe(""); + expect(formatIban("BR15 0000")).toBe(""); + }); + it("should cap the result to 29 characters", () => { expect(formatIban("BR1500000000000010932840814P2EXTRACHARS")).toBe( "BR15 0000 0000 0000 1093 2840 814P 2", diff --git a/src/format-iban/format-iban.ts b/src/format-iban/format-iban.ts index 5cb5ae88..ad6870d8 100644 --- a/src/format-iban/format-iban.ts +++ b/src/format-iban/format-iban.ts @@ -1,4 +1,4 @@ -import { BR_IBAN_LENGTH } from "../_internals/constants/iban"; +import { BR_IBAN_LENGTH, IBAN_FORMAT_REGEX } from "../_internals/constants/iban"; import { sanitizeToAlphanumeric } from "../_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric"; import { GROUP_SIZE } from "./constants"; @@ -10,9 +10,14 @@ import { GROUP_SIZE } from "./constants"; * the 29 character length of a Brazilian IBAN, as far as it goes, so the function can also be * used as an input mask. Use `isValidIban` to check validity. * + * The value still has to be written in the ISO 13616 print format: letters and digits in + * groups separated by a single space, with optional surrounding whitespace. Any other + * character makes the value something other than an IBAN, so it returns an empty string + * instead of quietly dropping the character and presenting the rest as an IBAN. + * * @param {string} value - The IBAN to be formatted. * @returns {string} The IBAN uppercased and grouped in blocks of 4 characters, or an empty - * string when `value` is not a string. + * string when `value` is not a string written in the print format. * * @example * ```typescript @@ -20,6 +25,7 @@ import { GROUP_SIZE } from "./constants"; * formatIban("br1500000000000010932840814p2"); // "BR15 0000 0000 0000 1093 2840 814P 2" * formatIban("BR15"); // "BR15" * formatIban("BR1500000000000010932840814P2EXTRA"); // "BR15 0000 0000 0000 1093 2840 814P 2" + * formatIban("BR1500000000000010932840814P-2"); // "" (hyphens are not part of an IBAN) * ``` * * @see Official: https://www.bcb.gov.br/pre/normativos/circ/2013/pdf/circ_3625_v1_O.pdf Circular BCB nº 3.625/2013 @@ -28,7 +34,11 @@ import { GROUP_SIZE } from "./constants"; export const formatIban = (value: string): string => { if (typeof value !== "string") return ""; - const sanitized = sanitizeToAlphanumeric(value).slice(0, BR_IBAN_LENGTH); + const printed = value.trim(); + + if (!IBAN_FORMAT_REGEX.test(printed)) return ""; + + const sanitized = sanitizeToAlphanumeric(printed).slice(0, BR_IBAN_LENGTH); let formatted = ""; diff --git a/src/is-valid-iban/is-valid-iban.test.ts b/src/is-valid-iban/is-valid-iban.test.ts index 76d4f776..652d2a83 100644 --- a/src/is-valid-iban/is-valid-iban.test.ts +++ b/src/is-valid-iban/is-valid-iban.test.ts @@ -22,6 +22,10 @@ describe("isValidIban", () => { expect(isValidIban("br1500000000000010932840814p2")).toBe(true); }); + test("for a value with surrounding whitespace", () => { + expect(isValidIban(" BR15 0000 0000 0000 1093 2840 814P 2 ")).toBe(true); + }); + test("for a valid IBAN with a poupança (P) account type", () => { expect(isValidIban("BR1460746948000020001234567P2")).toBe(true); }); @@ -73,6 +77,16 @@ describe("isValidIban", () => { expect(isValidIban("BR170000000A000010000012345C2")).toBe(false); }); + test("when it carries a character outside the print format", () => { + expect(isValidIban("BR1500000000000010932840814P-2")).toBe(false); + expect(isValidIban("BR15.0000.0000.0000.1093.2840.814P2")).toBe(false); + expect(isValidIban("BR1500000000000010932840814P/2")).toBe(false); + }); + + test("when the groups are separated by more than one space", () => { + expect(isValidIban("BR15 0000 0000 0000 1093 2840 814P 2")).toBe(false); + }); + test("when it is an empty string", () => { expect(isValidIban("")).toBe(false); }); diff --git a/src/is-valid-iban/is-valid-iban.ts b/src/is-valid-iban/is-valid-iban.ts index 41fdf247..a3dfe940 100644 --- a/src/is-valid-iban/is-valid-iban.ts +++ b/src/is-valid-iban/is-valid-iban.ts @@ -1,4 +1,4 @@ -import { BR_IBAN_REGEX } from "../_internals/constants/iban"; +import { BR_IBAN_REGEX, IBAN_FORMAT_REGEX } from "../_internals/constants/iban"; import { sanitizeToAlphanumeric } from "../_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric"; const LETTER_CODE_A = 65; @@ -24,6 +24,11 @@ const hasValidCheckDigits = (iban: string): boolean => { * ISO 13616 countries is out of scope, so any non `BR` IBAN, however well formed, returns * `false`. Accepts the usual grouping spaces and is case-insensitive. * + * The value has to be written in the ISO 13616 print format: letters and digits in groups + * separated by a single space, with optional surrounding whitespace. Any other character + * makes the value something other than an IBAN, so `"BR1500000000000010932840814P-2"` is + * rejected instead of having its hyphen stripped. + * * @param {string} value - The IBAN to be validated. * @returns {boolean} True when `value` is a structurally valid Brazilian IBAN whose ISO 7064 * MOD 97-10 check digits match. @@ -34,6 +39,7 @@ const hasValidCheckDigits = (iban: string): boolean => { * isValidIban("BR15 0000 0000 0000 1093 2840 814P 2"); // true (grouping spaces) * isValidIban("br1500000000000010932840814p2"); // true (case-insensitive) * isValidIban("BR1500000000000010932840814P3"); // false (bad check digits) + * isValidIban("BR1500000000000010932840814P-2"); // false (hyphens are not part of an IBAN) * isValidIban("DE89370400440532013000"); // false (non Brazilian IBAN) * ``` * @@ -46,7 +52,11 @@ const hasValidCheckDigits = (iban: string): boolean => { export const isValidIban = (value: string): boolean => { if (typeof value !== "string") return false; - const sanitized = sanitizeToAlphanumeric(value); + const printed = value.trim(); + + if (!IBAN_FORMAT_REGEX.test(printed)) return false; + + const sanitized = sanitizeToAlphanumeric(printed); if (!BR_IBAN_REGEX.test(sanitized)) return false; diff --git a/src/parse-iban/parse-iban.test.ts b/src/parse-iban/parse-iban.test.ts index 6963c9e2..40f0e590 100644 --- a/src/parse-iban/parse-iban.test.ts +++ b/src/parse-iban/parse-iban.test.ts @@ -115,6 +115,11 @@ describe("parseIban", () => { expect(parseIban("BR1500000000000010932840814X2")).toBeNull(); }); + test("when it carries a character outside the print format", () => { + expect(parseIban("BR1500000000000010932840814P-2")).toBeNull(); + expect(parseIban("BR15.0000.0000.0000.1093.2840.814P2")).toBeNull(); + }); + test("when it is an empty string", () => { expect(parseIban("")).toBeNull(); }); diff --git a/src/parse-iban/parse-iban.ts b/src/parse-iban/parse-iban.ts index 60b5dd5a..ff596660 100644 --- a/src/parse-iban/parse-iban.ts +++ b/src/parse-iban/parse-iban.ts @@ -47,7 +47,8 @@ const ACCOUNT_TYPE_END = ACCOUNT_END + ACCOUNT_TYPE_LENGTH; * scope, so a well-formed non `BR` IBAN also returns `null`. * * Accepts the same input forms as `isValidIban` (grouping spaces, lowercase) and returns `null` - * whenever `isValidIban` would return `false`. + * whenever `isValidIban` would return `false`, including a value carrying any character other + * than letters, digits and the grouping spaces of the ISO 13616 print format. * * @param {string} value - The IBAN to be parsed. * @returns {Iban|null} The parsed IBAN, or `null` when it is not a valid Brazilian IBAN. @@ -68,6 +69,7 @@ const ACCOUNT_TYPE_END = ACCOUNT_END + ACCOUNT_TYPE_LENGTH; * parseIban("BR15 0000 0000 0000 1093 2840 814P 2"); // same result (grouping spaces) * parseIban("DE89370400440532013000"); // null (non Brazilian IBAN) * parseIban("BR1500000000000010932840814P3"); // null (bad check digits) + * parseIban("BR1500000000000010932840814P-2"); // null (hyphens are not part of an IBAN) * ``` * * @see Official: https://www.bcb.gov.br/pre/normativos/circ/2013/pdf/circ_3625_v1_O.pdf Circular BCB nº 3.625/2013 From ff2e3c9a444a3f855630e3a3e52d58613444df24 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:43:22 -0300 Subject: [PATCH 10/14] fix(cnpj): fall back to the numeric format in generateCnpj for any version other than 2 --- src/generate-cnpj/generate-cnpj.test.ts | 18 ++++++++++++++++++ src/generate-cnpj/generate-cnpj.ts | 6 ++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/generate-cnpj/generate-cnpj.test.ts b/src/generate-cnpj/generate-cnpj.test.ts index ee35e47a..c82a5782 100644 --- a/src/generate-cnpj/generate-cnpj.test.ts +++ b/src/generate-cnpj/generate-cnpj.test.ts @@ -44,6 +44,24 @@ describe("generateCnpj", () => { expect(isValidCnpj(cnpj)).toBe(true); }); + test("should generate a valid numeric CNPJ when the version is null", () => { + // @ts-expect-error: intentionally invalid input + const cnpj = generateCnpj(null); + + expect(cnpj).toHaveLength(CNPJ_LENGTH); + expect(/^\d+$/.test(cnpj)).toBe(true); + expect(isValidCnpj(cnpj)).toBe(true); + }); + + test("should generate a valid numeric CNPJ when the version is not a known version", () => { + // @ts-expect-error: intentionally invalid input + const cnpj = generateCnpj("2"); + + expect(cnpj).toHaveLength(CNPJ_LENGTH); + expect(/^\d+$/.test(cnpj)).toBe(true); + expect(isValidCnpj(cnpj)).toBe(true); + }); + test("should regenerate the base when it comes out with repeated digits", () => { const digits = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2]; const originalRandom = Math.random; diff --git a/src/generate-cnpj/generate-cnpj.ts b/src/generate-cnpj/generate-cnpj.ts index 3bac42f5..a8cf2d75 100644 --- a/src/generate-cnpj/generate-cnpj.ts +++ b/src/generate-cnpj/generate-cnpj.ts @@ -63,7 +63,9 @@ const generateAlphanumericCnpj = (): string => { * * Uses `Math.random()` internally, so it is not cryptographically secure, do not use for security purposes. * - * @param {1 | 2} version - The version of the CNPJ to be generated. + * @param {1 | 2} version - The version of the CNPJ to be generated: `1` for the numeric CNPJ and + * `2` for the alphanumeric one. Defaults to `1`, and never throws: `null`, `undefined` and any + * other runtime value that is not `2` also generate a version 1 (numeric) CNPJ. * @returns {string} A valid 14-digit CNPJ string without formatting. * * @example @@ -77,4 +79,4 @@ const generateAlphanumericCnpj = (): string => { * @see Official: https://www.gov.br/receitafederal/pt-br/acesso-a-informacao/acoes-e-programas/programas-e-atividades/cnpj-alfanumerico */ export const generateCnpj = (version: 1 | 2 = 1): string => - version === 1 ? generateNumericCnpj() : generateAlphanumericCnpj(); + version === 2 ? generateAlphanumericCnpj() : generateNumericCnpj(); From d8326f0dc4b6ab7388f5aceffd4d630a88c9bc3f Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:43:23 -0300 Subject: [PATCH 11/14] feat(states): tie the name, region and IBGE code of State to its code --- scripts/states.ts | 48 +++-- src/_internals/constants/states.ts | 275 +++++++++++++++++++++-------- src/get-states/get-states.test.ts | 22 ++- 3 files changed, 249 insertions(+), 96 deletions(-) diff --git a/scripts/states.ts b/scripts/states.ts index 0a94516d..16854b85 100644 --- a/scripts/states.ts +++ b/scripts/states.ts @@ -35,8 +35,18 @@ const isState = (value: unknown): value is State => "nome" in value.regiao && typeof value.regiao.nome === "string"; -const union = (values: string[]): string => - values.map((value) => `| ${JSON.stringify(value)}`).join(" "); +type GeneratedState = { + code: string; + name: string; + regionCode: string; + regionName: string; + ibgeCode: number; +}; + +const member = (state: GeneratedState): string => + `| { readonly code: ${JSON.stringify(state.code)}; readonly name: ${JSON.stringify(state.name)}; readonly regionCode: ${JSON.stringify(state.regionCode)}; readonly regionName: ${JSON.stringify(state.regionName)}; readonly ibgeCode: ${state.ibgeCode} }`; + +const union = (states: GeneratedState[]): string => states.map((state) => member(state)).join(" "); const main = async (): Promise => { const response = await fetchWithRetry( @@ -67,25 +77,25 @@ const main = async (): Promise => { await writeFile( resolve(scriptsDir, "..", "./src/_internals/constants/states.ts"), - `/** The two letter code of each Brazilian state, as published by the IBGE. */ -export type StateCode = ${union(states.map((state) => state.code))}; + `/** + * One Brazilian state, as returned by \`getStates\`, \`getStateByIbgeCode\` and the other state + * utils. Every state is its own member of the union, so the fields of a state are tied to each + * other: \`Extract["name"]\` is \`"São Paulo"\`, and narrowing a \`State\` by + * \`code\` narrows its \`name\`, \`regionCode\`, \`regionName\` and \`ibgeCode\` too. An impossible + * combination such as \`{ code: "SP", name: "Acre" }\` is not a \`State\`. + * + * Each member has the two letter code of the state (\`code\`, e.g. \`"SP"\`), its full name + * (\`name\`, e.g. \`"São Paulo"\`), the code and the full name of the region it belongs to + * (\`regionCode\` and \`regionName\`, e.g. \`"SE"\` and \`"Sudeste"\`) and the 2 digit IBGE code of the + * Federative Unit (\`ibgeCode\`, the "cUF", e.g. \`35\`). + */ +export type State = ${union(states)}; + +/** The two letter code of each Brazilian state, as published by the IBGE. */ +export type StateCode = State["code"]; /** The name of each Brazilian state, as published by the IBGE. */ -export type StateName = ${union(states.map((state) => state.name))}; - -/** One Brazilian state, as returned by \`getStates\`, \`getStateByIbgeCode\` and the other state utils. */ -export type State = { - /** The two letter code of the state, e.g. \`"SP"\`. */ - readonly code: StateCode; - /** The full name of the state, e.g. \`"São Paulo"\`. */ - readonly name: StateName; - /** The code of the region the state belongs to, e.g. \`"SE"\`. */ - readonly regionCode: "N" | "NE" | "CO" | "SE" | "S"; - /** The full name of the region the state belongs to, e.g. \`"Sudeste"\`. */ - readonly regionName: "Norte" | "Nordeste" | "Centro-Oeste" | "Sudeste" | "Sul"; - /** The 2 digit IBGE code of the Federative Unit ("cUF"), e.g. \`35\`. */ - readonly ibgeCode: number; -}; +export type StateName = State["name"]; /** * Brazilian states published by the IBGE, sorted by name with \`localeCompare\` in the "pt-BR" diff --git a/src/_internals/constants/states.ts b/src/_internals/constants/states.ts index ce2f5f11..b7275ddb 100644 --- a/src/_internals/constants/states.ts +++ b/src/_internals/constants/states.ts @@ -1,76 +1,211 @@ +/** + * One Brazilian state, as returned by `getStates`, `getStateByIbgeCode` and the other state + * utils. Every state is its own member of the union, so the fields of a state are tied to each + * other: `Extract["name"]` is `"São Paulo"`, and narrowing a `State` by + * `code` narrows its `name`, `regionCode`, `regionName` and `ibgeCode` too. An impossible + * combination such as `{ code: "SP", name: "Acre" }` is not a `State`. + * + * Each member has the two letter code of the state (`code`, e.g. `"SP"`), its full name + * (`name`, e.g. `"São Paulo"`), the code and the full name of the region it belongs to + * (`regionCode` and `regionName`, e.g. `"SE"` and `"Sudeste"`) and the 2 digit IBGE code of the + * Federative Unit (`ibgeCode`, the "cUF", e.g. `35`). + */ +export type State = + | { + readonly code: "AC"; + readonly name: "Acre"; + readonly regionCode: "N"; + readonly regionName: "Norte"; + readonly ibgeCode: 12; + } + | { + readonly code: "AL"; + readonly name: "Alagoas"; + readonly regionCode: "NE"; + readonly regionName: "Nordeste"; + readonly ibgeCode: 27; + } + | { + readonly code: "AP"; + readonly name: "Amapá"; + readonly regionCode: "N"; + readonly regionName: "Norte"; + readonly ibgeCode: 16; + } + | { + readonly code: "AM"; + readonly name: "Amazonas"; + readonly regionCode: "N"; + readonly regionName: "Norte"; + readonly ibgeCode: 13; + } + | { + readonly code: "BA"; + readonly name: "Bahia"; + readonly regionCode: "NE"; + readonly regionName: "Nordeste"; + readonly ibgeCode: 29; + } + | { + readonly code: "CE"; + readonly name: "Ceará"; + readonly regionCode: "NE"; + readonly regionName: "Nordeste"; + readonly ibgeCode: 23; + } + | { + readonly code: "DF"; + readonly name: "Distrito Federal"; + readonly regionCode: "CO"; + readonly regionName: "Centro-Oeste"; + readonly ibgeCode: 53; + } + | { + readonly code: "ES"; + readonly name: "Espírito Santo"; + readonly regionCode: "SE"; + readonly regionName: "Sudeste"; + readonly ibgeCode: 32; + } + | { + readonly code: "GO"; + readonly name: "Goiás"; + readonly regionCode: "CO"; + readonly regionName: "Centro-Oeste"; + readonly ibgeCode: 52; + } + | { + readonly code: "MA"; + readonly name: "Maranhão"; + readonly regionCode: "NE"; + readonly regionName: "Nordeste"; + readonly ibgeCode: 21; + } + | { + readonly code: "MT"; + readonly name: "Mato Grosso"; + readonly regionCode: "CO"; + readonly regionName: "Centro-Oeste"; + readonly ibgeCode: 51; + } + | { + readonly code: "MS"; + readonly name: "Mato Grosso do Sul"; + readonly regionCode: "CO"; + readonly regionName: "Centro-Oeste"; + readonly ibgeCode: 50; + } + | { + readonly code: "MG"; + readonly name: "Minas Gerais"; + readonly regionCode: "SE"; + readonly regionName: "Sudeste"; + readonly ibgeCode: 31; + } + | { + readonly code: "PA"; + readonly name: "Pará"; + readonly regionCode: "N"; + readonly regionName: "Norte"; + readonly ibgeCode: 15; + } + | { + readonly code: "PB"; + readonly name: "Paraíba"; + readonly regionCode: "NE"; + readonly regionName: "Nordeste"; + readonly ibgeCode: 25; + } + | { + readonly code: "PR"; + readonly name: "Paraná"; + readonly regionCode: "S"; + readonly regionName: "Sul"; + readonly ibgeCode: 41; + } + | { + readonly code: "PE"; + readonly name: "Pernambuco"; + readonly regionCode: "NE"; + readonly regionName: "Nordeste"; + readonly ibgeCode: 26; + } + | { + readonly code: "PI"; + readonly name: "Piauí"; + readonly regionCode: "NE"; + readonly regionName: "Nordeste"; + readonly ibgeCode: 22; + } + | { + readonly code: "RJ"; + readonly name: "Rio de Janeiro"; + readonly regionCode: "SE"; + readonly regionName: "Sudeste"; + readonly ibgeCode: 33; + } + | { + readonly code: "RN"; + readonly name: "Rio Grande do Norte"; + readonly regionCode: "NE"; + readonly regionName: "Nordeste"; + readonly ibgeCode: 24; + } + | { + readonly code: "RS"; + readonly name: "Rio Grande do Sul"; + readonly regionCode: "S"; + readonly regionName: "Sul"; + readonly ibgeCode: 43; + } + | { + readonly code: "RO"; + readonly name: "Rondônia"; + readonly regionCode: "N"; + readonly regionName: "Norte"; + readonly ibgeCode: 11; + } + | { + readonly code: "RR"; + readonly name: "Roraima"; + readonly regionCode: "N"; + readonly regionName: "Norte"; + readonly ibgeCode: 14; + } + | { + readonly code: "SC"; + readonly name: "Santa Catarina"; + readonly regionCode: "S"; + readonly regionName: "Sul"; + readonly ibgeCode: 42; + } + | { + readonly code: "SP"; + readonly name: "São Paulo"; + readonly regionCode: "SE"; + readonly regionName: "Sudeste"; + readonly ibgeCode: 35; + } + | { + readonly code: "SE"; + readonly name: "Sergipe"; + readonly regionCode: "NE"; + readonly regionName: "Nordeste"; + readonly ibgeCode: 28; + } + | { + readonly code: "TO"; + readonly name: "Tocantins"; + readonly regionCode: "N"; + readonly regionName: "Norte"; + readonly ibgeCode: 17; + }; + /** The two letter code of each Brazilian state, as published by the IBGE. */ -export type StateCode = - | "AC" - | "AL" - | "AP" - | "AM" - | "BA" - | "CE" - | "DF" - | "ES" - | "GO" - | "MA" - | "MT" - | "MS" - | "MG" - | "PA" - | "PB" - | "PR" - | "PE" - | "PI" - | "RJ" - | "RN" - | "RS" - | "RO" - | "RR" - | "SC" - | "SP" - | "SE" - | "TO"; +export type StateCode = State["code"]; /** The name of each Brazilian state, as published by the IBGE. */ -export type StateName = - | "Acre" - | "Alagoas" - | "Amapá" - | "Amazonas" - | "Bahia" - | "Ceará" - | "Distrito Federal" - | "Espírito Santo" - | "Goiás" - | "Maranhão" - | "Mato Grosso" - | "Mato Grosso do Sul" - | "Minas Gerais" - | "Pará" - | "Paraíba" - | "Paraná" - | "Pernambuco" - | "Piauí" - | "Rio de Janeiro" - | "Rio Grande do Norte" - | "Rio Grande do Sul" - | "Rondônia" - | "Roraima" - | "Santa Catarina" - | "São Paulo" - | "Sergipe" - | "Tocantins"; - -/** One Brazilian state, as returned by `getStates`, `getStateByIbgeCode` and the other state utils. */ -export type State = { - /** The two letter code of the state, e.g. `"SP"`. */ - readonly code: StateCode; - /** The full name of the state, e.g. `"São Paulo"`. */ - readonly name: StateName; - /** The code of the region the state belongs to, e.g. `"SE"`. */ - readonly regionCode: "N" | "NE" | "CO" | "SE" | "S"; - /** The full name of the region the state belongs to, e.g. `"Sudeste"`. */ - readonly regionName: "Norte" | "Nordeste" | "Centro-Oeste" | "Sudeste" | "Sul"; - /** The 2 digit IBGE code of the Federative Unit ("cUF"), e.g. `35`. */ - readonly ibgeCode: number; -}; +export type StateName = State["name"]; /** * Brazilian states published by the IBGE, sorted by name with `localeCompare` in the "pt-BR" diff --git a/src/get-states/get-states.test.ts b/src/get-states/get-states.test.ts index fb84c79c..958e4279 100644 --- a/src/get-states/get-states.test.ts +++ b/src/get-states/get-states.test.ts @@ -96,12 +96,20 @@ describe("getStates types", () => { test("should take no arguments and return an array of State", () => { expectTypeOf(getStates).parameter(0).toBeUndefined(); expectTypeOf(getStates).returns.toEqualTypeOf(); - expectTypeOf().toEqualTypeOf<{ - readonly code: StateCode; - readonly name: StateName; - readonly regionCode: "N" | "NE" | "CO" | "SE" | "S"; - readonly regionName: "Norte" | "Nordeste" | "Centro-Oeste" | "Sudeste" | "Sul"; - readonly ibgeCode: number; - }>(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<"N" | "NE" | "CO" | "SE" | "S">(); + expectTypeOf().toEqualTypeOf< + "Norte" | "Nordeste" | "Centro-Oeste" | "Sudeste" | "Sul" + >(); + }); + + test("should tie every field of a state to its code, so narrowing by code narrows the rest", () => { + expectTypeOf["name"]>().toEqualTypeOf<"São Paulo">(); + expectTypeOf["regionCode"]>().toEqualTypeOf<"SE">(); + expectTypeOf["regionName"]>().toEqualTypeOf<"Sudeste">(); + expectTypeOf["ibgeCode"]>().toEqualTypeOf<35>(); + expectTypeOf["name"]>().toEqualTypeOf<"Acre">(); + expectTypeOf>().toEqualTypeOf(); }); }); From 558d81453a233fa95be6b59bce9222a7f82d7a3b Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:43:23 -0300 Subject: [PATCH 12/14] ci(tree-shaking): sort new and removed exports into the what-changed table by size --- scripts/tree-shaking.ts | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/scripts/tree-shaking.ts b/scripts/tree-shaking.ts index 623add7d..cb8e2a58 100644 --- a/scripts/tree-shaking.ts +++ b/scripts/tree-shaking.ts @@ -491,6 +491,25 @@ const renderMarkdown = ( const counts = describeCounts(result); const scope = counts === "" ? `${measured} exports measured` : `${counts} out of ${measured} exports`; + const changedRows = [ + ...result.changed.map((row) => ({ + name: row.name, + weight: Math.abs(row.deltaBytes), + line: renderExportRow(changeMarker(row, result), row.name, row.base, row.head), + })), + ...result.added.map((item) => ({ + name: item.name, + weight: item.bytes, + line: renderExportRow("🆕", item.name, null, item), + })), + ...result.removed.map((item) => ({ + name: item.name, + weight: item.bytes, + line: renderExportRow("🗑️", item.name, item, null), + })), + ] + .sort((a, b) => b.weight - a.weight || a.name.localeCompare(b.name)) + .map((item) => item.line); lines.push( `${status} ${scope}.`, "", @@ -500,13 +519,7 @@ const renderMarkdown = ( `| Full import | ${formatBytes(base.full.bytes)} | ${formatBytes(head.full.bytes)} (gzip ${formatBytes(head.full.gzip)}) | ${formatDelta(head.full.bytes - base.full.bytes, base.full.bytes === 0 ? 0 : (head.full.bytes - base.full.bytes) / base.full.bytes)} |`, `| Exports | ${Object.keys(base.exports).length} | ${measured} | ${formatCount(measured - Object.keys(base.exports).length)} |`, "", - ...renderRows("What changed", EXPORT_COLUMNS, [ - ...result.changed.map((row) => - renderExportRow(changeMarker(row, result), row.name, row.base, row.head), - ), - ...result.added.map((item) => renderExportRow("🆕", item.name, null, item)), - ...result.removed.map((item) => renderExportRow("🗑️", item.name, item, null)), - ]), + ...renderRows("What changed", EXPORT_COLUMNS, changedRows), ); } From d0dba0ac5de997175af599d59b9fa8edab692113 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:43:23 -0300 Subject: [PATCH 13/14] docs: refine the utilities pages and use npm run for the package scripts --- CONTRIBUTING.md | 48 ++++++++++---------- docs/llms-full.txt | 78 +++++++++++++++++++++----------- docs/llms.txt | 6 +-- docs/pt-br/utilities.md | 78 +++++++++++++++++++++----------- docs/utilities.md | 78 +++++++++++++++++++++----------- src/get-holidays/get-holidays.ts | 2 +- 6 files changed, 181 insertions(+), 109 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4f78283b..d45f1763 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,26 +28,26 @@ and is invoked through the `npm` scripts below, so you don't need to install any ### Useful scripts -| Command | What it does | -| --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `npm check` | Runs `vp check`: format check, lint and type-check together. Run this before opening a PR. | -| `npm check:fix` | Same as above, but auto-fixes what it can. | -| `npm format` / `npm format:check` | Formats the codebase / checks formatting with `vp fmt`. | -| `npm lint` / `npm lint:fix` | Lints the codebase with `vp lint`. | -| `npm test` | Runs the unit test suite with `vp test`. | -| `npm test:coverage` | Runs tests with coverage (`vp test run --coverage`). | -| `npm test:bun` | Runs the test suite on [Bun](https://bun.sh) (`bun test src`). | -| `npm test:deno` | Runs the test suite on [Deno](https://deno.com) (`deno test`). | -| `npm test:chrome-browser`, `npm test:firefox-browser`, `npm test:edge-browser`, `npm test:safari-browser` | Runs the test suite in real browsers via `vp test --browser.enabled`. | -| `npm build` | Builds the library with `vp build`. | -| `npm run check:duplication` | Runs [jscpd](https://jscpd.dev) over `src` and `scripts`; any copy-pasted block of 5+ lines / 50+ tokens fails. | -| `npm run check:unused` | Runs [knip](https://knip.dev): unused files, exports, types and dependencies fail. | -| `npm run test:mutation` | Runs [Stryker](https://stryker-mutator.io) mutation tests (`stryker run`); pass `-- --mutate src//.ts` for one file. | -| `npm run check:api` | Builds the package and runs API Extractor over `dist/brazilian-utils.d.ts`: a public type without a doc comment, or a type the API refers to without exporting, fails. | -| `npm run check:commits` | Checks the commit messages since `origin/main` with commitlint (Conventional Commits). | -| `npm run check:lockfile` | Checks `package-lock.json` only resolves to the npm registry over HTTPS with integrity hashes (lockfile-lint). | - -Before opening a pull request, make sure `npm check` and `npm test` both pass locally. If your +| Command | What it does | +| ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `npm run check` | Runs `vp check`: format check, lint and type-check together. Run this before opening a PR. | +| `npm run check:fix` | Same as above, but auto-fixes what it can. | +| `npm run format` / `npm run format:check` | Formats the codebase / checks formatting with `vp fmt`. | +| `npm run lint` / `npm run lint:fix` | Lints the codebase with `vp lint`. | +| `npm run test` | Runs the unit test suite with `vp test`. | +| `npm run test:coverage` | Runs tests with coverage (`vp test run --coverage`). | +| `npm run test:bun` | Runs the test suite on [Bun](https://bun.sh) (`bun test src`). | +| `npm run test:deno` | Runs the test suite on [Deno](https://deno.com) (`deno test`). | +| `npm run test:chrome-browser`, `npm run test:firefox-browser`, `npm run test:edge-browser`, `npm run test:safari-browser` | Runs the test suite in real browsers via `vp test --browser.enabled`. | +| `npm run build` | Builds the library with `vp build`. | +| `npm run check:duplication` | Runs [jscpd](https://jscpd.dev) over `src` and `scripts`; any copy-pasted block of 5+ lines / 50+ tokens fails. | +| `npm run check:unused` | Runs [knip](https://knip.dev): unused files, exports, types and dependencies fail. | +| `npm run test:mutation` | Runs [Stryker](https://stryker-mutator.io) mutation tests (`stryker run`); pass `-- --mutate src//.ts` for one file. | +| `npm run check:api` | Builds the package and runs API Extractor over `dist/brazilian-utils.d.ts`: a public type without a doc comment, or a type the API refers to without exporting, fails. | +| `npm run check:commits` | Checks the commit messages since `origin/main` with commitlint (Conventional Commits). | +| `npm run check:lockfile` | Checks `package-lock.json` only resolves to the npm registry over HTTPS with integrity hashes (lockfile-lint). | + +Before opening a pull request, make sure `npm run check` and `npm run test` both pass locally. If your change touches runtime behavior, also consider running the Bun/Deno scripts above. The library is tested and must keep working on Node.js, Bun, Deno and in browsers. @@ -76,7 +76,7 @@ example `formatSomething`): or `src/is-valid-service-phone/is-valid-service-phone.ts` for examples). 3. Add tests alongside it in `src/format-something/format-something.test.ts`. Cover valid input, invalid/edge-case input, and options, if any. Tests must pass on Node, Bun and Deno (see - `npm test:bun` / `npm test:deno` under Useful scripts). Expectations are hand-written literals, + `npm run test:bun` / `npm run test:deno` under Useful scripts). Expectations are hand-written literals, never values computed by the code under test. Close the file with a `describe("properties")` block of [fast-check](https://fast-check.dev) properties that hold by specification (a generated value is valid, format/parse round-trip, masks never change the verdict, arbitrary @@ -138,7 +138,7 @@ or `node scripts/tree-shaking.ts --json before.json` before a change and ## Lint and type strictness -`npm check` runs oxlint through Vite+ with the `correctness`, `suspicious`, `perf` and `pedantic` +`npm run check` runs oxlint through Vite+ with the `correctness`, `suspicious`, `perf` and `pedantic` categories as errors, the `import`, `jsdoc` and `promise` plugins, and a curated set of `restriction`/`style` rules on top (see `lint.rules` in `vite.config.ts`): explicit return types on every function, no `console` outside `scripts/`, no `forEach`, no parameter reassignment, no @@ -224,7 +224,7 @@ signatures are pinned by the `describe(" types")` blocks in the tests, and - The `Security` workflow lints the workflows themselves with [actionlint](https://github.com/rhysd/actionlint) and [zizmor](https://github.com/zizmorcore/zizmor) and scans `package-lock.json` with [OSV-Scanner](https://google.github.io/osv-scanner/); the - `Check` workflow runs `audit-ci` and lockfile-lint on top. The same workflow runs the + `Check` workflow runs `audit-ci` and lockfile-lint on top. The `Security` workflow also runs the [OpenSSF Scorecard](https://scorecard.dev/viewer/?uri=github.com/brazilian-utils/javascript) on every push to `main` and weekly: it grades the repository configuration (pinned actions, token permissions, branch protection, code review, dependency updates, SAST) rather than the code, @@ -315,7 +315,7 @@ No local `npm login`/`npm publish` or tagging is ever needed to cut a release. 3. Add or update tests. PRs without tests for new behavior will not be merged. 4. Update `docs/utilities.md` and `docs/pt-br/utilities.md` if you added or changed a utility's public behavior. -5. Run `npm check`, `npm test`, `npm run check:duplication` and `npm run check:unused` and make +5. Run `npm run check`, `npm run test`, `npm run check:duplication` and `npm run check:unused` and make sure all of them pass; run `npm run test:mutation -- --mutate ` when you changed production code. 6. Open a pull request against `main` using a Conventional Commit-style title. Fill in the pull diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 623ded59..f4e491e2 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -430,7 +430,7 @@ isValidPixKey('not a key'); // false ### parsePixKey -Identifies a Pix key and normalizes it to the canonical form the DICT expects inside a BR Code: 11 digit CPF, 14 character CNPJ, lowercased e-mail, E.164 mobile phone (a landline is not a Pix key) or lowercase UUID EVP. An 11 digit value that is valid both as a CPF and as a mobile phone is read as a CPF, unless it was written as a phone number (a `+55`/`0055` prefix or a DDD wrapped in parentheses). Returns `null` when the value is not a valid Pix key. The result is typed as `PixKey`. +Identifies a Pix key and normalizes it to the canonical form the DICT expects inside a BR Code: 11 digit CPF, 14 character CNPJ, lowercased e-mail, E.164 mobile phone (a landline is not a Pix key) or lowercase UUID EVP. An 11 digit value that is valid both as a CPF and as a mobile phone is read as a CPF, unless it was written as a phone number (a `+55`/`0055` prefix or a DDD wrapped in parentheses). The CPF and the phone number are recognized by the way they are written, not only by the digits they carry, so surrounding text is not stripped away and `'abc123.456.789-09'` is not a CPF key. Returns `null` when the value is not a valid Pix key. The result is typed as `PixKey`. ```javascript import { parsePixKey } from '@brazilian-utils/brazilian-utils'; @@ -447,7 +447,7 @@ parsePixKey('+5551998259765'); // { type: 'phone', value: '+5551998259765' } ### isValidPixPayload -Check if a Pix BR Code payload (the string behind a Pix QR Code and behind "Pix copia e cola") is valid: well-formed TLV structure, the mandatory objects present, one of the "Merchant Account Information" templates carrying the `br.gov.bcb.pix` GUI with a key or a URL, and a matching CRC-16. The key itself is not checked against the DICT formats, use `isValidPixKey` for that. Payloads that carry the location in an Unreserved Template (IDs 80 to 99), as the "QR Code composto" of Pix Automático (Pix recorrente) does, are out of scope and reported as invalid. +Check if a Pix BR Code payload (the string behind a Pix QR Code and behind "Pix copia e cola") is valid: well-formed TLV structure, the mandatory objects present, one of the "Merchant Account Information" templates carrying the `br.gov.bcb.pix` GUI with a key or a URL, a "Point of Initiation Method" object (`01`) that agrees with it (a key requires a static payload, so `01` is absent or `"11"`; a URL requires a dynamic one, so `01` is `"12"`), an amount (`54`) greater than zero in a static payload, and a matching CRC-16. The key itself is not checked against the DICT formats, use `isValidPixKey` for that. Payloads that carry the location in an Unreserved Template (IDs 80 to 99), as the "QR Code composto" of Pix Automático (Pix recorrente) does, are out of scope and reported as invalid. ```javascript import { isValidPixPayload } from '@brazilian-utils/brazilian-utils'; @@ -462,7 +462,7 @@ isValidPixPayload('00020126580014br.gov.bcb.pix...'); // false (broken CRC) ### parsePixPayload -Parses a Pix BR Code payload into its fields. The payload is validated by `isValidPixPayload` first, so a malformed structure, a broken CRC or a missing mandatory object returns `null` instead of a partial result. A static payload comes back with `key`, a dynamic one with `url`. The result is typed as `PixPayload`; `pointOfInitiation` is typed as `PixPointOfInitiation` (`"static"` or `"dynamic"`). The merchant account information must carry exactly one of a key or a `url` (checked with the same PSP location rule as `generatePixPayload`), and in a dynamic payload the amount and the `txid` are ignored, as the manual mandates. Payloads whose location lives in an Unreserved Template (IDs 80 to 99, Pix Automático) are out of scope and return `null`. +Parses a Pix BR Code payload into its fields. The payload is validated by `isValidPixPayload` first, so a malformed structure, a broken CRC or a missing mandatory object returns `null` instead of a partial result. A static payload comes back with `key`, a dynamic one with `url`. The result is typed as `PixPayload`; `pointOfInitiation` is typed as `PixPointOfInitiation` (`"static"` or `"dynamic"`). The merchant account information must carry exactly one of a key or a `url` (checked with the same PSP location rule as `generatePixPayload`), and the "Point of Initiation Method" object (`01`) must agree with it: a key belongs to a static payload (`01` absent or `"11"`) and a `url` to a dynamic one (`01` set to `"12"`), so any other pairing returns `null`. A static payload that states an amount must state one greater than zero (`54` set to `0.00` is reserved for the Pix Saque/Troco BR Code, which is out of scope), and in a dynamic payload the amount and the `txid` are ignored, as the manual mandates. Payloads whose location lives in an Unreserved Template (IDs 80 to 99, Pix Automático) are out of scope and return `null`. ```javascript import { parsePixPayload } from '@brazilian-utils/brazilian-utils'; @@ -507,7 +507,7 @@ generatePixPayload({ merchantName: 'Fulano', merchantCity: 'Brasília' }); // nu ### isValidNfeKey -Check if a DF-e (Documento Fiscal eletrônico) access key (chave de acesso) is valid. It 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. The emission type (`tpEmis`) must be one of the codes the MOC assigns, 1 to 7 or 9; 8 is not assigned and makes the key invalid. +Check if a DF-e (Documento Fiscal eletrônico) access key (chave de acesso) is valid. It covers every document that shares the same 44 digit layout: NF-e (modelo 55), NFC-e (modelo 65), CT-e (modelo 57), MDF-e (modelo 58) and CT-e OS (modelo 67, the Conhecimento de Transporte Eletrônico para Outros Serviços of the [Ajuste SINIEF 09/07](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2007/aj_009_07)). Accepts whitespace between digit groups (the common display mask) and the `NFe` prefix found in the `Id` attribute of the document's XML. The emission type (`tpEmis`) must be one of the codes the MOC assigns, 1 to 7 or 9; 8 is not assigned and makes the key invalid. ```javascript import { isValidNfeKey } from '@brazilian-utils/brazilian-utils'; @@ -521,7 +521,7 @@ isValidNfeKey('35170458716523000119550010000000128000123455'); // false (tpEmis ### formatNfeKey -Format a DF-e (NF-e, NFC-e, CT-e or MDF-e) access key into groups of 4 digits separated by spaces, the common display form printed on the DANFE. +Format a DF-e (NF-e, NFC-e, CT-e, MDF-e or CT-e OS) access key into groups of 4 digits separated by spaces, the common display form printed on the DANFE. ```javascript import { formatNfeKey } from '@brazilian-utils/brazilian-utils'; @@ -636,32 +636,41 @@ isValidServicePhone('11987654321'); // false (geographic number) ### getAreaCodeInfo -Get the state (and its region) a Brazilian DDD (area code) belongs to, out of the 67 DDDs in use under the Anatel Plano Geral de Numeração. Accepts a string or a number, stripping any non-digit characters before matching. Exports the `AreaCodeInfo` type. +Get the state (and its region) a Brazilian DDD (area code) belongs to, out of the 67 DDDs in use under the Anatel Plano Geral de Numeração. Accepts a string or a non-negative integer number, stripping any non-digit characters before matching. Exports the `AreaCodeInfo` type. + +`stateCode` is always a single state: the one that holds all but a handful of the DDD's municipalities. Four DDDs straddle a state border, and for those `stateCodes` lists the other states too. DDD 61 is the widest of them, serving the Distrito Federal and the twelve Goiás municipalities of the Entorno do Distrito Federal (Águas Lindas de Goiás, Cabeceiras, Cidade Ocidental, Cristalina, Formosa, Luziânia, Novo Gama, Padre Bernardo, Planaltina, Santo Antônio do Descoberto, Valparaíso de Goiás and Vila Boa). The other three are 42, shared by Paraná and Porto União (SC), 47, shared by Santa Catarina and Rio Negro (PR), and 49, shared by Santa Catarina and Barracão (PR). ```javascript import { getAreaCodeInfo } from '@brazilian-utils/brazilian-utils'; getAreaCodeInfo('11'); -// { areaCode: 11, stateCode: 'SP', stateName: 'São Paulo', region: 'Sudeste' } +// { areaCode: 11, stateCode: 'SP', stateName: 'São Paulo', region: 'Sudeste', stateCodes: ['SP'] } getAreaCodeInfo(21); -// { areaCode: 21, stateCode: 'RJ', stateName: 'Rio de Janeiro', region: 'Sudeste' } +// { areaCode: 21, stateCode: 'RJ', stateName: 'Rio de Janeiro', region: 'Sudeste', stateCodes: ['RJ'] } -getAreaCodeInfo('68'); -// { areaCode: 68, stateCode: 'AC', stateName: 'Acre', region: 'Norte' } +getAreaCodeInfo('61'); +// { areaCode: 61, stateCode: 'DF', stateName: 'Distrito Federal', region: 'Centro-Oeste', stateCodes: ['DF', 'GO'] } getAreaCodeInfo('00'); // null +getAreaCodeInfo(-11); // null +getAreaCodeInfo(1.1); // null ``` ### getAreaCodesByState -Get every DDD (area code) that belongs to a given Brazilian state, under the Anatel Plano Geral de Numeração. The match is case-insensitive and the result is sorted in ascending order. +Get every DDD (area code) that serves a given Brazilian state, under the Anatel Plano Geral de Numeração. The match is case-insensitive and the result is sorted in ascending order. + +A DDD that straddles a state border is listed under every state it serves, so DDD 61 comes back for both `'DF'` and `'GO'`: it serves the Distrito Federal and the twelve Goiás municipalities of the Entorno do Distrito Federal. The other three are 42, shared by Paraná and Porto União (SC), 47, shared by Santa Catarina and Rio Negro (PR), and 49, shared by Santa Catarina and Barracão (PR). ```javascript import { getAreaCodesByState } from '@brazilian-utils/brazilian-utils'; getAreaCodesByState('SP'); // [11, 12, 13, 14, 15, 16, 17, 18, 19] getAreaCodesByState('ac'); // [68] +getAreaCodesByState('DF'); // [61] +getAreaCodesByState('GO'); // [61, 62, 64] +getAreaCodesByState('SC'); // [42, 47, 48, 49] getAreaCodesByState('XX'); // [] ``` @@ -951,7 +960,7 @@ getBankByIspb('99999999'); // null ### isValidIban -Check if a Brazilian IBAN (International Bank Account Number) is valid, per Bacen's [Diretrizes de Implementação do IBAN no Brasil](https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf) (Circular BCB nº 3.625/2013): `BR` + 2 ISO 7064 MOD 97-10 check digits + 8 digit ISPB + 5 digit branch + 10 digit account + 1 letter account type (any letter, usually `C` for conta corrente or `P` for conta poupança) + 1 alphanumeric owner indicator, 29 characters total. Only Brazilian IBANs (country code `BR`) are recognized; any other country returns `false`, since this package does not carry the field layout of the other 90+ ISO 13616 countries. Accepts the usual grouping spaces and is case-insensitive. +Check if a Brazilian IBAN (International Bank Account Number) is valid, per Bacen's [Diretrizes de Implementação do IBAN no Brasil](https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf) (Circular BCB nº 3.625/2013): `BR` + 2 ISO 7064 MOD 97-10 check digits + 8 digit ISPB + 5 digit branch + 10 digit account + 1 letter account type (any letter, usually `C` for conta corrente or `P` for conta poupança) + 1 alphanumeric owner indicator, 29 characters total. Only Brazilian IBANs (country code `BR`) are recognized; any other country returns `false`, since this package does not carry the field layout of the other 90+ ISO 13616 countries. Accepts the usual grouping spaces and is case-insensitive. The value has to be written in the ISO 13616 print format: letters and digits in groups separated by a single space, with optional surrounding whitespace. Any other character makes the value something other than an IBAN, so it is rejected instead of being stripped. ```javascript import { isValidIban } from '@brazilian-utils/brazilian-utils'; @@ -959,12 +968,13 @@ import { isValidIban } from '@brazilian-utils/brazilian-utils'; isValidIban('BR1500000000000010932840814P2'); // true isValidIban('BR15 0000 0000 0000 1093 2840 814P 2'); // true (grouping spaces) isValidIban('BR1500000000000010932840814P3'); // false (bad check digits) +isValidIban('BR1500000000000010932840814P-2'); // false (hyphens are not part of an IBAN) isValidIban('DE89370400440532013000'); // false (non Brazilian IBAN) ``` ### formatIban -Format a Brazilian IBAN by grouping it in blocks of 4 characters, the ISO 13616 "print" presentation used on statements and bank forms. Does not validate the check digits or the field layout; formats whatever is given, up to the 29 character length of a Brazilian IBAN, as far as it goes, so the function can also be used as an input mask. Use `isValidIban` to check validity. +Format a Brazilian IBAN by grouping it in blocks of 4 characters, the ISO 13616 "print" presentation used on statements and bank forms. Does not validate the check digits or the field layout; formats whatever is given, up to the 29 character length of a Brazilian IBAN, as far as it goes, so the function can also be used as an input mask. Use `isValidIban` to check validity. The value still has to be written in the ISO 13616 print format (letters and digits in groups separated by a single space, with optional surrounding whitespace); any other character returns an empty string instead of being quietly dropped. ```javascript import { formatIban } from '@brazilian-utils/brazilian-utils'; @@ -972,11 +982,12 @@ import { formatIban } from '@brazilian-utils/brazilian-utils'; formatIban('BR1500000000000010932840814P2'); // 'BR15 0000 0000 0000 1093 2840 814P 2' formatIban('br1500000000000010932840814p2'); // 'BR15 0000 0000 0000 1093 2840 814P 2' formatIban('BR15'); // 'BR15' +formatIban('BR1500000000000010932840814P-2'); // '' (hyphens are not part of an IBAN) ``` ### parseIban -Parses a Brazilian IBAN into its fields: 2 (country code, always `BR`) + 2 (ISO 7064 MOD 97-10 check digits) + 8 (ISPB) + 5 (branch) + 10 (account) + 1 (account type, any letter, usually `C` for conta corrente or `P` for conta poupança) + 1 (owner indicator). Accepts the same input forms as `isValidIban` (grouping spaces, lowercase) and returns `null` whenever `isValidIban` would return `false`. The result is typed as `Iban`, whose `accountType` is a `string`. +Parses a Brazilian IBAN into its fields: 2 (country code, always `BR`) + 2 (ISO 7064 MOD 97-10 check digits) + 8 (ISPB) + 5 (branch) + 10 (account) + 1 (account type, any letter, usually `C` for conta corrente or `P` for conta poupança) + 1 (owner indicator). Accepts the same input forms as `isValidIban` (grouping spaces, lowercase) and returns `null` whenever `isValidIban` would return `false`, including a value carrying any character other than letters, digits and the grouping spaces of the print format. The result is typed as `Iban`, whose `accountType` is a `string`. ```javascript import { parseIban } from '@brazilian-utils/brazilian-utils'; @@ -993,11 +1004,12 @@ parseIban('BR1500000000000010932840814P2'); // } parseIban('DE89370400440532013000'); // null (non Brazilian IBAN) +parseIban('BR1500000000000010932840814P-2'); // null (hyphens are not part of an IBAN) ``` ### isValidCreditCard -Check if a payment card number is valid using the Luhn algorithm ([ISO/IEC 7812-1](https://www.iso.org/standard/70484.html)). Accepts the usual mask characters (spaces, hyphens) between digits. Performs no brand detection (Visa, Mastercard, Amex...), issuer range lookup or expiration/CVV checks, only the digit count (12 to 19) and the Luhn check digit. +Check if a payment card number is valid using the Luhn algorithm ([ISO/IEC 7812-1](https://www.iso.org/standard/70484.html)). Accepts the usual mask characters (spaces, hyphens) between digits. Performs no brand detection (Visa, Mastercard, Amex...), issuer range lookup or expiration/CVV checks, only the digit count (12 to 19) and the Luhn check digit. A `number` is only accepted when it is a non-negative safe integer: anything above `Number.MAX_SAFE_INTEGER` (2^53 - 1, 16 digits) has already been rounded to a different number before the function sees it, so pass a longer PAN as a string. ```javascript import { isValidCreditCard } from '@brazilian-utils/brazilian-utils'; @@ -1007,6 +1019,7 @@ isValidCreditCard('5555555555554444'); // true (Mastercard test number) isValidCreditCard('378282246310005'); // true (American Express test number) isValidCreditCard('4111 1111 1111 1111'); // true (spaced mask) isValidCreditCard('4111111111111112'); // false (bad check digit) +isValidCreditCard(4111111111111111111); // false (above 2^53 - 1, pass it as a string) ``` ### capitalize @@ -1097,7 +1110,7 @@ convertCurrencyToWords(1000, { case: 'upper' }); // "MIL REAIS" ### getStates -Get all Brazilian states, each with its two-letter code, name, region code, region name and 2-digit IBGE code of the Federative Unit (`cUF`). The list is sorted by name with `localeCompare` in the "pt-BR" locale, so accented names land where a Brazilian reader expects them: Pará, Paraíba, Paraná and Rio de Janeiro, Rio Grande do Norte, Rio Grande do Sul. Each call returns a fresh array of fresh objects, so mutating the result never affects subsequent calls. Exports the `State`, `StateCode` and `StateName` types. +Get all Brazilian states, each with its two-letter code, name, region code, region name and 2-digit IBGE code of the Federative Unit (`cUF`). The list is sorted by name with `localeCompare` in the "pt-BR" locale, so accented names land where a Brazilian reader expects them: Pará, Paraíba, Paraná and Rio de Janeiro, Rio Grande do Norte, Rio Grande do Sul. Each call returns a fresh array of fresh objects, so mutating the result never affects subsequent calls. Exports the `State`, `StateCode` and `StateName` types. `State` is a discriminated union with one member per state, so the fields of a state are tied to each other: narrowing a `State` by `code` narrows its `name`, `regionCode`, `regionName` and `ibgeCode` too (`Extract['name']` is `'São Paulo'`), and an impossible combination such as `{ code: 'SP', name: 'Acre' }` is not a `State`. ```javascript import { getStates } from '@brazilian-utils/brazilian-utils'; @@ -1136,7 +1149,7 @@ getStates(); ### getStateByIbgeCode -Get the Brazilian state whose 2-digit IBGE code ("cUF", the Código da Unidade da Federação) matches the given value. This 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. Accepts a string or a number, stripping any non-digit characters before matching. Exports the `State` type. +Get the Brazilian state whose 2-digit IBGE code ("cUF", the Código da Unidade da Federação) matches the given value. This 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. Accepts a string or a non-negative integer number, stripping any non-digit characters before matching. Exports the `State` type. ```javascript import { getStateByIbgeCode } from '@brazilian-utils/brazilian-utils'; @@ -1148,6 +1161,8 @@ getStateByIbgeCode(11); // { code: 'RO', name: 'Rondônia', regionCode: 'N', regionName: 'Norte', ibgeCode: 11 } getStateByIbgeCode('00'); // null +getStateByIbgeCode(-35); // null +getStateByIbgeCode(3.5); // null ``` ### getStateCodeByName @@ -1537,7 +1552,7 @@ generatePis(); // '91077906857' ### getMunicipality -Get municipality information by IBGE code, or get an IBGE code from municipality name and UF. A single function handles both directions, based on whether `options` has a `code` or a `municipalityName`/`uf`. `code` accepts both `string` and `number` input and must be exactly 7 digits, otherwise the function resolves to `null`. Resolution is entirely offline, from a bundled IBGE dataset: no network request is made. The municipality name match ignores accents and casing. An unknown municipality, an unknown UF or invalid input all resolve to `null`. +Get municipality information by IBGE code, or get an IBGE code from municipality name and UF. A single function handles both directions, based on whether `options` has a `code` or a `municipalityName`/`uf`. `code` accepts both `string` and `number` input and must be exactly 7 digits, otherwise the function resolves to `null`. A `code` given as a number must be a non-negative integer: a sign and a decimal point are not digits, so `-3550308` and `355030.8` resolve to `null` instead of being read as `3550308`. Resolution is entirely offline, from a bundled IBGE dataset: no network request is made. The municipality name match ignores accents and casing. An unknown municipality, an unknown UF or invalid input all resolve to `null`. ```javascript import { getMunicipality } from '@brazilian-utils/brazilian-utils'; @@ -1594,7 +1609,7 @@ getMunicipalities('ZZ'); // [] ### getMunicipalityByCode -Look up a Brazilian municipality by its 7-digit IBGE code. Accepts the code as a string or a number, with any non-digit characters stripped before matching. Returns `{ code, name, stateCode }`, a fresh object, or `null` when the code is not 7 digits long or does not match any known municipality. +Look up a Brazilian municipality by its 7-digit IBGE code. Accepts the code as a string or a number, with any non-digit characters stripped before matching; a code given as a number must be a non-negative integer, so `-3550308` and `355030.8` return `null` instead of being read as `3550308`. Returns `{ code, name, stateCode }`, a fresh object, or `null` when the code is not 7 digits long or does not match any known municipality. ```javascript import { getMunicipalityByCode } from '@brazilian-utils/brazilian-utils'; @@ -1764,7 +1779,7 @@ formatCns('89010001', { pad: true }); // '000 0000 8901 0001' Check if 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) is valid. 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), and both check digits are modulus 11 with weights cycling from 2 to 10 and back through 0. Accepts the usual mask characters and whitespace between/around groups. The layout is the in-force one of [art. 473 of the Código Nacional de Normas da Corregedoria Nacional de Justiça](https://atos.cnj.jus.br/atos/detalhar/5243) (Provimento CNJ nº 149/2023, in the wording of the Provimento CN nº 182/2024); the matrícula itself was instituted by the now revoked [Provimento CNJ nº 2/2009](https://atos.cnj.jus.br/atos/detalhar/1311). The check digits are detailed by [ghiorzi.org](http://ghiorzi.org/DVnew.htm) and implemented by [validation-br](https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts) and [validator-docs](https://github.com/geekcom/validator-docs/blob/master/src/validator-docs/Rules/Certidao.php). -The book-type digit always has to name one of the nine book types (the same `CertidaoType` returned by `parseCertidao`), so a matrícula whose digit is `0` is rejected however good its check digits are, the same way `parseCertidao` returns `null` for it. `options.accept` (part of `IsValidCertidaoOptions`) narrows that to the listed types; it defaults to every type, and a value that is not an array falls back to that default. Only a string is accepted: the 32 digits of a matrícula are more than a JavaScript number can hold. +The serviço digits are fixed at `55`, the code [art. 473, III](https://atos.cnj.jus.br/atos/detalhar/5243) assigns to the registro civil das pessoas naturais, so a matrícula carrying any other pair in the ninth and tenth positions is rejected however good its check digits are. The book-type digit always has to name one of the nine book types (the same `CertidaoType` returned by `parseCertidao`), so a matrícula whose digit is `0` is rejected however good its check digits are, the same way `parseCertidao` returns `null` for it. `options.accept` (part of `IsValidCertidaoOptions`) narrows that to the listed types; it defaults to every type, and a value that is not an array falls back to that default. Only a string is accepted: the 32 digits of a matrícula are more than a JavaScript number can hold. ```javascript import { isValidCertidao } from '@brazilian-utils/brazilian-utils'; @@ -1772,6 +1787,7 @@ import { isValidCertidao } from '@brazilian-utils/brazilian-utils'; 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('09400301542011100110002005191744'); // false (serviço is not 55) 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 @@ -1807,7 +1823,7 @@ The `Certidao` result carries: | --- | --- | | `registryCns` | The 6 digit CNS (Código Nacional de Serventia) of the serventia that issued the act. | | `acervo` | Acervo the book belongs to: `"01"` the serventia's own, `"02"` a collection it absorbed. | -| `service` | Service rendered by the serventia, `"55"` for registro civil das pessoas naturais. | +| `service` | Service rendered by the serventia, always `"55"`, the registro civil das pessoas naturais. | | `year` | Four digit year the act was recorded. | | `type` | The book the act belongs to: `"birth"`, `"marriage"`, `"religious-marriage"`, `"death"`, `"stillbirth"`, `"banns"`, `"other"`, `"emancipation"` or `"interdiction"`. | | `typeCode` | Raw book code, 1 to 9, as printed in the fifteenth position of the matrícula. | @@ -1934,7 +1950,7 @@ isValidVin('1HGCM8263IA004352'); // false (contains the excluded letter I) ### isValidCbo -Check if a CBO (Classificação Brasileira de Ocupações) code exists in the MTE occupation table. Accepts the code with or without the hyphen mask, or as a number. +Check if a CBO (Classificação Brasileira de Ocupações) code exists in the MTE occupation table. Accepts the code with or without the hyphen mask, or as a number. A string is only read as a code when it is written in one of those forms (the 6 digits, or the `NNNN-NN` mask, with the usual separators between the groups and optional surrounding whitespace), and a number only when it is a non-negative safe integer. ```javascript import { isValidCbo } from '@brazilian-utils/brazilian-utils'; @@ -1943,26 +1959,29 @@ isValidCbo('2124-05'); // true isValidCbo('212405'); // true isValidCbo(212405); // true isValidCbo('000000'); // false +isValidCbo('2124abc05'); // false (not a documented form) +isValidCbo(-212405); // false (not a non-negative safe integer) ``` The occupation titles come from the [official CBO 2002 tables published by the MTE](http://www.mtecbo.gov.br/cbosite/pages/downloads.jsf). ### getCbo -Look a CBO (Classificação Brasileira de Ocupações) code up and get its official occupation title. A `number` keeps its implied leading zeros: `getCbo(10205)` is read as `010205`. +Look a CBO (Classificação Brasileira de Ocupações) code up and get its official occupation title. A `number` keeps its implied leading zeros: `getCbo(10205)` is read as `010205`. Same input rules as `isValidCbo`: a string has to be written as the 6 digits or with the `NNNN-NN` mask, and a number has to be a non-negative safe integer. ```javascript import { getCbo } from '@brazilian-utils/brazilian-utils'; getCbo('2124-05'); // { code: '212405', title: 'Analista de desenvolvimento de sistemas' } getCbo('000000'); // null +getCbo('2124abc05'); // null (not a documented form) ``` The occupation titles come from the [official CBO 2002 tables published by the MTE](http://www.mtecbo.gov.br/cbosite/pages/downloads.jsf). ### isValidCnae -Check if a CNAE (Classificação Nacional de Atividades Econômicas) subclass code exists in the CNAE 2.3 table published by IBGE. Accepts the code with or without the `NNNN-N/NN` mask, or as a number. +Check if a CNAE (Classificação Nacional de Atividades Econômicas) subclass code exists in the CNAE 2.3 table published by IBGE. Accepts the code with or without the `NNNN-N/NN` mask, or as a number. A string is only read as a code when it is written in one of those forms (the 7 digits, or the mask, with the usual separators between the groups and optional surrounding whitespace), and a number only when it is a non-negative safe integer. ```javascript import { isValidCnae } from '@brazilian-utils/brazilian-utils'; @@ -1970,6 +1989,8 @@ import { isValidCnae } from '@brazilian-utils/brazilian-utils'; isValidCnae('6201-5/01'); // true isValidCnae('6201501'); // true isValidCnae('0000000'); // false +isValidCnae('0111abc301'); // false (not a documented form) +isValidCnae(-111301); // false (not a non-negative safe integer) ``` ### formatCnae @@ -1984,13 +2005,14 @@ formatCnae('6201501'); // 6201-5/01 ### getCnae -Look a CNAE (Classificação Nacional de Atividades Econômicas) subclass code up and get its formatted code and official description. A `number` keeps its implied leading zeros: `getCnae(111301)` is read as `0111301`. +Look a CNAE (Classificação Nacional de Atividades Econômicas) subclass code up and get its formatted code and official description. A `number` keeps its implied leading zeros: `getCnae(111301)` is read as `0111301`. Same input rules as `isValidCnae`: a string has to be written as the 7 digits or with the `NNNN-N/NN` mask, and a number has to be a non-negative safe integer. ```javascript import { getCnae } from '@brazilian-utils/brazilian-utils'; getCnae('6201501'); // { code: '6201-5/01', description: 'DESENVOLVIMENTO DE PROGRAMAS DE COMPUTADOR SOB ENCOMENDA' } getCnae('0000000'); // null +getCnae('0111abc301'); // null (not a documented form) ``` ### isValidNcm @@ -2017,24 +2039,26 @@ formatNcm('84713012'); // 8471.30.12 ### isValidCfop -Check if a CFOP (Código Fiscal de Operações e Prestações) code exists in the official table (Ajuste SINIEF 07/2001 and updates). +Check if a CFOP (Código Fiscal de Operações e Prestações) code exists in the official table (Ajuste SINIEF 07/2001 and updates). Only operable codes count: the group and subgroup headings of the official nomenclature, the codes ending in `00` and `50` (1000, 1100, 1150, 5350, ...), are section titles rather than codes a document can carry, so they are rejected. ```javascript import { isValidCfop } from '@brazilian-utils/brazilian-utils'; isValidCfop('5102'); // true isValidCfop('0000'); // false +isValidCfop('1150'); // false (a subgroup heading, not an operable code) ``` ### getCfop -Look a CFOP (Código Fiscal de Operações e Prestações) code up and get its code and official description. +Look a CFOP (Código Fiscal de Operações e Prestações) code up and get its code and official description. The group and subgroup headings of the official nomenclature, the codes ending in `00` and `50`, are not in the table and give `null`. ```javascript import { getCfop } from '@brazilian-utils/brazilian-utils'; getCfop('5102'); // { code: '5102', description: 'Venda de mercadoria adquirida ou recebida de terceiros' } getCfop('0000'); // null +getCfop('5350'); // null (a subgroup heading, not an operable code) ``` ### isValidCst diff --git a/docs/llms.txt b/docs/llms.txt index eedf5f22..c0834e7c 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -29,7 +29,7 @@ const { getCities } = await import('@brazilian-utils/brazilian-utils/get-cities' - [isValidCep](https://brazilian-utils.com.br/utilities.md#isvalidcep): Check if CEP (brazilian postal code) is valid. - [isValidBoleto](https://brazilian-utils.com.br/utilities.md#isvalidboleto): Check if boleto (brazilian payment method) is valid. - [isValidPixKey](https://brazilian-utils.com.br/utilities.md#isvalidpixkey): Check if a Pix key (chave Pix) is valid: a CPF, a CNPJ, an e-mail address, a Brazilian mobile phone number or a random key (EVP), per the DICT key formats. -- [isValidPixPayload](https://brazilian-utils.com.br/utilities.md#isvalidpixpayload): Check if a Pix BR Code payload (the string behind a Pix QR Code and behind "Pix copia e cola") is valid: well-formed TLV structure, the mandatory objects present, one of the "Merchant Account Information" templates carrying the `br.gov.bcb.pix` GUI with a key or a URL, and a matching CRC-16. +- [isValidPixPayload](https://brazilian-utils.com.br/utilities.md#isvalidpixpayload): Check if a Pix BR Code payload (the string behind a Pix QR Code and behind "Pix copia e cola") is valid: well-formed TLV structure, the mandatory objects present, one of the "Merchant Account Information" templates carrying the `br.gov.bcb.pix` GUI with a key or a URL, a "Point of Initiation Method" object (`01`) that agrees with it (a key requires a static payload, so `01` is absent or `"11"`; a URL requires a dynamic one, so `01` is `"12"`), an amount (`54`) greater than zero in a static payload, and a matching CRC-16. - [isValidNfeKey](https://brazilian-utils.com.br/utilities.md#isvalidnfekey): Check if a DF-e (Documento Fiscal eletrônico) access key (chave de acesso) is valid. - [isValidEmail](https://brazilian-utils.com.br/utilities.md#isvalidemail): Check if email is valid. - [isValidPhone](https://brazilian-utils.com.br/utilities.md#isvalidphone): Check if phone number (mobile or landline) is valid. @@ -67,7 +67,7 @@ const { getCities } = await import('@brazilian-utils/brazilian-utils/get-cities' - [formatCpf](https://brazilian-utils.com.br/utilities.md#formatcpf): Format CPF. - [formatCnpj](https://brazilian-utils.com.br/utilities.md#formatcnpj): Format CNPJ. - [formatBoleto](https://brazilian-utils.com.br/utilities.md#formatboleto): Format a boleto number. -- [formatNfeKey](https://brazilian-utils.com.br/utilities.md#formatnfekey): Format a DF-e (NF-e, NFC-e, CT-e or MDF-e) access key into groups of 4 digits separated by spaces, the common display form printed on the DANFE. +- [formatNfeKey](https://brazilian-utils.com.br/utilities.md#formatnfekey): Format a DF-e (NF-e, NFC-e, CT-e, MDF-e or CT-e OS) access key into groups of 4 digits separated by spaces, the common display form printed on the DANFE. - [formatPhone](https://brazilian-utils.com.br/utilities.md#formatphone): Format phone number according to Brazilian patterns. - [formatPis](https://brazilian-utils.com.br/utilities.md#formatpis): Format PIS number. - [formatCep](https://brazilian-utils.com.br/utilities.md#formatcep): Format CEP (brazilian postal code). @@ -128,7 +128,7 @@ const { getCities } = await import('@brazilian-utils/brazilian-utils/get-cities' - [getBoletoInfo](https://brazilian-utils.com.br/utilities.md#getboletoinfo): Extract information from a boleto (amount, expiration date, bank code). - [getAreaCodeInfo](https://brazilian-utils.com.br/utilities.md#getareacodeinfo): Get the state (and its region) a Brazilian DDD (area code) belongs to, out of the 67 DDDs in use under the Anatel Plano Geral de Numeração. -- [getAreaCodesByState](https://brazilian-utils.com.br/utilities.md#getareacodesbystate): Get every DDD (area code) that belongs to a given Brazilian state, under the Anatel Plano Geral de Numeração. +- [getAreaCodesByState](https://brazilian-utils.com.br/utilities.md#getareacodesbystate): Get every DDD (area code) that serves a given Brazilian state, under the Anatel Plano Geral de Numeração. - [getAddressInfoByCep](https://brazilian-utils.com.br/utilities.md#getaddressinfobycep): Fetch address information for a given CEP using multiple providers. - [getBanks](https://brazilian-utils.com.br/utilities.md#getbanks): Get every Brazilian bank with a compensation code (COMPE), published by Banco Central do Brasil in the STR participants list. - [getBankByCode](https://brazilian-utils.com.br/utilities.md#getbankbycode): Look a Brazilian bank up by its compensation code (COMPE), published by Banco Central do Brasil in the STR participants list. diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index 8e9f08d0..28c5b00a 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -192,7 +192,7 @@ isValidPixKey('not a key'); // false ## parsePixKey -Identifica uma chave Pix e a normaliza para a forma canônica que o DICT espera dentro do BR Code: CPF com 11 dígitos, CNPJ com 14 caracteres, e-mail em minúsculas, telefone celular em E.164 (um telefone fixo não é chave Pix) ou UUID em minúsculas (EVP). Um valor de 11 dígitos válido tanto como CPF quanto como celular é lido como CPF, a menos que tenha sido escrito como telefone (prefixo `+55`/`0055` ou DDD entre parênteses). Retorna `null` quando o valor não é uma chave Pix válida. O resultado é tipado como `PixKey`. +Identifica uma chave Pix e a normaliza para a forma canônica que o DICT espera dentro do BR Code: CPF com 11 dígitos, CNPJ com 14 caracteres, e-mail em minúsculas, telefone celular em E.164 (um telefone fixo não é chave Pix) ou UUID em minúsculas (EVP). Um valor de 11 dígitos válido tanto como CPF quanto como celular é lido como CPF, a menos que tenha sido escrito como telefone (prefixo `+55`/`0055` ou DDD entre parênteses). O CPF e o telefone são reconhecidos pela forma como são escritos, não apenas pelos dígitos que carregam, então texto ao redor não é descartado e `'abc123.456.789-09'` não é uma chave CPF. Retorna `null` quando o valor não é uma chave Pix válida. O resultado é tipado como `PixKey`. ```javascript import { parsePixKey } from '@brazilian-utils/brazilian-utils'; @@ -209,7 +209,7 @@ parsePixKey('+5551998259765'); // { type: 'phone', value: '+5551998259765' } ## isValidPixPayload -Valida se um payload de BR Code Pix (a string por trás de um QR Code Pix e do "Pix copia e cola") é válido: estrutura TLV bem formada, objetos obrigatórios presentes, um dos templates "Merchant Account Information" carregando o GUI `br.gov.bcb.pix` junto com uma chave ou uma URL, e um CRC-16 que confere. A chave em si não é validada contra os formatos do DICT, use `isValidPixKey` para isso. Payloads que trazem a localização em um Unreserved Template (IDs 80 a 99), como o "QR Code composto" do Pix Automático (Pix recorrente), estão fora de escopo e são considerados inválidos. +Valida se um payload de BR Code Pix (a string por trás de um QR Code Pix e do "Pix copia e cola") é válido: estrutura TLV bem formada, objetos obrigatórios presentes, um dos templates "Merchant Account Information" carregando o GUI `br.gov.bcb.pix` junto com uma chave ou uma URL, um objeto "Point of Initiation Method" (`01`) coerente com ele (uma chave exige um payload estático, com `01` ausente ou `"11"`; uma URL exige um dinâmico, com `01` igual a `"12"`), um valor (`54`) maior que zero em um payload estático, e um CRC-16 que confere. A chave em si não é validada contra os formatos do DICT, use `isValidPixKey` para isso. Payloads que trazem a localização em um Unreserved Template (IDs 80 a 99), como o "QR Code composto" do Pix Automático (Pix recorrente), estão fora de escopo e são considerados inválidos. ```javascript import { isValidPixPayload } from '@brazilian-utils/brazilian-utils'; @@ -224,7 +224,7 @@ isValidPixPayload('00020126580014br.gov.bcb.pix...'); // false (CRC quebrado) ## parsePixPayload -Interpreta um payload de BR Code Pix e retorna seus campos. O payload é validado pelo `isValidPixPayload` primeiro, então uma estrutura malformada, um CRC quebrado ou um objeto obrigatório ausente retornam `null` em vez de um resultado parcial. Um payload estático vem com `key`, um dinâmico com `url`. O resultado é tipado como `PixPayload`; `pointOfInitiation` é tipado como `PixPointOfInitiation` (`"static"` ou `"dynamic"`). As informações da conta do recebedor devem trazer exatamente uma chave ou uma `url` (verificada com a mesma regra de localização de PSP do `generatePixPayload`), e em um payload dinâmico o valor e o `txid` são ignorados, como o manual determina. Payloads cuja localização fica em um Unreserved Template (IDs 80 a 99, Pix Automático) estão fora de escopo e retornam `null`. +Interpreta um payload de BR Code Pix e retorna seus campos. O payload é validado pelo `isValidPixPayload` primeiro, então uma estrutura malformada, um CRC quebrado ou um objeto obrigatório ausente retornam `null` em vez de um resultado parcial. Um payload estático vem com `key`, um dinâmico com `url`. O resultado é tipado como `PixPayload`; `pointOfInitiation` é tipado como `PixPointOfInitiation` (`"static"` ou `"dynamic"`). As informações da conta do recebedor devem trazer exatamente uma chave ou uma `url` (verificada com a mesma regra de localização de PSP do `generatePixPayload`), e o objeto "Point of Initiation Method" (`01`) precisa ser coerente com isso: uma chave pertence a um payload estático (`01` ausente ou `"11"`) e uma `url` a um dinâmico (`01` igual a `"12"`), então qualquer outra combinação retorna `null`. Um payload estático que informa um valor precisa informar um valor maior que zero (`54` igual a `0.00` é reservado ao BR Code de Pix Saque/Troco, que está fora de escopo), e em um payload dinâmico o valor e o `txid` são ignorados, como o manual determina. Payloads cuja localização fica em um Unreserved Template (IDs 80 a 99, Pix Automático) estão fora de escopo e retornam `null`. ```javascript import { parsePixPayload } from '@brazilian-utils/brazilian-utils'; @@ -269,7 +269,7 @@ generatePixPayload({ merchantName: 'Fulano', merchantCity: 'Brasília' }); // nu ## isValidNfeKey -Valida se uma chave de acesso de DF-e (Documento Fiscal eletrônico) é válida. Cobre todos os documentos que compartilham o mesmo layout de 44 dígitos: NF-e (modelo 55), NFC-e (modelo 65), CT-e (modelo 57) e MDF-e (modelo 58). Aceita espaços entre os grupos de dígitos (a máscara de exibição usual) e o prefixo `NFe` encontrado no atributo `Id` do XML do documento. A forma de emissão (`tpEmis`) precisa ser um dos códigos atribuídos pelo MOC, de 1 a 7 ou 9; o 8 não é atribuído e torna a chave inválida. +Valida se uma chave de acesso de DF-e (Documento Fiscal eletrônico) é válida. Cobre todos os documentos que compartilham o mesmo layout de 44 dígitos: NF-e (modelo 55), NFC-e (modelo 65), CT-e (modelo 57), MDF-e (modelo 58) e CT-e OS (modelo 67, o Conhecimento de Transporte Eletrônico para Outros Serviços do [Ajuste SINIEF 09/07](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2007/aj_009_07)). Aceita espaços entre os grupos de dígitos (a máscara de exibição usual) e o prefixo `NFe` encontrado no atributo `Id` do XML do documento. A forma de emissão (`tpEmis`) precisa ser um dos códigos atribuídos pelo MOC, de 1 a 7 ou 9; o 8 não é atribuído e torna a chave inválida. ```javascript import { isValidNfeKey } from '@brazilian-utils/brazilian-utils'; @@ -283,7 +283,7 @@ isValidNfeKey('35170458716523000119550010000000128000123455'); // false (tpEmis ## formatNfeKey -Formata uma chave de acesso de DF-e (NF-e, NFC-e, CT-e ou MDF-e) em grupos de 4 dígitos separados por espaço, a forma de exibição usual impressa na DANFE. +Formata uma chave de acesso de DF-e (NF-e, NFC-e, CT-e, MDF-e ou CT-e OS) em grupos de 4 dígitos separados por espaço, a forma de exibição usual impressa na DANFE. ```javascript import { formatNfeKey } from '@brazilian-utils/brazilian-utils'; @@ -398,32 +398,41 @@ isValidServicePhone('11987654321'); // false (número geográfico) ## getAreaCodeInfo -Retorna o estado (e a região) a que um DDD brasileiro pertence, dentre os 67 DDDs em uso no Plano Geral de Numeração da Anatel. Aceita string ou número, removendo caracteres não numéricos antes de comparar. Exporta o tipo `AreaCodeInfo`. +Retorna o estado (e a região) a que um DDD brasileiro pertence, dentre os 67 DDDs em uso no Plano Geral de Numeração da Anatel. Aceita string ou número inteiro não negativo, removendo caracteres não numéricos antes de comparar. Exporta o tipo `AreaCodeInfo`. + +`stateCode` é sempre um único estado: aquele que concentra quase todos os municípios do DDD. Quatro DDDs cruzam a divisa de um estado, e para esses o `stateCodes` lista também os demais. O DDD 61 é o mais amplo deles: atende o Distrito Federal e os doze municípios goianos do Entorno do Distrito Federal (Águas Lindas de Goiás, Cabeceiras, Cidade Ocidental, Cristalina, Formosa, Luziânia, Novo Gama, Padre Bernardo, Planaltina, Santo Antônio do Descoberto, Valparaíso de Goiás e Vila Boa). Os outros três são o 42, compartilhado entre o Paraná e Porto União (SC), o 47, entre Santa Catarina e Rio Negro (PR), e o 49, entre Santa Catarina e Barracão (PR). ```javascript import { getAreaCodeInfo } from '@brazilian-utils/brazilian-utils'; getAreaCodeInfo('11'); -// { areaCode: 11, stateCode: 'SP', stateName: 'São Paulo', region: 'Sudeste' } +// { areaCode: 11, stateCode: 'SP', stateName: 'São Paulo', region: 'Sudeste', stateCodes: ['SP'] } getAreaCodeInfo(21); -// { areaCode: 21, stateCode: 'RJ', stateName: 'Rio de Janeiro', region: 'Sudeste' } +// { areaCode: 21, stateCode: 'RJ', stateName: 'Rio de Janeiro', region: 'Sudeste', stateCodes: ['RJ'] } -getAreaCodeInfo('68'); -// { areaCode: 68, stateCode: 'AC', stateName: 'Acre', region: 'Norte' } +getAreaCodeInfo('61'); +// { areaCode: 61, stateCode: 'DF', stateName: 'Distrito Federal', region: 'Centro-Oeste', stateCodes: ['DF', 'GO'] } getAreaCodeInfo('00'); // null +getAreaCodeInfo(-11); // null +getAreaCodeInfo(1.1); // null ``` ## getAreaCodesByState -Retorna todos os DDDs (códigos de área) que pertencem a um determinado estado brasileiro, dentro do Plano Geral de Numeração da Anatel. A comparação não diferencia maiúsculas de minúsculas e o resultado vem ordenado de forma crescente. +Retorna todos os DDDs (códigos de área) que atendem um determinado estado brasileiro, dentro do Plano Geral de Numeração da Anatel. A comparação não diferencia maiúsculas de minúsculas e o resultado vem ordenado de forma crescente. + +Um DDD que cruza a divisa de um estado aparece em todos os estados que atende, então o DDD 61 volta tanto para `'DF'` quanto para `'GO'`: ele atende o Distrito Federal e os doze municípios goianos do Entorno do Distrito Federal. Os outros três são o 42, compartilhado entre o Paraná e Porto União (SC), o 47, entre Santa Catarina e Rio Negro (PR), e o 49, entre Santa Catarina e Barracão (PR). ```javascript import { getAreaCodesByState } from '@brazilian-utils/brazilian-utils'; getAreaCodesByState('SP'); // [11, 12, 13, 14, 15, 16, 17, 18, 19] getAreaCodesByState('ac'); // [68] +getAreaCodesByState('DF'); // [61] +getAreaCodesByState('GO'); // [61, 62, 64] +getAreaCodesByState('SC'); // [42, 47, 48, 49] getAreaCodesByState('XX'); // [] ``` @@ -713,7 +722,7 @@ getBankByIspb('99999999'); // null ## isValidIban -Valida se um IBAN (International Bank Account Number) brasileiro é válido, conforme as [Diretrizes de Implementação do IBAN no Brasil](https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf) do Bacen (Circular BCB nº 3.625/2013): `BR` + 2 dígitos verificadores ISO 7064 MOD 97-10 + 8 dígitos de ISPB + 5 dígitos de agência + 10 dígitos de conta + 1 letra de tipo de conta (qualquer letra, normalmente `C` para conta corrente ou `P` para conta poupança) + 1 caractere alfanumérico de titularidade, totalizando 29 caracteres. Somente IBANs brasileiros (código de país `BR`) são reconhecidos; qualquer outro país retorna `false`, já que este pacote não conhece o layout de campos dos outros mais de 90 países da ISO 13616. Aceita os espaços de agrupamento usuais e não diferencia maiúsculas de minúsculas. +Valida se um IBAN (International Bank Account Number) brasileiro é válido, conforme as [Diretrizes de Implementação do IBAN no Brasil](https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf) do Bacen (Circular BCB nº 3.625/2013): `BR` + 2 dígitos verificadores ISO 7064 MOD 97-10 + 8 dígitos de ISPB + 5 dígitos de agência + 10 dígitos de conta + 1 letra de tipo de conta (qualquer letra, normalmente `C` para conta corrente ou `P` para conta poupança) + 1 caractere alfanumérico de titularidade, totalizando 29 caracteres. Somente IBANs brasileiros (código de país `BR`) são reconhecidos; qualquer outro país retorna `false`, já que este pacote não conhece o layout de campos dos outros mais de 90 países da ISO 13616. Aceita os espaços de agrupamento usuais e não diferencia maiúsculas de minúsculas. O valor precisa estar escrito no formato impresso da ISO 13616: letras e dígitos em grupos separados por um único espaço, com espaços em branco opcionais no início e no fim. Qualquer outro caractere faz do valor algo que não é um IBAN, então ele é rejeitado em vez de removido. ```javascript import { isValidIban } from '@brazilian-utils/brazilian-utils'; @@ -721,12 +730,13 @@ import { isValidIban } from '@brazilian-utils/brazilian-utils'; isValidIban('BR1500000000000010932840814P2'); // true isValidIban('BR15 0000 0000 0000 1093 2840 814P 2'); // true (espaços de agrupamento) isValidIban('BR1500000000000010932840814P3'); // false (dígitos verificadores inválidos) +isValidIban('BR1500000000000010932840814P-2'); // false (hífen não faz parte de um IBAN) isValidIban('DE89370400440532013000'); // false (IBAN não brasileiro) ``` ## formatIban -Formata um IBAN brasileiro agrupando-o em blocos de 4 caracteres, a apresentação "impressa" da ISO 13616 usada em extratos e formulários bancários. Não valida os dígitos verificadores nem o layout dos campos; formata o que for passado, até o limite de 29 caracteres de um IBAN brasileiro, até onde for possível, então a função também pode ser usada como máscara de digitação. Use `isValidIban` para verificar a validade. +Formata um IBAN brasileiro agrupando-o em blocos de 4 caracteres, a apresentação "impressa" da ISO 13616 usada em extratos e formulários bancários. Não valida os dígitos verificadores nem o layout dos campos; formata o que for passado, até o limite de 29 caracteres de um IBAN brasileiro, até onde for possível, então a função também pode ser usada como máscara de digitação. Use `isValidIban` para verificar a validade. O valor ainda precisa estar escrito no formato impresso da ISO 13616 (letras e dígitos em grupos separados por um único espaço, com espaços em branco opcionais no início e no fim); qualquer outro caractere resulta em uma string vazia, em vez de ser descartado silenciosamente. ```javascript import { formatIban } from '@brazilian-utils/brazilian-utils'; @@ -734,11 +744,12 @@ import { formatIban } from '@brazilian-utils/brazilian-utils'; formatIban('BR1500000000000010932840814P2'); // 'BR15 0000 0000 0000 1093 2840 814P 2' formatIban('br1500000000000010932840814p2'); // 'BR15 0000 0000 0000 1093 2840 814P 2' formatIban('BR15'); // 'BR15' +formatIban('BR1500000000000010932840814P-2'); // '' (hífen não faz parte de um IBAN) ``` ## parseIban -Interpreta um IBAN brasileiro em seus campos: 2 (código do país, sempre `BR`) + 2 (dígitos verificadores ISO 7064 MOD 97-10) + 8 (ISPB) + 5 (agência) + 10 (conta) + 1 (tipo de conta, qualquer letra, normalmente `C` para conta corrente ou `P` para conta poupança) + 1 (indicador do titular). Aceita as mesmas formas de entrada que `isValidIban` (espaços de agrupamento, minúsculas) e retorna `null` sempre que `isValidIban` retornaria `false`. O resultado é tipado como `Iban`, cujo `accountType` é uma `string`. +Interpreta um IBAN brasileiro em seus campos: 2 (código do país, sempre `BR`) + 2 (dígitos verificadores ISO 7064 MOD 97-10) + 8 (ISPB) + 5 (agência) + 10 (conta) + 1 (tipo de conta, qualquer letra, normalmente `C` para conta corrente ou `P` para conta poupança) + 1 (indicador do titular). Aceita as mesmas formas de entrada que `isValidIban` (espaços de agrupamento, minúsculas) e retorna `null` sempre que `isValidIban` retornaria `false`, inclusive quando o valor carrega qualquer caractere além de letras, dígitos e os espaços de agrupamento do formato impresso. O resultado é tipado como `Iban`, cujo `accountType` é uma `string`. ```javascript import { parseIban } from '@brazilian-utils/brazilian-utils'; @@ -755,11 +766,12 @@ parseIban('BR1500000000000010932840814P2'); // } parseIban('DE89370400440532013000'); // null (IBAN não brasileiro) +parseIban('BR1500000000000010932840814P-2'); // null (hífen não faz parte de um IBAN) ``` ## isValidCreditCard -Valida se um número de cartão de pagamento é válido usando o algoritmo de Luhn ([ISO/IEC 7812-1](https://www.iso.org/standard/70484.html)). Aceita os caracteres de máscara usuais (espaços, hifens) entre os dígitos. Não faz detecção de bandeira (Visa, Mastercard, Amex...), consulta de faixa de emissor nem validação de validade/CVV, verifica apenas a quantidade de dígitos (12 a 19) e o dígito verificador de Luhn. +Valida se um número de cartão de pagamento é válido usando o algoritmo de Luhn ([ISO/IEC 7812-1](https://www.iso.org/standard/70484.html)). Aceita os caracteres de máscara usuais (espaços, hifens) entre os dígitos. Não faz detecção de bandeira (Visa, Mastercard, Amex...), consulta de faixa de emissor nem validação de validade/CVV, verifica apenas a quantidade de dígitos (12 a 19) e o dígito verificador de Luhn. Um `number` só é aceito quando é um inteiro seguro não negativo: qualquer valor acima de `Number.MAX_SAFE_INTEGER` (2^53 - 1, 16 dígitos) já chega arredondado para outro número, então passe cartões mais longos como string. ```javascript import { isValidCreditCard } from '@brazilian-utils/brazilian-utils'; @@ -769,6 +781,7 @@ isValidCreditCard('5555555555554444'); // true (número de teste Mastercard) isValidCreditCard('378282246310005'); // true (número de teste American Express) isValidCreditCard('4111 1111 1111 1111'); // true (máscara com espaços) isValidCreditCard('4111111111111112'); // false (dígito verificador inválido) +isValidCreditCard(4111111111111111111); // false (acima de 2^53 - 1, passe como string) ``` ## capitalize @@ -859,7 +872,7 @@ convertCurrencyToWords(1000, { case: 'upper' }); // "MIL REAIS" ## getStates -Retorna todos os estados brasileiros, cada um com sigla, nome, código da região, nome da região e código IBGE de 2 dígitos da Unidade da Federação (`cUF`). A lista é ordenada por nome com `localeCompare` no locale "pt-BR", então nomes acentuados caem onde um leitor brasileiro espera: Pará, Paraíba, Paraná e Rio de Janeiro, Rio Grande do Norte, Rio Grande do Sul. Cada chamada retorna um array novo com objetos novos, então alterar o resultado nunca afeta chamadas seguintes. Exporta os tipos `State`, `StateCode` e `StateName`. +Retorna todos os estados brasileiros, cada um com sigla, nome, código da região, nome da região e código IBGE de 2 dígitos da Unidade da Federação (`cUF`). A lista é ordenada por nome com `localeCompare` no locale "pt-BR", então nomes acentuados caem onde um leitor brasileiro espera: Pará, Paraíba, Paraná e Rio de Janeiro, Rio Grande do Norte, Rio Grande do Sul. Cada chamada retorna um array novo com objetos novos, então alterar o resultado nunca afeta chamadas seguintes. Exporta os tipos `State`, `StateCode` e `StateName`. `State` é uma união discriminada com um membro por estado, então os campos de um estado ficam amarrados entre si: estreitar um `State` pelo `code` também estreita `name`, `regionCode`, `regionName` e `ibgeCode` (`Extract['name']` é `'São Paulo'`), e uma combinação impossível como `{ code: 'SP', name: 'Acre' }` não é um `State`. ```javascript import { getStates } from '@brazilian-utils/brazilian-utils'; @@ -898,7 +911,7 @@ getStates(); ## getStateByIbgeCode -Retorna o estado brasileiro cujo código IBGE de 2 dígitos ("cUF", Código da Unidade da Federação) corresponde ao valor informado. É o mesmo código de UF de 2 dígitos presente no primeiro campo de toda chave de acesso de DF-e (NF-e, NFC-e, CT-e e MDF-e). Aceita string ou número, removendo caracteres não numéricos antes de comparar. Exporta o tipo `State`. +Retorna o estado brasileiro cujo código IBGE de 2 dígitos ("cUF", Código da Unidade da Federação) corresponde ao valor informado. É o mesmo código de UF de 2 dígitos presente no primeiro campo de toda chave de acesso de DF-e (NF-e, NFC-e, CT-e e MDF-e). Aceita string ou número inteiro não negativo, removendo caracteres não numéricos antes de comparar. Exporta o tipo `State`. ```javascript import { getStateByIbgeCode } from '@brazilian-utils/brazilian-utils'; @@ -910,6 +923,8 @@ getStateByIbgeCode(11); // { code: 'RO', name: 'Rondônia', regionCode: 'N', regionName: 'Norte', ibgeCode: 11 } getStateByIbgeCode('00'); // null +getStateByIbgeCode(-35); // null +getStateByIbgeCode(3.5); // null ``` ## getStateCodeByName @@ -1299,7 +1314,7 @@ generatePis(); // '91077906857' ## getMunicipality -Busca informações de município por código IBGE, ou obtém o código IBGE a partir do nome do município e UF. Uma única função cobre as duas direções, dependendo se `options` tem `code` ou `municipalityName`/`uf`. `code` aceita tanto `string` quanto `number` e deve ter exatamente 7 dígitos, caso contrário a função resolve para `null`. A resolução é totalmente offline, a partir de um dataset do IBGE embutido na biblioteca: nenhuma requisição de rede é feita. A comparação do nome do município ignora acentos e diferenças entre maiúsculas/minúsculas. Um município desconhecido, uma UF desconhecida ou uma entrada inválida resolvem para `null`. +Busca informações de município por código IBGE, ou obtém o código IBGE a partir do nome do município e UF. Uma única função cobre as duas direções, dependendo se `options` tem `code` ou `municipalityName`/`uf`. `code` aceita tanto `string` quanto `number` e deve ter exatamente 7 dígitos, caso contrário a função resolve para `null`. Um `code` informado como número precisa ser um inteiro não negativo: sinal e ponto decimal não são dígitos, então `-3550308` e `355030.8` resolvem para `null` em vez de serem lidos como `3550308`. A resolução é totalmente offline, a partir de um dataset do IBGE embutido na biblioteca: nenhuma requisição de rede é feita. A comparação do nome do município ignora acentos e diferenças entre maiúsculas/minúsculas. Um município desconhecido, uma UF desconhecida ou uma entrada inválida resolvem para `null`. ```javascript import { getMunicipality } from '@brazilian-utils/brazilian-utils'; @@ -1356,7 +1371,7 @@ getMunicipalities('ZZ'); // [] ## getMunicipalityByCode -Busca um município brasileiro pelo código IBGE de 7 dígitos. Aceita o código como string ou número, removendo qualquer caractere não numérico antes de comparar. Retorna `{ code, name, stateCode }`, um objeto novo, ou `null` quando o código não tem 7 dígitos ou não corresponde a nenhum município conhecido. +Busca um município brasileiro pelo código IBGE de 7 dígitos. Aceita o código como string ou número, removendo qualquer caractere não numérico antes de comparar; um código informado como número precisa ser um inteiro não negativo, então `-3550308` e `355030.8` retornam `null` em vez de serem lidos como `3550308`. Retorna `{ code, name, stateCode }`, um objeto novo, ou `null` quando o código não tem 7 dígitos ou não corresponde a nenhum município conhecido. ```javascript import { getMunicipalityByCode } from '@brazilian-utils/brazilian-utils'; @@ -1526,7 +1541,7 @@ formatCns('89010001', { pad: true }); // '000 0000 8901 0001' Verifica se a matrícula de uma certidão de registro civil (nascimento, casamento, óbito e os demais atos mantidos por uma serventia de registro civil das pessoas naturais) é válida. A matrícula tem 32 dígitos distribuídos em 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), e os dois dígitos verificadores usam módulo 11 com pesos ciclando de 2 a 10 e voltando por 0. Aceita os caracteres de máscara usuais e espaços entre e ao redor dos grupos. O layout é o em vigor do [art. 473 do Código Nacional de Normas da Corregedoria Nacional de Justiça](https://atos.cnj.jus.br/atos/detalhar/5243) (Provimento CNJ nº 149/2023, na redação do Provimento CN nº 182/2024); a própria matrícula foi instituída pelo já revogado [Provimento CNJ nº 2/2009](https://atos.cnj.jus.br/atos/detalhar/1311). Os dígitos verificadores estão detalhados em [ghiorzi.org](http://ghiorzi.org/DVnew.htm) e implementado pelo [validation-br](https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts) e pelo [validator-docs](https://github.com/geekcom/validator-docs/blob/master/src/validator-docs/Rules/Certidao.php). -O dígito do tipo de livro sempre precisa nomear um dos nove tipos de livro (o mesmo `CertidaoType` retornado por `parseCertidao`), então uma matrícula cujo dígito é `0` é rejeitada por mais que os dígitos verificadores confiram, do mesmo jeito que `parseCertidao` devolve `null` para ela. `options.accept` (parte de `IsValidCertidaoOptions`) restringe ainda mais aos tipos listados; o padrão é aceitar todos os tipos, e um valor que não seja um array volta para esse padrão. Só uma string é aceita: os 32 dígitos de uma matrícula são mais do que um número JavaScript comporta. +Os dígitos do serviço são fixos em `55`, o código que o [art. 473, III](https://atos.cnj.jus.br/atos/detalhar/5243) atribui ao registro civil das pessoas naturais, então uma matrícula com qualquer outro par na nona e décima posições é rejeitada por mais que os dígitos verificadores confiram. O dígito do tipo de livro sempre precisa nomear um dos nove tipos de livro (o mesmo `CertidaoType` retornado por `parseCertidao`), então uma matrícula cujo dígito é `0` é rejeitada por mais que os dígitos verificadores confiram, do mesmo jeito que `parseCertidao` devolve `null` para ela. `options.accept` (parte de `IsValidCertidaoOptions`) restringe ainda mais aos tipos listados; o padrão é aceitar todos os tipos, e um valor que não seja um array volta para esse padrão. Só uma string é aceita: os 32 dígitos de uma matrícula são mais do que um número JavaScript comporta. ```javascript import { isValidCertidao } from '@brazilian-utils/brazilian-utils'; @@ -1534,6 +1549,7 @@ import { isValidCertidao } from '@brazilian-utils/brazilian-utils'; 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 (dígitos verificadores inválidos) +isValidCertidao('09400301542011100110002005191744'); // false (serviço diferente de 55) isValidCertidao('123456'); // false (tamanho inválido) 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 @@ -1569,7 +1585,7 @@ O resultado `Certidao` traz: | --- | --- | | `registryCns` | O CNS (Código Nacional de Serventia) de 6 dígitos da serventia que lavrou o ato. | | `acervo` | Acervo a que o livro pertence: `"01"` acervo próprio, `"02"` acervo incorporado. | -| `service` | Serviço prestado pela serventia, `"55"` para registro civil das pessoas naturais. | +| `service` | Serviço prestado pela serventia, sempre `"55"`, o registro civil das pessoas naturais. | | `year` | Ano do registro, com 4 dígitos. | | `type` | Livro a que o ato pertence: `"birth"`, `"marriage"`, `"religious-marriage"`, `"death"`, `"stillbirth"`, `"banns"`, `"other"`, `"emancipation"` ou `"interdiction"`. | | `typeCode` | Código bruto do livro, de 1 a 9, como impresso na décima quinta posição da matrícula. | @@ -1696,7 +1712,7 @@ isValidVin('1HGCM8263IA004352'); // false (contém a letra excluída I) ## isValidCbo -Valida se um código CBO (Classificação Brasileira de Ocupações) existe na tabela de ocupações do MTE. Aceita o código com ou sem a máscara de hífen, ou como número. +Valida se um código CBO (Classificação Brasileira de Ocupações) existe na tabela de ocupações do MTE. Aceita o código com ou sem a máscara de hífen, ou como número. Uma string só é lida como código quando está escrita em uma dessas formas (os 6 dígitos, ou a máscara `NNNN-NN`, com os separadores usuais entre os grupos e espaços em branco opcionais no início e no fim), e um número só quando é um inteiro seguro não negativo. ```javascript import { isValidCbo } from '@brazilian-utils/brazilian-utils'; @@ -1705,26 +1721,29 @@ isValidCbo('2124-05'); // true isValidCbo('212405'); // true isValidCbo(212405); // true isValidCbo('000000'); // false +isValidCbo('2124abc05'); // false (não é uma forma documentada) +isValidCbo(-212405); // false (não é um inteiro seguro não negativo) ``` Os títulos das ocupações vêm das [tabelas oficiais da CBO 2002 publicadas pelo MTE](http://www.mtecbo.gov.br/cbosite/pages/downloads.jsf). ## getCbo -Consulta um código CBO (Classificação Brasileira de Ocupações) e retorna o título oficial da ocupação. Um `number` mantém os zeros à esquerda implícitos: `getCbo(10205)` é lido como `010205`. +Consulta um código CBO (Classificação Brasileira de Ocupações) e retorna o título oficial da ocupação. Um `number` mantém os zeros à esquerda implícitos: `getCbo(10205)` é lido como `010205`. Valem as mesmas regras de entrada de `isValidCbo`: uma string precisa estar escrita com os 6 dígitos ou com a máscara `NNNN-NN`, e um número precisa ser um inteiro seguro não negativo. ```javascript import { getCbo } from '@brazilian-utils/brazilian-utils'; getCbo('2124-05'); // { code: '212405', title: 'Analista de desenvolvimento de sistemas' } getCbo('000000'); // null +getCbo('2124abc05'); // null (não é uma forma documentada) ``` Os títulos das ocupações vêm das [tabelas oficiais da CBO 2002 publicadas pelo MTE](http://www.mtecbo.gov.br/cbosite/pages/downloads.jsf). ## isValidCnae -Valida se um código de subclasse CNAE (Classificação Nacional de Atividades Econômicas) existe na tabela CNAE 2.3 publicada pelo IBGE. Aceita o código com ou sem a máscara `NNNN-N/NN`, ou como número. +Valida se um código de subclasse CNAE (Classificação Nacional de Atividades Econômicas) existe na tabela CNAE 2.3 publicada pelo IBGE. Aceita o código com ou sem a máscara `NNNN-N/NN`, ou como número. Uma string só é lida como código quando está escrita em uma dessas formas (os 7 dígitos, ou a máscara, com os separadores usuais entre os grupos e espaços em branco opcionais no início e no fim), e um número só quando é um inteiro seguro não negativo. ```javascript import { isValidCnae } from '@brazilian-utils/brazilian-utils'; @@ -1732,6 +1751,8 @@ import { isValidCnae } from '@brazilian-utils/brazilian-utils'; isValidCnae('6201-5/01'); // true isValidCnae('6201501'); // true isValidCnae('0000000'); // false +isValidCnae('0111abc301'); // false (não é uma forma documentada) +isValidCnae(-111301); // false (não é um inteiro seguro não negativo) ``` ## formatCnae @@ -1746,13 +1767,14 @@ formatCnae('6201501'); // 6201-5/01 ## getCnae -Busca um código de subclasse CNAE (Classificação Nacional de Atividades Econômicas) e retorna seu código formatado e a descrição oficial. Um `number` mantém os zeros à esquerda implícitos: `getCnae(111301)` é lido como `0111301`. +Busca um código de subclasse CNAE (Classificação Nacional de Atividades Econômicas) e retorna seu código formatado e a descrição oficial. Um `number` mantém os zeros à esquerda implícitos: `getCnae(111301)` é lido como `0111301`. Valem as mesmas regras de entrada de `isValidCnae`: uma string precisa estar escrita com os 7 dígitos ou com a máscara `NNNN-N/NN`, e um número precisa ser um inteiro seguro não negativo. ```javascript import { getCnae } from '@brazilian-utils/brazilian-utils'; getCnae('6201501'); // { code: '6201-5/01', description: 'DESENVOLVIMENTO DE PROGRAMAS DE COMPUTADOR SOB ENCOMENDA' } getCnae('0000000'); // null +getCnae('0111abc301'); // null (não é uma forma documentada) ``` ## isValidNcm @@ -1779,24 +1801,26 @@ formatNcm('84713012'); // 8471.30.12 ## isValidCfop -Valida se um código CFOP (Código Fiscal de Operações e Prestações) existe na tabela oficial (Ajuste SINIEF 07/2001 e atualizações). +Valida se um código CFOP (Código Fiscal de Operações e Prestações) existe na tabela oficial (Ajuste SINIEF 07/2001 e atualizações). Só os códigos operáveis contam: os títulos de grupo e subgrupo da nomenclatura oficial, os códigos terminados em `00` e `50` (1000, 1100, 1150, 5350, ...), são títulos de seção e não códigos que um documento pode carregar, então são rejeitados. ```javascript import { isValidCfop } from '@brazilian-utils/brazilian-utils'; isValidCfop('5102'); // true isValidCfop('0000'); // false +isValidCfop('1150'); // false (título de subgrupo, não é um código operável) ``` ## getCfop -Busca um código CFOP (Código Fiscal de Operações e Prestações) e retorna seu código e a descrição oficial. +Busca um código CFOP (Código Fiscal de Operações e Prestações) e retorna seu código e a descrição oficial. Os títulos de grupo e subgrupo da nomenclatura oficial, os códigos terminados em `00` e `50`, não estão na tabela e retornam `null`. ```javascript import { getCfop } from '@brazilian-utils/brazilian-utils'; getCfop('5102'); // { code: '5102', description: 'Venda de mercadoria adquirida ou recebida de terceiros' } getCfop('0000'); // null +getCfop('5350'); // null (título de subgrupo, não é um código operável) ``` ## isValidCst diff --git a/docs/utilities.md b/docs/utilities.md index 6e8d9c76..44d56ce7 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -192,7 +192,7 @@ isValidPixKey('not a key'); // false ## parsePixKey -Identifies a Pix key and normalizes it to the canonical form the DICT expects inside a BR Code: 11 digit CPF, 14 character CNPJ, lowercased e-mail, E.164 mobile phone (a landline is not a Pix key) or lowercase UUID EVP. An 11 digit value that is valid both as a CPF and as a mobile phone is read as a CPF, unless it was written as a phone number (a `+55`/`0055` prefix or a DDD wrapped in parentheses). Returns `null` when the value is not a valid Pix key. The result is typed as `PixKey`. +Identifies a Pix key and normalizes it to the canonical form the DICT expects inside a BR Code: 11 digit CPF, 14 character CNPJ, lowercased e-mail, E.164 mobile phone (a landline is not a Pix key) or lowercase UUID EVP. An 11 digit value that is valid both as a CPF and as a mobile phone is read as a CPF, unless it was written as a phone number (a `+55`/`0055` prefix or a DDD wrapped in parentheses). The CPF and the phone number are recognized by the way they are written, not only by the digits they carry, so surrounding text is not stripped away and `'abc123.456.789-09'` is not a CPF key. Returns `null` when the value is not a valid Pix key. The result is typed as `PixKey`. ```javascript import { parsePixKey } from '@brazilian-utils/brazilian-utils'; @@ -209,7 +209,7 @@ parsePixKey('+5551998259765'); // { type: 'phone', value: '+5551998259765' } ## isValidPixPayload -Check if a Pix BR Code payload (the string behind a Pix QR Code and behind "Pix copia e cola") is valid: well-formed TLV structure, the mandatory objects present, one of the "Merchant Account Information" templates carrying the `br.gov.bcb.pix` GUI with a key or a URL, and a matching CRC-16. The key itself is not checked against the DICT formats, use `isValidPixKey` for that. Payloads that carry the location in an Unreserved Template (IDs 80 to 99), as the "QR Code composto" of Pix Automático (Pix recorrente) does, are out of scope and reported as invalid. +Check if a Pix BR Code payload (the string behind a Pix QR Code and behind "Pix copia e cola") is valid: well-formed TLV structure, the mandatory objects present, one of the "Merchant Account Information" templates carrying the `br.gov.bcb.pix` GUI with a key or a URL, a "Point of Initiation Method" object (`01`) that agrees with it (a key requires a static payload, so `01` is absent or `"11"`; a URL requires a dynamic one, so `01` is `"12"`), an amount (`54`) greater than zero in a static payload, and a matching CRC-16. The key itself is not checked against the DICT formats, use `isValidPixKey` for that. Payloads that carry the location in an Unreserved Template (IDs 80 to 99), as the "QR Code composto" of Pix Automático (Pix recorrente) does, are out of scope and reported as invalid. ```javascript import { isValidPixPayload } from '@brazilian-utils/brazilian-utils'; @@ -224,7 +224,7 @@ isValidPixPayload('00020126580014br.gov.bcb.pix...'); // false (broken CRC) ## parsePixPayload -Parses a Pix BR Code payload into its fields. The payload is validated by `isValidPixPayload` first, so a malformed structure, a broken CRC or a missing mandatory object returns `null` instead of a partial result. A static payload comes back with `key`, a dynamic one with `url`. The result is typed as `PixPayload`; `pointOfInitiation` is typed as `PixPointOfInitiation` (`"static"` or `"dynamic"`). The merchant account information must carry exactly one of a key or a `url` (checked with the same PSP location rule as `generatePixPayload`), and in a dynamic payload the amount and the `txid` are ignored, as the manual mandates. Payloads whose location lives in an Unreserved Template (IDs 80 to 99, Pix Automático) are out of scope and return `null`. +Parses a Pix BR Code payload into its fields. The payload is validated by `isValidPixPayload` first, so a malformed structure, a broken CRC or a missing mandatory object returns `null` instead of a partial result. A static payload comes back with `key`, a dynamic one with `url`. The result is typed as `PixPayload`; `pointOfInitiation` is typed as `PixPointOfInitiation` (`"static"` or `"dynamic"`). The merchant account information must carry exactly one of a key or a `url` (checked with the same PSP location rule as `generatePixPayload`), and the "Point of Initiation Method" object (`01`) must agree with it: a key belongs to a static payload (`01` absent or `"11"`) and a `url` to a dynamic one (`01` set to `"12"`), so any other pairing returns `null`. A static payload that states an amount must state one greater than zero (`54` set to `0.00` is reserved for the Pix Saque/Troco BR Code, which is out of scope), and in a dynamic payload the amount and the `txid` are ignored, as the manual mandates. Payloads whose location lives in an Unreserved Template (IDs 80 to 99, Pix Automático) are out of scope and return `null`. ```javascript import { parsePixPayload } from '@brazilian-utils/brazilian-utils'; @@ -269,7 +269,7 @@ generatePixPayload({ merchantName: 'Fulano', merchantCity: 'Brasília' }); // nu ## isValidNfeKey -Check if a DF-e (Documento Fiscal eletrônico) access key (chave de acesso) is valid. It 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. The emission type (`tpEmis`) must be one of the codes the MOC assigns, 1 to 7 or 9; 8 is not assigned and makes the key invalid. +Check if a DF-e (Documento Fiscal eletrônico) access key (chave de acesso) is valid. It covers every document that shares the same 44 digit layout: NF-e (modelo 55), NFC-e (modelo 65), CT-e (modelo 57), MDF-e (modelo 58) and CT-e OS (modelo 67, the Conhecimento de Transporte Eletrônico para Outros Serviços of the [Ajuste SINIEF 09/07](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2007/aj_009_07)). Accepts whitespace between digit groups (the common display mask) and the `NFe` prefix found in the `Id` attribute of the document's XML. The emission type (`tpEmis`) must be one of the codes the MOC assigns, 1 to 7 or 9; 8 is not assigned and makes the key invalid. ```javascript import { isValidNfeKey } from '@brazilian-utils/brazilian-utils'; @@ -283,7 +283,7 @@ isValidNfeKey('35170458716523000119550010000000128000123455'); // false (tpEmis ## formatNfeKey -Format a DF-e (NF-e, NFC-e, CT-e or MDF-e) access key into groups of 4 digits separated by spaces, the common display form printed on the DANFE. +Format a DF-e (NF-e, NFC-e, CT-e, MDF-e or CT-e OS) access key into groups of 4 digits separated by spaces, the common display form printed on the DANFE. ```javascript import { formatNfeKey } from '@brazilian-utils/brazilian-utils'; @@ -398,32 +398,41 @@ isValidServicePhone('11987654321'); // false (geographic number) ## getAreaCodeInfo -Get the state (and its region) a Brazilian DDD (area code) belongs to, out of the 67 DDDs in use under the Anatel Plano Geral de Numeração. Accepts a string or a number, stripping any non-digit characters before matching. Exports the `AreaCodeInfo` type. +Get the state (and its region) a Brazilian DDD (area code) belongs to, out of the 67 DDDs in use under the Anatel Plano Geral de Numeração. Accepts a string or a non-negative integer number, stripping any non-digit characters before matching. Exports the `AreaCodeInfo` type. + +`stateCode` is always a single state: the one that holds all but a handful of the DDD's municipalities. Four DDDs straddle a state border, and for those `stateCodes` lists the other states too. DDD 61 is the widest of them, serving the Distrito Federal and the twelve Goiás municipalities of the Entorno do Distrito Federal (Águas Lindas de Goiás, Cabeceiras, Cidade Ocidental, Cristalina, Formosa, Luziânia, Novo Gama, Padre Bernardo, Planaltina, Santo Antônio do Descoberto, Valparaíso de Goiás and Vila Boa). The other three are 42, shared by Paraná and Porto União (SC), 47, shared by Santa Catarina and Rio Negro (PR), and 49, shared by Santa Catarina and Barracão (PR). ```javascript import { getAreaCodeInfo } from '@brazilian-utils/brazilian-utils'; getAreaCodeInfo('11'); -// { areaCode: 11, stateCode: 'SP', stateName: 'São Paulo', region: 'Sudeste' } +// { areaCode: 11, stateCode: 'SP', stateName: 'São Paulo', region: 'Sudeste', stateCodes: ['SP'] } getAreaCodeInfo(21); -// { areaCode: 21, stateCode: 'RJ', stateName: 'Rio de Janeiro', region: 'Sudeste' } +// { areaCode: 21, stateCode: 'RJ', stateName: 'Rio de Janeiro', region: 'Sudeste', stateCodes: ['RJ'] } -getAreaCodeInfo('68'); -// { areaCode: 68, stateCode: 'AC', stateName: 'Acre', region: 'Norte' } +getAreaCodeInfo('61'); +// { areaCode: 61, stateCode: 'DF', stateName: 'Distrito Federal', region: 'Centro-Oeste', stateCodes: ['DF', 'GO'] } getAreaCodeInfo('00'); // null +getAreaCodeInfo(-11); // null +getAreaCodeInfo(1.1); // null ``` ## getAreaCodesByState -Get every DDD (area code) that belongs to a given Brazilian state, under the Anatel Plano Geral de Numeração. The match is case-insensitive and the result is sorted in ascending order. +Get every DDD (area code) that serves a given Brazilian state, under the Anatel Plano Geral de Numeração. The match is case-insensitive and the result is sorted in ascending order. + +A DDD that straddles a state border is listed under every state it serves, so DDD 61 comes back for both `'DF'` and `'GO'`: it serves the Distrito Federal and the twelve Goiás municipalities of the Entorno do Distrito Federal. The other three are 42, shared by Paraná and Porto União (SC), 47, shared by Santa Catarina and Rio Negro (PR), and 49, shared by Santa Catarina and Barracão (PR). ```javascript import { getAreaCodesByState } from '@brazilian-utils/brazilian-utils'; getAreaCodesByState('SP'); // [11, 12, 13, 14, 15, 16, 17, 18, 19] getAreaCodesByState('ac'); // [68] +getAreaCodesByState('DF'); // [61] +getAreaCodesByState('GO'); // [61, 62, 64] +getAreaCodesByState('SC'); // [42, 47, 48, 49] getAreaCodesByState('XX'); // [] ``` @@ -713,7 +722,7 @@ getBankByIspb('99999999'); // null ## isValidIban -Check if a Brazilian IBAN (International Bank Account Number) is valid, per Bacen's [Diretrizes de Implementação do IBAN no Brasil](https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf) (Circular BCB nº 3.625/2013): `BR` + 2 ISO 7064 MOD 97-10 check digits + 8 digit ISPB + 5 digit branch + 10 digit account + 1 letter account type (any letter, usually `C` for conta corrente or `P` for conta poupança) + 1 alphanumeric owner indicator, 29 characters total. Only Brazilian IBANs (country code `BR`) are recognized; any other country returns `false`, since this package does not carry the field layout of the other 90+ ISO 13616 countries. Accepts the usual grouping spaces and is case-insensitive. +Check if a Brazilian IBAN (International Bank Account Number) is valid, per Bacen's [Diretrizes de Implementação do IBAN no Brasil](https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf) (Circular BCB nº 3.625/2013): `BR` + 2 ISO 7064 MOD 97-10 check digits + 8 digit ISPB + 5 digit branch + 10 digit account + 1 letter account type (any letter, usually `C` for conta corrente or `P` for conta poupança) + 1 alphanumeric owner indicator, 29 characters total. Only Brazilian IBANs (country code `BR`) are recognized; any other country returns `false`, since this package does not carry the field layout of the other 90+ ISO 13616 countries. Accepts the usual grouping spaces and is case-insensitive. The value has to be written in the ISO 13616 print format: letters and digits in groups separated by a single space, with optional surrounding whitespace. Any other character makes the value something other than an IBAN, so it is rejected instead of being stripped. ```javascript import { isValidIban } from '@brazilian-utils/brazilian-utils'; @@ -721,12 +730,13 @@ import { isValidIban } from '@brazilian-utils/brazilian-utils'; isValidIban('BR1500000000000010932840814P2'); // true isValidIban('BR15 0000 0000 0000 1093 2840 814P 2'); // true (grouping spaces) isValidIban('BR1500000000000010932840814P3'); // false (bad check digits) +isValidIban('BR1500000000000010932840814P-2'); // false (hyphens are not part of an IBAN) isValidIban('DE89370400440532013000'); // false (non Brazilian IBAN) ``` ## formatIban -Format a Brazilian IBAN by grouping it in blocks of 4 characters, the ISO 13616 "print" presentation used on statements and bank forms. Does not validate the check digits or the field layout; formats whatever is given, up to the 29 character length of a Brazilian IBAN, as far as it goes, so the function can also be used as an input mask. Use `isValidIban` to check validity. +Format a Brazilian IBAN by grouping it in blocks of 4 characters, the ISO 13616 "print" presentation used on statements and bank forms. Does not validate the check digits or the field layout; formats whatever is given, up to the 29 character length of a Brazilian IBAN, as far as it goes, so the function can also be used as an input mask. Use `isValidIban` to check validity. The value still has to be written in the ISO 13616 print format (letters and digits in groups separated by a single space, with optional surrounding whitespace); any other character returns an empty string instead of being quietly dropped. ```javascript import { formatIban } from '@brazilian-utils/brazilian-utils'; @@ -734,11 +744,12 @@ import { formatIban } from '@brazilian-utils/brazilian-utils'; formatIban('BR1500000000000010932840814P2'); // 'BR15 0000 0000 0000 1093 2840 814P 2' formatIban('br1500000000000010932840814p2'); // 'BR15 0000 0000 0000 1093 2840 814P 2' formatIban('BR15'); // 'BR15' +formatIban('BR1500000000000010932840814P-2'); // '' (hyphens are not part of an IBAN) ``` ## parseIban -Parses a Brazilian IBAN into its fields: 2 (country code, always `BR`) + 2 (ISO 7064 MOD 97-10 check digits) + 8 (ISPB) + 5 (branch) + 10 (account) + 1 (account type, any letter, usually `C` for conta corrente or `P` for conta poupança) + 1 (owner indicator). Accepts the same input forms as `isValidIban` (grouping spaces, lowercase) and returns `null` whenever `isValidIban` would return `false`. The result is typed as `Iban`, whose `accountType` is a `string`. +Parses a Brazilian IBAN into its fields: 2 (country code, always `BR`) + 2 (ISO 7064 MOD 97-10 check digits) + 8 (ISPB) + 5 (branch) + 10 (account) + 1 (account type, any letter, usually `C` for conta corrente or `P` for conta poupança) + 1 (owner indicator). Accepts the same input forms as `isValidIban` (grouping spaces, lowercase) and returns `null` whenever `isValidIban` would return `false`, including a value carrying any character other than letters, digits and the grouping spaces of the print format. The result is typed as `Iban`, whose `accountType` is a `string`. ```javascript import { parseIban } from '@brazilian-utils/brazilian-utils'; @@ -755,11 +766,12 @@ parseIban('BR1500000000000010932840814P2'); // } parseIban('DE89370400440532013000'); // null (non Brazilian IBAN) +parseIban('BR1500000000000010932840814P-2'); // null (hyphens are not part of an IBAN) ``` ## isValidCreditCard -Check if a payment card number is valid using the Luhn algorithm ([ISO/IEC 7812-1](https://www.iso.org/standard/70484.html)). Accepts the usual mask characters (spaces, hyphens) between digits. Performs no brand detection (Visa, Mastercard, Amex...), issuer range lookup or expiration/CVV checks, only the digit count (12 to 19) and the Luhn check digit. +Check if a payment card number is valid using the Luhn algorithm ([ISO/IEC 7812-1](https://www.iso.org/standard/70484.html)). Accepts the usual mask characters (spaces, hyphens) between digits. Performs no brand detection (Visa, Mastercard, Amex...), issuer range lookup or expiration/CVV checks, only the digit count (12 to 19) and the Luhn check digit. A `number` is only accepted when it is a non-negative safe integer: anything above `Number.MAX_SAFE_INTEGER` (2^53 - 1, 16 digits) has already been rounded to a different number before the function sees it, so pass a longer PAN as a string. ```javascript import { isValidCreditCard } from '@brazilian-utils/brazilian-utils'; @@ -769,6 +781,7 @@ isValidCreditCard('5555555555554444'); // true (Mastercard test number) isValidCreditCard('378282246310005'); // true (American Express test number) isValidCreditCard('4111 1111 1111 1111'); // true (spaced mask) isValidCreditCard('4111111111111112'); // false (bad check digit) +isValidCreditCard(4111111111111111111); // false (above 2^53 - 1, pass it as a string) ``` ## capitalize @@ -859,7 +872,7 @@ convertCurrencyToWords(1000, { case: 'upper' }); // "MIL REAIS" ## getStates -Get all Brazilian states, each with its two-letter code, name, region code, region name and 2-digit IBGE code of the Federative Unit (`cUF`). The list is sorted by name with `localeCompare` in the "pt-BR" locale, so accented names land where a Brazilian reader expects them: Pará, Paraíba, Paraná and Rio de Janeiro, Rio Grande do Norte, Rio Grande do Sul. Each call returns a fresh array of fresh objects, so mutating the result never affects subsequent calls. Exports the `State`, `StateCode` and `StateName` types. +Get all Brazilian states, each with its two-letter code, name, region code, region name and 2-digit IBGE code of the Federative Unit (`cUF`). The list is sorted by name with `localeCompare` in the "pt-BR" locale, so accented names land where a Brazilian reader expects them: Pará, Paraíba, Paraná and Rio de Janeiro, Rio Grande do Norte, Rio Grande do Sul. Each call returns a fresh array of fresh objects, so mutating the result never affects subsequent calls. Exports the `State`, `StateCode` and `StateName` types. `State` is a discriminated union with one member per state, so the fields of a state are tied to each other: narrowing a `State` by `code` narrows its `name`, `regionCode`, `regionName` and `ibgeCode` too (`Extract['name']` is `'São Paulo'`), and an impossible combination such as `{ code: 'SP', name: 'Acre' }` is not a `State`. ```javascript import { getStates } from '@brazilian-utils/brazilian-utils'; @@ -898,7 +911,7 @@ getStates(); ## getStateByIbgeCode -Get the Brazilian state whose 2-digit IBGE code ("cUF", the Código da Unidade da Federação) matches the given value. This 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. Accepts a string or a number, stripping any non-digit characters before matching. Exports the `State` type. +Get the Brazilian state whose 2-digit IBGE code ("cUF", the Código da Unidade da Federação) matches the given value. This 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. Accepts a string or a non-negative integer number, stripping any non-digit characters before matching. Exports the `State` type. ```javascript import { getStateByIbgeCode } from '@brazilian-utils/brazilian-utils'; @@ -910,6 +923,8 @@ getStateByIbgeCode(11); // { code: 'RO', name: 'Rondônia', regionCode: 'N', regionName: 'Norte', ibgeCode: 11 } getStateByIbgeCode('00'); // null +getStateByIbgeCode(-35); // null +getStateByIbgeCode(3.5); // null ``` ## getStateCodeByName @@ -1299,7 +1314,7 @@ generatePis(); // '91077906857' ## getMunicipality -Get municipality information by IBGE code, or get an IBGE code from municipality name and UF. A single function handles both directions, based on whether `options` has a `code` or a `municipalityName`/`uf`. `code` accepts both `string` and `number` input and must be exactly 7 digits, otherwise the function resolves to `null`. Resolution is entirely offline, from a bundled IBGE dataset: no network request is made. The municipality name match ignores accents and casing. An unknown municipality, an unknown UF or invalid input all resolve to `null`. +Get municipality information by IBGE code, or get an IBGE code from municipality name and UF. A single function handles both directions, based on whether `options` has a `code` or a `municipalityName`/`uf`. `code` accepts both `string` and `number` input and must be exactly 7 digits, otherwise the function resolves to `null`. A `code` given as a number must be a non-negative integer: a sign and a decimal point are not digits, so `-3550308` and `355030.8` resolve to `null` instead of being read as `3550308`. Resolution is entirely offline, from a bundled IBGE dataset: no network request is made. The municipality name match ignores accents and casing. An unknown municipality, an unknown UF or invalid input all resolve to `null`. ```javascript import { getMunicipality } from '@brazilian-utils/brazilian-utils'; @@ -1356,7 +1371,7 @@ getMunicipalities('ZZ'); // [] ## getMunicipalityByCode -Look up a Brazilian municipality by its 7-digit IBGE code. Accepts the code as a string or a number, with any non-digit characters stripped before matching. Returns `{ code, name, stateCode }`, a fresh object, or `null` when the code is not 7 digits long or does not match any known municipality. +Look up a Brazilian municipality by its 7-digit IBGE code. Accepts the code as a string or a number, with any non-digit characters stripped before matching; a code given as a number must be a non-negative integer, so `-3550308` and `355030.8` return `null` instead of being read as `3550308`. Returns `{ code, name, stateCode }`, a fresh object, or `null` when the code is not 7 digits long or does not match any known municipality. ```javascript import { getMunicipalityByCode } from '@brazilian-utils/brazilian-utils'; @@ -1526,7 +1541,7 @@ formatCns('89010001', { pad: true }); // '000 0000 8901 0001' Check if 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) is valid. 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), and both check digits are modulus 11 with weights cycling from 2 to 10 and back through 0. Accepts the usual mask characters and whitespace between/around groups. The layout is the in-force one of [art. 473 of the Código Nacional de Normas da Corregedoria Nacional de Justiça](https://atos.cnj.jus.br/atos/detalhar/5243) (Provimento CNJ nº 149/2023, in the wording of the Provimento CN nº 182/2024); the matrícula itself was instituted by the now revoked [Provimento CNJ nº 2/2009](https://atos.cnj.jus.br/atos/detalhar/1311). The check digits are detailed by [ghiorzi.org](http://ghiorzi.org/DVnew.htm) and implemented by [validation-br](https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts) and [validator-docs](https://github.com/geekcom/validator-docs/blob/master/src/validator-docs/Rules/Certidao.php). -The book-type digit always has to name one of the nine book types (the same `CertidaoType` returned by `parseCertidao`), so a matrícula whose digit is `0` is rejected however good its check digits are, the same way `parseCertidao` returns `null` for it. `options.accept` (part of `IsValidCertidaoOptions`) narrows that to the listed types; it defaults to every type, and a value that is not an array falls back to that default. Only a string is accepted: the 32 digits of a matrícula are more than a JavaScript number can hold. +The serviço digits are fixed at `55`, the code [art. 473, III](https://atos.cnj.jus.br/atos/detalhar/5243) assigns to the registro civil das pessoas naturais, so a matrícula carrying any other pair in the ninth and tenth positions is rejected however good its check digits are. The book-type digit always has to name one of the nine book types (the same `CertidaoType` returned by `parseCertidao`), so a matrícula whose digit is `0` is rejected however good its check digits are, the same way `parseCertidao` returns `null` for it. `options.accept` (part of `IsValidCertidaoOptions`) narrows that to the listed types; it defaults to every type, and a value that is not an array falls back to that default. Only a string is accepted: the 32 digits of a matrícula are more than a JavaScript number can hold. ```javascript import { isValidCertidao } from '@brazilian-utils/brazilian-utils'; @@ -1534,6 +1549,7 @@ import { isValidCertidao } from '@brazilian-utils/brazilian-utils'; 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('09400301542011100110002005191744'); // false (serviço is not 55) 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 @@ -1569,7 +1585,7 @@ The `Certidao` result carries: | --- | --- | | `registryCns` | The 6 digit CNS (Código Nacional de Serventia) of the serventia that issued the act. | | `acervo` | Acervo the book belongs to: `"01"` the serventia's own, `"02"` a collection it absorbed. | -| `service` | Service rendered by the serventia, `"55"` for registro civil das pessoas naturais. | +| `service` | Service rendered by the serventia, always `"55"`, the registro civil das pessoas naturais. | | `year` | Four digit year the act was recorded. | | `type` | The book the act belongs to: `"birth"`, `"marriage"`, `"religious-marriage"`, `"death"`, `"stillbirth"`, `"banns"`, `"other"`, `"emancipation"` or `"interdiction"`. | | `typeCode` | Raw book code, 1 to 9, as printed in the fifteenth position of the matrícula. | @@ -1696,7 +1712,7 @@ isValidVin('1HGCM8263IA004352'); // false (contains the excluded letter I) ## isValidCbo -Check if a CBO (Classificação Brasileira de Ocupações) code exists in the MTE occupation table. Accepts the code with or without the hyphen mask, or as a number. +Check if a CBO (Classificação Brasileira de Ocupações) code exists in the MTE occupation table. Accepts the code with or without the hyphen mask, or as a number. A string is only read as a code when it is written in one of those forms (the 6 digits, or the `NNNN-NN` mask, with the usual separators between the groups and optional surrounding whitespace), and a number only when it is a non-negative safe integer. ```javascript import { isValidCbo } from '@brazilian-utils/brazilian-utils'; @@ -1705,26 +1721,29 @@ isValidCbo('2124-05'); // true isValidCbo('212405'); // true isValidCbo(212405); // true isValidCbo('000000'); // false +isValidCbo('2124abc05'); // false (not a documented form) +isValidCbo(-212405); // false (not a non-negative safe integer) ``` The occupation titles come from the [official CBO 2002 tables published by the MTE](http://www.mtecbo.gov.br/cbosite/pages/downloads.jsf). ## getCbo -Look a CBO (Classificação Brasileira de Ocupações) code up and get its official occupation title. A `number` keeps its implied leading zeros: `getCbo(10205)` is read as `010205`. +Look a CBO (Classificação Brasileira de Ocupações) code up and get its official occupation title. A `number` keeps its implied leading zeros: `getCbo(10205)` is read as `010205`. Same input rules as `isValidCbo`: a string has to be written as the 6 digits or with the `NNNN-NN` mask, and a number has to be a non-negative safe integer. ```javascript import { getCbo } from '@brazilian-utils/brazilian-utils'; getCbo('2124-05'); // { code: '212405', title: 'Analista de desenvolvimento de sistemas' } getCbo('000000'); // null +getCbo('2124abc05'); // null (not a documented form) ``` The occupation titles come from the [official CBO 2002 tables published by the MTE](http://www.mtecbo.gov.br/cbosite/pages/downloads.jsf). ## isValidCnae -Check if a CNAE (Classificação Nacional de Atividades Econômicas) subclass code exists in the CNAE 2.3 table published by IBGE. Accepts the code with or without the `NNNN-N/NN` mask, or as a number. +Check if a CNAE (Classificação Nacional de Atividades Econômicas) subclass code exists in the CNAE 2.3 table published by IBGE. Accepts the code with or without the `NNNN-N/NN` mask, or as a number. A string is only read as a code when it is written in one of those forms (the 7 digits, or the mask, with the usual separators between the groups and optional surrounding whitespace), and a number only when it is a non-negative safe integer. ```javascript import { isValidCnae } from '@brazilian-utils/brazilian-utils'; @@ -1732,6 +1751,8 @@ import { isValidCnae } from '@brazilian-utils/brazilian-utils'; isValidCnae('6201-5/01'); // true isValidCnae('6201501'); // true isValidCnae('0000000'); // false +isValidCnae('0111abc301'); // false (not a documented form) +isValidCnae(-111301); // false (not a non-negative safe integer) ``` ## formatCnae @@ -1746,13 +1767,14 @@ formatCnae('6201501'); // 6201-5/01 ## getCnae -Look a CNAE (Classificação Nacional de Atividades Econômicas) subclass code up and get its formatted code and official description. A `number` keeps its implied leading zeros: `getCnae(111301)` is read as `0111301`. +Look a CNAE (Classificação Nacional de Atividades Econômicas) subclass code up and get its formatted code and official description. A `number` keeps its implied leading zeros: `getCnae(111301)` is read as `0111301`. Same input rules as `isValidCnae`: a string has to be written as the 7 digits or with the `NNNN-N/NN` mask, and a number has to be a non-negative safe integer. ```javascript import { getCnae } from '@brazilian-utils/brazilian-utils'; getCnae('6201501'); // { code: '6201-5/01', description: 'DESENVOLVIMENTO DE PROGRAMAS DE COMPUTADOR SOB ENCOMENDA' } getCnae('0000000'); // null +getCnae('0111abc301'); // null (not a documented form) ``` ## isValidNcm @@ -1779,24 +1801,26 @@ formatNcm('84713012'); // 8471.30.12 ## isValidCfop -Check if a CFOP (Código Fiscal de Operações e Prestações) code exists in the official table (Ajuste SINIEF 07/2001 and updates). +Check if a CFOP (Código Fiscal de Operações e Prestações) code exists in the official table (Ajuste SINIEF 07/2001 and updates). Only operable codes count: the group and subgroup headings of the official nomenclature, the codes ending in `00` and `50` (1000, 1100, 1150, 5350, ...), are section titles rather than codes a document can carry, so they are rejected. ```javascript import { isValidCfop } from '@brazilian-utils/brazilian-utils'; isValidCfop('5102'); // true isValidCfop('0000'); // false +isValidCfop('1150'); // false (a subgroup heading, not an operable code) ``` ## getCfop -Look a CFOP (Código Fiscal de Operações e Prestações) code up and get its code and official description. +Look a CFOP (Código Fiscal de Operações e Prestações) code up and get its code and official description. The group and subgroup headings of the official nomenclature, the codes ending in `00` and `50`, are not in the table and give `null`. ```javascript import { getCfop } from '@brazilian-utils/brazilian-utils'; getCfop('5102'); // { code: '5102', description: 'Venda de mercadoria adquirida ou recebida de terceiros' } getCfop('0000'); // null +getCfop('5350'); // null (a subgroup heading, not an operable code) ``` ## isValidCst diff --git a/src/get-holidays/get-holidays.ts b/src/get-holidays/get-holidays.ts index 90323f3c..6007793d 100644 --- a/src/get-holidays/get-holidays.ts +++ b/src/get-holidays/get-holidays.ts @@ -9,7 +9,7 @@ import { STATE_HOLIDAYS, } from "./constants"; -/** How a holiday returned by `getHolidays` is observed. */ +/** The class a holiday returned by `getHolidays` falls into. */ export type HolidayType = "national" | "state" | "optional" | "religious"; /** One holiday returned by `getHolidays`. */ From 4bbcbb5a3d08207a8ce37a9c4a326ef2cc167212 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:18:15 -0300 Subject: [PATCH 14/14] test: keep the property inputs under the fast-check seed and cover every IBAN check digit --- src/_internals/test/arbitraries.ts | 14 ++++++++ src/_internals/test/properties.ts | 5 +-- .../format-license-plate.test.ts | 19 ++++++---- .../generate-pix-payload.test.ts | 18 +++++----- .../generate-processo-juridico.test.ts | 35 ++++++++++--------- src/get-states/get-states.test.ts | 2 +- .../is-valid-bank-account.test.ts | 17 ++++++--- src/is-valid-iban/is-valid-iban.test.ts | 2 +- src/parse-iban/parse-iban.test.ts | 2 +- 9 files changed, 72 insertions(+), 42 deletions(-) diff --git a/src/_internals/test/arbitraries.ts b/src/_internals/test/arbitraries.ts index 4413d466..a606b5eb 100644 --- a/src/_internals/test/arbitraries.ts +++ b/src/_internals/test/arbitraries.ts @@ -1,5 +1,6 @@ import * as fc from "fast-check"; +import { type LicensePlateFormat } from "../../get-format-license-plate/get-format-license-plate"; import { HOLIDAYS_MAX_YEAR, HOLIDAYS_MIN_YEAR } from "../constants/holidays"; import { DATA as STATES, type StateCode } from "../constants/states"; @@ -102,6 +103,19 @@ export const maskedValues = ( ), ); +const LICENSE_PLATE_PATTERNS = { + LLLNLNN: /^[A-Z]{3}[0-9][A-Z][0-9]{2}$/, + LLLNNNN: /^[A-Z]{3}[0-9]{4}$/, +}; + +/** + * @param {LicensePlateFormat} format The layout the plate follows: `LLLNLNN` for a Mercosul plate + * and `LLLNNNN` for an old one. + * @returns {fc.Arbitrary} Uppercase unmasked plates of exactly that layout. + */ +export const licensePlates = (format: LicensePlateFormat): fc.Arbitrary => + fc.stringMatching(LICENSE_PLATE_PATTERNS[format]); + /** The two letter code of every Brazilian state. */ export const stateCodes: fc.Arbitrary = fc.constantFrom( ...STATES.map((state) => state.code), diff --git a/src/_internals/test/properties.ts b/src/_internals/test/properties.ts index 1cd6b9b6..08e38313 100644 --- a/src/_internals/test/properties.ts +++ b/src/_internals/test/properties.ts @@ -163,10 +163,11 @@ export const expectCaseInsensitive = ( * Asserts `parse` undoes `format` for every value the arbitrary produces. * @param {Function} format The formatter under test. * @param {Function} parse The parser that must undo it. - * @param {fc.Arbitrary} arbitrary The values to feed them. + * @param {fc.Arbitrary} arbitrary The values to feed them. Only primitives, so the round-trip + * is compared by value and not by reference. * @returns {void} Nothing. */ -export const expectRoundTrip = ( +export const expectRoundTrip = ( format: (value: T) => string, parse: (value: string) => T, arbitrary: fc.Arbitrary, diff --git a/src/format-license-plate/format-license-plate.test.ts b/src/format-license-plate/format-license-plate.test.ts index 6ce402dc..86c03fc8 100644 --- a/src/format-license-plate/format-license-plate.test.ts +++ b/src/format-license-plate/format-license-plate.test.ts @@ -1,7 +1,7 @@ import * as fc from "fast-check"; +import { licensePlates } from "../_internals/test/arbitraries"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; -import { generateLicensePlate } from "../generate-license-plate/generate-license-plate"; import { parseLicensePlate } from "../parse-license-plate/parse-license-plate"; import { formatLicensePlate } from "./format-license-plate"; @@ -46,13 +46,20 @@ describe("formatLicensePlate", () => { }); describe("properties", () => { - test("should hyphenate an old format plate and leave a Mercosul one alone", () => { + test("should hyphenate an old format plate", () => { fc.assert( - fc.property(fc.constantFrom("LLLNNNN", "LLLNLNN"), (format) => { - const plate = generateLicensePlate(format); - const expected = format === "LLLNNNN" ? `${plate.slice(0, 3)}-${plate.slice(3)}` : plate; + fc.property(licensePlates("LLLNNNN"), (plate) => { + expect(formatLicensePlate(plate.toLowerCase())).toBe( + `${plate.slice(0, 3)}-${plate.slice(3)}`, + ); + }), + ); + }); - expect(formatLicensePlate(plate.toLowerCase())).toBe(expected); + test("should leave a Mercosul plate alone", () => { + fc.assert( + fc.property(licensePlates("LLLNLNN"), (plate) => { + expect(formatLicensePlate(plate.toLowerCase())).toBe(plate); }), ); }); diff --git a/src/generate-pix-payload/generate-pix-payload.test.ts b/src/generate-pix-payload/generate-pix-payload.test.ts index b37a5f9a..80ec48b2 100644 --- a/src/generate-pix-payload/generate-pix-payload.test.ts +++ b/src/generate-pix-payload/generate-pix-payload.test.ts @@ -16,6 +16,10 @@ const BASE = { const EVP = "71c7d9be-4b85-4e43-9f1c-1f3b8b4e9a2d"; +const CPF_KEY = "12345678909"; + +const CNPJ_KEY = "13723705000189"; + describe("generatePixPayload", () => { describe("should return null", () => { test("when it is null", () => { @@ -423,12 +427,11 @@ describe("generatePixPayload", () => { test("should round-trip a static payload through parsePixPayload", () => { fc.assert( fc.property(names, cities, (merchantName, merchantCity) => { - const key = generateCpf(); - const payload = generatePixPayload({ key, merchantName, merchantCity }); + const payload = generatePixPayload({ key: CPF_KEY, merchantName, merchantCity }); const parsed = parsePixPayload(payload ?? ""); expect(isValidPixPayload(payload ?? "")).toBe(true); - expect(parsed?.key).toBe(key); + expect(parsed?.key).toBe(CPF_KEY); expect(parsed?.merchantName).toBe(merchantName); expect(parsed?.merchantCity).toBe(merchantCity); expect(parsed?.amount).toBeUndefined(); @@ -441,9 +444,8 @@ describe("generatePixPayload", () => { fc.assert( fc.property(names, cents, txids, (merchantName, amountInCents, txid) => { const amount = amountInCents / 100; - const key = generateCnpj(); const payload = generatePixPayload({ - key, + key: CNPJ_KEY, merchantName, merchantCity: "BRASILIA", amount, @@ -473,10 +475,9 @@ describe("generatePixPayload", () => { test("should return null unless exactly one of the key and the url is given", () => { fc.assert( fc.property(names, urls, (merchantName, url) => { - const key = generateCpf(); const merchantCity = "BRASILIA"; - expect(generatePixPayload({ key, url, merchantName, merchantCity })).toBeNull(); + expect(generatePixPayload({ key: CPF_KEY, url, merchantName, merchantCity })).toBeNull(); expect(generatePixPayload({ merchantName, merchantCity })).toBeNull(); }), ); @@ -488,8 +489,7 @@ describe("generatePixPayload", () => { fc.string({ minLength: 1, unit: "grapheme" }), fc.string({ minLength: 1, unit: "grapheme" }), (merchantName, merchantCity) => { - const key = generateCpf(); - const payload = generatePixPayload({ key, merchantName, merchantCity }); + const payload = generatePixPayload({ key: CPF_KEY, merchantName, merchantCity }); fc.pre(payload !== null); diff --git a/src/generate-processo-juridico/generate-processo-juridico.test.ts b/src/generate-processo-juridico/generate-processo-juridico.test.ts index 21bae3e9..92adccc5 100644 --- a/src/generate-processo-juridico/generate-processo-juridico.test.ts +++ b/src/generate-processo-juridico/generate-processo-juridico.test.ts @@ -8,7 +8,7 @@ import { type GenerateProcessoJuridicoOptions, } from "./generate-processo-juridico"; -const currentYear = new Date().getFullYear(); +const currentYear = (): number => new Date().getFullYear(); const expectValidGeneratedProcessoJuridico = (value: string | null) => { expect(value).not.toBe(null); @@ -28,16 +28,16 @@ describe("generateProcessoJuridico", () => { }); it("should honor the year and court options", () => { - const value = generateProcessoJuridico({ year: currentYear, court: 5 }); + const value = generateProcessoJuridico({ year: currentYear(), court: 5 }); expect(value).not.toBe(null); - expect((value as string).slice(9, 13)).toBe(String(currentYear)); + expect((value as string).slice(9, 13)).toBe(String(currentYear())); expect((value as string).charAt(13)).toBe("5"); expect(isValidProcessoJuridico(value as string)).toBe(true); }); it("should return null for years before the current one", () => { - expect(generateProcessoJuridico({ year: currentYear - 1 })).toBe(null); + expect(generateProcessoJuridico({ year: currentYear() - 1 })).toBe(null); }); it("should return null for years above 9999", () => { @@ -57,7 +57,7 @@ describe("generateProcessoJuridico", () => { }); it("should return null for non integer years", () => { - expect(generateProcessoJuridico({ year: currentYear + 0.5 })).toBe(null); + expect(generateProcessoJuridico({ year: currentYear() + 0.5 })).toBe(null); expect(generateProcessoJuridico({ year: Number.NaN })).toBe(null); }); @@ -74,7 +74,7 @@ describe("generateProcessoJuridico", () => { Math.random = () => 0.5; try { - const value = generateProcessoJuridico({ year: currentYear }); + const value = generateProcessoJuridico({ year: currentYear() }); expect(value).not.toBe(null); expect((value as string).charAt(13)).toBe("5"); @@ -84,31 +84,32 @@ describe("generateProcessoJuridico", () => { }); describe("properties", () => { - const year = fc.integer({ min: currentYear, max: 9999 }); + const year = fc.integer({ min: 0, max: 9999 }); const court = fc.integer({ min: 1, max: 9 }); test("should embed every accepted year and court in a valid number", () => { fc.assert( fc.property(year, court, (chosenYear, chosenCourt) => { - const value = generateProcessoJuridico({ - year: chosenYear, - court: chosenCourt, - }) as string; + fc.pre(chosenYear >= currentYear()); + const value = generateProcessoJuridico({ year: chosenYear, court: chosenCourt }); + + expect(value).not.toBe(null); expect(value).toHaveLength(PROCESSO_JURIDICO_LENGTH); - expect(value.slice(9, 13)).toBe(String(chosenYear)); - expect(value.charAt(13)).toBe(String(chosenCourt)); - expect(isValidProcessoJuridico(value)).toBe(true); + expect(value?.slice(9, 13)).toBe(String(chosenYear)); + expect(value?.charAt(13)).toBe(String(chosenCourt)); + expect(isValidProcessoJuridico(value ?? "")).toBe(true); }), ); }); test("should return null for every year outside the accepted range", () => { - const tooEarly = fc.integer({ min: -9999, max: currentYear - 1 }); - const tooLate = fc.integer({ min: 10_000, max: 999_999 }); + const outOfRangeYears = fc.integer({ min: -9999, max: 999_999 }); fc.assert( - fc.property(fc.oneof(tooEarly, tooLate), (invalidYear) => { + fc.property(outOfRangeYears, (invalidYear) => { + fc.pre(invalidYear < currentYear() || invalidYear > 9999); + expect(generateProcessoJuridico({ year: invalidYear })).toBe(null); }), ); diff --git a/src/get-states/get-states.test.ts b/src/get-states/get-states.test.ts index 958e4279..c628b676 100644 --- a/src/get-states/get-states.test.ts +++ b/src/get-states/get-states.test.ts @@ -94,7 +94,7 @@ describe("getStates", () => { describe("getStates types", () => { test("should take no arguments and return an array of State", () => { - expectTypeOf(getStates).parameter(0).toBeUndefined(); + expectTypeOf(getStates).parameters.toEqualTypeOf<[]>(); expectTypeOf(getStates).returns.toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); diff --git a/src/is-valid-bank-account/is-valid-bank-account.test.ts b/src/is-valid-bank-account/is-valid-bank-account.test.ts index 554e270a..0a99d50e 100644 --- a/src/is-valid-bank-account/is-valid-bank-account.test.ts +++ b/src/is-valid-bank-account/is-valid-bank-account.test.ts @@ -1589,11 +1589,18 @@ describe("isValidBankAccount", () => { const structureOnly = fc.constantFrom("077", "085", "197", "290", "336", "748", "756"); fc.assert( - fc.property(structureOnly, agencies, accounts, (bankCode, agency, pool) => { - const account = pool.slice(0, 6); - - expect(isValidBankAccount({ bankCode, agency, account, digit: "7" })).toBe(true); - }), + fc.property( + structureOnly, + agencies, + accounts, + fc.integer({ min: 0, max: 9 }), + (bankCode, agency, pool, checkDigit) => { + const account = pool.slice(0, 6); + const digit = String(checkDigit); + + expect(isValidBankAccount({ bankCode, agency, account, digit })).toBe(true); + }, + ), ); }); diff --git a/src/is-valid-iban/is-valid-iban.test.ts b/src/is-valid-iban/is-valid-iban.test.ts index 652d2a83..b3b7da33 100644 --- a/src/is-valid-iban/is-valid-iban.test.ts +++ b/src/is-valid-iban/is-valid-iban.test.ts @@ -3,7 +3,7 @@ import * as fc from "fast-check"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; import { isValidIban } from "./is-valid-iban"; -const CHECK_DIGITS = Array.from({ length: 97 }, (_, index) => String(index).padStart(2, "0")); +const CHECK_DIGITS = Array.from({ length: 97 }, (_, index) => String(index + 2).padStart(2, "0")); const findIban = (body: string): string => CHECK_DIGITS.map((pair) => `BR${pair}${body}`).find((iban) => isValidIban(iban)) ?? ""; diff --git a/src/parse-iban/parse-iban.test.ts b/src/parse-iban/parse-iban.test.ts index 40f0e590..83bea941 100644 --- a/src/parse-iban/parse-iban.test.ts +++ b/src/parse-iban/parse-iban.test.ts @@ -6,7 +6,7 @@ import { isValidIban } from "../is-valid-iban/is-valid-iban"; import { parseIban, type Iban } from "./parse-iban"; const findBrazilianIban = (body: string): string => { - for (let pair = 0; pair < 97; pair++) { + for (let pair = 2; pair <= 98; pair++) { const candidate = `BR${String(pair).padStart(2, "0")}${body}`; if (isValidIban(candidate)) return candidate;