- Auto-update fiscal data on org creation via updateOrgLegalOnCreate - Add Carta Manifiesto embedded iframe in CSD config page - Fix CSF scraper: 60s timeout + manual RFC fallback when SAT doesn't auto-populate - Fix contribuyenteId propagation in constancia frontend hooks/API - Fix needsInitialSync to check per-contribuyente, not just per-tenant - Fix documento notifications for global_admin using viewingTenantId - Extract CSF manually for Carlos Husberto Torres Romero - Trigger initial SAT sync for Carlos Husberto Torres Romero - Update org legal data in Facturapi for Carlos Husberto (tax_system 612 + address) Files changed: - apps/api/src/controllers/documentos.controller.ts - apps/api/src/jobs/sat-sync.job.ts - apps/api/src/services/constancia.service.ts - apps/api/src/services/contribuyente-facturapi.service.ts - apps/api/src/services/sat/sat-csf-login.ts - apps/web/app/(dashboard)/configuracion/csd/page.tsx - apps/web/lib/api/constancias.ts - apps/web/lib/hooks/use-constancias.ts - docs/sessions/2026-05-17-facturapi-csf-sync-notifications.md
431 lines
16 KiB
TypeScript
431 lines
16 KiB
TypeScript
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,
|
|
args: ['--disable-blink-features=AutomationControlled'],
|
|
ignoreDefaultArgs: ['--enable-automation'],
|
|
});
|
|
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, 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<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)];
|
|
}
|
|
|
|
/**
|
|
* 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<string, number> = {
|
|
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<string, string> = {};
|
|
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<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, 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 */ }
|
|
}
|
|
}
|