@77sol-ui/form-schemas 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +166 -0
  2. package/package.json +61 -0
  3. package/src/domains/financing/enums/banks.ts +22 -0
  4. package/src/domains/financing/enums/index.ts +2 -0
  5. package/src/domains/financing/enums/occupation.ts +2 -0
  6. package/src/domains/financing/formalization/extras.ts +21 -0
  7. package/src/domains/financing/formalization/index.ts +16 -0
  8. package/src/domains/financing/formalization/refinements.ts +70 -0
  9. package/src/domains/financing/formalization/registry.ts +114 -0
  10. package/src/domains/financing/formalization/schemas/AlfaPFSchema.ts +46 -0
  11. package/src/domains/financing/formalization/schemas/AlfaPJSchema.ts +65 -0
  12. package/src/domains/financing/formalization/schemas/BancoDoBrasilPFSchema.ts +46 -0
  13. package/src/domains/financing/formalization/schemas/BancoDoBrasilPJSchema.ts +65 -0
  14. package/src/domains/financing/formalization/schemas/BtgPFSchema.ts +46 -0
  15. package/src/domains/financing/formalization/schemas/BtgPJSchema.ts +77 -0
  16. package/src/domains/financing/formalization/schemas/BvPFSchema.ts +59 -0
  17. package/src/domains/financing/formalization/schemas/BvPJSchema.ts +84 -0
  18. package/src/domains/financing/formalization/schemas/CaixaEconomicaFederalPFSchema.ts +48 -0
  19. package/src/domains/financing/formalization/schemas/CaixaEconomicaFederalPJSchema.ts +67 -0
  20. package/src/domains/financing/formalization/schemas/CashMePFSchema.ts +46 -0
  21. package/src/domains/financing/formalization/schemas/CashMePJSchema.ts +65 -0
  22. package/src/domains/financing/formalization/schemas/Credito77PFSchema.ts +46 -0
  23. package/src/domains/financing/formalization/schemas/Credito77PJSchema.ts +65 -0
  24. package/src/domains/financing/formalization/schemas/EosPFSchema.ts +46 -0
  25. package/src/domains/financing/formalization/schemas/EosPJSchema.ts +65 -0
  26. package/src/domains/financing/formalization/schemas/HdtEnergyPFSchema.ts +46 -0
  27. package/src/domains/financing/formalization/schemas/HdtEnergyPJSchema.ts +65 -0
  28. package/src/domains/financing/formalization/schemas/LosangoPFSchema.ts +46 -0
  29. package/src/domains/financing/formalization/schemas/LosangoPJSchema.ts +65 -0
  30. package/src/domains/financing/formalization/schemas/SafraPFSchema.ts +46 -0
  31. package/src/domains/financing/formalization/schemas/SafraPJSchema.ts +65 -0
  32. package/src/domains/financing/formalization/schemas/SantanderPFSchema.ts +46 -0
  33. package/src/domains/financing/formalization/schemas/SantanderPJSchema.ts +65 -0
  34. package/src/domains/financing/formalization/schemas/SolAgoraPFSchema.ts +46 -0
  35. package/src/domains/financing/formalization/schemas/SolAgoraPJSchema.ts +75 -0
  36. package/src/domains/financing/formalization/schemas/SolfacilPFSchema.ts +46 -0
  37. package/src/domains/financing/formalization/schemas/SolfacilPJSchema.ts +70 -0
  38. package/src/domains/financing/formalization/uiMeta.ts +122 -0
  39. package/src/domains/financing/index.ts +1 -0
  40. package/src/index.ts +1 -0
  41. package/src/shared/enums/document-type.ts +4 -0
  42. package/src/shared/enums/index.ts +3 -0
  43. package/src/shared/enums/nationality.ts +2 -0
  44. package/src/shared/enums/sex.ts +4 -0
  45. package/src/shared/fields/index.ts +1 -0
  46. package/src/shared/fields/primitives.ts +32 -0
  47. package/src/shared/regex/index.ts +75 -0
  48. package/src/shared/regex/patterns.ts +15 -0
  49. package/src/shared/regex/validators.spec.ts +162 -0
  50. package/src/shared/regex/validators.ts +96 -0
