feat: ecosistema FRASA — apps Cotizador, Alta Clientes, Cobranza y Landing

- Cotizador (/quote): 4 propuestas con formulas validadas del Excel, comparador,
  combobox de unidades, vigencia, PDF ANEXO A
- Alta Clientes (/client): wizard 8 pasos, validacion CURP RENAPO, OCR de INE,
  checklist de expediente, firma digital, 5 PDFs legales
- Cobranza (/pay): planes de enganche, mora, vista cobranza del dia, recibos PDF,
  bitacora, dashboard con filtros
- Landing (/): entrada al ecosistema
- Design system StudioBlank (monocromo, acento #1B4332) en las 4 apps
- 123 tests vitest
This commit is contained in:
2026-08-21 22:27:36 +00:00
commit ecd4edac02
147 changed files with 27345 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest'
import { calcularTotalIngresos, calcularGastosTotal } from './calculations'
describe('calcularTotalIngresos', () => {
it('suma ingresos mensuales y otros ingresos', () => {
expect(calcularTotalIngresos({ ingresosMensuales: 54988, otrosIngresos: 0 })).toBe(54988)
expect(calcularTotalIngresos({ ingresosMensuales: 20000, otrosIngresos: 5000 })).toBe(25000)
})
it('devuelve 0 cuando ambos son 0', () => {
expect(calcularTotalIngresos({ ingresosMensuales: 0, otrosIngresos: 0 })).toBe(0)
})
})
describe('calcularGastosTotal', () => {
it('suma gastos de luz y otros gastos', () => {
expect(calcularGastosTotal({ gastosLuz: 500, gastosOtros: 5000 })).toBe(5500)
})
it('devuelve 0 cuando ambos son 0', () => {
expect(calcularGastosTotal({ gastosLuz: 0, gastosOtros: 0 })).toBe(0)
})
})

View File

@@ -0,0 +1,11 @@
import type { DatosEconomicos } from '../types'
/** Total de ingresos: ingresos mensuales + otros ingresos */
export function calcularTotalIngresos(economicos: Pick<DatosEconomicos, 'ingresosMensuales' | 'otrosIngresos'>): number {
return economicos.ingresosMensuales + economicos.otrosIngresos
}
/** Total de gastos aproximados: luz + otros */
export function calcularGastosTotal(economicos: Pick<DatosEconomicos, 'gastosLuz' | 'gastosOtros'>): number {
return economicos.gastosLuz + economicos.gastosOtros
}

View File

@@ -0,0 +1,109 @@
import { describe, it, expect } from 'vitest'
import {
validarCurp,
extraerDeCurp,
compararConDatos,
digitoVerificadorCurp,
ENTIDADES_CURP,
} from './curp'
// Fixture real del formato PDF de alta (reference/alta-clientes/pdf_formatos.txt)
const CURP_FIXTURE = 'TECP891124HBCLHS07'
/** Construye una CURP de prueba con dígito verificador calculado por el propio algoritmo */
function construirCurp(base17: string): string {
return `${base17}${digitoVerificadorCurp(base17)}`
}
describe('validarCurp', () => {
it('acepta el fixture real TECP891124HBCLHS07 (formato y dígito)', () => {
expect(validarCurp(CURP_FIXTURE)).toEqual({ valida: true })
})
it('acepta CURPs construidas con el propio algoritmo', () => {
const bases = [
'TECP891124HBCLHS0',
'GOML900515HDFRRN0',
'LOQA050312MCLPMA0',
'HEMA000101HDFXXX0',
'XAXX010101MNEXXXA', // genérica sin homoclave real, solo para dígito
]
for (const base of bases) {
expect(validarCurp(construirCurp(base)).valida).toBe(true)
}
})
it('rechaza un dígito verificador alterado', () => {
const buena = construirCurp('TECP891124HBCLHS0')
const malDigito = `${buena.slice(0, 17)}${(Number(buena[17]) + 1) % 10}`
expect(validarCurp(malDigito)).toEqual({ valida: false, motivo: 'digito' })
})
it('rechaza formatos inválidos', () => {
expect(validarCurp('TECP891124')).toEqual({ valida: false, motivo: 'formato' })
expect(validarCurp('TECP891124XBCLHS07')).toEqual({ valida: false, motivo: 'formato' })
expect(validarCurp('')).toEqual({ valida: false, motivo: 'formato' })
})
it('acepta minúsculas y espacios alrededor', () => {
expect(validarCurp(' tecp891124hbclhs07 ').valida).toBe(true)
})
})
describe('extraerDeCurp', () => {
it('parsea fecha, sexo y entidad del fixture real', () => {
const datos = extraerDeCurp(CURP_FIXTURE)
expect(datos).not.toBeNull()
expect(datos?.fechaNacimiento).toBe('1989-11-24')
expect(datos?.sexo).toBe('H')
expect(datos?.entidad).toBe('BC')
expect(datos?.entidadNombre).toBe('BAJA CALIFORNIA')
})
it('aplica la regla de siglo (YY <= año actual => 2000s)', () => {
const datos = extraerDeCurp(construirCurp('GOML050515HDFRRN0'))
expect(datos?.fechaNacimiento).toBe('2005-05-15')
})
it('devuelve null con formato inválido', () => {
expect(extraerDeCurp('NO-ES-CURP')).toBeNull()
})
it('el mapa tiene las 33 entidades', () => {
expect(Object.keys(ENTIDADES_CURP)).toHaveLength(33)
expect(ENTIDADES_CURP.NE).toBe('NACIDO EN EL EXTRANJERO')
})
})
describe('compararConDatos', () => {
it('sin advertencias cuando todo coincide', () => {
expect(
compararConDatos(CURP_FIXTURE, {
fechaNacimiento: '1989-11-24',
lugarNacimiento: 'Tijuana, Baja California',
}),
).toEqual([])
})
it('advierte cuando la fecha no coincide', () => {
const avisos = compararConDatos(CURP_FIXTURE, { fechaNacimiento: '1990-01-01' })
expect(avisos).toHaveLength(1)
expect(avisos[0]).toContain('fecha de nacimiento')
})
it('advierte cuando el lugar no coincide con la entidad', () => {
const avisos = compararConDatos(CURP_FIXTURE, { lugarNacimiento: 'Guadalajara, Jalisco' })
expect(avisos).toHaveLength(1)
expect(avisos[0]).toContain('lugar de nacimiento')
expect(avisos[0]).toContain('BAJA CALIFORNIA')
})
it('advierte dígito verificador incorrecto', () => {
const avisos = compararConDatos('TECP891124HBCLHS09', {})
expect(avisos.some((a) => a.includes('dígito verificador'))).toBe(true)
})
it('no advierte nada con CURP vacía', () => {
expect(compararConDatos('', { fechaNacimiento: '1989-11-24' })).toEqual([])
})
})

View File

@@ -0,0 +1,148 @@
// Validación y extracción de datos de la CURP (Clave Única de Registro de Población)
// Algoritmo oficial del dígito verificador: alfabeto RENAPO, pesos 18..2,
// suma mod 10 y complemento a 10.
export const REGEX_CURP_FORMATO = /^[A-Z]{4}\d{6}[HM][A-Z]{5}[0-9A-Z]\d$/
const ALFABETO = '0123456789ABCDEFGHIJKLMNÑOPQRSTUVWXYZ'
export const ENTIDADES_CURP: Record<string, string> = {
AS: 'AGUASCALIENTES',
BC: 'BAJA CALIFORNIA',
BS: 'BAJA CALIFORNIA SUR',
CC: 'CAMPECHE',
CL: 'COAHUILA',
CM: 'COLIMA',
CS: 'CHIAPAS',
CH: 'CHIHUAHUA',
DF: 'CIUDAD DE MEXICO',
DG: 'DURANGO',
GT: 'GUANAJUATO',
GR: 'GUERRERO',
HG: 'HIDALGO',
JC: 'JALISCO',
MC: 'MEXICO',
MN: 'MICHOACAN',
MS: 'MORELOS',
NT: 'NAYARIT',
NL: 'NUEVO LEON',
OC: 'OAXACA',
PL: 'PUEBLA',
QT: 'QUERETARO',
QR: 'QUINTANA ROO',
SP: 'SAN LUIS POTOSI',
SL: 'SINALOA',
SR: 'SONORA',
TC: 'TABASCO',
TS: 'TAMAULIPAS',
TL: 'TLAXCALA',
VZ: 'VERACRUZ',
YN: 'YUCATAN',
ZS: 'ZACATECAS',
NE: 'NACIDO EN EL EXTRANJERO',
}
/** Quita acentos y pasa a mayúsculas para comparar textos */
function normalizar(texto: string): string {
return texto
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toUpperCase()
.trim()
}
/** Calcula el dígito verificador de los primeros 17 caracteres de la CURP */
export function digitoVerificadorCurp(curp17: string): number {
let suma = 0
for (let i = 0; i < 17; i++) {
const valor = ALFABETO.indexOf(curp17[i])
if (valor === -1) return -1
suma += valor * (18 - i)
}
return (10 - (suma % 10)) % 10
}
export interface ResultadoValidacionCurp {
valida: boolean
/** 'formato' si no cumple el patrón, 'digito' si el verificador no cuadra */
motivo?: 'formato' | 'digito'
}
/** Valida formato (regex) y dígito verificador de la CURP */
export function validarCurp(curp: string): ResultadoValidacionCurp {
const limpia = normalizar(curp)
if (!REGEX_CURP_FORMATO.test(limpia)) return { valida: false, motivo: 'formato' }
const esperado = digitoVerificadorCurp(limpia.slice(0, 17))
if (esperado !== Number(limpia[17])) return { valida: false, motivo: 'digito' }
return { valida: true }
}
export interface DatosCurp {
/** Fecha de nacimiento en formato YYYY-MM-DD */
fechaNacimiento: string
sexo: 'H' | 'M'
/** Código de entidad federativa (posiciones 12-13) */
entidad: string
/** Nombre de la entidad federativa */
entidadNombre: string
}
/**
* Extrae fecha de nacimiento, sexo y entidad de una CURP con formato válido.
* Regla de siglo: YY <= año actual % 100 => 20YY, si no 19YY.
* Devuelve null si el formato no es válido.
*/
export function extraerDeCurp(curp: string): DatosCurp | null {
const limpia = normalizar(curp)
if (!REGEX_CURP_FORMATO.test(limpia)) return null
const yy = Number(limpia.slice(4, 6))
const mm = limpia.slice(6, 8)
const dd = limpia.slice(8, 10)
const anioActual = new Date().getFullYear() % 100
const anio = yy <= anioActual ? 2000 + yy : 1900 + yy
const entidad = limpia.slice(11, 13)
return {
fechaNacimiento: `${anio}-${mm}-${dd}`,
sexo: limpia[10] as 'H' | 'M',
entidad,
entidadNombre: ENTIDADES_CURP[entidad] ?? entidad,
}
}
/**
* Compara la CURP con los datos capturados y devuelve advertencias
* (no bloquean; solo alertan inconsistencias).
*/
export function compararConDatos(
curp: string,
datos: { fechaNacimiento?: string; lugarNacimiento?: string },
): string[] {
const advertencias: string[] = []
const limpia = normalizar(curp)
if (limpia === '') return advertencias
const validacion = validarCurp(limpia)
if (!validacion.valida && validacion.motivo === 'digito') {
advertencias.push('El dígito verificador de la CURP no coincide; verifica la captura')
}
if (!validacion.valida) return advertencias
const extraidos = extraerDeCurp(limpia)
if (!extraidos) return advertencias
const fechaCapturada = (datos.fechaNacimiento ?? '').trim()
if (fechaCapturada !== '' && fechaCapturada !== extraidos.fechaNacimiento) {
advertencias.push(
`La fecha de nacimiento no coincide con la CURP (${extraidos.fechaNacimiento} según CURP)`,
)
}
const lugar = normalizar(datos.lugarNacimiento ?? '')
if (lugar !== '' && !lugar.includes(extraidos.entidadNombre)) {
advertencias.push(
`El lugar de nacimiento no coincide con la entidad de la CURP (${extraidos.entidadNombre})`,
)
}
return advertencias
}

View File

@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest'
import { estadoExpediente } from './expediente'
import type { DocumentoCargado, SlotDocumento } from '../types'
const doc = (slot: SlotDocumento): DocumentoCargado => ({
slot,
nombre: `${slot}.pdf`,
tamano: 1024,
tipoMime: 'application/pdf',
archivo: new File(['x'], `${slot}.pdf`, { type: 'application/pdf' }),
})
const FIRMA = 'data:image/png;base64,iVBORw0KGgo='
describe('estadoExpediente', () => {
it('expediente vacío: 0/6 y todo pendiente', () => {
const e = estadoExpediente([], '')
expect(e.total).toBe(6)
expect(e.completos).toBe(0)
expect(e.completoTodo).toBe(false)
expect(e.items.every((i) => !i.completo)).toBe(true)
})
it('cuenta documentos cargados y firma por separado', () => {
const e = estadoExpediente([doc('INE'), doc('CURP')], FIRMA)
expect(e.completos).toBe(3)
expect(e.items.find((i) => i.clave === 'INE')?.completo).toBe(true)
expect(e.items.find((i) => i.clave === 'CONSTANCIA_RFC')?.completo).toBe(false)
expect(e.items.find((i) => i.clave === 'FIRMA')?.completo).toBe(true)
})
it('expediente completo: 6/6', () => {
const docs = [
doc('INE'),
doc('CONSTANCIA_RFC'),
doc('CURP'),
doc('COMPROBANTE_DOMICILIO'),
doc('ACTA_NACIMIENTO'),
]
const e = estadoExpediente(docs, FIRMA)
expect(e.completos).toBe(6)
expect(e.completoTodo).toBe(true)
})
it('una cadena que no es data:image no cuenta como firma', () => {
expect(estadoExpediente([], 'garabato').items.find((i) => i.clave === 'FIRMA')?.completo).toBe(false)
})
})

View File

@@ -0,0 +1,34 @@
import type { DocumentoCargado } from '../types'
import { SLOTS_DOCUMENTOS } from '../data/constantes'
export interface ItemExpediente {
clave: string
etiqueta: string
completo: boolean
}
export interface EstadoExpediente {
items: ItemExpediente[]
completos: number
total: number
completoTodo: boolean
}
/**
* Estado del expediente en vivo: los 5 documentos del formato + la firma.
* Función pura para poder probarla y reutilizarla en los pasos 7 y 8.
*/
export function estadoExpediente(documentos: DocumentoCargado[], firmaDataUrl: string): EstadoExpediente {
const items: ItemExpediente[] = SLOTS_DOCUMENTOS.map(({ slot, etiqueta }) => ({
clave: slot,
etiqueta,
completo: documentos.some((d) => d.slot === slot),
}))
items.push({
clave: 'FIRMA',
etiqueta: 'Firma del cliente',
completo: firmaDataUrl.startsWith('data:image/'),
})
const completos = items.filter((i) => i.completo).length
return { items, completos, total: items.length, completoTodo: completos === items.length }
}

View File

@@ -0,0 +1,156 @@
import type { AltaCliente } from '../types'
// PNG de 1x1 px para ejercitar la incrustación de firma en los tests
export const FIRMA_PRUEBA =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='
/**
* Fixture con los datos del cliente del PDF de referencia
* (PASCUAL MARTIN TELLEZ CHIGUINDO — pdf_formatos.txt)
*/
export const fixtureAlta: AltaCliente = {
datosGenerales: {
campaniaVenta: '',
fecha: '2026-07-19',
nombreCompleto: 'PASCUAL MARTIN TELLEZ CHIGUINDO',
nacionalidad: 'MEXICANA',
nivelEstudios: 'MAESTRIA',
curp: 'TECP891124HBCLHS07',
rfc: 'TECP8911246B4',
nss: '',
delegacionMunicipio: 'TIJUANA',
direccion: 'RIO EUFRATES EDIF 1 7145 203',
codigoPostal: '22223',
antiguedadDomicilio: '2',
fechaNacimiento: '1989-11-24',
lugarNacimiento: 'TIJUANA',
estadoCivil: 'SOLTERO',
regimenBienes: '',
peso: '87',
estatura: '1.78',
email: 'dantemartinchi@gmail.com',
telCasa: '6646935924',
celular: '6646935924',
viviendaAnios: '2',
tipoVivienda: 'PROPIA',
tipoCompra: 'VIVIR_AHI',
actividadOcupacion: 'AGENTE DEL MINISTERIO PUBLICO FEDERAL',
},
empleo: {
tipoEmpleo: 'ASALARIADO',
tipoEconomia: 'E_MEXICANA',
compania: 'FISCALIA GENERAL DE LA REPUBLICA',
antiguedadAnios: '12',
antiguedadMeses: '10',
direccion: 'ZONA RIO TIJUANA',
colonia: 'ZONA RIO',
numero: '',
codigoPostal: '22230',
puesto: 'AGENTE DE MINISTERIO PUBLICO FEDERAL',
telefono: '6646336847',
ext: '',
delegacion: 'TIJUANA',
},
economicos: {
ingresosMensuales: 54988,
otrosIngresos: 0,
gastosLuz: 500,
gastosOtros: 5000,
dependientes: { personas: '', parentesco: '', edad: '' },
},
coacreditado: {
tieneCoacreditado: false,
generales: {
campaniaVenta: '',
fecha: '2026-07-19',
nombreCompleto: '',
nacionalidad: '',
nivelEstudios: '',
curp: '',
rfc: '',
nss: '',
delegacionMunicipio: '',
direccion: '',
codigoPostal: '',
antiguedadDomicilio: '',
fechaNacimiento: '',
lugarNacimiento: '',
estadoCivil: '',
regimenBienes: '',
peso: '',
estatura: '',
email: '',
telCasa: '',
celular: '',
viviendaAnios: '',
tipoVivienda: '',
tipoCompra: '',
actividadOcupacion: '',
},
empleo: {
tipoEmpleo: '',
tipoEconomia: '',
compania: '',
antiguedadAnios: '',
antiguedadMeses: '',
direccion: '',
colonia: '',
numero: '',
codigoPostal: '',
puesto: '',
telefono: '',
ext: '',
delegacion: '',
},
economicos: {
ingresosMensuales: 0,
otrosIngresos: 0,
gastosLuz: 0,
gastosOtros: 0,
dependientes: { personas: '', parentesco: '', edad: '' },
},
},
referencias: [
{ nombre: 'JOSE ARNOLDO MENDOZA', telefono: '6641887799', parentesco: 'AMIGO', direccion: 'ALTAMIRA' },
{ nombre: 'JAIME JOHAN LARA', telefono: '6644704362', parentesco: 'AMIGO', direccion: 'CAÑON OASIS' },
{ nombre: 'FANNY VANESSA CARDENAS', telefono: '6634381839', parentesco: 'AMIGO', direccion: 'VISTA LAGO' },
{ nombre: 'CYNTHIA ANAHI GARCIA', telefono: '6634303303', parentesco: 'AMIGO', direccion: 'ZONA RIO' },
],
viviendaCredito: {
proyecto: 'SENDA',
proyectoOtro: '',
modelo: 'LAUREL',
manzana: '11',
lote: '11',
ubicacion: '409-1-11-11',
superficieM2: '',
valorVenta: '2700000',
enganche: '0',
equipamientoAdicional: '',
fechaPagoEnganche: '',
firmaContrato: '',
tiposCredito: ['BANCARIO'],
montoCreditoInfoFovi: '',
ahorroVoluntarioSubcuenta: '',
tipoProducto: '',
descuentoMensualInfoFovissste: '',
idAutoridadEmisora: 'INSTITUTO NACIONAL ELECTORAL',
idNumero: 'IDMEX2311049617',
idVigencia: '2032-12-31',
domicilioFiscal: {
calle: 'FRAY PEDRO DE GANTE',
numExt: '97',
numInt: '0',
colonia: 'TIJUANA OTAY',
municipio: 'TIJUANA',
estado: 'BAJA CALIFORNIA',
cp: '22508',
pais: 'MEXICO',
},
},
documentos: [],
firma: {
firmaDataUrl: FIRMA_PRUEBA,
asesorNombre: 'ESMERALDA BECERRA DOMINGUEZ',
},
}

View File

@@ -0,0 +1,48 @@
const formatoMXN = new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
})
/** Formato monetario MXN, p. ej. $2,700,000.00 */
export function formatoMoneda(valor: number): string {
return formatoMXN.format(valor)
}
/** Fecha en formato largo es-MX: "20 de agosto de 2026" */
export function formatoFechaLarga(fecha: Date): string {
return fecha.toLocaleDateString('es-MX', { day: 'numeric', month: 'long', year: 'numeric' })
}
/** ISO 'YYYY-MM-DD' a fecha legible corta: "24/11/1989" */
export function formatoFechaCorta(iso: string): string {
if (!iso) return ''
return new Date(`${iso}T00:00:00`).toLocaleDateString('es-MX', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
/** Fecha ISO del día (para nombre de archivo): 'YYYY-MM-DD' */
export function fechaISOHoy(fecha: Date = new Date()): string {
const y = fecha.getFullYear()
const m = String(fecha.getMonth() + 1).padStart(2, '0')
const d = String(fecha.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
/** Normaliza un nombre para usarlo en nombre de archivo: sin acentos ni espacios */
export function normalizarNombreArchivo(nombre: string): string {
return nombre
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.replace(/\s+/g, '_')
.replace(/[^A-Za-z0-9_]/g, '')
.toUpperCase()
}
/** Construye el nombre de archivo PDF: {tipo}_{nombreCliente}_{fechaISO}.pdf */
export function nombreArchivoPdf(tipo: string, nombreCliente: string, fecha: Date = new Date()): string {
return `${tipo}_${normalizarNombreArchivo(nombreCliente)}_${fechaISOHoy(fecha)}.pdf`
}

View File

@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest'
import { parsearTextoIne } from './ocrIne'
// Texto OCR de ejemplo de una credencial INE (frente)
const TEXTO_OCR = `
INSTITUTO NACIONAL ELECTORAL
CREDENCIAL PARA VOTAR
NOMBRE
TELLEZ CHIGUINDO
PASCUAL MARTIN
DOMICILIO
AV REVOLUCION 123
TIJUANA BC 22000
CLAVE DE ELECTOR TLCHPS89112402H400
CURP TECP891124HBCLHS07
FECHA DE NACIMIENTO 24/11/1989
SEXO H
VIGENCIA 2020-2030
`
describe('parsearTextoIne', () => {
it('extrae CURP y deriva fecha y sexo', () => {
const s = parsearTextoIne(TEXTO_OCR)
expect(s.curp).toBe('TECP891124HBCLHS07')
expect(s.fechaNacimiento).toBe('1989-11-24')
expect(s.sexo).toBe('H')
})
it('extrae el nombre de las líneas tras NOMBRE', () => {
const s = parsearTextoIne(TEXTO_OCR)
expect(s.nombre).toBe('TELLEZ CHIGUINDO PASCUAL MARTIN')
})
it('sin CURP no deriva fecha ni sexo', () => {
const s = parsearTextoIne('NOMBRE\nPEREZ LOPEZ\nJUAN\nDOMICILIO')
expect(s.curp).toBeUndefined()
expect(s.fechaNacimiento).toBeUndefined()
expect(s.sexo).toBeUndefined()
expect(s.nombre).toBe('PEREZ LOPEZ JUAN')
})
it('texto sin nada útil devuelve sugerencias vacías', () => {
expect(parsearTextoIne('!!! ### 123')).toEqual({})
})
it('tolerante a minúsculas del OCR', () => {
const s = parsearTextoIne('nombre\ntellez chiguindo\npascual martin\ncurp tecp891124hbclhs07')
expect(s.curp).toBe('TECP891124HBCLHS07')
expect(s.nombre).toBe('TELLEZ CHIGUINDO PASCUAL MARTIN')
})
})

View File

@@ -0,0 +1,81 @@
// Parser del texto OCR de una credencial INE: extrae sugerencias
// (CURP, nombre, y fecha/sexo derivados de la CURP) sin tocar el formulario.
import { extraerDeCurp } from './curp'
export interface SugerenciasIne {
curp?: string
nombre?: string
/** YYYY-MM-DD, derivada de la CURP detectada */
fechaNacimiento?: string
sexo?: 'H' | 'M'
}
const REGEX_CURP_TEXTO = /[A-ZÑ]{4}\d{6}[HM][A-Z]{5}[0-9A-Z]\d/
// Etiquetas típicas de la credencial que delimitan el bloque del nombre
const ETIQUETAS_CORTE = [
'DOMICILIO',
'FECHA DE NACIMIENTO',
'CLAVE DE ELECTOR',
'CURP',
'ESTADO',
'MUNICIPIO',
'LOCALIDAD',
'SEXO',
'VIGENCIA',
'EMISION',
'ANO DE REGISTRO',
]
function esEtiquetaCorte(linea: string): boolean {
return ETIQUETAS_CORTE.some((e) => linea.startsWith(e))
}
/**
* Heurística de nombre: líneas en mayúsculas que siguen a la línea "NOMBRE",
* hasta topar con otra etiqueta de la credencial (máx 2 líneas).
*/
function extraerNombre(lineas: string[]): string | undefined {
const idx = lineas.findIndex((l) => l === 'NOMBRE' || l.startsWith('NOMBRE '))
if (idx === -1) return undefined
const partes: string[] = []
// Si la línea "NOMBRE" ya trae texto detrás, tómalo como primera parte
const enMismaLinea = lineas[idx].replace(/^NOMBRE\s*/, '').trim()
if (enMismaLinea !== '') partes.push(enMismaLinea)
for (let i = idx + 1; i < lineas.length && partes.length < 2; i++) {
const linea = lineas[i]
if (esEtiquetaCorte(linea)) break
if (!/^[A-ZÑÁÉÍÓÚ' -]{3,}$/.test(linea)) break
partes.push(linea)
}
const nombre = partes.join(' ').replace(/\s+/g, ' ').trim()
return nombre === '' ? undefined : nombre
}
/** Convierte el texto OCR crudo en sugerencias para el formulario */
export function parsearTextoIne(texto: string): SugerenciasIne {
const lineas = texto
.toUpperCase()
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l !== '')
const sugerencias: SugerenciasIne = {}
const textoPlano = lineas.join(' ')
const curp = textoPlano.match(REGEX_CURP_TEXTO)?.[0]
if (curp) {
sugerencias.curp = curp
const derivados = extraerDeCurp(curp)
if (derivados) {
sugerencias.fechaNacimiento = derivados.fechaNacimiento
sugerencias.sexo = derivados.sexo
}
}
const nombre = extraerNombre(lineas)
if (nombre) sugerencias.nombre = nombre
return sugerencias
}

View File

@@ -0,0 +1,39 @@
import { jsPDF } from 'jspdf'
import type { AltaCliente } from '../../types'
import { formatoFechaLarga, nombreArchivoPdf } from '../formatters'
import {
AVISO_PRIVACIDAD_TITULO,
AVISO_PRIVACIDAD_PARRAFOS,
avisoPrivacidadParrafoFinal,
} from '../../data/textosLegales'
import { MARGEN, parrafo, titulo, lineaFirma, type DocumentoPdf } from './helpers'
/** Construye el PDF "AVISO DE PRIVACIDAD (SINÓPTICO)" (sin descargarlo) */
export function crearDocumento(alta: AltaCliente): DocumentoPdf {
const { datosGenerales: g, firma } = alta
const doc = new jsPDF()
let y = titulo(doc, MARGEN + 4, AVISO_PRIVACIDAD_TITULO, 13)
y += 2
for (const p of AVISO_PRIVACIDAD_PARRAFOS) {
y = parrafo(doc, y, p)
y += 1
}
y = parrafo(doc, y, avisoPrivacidadParrafoFinal(g.nombreCompleto))
y += 4
const fecha = g.fecha ? new Date(`${g.fecha}T00:00:00`) : new Date()
y = parrafo(doc, y, `Tijuana, Baja California, a ${formatoFechaLarga(fecha)}.`)
y += 2
y = parrafo(doc, y, 'ENTERADO-CONFORME\nAUTORIZO EL USO DE MIS DATOS', { negritas: true })
y += 12
lineaFirma(doc, y, MARGEN + 40, 100, g.nombreCompleto, firma.firmaDataUrl)
return { doc, nombreArchivo: nombreArchivoPdf('aviso_privacidad_firmado', g.nombreCompleto) }
}
/** Genera y descarga el PDF del aviso de privacidad */
export function descargar(alta: AltaCliente): void {
const { doc, nombreArchivo } = crearDocumento(alta)
doc.save(nombreArchivo)
}

View File

@@ -0,0 +1,54 @@
import { jsPDF } from 'jspdf'
import type { AltaCliente } from '../../types'
import { nombreArchivoPdf } from '../formatters'
import {
cartaAntilavadoCuerpo,
CARTA_ANTILAVADO_CIERRE,
CARTA_ANTILAVADO_DESPEDIDA,
} from '../../data/textosLegales'
import { MARGEN, parrafo, lineaFirma, type DocumentoPdf } from './helpers'
/** Construye la carta de no pagos en efectivo (anti-lavado) sin descargarla */
export function crearDocumento(alta: AltaCliente): DocumentoPdf {
const { datosGenerales: g, viviendaCredito: v, firma } = alta
const doc = new jsPDF()
const fecha = g.fecha ? new Date(`${g.fecha}T00:00:00`) : new Date()
const proyecto = v.proyecto === 'OTRO' ? v.proyectoOtro ?? '' : v.proyecto
let y = MARGEN + 4
doc.setFontSize(9)
doc.setFont('helvetica', 'normal')
const dd = String(fecha.getDate()).padStart(2, '0')
const mm = String(fecha.getMonth() + 1).padStart(2, '0')
doc.text(`Tijuana, B.C a ${dd} de ${mm} de ${fecha.getFullYear()}`, MARGEN, y)
y += 10
y = parrafo(doc, y, `Estimado (a) ${g.nombreCompleto}`, { negritas: true, tamano: 9 })
y = parrafo(doc, y, 'P R E S E N T E', { negritas: true, tamano: 9 })
y += 3
y = parrafo(doc, y, cartaAntilavadoCuerpo(proyecto), { tamano: 9 })
y += 2
y = parrafo(doc, y, CARTA_ANTILAVADO_CIERRE, { tamano: 9 })
y += 2
y = parrafo(doc, y, CARTA_ANTILAVADO_DESPEDIDA, { tamano: 9 })
y += 6
y = parrafo(doc, y, 'A T E N T A M E N T E', { negritas: true, tamano: 9 })
y += 12
y = lineaFirma(doc, y, MARGEN + 40, 100, 'Frasa Desarrollos SAPI C.V.')
y = parrafo(doc, y, 'Por medio de su representante legal', { tamano: 9 })
y += 8
y = parrafo(doc, y, 'DE CONFORMIDAD', { negritas: true, tamano: 9 })
y += 12
y = lineaFirma(doc, y, MARGEN + 40, 100, g.nombreCompleto, firma.firmaDataUrl)
parrafo(doc, y, 'En mi carácter de “COMPRADOR”', { tamano: 9 })
return { doc, nombreArchivo: nombreArchivoPdf('carta_antilavado_firmada', g.nombreCompleto) }
}
/** Genera y descarga la carta anti-lavado */
export function descargar(alta: AltaCliente): void {
const { doc, nombreArchivo } = crearDocumento(alta)
doc.save(nombreArchivo)
}

View File

@@ -0,0 +1,116 @@
import { jsPDF } from 'jspdf'
import autoTable from 'jspdf-autotable'
import type { AltaCliente } from '../../types'
import { formatoMoneda, nombreArchivoPdf } from '../formatters'
import {
CHECKLIST_BASICO,
CHECKLIST_ANTES_ESCRITURA,
CHECKLIST_NOTAS,
REQUISITOS_GENERALES,
REQUISITOS_CONTACTO_ENTREGA,
REQUISITOS_CONTACTO_CONTRATO,
} from '../../data/textosLegales'
import { MARGEN, finalY, parrafo, titulo, lineaFirma, numero, type DocumentoPdf } from './helpers'
const vacio = (v: string | undefined | null): string => (v && v.trim() !== '' ? v : '—')
/**
* Construye el PDF "FORMATO DE RECEPCIÓN Y CONTROL DE EXPEDIENTE" +
* página de "REQUISITOS GENERALES" (sin descargarlo)
*/
export function crearDocumento(alta: AltaCliente): DocumentoPdf {
const { datosGenerales: g, viviendaCredito: v, referencias, firma } = alta
const doc = new jsPDF()
const proyecto = v.proyecto === 'OTRO' ? v.proyectoOtro ?? '' : v.proyecto
let y = titulo(doc, MARGEN + 4, 'FORMATO DE RECEPCIÓN Y\nCONTROL DE EXPEDIENTE', 13)
y += 2
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 8, cellPadding: 1.5 },
headStyles: { fillColor: [4, 120, 87] },
body: [
['CLIENTE', g.nombreCompleto],
['PROYECTO', vacio(proyecto)],
['UBICACIÓN / PROTOTIPO', `${vacio(v.ubicacion)} ${vacio(v.modelo)}`.replace('— —', '—')],
['VALOR VIVIENDA', formatoMoneda(numero(v.valorVenta))],
['ENGANCHE', formatoMoneda(numero(v.enganche))],
['FECHA PAGO DE ENGANCHE', vacio(v.fechaPagoEnganche)],
['FIRMA DE CONTRATO', vacio(v.firmaContrato)],
],
})
y = finalY(doc) + 6
// Checklists en dos columnas
doc.setFontSize(9)
doc.setFont('helvetica', 'bold')
doc.text('CHECK LIST EXPEDIENTE BÁSICO:', MARGEN, y)
doc.text('ANTES DE ESCRITURA:', 115, y)
y += 5
doc.setFont('helvetica', 'normal')
doc.setFontSize(8)
const filas = Math.max(CHECKLIST_BASICO.length, CHECKLIST_ANTES_ESCRITURA.length)
for (let i = 0; i < filas; i++) {
if (CHECKLIST_BASICO[i]) doc.text(`[ ] ${CHECKLIST_BASICO[i]}`, MARGEN, y)
if (CHECKLIST_ANTES_ESCRITURA[i]) doc.text(`[ ] ${CHECKLIST_ANTES_ESCRITURA[i]}`, 115, y)
y += 5
}
y += 4
for (const nota of CHECKLIST_NOTAS) {
y = parrafo(doc, y, nota)
y += 1
}
y += 14
// Firmas de recepción del expediente
const yFirmas = y
lineaFirma(doc, yFirmas, MARGEN, 55, 'GLADIS VALENZUELA\nFECHA DE RECEPCIÓN')
lineaFirma(doc, yFirmas, 78, 55, 'DEYSI LORENA ALEMAN FIGUEROA\nCORPORATIVO')
lineaFirma(doc, yFirmas, 142, 55, 'DANIEL ALVARADO LUQUE\nRECIBIÓ')
// Página 2: requisitos generales
doc.addPage()
y = titulo(doc, MARGEN + 4, 'REQUISITOS\nGENERALES', 13)
y = parrafo(doc, y, `NOMBRE: ${g.nombreCompleto}`, { negritas: true, tamano: 9 })
y = parrafo(doc, y, `FECHA: ${vacio(g.fecha)}`, { negritas: true, tamano: 9 })
y += 2
for (const grupo of REQUISITOS_GENERALES) {
y = parrafo(doc, y, grupo.titulo, { negritas: true, tamano: 9 })
for (const item of grupo.items) {
y = parrafo(doc, y, `• ${item}`)
}
y += 2
}
// Referencias en U.S.A. (tabla vacía para llenar a mano)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['NOMBRE', 'TELÉFONO', 'PARENTESCO', 'DIRECCIÓN']],
body: referencias.map((r) => [vacio(r.nombre), vacio(r.telefono), vacio(r.parentesco), vacio(r.direccion)]),
})
y = finalY(doc) + 6
y = parrafo(doc, y, 'ENTREGA DE DOCUMENTOS:', { negritas: true, tamano: 9 })
y = parrafo(doc, y, REQUISITOS_CONTACTO_ENTREGA)
y += 2
y = parrafo(doc, y, 'FIRMA DE CONTRATO:', { negritas: true, tamano: 9 })
y = parrafo(doc, y, REQUISITOS_CONTACTO_CONTRATO)
y += 12
lineaFirma(doc, y, MARGEN + 10, 70, `NOMBRE Y FIRMA DEL CLIENTE\n${g.nombreCompleto}`, firma.firmaDataUrl)
lineaFirma(doc, y, 120, 70, `NOMBRE Y FIRMA DEL ASESOR\n${firma.asesorNombre}`)
return { doc, nombreArchivo: nombreArchivoPdf('checklist_expediente', g.nombreCompleto) }
}
/** Genera y descarga el PDF del checklist de expediente */
export function descargar(alta: AltaCliente): void {
const { doc, nombreArchivo } = crearDocumento(alta)
doc.save(nombreArchivo)
}

