diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index e67670f41..75d119eff 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -600,6 +600,67 @@ getNfeKeyInfo('35170458716523000119620010000000121000123450'); getNfeKeyInfo('invalid'); // null ``` +## SUFRAMA + +### isValidSuframa + +Valida uma Inscrição SUFRAMA. É o número de registro que a Superintendência da Zona Franca de Manaus dá às empresas com incentivo fiscal, informado no campo `ISUF` do destinatário da NF-e. + +- O número tem a forma `SS.NNNN.LLD`: setor de atividade, número sequencial, localidade da unidade da SUFRAMA e dígito verificador. +- Aceita 8 ou 9 dígitos: um valor de 8 dígitos é um número cujo código de setor perdeu o zero à esquerda. +- Retorna `false` para um código de setor `00` e para um dígito verificador módulo 11 errado. +- Os códigos de setor e de localidade não são conferidos com uma tabela, pois o manual os lista apenas como exemplos. +- Além dos caracteres de máscara usuais, `(`, `)`, `,` e `*` também são ignorados. + +```javascript +import { isValidSuframa } from '@brazilian-utils/brazilian-utils'; + +isValidSuframa('123456789'); // true +isValidSuframa('12.3456.789'); // true +isValidSuframa('10001018'); // true (o mesmo que '010001018') +isValidSuframa('123456780'); // false +isValidSuframa('001234560'); // false (setor 00) +``` + +### formatSuframa + +Formata uma Inscrição SUFRAMA. + +- **Opções** (`FormatSuframaOptions`): `pad` completa o valor com zeros à esquerda até os 9 dígitos antes de aplicar a máscara (padrão `false`), o que devolve o zero à esquerda de um valor de 8 dígitos. +- A máscara é progressiva, como nas outras funções `format`, então um valor de 8 dígitos sem `pad` é agrupado uma posição antes: use `pad: true` para um valor lido direto do campo `ISUF`, que pode vir com 8 dígitos. + +```javascript +import { formatSuframa } from '@brazilian-utils/brazilian-utils'; + +formatSuframa('123456789'); // 12.3456.789 +formatSuframa('10001018'); // 10.0010.18 (8 dígitos, a máscara agrupa uma posição antes) +formatSuframa('10001018', { pad: true }); // 01.0001.018 +``` + +### parseSuframa + +Remove a formatação da Inscrição SUFRAMA, mantém apenas os dígitos e limita o resultado a 9 dígitos. + +```javascript +import { parseSuframa } from '@brazilian-utils/brazilian-utils'; + +parseSuframa('12.3456.789'); // 123456789 +``` + +### generateSuframa + +Gera uma Inscrição SUFRAMA aleatória válida de 9 dígitos. + +- O dígito verificador é válido e o código de setor nunca é `00`. Os códigos de setor e de localidade são aleatórios. + +```javascript +import { generateSuframa } from '@brazilian-utils/brazilian-utils'; + +generateSuframa(); // '205678106' +``` + +Fonte: [Manual de Orientação do Contribuinte da NF-e 7.0, Visão Geral](https://www.confaz.fazenda.gov.br/legislacao/arquivo-manuais/moc7-visao-geral.pdf) (seção 8.4), [MOC 7.0, Anexo I](https://www.confaz.fazenda.gov.br/legislacao/arquivo-manuais/moc7-anexo-i-leiaute-e-rv.pdf) (campo 79, `E18` `ISUF`, e regra E18-20). + ## Telefone ### isValidPhone diff --git a/docs/utilities.md b/docs/utilities.md index 563acf8f7..b10fc7bce 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -600,6 +600,67 @@ getNfeKeyInfo('35170458716523000119620010000000121000123450'); getNfeKeyInfo('invalid'); // null ``` +## SUFRAMA + +### isValidSuframa + +Check if an Inscrição SUFRAMA is valid. It is the registration number the Superintendência da Zona Franca de Manaus gives to companies with tax incentives, carried by the `ISUF` field of the NF-e recipient. + +- The number is `SS.NNNN.LLD`: sector of activity, sequential number, locality of the SUFRAMA unit and check digit. +- Accepts 8 or 9 digits: an 8 digit value is a number whose sector code lost its leading zero. +- Returns `false` for a sector code of `00` and for a wrong módulo 11 check digit. +- The sector and locality codes are not checked against a table, since the manual lists them only as examples. +- Besides the usual mask characters, `(`, `)`, `,` and `*` are also ignored. + +```javascript +import { isValidSuframa } from '@brazilian-utils/brazilian-utils'; + +isValidSuframa('123456789'); // true +isValidSuframa('12.3456.789'); // true +isValidSuframa('10001018'); // true (same as '010001018') +isValidSuframa('123456780'); // false +isValidSuframa('001234560'); // false (sector 00) +``` + +### formatSuframa + +Format an Inscrição SUFRAMA. + +- **Options** (`FormatSuframaOptions`): `pad` left-pads the value with zeros to the full 9 digits before masking (default `false`), which restores the leading zero of an 8 digit value. +- The mask is progressive, as in the other `format` utilities, so an 8 digit value without `pad` is grouped one position early: use `pad: true` for a value read straight out of the `ISUF` field, which may be stored with 8 digits. + +```javascript +import { formatSuframa } from '@brazilian-utils/brazilian-utils'; + +formatSuframa('123456789'); // 12.3456.789 +formatSuframa('10001018'); // 10.0010.18 (8 digits, the mask groups one position early) +formatSuframa('10001018', { pad: true }); // 01.0001.018 +``` + +### parseSuframa + +Remove Inscrição SUFRAMA formatting, keep only digits, and cap the result to 9 digits. + +```javascript +import { parseSuframa } from '@brazilian-utils/brazilian-utils'; + +parseSuframa('12.3456.789'); // 123456789 +``` + +### generateSuframa + +Generate a valid random 9 digit Inscrição SUFRAMA. + +- The check digit is valid and the sector code is never `00`. The sector and locality codes are random. + +```javascript +import { generateSuframa } from '@brazilian-utils/brazilian-utils'; + +generateSuframa(); // '205678106' +``` + +Source: [NF-e Manual de Orientação do Contribuinte 7.0, Visão Geral](https://www.confaz.fazenda.gov.br/legislacao/arquivo-manuais/moc7-visao-geral.pdf) (section 8.4), [MOC 7.0, Anexo I](https://www.confaz.fazenda.gov.br/legislacao/arquivo-manuais/moc7-anexo-i-leiaute-e-rv.pdf) (field 79, `E18` `ISUF`, and rule E18-20). + ## Phone ### isValidPhone diff --git a/jsr.json b/jsr.json index 9c7925fa5..6a2299eb7 100644 --- a/jsr.json +++ b/jsr.json @@ -32,6 +32,7 @@ "./format-phone": "./src/format-phone/format-phone.ts", "./format-pis": "./src/format-pis/format-pis.ts", "./format-processo-juridico": "./src/format-processo-juridico/format-processo-juridico.ts", + "./format-suframa": "./src/format-suframa/format-suframa.ts", "./format-voter-id": "./src/format-voter-id/format-voter-id.ts", "./generate-boleto": "./src/generate-boleto/generate-boleto.ts", "./generate-cep": "./src/generate-cep/generate-cep.ts", @@ -46,6 +47,7 @@ "./generate-pix-payload": "./src/generate-pix-payload/generate-pix-payload.ts", "./generate-processo-juridico": "./src/generate-processo-juridico/generate-processo-juridico.ts", "./generate-renavam": "./src/generate-renavam/generate-renavam.ts", + "./generate-suframa": "./src/generate-suframa/generate-suframa.ts", "./generate-voter-id": "./src/generate-voter-id/generate-voter-id.ts", "./get-address-info-by-cep": "./src/get-address-info-by-cep/get-address-info-by-cep.ts", "./get-area-code-info": "./src/get-area-code-info/get-area-code-info.ts", @@ -115,6 +117,7 @@ "./is-valid-registro-profissional": "./src/is-valid-registro-profissional/is-valid-registro-profissional.ts", "./is-valid-renavam": "./src/is-valid-renavam/is-valid-renavam.ts", "./is-valid-service-phone": "./src/is-valid-service-phone/is-valid-service-phone.ts", + "./is-valid-suframa": "./src/is-valid-suframa/is-valid-suframa.ts", "./is-valid-vin": "./src/is-valid-vin/is-valid-vin.ts", "./is-valid-voter-id": "./src/is-valid-voter-id/is-valid-voter-id.ts", "./parse-boleto": "./src/parse-boleto/parse-boleto.ts", @@ -140,6 +143,7 @@ "./parse-phone": "./src/parse-phone/parse-phone.ts", "./parse-pis": "./src/parse-pis/parse-pis.ts", "./parse-processo-juridico": "./src/parse-processo-juridico/parse-processo-juridico.ts", + "./parse-suframa": "./src/parse-suframa/parse-suframa.ts", "./parse-voter-id": "./src/parse-voter-id/parse-voter-id.ts", "./remove-accents": "./src/remove-accents/remove-accents.ts", "./sub-business-days": "./src/sub-business-days/sub-business-days.ts", diff --git a/reports/api/brazilian-utils.api.md b/reports/api/brazilian-utils.api.md index 1ccd42087..0d630e3fb 100644 --- a/reports/api/brazilian-utils.api.md +++ b/reports/api/brazilian-utils.api.md @@ -314,6 +314,14 @@ export type FormatProcessoJuridicoOptions = { pad?: boolean; }; +// @public +export const formatSuframa: (value: string | number, options?: FormatSuframaOptions) => string; + +// @public +export type FormatSuframaOptions = { + pad?: boolean; +}; + // @public export const formatVoterId: (value: string | number) => string; @@ -399,6 +407,9 @@ export type GenerateProcessoJuridicoParams = { // @public export const generateRenavam: () => string; +// @public +export const generateSuframa: () => string; + // @public export const generateVoterId: (state?: StateCode | "ZZ") => string; @@ -815,6 +826,9 @@ export const isValidRenavam: (renavam: string | number) => boolean; // @public export const isValidServicePhone: (value: string) => boolean; +// @public +export const isValidSuframa: (suframa: string) => boolean; + // @public export const isValidVin: (value: string) => boolean; @@ -947,6 +961,9 @@ export const parsePis: (value: string | number) => string; // @public export const parseProcessoJuridico: (value: string | number) => string; +// @public +export const parseSuframa: (value: string | number) => string; + // @public export const parseVoterId: (value: string | number) => string; diff --git a/src/_internals/constants/suframa.ts b/src/_internals/constants/suframa.ts new file mode 100644 index 000000000..3dec53b8a --- /dev/null +++ b/src/_internals/constants/suframa.ts @@ -0,0 +1,2 @@ +/** Digits of an Inscrição SUFRAMA written in full, sector code with its leading zero included. */ +export const SUFRAMA_LENGTH = 9; diff --git a/src/format-suframa/format-suframa.test.ts b/src/format-suframa/format-suframa.test.ts new file mode 100644 index 000000000..5d8a5f5d1 --- /dev/null +++ b/src/format-suframa/format-suframa.test.ts @@ -0,0 +1,118 @@ +import { SUFRAMA_LENGTH } from "../_internals/constants/suframa"; +import { anyValue, digits, digitsUpTo } from "../_internals/test/arbitraries"; +import { + expectAlwaysReturnsType, + expectMatchesPattern, + expectPadsToLength, + expectRoundTrip, +} from "../_internals/test/properties"; +import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; +import { parseSuframa } from "../parse-suframa/parse-suframa"; +import { formatSuframa, type FormatSuframaOptions } from "./format-suframa"; + +describe("formatSuframa", () => { + it("should format the example of the NF-e manual", () => { + expect(formatSuframa("123456789")).toBe("12.3456.789"); + }); + + it("when it is a no formatted string", () => { + expect(formatSuframa("")).toBe(""); + expect(formatSuframa("2")).toBe("2"); + expect(formatSuframa("20")).toBe("20"); + expect(formatSuframa("205")).toBe("20.5"); + expect(formatSuframa("2056")).toBe("20.56"); + expect(formatSuframa("20567")).toBe("20.567"); + expect(formatSuframa("205678")).toBe("20.5678"); + expect(formatSuframa("2056781")).toBe("20.5678.1"); + expect(formatSuframa("20567810")).toBe("20.5678.10"); + expect(formatSuframa("205678106")).toBe("20.5678.106"); + }); + + it("when it is a formatted string", () => { + expect(formatSuframa("20.5")).toBe("20.5"); + expect(formatSuframa("20.5678")).toBe("20.5678"); + expect(formatSuframa("20.56781")).toBe("20.5678.1"); + expect(formatSuframa("20.5678.106")).toBe("20.5678.106"); + expect(formatSuframa("20.5678.10-6")).toBe("20.5678.106"); + }); + + it("when it is a malformed string", () => { + expect(formatSuframa("20#Error*&@#5678#Char!106")).toBe("20.5678.106"); + expect(formatSuframa("#-+Error#205678106#Char!")).toBe("20.5678.106"); + }); + + it("when it is a number", () => { + expect(formatSuframa(1)).toBe("1"); + expect(formatSuframa(205)).toBe("20.5"); + expect(formatSuframa(205_678_106)).toBe("20.5678.106"); + expect(formatSuframa(10_001_018)).toBe("10.0010.18"); + }); + + it("should left pad with zeros when the pad option is set", () => { + expect(formatSuframa("10001018", { pad: true })).toBe("01.0001.018"); + expect(formatSuframa(10_001_018, { pad: true })).toBe("01.0001.018"); + expect(formatSuframa("1", { pad: true })).toBe("00.0000.001"); + expect(formatSuframa("205678106", { pad: true })).toBe("20.5678.106"); + }); + + it("should not pad when the pad option is false", () => { + expect(formatSuframa("10001018", { pad: false })).toBe("10.0010.18"); + }); + + it(`should NOT add digits after the Inscrição SUFRAMA length (${SUFRAMA_LENGTH})`, () => { + expect(formatSuframa("2056781069")).toBe("20.5678.106"); + expect(formatSuframa("205678106999")).toBe("20.5678.106"); + }); + + it("should return an empty string for null or undefined", () => { + // @ts-expect-error: intentionally invalid input + expect(formatSuframa(null)).toBe(""); + // @ts-expect-error: intentionally invalid input + expect(formatSuframa()).toBe(""); + }); + + describe("properties", () => { + const upToASuframa = digitsUpTo(9); + + test("should only add the mask, never change the digits", () => { + expectRoundTrip(formatSuframa, parseSuframa, upToASuframa); + }); + + test("should produce the documented mask shape for a full Inscrição SUFRAMA", () => { + expectMatchesPattern(formatSuframa, /^\d{2}\.\d{4}\.\d{3}$/, digits(9)); + }); + + test("should left pad a shorter value up to the Inscrição SUFRAMA length", () => { + expectPadsToLength(formatSuframa, parseSuframa, upToASuframa, SUFRAMA_LENGTH); + }); + + test("should never throw and always return a string", () => { + expectAlwaysReturnsType(formatSuframa, "string", anyValue); + }); + }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(formatSuframa(-101_234_567)).toBe(""); + expect(formatSuframa(-1)).toBe(""); + expect(formatSuframa(1.5)).toBe(""); + expect(formatSuframa(2 ** 53)).toBe(""); + expect(formatSuframa(Number.MAX_VALUE)).toBe(""); + expect(formatSuframa(1e21)).toBe(""); + expect(formatSuframa(Number.NaN)).toBe(""); + expect(formatSuframa(Number.POSITIVE_INFINITY)).toBe(""); + expect(formatSuframa(Number.NEGATIVE_INFINITY)).toBe(""); + expect(formatSuframa(-101_234_567, { pad: true })).toBe(""); + }); +}); + +describe("formatSuframa types", () => { + test("should take a string or number value and options and return a string", () => { + expectTypeOf(formatSuframa).parameter(0).toEqualTypeOf(); + expectTypeOf(formatSuframa).parameter(1).toEqualTypeOf(); + expectTypeOf(formatSuframa).returns.toEqualTypeOf(); + }); + + test("should type the pad option as an optional boolean", () => { + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/src/format-suframa/format-suframa.ts b/src/format-suframa/format-suframa.ts new file mode 100644 index 000000000..53f976fae --- /dev/null +++ b/src/format-suframa/format-suframa.ts @@ -0,0 +1,47 @@ +import { format } from "../_internals/format/format"; +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; + +/** Options of `formatSuframa`. */ +export type FormatSuframaOptions = { + /** Whether to left pad the value with zeros up to the number of slots in the pattern (default: `false`). */ + pad?: boolean; +}; + +/** + * Formats an Inscrição SUFRAMA with the `SS.NNNN.LLD` mask: sector of activity, sequential + * number, locality and check digit. + * + * An 8 digit value is a number whose sector code lost its leading zero, so format it with + * `pad: true` to get the zero back. The mask is progressive, as in the other `format` utilities, + * so an 8 digit value without `pad` is grouped one position early. + * + * A number is only read when it is a non-negative safe integer; any other number (negative, + * fractional, not finite or past `Number.MAX_SAFE_INTEGER`) gives an empty string. + * + * @param {string|number} value - The Inscrição SUFRAMA to be formatted. It can be a string or a number. + * @param {FormatSuframaOptions} [options] - Optional formatting options. + * @param {boolean} options.pad - If true, pads the value with leading zeros if necessary. + * @returns {string} The formatted Inscrição SUFRAMA as a string. + * + * @example + * ```typescript + * formatSuframa("123456789"); // "12.3456.789" + * formatSuframa(123456789); // "12.3456.789" + * formatSuframa("10001018"); // "10.0010.18" (8 digits, the mask groups one position early) + * formatSuframa("10001018", { pad: true }); // "01.0001.018" + * formatSuframa(-101234567); // "" (not a non-negative safe integer) + * ``` + * + * @see Official: https://www.confaz.fazenda.gov.br/legislacao/arquivo-manuais/moc7-visao-geral.pdf + * Manual de Orientação do Contribuinte (MOC) NF-e 7.0, Visão Geral, section 8.4, which gives the + * composition as `SS.NNNN.LLD`. + */ +export const formatSuframa = (value: string | number, options?: FormatSuframaOptions): string => + isLookupCode(value) + ? format({ + pad: options?.pad, + value: sanitizeToDigits(value), + pattern: "00.0000.000", + }) + : ""; diff --git a/src/generate-suframa/generate-suframa.test.ts b/src/generate-suframa/generate-suframa.test.ts new file mode 100644 index 000000000..edcbb6015 --- /dev/null +++ b/src/generate-suframa/generate-suframa.test.ts @@ -0,0 +1,81 @@ +import * as fc from "fast-check"; + +import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; +import { isValidSuframa } from "../is-valid-suframa/is-valid-suframa"; +import { generateSuframa } from "./generate-suframa"; + +const withRandomDigits = (digits: number[], run: () => void): void => { + const originalRandom = Math.random; + let call = 0; + + Math.random = () => { + const digit = digits[call]; + call += 1; + return (digit + 0.5) / 10; + }; + + try { + run(); + } finally { + Math.random = originalRandom; + } +}; + +describe("generateSuframa", () => { + test("should have the right length without mask (9)", () => { + expect(generateSuframa()).toHaveLength(9); + expect(/^\d{9}$/.test(generateSuframa())).toBe(true); + }); + + test("should always generate a valid Inscrição SUFRAMA", () => { + for (let i = 0; i < 1000; i++) { + expect(isValidSuframa(generateSuframa())).toBe(true); + } + }); + + test("should append the check digit of the NF-e manual example", () => { + withRandomDigits([1, 2, 3, 4, 5, 6, 7, 8], () => { + expect(generateSuframa()).toBe("123456789"); + }); + }); + + test("should append 0 when the remainder is 0 or 1", () => { + withRandomDigits([1, 0, 0, 0, 0, 0, 0, 1], () => { + expect(generateSuframa()).toBe("100000010"); + }); + withRandomDigits([6, 0, 0, 0, 0, 1, 3, 0], () => { + expect(generateSuframa()).toBe("600001300"); + }); + }); + + test("should regenerate the base when the sector code comes out as 00", () => { + withRandomDigits([0, 0, 1, 2, 3, 4, 5, 6, 0, 1, 0, 0, 0, 1, 0, 1], () => { + expect(generateSuframa()).toBe("010001018"); + }); + }); + + describe("properties", () => { + const batchSize = fc.integer({ min: 1, max: 20 }); + + test("should generate 9 digit numbers its own validator accepts, never with sector code 00", () => { + fc.assert( + fc.property(batchSize, (size) => { + for (let index = 0; index < size; index++) { + const suframa = generateSuframa(); + + expect(suframa).toMatch(/^\d{9}$/); + expect(suframa.startsWith("00")).toBe(false); + expect(isValidSuframa(suframa)).toBe(true); + } + }), + ); + }); + }); +}); + +describe("generateSuframa types", () => { + test("should take no parameters and return a string", () => { + expectTypeOf(generateSuframa).parameters.toEqualTypeOf<[]>(); + expectTypeOf(generateSuframa).returns.toEqualTypeOf(); + }); +}); diff --git a/src/generate-suframa/generate-suframa.ts b/src/generate-suframa/generate-suframa.ts new file mode 100644 index 000000000..411282541 --- /dev/null +++ b/src/generate-suframa/generate-suframa.ts @@ -0,0 +1,33 @@ +import { SUFRAMA_LENGTH } from "../_internals/constants/suframa"; +import { generateRandomNumber } from "../_internals/generate-random-number/generate-random-number"; +import { mod11 } from "../_internals/mod11/mod11"; + +/** + * Generates a random Inscrição SUFRAMA with a valid check digit, for tests and fixtures. + * + * The sector code is never `00`, the only structural rule the NF-e manual states. The manual lists + * sector and locality codes only as examples, so the generated ones are random and need not match + * a code SUFRAMA uses. + * + * Uses `Math.random()` internally, so it is not cryptographically secure, do not use for security purposes. + * + * @returns {string} A valid 9-digit Inscrição SUFRAMA string without formatting. + * + * @example + * ```typescript + * generateSuframa(); // "123456789" + * ``` + * + * @see Official: https://www.confaz.fazenda.gov.br/legislacao/arquivo-manuais/moc7-visao-geral.pdf + * Manual de Orientação do Contribuinte (MOC) NF-e 7.0, Visão Geral, section 8.4: the composition + * `SS.NNNN.LLD` with `SS` never `00`, and the módulo 11 check digit with weights 2 to 9. + */ +export const generateSuframa = (): string => { + let base = generateRandomNumber(SUFRAMA_LENGTH - 1); + + while (base.startsWith("00")) { + base = generateRandomNumber(SUFRAMA_LENGTH - 1); + } + + return `${base}${mod11(base, { variant: "arrecadacao" })}`; +}; diff --git a/src/index.test.ts b/src/index.test.ts index 61ad45293..a4e4cf4f4 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -34,6 +34,7 @@ import { type FormatPhoneOptions, type FormatPisOptions, type FormatProcessoJuridicoOptions, + type FormatSuframaOptions, type GenerateBoletoParams, type GenerateCnpjParams, type GenerateLicensePlateFormat, @@ -142,6 +143,7 @@ const PUBLIC = [ "formatPhone", "formatPis", "formatProcessoJuridico", + "formatSuframa", "formatVoterId", "generateBoleto", "generateCNPJ", @@ -158,6 +160,7 @@ const PUBLIC = [ "generatePixPayload", "generateProcessoJuridico", "generateRenavam", + "generateSuframa", "generateVoterId", "getAddressInfoByCep", "getAreaCodeInfo", @@ -232,6 +235,7 @@ const PUBLIC = [ "isValidRegistroProfissional", "isValidRenavam", "isValidServicePhone", + "isValidSuframa", "isValidVin", "isValidVoterId", "parseBoleto", @@ -257,6 +261,7 @@ const PUBLIC = [ "parsePhone", "parsePis", "parseProcessoJuridico", + "parseSuframa", "parseVoterId", "removeAccents", "subBusinessDays", @@ -324,6 +329,7 @@ describe("Public API", () => { FormatPhoneOptions: FormatPhoneOptions; FormatPisOptions: FormatPisOptions; FormatProcessoJuridicoOptions: FormatProcessoJuridicoOptions; + FormatSuframaOptions: FormatSuframaOptions; GenerateBoletoParams: GenerateBoletoParams; GenerateCnpjParams: GenerateCnpjParams; GenerateLicensePlateFormat: GenerateLicensePlateFormat; diff --git a/src/index.ts b/src/index.ts index 68c6b5693..98c3dd618 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,6 +53,7 @@ export { type FormatProcessoJuridicoOptions, formatProcessoJuridico, } from "./format-processo-juridico/format-processo-juridico"; +export { type FormatSuframaOptions, formatSuframa } from "./format-suframa/format-suframa"; export { formatVoterId } from "./format-voter-id/format-voter-id"; export { type GenerateBoletoParams, generateBoleto } from "./generate-boleto/generate-boleto"; export { generateCep } from "./generate-cep/generate-cep"; @@ -76,6 +77,7 @@ export { generateProcessoJuridico, } from "./generate-processo-juridico/generate-processo-juridico"; export { generateRenavam } from "./generate-renavam/generate-renavam"; +export { generateSuframa } from "./generate-suframa/generate-suframa"; export { generateVoterId } from "./generate-voter-id/generate-voter-id"; export { type AddressInfo, @@ -219,6 +221,7 @@ export { } from "./is-valid-registro-profissional/is-valid-registro-profissional"; export { isValidRenavam } from "./is-valid-renavam/is-valid-renavam"; export { isValidServicePhone } from "./is-valid-service-phone/is-valid-service-phone"; +export { isValidSuframa } from "./is-valid-suframa/is-valid-suframa"; export { isValidVin } from "./is-valid-vin/is-valid-vin"; export { isValidVoterId } from "./is-valid-voter-id/is-valid-voter-id"; export { parseBoleto } from "./parse-boleto/parse-boleto"; @@ -244,6 +247,7 @@ export { parsePassport } from "./parse-passport/parse-passport"; export { parsePhone } from "./parse-phone/parse-phone"; export { parsePis } from "./parse-pis/parse-pis"; export { parseProcessoJuridico } from "./parse-processo-juridico/parse-processo-juridico"; +export { parseSuframa } from "./parse-suframa/parse-suframa"; export { parseVoterId } from "./parse-voter-id/parse-voter-id"; export { removeAccents } from "./remove-accents/remove-accents"; export { subBusinessDays } from "./sub-business-days/sub-business-days"; diff --git a/src/is-valid-suframa/is-valid-suframa.test.ts b/src/is-valid-suframa/is-valid-suframa.test.ts new file mode 100644 index 000000000..6ffc60f5d --- /dev/null +++ b/src/is-valid-suframa/is-valid-suframa.test.ts @@ -0,0 +1,157 @@ +import * as fc from "fast-check"; + +import { anyValue, digitsOfOtherLength, maskSeparators } from "../_internals/test/arbitraries"; +import { expectAlwaysReturnsType, expectRejected } from "../_internals/test/properties"; +import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; +import { generateSuframa } from "../generate-suframa/generate-suframa"; +import { isValidSuframa } from "./is-valid-suframa"; + +describe("isValidSuframa", () => { + describe("should return false", () => { + test("when it is an empty string", () => { + expect(isValidSuframa("")).toBe(false); + }); + + test("when it is null", () => { + // @ts-expect-error: intentionally invalid input + expect(isValidSuframa(null)).toBe(false); + }); + + test("when it is undefined", () => { + // @ts-expect-error: intentionally invalid input + expect(isValidSuframa()).toBe(false); + }); + + test("when it is a boolean", () => { + // @ts-expect-error: intentionally invalid input + expect(isValidSuframa(true)).toBe(false); + // @ts-expect-error: intentionally invalid input + expect(isValidSuframa(false)).toBe(false); + }); + + test("when it is an object", () => { + // @ts-expect-error: intentionally invalid input + expect(isValidSuframa({})).toBe(false); + }); + + test("when it is an array", () => { + // @ts-expect-error: intentionally invalid input + expect(isValidSuframa([])).toBe(false); + }); + + test("when it is a non-string that stringifies to a valid Inscrição SUFRAMA", () => { + // @ts-expect-error: intentionally invalid input + expect(isValidSuframa(123_456_789)).toBe(false); + // @ts-expect-error: intentionally invalid input + expect(isValidSuframa([123_456_789])).toBe(false); + }); + + test("when it has fewer than 8 digits", () => { + expect(isValidSuframa("1234567")).toBe(false); + expect(isValidSuframa("0001018")).toBe(false); + }); + + test("when it has more than 9 digits, even if the first 9 are valid", () => { + expect(isValidSuframa("1234567899")).toBe(false); + }); + + test("when it has more than 9 digits, even if the last 9 are valid", () => { + expect(isValidSuframa("1234567090")).toBe(false); + }); + + test("when it contains letters or special characters", () => { + expect(isValidSuframa("12345678A9")).toBe(false); + expect(isValidSuframa("12#3456#789")).toBe(false); + expect(isValidSuframa("abcdefghi")).toBe(false); + }); + + test("when the check digit is wrong", () => { + expect(isValidSuframa("123456780")).toBe(false); + expect(isValidSuframa("123456788")).toBe(false); + expect(isValidSuframa("205678105")).toBe(false); + expect(isValidSuframa("10001019")).toBe(false); + }); + + test("when the remainder is 0 or 1 and the check digit is not 0", () => { + expect(isValidSuframa("100000011")).toBe(false); + expect(isValidSuframa("600001301")).toBe(false); + }); + + test("when the sector code is 00", () => { + expect(isValidSuframa("001234560")).toBe(false); + expect(isValidSuframa("000000000")).toBe(false); + expect(isValidSuframa("000000019")).toBe(false); + }); + + test("when an 8 digit value starts with 0, which reads as sector code 00", () => { + expect(isValidSuframa("01234560")).toBe(false); + }); + }); + + describe("should return true", () => { + test("when it is the example of the NF-e manual", () => { + expect(isValidSuframa("123456789")).toBe(true); + }); + + test("when it is a valid Inscrição SUFRAMA without mask", () => { + expect(isValidSuframa("010001018")).toBe(true); + expect(isValidSuframa("101234015")).toBe(true); + expect(isValidSuframa("205678106")).toBe(true); + expect(isValidSuframa("601234308")).toBe(true); + }); + + test("when it is a valid Inscrição SUFRAMA with mask", () => { + expect(isValidSuframa("12.3456.789")).toBe(true); + expect(isValidSuframa("20.5678.10-6")).toBe(true); + expect(isValidSuframa("20 5678 10 6")).toBe(true); + }); + + test("when it has 8 digits because the sector code lost its leading zero", () => { + expect(isValidSuframa("10001018")).toBe(true); + expect(isValidSuframa("1.0001.018")).toBe(true); + }); + + test("when the remainder is 0 and the check digit is 0", () => { + expect(isValidSuframa("100000010")).toBe(true); + }); + + test("when the remainder is 1 and the check digit is 0", () => { + expect(isValidSuframa("600001300")).toBe(true); + }); + + test("when the remainder is 10 and the check digit is 1", () => { + expect(isValidSuframa("100000061")).toBe(true); + }); + }); + + describe("properties", () => { + test("should accept a generated Inscrição SUFRAMA written with any of the accepted mask characters", () => { + const masks = maskSeparators([".", "-", "/", " ", "(", ")", ",", "*"], 4, 3); + + fc.assert( + fc.property(masks, (separators) => { + const suframa = generateSuframa(); + const head = `${separators[0]}${suframa.slice(0, 2)}${separators[1]}`; + const tail = `${suframa.slice(2, 6)}${separators[2]}${suframa.slice(6)}`; + + expect(isValidSuframa(`${head}${tail}${separators[3]}`)).toBe(true); + }), + ); + }); + + test("should reject any digits only value that is not 8 or 9 digits long", () => { + expectRejected(isValidSuframa, digitsOfOtherLength(22, [8, 9])); + }); + + test("should never throw and always return a boolean", () => { + expectAlwaysReturnsType(isValidSuframa, "boolean", anyValue); + }); + }); +}); + +describe("isValidSuframa types", () => { + test("should take a string and return a boolean", () => { + expectTypeOf(isValidSuframa).parameter(0).toEqualTypeOf(); + expectTypeOf(isValidSuframa).returns.toEqualTypeOf(); + }); +}); diff --git a/src/is-valid-suframa/is-valid-suframa.ts b/src/is-valid-suframa/is-valid-suframa.ts new file mode 100644 index 000000000..6e63a3c5f --- /dev/null +++ b/src/is-valid-suframa/is-valid-suframa.ts @@ -0,0 +1,57 @@ +import { SUFRAMA_LENGTH } from "../_internals/constants/suframa"; +import { mod11 } from "../_internals/mod11/mod11"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; + +/** + * Validates an Inscrição SUFRAMA, the registration number the Superintendência da Zona Franca de + * Manaus gives to companies with tax incentives, carried by the `ISUF` field of the NF-e. + * Accepts the usual mask characters (`.`, `-`, `/`, `(`, `)`, `,`, `*`) and whitespace. + * + * The number is `SS.NNNN.LLD`: sector of activity, sequential number, locality of the SUFRAMA + * unit that registered the company and check digit. The NF-e field is numeric with 8 or 9 + * positions, because a sector code such as `01` loses its leading zero, so an 8 digit value is + * read with that zero back in place. The sector code can never be `00`. The check digit is módulo + * 11 with weights 2 to 9 from right to left, and is 0 when the remainder is 0 or 1. + * + * The manual lists sector and locality codes only as examples, so they are not checked against a + * table. + * + * @param {string} suframa - The Inscrição SUFRAMA to validate. + * @returns {boolean} True if the Inscrição SUFRAMA is valid, false otherwise. + * + * @example + * ```typescript + * isValidSuframa("123456789"); // true + * isValidSuframa("12.3456.789"); // true + * isValidSuframa("10001018"); // true (same as "010001018") + * isValidSuframa("123456780"); // false (wrong check digit) + * isValidSuframa("001234560"); // false (sector 00) + * ``` + * + * @see Official: https://www.confaz.fazenda.gov.br/legislacao/arquivo-manuais/moc7-visao-geral.pdf + * Manual de Orientação do Contribuinte (MOC) NF-e 7.0, Visão Geral, section 8.4: the composition + * `SS.NNNN.LLD`, the numeric field with 8 or 9 positions whose `SS` may start with 0 but never be + * `00`, and the módulo 11 check digit with weights 2 to 9 and 0 for a remainder of 0 or 1. + * @see Official: https://www.confaz.fazenda.gov.br/legislacao/arquivo-manuais/moc7-anexo-i-leiaute-e-rv.pdf + * MOC 7.0, Anexo I: field 79 (`E18`, `ISUF`) is numeric with 8 to 9 positions, and validation + * rule E18-20 turns down an Inscrição SUFRAMA with an invalid check digit (rejection 235). + */ +export const isValidSuframa = (suframa: string): boolean => { + if (typeof suframa !== "string") return false; + + const hasInvalidChars = /[^0-9\s().,*/-]/.test(suframa); + + if (hasInvalidChars) return false; + + const digits = sanitizeToDigits(suframa); + + if (digits.length !== SUFRAMA_LENGTH && digits.length !== SUFRAMA_LENGTH - 1) return false; + + const full = digits.padStart(SUFRAMA_LENGTH, "0"); + + if (full.startsWith("00")) return false; + + const checkDigit = mod11(full.slice(0, -1), { variant: "arrecadacao" }); + + return full.charCodeAt(SUFRAMA_LENGTH - 1) - 48 === checkDigit; +}; diff --git a/src/parse-suframa/parse-suframa.test.ts b/src/parse-suframa/parse-suframa.test.ts new file mode 100644 index 000000000..4bd49205c --- /dev/null +++ b/src/parse-suframa/parse-suframa.test.ts @@ -0,0 +1,72 @@ +import { anyText, anyValue } from "../_internals/test/arbitraries"; +import { + expectAlwaysReturnsType, + expectIdempotent, + expectMatchesPattern, +} from "../_internals/test/properties"; +import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; +import { parseSuframa } from "./parse-suframa"; + +describe("parseSuframa", () => { + it("should remove Inscrição SUFRAMA mask characters", () => { + expect(parseSuframa("12.3456.789")).toBe("123456789"); + expect(parseSuframa("20.5678.10-6")).toBe("205678106"); + }); + + it("should keep an unmasked value as it is", () => { + expect(parseSuframa("123456789")).toBe("123456789"); + expect(parseSuframa("10001018")).toBe("10001018"); + }); + + it("should accept a number", () => { + expect(parseSuframa(123_456_789)).toBe("123456789"); + }); + + it("should remove non numeric characters", () => { + expect(parseSuframa("12#Error*&@#3456#Char!789")).toBe("123456789"); + }); + + it("should ignore digits after the Inscrição SUFRAMA length", () => { + expect(parseSuframa("123456789123")).toBe("123456789"); + }); + + it("should return an empty string for null or undefined", () => { + // @ts-expect-error: intentionally invalid input + expect(parseSuframa(null)).toBe(""); + // @ts-expect-error: intentionally invalid input + expect(parseSuframa()).toBe(""); + }); + + describe("properties", () => { + test("should return at most the digits of an Inscrição SUFRAMA", () => { + expectMatchesPattern(parseSuframa, /^\d{0,9}$/, anyText); + }); + + test("should be idempotent", () => { + expectIdempotent(parseSuframa, anyText); + }); + + test("should never throw and always return a string", () => { + expectAlwaysReturnsType(parseSuframa, "string", anyValue); + }); + }); + + test("when it is a negative, fractional or unsafe number", () => { + expect(parseSuframa(10_123_456.7)).toBe(""); + expect(parseSuframa(-1)).toBe(""); + expect(parseSuframa(1.5)).toBe(""); + expect(parseSuframa(2 ** 53)).toBe(""); + expect(parseSuframa(Number.MAX_VALUE)).toBe(""); + expect(parseSuframa(1e21)).toBe(""); + expect(parseSuframa(Number.NaN)).toBe(""); + expect(parseSuframa(Number.POSITIVE_INFINITY)).toBe(""); + expect(parseSuframa(Number.NEGATIVE_INFINITY)).toBe(""); + }); +}); + +describe("parseSuframa types", () => { + test("should take a string or number value and return a string", () => { + expectTypeOf(parseSuframa).parameter(0).toEqualTypeOf(); + expectTypeOf(parseSuframa).returns.toEqualTypeOf(); + }); +}); diff --git a/src/parse-suframa/parse-suframa.ts b/src/parse-suframa/parse-suframa.ts new file mode 100644 index 000000000..0d2d2d418 --- /dev/null +++ b/src/parse-suframa/parse-suframa.ts @@ -0,0 +1,28 @@ +import { SUFRAMA_LENGTH } from "../_internals/constants/suframa"; +import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; + +/** + * Removes Inscrição SUFRAMA formatting characters and returns only digits, the way the `ISUF` + * field of the NF-e expects them. + * + * A number is only read when it is a non-negative safe integer; any other number (negative, + * fractional, not finite or past `Number.MAX_SAFE_INTEGER`) gives an empty string. + * + * @param {string|number} value - The Inscrição SUFRAMA to be parsed. + * @returns {string} The Inscrição SUFRAMA without formatting. + * + * @example + * ```typescript + * parseSuframa("12.3456.789"); // "123456789" + * parseSuframa(10123456.7); // "" (not a non-negative safe integer) + * ``` + * + * @see Official: https://www.confaz.fazenda.gov.br/legislacao/arquivo-manuais/moc7-visao-geral.pdf + * Manual de Orientação do Contribuinte (MOC) NF-e 7.0, Visão Geral, section 8.4, which gives the + * composition as `SS.NNNN.LLD`. + * @see Official: https://www.confaz.fazenda.gov.br/legislacao/arquivo-manuais/moc7-anexo-i-leiaute-e-rv.pdf + * MOC 7.0, Anexo I: field 79 (`E18`, `ISUF`) is numeric with 8 to 9 positions. + */ +export const parseSuframa = (value: string | number): string => + isLookupCode(value) ? sanitizeToDigits(value).slice(0, SUFRAMA_LENGTH) : "";