diff --git a/SUMMARY.md b/SUMMARY.md index 6174a65..cfd51ea 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -17,3 +17,5 @@ - [Telefone/Celular](docs/UTILITARIOS/TELEFONE.md) - [UUID](docs/UTILITARIOS/UUID.md) - [Boleto](docs/UTILITARIOS/BOLETO.md) + - [Extensões](docs/EXTENSOES/README.md) + - [Joi](docs/EXTENSOES/JOI.md) diff --git a/docs/EXTENSOES/JOI.md b/docs/EXTENSOES/JOI.md new file mode 100644 index 0000000..9258111 --- /dev/null +++ b/docs/EXTENSOES/JOI.md @@ -0,0 +1,74 @@ +# Joi + +## Integração com br-docs + +A extensão de Joi do **br-docs** adiciona um novo tipo chamado `document` com regras específicas para documentos brasileiros. + +### Instalação + +```bash +npm i br-docs joi +``` + +### Uso básico + +```typescript +import Joi from "joi"; +import documentExtension from "br-docs/joi"; + +const JoiExtended = Joi.extend(documentExtension); + +const schema = JoiExtended.object({ + cpf: JoiExtended.document().cpf().required(), + cnpj: JoiExtended.document().cnpj().required(), + nis: JoiExtended.document().nis().required(), + cnh: JoiExtended.document().cnh().required(), + cep: JoiExtended.document().cep().required(), + telefone: JoiExtended.document().phone().required(), + pix: JoiExtended.document().pix().required(), + boleto: JoiExtended.document().boleto().required(), + celular: JoiExtended.document().celular().required(), +}); + +const result = schema.validate({ + cpf: "123.456.789-09", + cnpj: "11.222.333/0001-81", + nis: "123.45678.90-1", + cnh: "02650306461", + cep: "01001-000", + telefone: "(11) 3456-7890", + pix: "email@dominio.com", + boleto: "34191.79001 01043.510047 91020.150008 6 84570000002000", + celular: "(11) 98765-4321", +}); + +if (result.error) { + console.error(result.error.details); +} +``` + +## Regras disponíveis + +As regras abaixo validam o valor e retornam erro com mensagens específicas: + +- `document().cpf()` — CPF inválido +- `document().cnpj()` — CNPJ inválido +- `document().nis()` — NIS inválido +- `document().cnh()` — CNH inválida +- `document().cep()` — CEP inválido +- `document().phone()` — Telefone inválido +- `document().pix()` — Chave PIX inválida +- `document().boleto()` — Boleto inválido +- `document().celular()` — Celular inválido + +## Mensagens de erro personalizadas + +Você pode customizar mensagens normalmente pelo Joi: + +```typescript +const schema = JoiExtended.object({ + cpf: JoiExtended.document().cpf().messages({ + "document.cpf": "CPF informado não é válido", + }), +}); +``` diff --git a/docs/EXTENSOES/README.md b/docs/EXTENSOES/README.md new file mode 100644 index 0000000..876f2b4 --- /dev/null +++ b/docs/EXTENSOES/README.md @@ -0,0 +1,3 @@ +# Extensões + +Nesta seção você encontra integrações do **br-docs** com bibliotecas de validação e outros ecossistemas. diff --git a/package-lock.json b/package-lock.json index 291893e..5da1000 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,17 @@ "@typescript-eslint/eslint-plugin": "^8.37.0", "@vitest/coverage-v8": "^3.2.4", "eslint": "^8.50.0", + "joi": "^17.0.0", "typescript": "^5.8.3", "vitest": "^3.2.4" + }, + "peerDependencies": { + "joi": "^17.0.0" + }, + "peerDependenciesMeta": { + "joi": { + "optional": true + } } }, "node_modules/@ampproject/remapping": { @@ -629,6 +638,23 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -1186,6 +1212,30 @@ "win32" ] }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -2563,6 +2613,20 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", diff --git a/package.json b/package.json index d62f91e..812638f 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ "./cnh": "./dist/cnh.js", "./cep": "./dist/cep.js", "./telefone": "./dist/telefone.js", - "./boleto": "./dist/boleto.js" + "./boleto": "./dist/boleto.js", + "./nis": "./dist/nis.js", + "./joi": "./dist/joi.js" }, "scripts": { "build": "tsc", @@ -39,7 +41,16 @@ "cpf", "phone", "cep", - "cnpj" + "cnpj", + "pix", + "boleto", + "cnh", + "nis", + "uuid", + "email", + "documentos", + "validacao", + "formatacao" ], "author": "Mateus Teruki", "description": "Utilitários para validação e formatação de documentos brasileiros como CPF, telefone, CEP e CNPJ.", @@ -49,7 +60,16 @@ "@vitest/coverage-v8": "^3.2.4", "eslint": "^8.50.0", "typescript": "^5.8.3", - "vitest": "^3.2.4" + "vitest": "^3.2.4", + "joi": "^17.0.0" + }, + "peerDependencies": { + "joi": "^17.0.0" + }, + "peerDependenciesMeta": { + "joi": { + "optional": true + } }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/__tests__/cnh.test.ts b/src/__tests__/cnh.test.ts index c038a54..a54b7bc 100644 --- a/src/__tests__/cnh.test.ts +++ b/src/__tests__/cnh.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect } from "vitest"; describe("CNH Validation", () => { it("should validate a correct CNH", () => { - expect(Cnh.isValid("12345678900")).toBe(true); + expect(Cnh.isValid("60930978987")).toBe(true); }); it("should invalidate an incorrect CNH", () => { diff --git a/src/__tests__/joi.test.ts b/src/__tests__/joi.test.ts new file mode 100644 index 0000000..4f80f01 --- /dev/null +++ b/src/__tests__/joi.test.ts @@ -0,0 +1,54 @@ +import BaseJoi, { Schema } from "joi"; +import validator from "../joi"; +import { describe, expect, it } from "vitest"; + +const Joi = BaseJoi.extend(validator); + +function expectOk(schema: Schema, value: string) { + const { error } = schema.validate(value); + expect(error).toBeUndefined(); +} + +function expectErr(schema: Schema, value: string, code: string) { + const { error } = schema.validate(value); + expect(error).toBeTruthy(); + expect(error!.details[0].type).toBe(code); +} + +describe("br-docs Joi extension", () => { + it("cpf", () => { + expectOk(Joi.document().cpf(), "529.982.247-25"); + expectErr(Joi.document().cpf(), "123", "document.cpf"); + }); + + it("cnpj", () => { + expectOk(Joi.document().cnpj(), "04.252.011/0001-10"); + expectErr(Joi.document().cnpj(), "123", "document.cnpj"); + }); + + it("nis", () => { + expectOk(Joi.document().nis(), "16029214650"); + expectErr(Joi.document().nis(), "16029214651", "document.nis"); + }); + + it("cnh", () => { + expectOk(Joi.document().cnh(), "60930978987"); + expectErr(Joi.document().cnh(), "11111111111", "document.cnh"); + }); + + it("cep / phone / celular / uuid / pix", () => { + expectOk(Joi.document().cep(), "01001-000"); + expectOk(Joi.document().phone(), "1133334444"); + expectOk(Joi.document().celular(), "11999998888"); + expectOk(Joi.document().uuid(), "550e8400-e29b-41d4-a716-446655440000"); + expectOk(Joi.document().pix(), "a@b.com"); + }); + + it("boleto", () => { + expectOk( + Joi.document().boleto(), + "00190.00009 02372.190005 00017.356171 9 63690000007179", + ); + expectErr(Joi.document().boleto(), "123", "document.boleto"); + }); +}); diff --git a/src/cnh.ts b/src/cnh.ts index d58b6fa..40982ad 100644 --- a/src/cnh.ts +++ b/src/cnh.ts @@ -1,19 +1,6 @@ import { IDocumentHandler } from "."; import clean from "./utils/cleanString"; -/** - * Função auxiliar para calcular o dígito verificador de um número. - * @param digits - * @returns {number} O dígito verificador calculado. - */ -const calcDigit = (digits: string): number => { - const digit = digits - .split("") - .reduce((acc, digit, index) => acc + parseInt(digit, 10) * (9 - index), 0); - const remainder = digit % 11; - return remainder < 2 ? 0 : 11 - remainder; -}; - export class Cnh implements IDocumentHandler { constructor(private cnh: string) {} @@ -40,14 +27,32 @@ export class Cnh implements IDocumentHandler { const cleanCnh = clean(cnh); - const firstDigit = calcDigit(cleanCnh.slice(0, 9)); - const secondDigit = calcDigit(cleanCnh.slice(0, 10)); + if (cleanCnh.length !== 11) return false; + if (/^(\d)\1{10}$/.test(cleanCnh)) return false; + + const base = cleanCnh.slice(0, 9).split("").map(Number); + + let sum1 = 0; + let sum2 = 0; + + for (let i = 0; i < 9; i++) { + sum1 += base[i] * (9 - i); + sum2 += base[i] * (1 + i); + } + + const dv1Raw = sum1 % 11; + const dv1 = dv1Raw > 9 ? 0 : dv1Raw; + + let dv2 = sum2 % 11; + + if (dv1Raw > 9) { + dv2 = dv2 - 2; + if (dv2 < 0) dv2 += 9; + } + + if (dv2 > 9) dv2 = 0; - return ( - cleanCnh.length === 11 && - cleanCnh[9] === firstDigit.toString() && - cleanCnh[10] === secondDigit.toString() - ); + return cleanCnh[9] === String(dv1) && cleanCnh[10] === String(dv2); } /** * Formata uma CNH brasileira. diff --git a/src/index.ts b/src/index.ts index 21af4a7..50ef222 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,6 @@ +import { Cnpj } from "./cnpj"; +import { Cpf } from "./cpf"; + /** * Interface que define o método de validação de um documento. * Implementa o método de validação que verifica se o documento é válido. @@ -45,3 +48,35 @@ export { Cep } from "./cep"; export { Telefone } from "./telefone"; export { Cnh } from "./cnh"; export { Boleto } from "./boleto"; +export { Nis } from "./nis"; + +export const validator = (joi: any) => ({ + type: "document", + base: joi.string(), + messages: { + "document.cpf": "CPF inválido", + "document.cnpj": "CNPJ inválido", + }, + rules: { + cpf: { + validate(value: any, helpers: any, args: any, options: any) { + if (!Cpf.isValid(value)) { + return helpers.error("document.cpf"); + } + + return value; + }, + }, + cnpj: { + validate(value: any, helpers: any, args: any, options: any) { + if (!Cnpj.isValid(value)) { + return helpers.error("document.cnpj"); + } + + return value; + }, + }, + }, +}); + +export default validator; diff --git a/src/joi.ts b/src/joi.ts new file mode 100644 index 0000000..70cb985 --- /dev/null +++ b/src/joi.ts @@ -0,0 +1,68 @@ +import type { ExtensionFactory } from "joi"; + +import { Cpf } from "./cpf"; +import { Cnpj } from "./cnpj"; +import { Nis } from "./nis"; +import { Cnh } from "./cnh"; +import { Cep } from "./cep"; +import { Telefone } from "./telefone"; +import { Pix } from "./pix"; +import { Boleto } from "./boleto"; +import { Celular } from "./celular"; + +const validator: ExtensionFactory = (joi) => ({ + type: "document", + base: joi.string(), + messages: { + "document.cpf": "CPF inválido", + "document.cnpj": "CNPJ inválido", + "document.nis": "NIS inválido", + "document.cnh": "CNH inválida", + "document.cep": "CEP inválido", + "document.phone": "Telefone inválido", + "document.uuid": "UUID inválido", + "document.pix": "Chave PIX inválida", + "document.boleto": "Boleto inválido", + "document.celular": "Celular inválido", + }, + rules: { + cpf: { + validate: (value, helpers) => + Cpf.isValid(value) ? value : helpers.error("document.cpf"), + }, + cnpj: { + validate: (value, helpers) => + Cnpj.isValid(value) ? value : helpers.error("document.cnpj"), + }, + nis: { + validate: (value, helpers) => + Nis.isValid(value) ? value : helpers.error("document.nis"), + }, + cnh: { + validate: (value, helpers) => + Cnh.isValid(value) ? value : helpers.error("document.cnh"), + }, + cep: { + validate: (value, helpers) => + Cep.isValid(value) ? value : helpers.error("document.cep"), + }, + phone: { + validate: (value, helpers) => + Telefone.isValid(value) ? value : helpers.error("document.phone"), + }, + pix: { + validate: (value, helpers) => + Pix.isValid(value) ? value : helpers.error("document.pix"), + }, + boleto: { + validate: (value, helpers) => + Boleto.isValid(value) ? value : helpers.error("document.boleto"), + }, + celular: { + validate: (value, helpers) => + Celular.isValid(value) ? value : helpers.error("document.celular"), + }, + }, +}); + +export default validator;