Initial commit - Horux Despachos NL
This commit is contained in:
402
apps/api/src/services/constancia.service.ts
Normal file
402
apps/api/src/services/constancia.service.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
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<ConstanciaRow> {
|
||||
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 });
|
||||
try {
|
||||
const timeoutPromise = new Promise<never>((_, 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);
|
||||
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<string, string | undefined> {
|
||||
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<number[]> {
|
||||
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)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, string> = {};
|
||||
for (const [k, v] of Object.entries(fields)) {
|
||||
if (v && v.trim().length > 0) updates[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<ConstanciaRow[]> {
|
||||
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<Buffer | null> {
|
||||
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<ConstanciaRow> {
|
||||
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<never>((_, 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);
|
||||
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 */ }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user