@@ -0,0 +1,46 @@
1
+ import { z } from 'zod'
2
+ import { DocumentTypeEnum, SexEnum } from '../../../../shared/enums'
3
+ import { textField } from '../../../../shared/fields/primitives'
4
+ import {
5
+ birthDateSchema,
6
+ cepSchema,
7
+ cpfCnpjSchema,
8
+ cpfSchema,
9
+ currencySchema,
10
+ emailSchema,
11
+ issueDateSchema,
12
+ phoneBRSchema,
13
+ } from '../../../../shared/regex'
14
+ import { formalizationExtras } from '../extras'
15
+ import { applyPfRefinements } from '../refinements'
16
+
17
+ export const solfacilPfObject = z.object({
18
+ ...formalizationExtras,
19
+ name: textField(),
20
+ cep: cepSchema,
21
+ cpf: cpfSchema,
22
+ address: textField(),
23
+ birth_date: birthDateSchema,
24
+ number: textField(),
25
+ sex: SexEnum,
26
+ complement: textField(15).optional(),
27
+ mother_name: textField(),
28
+ district: textField(),
29
+ energy_account_in_requester_name: textField(),
30
+ city: textField(),
31
+ energy_bill_owner_document: cpfCnpjSchema.optional(),
32
+ state: textField(2),
33
+ nationality: textField(),
34
+ cellphone: phoneBRSchema,
35
+ email: emailSchema,
36
+ monthly_income: currencySchema,
37
+ profession: textField(),
38
+ type_doc: DocumentTypeEnum,
39
+ doc: textField(15),
40
+ issuing_body: textField(6),
41
+ doc_issue_date: issueDateSchema,
42
+ })
43
+
44
+ export const solfacilPfSchema = solfacilPfObject.strict().superRefine(applyPfRefinements)
45
+
46
+ export type SolfacilPfFicha = z.infer<typeof solfacilPfSchema>
@@ -0,0 +1,70 @@
1
+ import { z } from 'zod'
2
+ import { SexEnum } from '../../../../shared/enums'
3
+ import { textField } from '../../../../shared/fields/primitives'
4
+ import {
5
+ birthDateSchema,
6
+ cepSchema,
7
+ cnpjSchema,
8
+ cpfSchema,
9
+ currencySchema,
10
+ emailSchema,
11
+ issueDateSchema,
12
+ phoneBRSchema,
13
+ } from '../../../../shared/regex'
14
+ import { formalizationExtras } from '../extras'
15
+ import { applyPjRefinements } from '../refinements'
16
+
17
+ export const solfacilPjObject = z.object({
18
+ ...formalizationExtras,
19
+ financing_company: z
20
+ .object({
21
+ corporate_name: textField(),
22
+ cep: cepSchema,
23
+ address: textField(),
24
+ foundation_date: issueDateSchema,
25
+ number: textField(),
26
+ cnpj: cnpjSchema,
27
+ email: emailSchema,
28
+ complement: textField(15).optional(),
29
+ phone: phoneBRSchema,
30
+ district: textField(),
31
+ legal_nature: textField(),
32
+ city: textField(),
33
+ state: textField(2),
34
+ })
35
+ .strict(),
36
+ financing_company_guarantor: z.array(
37
+ z
38
+ .object({
39
+ name: textField(),
40
+ cep: cepSchema,
41
+ cpf: cpfSchema,
42
+ address: textField(),
43
+ birth_date: birthDateSchema,
44
+ number: textField(),
45
+ complement: textField(15).optional(),
46
+ cellphone: phoneBRSchema,
47
+ phone: phoneBRSchema,
48
+ email: emailSchema,
49
+ district: textField(),
50
+ sex: SexEnum,
51
+ city: textField(),
52
+ state: textField(2),
53
+ civil_status: textField(),
54
+ office: textField(),
55
+ doc: textField(),
56
+ issuing_body: textField(),
57
+ uf_issuing_body: textField(2),
58
+ doc_issue_date: issueDateSchema,
59
+ mother_name: textField(),
60
+ monthly_income: currencySchema,
61
+ patrimony: currencySchema,
62
+ nationality: textField(),
63
+ })
64
+ .strict(),
65
+ ),
66
+ })
67
+
68
+ export const solfacilPjSchema = solfacilPjObject.strict().superRefine(applyPjRefinements)
69
+
70
+ export type SolfacilPjFicha = z.infer<typeof solfacilPjSchema>
@@ -0,0 +1,122 @@
1
+ export type FormalizationFieldKind =
2
+ | 'text'
3
+ | 'cpf'
4
+ | 'cnpj'
5
+ | 'cpfCnpj'
6
+ | 'cep'
7
+ | 'phone'
8
+ | 'email'
9
+ | 'date'
10
+ | 'currency'
11
+ | 'integer'
12
+ | 'number'
13
+ | 'select'
14
+ | 'file'
15
+ | 'toggle'
16
+ | 'group'
17
+ | 'array'
18
+
19
+ export interface FormalizationFieldMeta {
20
+ kind: FormalizationFieldKind
21
+ label: string
22
+ step: number
23
+ }
24
+
25
+ export const FORMALIZATION_FIELD_META: Record<string, FormalizationFieldMeta> = {
26
+ energy_bill_upload: { kind: 'file', label: 'Conta de energia', step: 1 },
27
+ account_third_party: {
28
+ kind: 'select',
29
+ label: 'Conta em nome de terceiro?',
30
+ step: 1,
31
+ },
32
+ bond_document_upload: {
33
+ kind: 'file',
34
+ label: 'Documento de vínculo com o titular',
35
+ step: 1,
36
+ },
37
+ document_front_upload: {
38
+ kind: 'file',
39
+ label: 'Documento de identidade (frente)',
40
+ step: 2,
41
+ },
42
+ document_back_upload: {
43
+ kind: 'file',
44
+ label: 'Documento de identidade (verso)',
45
+ step: 2,
46
+ },
47
+ rd_re_insurance_toggle: {
48
+ kind: 'toggle',
49
+ label: 'Seguro prestamista (RD/RE)',
50
+ step: 3,
51
+ },
52
+
53
+ name: { kind: 'text', label: 'Nome', step: 3 },
54
+ cpf: { kind: 'cpf', label: 'CPF', step: 3 },
55
+ birth_date: { kind: 'date', label: 'Data de nascimento', step: 3 },
56
+ nationality: { kind: 'text', label: 'Nacionalidade', step: 3 },
57
+ type_doc: { kind: 'select', label: 'Tipo de documento', step: 3 },
58
+ doc: { kind: 'text', label: 'Número do documento', step: 3 },
59
+ issuing_body: { kind: 'text', label: 'Órgão emissor', step: 3 },
60
+ emitting_state: { kind: 'text', label: 'UF emissor', step: 3 },
61
+ doc_issue_date: { kind: 'date', label: 'Data de expedição', step: 3 },
62
+ email: { kind: 'email', label: 'E-mail', step: 3 },
63
+ cellphone: { kind: 'phone', label: 'Celular', step: 3 },
64
+ monthly_income: { kind: 'currency', label: 'Renda mensal', step: 3 },
65
+ equity_value: { kind: 'currency', label: 'Patrimônio', step: 3 },
66
+ mother_name: { kind: 'text', label: 'Nome da mãe', step: 3 },
67
+ civil_status: { kind: 'text', label: 'Estado civil', step: 3 },
68
+ naturalness: { kind: 'text', label: 'Naturalidade', step: 3 },
69
+ uf_naturalness: { kind: 'text', label: 'UF de naturalidade', step: 3 },
70
+ nature_of_occupation: {
71
+ kind: 'text',
72
+ label: 'Natureza da ocupação',
73
+ step: 3,
74
+ },
75
+ company_time: { kind: 'number', label: 'Tempo de empresa (meses)', step: 3 },
76
+ cnpj_proprietary: { kind: 'cnpj', label: 'CNPJ do proprietário', step: 3 },
77
+ pep_relationship: { kind: 'text', label: 'Relação com PEP', step: 3 },
78
+ residence_situation: {
79
+ kind: 'text',
80
+ label: 'Situação da residência',
81
+ step: 3,
82
+ },
83
+ months_in_residence: {
84
+ kind: 'integer',
85
+ label: 'Tempo de residência (meses)',
86
+ step: 3,
87
+ },
88
+ is_installation_at_requester: {
89
+ kind: 'text',
90
+ label: 'Instalação no endereço do solicitante',
91
+ step: 3,
92
+ },
93
+ energy_account_in_requester_name: {
94
+ kind: 'text',
95
+ label: 'Conta de energia no nome do solicitante',
96
+ step: 3,
97
+ },
98
+ energy_bill_owner_document: {
99
+ kind: 'cpfCnpj',
100
+ label: 'Documento do titular da conta de energia',
101
+ step: 3,
102
+ },
103
+
104
+ cep: { kind: 'cep', label: 'CEP', step: 3 },
105
+ address: { kind: 'text', label: 'Endereço', step: 3 },
106
+ number: { kind: 'text', label: 'Número', step: 3 },
107
+ complement: { kind: 'text', label: 'Complemento', step: 3 },
108
+ district: { kind: 'text', label: 'Bairro', step: 3 },
109
+ state: { kind: 'text', label: 'Estado (UF)', step: 3 },
110
+ city: { kind: 'text', label: 'Cidade', step: 3 },
111
+
112
+ sex: { kind: 'select', label: 'Sexo', step: 4 },
113
+ profession: { kind: 'text', label: 'Profissão', step: 4 },
114
+
115
+ financing_company: { kind: 'group', label: 'Dados da empresa', step: 3 },
116
+ financing_company_guarantor: { kind: 'array', label: 'Avalistas', step: 3 },
117
+ financing_company_qsa: {
118
+ kind: 'array',
119
+ label: 'Quadro societário (QSA)',
120
+ step: 3,
121
+ },
122
+ }
@@ -0,0 +1 @@
1
+ export * from './formalization'
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './domains/financing'
@@ -0,0 +1,4 @@
1
+ import { z } from 'zod'
2
+
3
+ export const DocumentTypeEnum = z.enum(['rg', 'cnh', 'rne'])
4
+ export type DocumentType = z.infer<typeof DocumentTypeEnum>
@@ -0,0 +1,3 @@
1
+ export * from './document-type'
2
+ export * from './nationality'
3
+ export * from './sex'
@@ -0,0 +1,2 @@
1
+ // Usada na regra que força tipo de documento = RNE para estrangeiros.
2
+ export const NATIONALITY_BRAZIL = 'brasil' as const
@@ -0,0 +1,4 @@
1
+ import { z } from 'zod'
2
+
3
+ export const SexEnum = z.enum(['masculino', 'feminino'])
4
+ export type Sex = z.infer<typeof SexEnum>
@@ -0,0 +1 @@
1
+ export * from './primitives'
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod'
2
+
3
+ export function textField(maxLength?: number) {
4
+ const base = z.string().trim().min(1, 'Campo obrigatório')
5
+ return maxLength === undefined ? base : base.max(maxLength, `Máximo ${maxLength} caracteres`)
6
+ }
7
+
8
+ export const numberField = z
9
+ .number({ invalid_type_error: 'Valor numérico inválido' })
10
+ .nonnegative('Valor não pode ser negativo')
11
+
12
+ export const integerField = z
13
+ .number({ invalid_type_error: 'Valor numérico inválido' })
14
+ .int('Deve ser um número inteiro')
15
+ .nonnegative('Valor não pode ser negativo')
16
+
17
+ export function selectField<T extends readonly [string, ...string[]]>(values: T) {
18
+ return z.enum(values)
19
+ }
20
+
21
+ // Valida apenas os metadados do arquivo; o binário é tratado pelo serviço de upload.
22
+ export const MAX_UPLOAD_BYTES = 5 * 1024 * 1024
23
+ export const ACCEPTED_UPLOAD_MIME = ['image/jpeg', 'image/png', 'application/pdf'] as const
24
+
25
+ export const fileUploadField = z.object({
26
+ url: z.string().url().optional(),
27
+ mime: z.enum(ACCEPTED_UPLOAD_MIME, {
28
+ errorMap: () => ({ message: 'Formato deve ser JPEG, PNG ou PDF' }),
29
+ }),
30
+ sizeBytes: z.number().max(MAX_UPLOAD_BYTES, 'Arquivo deve ter no máximo 5MB'),
31
+ })
32
+ export type FileUpload = z.infer<typeof fileUploadField>
@@ -0,0 +1,75 @@
1
+ import { z } from 'zod'
2
+ import { PATTERNS } from './patterns'
3
+ import {
4
+ isAdultDMY,
5
+ isNotFutureDMY,
6
+ isValidCNPJ,
7
+ isValidCPF,
8
+ onlyDigits,
9
+ parseDMY,
10
+ } from './validators'
11
+
12
+ export * from './patterns'
13
+ export * from './validators'
14
+
15
+ export const cpfSchema = z
16
+ .string()
17
+ .transform(onlyDigits)
18
+ .refine((v) => PATTERNS.cpf.test(v), 'CPF deve ter 11 dígitos')
19
+ .refine(isValidCPF, 'CPF inválido')
20
+
21
+ export const cnpjSchema = z
22
+ .string()
23
+ .transform(onlyDigits)
24
+ .refine((v) => PATTERNS.cnpj.test(v), 'CNPJ deve ter 14 dígitos')
25
+ .refine(isValidCNPJ, 'CNPJ inválido')
26
+
27
+ export const cpfCnpjSchema = z
28
+ .string()
29
+ .transform(onlyDigits)
30
+ .refine(
31
+ (v) => PATTERNS.cpf.test(v) || PATTERNS.cnpj.test(v),
32
+ 'Documento deve ter 11 (CPF) ou 14 (CNPJ) dígitos',
33
+ )
34
+ .refine((v) => (v.length === 11 ? isValidCPF(v) : isValidCNPJ(v)), 'Documento inválido')
35
+
36
+ export const cepSchema = z
37
+ .string()
38
+ .transform(onlyDigits)
39
+ .refine((v) => PATTERNS.cep.test(v), 'CEP deve ter 8 dígitos')
40
+
41
+ export const phoneBRSchema = z
42
+ .string()
43
+ .transform(onlyDigits)
44
+ .refine((v) => PATTERNS.phoneBR.test(v), 'Telefone deve ter 11 dígitos com DDD')
45
+
46
+ export const emailSchema = z
47
+ .string()
48
+ .trim()
49
+ .refine((v) => PATTERNS.email.test(v), 'E-mail inválido')
50
+
51
+ export const dateDMYSchema = z
52
+ .string()
53
+ .refine((v) => PATTERNS.dateDMY.test(v), 'Data deve estar no formato DD/MM/AAAA')
54
+ .refine((v) => parseDMY(v) !== null, 'Data inexistente no calendário')
55
+
56
+ export const birthDateSchema = dateDMYSchema.refine(
57
+ (v) => isAdultDMY(v),
58
+ 'É necessário ser maior de idade (18+)',
59
+ )
60
+
61
+ // RN-011 (prazo por tipo de documento) é aplicada no schema PF.
62
+ export const issueDateSchema = dateDMYSchema.refine(
63
+ (v) => isNotFutureDMY(v),
64
+ 'Data de emissão não pode ser futura',
65
+ )
66
+
67
+ export const currencySchema = z
68
+ .number({ invalid_type_error: 'Valor monetário inválido' })
69
+ .positive('Valor deve ser positivo')
70
+
71
+ export const billDueDaySchema = z
72
+ .number()
73
+ .int('Deve ser um número inteiro')
74
+ .min(1, 'Mínimo 1')
75
+ .max(28, 'Máximo 28')
@@ -0,0 +1,15 @@
1
+ // Regras de formato (validação), não máscara visual da UI. Assumem input já
2
+ // "limpo" (somente dígitos quando aplicável). Dígito verificador: validators.ts.
3
+ export const PATTERNS = {
4
+ cpf: /^\d{11}$/,
5
+ cnpj: /^\d{14}$/,
6
+ cep: /^\d{8}$/,
7
+ phoneBR: /^\d{11}$/,
8
+ email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
9
+ dateDMY: /^\d{2}\/\d{2}\/\d{4}$/,
10
+ cnhNumber: /^\d{11}$/,
11
+ rneNumber: /^[A-Za-z0-9]+$/,
12
+ rgNumber: /^[A-Za-z0-9./-]+$/,
13
+ } as const
14
+
15
+ export type PatternKey = keyof typeof PATTERNS
@@ -0,0 +1,162 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ birthDateSchema,
4
+ cepSchema,
5
+ cnpjSchema,
6
+ cpfSchema,
7
+ emailSchema,
8
+ issueDateSchema,
9
+ phoneBRSchema,
10
+ } from './index'
11
+ import {
12
+ ageInYears,
13
+ isAdultDMY,
14
+ isNotFutureDMY,
15
+ isValidCNPJ,
16
+ isValidCPF,
17
+ onlyDigits,
18
+ parseDMY,
19
+ yearsSinceDMY,
20
+ } from './validators'
21
+
22
+ describe('CPF', () => {
23
+ it('aceita CPF com dígito verificador válido', () => {
24
+ expect(isValidCPF('52998224725')).toBe(true)
25
+ expect(cpfSchema.safeParse('529.982.247-25').success).toBe(true)
26
+ })
27
+
28
+ it('rejeita dígito verificador inválido', () => {
29
+ expect(isValidCPF('52998224724')).toBe(false)
30
+ expect(cpfSchema.safeParse('52998224724').success).toBe(false)
31
+ })
32
+
33
+ it('rejeita tamanho errado', () => {
34
+ expect(isValidCPF('1234567890')).toBe(false) // 10 dígitos
35
+ expect(cpfSchema.safeParse('123').success).toBe(false)
36
+ })
37
+
38
+ it('rejeita repetidos (todos os dígitos iguais)', () => {
39
+ expect(isValidCPF('11111111111')).toBe(false)
40
+ })
41
+ })
42
+
43
+ describe('CNPJ', () => {
44
+ it('aceita CNPJ com dígito verificador válido', () => {
45
+ expect(isValidCNPJ('11222333000181')).toBe(true)
46
+ expect(cnpjSchema.safeParse('11.222.333/0001-81').success).toBe(true)
47
+ })
48
+
49
+ it('rejeita dígito verificador inválido', () => {
50
+ expect(isValidCNPJ('11222333000180')).toBe(false)
51
+ expect(cnpjSchema.safeParse('11222333000180').success).toBe(false)
52
+ })
53
+
54
+ it('rejeita tamanho errado', () => {
55
+ expect(isValidCNPJ('112223330001')).toBe(false) // 12 dígitos
56
+ })
57
+ })
58
+
59
+ describe('CEP', () => {
60
+ it('aceita 8 dígitos', () => {
61
+ expect(cepSchema.safeParse('01310930').success).toBe(true)
62
+ expect(cepSchema.safeParse('01310-930').success).toBe(true)
63
+ })
64
+
65
+ it('rejeita tamanho diferente de 8', () => {
66
+ expect(cepSchema.safeParse('1234567').success).toBe(false)
67
+ expect(cepSchema.safeParse('123456789').success).toBe(false)
68
+ })
69
+ })
70
+
71
+ describe('Telefone', () => {
72
+ it('aceita 11 dígitos com DDD', () => {
73
+ expect(phoneBRSchema.safeParse('11999998888').success).toBe(true)
74
+ expect(phoneBRSchema.safeParse('(11) 99999-8888').success).toBe(true)
75
+ })
76
+
77
+ it('rejeita menos de 11 dígitos', () => {
78
+ expect(phoneBRSchema.safeParse('1199998888').success).toBe(false)
79
+ })
80
+ })
81
+
82
+ describe('Email', () => {
83
+ it('aceita e-mail válido', () => {
84
+ expect(emailSchema.safeParse('cliente@exemplo.com').success).toBe(true)
85
+ })
86
+
87
+ it('rejeita e-mail inválido', () => {
88
+ expect(emailSchema.safeParse('cliente@@exemplo').success).toBe(false)
89
+ expect(emailSchema.safeParse('sem-arroba.com').success).toBe(false)
90
+ })
91
+ })
92
+
93
+ describe('parseDMY', () => {
94
+ it('faz parse de data válida', () => {
95
+ const d = parseDMY('10/05/1990')
96
+ expect(d).not.toBeNull()
97
+ expect(d?.getFullYear()).toBe(1990)
98
+ expect(d?.getMonth()).toBe(4) // maio = índice 4
99
+ expect(d?.getDate()).toBe(10)
100
+ })
101
+
102
+ it('rejeita data de calendário inexistente (31/02/2000)', () => {
103
+ expect(parseDMY('31/02/2000')).toBeNull()
104
+ })
105
+
106
+ it('rejeita formato inválido', () => {
107
+ expect(parseDMY('1990-05-10')).toBeNull()
108
+ expect(parseDMY('5/5/1990')).toBeNull()
109
+ })
110
+ })
111
+
112
+ describe('onlyDigits', () => {
113
+ it('remove tudo que não for dígito', () => {
114
+ expect(onlyDigits('(11) 99999-8888')).toBe('11999998888')
115
+ })
116
+ })
117
+
118
+ describe('birthDate (idade mínima)', () => {
119
+ const reference = new Date(2026, 5, 1) // 01/06/2026
120
+
121
+ it('rejeita menor de 18 anos', () => {
122
+ expect(isAdultDMY('01/01/2010', reference)).toBe(false)
123
+ expect(birthDateSchema.safeParse('01/01/2015').success).toBe(false)
124
+ })
125
+
126
+ it('aceita adulto', () => {
127
+ expect(isAdultDMY('01/01/1990', reference)).toBe(true)
128
+ expect(birthDateSchema.safeParse('10/05/1990').success).toBe(true)
129
+ })
130
+ })
131
+
132
+ describe('issueDate (não futura)', () => {
133
+ const reference = new Date(2026, 5, 1)
134
+
135
+ it('rejeita data futura', () => {
136
+ expect(isNotFutureDMY('01/01/2030', reference)).toBe(false)
137
+ expect(issueDateSchema.safeParse('01/01/2999').success).toBe(false)
138
+ })
139
+
140
+ it('aceita data passada', () => {
141
+ expect(isNotFutureDMY('01/01/2020', reference)).toBe(true)
142
+ expect(issueDateSchema.safeParse('01/01/2020').success).toBe(true)
143
+ })
144
+ })
145
+
146
+ describe('yearsSinceDMY / ageInYears', () => {
147
+ const reference = new Date(2026, 5, 1) // 01/06/2026
148
+
149
+ it('calcula anos completos de forma coerente', () => {
150
+ expect(yearsSinceDMY('01/06/2016', reference)).toBe(10)
151
+ expect(yearsSinceDMY('02/06/2016', reference)).toBe(9) // aniversário ainda não chegou
152
+ })
153
+
154
+ it('retorna null para data inválida', () => {
155
+ expect(yearsSinceDMY('31/02/2000', reference)).toBeNull()
156
+ })
157
+
158
+ it('ageInYears é consistente entre duas datas', () => {
159
+ expect(ageInYears(new Date(2000, 0, 1), new Date(2026, 0, 1))).toBe(26)
160
+ expect(ageInYears(new Date(2000, 0, 2), new Date(2026, 0, 1))).toBe(25)
161
+ })
162
+ })
@@ -0,0 +1,96 @@
1
+ export function onlyDigits(value: string): string {
2
+ return value.replace(/\D/g, '')
3
+ }
4
+
5
+ export function isValidCPF(value: string): boolean {
6
+ const cpf = onlyDigits(value)
7
+ if (cpf.length !== 11) {
8
+ return false
9
+ }
10
+ if (/^(\d)\1{10}$/.test(cpf)) {
11
+ return false
12
+ }
13
+
14
+ const calcCheck = (slice: number): number => {
15
+ let sum = 0
16
+ for (let i = 0; i < slice; i++) {
17
+ sum += Number(cpf[i]) * (slice + 1 - i)
18
+ }
19
+ const rest = (sum * 10) % 11
20
+ return rest === 10 ? 0 : rest
21
+ }
22
+
23
+ return calcCheck(9) === Number(cpf[9]) && calcCheck(10) === Number(cpf[10])
24
+ }
25
+
26
+ export function isValidCNPJ(value: string): boolean {
27
+ const cnpj = onlyDigits(value)
28
+ if (cnpj.length !== 14) {
29
+ return false
30
+ }
31
+ if (/^(\d)\1{13}$/.test(cnpj)) {
32
+ return false
33
+ }
34
+
35
+ const calcCheck = (length: number): number => {
36
+ const weights =
37
+ length === 12 ? [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2] : [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]
38
+ let sum = 0
39
+ for (const [i, weight] of weights.entries()) {
40
+ sum += Number(cnpj[i]) * weight
41
+ }
42
+ const rest = sum % 11
43
+ return rest < 2 ? 0 : 11 - rest
44
+ }
45
+
46
+ return calcCheck(12) === Number(cnpj[12]) && calcCheck(13) === Number(cnpj[13])
47
+ }
48
+
49
+ export function parseDMY(value: string): Date | null {
50
+ const match = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(value)
51
+ if (!match) {
52
+ return null
53
+ }
54
+ const [, dd, mm, yyyy] = match
55
+ const day = Number(dd)
56
+ const month = Number(mm)
57
+ const year = Number(yyyy)
58
+ const date = new Date(year, month - 1, day)
59
+ if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
60
+ return null
61
+ }
62
+ return date
63
+ }
64
+
65
+ export function ageInYears(birth: Date, reference: Date = new Date()): number {
66
+ let age = reference.getFullYear() - birth.getFullYear()
67
+ const monthDiff = reference.getMonth() - birth.getMonth()
68
+ if (monthDiff < 0 || (monthDiff === 0 && reference.getDate() < birth.getDate())) {
69
+ age--
70
+ }
71
+ return age
72
+ }
73
+
74
+ export function isAdultDMY(value: string, reference?: Date): boolean {
75
+ const date = parseDMY(value)
76
+ if (!date) {
77
+ return false
78
+ }
79
+ return ageInYears(date, reference) >= 18
80
+ }
81
+
82
+ export function isNotFutureDMY(value: string, reference: Date = new Date()): boolean {
83
+ const date = parseDMY(value)
84
+ if (!date) {
85
+ return false
86
+ }
87
+ return date.getTime() <= reference.getTime()
88
+ }
89
+
90
+ export function yearsSinceDMY(value: string, reference: Date = new Date()): number | null {
91
+ const date = parseDMY(value)
92
+ if (!date) {
93
+ return null
94
+ }
95
+ return ageInYears(date, reference)
96
+ }