View File

@@ -0,0 +1,79 @@
import { jsPDF } from 'jspdf'
export const MARGEN = 14
const ANCHO_UTIL = 210 - MARGEN * 2
const LIMITE_PAGINA = 285
export interface DocumentoPdf {
doc: jsPDF
nombreArchivo: string
}
// jspdf-autotable expone finalY en doc.lastAutoTable en runtime; sus tipos no lo declaran
type DocConTabla = jsPDF & { lastAutoTable: { finalY: number } }
export const finalY = (doc: jsPDF): number => (doc as DocConTabla).lastAutoTable.finalY
export interface OpcionesParrafo {
negritas?: boolean
tamano?: number
alinear?: 'left' | 'center' | 'right'
}
/** Escribe un párrafo con salto de página automático. Devuelve la nueva Y. */
export function parrafo(doc: jsPDF, y: number, texto: string, opciones: OpcionesParrafo = {}): number {
const tamano = opciones.tamano ?? 8
doc.setFontSize(tamano)
doc.setFont('helvetica', opciones.negritas ? 'bold' : 'normal')
const lineas = doc.splitTextToSize(texto, ANCHO_UTIL) as string[]
if (y + lineas.length * tamano * 0.4 > LIMITE_PAGINA) {
doc.addPage()
y = MARGEN
}
const alinear = opciones.alinear ?? 'left'
const x = alinear === 'center' ? 105 : alinear === 'right' ? 210 - MARGEN : MARGEN
doc.text(lineas, x, y, { align: alinear })
return y + lineas.length * tamano * 0.4 + 3
}
/** Título centrado. Devuelve la nueva Y. */
export function titulo(doc: jsPDF, y: number, texto: string, tamano = 12): number {
doc.setFontSize(tamano)
doc.setFont('helvetica', 'bold')
const lineas = doc.splitTextToSize(texto, ANCHO_UTIL) as string[]
doc.text(lineas, 105, y, { align: 'center' })
return y + lineas.length * tamano * 0.45 + 3
}
/**
* Línea de firma con leyenda; si hay imagen de firma se incrusta sobre la línea.
* Devuelve la nueva Y.
*/
export function lineaFirma(
doc: jsPDF,
y: number,
x: number,
ancho: number,
leyenda: string,
firmaDataUrl?: string,
): number {
if (y + 25 > LIMITE_PAGINA) {
doc.addPage()
y = MARGEN + 10
}
if (firmaDataUrl) {
doc.addImage(firmaDataUrl, 'PNG', x + ancho / 2 - 15, y - 14, 30, 14)
}
doc.setLineWidth(0.3)
doc.line(x, y, x + ancho, y)
doc.setFontSize(8)
doc.setFont('helvetica', 'bold')
const lineas = doc.splitTextToSize(leyenda, ancho) as string[]
doc.text(lineas, x + ancho / 2, y + 4, { align: 'center' })
return y + 4 + lineas.length * 8 * 0.4 + 4
}
/** Parsea un texto numérico del formulario; vacío o inválido devuelve 0 */
export function numero(valor: string): number {
const n = Number.parseFloat(valor)
return Number.isFinite(n) ? n : 0
}

