diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index dd08b659b..7e633d651 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -2755,6 +2755,71 @@ isValidCsosn(-101); // false (não é um inteiro seguro não negativo) Fonte: [Anexo III-A consolidado do Convênio SINIEF s/nº 1970](https://www.confaz.fazenda.gov.br/legislacao/ajustes/sinief/cvsn_70) e [Ajuste SINIEF 03/2010](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2010/aj_003_10). +## GTIN (código de barras de produto) + +### isValidGtin + +Verifica se um GTIN (Global Trade Item Number, o número sob um código de barras EAN/UPC) é válido. + +- Cobre as quatro estruturas das GS1 General Specifications, as mesmas quatro que a NF-e aceita em `cEAN` e `cEANTrib`: GTIN-8, GTIN-12 (UPC), GTIN-13 (EAN) e GTIN-14 (DUN-14). +- **Opções** (`IsValidGtinOptions`): `lengths` aceita só alguns dos quatro tamanhos. O padrão são os quatro. +- O valor deve ser uma string de 8, 12, 13 ou 14 dígitos, fora os espaços em volta, cujo último dígito é o dígito verificador módulo 10 da GS1: pesos 3 e 1 alternados a partir da direita, e a soma subtraída do múltiplo de dez igual ou imediatamente superior. É o que as regras I03-10 e I12-10 da Nota Técnica 2021.003 da SEFAZ verificam (rejeições 611 e 612). +- Zeros à esquerda contam, então um número nunca é aceito, e um valor com máscara (`'7 890000 000017'`) é rejeitado em vez de ter seus dígitos extraídos. +- O literal `'SEM GTIN'`, que a NF-e usa para produto sem GTIN, não é um GTIN e portanto não é válido aqui: teste por ele antes de chamar. +- O prefixo não muda o veredito. Os Números de Circulação Restrita (prefixos 02, 04 e 20 a 29, os códigos que a loja imprime nas etiquetas da própria balança) e as faixas de ISSN, ISBN e cupons têm a mesma estrutura e o mesmo dígito verificador, e a "Tabela Prefixo GS1", contra a qual a SEFAZ valida o `cEAN`, lista essas faixas como válidas; use `getGtinInfo` para distingui-las. +- O prefixo também não é conferido contra a lista de Organizações Membro da GS1: a GS1 segue atribuindo faixas, e uma cópia dessa lista passaria a recusar números válidos conforme envelhecesse. Se o número está cadastrado (a consulta ao Cadastro Centralizado de GTIN que a SEFAZ faz para os prefixos 789 e 790) não dá para verificar offline. + +```javascript +import { isValidGtin } from '@brazilian-utils/brazilian-utils'; + +isValidGtin('7890000000017'); // true (GTIN-13, prefixo da GS1 Brasil) +isValidGtin('6291041500213'); // true (o exemplo da página de dígito verificador da GS1) +isValidGtin('78912342'); // true (GTIN-8) +isValidGtin('061414112345'); // true (GTIN-12) +isValidGtin('17890000000014'); // true (GTIN-14) +isValidGtin('7890000000018'); // false (dígito verificador errado) +isValidGtin('17890000000014', { lengths: [8, 12, 13] }); // false (GTIN-14 não aceito) +isValidGtin('7 890000 000017'); // false (somente dígitos) +isValidGtin('SEM GTIN'); // false +``` + +### getGtinInfo + +Extrai os campos de um GTIN, como um `GtinInfo`. + +- Retorna `null` quando o valor não é um GTIN válido, sob as mesmas regras de `isValidGtin`. +- O prefixo é lido como a "Tabela Prefixo GS1" do Portal da NF-e orienta: o valor é preenchido com zeros à esquerda até 14 dígitos, e o prefixo são as posições 7 a 9 quando as seis primeiras são zeros (um GTIN-8) e as posições 2 a 4 caso contrário. Um GTIN-12 tem, portanto, um prefixo que começa com `0`, e um GTIN-14 tem o prefixo do GTIN-13 que ele agrupa, depois do dígito indicador. + +| Campo | Descrição | +| --- | --- | +| `type` | `'GTIN-8'`, `'GTIN-12'`, `'GTIN-13'` ou `'GTIN-14'` (`GtinType`), conforme o tamanho com que o valor foi escrito | +| `length` | `8`, `12`, `13` ou `14` (`GtinLength`) | +| `prefix` | O Prefixo GS1 de três dígitos, ou um Prefixo GS1-8 quando os seis primeiros dígitos da forma de 14 dígitos são zeros, o que cobre todo GTIN-8 e o Prefixo GS1 `0000000`. Identifica a Organização Membro da GS1 que licenciou o número, não o país de origem | +| `isBrazilian` | `true` quando o prefixo é um dos da GS1 Brasil, `789` ou `790`, o que a NT 2021.003 chama de "prefixo do Brasil" | +| `isRestrictedCirculation` | `true` quando o prefixo está em uma faixa que a GS1 reserva para Números de Circulação Restrita (Prefixos GS1 02, 04 e 20 a 29; Prefixos GS1-8 000 a 099 e 200 a 299, faixa em que também cai o Prefixo GS1 `0000000`, já que sua forma de 14 dígitos começa com seis zeros), ou seja, o número só é único dentro de uma empresa ou região | +| `checkDigit` | O dígito verificador módulo 10, o último dígito | + +```javascript +import { getGtinInfo } from '@brazilian-utils/brazilian-utils'; + +getGtinInfo('7890000000017'); +// { type: 'GTIN-13', length: 13, prefix: '789', isBrazilian: true, +// isRestrictedCirculation: false, checkDigit: 7 } + +getGtinInfo('17890000000014'); +// { type: 'GTIN-14', length: 14, prefix: '789', isBrazilian: true, +// isRestrictedCirculation: false, checkDigit: 4 } + +getGtinInfo('061414112345'); +// { type: 'GTIN-12', length: 12, prefix: '006', isBrazilian: false, +// isRestrictedCirculation: false, checkDigit: 5 } + +getGtinInfo('2000000000015')?.isRestrictedCirculation; // true (número interno da loja) +getGtinInfo('7890000000018'); // null (dígito verificador errado) +``` + +Fonte: [GS1 General Specifications](https://ref.gs1.org/standards/genspecs/), [calculadora de dígito verificador da GS1](https://www.gs1.org/services/how-calculate-check-digit-manually), [Nota Técnica 2021.003 da SEFAZ](https://www.nfe.fazenda.gov.br/portal/exibirArquivo.aspx?conteudo=SrQT9ys8ODo%3D) e [Tabela Prefixo GS1](https://www.nfe.fazenda.gov.br/portal/exibirArquivo.aspx?conteudo=Oc+fygAxwmc%3D) do Portal da NF-e. + ## Texto ### capitalize diff --git a/docs/utilities.md b/docs/utilities.md index 7eb70d2a5..f8abed09d 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -2755,6 +2755,71 @@ isValidCsosn(-101); // false (not a non-negative safe integer) Source: [consolidated Anexo III-A of Convênio SINIEF s/nº 1970](https://www.confaz.fazenda.gov.br/legislacao/ajustes/sinief/cvsn_70) and [Ajuste SINIEF 03/2010](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2010/aj_003_10). +## GTIN (product barcode) + +### isValidGtin + +Check if a GTIN (Global Trade Item Number, the number under an EAN/UPC barcode) is valid. + +- Covers the four structures of the GS1 General Specifications, the same four the NF-e accepts in `cEAN` and `cEANTrib`: GTIN-8, GTIN-12 (UPC), GTIN-13 (EAN) and GTIN-14 (DUN-14). +- **Options** (`IsValidGtinOptions`): `lengths` accepts only some of the four lengths, and defaults to all four. +- The value must be a string of 8, 12, 13 or 14 digits, surrounding whitespace aside, whose last digit is the GS1 modulo 10 check digit: weights 3 and 1 alternating from the right, the sum subtracted from the nearest equal or higher multiple of ten. That is what rules I03-10 and I12-10 of SEFAZ Nota Técnica 2021.003 check (rejections 611 and 612). +- Leading zeros count, so a number is never accepted, and a masked value (`'7 890000 000017'`) is rejected instead of having its digits picked out. +- The `'SEM GTIN'` literal the NF-e uses for a product without a GTIN is not a GTIN, so it is not valid here: test for it before calling. +- The prefix does not change the verdict. Restricted Circulation Numbers (prefixes 02, 04 and 20 to 29, the codes a shop prints on its own scale labels) and the ISSN, ISBN and coupon ranges share the structure and the check digit, and the "Tabela Prefixo GS1" SEFAZ validates `cEAN` against lists them as valid; use `getGtinInfo` to tell them apart. +- The prefix is not checked against the list of GS1 Member Organisations either: GS1 keeps assigning ranges, so a copy of that list would turn down valid numbers as it ages. Whether the number is registered (the Cadastro Centralizado de GTIN lookup SEFAZ runs for the 789 and 790 prefixes) cannot be checked offline. + +```javascript +import { isValidGtin } from '@brazilian-utils/brazilian-utils'; + +isValidGtin('7890000000017'); // true (GTIN-13, GS1 Brasil prefix) +isValidGtin('6291041500213'); // true (the example of the GS1 check digit page) +isValidGtin('78912342'); // true (GTIN-8) +isValidGtin('061414112345'); // true (GTIN-12) +isValidGtin('17890000000014'); // true (GTIN-14) +isValidGtin('7890000000018'); // false (wrong check digit) +isValidGtin('17890000000014', { lengths: [8, 12, 13] }); // false (GTIN-14 not accepted) +isValidGtin('7 890000 000017'); // false (digits only) +isValidGtin('SEM GTIN'); // false +``` + +### getGtinInfo + +Parse a GTIN into its fields, as a `GtinInfo`. + +- Returns `null` when the value is not a valid GTIN, under the same rules as `isValidGtin`. +- The prefix is read the way the "Tabela Prefixo GS1" of the Portal da NF-e tells: the value is left padded with zeros to 14 digits, and the prefix is positions 7 to 9 when the first six are zeros (a GTIN-8) and positions 2 to 4 otherwise. A GTIN-12 therefore has a prefix that starts with `0`, and a GTIN-14 has the prefix of the GTIN-13 it packs, after the indicator digit. + +| Field | Description | +| --- | --- | +| `type` | `'GTIN-8'`, `'GTIN-12'`, `'GTIN-13'` or `'GTIN-14'` (`GtinType`), from the length the value was written with | +| `length` | `8`, `12`, `13` or `14` (`GtinLength`) | +| `prefix` | The three digit GS1 Prefix, or a GS1-8 Prefix when the first six digits of the 14 digit form are zeros, which covers every GTIN-8 and the GS1 Prefix `0000000`. It names the GS1 Member Organisation that licensed the number, not the country of origin | +| `isBrazilian` | `true` when the prefix is one of GS1 Brasil, `789` or `790`, what NT 2021.003 calls "prefixo do Brasil" | +| `isRestrictedCirculation` | `true` when the prefix is in a range GS1 sets aside for Restricted Circulation Numbers (GS1 Prefixes 02, 04 and 20 to 29; GS1-8 Prefixes 000 to 099 and 200 to 299, which is also where the GS1 Prefix `0000000` lands, since its 14 digit form starts with six zeros), so the number is only unique inside a company or region | +| `checkDigit` | The modulo 10 check digit, the last digit | + +```javascript +import { getGtinInfo } from '@brazilian-utils/brazilian-utils'; + +getGtinInfo('7890000000017'); +// { type: 'GTIN-13', length: 13, prefix: '789', isBrazilian: true, +// isRestrictedCirculation: false, checkDigit: 7 } + +getGtinInfo('17890000000014'); +// { type: 'GTIN-14', length: 14, prefix: '789', isBrazilian: true, +// isRestrictedCirculation: false, checkDigit: 4 } + +getGtinInfo('061414112345'); +// { type: 'GTIN-12', length: 12, prefix: '006', isBrazilian: false, +// isRestrictedCirculation: false, checkDigit: 5 } + +getGtinInfo('2000000000015')?.isRestrictedCirculation; // true (in-store number) +getGtinInfo('7890000000018'); // null (wrong check digit) +``` + +Source: [GS1 General Specifications](https://ref.gs1.org/standards/genspecs/), [GS1 check digit calculator](https://www.gs1.org/services/how-calculate-check-digit-manually), [SEFAZ Nota Técnica 2021.003](https://www.nfe.fazenda.gov.br/portal/exibirArquivo.aspx?conteudo=SrQT9ys8ODo%3D) and the [Tabela Prefixo GS1](https://www.nfe.fazenda.gov.br/portal/exibirArquivo.aspx?conteudo=Oc+fygAxwmc%3D) of the Portal da NF-e. + ## Text ### capitalize diff --git a/jsr.json b/jsr.json index fbdd94b7b..67174cf9c 100644 --- a/jsr.json +++ b/jsr.json @@ -65,6 +65,7 @@ "./get-cnpj-info": "./src/get-cnpj-info/get-cnpj-info.ts", "./get-cpf-info": "./src/get-cpf-info/get-cpf-info.ts", "./get-format-license-plate": "./src/get-format-license-plate/get-format-license-plate.ts", + "./get-gtin-info": "./src/get-gtin-info/get-gtin-info.ts", "./get-holidays": "./src/get-holidays/get-holidays.ts", "./get-iban-info": "./src/get-iban-info/get-iban-info.ts", "./get-legal-nature": "./src/get-legal-nature/get-legal-nature.ts", @@ -102,6 +103,7 @@ "./is-valid-csosn": "./src/is-valid-csosn/is-valid-csosn.ts", "./is-valid-cst": "./src/is-valid-cst/is-valid-cst.ts", "./is-valid-email": "./src/is-valid-email/is-valid-email.ts", + "./is-valid-gtin": "./src/is-valid-gtin/is-valid-gtin.ts", "./is-valid-iban": "./src/is-valid-iban/is-valid-iban.ts", "./is-valid-ie": "./src/is-valid-ie/is-valid-ie.ts", "./is-valid-landline-phone": "./src/is-valid-landline-phone/is-valid-landline-phone.ts", diff --git a/reports/api/brazilian-utils.api.md b/reports/api/brazilian-utils.api.md index 56e66f80c..abc8486eb 100644 --- a/reports/api/brazilian-utils.api.md +++ b/reports/api/brazilian-utils.api.md @@ -527,6 +527,9 @@ export const getCpfInfo: (value: string) => CpfInfo | null; // @public export const getFormatLicensePlate: (value: string) => LicensePlateFormat | null; +// @public +export const getGtinInfo: (value: string) => GtinInfo | null; + // @public export function getHolidays(year: number): Holiday[]; @@ -629,6 +632,22 @@ export const getStates: () => State[]; // @public export const getTimezoneByState: (stateCode: string) => string | null; +// @public +export type GtinInfo = { + type: GtinType; + length: GtinLength; + prefix: string; + isBrazilian: boolean; + isRestrictedCirculation: boolean; + checkDigit: number; +}; + +// @public +export type GtinLength = 8 | 12 | 13 | 14; + +// @public +export type GtinType = "GTIN-8" | "GTIN-12" | "GTIN-13" | "GTIN-14"; + // @public export type Holiday = { name: string; @@ -754,6 +773,14 @@ export type IsValidCstOptions = { // @public export const isValidEmail: (value: string) => boolean; +// @public +export const isValidGtin: (value: string, options?: IsValidGtinOptions) => boolean; + +// @public +export type IsValidGtinOptions = { + lengths?: GtinLength[]; +}; + // @public export const isValidIban: (value: string) => boolean; diff --git a/src/_internals/mod10/mod10.test.ts b/src/_internals/mod10/mod10.test.ts index 45a2d715b..7bc2f6252 100644 --- a/src/_internals/mod10/mod10.test.ts +++ b/src/_internals/mod10/mod10.test.ts @@ -17,4 +17,30 @@ describe("mod10", () => { test("should return 0 when mod is 0", () => { expect(mod10("000000000")).toBe(0); }); + + describe("with the gs1 variant", () => { + test("should calculate the 18 digit example of the GS1 General Specifications, table 7-9", () => { + expect(mod10("37610425002123456", { variant: "gs1" })).toBe(9); + }); + + test("should calculate the check digit of the GTIN examples of the GS1 General Specifications", () => { + expect(mod10("952123450001", { variant: "gs1" })).toBe(8); + expect(mod10("0952414123456", { variant: "gs1" })).toBe(4); + expect(mod10("06141411234", { variant: "gs1" })).toBe(5); + }); + + test("should return 0 when the sum is a multiple of ten", () => { + expect(mod10("0000000", { variant: "gs1" })).toBe(0); + expect(mod10("1234567", { variant: "gs1" })).toBe(0); + }); + + test("should differ from the luhn variant for the same digits", () => { + expect(mod10("952123450001")).toBe(1); + expect(mod10("952123450001", { variant: "gs1" })).toBe(8); + }); + }); + + test("should apply the luhn variant when it is named", () => { + expect(mod10("001900000", { variant: "luhn" })).toBe(9); + }); }); diff --git a/src/_internals/mod10/mod10.ts b/src/_internals/mod10/mod10.ts index 02256a033..f2de0fa0a 100644 --- a/src/_internals/mod10/mod10.ts +++ b/src/_internals/mod10/mod10.ts @@ -1,21 +1,38 @@ +type Mod10Variant = "luhn" | "gs1"; + +export type Mod10Options = { + /** Which modulo 10 rule to apply (default: `"luhn"`). */ + variant?: Mod10Variant; +}; + /** * Calculates the modulus 10 check digit for a given string. * + * Both variants weigh the digits from the right, alternating with 1. `"luhn"` uses the weight 2 + * and adds the digits of each product (the boleto, credit card and bank account rule). `"gs1"` + * uses the weight 3 and adds the products as they are (the rule of the GS1 General + * Specifications, section 7.9.1, for GTIN and the other fixed length GS1 keys). + * * @param {string} str - The string to calculate the check digit for. + * @param {Mod10Options} [options] - Optional options. + * @param {Mod10Variant} [options.variant] - The weighting rule to apply. Defaults to `"luhn"`. * @returns {number} The calculated check digit (0-9). * * @example * ```typescript * mod10("001900000"); // 9 + * mod10("37610425002123456", { variant: "gs1" }); // 9 * ``` */ -export const mod10 = (str: string): number => { +export const mod10 = (str: string, options?: Mod10Options): number => { + const isGs1 = options?.variant === "gs1"; + const weight = isGs1 ? 3 : 2; let sum = 0; const len = str.length; for (let i = 0; i < len; i++) { const digit = str.charCodeAt(len - 1 - i) - 48; - const result = digit * (i % 2 === 0 ? 2 : 1); - sum += result > 9 ? result - 9 : result; + const result = digit * (i % 2 === 0 ? weight : 1); + sum += !isGs1 && result > 9 ? result - 9 : result; } const mod = sum % 10; return mod > 0 ? 10 - mod : 0; diff --git a/src/get-gtin-info/constants.ts b/src/get-gtin-info/constants.ts new file mode 100644 index 000000000..ccf17f6a2 --- /dev/null +++ b/src/get-gtin-info/constants.ts @@ -0,0 +1,46 @@ +import { type GtinLength, type GtinType } from "./get-gtin-info"; + +/** A GTIN is written with digits only, so anything else is turned down before it is measured. */ +export const DIGITS_REGEX = /^\d+$/; + +/** + * The four GTIN structures of the GS1 General Specifications, the same four the NF-e leiaute + * accepts in `cEAN` and `cEANTrib` (NT 2021.003, chapter 3, fields I03 and I12: "GTIN-8, GTIN-12, + * GTIN-13 ou GTIN-14"). + */ +export const GTIN_LENGTHS: readonly GtinLength[] = [8, 12, 13, 14]; + +/** The name GS1 gives to the structure of each length. */ +export const GTIN_TYPES: Record = { + 8: "GTIN-8", + 12: "GTIN-12", + 13: "GTIN-13", + 14: "GTIN-14", +}; + +/** Every GTIN is compared in its 14 digit form, left padded with zeros. */ +export const NORMALIZED_LENGTH = 14; + +/** + * The six zeros a GTIN-8 gets in the 14 digit form. GS1 leaves the prefixes 0000001 to 0000099 + * unused so that no longer GTIN collides with one (General Specifications, table 1-4), and the + * "Tabela Prefixo GS1" of the Portal da NF-e reads the prefix the same way: from positions 7 to 9 + * when the first six are zeros, from positions 2 to 4 otherwise. + */ +export const GS1_8_PADDING = "000000"; + +/** The GS1 Prefixes of GS1 Brasil, the ones NT 2021.003 calls "prefixo do Brasil". */ +export const BRAZILIAN_PREFIXES: readonly string[] = ["789", "790"]; + +/** + * The GS1 Prefix ranges table 1-4 of the General Specifications sets aside for Restricted + * Circulation Numbers: 02 and 20 to 29 (within a geographic region) and 04 (within a company). + * The range 0000000 falls under the GS1-8 reading below. + */ +export const RESTRICTED_PREFIX_REGEX = /^(?:02|04|2)/; + +/** + * The GS1-8 Prefix ranges table 1-5 of the General Specifications sets aside for Restricted + * Circulation Numbers within a company: 000 to 099 and 200 to 299. + */ +export const RESTRICTED_GS1_8_PREFIX_REGEX = /^[02]/; diff --git a/src/get-gtin-info/get-gtin-info.test.ts b/src/get-gtin-info/get-gtin-info.test.ts new file mode 100644 index 000000000..e26fd8e86 --- /dev/null +++ b/src/get-gtin-info/get-gtin-info.test.ts @@ -0,0 +1,300 @@ +import * as fc from "fast-check"; + +import { anyGarbage, digits, PROTOTYPE_KEYS } from "../_internals/test/arbitraries"; +import { expectNeverThrows } from "../_internals/test/properties"; +import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; +import { type GtinInfo, type GtinLength, type GtinType, getGtinInfo } from "./get-gtin-info"; + +const withCheckDigit = (body: string): string => { + let sum = 0; + + for (let index = 0; index < body.length; index++) { + sum += Number(body.at(-1 - index)) * (index % 2 === 0 ? 3 : 1); + } + + return `${body}${(10 - (sum % 10)) % 10}`; +}; + +describe("getGtinInfo", () => { + describe("should return null", () => { + test("when the value is not a string", () => { + expect(getGtinInfo(7_890_000_000_017 as unknown as string)).toBeNull(); + expect(getGtinInfo(null as unknown as string)).toBeNull(); + expect(getGtinInfo(undefined as unknown as string)).toBeNull(); + expect(getGtinInfo({} as unknown as string)).toBeNull(); + expect(getGtinInfo(["7890000000017"] as unknown as string)).toBeNull(); + }); + + test("when the value is empty or blank", () => { + expect(getGtinInfo("")).toBeNull(); + expect(getGtinInfo(" ")).toBeNull(); + }); + + test("when the value is the literal the NF-e uses for a product without a GTIN", () => { + expect(getGtinInfo("SEM GTIN")).toBeNull(); + }); + + test("when the value is a prototype key", () => { + expect(getGtinInfo("__proto__")).toBeNull(); + expect(getGtinInfo("constructor")).toBeNull(); + }); + + test("when the value carries anything but digits", () => { + expect(getGtinInfo("789000000001a")).toBeNull(); + expect(getGtinInfo("7 890000 000017")).toBeNull(); + expect(getGtinInfo("789-0000-000017")).toBeNull(); + expect(getGtinInfo("+7890000000017")).toBeNull(); + expect(getGtinInfo("7890000000017\n1")).toBeNull(); + }); + + test("when a character next to the digits in the ASCII table would add up to the check digit", () => { + expect(getGtinInfo("78900000000:0")).toBeNull(); + }); + + test("when the length is not 8, 12, 13 or 14", () => { + expect(getGtinInfo("7")).toBeNull(); + expect(getGtinInfo("7891231")).toBeNull(); + expect(getGtinInfo("789123457")).toBeNull(); + expect(getGtinInfo("78900000017")).toBeNull(); + expect(getGtinInfo("178900000000143")).toBeNull(); + expect(getGtinInfo("376104250021234569")).toBeNull(); + }); + + test("when the check digit is wrong", () => { + expect(getGtinInfo("7890000000018")).toBeNull(); + expect(getGtinInfo("7890000000010")).toBeNull(); + expect(getGtinInfo("78912343")).toBeNull(); + expect(getGtinInfo("061414112344")).toBeNull(); + expect(getGtinInfo("17890000000013")).toBeNull(); + }); + + test("when the check digit is the Luhn one instead of the GS1 one", () => { + expect(getGtinInfo("9521234500011")).toBeNull(); + }); + + test("when two digits are swapped", () => { + expect(getGtinInfo("7890000000071")).toBeNull(); + }); + }); + + describe("should return the parsed GTIN", () => { + test("for a GTIN-13 of GS1 Brasil with the 789 prefix", () => { + expect(getGtinInfo("7890000000017")).toEqual({ + type: "GTIN-13", + length: 13, + prefix: "789", + isBrazilian: true, + isRestrictedCirculation: false, + checkDigit: 7, + }); + }); + + test("for a GTIN-13 of GS1 Brasil with the 790 prefix", () => { + expect(getGtinInfo("7901234567891")).toEqual({ + type: "GTIN-13", + length: 13, + prefix: "790", + isBrazilian: true, + isRestrictedCirculation: false, + checkDigit: 1, + }); + }); + + test("for the GTIN-13 example of the GS1 check digit page", () => { + expect(getGtinInfo("6291041500213")).toEqual({ + type: "GTIN-13", + length: 13, + prefix: "629", + isBrazilian: false, + isRestrictedCirculation: false, + checkDigit: 3, + }); + }); + + test("for the 13 digit example of the GS1 General Specifications, a GLN under the same check digit rule (demonstration prefix 952)", () => { + expect(getGtinInfo("9521234500018")).toEqual({ + type: "GTIN-13", + length: 13, + prefix: "952", + isBrazilian: false, + isRestrictedCirculation: false, + checkDigit: 8, + }); + }); + + test("for a GTIN-8, reading the GS1-8 Prefix", () => { + expect(getGtinInfo("78912342")).toEqual({ + type: "GTIN-8", + length: 8, + prefix: "789", + isBrazilian: true, + isRestrictedCirculation: false, + checkDigit: 2, + }); + }); + + test("for a GTIN-12, whose GS1 Prefix carries the implied leading zero", () => { + expect(getGtinInfo("061414112345")).toEqual({ + type: "GTIN-12", + length: 12, + prefix: "006", + isBrazilian: false, + isRestrictedCirculation: false, + checkDigit: 5, + }); + }); + + test("for a GTIN-14, reading the prefix after the indicator digit", () => { + expect(getGtinInfo("17890000000014")).toEqual({ + type: "GTIN-14", + length: 14, + prefix: "789", + isBrazilian: true, + isRestrictedCirculation: false, + checkDigit: 4, + }); + }); + + test("for the 14 digit example of the GS1 General Specifications, a GRAI read here as a GTIN-14", () => { + expect(getGtinInfo("09524141234564")).toEqual({ + type: "GTIN-14", + length: 14, + prefix: "952", + isBrazilian: false, + isRestrictedCirculation: false, + checkDigit: 4, + }); + }); + + test("for a GTIN-8 written in the 14 digit form, reading the GS1-8 Prefix", () => { + expect(getGtinInfo("00000078912342")).toEqual({ + type: "GTIN-14", + length: 14, + prefix: "789", + isBrazilian: true, + isRestrictedCirculation: false, + checkDigit: 2, + }); + }); + + test("with surrounding whitespace", () => { + expect(getGtinInfo(" 7890000000017\n")?.prefix).toBe("789"); + }); + + test("with a check digit of zero", () => { + expect(getGtinInfo("12345670")?.checkDigit).toBe(0); + }); + + test("telling the neighbours of the Brazilian prefixes apart", () => { + expect(getGtinInfo("7880000000018")?.isBrazilian).toBe(false); + expect(getGtinInfo("7910000000012")?.isBrazilian).toBe(false); + expect(getGtinInfo("17890000000014")?.isBrazilian).toBe(true); + expect(getGtinInfo("0789000000004")?.isBrazilian).toBe(false); + }); + }); + + describe("should flag the Restricted Circulation Number ranges", () => { + test("for the GS1 Prefixes 02, 04 and 20 to 29", () => { + expect(getGtinInfo("0200000000011")?.isRestrictedCirculation).toBe(true); + expect(getGtinInfo("0400000000015")?.isRestrictedCirculation).toBe(true); + expect(getGtinInfo("2000000000015")?.isRestrictedCirculation).toBe(true); + expect(getGtinInfo("2000000000015")?.prefix).toBe("200"); + expect(getGtinInfo("12000000000012")?.isRestrictedCirculation).toBe(true); + }); + + test("for the U.P.C. Prefixes 2 and 4 of a GTIN-12", () => { + expect(getGtinInfo("200000000004")?.isRestrictedCirculation).toBe(true); + expect(getGtinInfo("400000000008")?.isRestrictedCirculation).toBe(true); + }); + + test("for the GS1-8 Prefixes 000 to 099 and 200 to 299", () => { + expect(getGtinInfo("01234565")?.isRestrictedCirculation).toBe(true); + expect(getGtinInfo("20000004")?.isRestrictedCirculation).toBe(true); + }); + + test("for the GS1 Prefix 0000000, which reads as a GS1-8 Prefix that starts with zero", () => { + expect(getGtinInfo("0000000123457")?.isRestrictedCirculation).toBe(true); + expect(getGtinInfo("0000000123457")?.prefix).toBe("001"); + }); + + test("but not for the ranges that only look alike", () => { + expect(getGtinInfo("030000000014")?.isRestrictedCirculation).toBe(false); + expect(getGtinInfo("050000000012")?.isRestrictedCirculation).toBe(false); + expect(getGtinInfo("11200000000000")?.isRestrictedCirculation).toBe(false); + expect(getGtinInfo("9020000000009")?.isRestrictedCirculation).toBe(false); + expect(getGtinInfo("40000008")?.isRestrictedCirculation).toBe(false); + expect(getGtinInfo("30000001")?.isRestrictedCirculation).toBe(false); + expect(getGtinInfo("12345670")?.isRestrictedCirculation).toBe(false); + }); + + test("and not for the ISSN and ISBN ranges", () => { + expect(getGtinInfo("9771234567003")?.isRestrictedCirculation).toBe(false); + expect(getGtinInfo("9780000000019")?.prefix).toBe("978"); + }); + }); + + describe("properties", () => { + const lengths = fc.constantFrom(8, 12, 13, 14); + const gtins = lengths.chain((length) => digits(length - 1).map((body) => withCheckDigit(body))); + + test("should give back the length and the check digit of every well-formed GTIN", () => { + fc.assert( + fc.property(gtins, (gtin) => { + const parsed = getGtinInfo(gtin); + + expect(parsed?.length).toBe(gtin.length); + expect(parsed?.type).toBe(`GTIN-${gtin.length}`); + expect(parsed?.checkDigit).toBe(Number(gtin.at(-1))); + expect(parsed?.prefix).toMatch(/^\d{3}$/); + }), + ); + }); + + test("should turn down every other check digit", () => { + fc.assert( + fc.property(gtins, fc.integer({ min: 1, max: 9 }), (gtin, shift) => { + const wrong = (Number(gtin.at(-1)) + shift) % 10; + + expect(getGtinInfo(`${gtin.slice(0, -1)}${wrong}`)).toBeNull(); + }), + ); + }); + + test("should call Brazilian exactly the 13 digit values that start with 789 or 790", () => { + fc.assert( + fc.property( + digits(12).map((body) => withCheckDigit(body)), + (gtin) => { + const expected = gtin.startsWith("789") || gtin.startsWith("790"); + + fc.pre(!gtin.startsWith("00000")); + + expect(getGtinInfo(gtin)?.isBrazilian).toBe(expected); + }, + ), + ); + }); + + test("should never throw", () => { + expectNeverThrows(getGtinInfo, fc.oneof(fc.anything(), anyGarbage)); + expectNeverThrows(getGtinInfo, fc.constantFrom(...PROTOTYPE_KEYS)); + }); + }); +}); + +describe("getGtinInfo types", () => { + test("should take a string and return a GtinInfo or null", () => { + expectTypeOf(getGtinInfo).parameter(0).toEqualTypeOf(); + expectTypeOf(getGtinInfo).returns.toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<{ + type: GtinType; + length: GtinLength; + prefix: string; + isBrazilian: boolean; + isRestrictedCirculation: boolean; + checkDigit: number; + }>(); + expectTypeOf().toEqualTypeOf<8 | 12 | 13 | 14>(); + expectTypeOf().toEqualTypeOf<"GTIN-8" | "GTIN-12" | "GTIN-13" | "GTIN-14">(); + }); +}); diff --git a/src/get-gtin-info/get-gtin-info.ts b/src/get-gtin-info/get-gtin-info.ts new file mode 100644 index 000000000..882ba6617 --- /dev/null +++ b/src/get-gtin-info/get-gtin-info.ts @@ -0,0 +1,132 @@ +import { mod10 } from "../_internals/mod10/mod10"; +import { + BRAZILIAN_PREFIXES, + DIGITS_REGEX, + GS1_8_PADDING, + GTIN_LENGTHS, + GTIN_TYPES, + NORMALIZED_LENGTH, + RESTRICTED_GS1_8_PREFIX_REGEX, + RESTRICTED_PREFIX_REGEX, +} from "./constants"; + +/** How many digits a GTIN may be written with. */ +export type GtinLength = 8 | 12 | 13 | 14; + +/** The GS1 name of each GTIN structure. */ +export type GtinType = "GTIN-8" | "GTIN-12" | "GTIN-13" | "GTIN-14"; + +/** The fields `getGtinInfo` reads out of a GTIN (Global Trade Item Number). */ +export type GtinInfo = { + /** GS1 name of the structure the value was written in. */ + type: GtinType; + /** How many digits the value was written with. */ + length: GtinLength; + /** + * The three digit GS1 Prefix, or a GS1-8 Prefix when the first six digits of the 14 digit form + * are zeros, which covers every GTIN-8 and the GS1 Prefix 0000000. It names the GS1 Member + * Organisation that licensed the number, not the country of origin. + */ + prefix: string; + /** True when the prefix is one of GS1 Brasil (789 or 790). */ + isBrazilian: boolean; + /** + * True when the prefix is in a range GS1 sets aside for Restricted Circulation Numbers, so the + * number is only unique inside a company or region and is not a globally unique GTIN. + */ + isRestrictedCirculation: boolean; + /** The modulo 10 check digit, the last digit of the value. */ + checkDigit: number; +}; + +const GS1_8_PREFIX_START = 6; + +const PREFIX_START = 1; + +const PREFIX_LENGTH = 3; + +/** + * Parses a GTIN (Global Trade Item Number, the number under an EAN/UPC barcode) into its fields. + * + * Covers the four structures of the GS1 General Specifications, the same four the NF-e accepts + * in `cEAN` and `cEANTrib`: GTIN-8, GTIN-12 (UPC), GTIN-13 (EAN) and GTIN-14 (DUN-14). The value + * must be a string of 8, 12, 13 or 14 digits, surrounding whitespace aside, whose last digit is + * the GS1 modulo 10 check digit: weights 3 and 1 alternating from the right, the sum subtracted + * from the nearest equal or higher multiple of ten. Anything else returns `null`, including the + * `"SEM GTIN"` literal the NF-e uses for a product without one. Leading zeros count, so the value + * is never read from a number. + * + * `type` and `length` describe the value as it was written. The prefix is read the way the + * "Tabela Prefixo GS1" of the Portal da NF-e tells: the value is left padded with zeros to 14 + * digits, and the prefix is positions 7 to 9 when the first six are zeros (a GTIN-8) and + * positions 2 to 4 otherwise. A GTIN-12 therefore has a prefix that starts with `0`, and a + * GTIN-14 has the prefix of the GTIN-13 it packs, after the indicator digit. + * + * Only the prefixes of GS1 Brasil (789 and 790) and the Restricted Circulation Number ranges of + * the General Specifications are told apart, since both are fixed by a standard or a rule. The + * prefix is not checked against the list of Member Organisations: GS1 keeps assigning ranges, so + * a copy of that list would turn down valid numbers as it ages. SEFAZ does run that check + * (rules I03-20 and I12-20 of NT 2021.003, against its own "Tabela Prefixo GS1", which lists the + * restricted and special ranges as valid) and, for the 789 and 790 prefixes, looks the number up + * in the Cadastro Centralizado de GTIN, which no offline check can stand in for. + * + * @param {string} value - The GTIN to be parsed, digits only. + * @returns {GtinInfo | null} The parsed GTIN, or `null` when it is not valid. + * + * @see Official: https://ref.gs1.org/standards/genspecs/ + * GS1 General Specifications, release 26.0: section 7.9.1 (check digit, tables 7-8 and 7-9), + * tables 1-4 and 1-5 (GS1 Prefix and GS1-8 Prefix ranges, Restricted Circulation Numbers). + * @see Official: https://www.gs1.org/services/how-calculate-check-digit-manually + * GS1, "How to calculate a check digit manually". + * @see Official: https://www.nfe.fazenda.gov.br/portal/exibirArquivo.aspx?conteudo=SrQT9ys8ODo%3D + * SEFAZ Nota Técnica 2021.003 (Validação GTIN, replaces NT 2017.001): fields I03 `cEAN` and I12 + * `cEANTrib`, rules I03-10, I03-20, I12-10, I12-20, 9I03-10 and 9I12-10 ("prefixo do Brasil + * (iniciado em 789 ou 790)"). + * @see Official: https://www.nfe.fazenda.gov.br/portal/exibirArquivo.aspx?conteudo=Oc+fygAxwmc%3D + * "Tabela Prefixo GS1" of the Portal da NF-e: how to read the prefix from the 14 digit form, and + * the range 789 to 790 for GS1 Brasil. + * + * @example + * ```typescript + * getGtinInfo("7890000000017"); + * // { type: "GTIN-13", length: 13, prefix: "789", isBrazilian: true, + * // isRestrictedCirculation: false, checkDigit: 7 } + * + * getGtinInfo("17890000000014"); + * // { type: "GTIN-14", length: 14, prefix: "789", isBrazilian: true, + * // isRestrictedCirculation: false, checkDigit: 4 } + * + * getGtinInfo("7890000000018"); // null (wrong check digit) + * getGtinInfo("SEM GTIN"); // null + * ``` + */ +export const getGtinInfo = (value: string): GtinInfo | null => { + if (typeof value !== "string") return null; + + const digits = value.trim(); + + if (!DIGITS_REGEX.test(digits)) return null; + + const length = GTIN_LENGTHS.find((candidate) => candidate === digits.length); + + if (length === undefined) return null; + + const checkDigit = Number(digits.at(-1)); + + if (mod10(digits.slice(0, -1), { variant: "gs1" }) !== checkDigit) return null; + + const normalized = digits.padStart(NORMALIZED_LENGTH, "0"); + const isGs1Eight = normalized.startsWith(GS1_8_PADDING); + const start = isGs1Eight ? GS1_8_PREFIX_START : PREFIX_START; + const prefix = normalized.slice(start, start + PREFIX_LENGTH); + const restrictedRegex = isGs1Eight ? RESTRICTED_GS1_8_PREFIX_REGEX : RESTRICTED_PREFIX_REGEX; + + return { + type: GTIN_TYPES[length], + length, + prefix, + isBrazilian: BRAZILIAN_PREFIXES.includes(prefix), + isRestrictedCirculation: restrictedRegex.test(prefix), + checkDigit, + }; +}; diff --git a/src/index.test.ts b/src/index.test.ts index ab00644ff..136f06759 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -58,6 +58,9 @@ import { type GetMunicipalityByNameParams, type GetMunicipalityOptions, type GetMunicipalityParams, + type GtinInfo, + type GtinLength, + type GtinType, type Holiday, type HolidayType, type IbanInfo, @@ -68,6 +71,7 @@ import { type IsValidCertidaoOptions, type IsValidCnpjOptions, type IsValidCstOptions, + type IsValidGtinOptions, type IsValidIeParams, type IsValidMobilePhoneOptions, type IsValidPhoneOptions, @@ -180,6 +184,7 @@ const PUBLIC = [ "getCnpjInfo", "getCpfInfo", "getFormatLicensePlate", + "getGtinInfo", "getHolidays", "getIbanInfo", "getLegalNature", @@ -220,6 +225,7 @@ const PUBLIC = [ "isValidCsosn", "isValidCst", "isValidEmail", + "isValidGtin", "isValidIE", "isValidIban", "isValidIe", @@ -357,6 +363,9 @@ describe("Public API", () => { GetMunicipalityByNameParams: GetMunicipalityByNameParams; GetMunicipalityOptions: GetMunicipalityOptions; GetMunicipalityParams: GetMunicipalityParams; + GtinInfo: GtinInfo; + GtinLength: GtinLength; + GtinType: GtinType; Holiday: Holiday; HolidayType: HolidayType; IbanInfo: IbanInfo; @@ -367,6 +376,7 @@ describe("Public API", () => { IsValidCertidaoOptions: IsValidCertidaoOptions; IsValidCnpjOptions: IsValidCnpjOptions; IsValidCstOptions: IsValidCstOptions; + IsValidGtinOptions: IsValidGtinOptions; IsValidIeParams: IsValidIeParams; IsValidMobilePhoneOptions: IsValidMobilePhoneOptions; IsValidPhoneOptions: IsValidPhoneOptions; diff --git a/src/index.ts b/src/index.ts index b8cdef904..4cf115cde 100644 --- a/src/index.ts +++ b/src/index.ts @@ -122,6 +122,12 @@ export { getFormatLicensePlate, type LicensePlateFormat, } from "./get-format-license-plate/get-format-license-plate"; +export { + type GtinInfo, + type GtinLength, + type GtinType, + getGtinInfo, +} from "./get-gtin-info/get-gtin-info"; export { type GetHolidaysParams, type Holiday, @@ -194,6 +200,7 @@ export { isValidCreditCard } from "./is-valid-credit-card/is-valid-credit-card"; export { isValidCsosn } from "./is-valid-csosn/is-valid-csosn"; export { type IsValidCstOptions, isValidCst } from "./is-valid-cst/is-valid-cst"; export { isValidEmail } from "./is-valid-email/is-valid-email"; +export { type IsValidGtinOptions, isValidGtin } from "./is-valid-gtin/is-valid-gtin"; export { isValidIban } from "./is-valid-iban/is-valid-iban"; export { type IsValidIeParams, isValidIe } from "./is-valid-ie/is-valid-ie"; export { isValidLandlinePhone } from "./is-valid-landline-phone/is-valid-landline-phone"; diff --git a/src/is-valid-gtin/is-valid-gtin.test.ts b/src/is-valid-gtin/is-valid-gtin.test.ts new file mode 100644 index 000000000..fcd879449 --- /dev/null +++ b/src/is-valid-gtin/is-valid-gtin.test.ts @@ -0,0 +1,171 @@ +import * as fc from "fast-check"; + +import { + anyGarbage, + anyText, + digitsOfOtherLength, + PROTOTYPE_KEYS, +} from "../_internals/test/arbitraries"; +import { + expectAlwaysReturnsType, + expectNeverThrowsWithOptions, + expectRejected, +} from "../_internals/test/properties"; +import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; +import { type GtinLength, type IsValidGtinOptions, isValidGtin } from "./is-valid-gtin"; + +const GTIN_8 = "78912342"; +const GTIN_12 = "061414112345"; +const GTIN_13 = "7890000000017"; +const GTIN_14 = "17890000000014"; + +describe("isValidGtin", () => { + describe("should return true", () => { + test("for a GTIN-8", () => { + expect(isValidGtin(GTIN_8)).toBe(true); + }); + + test("for a GTIN-12 built from the U.P.C. Company Prefix 614141 of the GS1 General Specifications", () => { + expect(isValidGtin(GTIN_12)).toBe(true); + }); + + test("for a GTIN-13 with each prefix of GS1 Brasil", () => { + expect(isValidGtin(GTIN_13)).toBe(true); + expect(isValidGtin("7901234567891")).toBe(true); + }); + + test("for a GTIN-14", () => { + expect(isValidGtin(GTIN_14)).toBe(true); + }); + + test("for the numbers GS1 publishes as examples: a GTIN-13, a GLN and a GRAI", () => { + expect(isValidGtin("6291041500213")).toBe(true); + expect(isValidGtin("9521234500018")).toBe(true); + expect(isValidGtin("09524141234564")).toBe(true); + }); + + test("for a prefix that is not Brazilian", () => { + expect(isValidGtin("7880000000018")).toBe(true); + }); + + test("for a Restricted Circulation Number and for an ISSN or ISBN, which share the structure", () => { + expect(isValidGtin("2000000000015")).toBe(true); + expect(isValidGtin("0400000000015")).toBe(true); + expect(isValidGtin("9771234567003")).toBe(true); + expect(isValidGtin("9780000000019")).toBe(true); + }); + + test("with surrounding whitespace", () => { + expect(isValidGtin(` ${GTIN_13}\t`)).toBe(true); + }); + }); + + describe("should return false", () => { + test("when the value is not a string", () => { + expect(isValidGtin(7_890_000_000_017 as unknown as string)).toBe(false); + expect(isValidGtin(null as unknown as string)).toBe(false); + expect(isValidGtin(undefined as unknown as string)).toBe(false); + expect(isValidGtin({} as unknown as string)).toBe(false); + expect(isValidGtin([GTIN_13] as unknown as string)).toBe(false); + }); + + test("when the value is empty", () => { + expect(isValidGtin("")).toBe(false); + }); + + test("when the value is the literal the NF-e uses for a product without a GTIN", () => { + expect(isValidGtin("SEM GTIN")).toBe(false); + }); + + test("when the check digit is wrong", () => { + expect(isValidGtin("78912343")).toBe(false); + expect(isValidGtin("061414112346")).toBe(false); + expect(isValidGtin("7890000000018")).toBe(false); + expect(isValidGtin("17890000000015")).toBe(false); + }); + + test("when the length is not 8, 12, 13 or 14", () => { + expect(isValidGtin("789000017")).toBe(false); + expect(isValidGtin("78900000017")).toBe(false); + expect(isValidGtin("376104250021234569")).toBe(false); + }); + + test("when the value carries a mask or a letter", () => { + expect(isValidGtin("7 890000 000017")).toBe(false); + expect(isValidGtin("789000000001X")).toBe(false); + }); + }); + + describe("with the lengths option", () => { + test("should accept only the lengths it lists", () => { + expect(isValidGtin(GTIN_13, { lengths: [13] })).toBe(true); + expect(isValidGtin(GTIN_8, { lengths: [13] })).toBe(false); + expect(isValidGtin(GTIN_12, { lengths: [13] })).toBe(false); + expect(isValidGtin(GTIN_14, { lengths: [13] })).toBe(false); + expect(isValidGtin(GTIN_14, { lengths: [8, 12, 13] })).toBe(false); + expect(isValidGtin(GTIN_8, { lengths: [8, 14] })).toBe(true); + expect(isValidGtin(GTIN_14, { lengths: [8, 14] })).toBe(true); + }); + + test("should accept nothing when the list is empty", () => { + expect(isValidGtin(GTIN_13, { lengths: [] })).toBe(false); + }); + + test("should still turn down a wrong check digit of a listed length", () => { + expect(isValidGtin("7890000000018", { lengths: [13] })).toBe(false); + }); + + test("should accept the four lengths when it is left out or is not a list", () => { + expect(isValidGtin(GTIN_8, {})).toBe(true); + expect(isValidGtin(GTIN_14, { lengths: undefined })).toBe(true); + expect(isValidGtin(GTIN_13, null as unknown as IsValidGtinOptions)).toBe(true); + expect(isValidGtin(GTIN_13, { lengths: "8" } as unknown as IsValidGtinOptions)).toBe(true); + expect(isValidGtin(GTIN_13, { lengths: 8 } as unknown as IsValidGtinOptions)).toBe(true); + }); + }); + + describe("properties", () => { + test("should reject every digit string of another length", () => { + expectRejected(isValidGtin, digitsOfOtherLength(20, [8, 12, 13, 14])); + }); + + test("should accept exactly one check digit for any 12 digit body", () => { + fc.assert( + fc.property(fc.stringMatching(/^[0-9]{12}$/), (body) => { + const accepted = Array.from({ length: 10 }, (_, digit) => `${body}${digit}`).filter( + (candidate) => isValidGtin(candidate), + ); + + expect(accepted).toHaveLength(1); + }), + ); + }); + + test("should never accept with a list of lengths what it turns down without one", () => { + fc.assert( + fc.property( + fc.stringMatching(/^[0-9]{8,14}$/), + fc.subarray([8, 12, 13, 14]), + (value, lengths) => { + expect(isValidGtin(value, { lengths }) && !isValidGtin(value)).toBe(false); + }, + ), + ); + }); + + test("should always return a boolean and never throw", () => { + expectAlwaysReturnsType(isValidGtin, "boolean", fc.oneof(anyText, fc.anything())); + expectAlwaysReturnsType(isValidGtin, "boolean", fc.constantFrom(...PROTOTYPE_KEYS)); + expectNeverThrowsWithOptions(isValidGtin, fc.oneof(anyText, anyGarbage), anyGarbage); + }); + }); +}); + +describe("isValidGtin types", () => { + test("should take a string and optional options and return a boolean", () => { + expectTypeOf(isValidGtin).parameter(0).toEqualTypeOf(); + expectTypeOf(isValidGtin).parameter(1).toEqualTypeOf(); + expectTypeOf(isValidGtin).returns.toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<{ lengths?: GtinLength[] }>(); + }); +}); diff --git a/src/is-valid-gtin/is-valid-gtin.ts b/src/is-valid-gtin/is-valid-gtin.ts new file mode 100644 index 000000000..f2239515c --- /dev/null +++ b/src/is-valid-gtin/is-valid-gtin.ts @@ -0,0 +1,65 @@ +import { type GtinLength, getGtinInfo } from "../get-gtin-info/get-gtin-info"; + +export type { GtinLength } from "../get-gtin-info/get-gtin-info"; + +/** Options of `isValidGtin`. */ +export type IsValidGtinOptions = { + /** The lengths to accept (default: all four, 8, 12, 13 and 14). */ + lengths?: GtinLength[]; +}; + +/** + * Validates a GTIN (Global Trade Item Number, the number under an EAN/UPC barcode). + * + * Covers the four structures of the GS1 General Specifications, the same four the NF-e accepts + * in `cEAN` and `cEANTrib`: GTIN-8, GTIN-12 (UPC), GTIN-13 (EAN) and GTIN-14 (DUN-14). The value + * must be a string of 8, 12, 13 or 14 digits, surrounding whitespace aside, whose last digit is + * the GS1 modulo 10 check digit: weights 3 and 1 alternating from the right, the sum subtracted + * from the nearest equal or higher multiple of ten. This is what rules I03-10 and I12-10 of the + * NF-e check (rejections 611 and 612). The `"SEM GTIN"` literal the NF-e uses for a product + * without a GTIN is not a GTIN, so it is not valid here. Leading zeros count, so a number is + * never accepted. + * + * The prefix does not change the verdict: Restricted Circulation Numbers (prefixes 02, 04 and 20 + * to 29, the codes a shop prints on its own scale labels) and the ISSN, ISBN and coupon ranges + * share the structure and the check digit, and the "Tabela Prefixo GS1" SEFAZ validates `cEAN` + * against lists them as valid. `getGtinInfo` tells the restricted and the Brazilian prefixes + * apart for the caller that needs to. Whether the number is registered with GS1 (the Cadastro + * Centralizado de GTIN lookup of rules 9I03-10 and 9I12-10) cannot be checked offline. + * + * @param {string} value - The GTIN to be validated, digits only. + * @param {IsValidGtinOptions} [options] - Optional options. + * @param {GtinLength[]} [options.lengths] - The lengths to accept. Defaults to all four. + * @returns {boolean} True if the GTIN is valid, false otherwise. + * + * @see Official: https://ref.gs1.org/standards/genspecs/ + * GS1 General Specifications, release 26.0: section 7.9.1 (check digit, tables 7-8 and 7-9). + * @see Official: https://www.gs1.org/services/how-calculate-check-digit-manually + * GS1, "How to calculate a check digit manually", source of the 6291041500213 example. + * @see Official: https://www.nfe.fazenda.gov.br/portal/exibirArquivo.aspx?conteudo=SrQT9ys8ODo%3D + * SEFAZ Nota Técnica 2021.003 (Validação GTIN, replaces NT 2017.001): fields I03 `cEAN` and I12 + * `cEANTrib`, rules I03-10 and I12-10. + * @see Official: https://www.nfe.fazenda.gov.br/portal/exibirArquivo.aspx?conteudo=Oc+fygAxwmc%3D + * "Tabela Prefixo GS1" of the Portal da NF-e, the prefix ranges rules I03-20 and I12-20 accept. + * + * @example + * ```typescript + * isValidGtin("7890000000017"); // true (GTIN-13, GS1 Brasil prefix) + * isValidGtin("6291041500213"); // true (the GS1 example) + * isValidGtin("78912342"); // true (GTIN-8) + * isValidGtin("061414112345"); // true (GTIN-12) + * isValidGtin("17890000000014"); // true (GTIN-14) + * isValidGtin("7890000000018"); // false (wrong check digit) + * isValidGtin("17890000000014", { lengths: [8, 12, 13] }); // false (GTIN-14 not accepted) + * isValidGtin("SEM GTIN"); // false + * ``` + */ +export const isValidGtin = (value: string, options?: IsValidGtinOptions): boolean => { + const info = getGtinInfo(value); + + if (info === null) return false; + + const lengths = options?.lengths; + + return Array.isArray(lengths) ? lengths.includes(info.length) : true; +};