From 44d7c796c91c2765757a98d061bb164f294a95a3 Mon Sep 17 00:00:00 2001 From: Horux Dev Date: Sun, 17 May 2026 04:28:32 +0000 Subject: [PATCH] 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 --- .../src/controllers/documentos.controller.ts | 4 +- apps/api/src/jobs/sat-sync.job.ts | 56 ++++--- apps/api/src/services/constancia.service.ts | 4 +- .../contribuyente-facturapi.service.ts | 147 ++++++++++++------ apps/api/src/services/sat/sat-csf-login.ts | 31 +++- .../(dashboard)/configuracion/csd/page.tsx | 25 ++- apps/web/lib/api/constancias.ts | 6 +- apps/web/lib/hooks/use-constancias.ts | 5 +- ...-05-17-facturapi-csf-sync-notifications.md | 98 ++++++++++++ 9 files changed, 292 insertions(+), 84 deletions(-) create mode 100644 docs/sessions/2026-05-17-facturapi-csf-sync-notifications.md diff --git a/apps/api/src/controllers/documentos.controller.ts b/apps/api/src/controllers/documentos.controller.ts index 2fea881..0adb8c2 100644 --- a/apps/api/src/controllers/documentos.controller.ts +++ b/apps/api/src/controllers/documentos.controller.ts @@ -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', diff --git a/apps/api/src/jobs/sat-sync.job.ts b/apps/api/src/jobs/sat-sync.job.ts index 54c5629..bca9193 100644 --- a/apps/api/src/jobs/sat-sync.job.ts +++ b/apps/api/src/jobs/sat-sync.job.ts @@ -43,16 +43,19 @@ async function getTenantsWithFiel(): Promise { } /** - * 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 { - const completedSync = await prisma.satSyncJob.findFirst({ - where: { - tenantId, - type: 'initial', - status: 'completed', - }, - }); +async function needsInitialSync(tenantId: string, contribuyenteId?: string): Promise { + 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 { */ async function syncTenant(tenantId: string): Promise { 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 { // 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 { 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 { 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 { */ async function incrementalSyncTenant(tenantId: string): Promise { 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 { // 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 { 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`); diff --git a/apps/api/src/services/constancia.service.ts b/apps/api/src/services/constancia.service.ts index 58f4a39..6089cc3 100644 --- a/apps/api/src/services/constancia.service.ts +++ b/apps/api/src/services/constancia.service.ts @@ -80,7 +80,7 @@ export async function consultarConstancia(tenantId: string): Promise { - 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); diff --git a/apps/api/src/services/contribuyente-facturapi.service.ts b/apps/api/src/services/contribuyente-facturapi.service.ts index f9dda97..3fb9d3a 100644 --- a/apps/api/src/services/contribuyente-facturapi.service.ts +++ b/apps/api/src/services/contribuyente-facturapi.service.ts @@ -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 { 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 { + const { rows } = await pool.query( + `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 { + 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 { + 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); } diff --git a/apps/api/src/services/sat/sat-csf-login.ts b/apps/api/src/services/sat/sat-csf-login.ts index 9be08df..2ede79b 100644 --- a/apps/api/src/services/sat/sat-csf-login.ts +++ b/apps/api/src/services/sat/sat-csf-login.ts @@ -18,6 +18,7 @@ export async function loginSatCsf( cerPath: string, keyPath: string, password: string, + knownRfc?: string, ): Promise { 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 diff --git a/apps/web/app/(dashboard)/configuracion/csd/page.tsx b/apps/web/app/(dashboard)/configuracion/csd/page.tsx index dc6c6e7..8dd9b19 100644 --- a/apps/web/app/(dashboard)/configuracion/csd/page.tsx +++ b/apps/web/app/(dashboard)/configuracion/csd/page.tsx @@ -7,7 +7,7 @@ import { useTimbres } from '@/lib/hooks/use-facturacion'; import { apiClient } from '@/lib/api/client'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useContribuyenteStore } from '@/stores/contribuyente-store'; -import { Shield, Upload, Check, AlertCircle, Receipt, Palette, Image, Building2 } from 'lucide-react'; +import { Shield, Upload, Check, AlertCircle, Receipt, Palette, Image, Building2, FileText } from 'lucide-react'; function CustomizationSection() { const queryClient = useQueryClient(); @@ -333,6 +333,29 @@ export default function CsdConfigPage() { )} + {/* Carta Manifiesto */} + {selectedContribuyenteId && orgStatus?.configured && ( + + + + + Carta Manifiesto + + + Firma requerida por el SAT/RMF para emitir CFDI. Usa tu e.firma (FIEL). + + + +