View File

@@ -0,0 +1,233 @@
import { jsPDF } from 'jspdf'
import autoTable from 'jspdf-autotable'
import type { AltaCliente } from '../../types'
import { formatoFechaCorta, nombreArchivoPdf } from '../formatters'
import {
PLD_TEXTO_PROTECCION_DATOS,
PLD_TEXTO_DOCUMENTOS_INTEGRAN,
PLD_TEXTO_BENEFICIARIO,
PLD_TEXTO_RESPONSABILIDAD,
PLD_TEXTO_OTRO_FORMATO,
beneficiarioControladorIntro,
BENEFICIARIO_CONTROLADOR_ARTICULO3,
BENEFICIARIO_CONTROLADOR_MANIFIESTO,
BENEFICIARIO_CONTROLADOR_CIERRE,
} from '../../data/textosLegales'
import { MARGEN, finalY, parrafo, titulo, lineaFirma, type DocumentoPdf } from './helpers'
const vacio = (v: string | undefined | null): string => (v && v.trim() !== '' ? v : '')
/**
* Construye el PDF "FORMATO DE IDENTIFICACION DE CLIENTES" (PLD) +
* "FORMATO PARA LA IDENTIFICACIÓN DEL BENEFICIARIO CONTROLADOR" (sin descargarlo)
*/
export function crearDocumento(alta: AltaCliente): DocumentoPdf {
const { datosGenerales: g, viviendaCredito: v, firma } = alta
const df = v.domicilioFiscal
const doc = new jsPDF()
let y = titulo(
doc,
MARGEN + 4,
'FORMATO DE IDENTIFICACION DE CLIENTES\nPERSONA FÍSICA NACIONALIDAD MEXICANA O EXTRANJERA\nCON RESIDENCIA PERMANENTE O TEMPORAL',
)
doc.setFontSize(8)
doc.setFont('helvetica', 'bold')
doc.text(`FECHA: ${formatoFechaCorta(g.fecha)}`, 210 - MARGEN, y, { align: 'right' })
y += 5
// Campos 1 a 11 del formato PLD
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['1 - Nombre y apellidos del cliente']],
body: [[g.nombreCompleto]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['2 - Fecha de Nacimiento', '3 - Lugar de Nacimiento']],
body: [[formatoFechaCorta(g.fechaNacimiento), vacio(g.lugarNacimiento)]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['4 - Nacionalizado o Residente', '4 - País']],
body: [[vacio(g.nacionalidad), 'MEXICO']],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['5 - Actividad, Ocupación o Profesión']],
body: [[vacio(g.actividadOcupacion)]],
})
y = finalY(doc)
// 6 - Domicilio particular
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['6 - Domicilio Particular', '']],
body: [
['Calle o Avenida / Dirección', vacio(g.direccion)],
['Colonia o Zona Urbana / Municipio', [vacio(g.delegacionMunicipio)].join(' ')],
['Entidad Federativa / Código Postal / País', `BAJA CALIFORNIA ${vacio(g.codigoPostal)} MEXICO`.trim()],
],
})
y = finalY(doc)
// 6 - Domicilio fiscal (opcional)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['6 - Domicilio Fiscal (Art 29 y 29A CFF)', '']],
body: [
['Calle o Avenida', vacio(df.calle)],
['Número Exterior / Número Interior', `${vacio(df.numExt)} ${vacio(df.numInt)}`.trim()],
['Colonia o Zona Urbana', vacio(df.colonia)],
['Municipio o Demarcación Territorial', vacio(df.municipio)],
['Entidad Federativa / Código Postal / País', `${vacio(df.estado)} ${vacio(df.cp)} ${vacio(df.pais)}`.trim()],
],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['7 - Número Telefónico Casa', '7 - Número Telefónico Celular']],
body: [[vacio(g.telCasa), vacio(g.celular)]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['8 - Correo Electrónico Válido', '9 - CURP']],
body: [[g.email, g.curp]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['10 - RFC (SAT)']],
body: [[g.rfc]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['11 - Datos de la Identificación Oficial Vigente', '']],
body: [
['Nombre en Identificación', g.nombreCompleto],
['Autoridad que la Emite', vacio(v.idAutoridadEmisora)],
['Número de Identificación / Vigencia', `${vacio(v.idNumero)} ${formatoFechaCorta(v.idVigencia ?? '')}`.trim()],
],
})
y = finalY(doc) + 5
// Textos legales PLD
y = parrafo(doc, y, PLD_TEXTO_PROTECCION_DATOS)
y = parrafo(doc, y, 'IV.- DOCUMENTOS QUE SE INTEGRAN AL PRESENTE', { negritas: true })
y = parrafo(doc, y, PLD_TEXTO_DOCUMENTOS_INTEGRAN)
y = parrafo(doc, y, 'Identificación Oficial con Fotografía Comprobante de Domicilio Actual RFC CURP', {
negritas: true,
})
y = parrafo(doc, y, PLD_TEXTO_BENEFICIARIO)
y = parrafo(doc, y, PLD_TEXTO_RESPONSABILIDAD)
y = parrafo(doc, y, PLD_TEXTO_OTRO_FORMATO)
y += 10
y = lineaFirma(doc, y, MARGEN + 40, 100, `NOMBRE Y FIRMA DEL CLIENTE\n${g.nombreCompleto}`, firma.firmaDataUrl)
// Página 2: beneficiario controlador
doc.addPage()
y = titulo(doc, MARGEN + 4, 'FORMATO PARA LA IDENTIFICACIÓN DEL\nBENEFICIARIO CONTROLADOR')
const condominio = vacio(v.manzana) || '___'
y = parrafo(doc, y, beneficiarioControladorIntro(g.nombreCompleto, vacio(v.ubicacion) || '___', condominio))
y = parrafo(doc, y, BENEFICIARIO_CONTROLADOR_ARTICULO3)
y = parrafo(doc, y, BENEFICIARIO_CONTROLADOR_MANIFIESTO)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['Nombre', 'Nacionalidad']],
body: [
[g.nombreCompleto, vacio(g.nacionalidad)],
],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['Fecha Nacimiento', 'Lugar Nacimiento']],
body: [[formatoFechaCorta(g.fechaNacimiento), vacio(g.lugarNacimiento)]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['Domicilio']],
body: [[`${vacio(g.direccion)} ${vacio(g.codigoPostal)} ${vacio(g.delegacionMunicipio)} BC MEXICO`.trim()]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['Teléfono Fijo', 'Teléfono Celular']],
body: [[vacio(g.telCasa), vacio(g.celular)]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['Correo Electrónico', 'Ocupación']],
body: [[g.email, vacio(g.actividadOcupacion)]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['C.U.R.P', 'R.F.C']],
body: [[g.curp, g.rfc]],
})
y = finalY(doc) + 5
y = parrafo(doc, y, BENEFICIARIO_CONTROLADOR_CIERRE)
y += 10
lineaFirma(doc, y, MARGEN + 40, 100, `NOMBRE Y FIRMA DEL CLIENTE\n${g.nombreCompleto}`, firma.firmaDataUrl)
return { doc, nombreArchivo: nombreArchivoPdf('formato_identificacion_cliente', g.nombreCompleto) }
}
/** Genera y descarga el PDF del formato PLD de identificación */
export function descargar(alta: AltaCliente): void {
const { doc, nombreArchivo } = crearDocumento(alta)
doc.save(nombreArchivo)
}

