import { chromium } from 'playwright'; import { writeFileSync, unlinkSync, mkdirSync, rmdirSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { randomUUID } from 'crypto'; import type { Pool } from 'pg'; import { prisma, tenantDb } from '../config/database.js'; import { getDecryptedFiel } from './fiel.service.js'; import { getDecryptedFielContribuyente } from './contribuyente-fiel.service.js'; import { loginSatCsf } from './sat/sat-csf-login.js'; import { extractCsfPdf } from './sat/sat-csf-scraper.js'; import { parseCsfPdf, type ConstanciaSituacionFiscal, type Domicilio, type RegimenCsf } from './sat/sat-csf-parser.js'; const PROCESS_TIMEOUT = 180_000; export interface ConstanciaRow { id: number; rfc: string; idCif: string | null; razonSocial: string | null; estatusPadron: string | null; fechaEmision: string | null; datos: ConstanciaSituacionFiscal; fechaConsulta: string; createdAt: string; } function rowToConstancia(r: any): ConstanciaRow { return { id: r.id, rfc: r.rfc, idCif: r.id_cif, razonSocial: r.razon_social, estatusPadron: r.estatus_padron, fechaEmision: r.fecha_emision, datos: r.datos, fechaConsulta: r.fecha_consulta.toISOString(), createdAt: r.created_at.toISOString(), }; } /** * Descarga la CSF del portal SAT, la parsea, guarda en BD del tenant, y * sincroniza automáticamente domicilio + regímenes activos con lo que reporta * el SAT. El auto-fill NO es destructivo para datos custom del usuario: * solo sobreescribe campos si la CSF tiene un valor no-vacío. */ export async function consultarConstancia(tenantId: string): Promise { const fiel = await getDecryptedFiel(tenantId); if (!fiel) throw new Error('No hay FIEL configurada o está vencida'); const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { databaseName: true }, }); if (!tenant) throw new Error('Tenant no encontrado'); const tempId = randomUUID(); const tempDir = join(tmpdir(), `horux-csf-${tempId}`); mkdirSync(tempDir, { recursive: true, mode: 0o700 }); const cerPath = join(tempDir, 'cert.cer'); const keyPath = join(tempDir, 'key.key'); try { writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 }); writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 }); // Headless por default. El fix de dispatchEvent en sat-csf-login cubre el // caso donde el click sintético no dispara el handler del SAT. Si algún // ambiente necesita ver el browser (debug), setear SAT_HEADLESS=false. const headless = process.env.SAT_HEADLESS !== 'false'; const browser = await chromium.launch({ headless, args: ['--disable-blink-features=AutomationControlled'], ignoreDefaultArgs: ['--enable-automation'], }); try { const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 3 minutos')), PROCESS_TIMEOUT), ); const resultPromise = (async () => { const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password, fiel.rfc); const pdfBuffer = await extractCsfPdf(session); const csf = await parseCsfPdf(pdfBuffer); const pool = await tenantDb.getPool(tenantId, tenant.databaseName); const { rows } = await pool.query( `INSERT INTO constancias_situacion_fiscal (rfc, id_cif, razon_social, estatus_padron, fecha_emision, datos, pdf) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, rfc, id_cif, razon_social, estatus_padron, fecha_emision, datos, fecha_consulta, created_at`, [ csf.rfc, csf.idCIF, csf.razonSocial ?? [csf.nombre, csf.primerApellido, csf.segundoApellido].filter(Boolean).join(' ') ?? null, csf.estatusPadron, csf.lugarFechaEmision, JSON.stringify(csf), pdfBuffer, ], ); // Auto-fill domicilio del tenant + regímenes activos desde el CSF. // Se hace después del INSERT para que si algo falla en la sincronización // la CSF ya quedó guardada y el usuario puede verla. await sincronizarDatosFiscales(tenantId, csf).catch(err => { console.error(`[CSF] Error sincronizando datos fiscales para tenant ${tenantId}:`, err); }); return rowToConstancia(rows[0]); })(); return await Promise.race([resultPromise, timeoutPromise]); } finally { await browser.close(); } } finally { try { unlinkSync(cerPath); } catch { /* ok */ } try { unlinkSync(keyPath); } catch { /* ok */ } try { rmdirSync(tempDir); } catch { /* ok */ } } } /** * Convierte el domicilio del CSF a los campos de `tenants` (calle compuesta * por tipoVialidad + nombreVialidad). Solo actualiza campos cuando el CSF * trae un valor — nunca pisa con null. */ function domicilioToTenantFields(d: Domicilio): Record { const calleComponents = [d.tipoVialidad, d.nombreVialidad].filter(Boolean); const calle = calleComponents.length > 0 ? calleComponents.join(' ') : undefined; return { codigoPostal: d.codigoPostal, calle, numExterior: d.numeroExterior, numInterior: d.numeroInterior && d.numeroInterior.toUpperCase() !== 'SIN NUMERO' ? d.numeroInterior : undefined, colonia: d.colonia, ciudad: d.localidad, municipio: d.municipio, estado: d.entidadFederativa, }; } /** * Matchea el nombre del régimen como aparece en la CSF contra el catálogo * `regimenes` (clave SAT + descripción). La CSF prefija "Régimen " o * "Régimen de " a veces, y el catálogo no — normalizamos ambos para matchear. */ function normalizeRegimenName(s: string): string { return s .normalize('NFD').replace(/[\u0300-\u036f]/g, '') .toLowerCase() .replace(/^r[eé]gimen\s+(?:de\s+(?:las?|los)?\s*)?/i, '') .replace(/\s+/g, ' ') .trim(); } async function matchRegimenesToCatalogo(regimenesCsf: RegimenCsf[]): Promise { const activos = regimenesCsf.filter(r => !r.fechaFin); if (activos.length === 0) return []; const catalogo = await prisma.regimen.findMany({ where: { activo: true } }); const ids: number[] = []; for (const rc of activos) { const nNormalizado = normalizeRegimenName(rc.nombre); const match = catalogo.find(c => { const catNorm = normalizeRegimenName(c.descripcion); return catNorm === nNormalizado || catNorm.includes(nNormalizado) || nNormalizado.includes(catNorm); }); if (match) ids.push(match.id); } return [...new Set(ids)]; } /** * Límites de longitud en el schema Prisma de Tenant (defensivo para * evitar P2000 cuando el SAT devuelve valores más largos de lo esperado). */ const TENANT_FIELD_LIMITS: Record = { codigoPostal: 5, calle: 255, numExterior: 20, numInterior: 20, colonia: 255, ciudad: 100, municipio: 100, estado: 100, telefono: 20, }; function truncateToLimit(key: string, value: string): string { const limit = TENANT_FIELD_LIMITS[key]; if (!limit || value.length <= limit) return value; return value.slice(0, limit); } /** * Aplica el domicilio + regímenes activos de la CSF al tenant. Idempotente: * se puede llamar N veces, el resultado final refleja el último CSF. */ export async function sincronizarDatosFiscales( tenantId: string, csf: ConstanciaSituacionFiscal, ): Promise<{ domicilioActualizado: boolean; regimenesSincronizados: number }> { // 1. Domicilio const fields = domicilioToTenantFields(csf.domicilio); const updates: Record = {}; for (const [k, v] of Object.entries(fields)) { if (v && v.trim().length > 0) { updates[k] = truncateToLimit(k, v.trim()); } } if (Object.keys(updates).length > 0) { await prisma.tenant.update({ where: { id: tenantId }, data: updates }); } // 2. Regímenes activos — sobreescribe la lista completa con lo que diga la CSF const regimenIds = await matchRegimenesToCatalogo(csf.regimenes); if (regimenIds.length > 0) { await prisma.$transaction([ prisma.tenantRegimenActivo.deleteMany({ where: { tenantId } }), prisma.tenantRegimenActivo.createMany({ data: regimenIds.map(regimenId => ({ tenantId, regimenId })) }), ]); } return { domicilioActualizado: Object.keys(updates).length > 0, regimenesSincronizados: regimenIds.length, }; } export async function listConstancias(pool: Pool, limit = 12, rfc?: string): Promise { const params: unknown[] = [limit]; let rfcFilter = ''; if (rfc) { rfcFilter = 'WHERE rfc = $2'; params.push(rfc); } const { rows } = await pool.query( `SELECT id, rfc, id_cif, razon_social, estatus_padron, fecha_emision, datos, fecha_consulta, created_at FROM constancias_situacion_fiscal ${rfcFilter} ORDER BY fecha_consulta DESC LIMIT $1`, params, ); return rows.map(rowToConstancia); } export async function getConstanciaPdf(pool: Pool, id: number): Promise { const { rows } = await pool.query(`SELECT pdf FROM constancias_situacion_fiscal WHERE id = $1`, [id]); return rows.length > 0 ? rows[0].pdf : null; } /** * Retención 5 años (CFF Art. 30). Se ejecuta en cron diario. */ export async function purgeConstanciasAntiguas(pool: Pool): Promise<{ deleted: number }> { const { rowCount } = await pool.query( `DELETE FROM constancias_situacion_fiscal WHERE created_at < NOW() - INTERVAL '5 years'`, ); return { deleted: rowCount ?? 0 }; } /** * Descarga la CSF para un contribuyente específico (modo despacho). * Usa la FIEL almacenada en la BD del tenant en lugar de la BD central. */ export async function consultarConstanciaContribuyente( pool: Pool, contribuyenteId: string, ): Promise { const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, ''); const fiel = await getDecryptedFielContribuyente(pool, safeId); if (!fiel) throw new Error('No hay FIEL configurada para este contribuyente o está vencida'); const tempId = randomUUID(); const tempDir = join(tmpdir(), `horux-csf-${tempId}`); mkdirSync(tempDir, { recursive: true, mode: 0o700 }); const cerPath = join(tempDir, 'cert.cer'); const keyPath = join(tempDir, 'key.key'); try { writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 }); writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 }); const headless = process.env.SAT_HEADLESS !== 'false'; const browser = await chromium.launch({ headless }); try { const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 3 minutos')), PROCESS_TIMEOUT), ); const resultPromise = (async () => { const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password, fiel.rfc); const pdfBuffer = await extractCsfPdf(session); const csf = await parseCsfPdf(pdfBuffer); const { rows } = await pool.query( `INSERT INTO constancias_situacion_fiscal (rfc, id_cif, razon_social, estatus_padron, fecha_emision, datos, pdf) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, rfc, id_cif, razon_social, estatus_padron, fecha_emision, datos, fecha_consulta, created_at`, [ csf.rfc, csf.idCIF, csf.razonSocial ?? [csf.nombre, csf.primerApellido, csf.segundoApellido].filter(Boolean).join(' ') ?? null, csf.estatusPadron, csf.lugarFechaEmision, JSON.stringify(csf), pdfBuffer, ], ); // Sync datos fiscales to contribuyente table try { const rawDom = csf.domicilio || {}; // The PDF parser sometimes captures label prefixes inside values // when the PDF has a two-column layout. Clean them out. function cleanDomField(val: string | undefined): string { if (!val) return ''; // Remove embedded label prefixes like "Nombre de la Colonia: " return val .replace(/^.*(?:Nombre de la Colonia|Nombre del Municipio|Nombre de la Localidad|Nombre de la Entidad|Número Exterior|Número Interior|Tipo de Vialidad|Entre Calle|Y Calle|Código Postal)\s*:\s*/i, '') .trim(); } // Extract embedded values from fields that swallowed the next column function extractEmbedded(val: string | undefined, labelPrefix: string): string { if (!val) return ''; const re = new RegExp(`${labelPrefix}\\s*:\\s*(.+)`, 'i'); const m = val.match(re); return m ? m[1].trim() : ''; } // Check if values have embedded labels and extract the correct fields const rawNumInterior = rawDom.numeroInterior || ''; const rawLocalidad = rawDom.localidad || ''; const colonia = rawDom.colonia || extractEmbedded(rawNumInterior, 'Nombre de la Colonia') || extractEmbedded(rawLocalidad, 'Nombre de la Colonia') || ''; const municipio = rawDom.municipio || extractEmbedded(rawLocalidad, 'Nombre del Municipio o Demarcación Territorial') || extractEmbedded(rawNumInterior, 'Nombre del Municipio') || ''; // Map CSF field names → UI field names const domicilioMapped = { codigoPostal: cleanDomField(rawDom.codigoPostal), calle: cleanDomField(rawDom.nombreVialidad) || '', numExterior: cleanDomField(rawDom.numeroExterior), numInterior: cleanDomField(rawDom.numeroInterior), colonia: cleanDomField(colonia), ciudad: cleanDomField(rawDom.localidad) || cleanDomField(rawDom.municipio) || '', municipio: cleanDomField(municipio), estado: cleanDomField(rawDom.entidadFederativa), entreCalle: cleanDomField(rawDom.entreCalle), yCalle: cleanDomField(rawDom.yCalle), }; // Resolve ALL regímenes (not just the first) let regimenClaves: string[] = []; if (csf.regimenes?.length) { const { prisma: centralPrisma } = await import('../config/database.js'); const allRegimenes = await centralPrisma.regimen.findMany({ where: { activo: true }, select: { clave: true, descripcion: true }, }); // Normalize for accent-insensitive comparison const norm = (s: string) => s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim(); for (const reg of csf.regimenes) { if (reg.fechaFin) continue; // Skip inactive regimenes const regNorm = norm(reg.nombre); // Score-based: prefer the match with the highest overlap let bestMatch: { clave: string; score: number } | null = null; for (const r of allRegimenes) { const catNorm = norm(r.descripcion); // Exact match or containment if (regNorm === catNorm || regNorm.includes(catNorm) || catNorm.includes(regNorm)) { const score = catNorm.length; // Longer match = more specific = better if (!bestMatch || score > bestMatch.score) { bestMatch = { clave: r.clave, score }; } } } if (bestMatch) regimenClaves.push(bestMatch.clave); } } await pool.query(` UPDATE contribuyentes SET regimen_fiscal = COALESCE($2, regimen_fiscal), codigo_postal = COALESCE($3, codigo_postal), domicilio = COALESCE($4, domicilio) WHERE entidad_id = $1 `, [ contribuyenteId, regimenClaves.length > 0 ? regimenClaves.join(',') : null, domicilioMapped.codigoPostal || null, JSON.stringify(domicilioMapped), ]); console.log(`[CSF] Datos fiscales sincronizados para contribuyente ${contribuyenteId}: regímenes=${regimenClaves.join(',')}, CP=${domicilioMapped.codigoPostal}`); } catch (syncErr: any) { console.error(`[CSF] Error sincronizando datos fiscales:`, syncErr.message); } return rowToConstancia(rows[0]); })(); return await Promise.race([resultPromise, timeoutPromise]); } finally { await browser.close(); } } finally { try { unlinkSync(cerPath); } catch { /* ok */ } try { unlinkSync(keyPath); } catch { /* ok */ } try { rmdirSync(tempDir); } catch { /* ok */ } } }