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
This commit is contained in:
Horux Dev
2026-05-17 04:28:32 +00:00
parent 1c92b8eaf1
commit 44d7c796c9
9 changed files with 292 additions and 84 deletions

View File

@@ -122,7 +122,7 @@ export async function crearDeclaracion(req: Request, res: Response, next: NextFu
// No bloquea la respuesta ni falla la creación si SMTP no está configurado.
notifyDocumentoSubido({
pool: req.tenantPool!,
tenantId: req.user!.tenantId,
tenantId: req.viewingTenantId ?? req.user!.tenantId,
contribuyenteId: contribuyenteId ?? null,
subidoPor: req.user!.email,
kind: 'declaracion',
@@ -283,7 +283,7 @@ export async function crearExtra(req: Request, res: Response, next: NextFunction
// Notificación fire-and-forget a owners del despacho + supervisor del RFC.
notifyDocumentoSubido({
pool: req.tenantPool!,
tenantId: req.user!.tenantId,
tenantId: req.viewingTenantId ?? req.user!.tenantId,
contribuyenteId: contribuyenteId ?? null,
subidoPor: req.user!.email,
kind: 'extra',

View File

@@ -43,16 +43,19 @@ async function getTenantsWithFiel(): Promise<string[]> {
}
/**
* Verifica si un tenant necesita sincronización inicial
* Verifica si un tenant (o un contribuyente específico dentro del tenant)
* necesita sincronización inicial.
*/
async function needsInitialSync(tenantId: string): Promise<boolean> {
const completedSync = await prisma.satSyncJob.findFirst({
where: {
tenantId,
type: 'initial',
status: 'completed',
},
});
async function needsInitialSync(tenantId: string, contribuyenteId?: string): Promise<boolean> {
const where: any = {
tenantId,
type: 'initial',
status: 'completed',
};
if (contribuyenteId) {
where.contribuyenteId = contribuyenteId;
}
const completedSync = await prisma.satSyncJob.findFirst({ where });
return !completedSync;
}
@@ -62,10 +65,6 @@ async function needsInitialSync(tenantId: string): Promise<boolean> {
*/
async function syncTenant(tenantId: string): Promise<void> {
try {
// Determinar tipo de sync
const needsInitial = await needsInitialSync(tenantId);
const syncType = needsInitial ? 'initial' : 'daily';
// Obtener contribuyentes del tenant
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
@@ -81,6 +80,8 @@ async function syncTenant(tenantId: string): Promise<void> {
// Si no hay contribuyentes, sincronizar a nivel tenant (legacy Horux 360)
if (contribuyenteIds.length === 0) {
const needsInitial = await needsInitialSync(tenantId);
const syncType = needsInitial ? 'initial' : 'daily';
const status = await getSyncStatus(tenantId);
if (status.hasActiveSync) {
console.log(`[SAT Cron] Tenant ${tenantId} ya tiene sync activo, omitiendo`);
@@ -92,7 +93,7 @@ async function syncTenant(tenantId: string): Promise<void> {
return;
}
// Sincronizar cada contribuyente
// Sincronizar cada contribuyente (cada uno puede necesitar su propio initial)
for (const contribuyenteId of contribuyenteIds) {
try {
const status = await getSyncStatus(tenantId, contribuyenteId);
@@ -101,6 +102,8 @@ async function syncTenant(tenantId: string): Promise<void> {
continue;
}
const needsInitial = await needsInitialSync(tenantId, contribuyenteId);
const syncType = needsInitial ? 'initial' : 'daily';
console.log(`[SAT Cron] Iniciando sync ${syncType} para tenant ${tenantId} contribuyente ${contribuyenteId}`);
const jobId = await startSync(tenantId, syncType, undefined, undefined, contribuyenteId);
console.log(`[SAT Cron] Job ${jobId} iniciado para tenant ${tenantId} contribuyente ${contribuyenteId}`);
@@ -187,14 +190,6 @@ async function getTenantsConSatIncremental(): Promise<string[]> {
*/
async function incrementalSyncTenant(tenantId: string): Promise<void> {
try {
const completedInitial = await prisma.satSyncJob.findFirst({
where: { tenantId, type: 'initial', status: 'completed' },
});
if (!completedInitial) {
console.log(`[SAT Cron Inc] Tenant ${tenantId} sin sync inicial completado, omitiendo incremental`);
return;
}
// Obtener contribuyentes del tenant
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
@@ -210,6 +205,13 @@ async function incrementalSyncTenant(tenantId: string): Promise<void> {
// Si no hay contribuyentes, sincronizar a nivel tenant (legacy)
if (contribuyenteIds.length === 0) {
const completedInitial = await prisma.satSyncJob.findFirst({
where: { tenantId, type: 'initial', status: 'completed' },
});
if (!completedInitial) {
console.log(`[SAT Cron Inc] Tenant ${tenantId} sin sync inicial completado, omitiendo incremental`);
return;
}
const status = await getSyncStatus(tenantId);
if (status.hasActiveSync) {
console.log(`[SAT Cron Inc] Tenant ${tenantId} con sync activo, omitiendo`);
@@ -221,9 +223,17 @@ async function incrementalSyncTenant(tenantId: string): Promise<void> {
return;
}
// Sincronizar cada contribuyente
// Sincronizar cada contribuyente solo si ya tiene su initial completado
for (const contribuyenteId of contribuyenteIds) {
try {
const hasInitial = await prisma.satSyncJob.findFirst({
where: { tenantId, contribuyenteId, type: 'initial', status: 'completed' },
});
if (!hasInitial) {
console.log(`[SAT Cron Inc] Tenant ${tenantId} contribuyente ${contribuyenteId} sin sync inicial, omitiendo incremental`);
continue;
}
const status = await getSyncStatus(tenantId, contribuyenteId);
if (status.hasActiveSync) {
console.log(`[SAT Cron Inc] Tenant ${tenantId} contribuyente ${contribuyenteId} con sync activo, omitiendo`);

View File

@@ -80,7 +80,7 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
);
const resultPromise = (async () => {
const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password);
const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password, fiel.rfc);
const pdfBuffer = await extractCsfPdf(session);
const csf = await parseCsfPdf(pdfBuffer);
@@ -297,7 +297,7 @@ export async function consultarConstanciaContribuyente(
);
const resultPromise = (async () => {
const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password);
const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password, fiel.rfc);
const pdfBuffer = await extractCsfPdf(session);
const csf = await parseCsfPdf(pdfBuffer);

View File

@@ -92,6 +92,8 @@ export async function createOrgContribuyente(
// Idempotente: si existe en ambos lados, asegurar que la live key está
// cacheada (puede faltar en orgs legacy creadas antes del refactor live).
await ensureLiveKeyCached(pool, existingId);
// Backfill: si la org fue creada antes de este fix, sincronizar datos fiscales.
await updateOrgLegalOnCreate(pool, existingId, contribuyenteId);
return { orgId: existingId, reused: true };
} catch {
const org = await client.organizations.create({ name: nombre });
@@ -101,6 +103,7 @@ export async function createOrgContribuyente(
);
// Eager: generar y cachear live key para que la org quede lista para emitir.
await ensureLiveKeyCached(pool, org.id);
await updateOrgLegalOnCreate(pool, org.id, contribuyenteId);
return { orgId: org.id, recreated: true };
}
}
@@ -113,6 +116,7 @@ export async function createOrgContribuyente(
);
// Eager: generar y cachear live key inmediatamente tras crear la org.
await ensureLiveKeyCached(pool, org.id);
await updateOrgLegalOnCreate(pool, org.id, contribuyenteId);
return { orgId: org.id };
}
@@ -132,6 +136,98 @@ async function ensureLiveKeyCached(pool: Pool, orgId: string): Promise<void> {
await persistEncryptedKey(pool, orgId, apiKey);
}
interface ContribuyenteFiscalData {
rfc: string;
razon_social: string | null;
regimen_fiscal: string | null;
codigo_postal: string | null;
domicilio: any;
}
async function fetchContribuyenteFiscalData(
pool: Pool,
contribuyenteId: string,
): Promise<ContribuyenteFiscalData> {
const { rows } = await pool.query<ContribuyenteFiscalData>(
`SELECT c.rfc, r.razon_social, c.regimen_fiscal, c.codigo_postal, c.domicilio
FROM contribuyentes c
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
WHERE c.entidad_id = $1`,
[contribuyenteId],
);
if (rows.length === 0) throw new Error('Contribuyente no encontrado');
return rows[0];
}
async function buildLegalPayload(
contrib: ContribuyenteFiscalData,
chosenTaxSystem: string,
currentLegal?: any,
) {
const domicilio = (contrib.domicilio || {}) as any;
return {
name: contrib.razon_social || currentLegal?.name || '',
legal_name: contrib.razon_social || currentLegal?.legal_name || '',
tax_system: chosenTaxSystem,
address: {
street: domicilio.calle || currentLegal?.address?.street || '',
exterior: domicilio.numExterior || currentLegal?.address?.exterior || '',
interior: domicilio.numInterior || currentLegal?.address?.interior || '',
neighborhood: domicilio.colonia || currentLegal?.address?.neighborhood || '',
city: domicilio.ciudad || currentLegal?.address?.city || '',
municipality: domicilio.municipio || currentLegal?.address?.municipality || '',
state: domicilio.estado || currentLegal?.address?.state || '',
zip: contrib.codigo_postal || domicilio.codigoPostal || currentLegal?.address?.zip || '',
},
};
}
async function putOrgLegal(orgId: string, payload: any): Promise<void> {
const userKey = env.FACTURAPI_USER_KEY;
if (!userKey) throw new Error('FACTURAPI_USER_KEY no configurada');
const putRes = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/legal`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${userKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!putRes.ok) {
const errText = await putRes.text();
throw new Error(
`Error actualizando datos fiscales de la organización Facturapi (${putRes.status}): ${errText}`,
);
}
}
/**
* Actualiza los datos fiscales de una organización Facturapi recién creada
* usando la información del contribuyente. Se usa el primer régimen fiscal
* registrado. No-op si no hay régimen fiscal o razón social que setear.
*/
async function updateOrgLegalOnCreate(
pool: Pool,
orgId: string,
contribuyenteId: string,
): Promise<void> {
try {
const contrib = await fetchContribuyenteFiscalData(pool, contribuyenteId);
const allowed = (contrib.regimen_fiscal || '')
.split(',')
.map(s => s.trim())
.filter(Boolean);
if (!allowed.length || !contrib.razon_social) {
// Datos incompletos: no fallar la creación de la org, solo loguear silenciosamente.
return;
}
const payload = await buildLegalPayload(contrib, allowed[0]);
await putOrgLegal(orgId, payload);
} catch {
// No bloquear la creación de la org si el update legal falla.
}
}
export async function getOrgStatusContribuyente(
pool: Pool,
contribuyenteId: string
@@ -409,22 +505,7 @@ async function ensureOrgLegalForEmit(
const userKey = env.FACTURAPI_USER_KEY;
if (!userKey) throw new Error('FACTURAPI_USER_KEY no configurada');
// Datos fiscales del contribuyente (razón social + domicilio)
const { rows } = await pool.query<{
rfc: string;
razon_social: string | null;
regimen_fiscal: string | null;
codigo_postal: string | null;
domicilio: any;
}>(
`SELECT c.rfc, r.razon_social, c.regimen_fiscal, c.codigo_postal, c.domicilio
FROM contribuyentes c
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
WHERE c.entidad_id = $1`,
[contribuyenteId],
);
if (rows.length === 0) throw new Error('Contribuyente no encontrado');
const contrib = rows[0];
const contrib = await fetchContribuyenteFiscalData(pool, contribuyenteId);
// Validar que el régimen elegido esté entre los registrados del contrib
const allowed = (contrib.regimen_fiscal || '')
@@ -458,36 +539,6 @@ async function ensureOrgLegalForEmit(
return;
}
const domicilio = (contrib.domicilio || {}) as any;
const legalPayload = {
name: contrib.razon_social || currentLegal.name || '',
legal_name: contrib.razon_social || currentLegal.legal_name || '',
tax_system: chosenTaxSystem,
address: {
street: domicilio.calle || currentLegal.address?.street || '',
exterior: domicilio.numExterior || currentLegal.address?.exterior || '',
interior: domicilio.numInterior || currentLegal.address?.interior || '',
neighborhood: domicilio.colonia || currentLegal.address?.neighborhood || '',
city: domicilio.ciudad || currentLegal.address?.city || '',
municipality: domicilio.municipio || currentLegal.address?.municipality || '',
state: domicilio.estado || currentLegal.address?.state || '',
zip: contrib.codigo_postal || domicilio.codigoPostal || currentLegal.address?.zip || '',
},
};
const putRes = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/legal`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${userKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(legalPayload),
});
if (!putRes.ok) {
const errText = await putRes.text();
throw new Error(
`Error actualizando datos fiscales de la organización Facturapi (${putRes.status}): ${errText}`,
);
}
const payload = await buildLegalPayload(contrib, chosenTaxSystem, currentLegal);
await putOrgLegal(orgId, payload);
}

View File

@@ -18,6 +18,7 @@ export async function loginSatCsf(
cerPath: string,
keyPath: string,
password: string,
knownRfc?: string,
): Promise<CsfLoginSession> {
const context = await browser.newContext({
acceptDownloads: true,
@@ -73,6 +74,7 @@ export async function loginSatCsf(
await fileInputs.nth(1).setInputFiles(keyPath);
// Esperar a que el cert async parsing termine (RFC auto-populado por SAT).
let rfcPopulated = false;
try {
await loginPage.waitForFunction(
() => {
@@ -80,15 +82,36 @@ export async function loginSatCsf(
return rfc !== null && rfc.value.length >= 12;
},
null,
{ timeout: 30_000 },
{ timeout: 60_000 },
);
} catch (err) {
rfcPopulated = true;
} catch {
// Fallback: si tenemos el RFC conocido, intentar llenarlo manualmente
// (el SAT a veces no auto-popula en tiempo esperado pero acepta el envío igual).
if (knownRfc && knownRfc.length >= 12) {
try {
const rfcInput = loginPage.locator('#rfc').first();
await rfcInput.evaluate((el: HTMLElement, val: string) => {
(el as HTMLInputElement).disabled = false;
(el as HTMLInputElement).value = val;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}, knownRfc);
await loginPage.waitForTimeout(500);
rfcPopulated = true;
} catch {
// Manual fill failed, will debug-dump below
}
}
}
if (!rfcPopulated) {
const html = await loginPage.content();
const { writeFileSync, mkdirSync } = await import('node:fs');
const debugDir = '/tmp/horux-csf-debug';
try { mkdirSync(debugDir, { recursive: true }); } catch { /* ok */ }
writeFileSync(`${debugDir}/04c-rfc-timeout-html.html`, html);
throw err;
writeFileSync(`${debugDir}/04c-rfc-timeout-html-${Date.now()}.html`, html);
throw new Error('El SAT no auto-populó el RFC tras subir el certificado y no se pudo llenar manualmente');
}
// Password + Enviar