View File

@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest'
import { fixtureAlta } from '../fixture'
import * as perfilCliente from './perfilCliente'
import * as identificacionCliente from './identificacionCliente'
import * as avisoPrivacidad from './avisoPrivacidad'
import * as cartaAntilavado from './cartaAntilavado'
import * as checklistExpediente from './checklistExpediente'
const generadores = [
['perfil_cliente_completo', perfilCliente],
['formato_identificacion_cliente', identificacionCliente],
['aviso_privacidad_firmado', avisoPrivacidad],
['carta_antilavado_firmada', cartaAntilavado],
['checklist_expediente', checklistExpediente],
] as const
describe('generadores de PDF (smoke test con fixture)', () => {
for (const [tipo, modulo] of generadores) {
it(`construye ${tipo} sin lanzar error`, () => {
const { doc, nombreArchivo } = modulo.crearDocumento(fixtureAlta)
expect(doc).toBeDefined()
expect(doc.getNumberOfPages()).toBeGreaterThan(0)
expect(nombreArchivo).toMatch(
new RegExp(`^${tipo}_PASCUAL_MARTIN_TELLEZ_CHIGUINDO_\\d{4}-\\d{2}-\\d{2}\\.pdf$`),
)
})
}
it('el formato PLD y el checklist agregan la segunda página', () => {
expect(identificacionCliente.crearDocumento(fixtureAlta).doc.getNumberOfPages()).toBeGreaterThanOrEqual(2)
expect(checklistExpediente.crearDocumento(fixtureAlta).doc.getNumberOfPages()).toBeGreaterThanOrEqual(2)
})
})

