Files
HoruxDespachosNuevo/apps/api/src/services/constancia.service.ts
Horux Dev 44d7c796c9 fix: facturapi onboarding, CSF scraper, SAT sync initial, doc notifications
- 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
2026-05-17 04:28:32 +00:00

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 */ }
}
}