From cab96d4909d7aefc1c7878a9a3694746caebfce9 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:57:07 -0300 Subject: [PATCH 1/3] refactor(mod10): add the gs1 variant next to the luhn one GS1 keys use a modulo 10 that differs from the Luhn one only in the weight (3 instead of 2) and in adding the products as they are instead of their digits (GS1 General Specifications, section 7.9.1). It comes as `options.variant`, the way `mod11` takes its variants, with `"luhn"` as the default so the boleto, credit card, bank account and IE callers are untouched. --- src/_internals/mod10/mod10.test.ts | 26 ++++++++++++++++++++++++++ src/_internals/mod10/mod10.ts | 23 ++++++++++++++++++++--- 2 files changed, 46 insertions(+), 3 deletions(-) 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; From e609011574ba8ebf0b89c32b2cc1f0ca79078ead Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:57:07 -0300 Subject: [PATCH 2/3] feat(gtin): add isValidGtin and getGtinInfo The NF-e requires a valid GTIN in `cEAN` and `cEANTrib` (rules I03-10 and I12-10 of SEFAZ NT 2021.003, rejections 611 and 612), so the product barcode sits next to the NCM, CFOP and CST utilities. `isValidGtin(value, options?)` covers GTIN-8, GTIN-12, GTIN-13 and GTIN-14 with the GS1 modulo 10 check digit and takes `lengths` to accept only some of them. `getGtinInfo(value)` returns the type, the length, the three digit GS1 Prefix read from the 14 digit form the way the "Tabela Prefixo GS1" of the Portal da NF-e tells, whether it is one of GS1 Brasil (789, 790), whether it falls in a Restricted Circulation Number range of the General Specifications (tables 1-4 and 1-5) and the check digit. The prefix never changes the verdict: the SEFAZ table lists the restricted, ISSN, ISBN and coupon ranges as valid, and a copy of the Member Organisation list would turn down valid numbers as GS1 assigns new ranges. The vectors are the examples GS1 publishes (6291041500213, 9521234500018, 09524141234564, 061414112345) and synthetic values worked out by hand. --- docs/pt-br/utilities.md | 65 +++++ docs/utilities.md | 65 +++++ jsr.json | 2 + reports/api/brazilian-utils.api.md | 27 +++ src/get-gtin-info/constants.ts | 46 ++++ src/get-gtin-info/get-gtin-info.test.ts | 300 ++++++++++++++++++++++++ src/get-gtin-info/get-gtin-info.ts | 131 +++++++++++ src/index.test.ts | 10 + src/index.ts | 7 + src/is-valid-gtin/is-valid-gtin.test.ts | 171 ++++++++++++++ src/is-valid-gtin/is-valid-gtin.ts | 64 +++++ 11 files changed, 888 insertions(+) create mode 100644 src/get-gtin-info/constants.ts create mode 100644 src/get-gtin-info/get-gtin-info.test.ts create mode 100644 src/get-gtin-info/get-gtin-info.ts create mode 100644 src/is-valid-gtin/is-valid-gtin.test.ts create mode 100644 src/is-valid-gtin/is-valid-gtin.ts diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index dd08b659b..40c8474bc 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 próximo múltiplo de dez. É 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 (Prefixo GS1-8 em um GTIN-8). 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), 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..9edfad3d0 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 next 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 (GS1-8 Prefix for a GTIN-8). 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), 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/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..ab9848820 --- /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 GTIN-13 example of the GS1 General Specifications (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 GTIN-13 with a leading zero", () => { + 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..ac6edaf47 --- /dev/null +++ b/src/get-gtin-info/get-gtin-info.ts @@ -0,0 +1,131 @@ +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 GS1-8 Prefix for a GTIN-8, read from the 14 digit form. 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 next 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..b0e03b464 --- /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, the U.P.C. example 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 examples GS1 publishes", () => { + 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..205173454 --- /dev/null +++ b/src/is-valid-gtin/is-valid-gtin.ts @@ -0,0 +1,64 @@ +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 next 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; +}; From 3b5340a98a755ee108cf07e804f5178eb436d408 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:45:01 -0300 Subject: [PATCH 3/3] docs(gtin): use the GS1 wording and credit the example numbers right The check digit paragraphs said the sum is subtracted from "the next multiple of ten". Read literally that gives 10 for a sum that already is a multiple of ten. Table 7-8 of the General Specifications says "nearest equal or higher multiple of ten", which is what the code does, so use that phrasing in both JSDoc blocks and in both docs pages. Three test titles credited GS1 numbers to the wrong key: 9521234500018 appears in the General Specifications as a GLN, 09524141234564 as a GRAI, and 061414112345 is the 12 digit body of a GTIN-13 built on the U.P.C. Company Prefix 614141, not a published GTIN-12. All three are valid vectors, since section 7.9.1 is the same rule for every fixed length GS1 key, so only the titles change. The isRestrictedCirculation row of both docs listed three restricted ranges of table 1-4 and left out the GS1 Prefix 0000000, which the code already flags through the GS1-8 reading and which the tests already cover. Say so in the row, and widen the prefix description from "GS1-8 Prefix for a GTIN-8" to the condition the code tests: the first six digits of the 14 digit form are zeros. --- docs/pt-br/utilities.md | 6 +++--- docs/utilities.md | 6 +++--- src/get-gtin-info/get-gtin-info.test.ts | 4 ++-- src/get-gtin-info/get-gtin-info.ts | 11 ++++++----- src/is-valid-gtin/is-valid-gtin.test.ts | 4 ++-- src/is-valid-gtin/is-valid-gtin.ts | 7 ++++--- 6 files changed, 20 insertions(+), 18 deletions(-) diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index 40c8474bc..7e633d651 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -2763,7 +2763,7 @@ Verifica se um GTIN (Global Trade Item Number, o número sob um código de barra - 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 próximo múltiplo de dez. É o que as regras I03-10 e I12-10 da Nota Técnica 2021.003 da SEFAZ verificam (rejeições 611 e 612). +- 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. @@ -2794,9 +2794,9 @@ Extrai os campos de um GTIN, como um `GtinInfo`. | --- | --- | | `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 (Prefixo GS1-8 em um GTIN-8). Identifica a Organização Membro da GS1 que licenciou o número, não o país de origem | +| `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), ou seja, o número só é único dentro de uma empresa ou região | +| `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 diff --git a/docs/utilities.md b/docs/utilities.md index 9edfad3d0..f8abed09d 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -2763,7 +2763,7 @@ Check if 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). - **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 next multiple of ten. That is what rules I03-10 and I12-10 of SEFAZ Nota Técnica 2021.003 check (rejections 611 and 612). +- 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. @@ -2794,9 +2794,9 @@ Parse a GTIN into its fields, as a `GtinInfo`. | --- | --- | | `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 (GS1-8 Prefix for a GTIN-8). It names the GS1 Member Organisation that licensed the number, not the country of origin | +| `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), so the number is only unique inside a company or region | +| `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 diff --git a/src/get-gtin-info/get-gtin-info.test.ts b/src/get-gtin-info/get-gtin-info.test.ts index ab9848820..e26fd8e86 100644 --- a/src/get-gtin-info/get-gtin-info.test.ts +++ b/src/get-gtin-info/get-gtin-info.test.ts @@ -111,7 +111,7 @@ describe("getGtinInfo", () => { }); }); - test("for the GTIN-13 example of the GS1 General Specifications (demonstration prefix 952)", () => { + 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, @@ -155,7 +155,7 @@ describe("getGtinInfo", () => { }); }); - test("for the 14 digit example of the GS1 General Specifications, a GTIN-13 with a leading zero", () => { + 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, diff --git a/src/get-gtin-info/get-gtin-info.ts b/src/get-gtin-info/get-gtin-info.ts index ac6edaf47..882ba6617 100644 --- a/src/get-gtin-info/get-gtin-info.ts +++ b/src/get-gtin-info/get-gtin-info.ts @@ -23,8 +23,9 @@ export type GtinInfo = { /** How many digits the value was written with. */ length: GtinLength; /** - * The three digit GS1 Prefix, or GS1-8 Prefix for a GTIN-8, read from the 14 digit form. It - * names the GS1 Member Organisation that licensed the number, not the country of origin. + * 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). */ @@ -51,9 +52,9 @@ const PREFIX_LENGTH = 3; * 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 next 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. + * 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 diff --git a/src/is-valid-gtin/is-valid-gtin.test.ts b/src/is-valid-gtin/is-valid-gtin.test.ts index b0e03b464..fcd879449 100644 --- a/src/is-valid-gtin/is-valid-gtin.test.ts +++ b/src/is-valid-gtin/is-valid-gtin.test.ts @@ -25,7 +25,7 @@ describe("isValidGtin", () => { expect(isValidGtin(GTIN_8)).toBe(true); }); - test("for a GTIN-12, the U.P.C. example of the GS1 General Specifications", () => { + 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); }); @@ -38,7 +38,7 @@ describe("isValidGtin", () => { expect(isValidGtin(GTIN_14)).toBe(true); }); - test("for the examples GS1 publishes", () => { + 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); diff --git a/src/is-valid-gtin/is-valid-gtin.ts b/src/is-valid-gtin/is-valid-gtin.ts index 205173454..f2239515c 100644 --- a/src/is-valid-gtin/is-valid-gtin.ts +++ b/src/is-valid-gtin/is-valid-gtin.ts @@ -15,9 +15,10 @@ export type IsValidGtinOptions = { * 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 next 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. + * 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