View File

@@ -0,0 +1,245 @@
import { jsPDF } from 'jspdf'
import autoTable from 'jspdf-autotable'
import type { AltaCliente } from '../../types'
import { formatoMoneda, formatoFechaCorta, nombreArchivoPdf } from '../formatters'
import { calcularTotalIngresos, calcularGastosTotal } from '../calculations'
import { TIPOS_CREDITO } from '../../data/constantes'
import { MARGEN, finalY, titulo, lineaFirma, numero, type DocumentoPdf } from './helpers'
const vacio = (v: string | undefined | null): string => (v && v.trim() !== '' ? v : '—')
function seccion(doc: jsPDF, y: number, texto: string): number {
if (y > 260) {
doc.addPage()
y = MARGEN
}
doc.setFontSize(10)
doc.setFont('helvetica', 'bold')
doc.text(texto, MARGEN, y)
return y + 3
}
/** Construye el PDF "PERFIL DEL CLIENTE" (sin descargarlo) */
export function crearDocumento(alta: AltaCliente): DocumentoPdf {
const { datosGenerales: g, empleo: e, economicos: eco, coacreditado, referencias, viviendaCredito: v, firma } = alta
const doc = new jsPDF()
let y = titulo(doc, MARGEN + 4, 'PERFIL DEL CLIENTE', 14)
// PERSONALES
y = seccion(doc, y, 'PERSONALES')
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['FECHA', 'NOMBRE', 'RFC']],
body: [
[formatoFechaCorta(g.fecha), g.nombreCompleto, g.rfc],
],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['FECHA NACIMIENTO', 'LUGAR DE NACIMIENTO', 'NACIONALIDAD']],
body: [[formatoFechaCorta(g.fechaNacimiento), vacio(g.lugarNacimiento), vacio(g.nacionalidad)]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['CURP', 'CORREO', 'TELÉFONO']],
body: [[g.curp, g.email, g.celular]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['ESTADO CIVIL', 'RÉGIMEN BIENES', 'PESO', 'ESTATURA']],
body: [[vacio(g.estadoCivil), vacio(g.regimenBienes), vacio(g.peso), vacio(g.estatura)]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['NIVEL DE ESTUDIOS', 'DIRECCIÓN']],
body: [[vacio(g.nivelEstudios), vacio(g.direccion)]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['DELEG./MUNICIPIO', 'C.P.', 'ANTIGÜEDAD', 'TIPO DE VIVIENDA', 'TIPO DE COMPRA']],
body: [
[vacio(g.delegacionMunicipio), vacio(g.codigoPostal), vacio(g.antiguedadDomicilio), vacio(g.tipoVivienda), vacio(g.tipoCompra)],
],
})
y = finalY(doc) + 6
// EMPLEO
y = seccion(doc, y, 'EMPLEO')
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['TIPO DE EMPLEO', 'TIPO DE ECONOMÍA', 'COMPAÑÍA', 'ANTIGÜEDAD']],
body: [
[
vacio(e.tipoEmpleo),
vacio(e.tipoEconomia),
vacio(e.compania),
`${vacio(e.antiguedadAnios)} AÑOS ${vacio(e.antiguedadMeses)} MESES`,
],
],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['DIRECCIÓN', 'C.P.', 'PUESTO', 'TELÉFONO', 'EXT.']],
body: [
[
[vacio(e.direccion), vacio(e.colonia), vacio(e.numero)].filter((p) => p !== '—').join(' ') || '—',
vacio(e.codigoPostal),
vacio(e.puesto),
vacio(e.telefono),
vacio(e.ext),
],
],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['INGRESO MENSUAL', 'OTROS INGRESOS', 'TOTAL INGRESOS', 'GASTOS APROXIMADOS', 'DEPENDIENTES ECONÓMICOS']],
body: [
[
formatoMoneda(eco.ingresosMensuales),
formatoMoneda(eco.otrosIngresos),
formatoMoneda(calcularTotalIngresos(eco)),
formatoMoneda(calcularGastosTotal(eco)),
vacio(eco.dependientes.personas),
],
],
})
y = finalY(doc) + 6
// CO-ACREDITADO O CÓNYUGE
if (coacreditado.tieneCoacreditado) {
const cg = coacreditado.generales
const ce = coacreditado.empleo
const ceco = coacreditado.economicos
y = seccion(doc, y, 'CO-ACREDITADO O CÓNYUGE')
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['NOMBRE', 'RFC', 'FECHA NACIMIENTO', 'CURP']],
body: [[cg.nombreCompleto, vacio(cg.rfc), formatoFechaCorta(cg.fechaNacimiento ?? '') || '—', vacio(cg.curp)]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['CORREO', 'TELÉFONO', 'ESTADO CIVIL', 'COMPAÑÍA', 'PUESTO']],
body: [[vacio(cg.email), vacio(cg.celular), vacio(cg.estadoCivil), vacio(ce.compania), vacio(ce.puesto)]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['INGRESO MENSUAL', 'OTROS INGRESOS', 'TOTAL INGRESOS', 'GASTOS APROXIMADOS']],
body: [
[
formatoMoneda(ceco.ingresosMensuales),
formatoMoneda(ceco.otrosIngresos),
formatoMoneda(calcularTotalIngresos(ceco)),
formatoMoneda(calcularGastosTotal(ceco)),
],
],
})
y = finalY(doc) + 6
}
// REFERENCIAS PERSONALES
y = seccion(doc, y, 'REFERENCIAS PERSONALES')
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['NOMBRE', 'TELÉFONO', 'PARENTESCO', 'DIRECCIÓN']],
body: referencias.map((r) => [vacio(r.nombre), vacio(r.telefono), vacio(r.parentesco), vacio(r.direccion)]),
})
y = finalY(doc) + 6
// DESARROLLO
y = seccion(doc, y, 'DESARROLLO')
const proyecto = v.proyecto === 'OTRO' ? v.proyectoOtro ?? '' : v.proyecto
const creditos = v.tiposCredito
.map((t) => TIPOS_CREDITO.find((tc) => tc.valor === t)?.etiqueta ?? t)
.join(', ')
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['PROYECTO', 'UBICACIÓN', 'PROTOTIPO']],
body: [
[
[`${vacio(v.manzana)} ${vacio(v.lote)}`.replace(/—/g, '').trim(), proyecto, vacio(v.modelo)].join(' ').trim(),
vacio(v.ubicacion),
vacio(v.modelo),
],
],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['VALOR VIVIENDA', 'ENGANCHE', 'TIPO DE CRÉDITO', 'MONTO CRÉDITO INFO/FOVI']],
body: [
[
formatoMoneda(numero(v.valorVenta)),
formatoMoneda(numero(v.enganche)),
creditos || '—',
formatoMoneda(numero(v.montoCreditoInfoFovi)),
],
],
})
y = finalY(doc) + 18
// Firmas
lineaFirma(doc, y, MARGEN + 10, 70, g.nombreCompleto, firma.firmaDataUrl)
lineaFirma(doc, y, 120, 70, firma.asesorNombre || 'ASESOR')
return { doc, nombreArchivo: nombreArchivoPdf('perfil_cliente_completo', g.nombreCompleto) }
}
/** Genera y descarga el PDF del perfil del cliente */
export function descargar(alta: AltaCliente): void {
const { doc, nombreArchivo } = crearDocumento(alta)
doc.save(nombreArchivo)
}

