From e21ccd6860fdada4bafee7cfaa2368783ddb4505 Mon Sep 17 00:00:00 2001 From: Horux Dev Date: Mon, 11 May 2026 03:58:53 +0000 Subject: [PATCH] fix(sat,conciliacion): propagar contribuyenteId en sync SAT y campos faltantes en visor de conciliacion - sat-sync.job.ts: cron diario e incremental ahora iteran contribuyentes por tenant y pasan contribuyenteId a startSync(). Evita que CFDIs importados del SAT queden con contribuyente_id = NULL. - sat.service.ts: retryJob() ahora reintenta con job.contribuyenteId. - conciliacion.service.ts: agrega campos faltantes al SELECT de CFDIs: status, formaPago, serie, folio, usoCfdi, subtotal, descuento, moneda, tipoCambio, ivaTraslado, ivaRetencion, isrRetencion, fechaCertSat. Antes el visor mostraba 'CANCELADO' para todos los CFDIs (status era undefined) y faltaban datos de forma de pago, impuestos, serie/folio, etc. Refs: docs/CAMBIOS-2026-05-09.md secciones 6 y 7 --- apps/api/src/jobs/sat-sync.job.ts | 111 +++++++++++++---- apps/api/src/services/conciliacion.service.ts | 37 ++++++ apps/api/src/services/sat/sat.service.ts | 2 +- docs/CAMBIOS-2026-05-09.md | 112 ++++++++++++++++++ 4 files changed, 237 insertions(+), 25 deletions(-) diff --git a/apps/api/src/jobs/sat-sync.job.ts b/apps/api/src/jobs/sat-sync.job.ts index 441ec2b..54c5629 100644 --- a/apps/api/src/jobs/sat-sync.job.ts +++ b/apps/api/src/jobs/sat-sync.job.ts @@ -58,24 +58,56 @@ async function needsInitialSync(tenantId: string): Promise { } /** - * Ejecuta sincronización para un tenant + * Ejecuta sincronización para un tenant y sus contribuyentes */ async function syncTenant(tenantId: string): Promise { try { - // Verificar si hay sync activo - const status = await getSyncStatus(tenantId); - if (status.hasActiveSync) { - console.log(`[SAT Cron] Tenant ${tenantId} ya tiene sync activo, omitiendo`); - return; - } - // Determinar tipo de sync const needsInitial = await needsInitialSync(tenantId); const syncType = needsInitial ? 'initial' : 'daily'; - console.log(`[SAT Cron] Iniciando sync ${syncType} para tenant ${tenantId}`); - const jobId = await startSync(tenantId, syncType); - console.log(`[SAT Cron] Job ${jobId} iniciado para tenant ${tenantId}`); + // Obtener contribuyentes del tenant + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { databaseName: true }, + }); + + let contribuyenteIds: string[] = []; + if (tenant?.databaseName) { + const pool = await tenantDb.getPool(tenantId, tenant.databaseName); + const { rows } = await pool.query('SELECT entidad_id FROM contribuyentes'); + contribuyenteIds = rows.map((r: any) => r.entidad_id); + } + + // Si no hay contribuyentes, sincronizar a nivel tenant (legacy Horux 360) + if (contribuyenteIds.length === 0) { + const status = await getSyncStatus(tenantId); + if (status.hasActiveSync) { + console.log(`[SAT Cron] Tenant ${tenantId} ya tiene sync activo, omitiendo`); + return; + } + console.log(`[SAT Cron] Iniciando sync ${syncType} para tenant ${tenantId} (sin contribuyentes)`); + const jobId = await startSync(tenantId, syncType); + console.log(`[SAT Cron] Job ${jobId} iniciado para tenant ${tenantId}`); + return; + } + + // Sincronizar cada contribuyente + for (const contribuyenteId of contribuyenteIds) { + try { + const status = await getSyncStatus(tenantId, contribuyenteId); + if (status.hasActiveSync) { + console.log(`[SAT Cron] Tenant ${tenantId} contribuyente ${contribuyenteId} ya tiene sync activo, omitiendo`); + continue; + } + + 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}`); + } catch (error: any) { + console.error(`[SAT Cron] Error sincronizando tenant ${tenantId} contribuyente ${contribuyenteId}:`, error.message); + } + } } catch (error: any) { console.error(`[SAT Cron] Error sincronizando tenant ${tenantId}:`, error.message); } @@ -150,19 +182,11 @@ async function getTenantsConSatIncremental(): Promise { } /** - * Dispara una sincronización incremental (ventana de 6 horas) para un tenant. - * Si el tenant ya tiene un sync activo, omite para no solapar solicitudes al SAT. - * Si el tenant nunca ha hecho `initial`, omite: el incremental no debe actuar - * como primera descarga — la inicial requiere correrse aparte. + * Dispara una sincronización incremental (ventana de 6 horas) para un tenant + * y sus contribuyentes. */ async function incrementalSyncTenant(tenantId: string): Promise { try { - const status = await getSyncStatus(tenantId); - if (status.hasActiveSync) { - console.log(`[SAT Cron Inc] Tenant ${tenantId} con sync activo, omitiendo`); - return; - } - const completedInitial = await prisma.satSyncJob.findFirst({ where: { tenantId, type: 'initial', status: 'completed' }, }); @@ -171,9 +195,48 @@ async function incrementalSyncTenant(tenantId: string): Promise { return; } - console.log(`[SAT Cron Inc] Iniciando incremental para tenant ${tenantId}`); - const jobId = await startSync(tenantId, 'incremental'); - console.log(`[SAT Cron Inc] Job ${jobId} iniciado`); + // Obtener contribuyentes del tenant + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { databaseName: true }, + }); + + let contribuyenteIds: string[] = []; + if (tenant?.databaseName) { + const pool = await tenantDb.getPool(tenantId, tenant.databaseName); + const { rows } = await pool.query('SELECT entidad_id FROM contribuyentes'); + contribuyenteIds = rows.map((r: any) => r.entidad_id); + } + + // Si no hay contribuyentes, sincronizar a nivel tenant (legacy) + if (contribuyenteIds.length === 0) { + const status = await getSyncStatus(tenantId); + if (status.hasActiveSync) { + console.log(`[SAT Cron Inc] Tenant ${tenantId} con sync activo, omitiendo`); + return; + } + console.log(`[SAT Cron Inc] Iniciando incremental para tenant ${tenantId} (sin contribuyentes)`); + const jobId = await startSync(tenantId, 'incremental'); + console.log(`[SAT Cron Inc] Job ${jobId} iniciado`); + return; + } + + // Sincronizar cada contribuyente + for (const contribuyenteId of contribuyenteIds) { + try { + const status = await getSyncStatus(tenantId, contribuyenteId); + if (status.hasActiveSync) { + console.log(`[SAT Cron Inc] Tenant ${tenantId} contribuyente ${contribuyenteId} con sync activo, omitiendo`); + continue; + } + + console.log(`[SAT Cron Inc] Iniciando incremental para tenant ${tenantId} contribuyente ${contribuyenteId}`); + const jobId = await startSync(tenantId, 'incremental', undefined, undefined, contribuyenteId); + console.log(`[SAT Cron Inc] Job ${jobId} iniciado`); + } catch (error: any) { + console.error(`[SAT Cron Inc] Error para tenant ${tenantId} contribuyente ${contribuyenteId}:`, error.message); + } + } } catch (error: any) { console.error(`[SAT Cron Inc] Error para tenant ${tenantId}:`, error.message); } diff --git a/apps/api/src/services/conciliacion.service.ts b/apps/api/src/services/conciliacion.service.ts index 78360c3..f66c40d 100644 --- a/apps/api/src/services/conciliacion.service.ts +++ b/apps/api/src/services/conciliacion.service.ts @@ -6,6 +6,8 @@ export interface ConciliacionCfdi { id: number; uuid: string; type: string; + serie: string | null; + folio: string | null; fechaEmision: string; rfcEmisor: string; nombreEmisor: string; @@ -13,7 +15,19 @@ export interface ConciliacionCfdi { nombreReceptor: string; total: number; totalMxn: number; + subtotal: number; + descuento: number; + moneda: string; + tipoCambio: number; + tipoComprobante: string | null; metodoPago: string | null; + formaPago: string | null; + usoCfdi: string | null; + status: string | null; + fechaCertSat: string | null; + ivaTraslado: number; + ivaRetencion: number; + isrRetencion: number; conciliado: string | null; idConciliacion: number | null; conciliacion: { @@ -78,13 +92,23 @@ export async function getCfdisConConciliacion( const { rows } = await pool.query(` SELECT c.id, c.uuid, c.type, + c.serie, c.folio, c.fecha_emision as "fechaEmision", c.rfc_emisor as "rfcEmisor", c.nombre_emisor as "nombreEmisor", c.rfc_receptor as "rfcReceptor", c.nombre_receptor as "nombreReceptor", c.total, c.total_mxn as "totalMxn", + c.subtotal, c.descuento, + c.moneda, c.tipo_cambio as "tipoCambio", c.tipo_comprobante as "tipoComprobante", c.monto_pago_mxn as "montoPagoMxn", c.metodo_pago as "metodoPago", + c.forma_pago as "formaPago", + c.uso_cfdi as "usoCfdi", + c.status, + c.fecha_cert_sat as "fechaCertSat", + c.iva_traslado as "ivaTraslado", + c.iva_retencion as "ivaRetencion", + c.isr_retencion as "isrRetencion", c.conciliado, c.id_conciliacion as "idConciliacion", con.id as "conId", @@ -102,6 +126,8 @@ export async function getCfdisConConciliacion( id: r.id, uuid: r.uuid, type: r.type, + serie: r.serie, + folio: r.folio, fechaEmision: r.fechaEmision, rfcEmisor: r.rfcEmisor, nombreEmisor: r.nombreEmisor, @@ -109,6 +135,10 @@ export async function getCfdisConConciliacion( nombreReceptor: r.nombreReceptor, total: Number(r.total), totalMxn: Number(r.totalMxn), + subtotal: Number(r.subtotal || 0), + descuento: Number(r.descuento || 0), + moneda: r.moneda || 'MXN', + tipoCambio: Number(r.tipoCambio || 1), tipoComprobante: r.tipoComprobante, montoPagoMxn: Number(r.montoPagoMxn || 0), // P usa monto_pago_mxn, PPD conciliada no suma (evitar duplicar con su P), resto usa total_mxn @@ -116,6 +146,13 @@ export async function getCfdisConConciliacion( ? Number(r.montoPagoMxn || 0) : (r.metodoPago === 'PPD' && r.conciliado === 'true') ? 0 : Number(r.totalMxn || 0), metodoPago: r.metodoPago, + formaPago: r.formaPago, + usoCfdi: r.usoCfdi, + status: r.status, + fechaCertSat: r.fechaCertSat, + ivaTraslado: Number(r.ivaTraslado || 0), + ivaRetencion: Number(r.ivaRetencion || 0), + isrRetencion: Number(r.isrRetencion || 0), conciliado: r.conciliado, idConciliacion: r.idConciliacion, conciliacion: r.conId ? { diff --git a/apps/api/src/services/sat/sat.service.ts b/apps/api/src/services/sat/sat.service.ts index 21afaaf..8a84a02 100644 --- a/apps/api/src/services/sat/sat.service.ts +++ b/apps/api/src/services/sat/sat.service.ts @@ -1511,5 +1511,5 @@ export async function retryJob(jobId: string): Promise { throw new Error('Solo se pueden reintentar jobs fallidos'); } - return startSync(job.tenantId, job.type, job.dateFrom, job.dateTo); + return startSync(job.tenantId, job.type, job.dateFrom, job.dateTo, job.contribuyenteId ?? undefined); } diff --git a/docs/CAMBIOS-2026-05-09.md b/docs/CAMBIOS-2026-05-09.md index beb5731..17133c9 100644 --- a/docs/CAMBIOS-2026-05-09.md +++ b/docs/CAMBIOS-2026-05-09.md @@ -142,6 +142,33 @@ WHERE contribuyente_id = '96f98a42-5f27-4f27-acf6-61822dea666c'; --- +## 6. Fix: CFDIs sin `contribuyente_id` en sincronizaciones SAT + +**Problema:** Todos los CFDIs importados por SAT sync tenían `contribuyente_id = NULL`, aunque la columna ya existía. Esto causaba que no aparecieran facturas para conciliar ni en otros módulos que filtran por contribuyente. + +**Causa raíz:** El cron job `sat-sync.job.ts` llamaba a `startSync(tenantId, syncType)` **sin pasar `contribuyenteId`**. Los jobs se creaban con `contribuyenteId = null`, y `saveCfdis()` insertaba los CFDIs con `contribuyente_id = null`. + +**Fix aplicado:** + +1. **`syncTenant()` (cron diario 3 AM)** — Ahora obtiene los contribuyentes del tenant desde su BD y ejecuta `startSync()` para cada uno pasando `contribuyenteId`. Si no hay contribuyentes, sincroniza a nivel tenant (legacy). + +2. **`incrementalSyncTenant()` (cron incremental 11h/15h/19h)** — Mismo fix. + +3. **`retryJob()` (reintento manual)** — Ahora pasa `job.contribuyenteId` al reintentar. + +4. **Backfill de datos** — Se actualizaron los `contribuyente_id` de los CFDIs existentes para todos los tenants: + - Alexa Torres: 383 CFDIs + - Horux 360: 67 CFDIs + - Miguel Estrada: 84,429 CFDIs + - Aarón Ahumada: 2,290 CFDIs + - Humberto Torres: 33 CFDIs + +**Archivos:** +- `apps/api/src/jobs/sat-sync.job.ts` +- `apps/api/src/services/sat/sat.service.ts` + +--- + ## Archivos modificados ### Backend (`apps/api/`) @@ -187,8 +214,93 @@ WHERE contribuyente_id = '96f98a42-5f27-4f27-acf6-61822dea666c'; --- +## 7. Fix: Visor de CFDI en conciliación mostraba todo como "Cancelado" y faltaban datos + +**Problema:** Al abrir cualquier CFDI desde el módulo de conciliación, el visor mostraba: +- Estatus: **CANCELADO** (aunque el CFDI estuviera vigente) +- Forma de pago: **-** (vacío) +- Serie/Folio: **S/N** +- Uso CFDI: no aparecía +- Totales desglosados (subtotal, descuento, impuestos): todos en 0 + +**Causa raíz:** El servicio `conciliacion.service.ts` solo seleccionaba un subconjunto mínimo de campos de la tabla `cfdis`. No incluía `status`, `forma_pago`, `serie`, `folio`, `uso_cfdi`, `subtotal`, `descuento`, `iva_traslado`, `iva_retencion`, `isr_retencion`, `moneda`, `tipo_cambio`, ni `fecha_cert_sat`. + +Como `status` llegaba `undefined` al componente `CfdiInvoice`, la condición: +```tsx +cfdi.status === 'Vigente' || cfdi.status === '1' ? 'VIGENTE' : 'CANCELADO' +``` +Siempre caía en el `else` mostrando CANCELADO. + +**Fix aplicado en `apps/api/src/services/conciliacion.service.ts`:** +- Agregados todos los campos faltantes al `SELECT` SQL +- Agregados a la interfaz `ConciliacionCfdi` +- Agregados al mapeo de resultados con valores por defecto seguros (`|| 0`, `|| 'MXN'`, `|| 1`) + +**Campos agregados:** +| Campo | Uso en visor | +|---|---| +| `status` | Badge VIGENTE / CANCELADO | +| `formaPago` | Datos del comprobante | +| `serie`, `folio` | Encabezado (serie-folio) | +| `usoCfdi` | Panel del receptor | +| `subtotal`, `descuento` | Totales | +| `ivaTraslado`, `ivaRetencion`, `isrRetencion` | Desglose de impuestos | +| `moneda`, `tipoCambio` | Moneda y tipo de cambio | +| `fechaCertSat` | Timbre fiscal digital | + +--- + +## Archivos modificados (actualizado) + +### Backend (`apps/api/`) +| Archivo | Cambio | +|---|---| +| `.env` | Fix typo `MP_ACCESS_TOKEN` | +| `src/services/payment/mercadopago.service.ts` | Fix validación firma webhook | +| `src/services/payment/invoicing.service.ts` | Notificación email + exports | +| `src/services/email/email.service.ts` | `sendPrimerPagoFacturar()` | +| `src/services/email/templates/primer-pago-facturar.ts` | **Nuevo** template | +| `src/controllers/facturacion.controller.ts` | `getPagosSinFactura()` + `emitirFacturaPago()` + fix `searchRfcs()` + fix `searchConceptos()` | +| `src/routes/facturacion.routes.ts` | Rutas `/pagos-sin-factura` + `/emitir-factura-pago/:paymentId` | +| `src/jobs/sat-sync.job.ts` | Fix: pasa `contribuyenteId` en cron diario e incremental | +| `src/services/sat/sat.service.ts` | Fix: `retryJob()` pasa `contribuyenteId` + `saveCfdis()` usa `contribuyente_id` | +| `src/services/conciliacion.service.ts` | Fix: agrega campos faltantes (`status`, `formaPago`, impuestos, etc.) | + +### Frontend (`apps/web/`) +| Archivo | Cambio | +|---|---| +| `lib/api/facturacion.ts` | `getPagosSinFactura()` + `emitirFacturaPago()` | +| `lib/hooks/use-pagos-sin-factura.ts` | **Nuevo** hooks | +| `app/(dashboard)/admin/facturas-pendientes/page.tsx` | **Nuevo** página admin | +| `app/(dashboard)/clientes/page.tsx` | KPI "Facturas pendientes" | +| `components/layouts/sidebar.tsx` | Removido "Facturas Pendientes" del menú admin | +| `components/layouts/sidebar-floating.tsx` | Removido "Facturas Pendientes" del menú admin | +| `components/layouts/sidebar-compact.tsx` | Removido "Facturas Pendientes" del menú admin | +| `components/layouts/topnav.tsx` | Removido "Facturas Pendientes" del menú admin | + +--- + +## Configuración requerida en MercadoPago Dashboard + +- **Aplicación:** Horux360 (ID: `5319386258998241`) +- **Webhook URL:** `https://horuxfin.com/api/webhooks/mercadopago` +- **Tópicos:** `payment`, `subscription_preapproval` + +--- + +## Datos de organizaciones Facturapi + +| Org | RFC | Uso | +|---|---|---| +| `69f23a5a242e0af47a41fa0d` | HTS240708LJA | Horux 360 (emisor principal) — ✅ Activa | +| `69ff900f48058f06ef1234c0` | — | Org fantasma (eliminada de BD) — ❌ Obsoleta | +| `69ff8fabc2053c5568d799c5` | XIA190128J61 | Org creada accidentalmente durante diagnóstico — ❌ Obsoleta | + +--- + ## Notas técnicas - La encriptación de API keys usa AES-256-GCM con clave derivada de `FIEL_ENCRYPTION_KEY` (SHA-256) - El endpoint `POST /emitir-factura-pago/:paymentId` requiere rol `platform_admin` - La regla "primer pago no se factura automáticamente" sigue vigente; los subsecuentes sí son automáticos +- Los CFDIs importados por SAT sync ahora se asocian correctamente al `contribuyente_id` correspondiente