View File

@@ -0,0 +1,173 @@
import { describe, it, expect } from 'vitest'
import {
datosGeneralesSchema,
coacreditadoSchema,
referenciasSchema,
REGEX_CURP,
REGEX_RFC,
} from './schemas'
import type { DatosGenerales } from '../types'
// Datos válidos base con el fixture del cliente del PDF de referencia
const generalesValidos: DatosGenerales = {
campaniaVenta: '',
fecha: '2026-07-19',
nombreCompleto: 'PASCUAL MARTIN TELLEZ CHIGUINDO',
nacionalidad: 'MEXICANA',
nivelEstudios: 'MAESTRIA',
curp: 'TECP891124HBCLHS07',
rfc: 'TECP8911246B4',
nss: '',
delegacionMunicipio: 'TIJUANA',
direccion: 'RIO EUFRATES EDIF 1 7145 203',
codigoPostal: '22223',
antiguedadDomicilio: '2',
fechaNacimiento: '1989-11-24',
lugarNacimiento: 'TIJUANA',
estadoCivil: 'SOLTERO',
regimenBienes: '',
peso: '87',
estatura: '1.78',
email: 'dantemartinchi@gmail.com',
telCasa: '',
celular: '6646935924',
viviendaAnios: '2',
tipoVivienda: 'PROPIA',
tipoCompra: 'VIVIR_AHI',
actividadOcupacion: 'AGENTE DEL MINISTERIO PUBLICO FEDERAL',
}
describe('regex de identificadores', () => {
it('acepta CURP válida', () => {
expect(REGEX_CURP.test('TECP891124HBCLHS07')).toBe(true)
})
it('rechaza CURP inválida', () => {
expect(REGEX_CURP.test('TECP891124HBCLHS0')).toBe(false) // corta
expect(REGEX_CURP.test('tecp891124hbclhs07')).toBe(false) // minúsculas
expect(REGEX_CURP.test('TECP89112XHBCLHS07')).toBe(false) // fecha inválida
})
it('acepta RFC válido', () => {
expect(REGEX_RFC.test('TECP8911246B4')).toBe(true)
})
it('rechaza RFC inválido', () => {
expect(REGEX_RFC.test('TECP8911246B')).toBe(false) // corto
expect(REGEX_RFC.test('TECP891124!B4')).toBe(false)
expect(REGEX_RFC.test('tecp8911246b4')).toBe(false)
})
})
describe('datosGeneralesSchema', () => {
it('acepta los datos del fixture', () => {
expect(datosGeneralesSchema.safeParse(generalesValidos).success).toBe(true)
})
it('rechaza CURP inválida', () => {
const r = datosGeneralesSchema.safeParse({ ...generalesValidos, curp: 'XXXX000000XXXXXXX0' })
expect(r.success).toBe(false)
})
it('rechaza RFC inválido', () => {
const r = datosGeneralesSchema.safeParse({ ...generalesValidos, rfc: '123' })
expect(r.success).toBe(false)
})
it('rechaza email inválido', () => {
const r = datosGeneralesSchema.safeParse({ ...generalesValidos, email: 'no-es-correo' })
expect(r.success).toBe(false)
})
it('rechaza código postal que no tenga 5 dígitos', () => {
const r = datosGeneralesSchema.safeParse({ ...generalesValidos, codigoPostal: '2223' })
expect(r.success).toBe(false)
})
it('exige régimen de bienes solo cuando el estado civil es CASADO', () => {
const casado = datosGeneralesSchema.safeParse({ ...generalesValidos, estadoCivil: 'CASADO' })
expect(casado.success).toBe(false)
const casadoConRegimen = datosGeneralesSchema.safeParse({
...generalesValidos,
estadoCivil: 'CASADO',
regimenBienes: 'SEPARACION_BIENES',
})
expect(casadoConRegimen.success).toBe(true)
})
})
describe('coacreditadoSchema', () => {
const base = {
generales: { ...generalesValidos, nombreCompleto: '', curp: '', rfc: '', email: '', fechaNacimiento: '', celular: '' },
empleo: {
tipoEmpleo: '' as const,
tipoEconomia: '' as const,
compania: '',
antiguedadAnios: '',
antiguedadMeses: '',
direccion: '',
colonia: '',
numero: '',
codigoPostal: '',
puesto: '',
telefono: '',
ext: '',
delegacion: '',
},
economicos: {
ingresosMensuales: 0,
otrosIngresos: 0,
gastosLuz: 0,
gastosOtros: 0,
dependientes: { personas: '', parentesco: '', edad: '' },
},
}
it('no exige datos del co-acreditado cuando el toggle está apagado', () => {
const r = coacreditadoSchema.safeParse({ ...base, tieneCoacreditado: false })
expect(r.success).toBe(true)
})
it('exige el nombre del co-acreditado cuando el toggle está activo', () => {
const r = coacreditadoSchema.safeParse({ ...base, tieneCoacreditado: true })
expect(r.success).toBe(false)
})
it('acepta co-acreditado con nombre cuando el toggle está activo', () => {
const r = coacreditadoSchema.safeParse({
...base,
tieneCoacreditado: true,
generales: { ...base.generales, nombreCompleto: 'MARIA LOPEZ PEREZ' },
})
expect(r.success).toBe(true)
})
})
describe('referenciasSchema', () => {
const vacia = { nombre: '', telefono: '', parentesco: '', direccion: '' }
it('rechaza cuando ninguna referencia tiene nombre y teléfono', () => {
const r = referenciasSchema.safeParse([vacia, vacia, vacia, vacia])
expect(r.success).toBe(false)
})
it('rechaza referencia con nombre pero sin teléfono', () => {
const r = referenciasSchema.safeParse([
{ ...vacia, nombre: 'JOSE ARNOLDO MENDOZA' },
vacia,
vacia,
vacia,
])
expect(r.success).toBe(false)
})
it('acepta con al menos 1 referencia válida', () => {
const r = referenciasSchema.safeParse([
{ nombre: 'JOSE ARNOLDO MENDOZA', telefono: '6641887799', parentesco: 'AMIGO', direccion: 'ALTAMIRA' },
vacia,
vacia,
vacia,
])
expect(r.success).toBe(true)
})
})

View File

@@ -0,0 +1,219 @@
import { z } from 'zod'
export const REGEX_CURP = /^[A-Z]{4}\d{6}[HM][A-Z]{5}[0-9A-Z]\d$/
export const REGEX_RFC = /^[A-ZÑ&]{4}\d{6}[0-9A-Z]{3}$/
export const REGEX_CP = /^\d{5}$/
// Texto opcional: cadena siempre presente (vacía si no se captura)
const textoOpcional = z.string().trim().max(200)
const curpSchema = z
.string()
.trim()
.toUpperCase()
.regex(REGEX_CURP, 'CURP inválida (18 caracteres, formato oficial)')
const rfcSchema = z
.string()
.trim()
.toUpperCase()
.regex(REGEX_RFC, 'RFC inválido (13 caracteres, formato persona física)')
const cpOpcional = z
.string()
.trim()
.refine((v) => v === '' || REGEX_CP.test(v), 'Código postal de 5 dígitos')
const curpOpcional = z
.string()
.trim()
.toUpperCase()
.refine((v) => v === '' || REGEX_CURP.test(v), 'CURP inválida')
const rfcOpcional = z
.string()
.trim()
.toUpperCase()
.refine((v) => v === '' || REGEX_RFC.test(v), 'RFC inválido')
const emailOpcional = z
.string()
.trim()
.refine((v) => v === '' || z.string().email().safeParse(v).success, 'Correo electrónico inválido')
export const datosGeneralesSchema = z
.object({
campaniaVenta: textoOpcional,
fecha: z.string().min(1, 'La fecha es requerida'),
nombreCompleto: z.string().trim().min(3, 'El nombre completo es requerido'),
nacionalidad: textoOpcional,
nivelEstudios: textoOpcional,
curp: curpSchema,
rfc: rfcSchema,
nss: textoOpcional,
delegacionMunicipio: textoOpcional,
direccion: textoOpcional,
codigoPostal: cpOpcional,
antiguedadDomicilio: textoOpcional,
fechaNacimiento: z.string().min(1, 'La fecha de nacimiento es requerida'),
lugarNacimiento: textoOpcional,
estadoCivil: z.enum(['SOLTERO', 'CASADO', 'UNION_LIBRE', 'DIVORCIADO', 'VIUDO', '']),
regimenBienes: z.enum(['SOCIEDAD_CONYUGAL', 'SEPARACION_BIENES', '']),
peso: textoOpcional,
estatura: textoOpcional,
email: z.string().trim().email('Correo electrónico inválido'),
telCasa: textoOpcional,
celular: z.string().trim().min(8, 'El celular es requerido'),
viviendaAnios: textoOpcional,
tipoVivienda: z.enum(['PRESTADA', 'RENTADA', 'FAMILIAR', 'PROPIA', '']),
tipoCompra: z.enum(['INVERSION', 'VIVIR_AHI', '']),
actividadOcupacion: textoOpcional,
})
.superRefine((datos, ctx) => {
// Régimen de bienes solo aplica (y se exige) cuando el estado civil es CASADO
if (datos.estadoCivil === 'CASADO' && datos.regimenBienes === '') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['regimenBienes'],
message: 'Selecciona el régimen de bienes',
})
}
})
export const empleoSchema = z.object({
tipoEmpleo: z.enum(['ASALARIADO', 'INDEPENDIENTE', '']),
tipoEconomia: z.enum(['E_MEXICANA', 'E_AMERICANA', 'MIXTO', '']),
compania: textoOpcional,
antiguedadAnios: textoOpcional,
antiguedadMeses: textoOpcional,
direccion: textoOpcional,
colonia: textoOpcional,
numero: textoOpcional,
codigoPostal: cpOpcional,
puesto: textoOpcional,
telefono: textoOpcional,
ext: textoOpcional,
delegacion: textoOpcional,
})
const numeroNoNegativo = z.number({ invalid_type_error: 'Ingresa un número' }).min(0, 'No puede ser negativo')
export const economicosSchema = z.object({
ingresosMensuales: numeroNoNegativo,
otrosIngresos: numeroNoNegativo,
gastosLuz: numeroNoNegativo,
gastosOtros: numeroNoNegativo,
dependientes: z.object({
personas: textoOpcional,
parentesco: textoOpcional,
edad: textoOpcional,
}),
})
// Datos generales del co-acreditado: el nombre se exige vía superRefine solo
// cuando el toggle está activo; el resto se valida solo si se captura
const coacreditadoGeneralesSchema = datosGeneralesSchema.innerType().extend({
nombreCompleto: z.string().trim().max(200),
curp: curpOpcional,
rfc: rfcOpcional,
email: emailOpcional,
fechaNacimiento: textoOpcional,
celular: textoOpcional,
})
export const coacreditadoSchema = z
.object({
tieneCoacreditado: z.boolean(),
generales: coacreditadoGeneralesSchema,
empleo: empleoSchema,
economicos: economicosSchema,
})
.superRefine((datos, ctx) => {
// Los datos del co-acreditado solo se exigen si el toggle está activo
if (datos.tieneCoacreditado && datos.generales.nombreCompleto.trim().length < 3) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['generales', 'nombreCompleto'],
message: 'El nombre del co-acreditado es requerido',
})
}
})
const referenciaSchema = z.object({
nombre: textoOpcional,
telefono: textoOpcional,
parentesco: textoOpcional,
direccion: textoOpcional,
})
export const referenciasSchema = z
.array(referenciaSchema)
.length(4, 'Se capturan 4 referencias')
.refine(
(refs) => refs.some((r) => r.nombre.trim() !== '' && r.telefono.trim() !== ''),
'Captura al menos 1 referencia con nombre y teléfono',
)
export const domicilioFiscalSchema = z.object({
calle: textoOpcional,
numExt: textoOpcional,
numInt: textoOpcional,
colonia: textoOpcional,
municipio: textoOpcional,
estado: textoOpcional,
cp: cpOpcional,
pais: textoOpcional,
})
export const viviendaCreditoSchema = z
.object({
proyecto: z.string().min(1, 'Selecciona el proyecto'),
proyectoOtro: textoOpcional,
modelo: textoOpcional,
manzana: textoOpcional,
lote: textoOpcional,
ubicacion: textoOpcional,
superficieM2: textoOpcional,
valorVenta: textoOpcional,
enganche: textoOpcional,
equipamientoAdicional: textoOpcional,
fechaPagoEnganche: textoOpcional,
firmaContrato: textoOpcional,
tiposCredito: z.array(
z.enum([
'INFONAVIT',
'APOYO_INFONAVIT',
'COFINAVIT',
'SEGUNDO_CREDITO',
'CONTADO',
'FOVISSSTE',
'COFI_FOVISSSTE',
'BANCARIO',
]),
),
montoCreditoInfoFovi: textoOpcional,
ahorroVoluntarioSubcuenta: textoOpcional,
tipoProducto: z.enum(['PESOS_FIJOS', 'PESOS_CRECIENTES', 'VSM', '']),
descuentoMensualInfoFovissste: textoOpcional,
idAutoridadEmisora: textoOpcional,
idNumero: textoOpcional,
idVigencia: textoOpcional,
domicilioFiscal: domicilioFiscalSchema,
})
.superRefine((datos, ctx) => {
if (datos.proyecto === 'OTRO' && datos.proyectoOtro.trim() === '') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['proyectoOtro'],
message: 'Escribe el nombre del proyecto',
})
}
})
export const firmaSchema = z.object({
firmaDataUrl: z
.string()
.min(1, 'La firma del cliente es requerida')
.startsWith('data:image/', 'La firma debe ser una imagen'),
asesorNombre: z.string().trim().min(3, 'El nombre del asesor es requerido'),
})

View File

@@ -0,0 +1,14 @@
/**
* Genera un UUID v4. crypto.randomUUID solo existe en contextos seguros
* (HTTPS o localhost); al servir por IP en HTTP hay que usar el fallback.
*/
export function uuid(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
const v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}