# Horux_despachos vs Horux 360 — Cambios de lógica a portar Guía para retroalimentar a Horux 360 los cambios de lógica hechos en el fork `Horux_despacho`. **Omite** todo lo relativo a `contribuyente_id` y multi-contribuyente (exclusivo del fork). Solo incluye cambios que aplican al modelo single-tenant de Horux 360. Cada sección lista: el problema original, la solución, y los archivos tocados (rutas relativas al repo). --- ## Changelog 2026-04-23 al 2026-04-26 Consolidado de cambios de ambos días. Detalle en cada §. ### CFDI / Schema - **§12** — Storage de `cfdi_tipo_relacion` + `cfdis_relacionados` (CFDI 4.0). Migración 032, parser, hooks en SAT sync + upload manual. Backfill idempotente desde `xml_original` aplicado (1,168 CFDIs actualizados en 2 tenants). ### Saldos - **§13** — Saldo real en CxP/CxC usando `saldo_pendiente_mxn` denormalizado: fórmula centralizada en `utils/saldo.ts` (pagos P + NC no-07 + anticipo aplicado), hooks post-insert en SAT sync + manual upload, backfill one-shot. Delta inicial: -$11.7M "recuperado" (saldo que reportaba como pendiente pero ya tenía pagos/NC/anticipos aplicados). ### I/07 (aplicación de anticipo) — Tratamiento final - **§13b** — Evolución iterativa durante el día: 1. Exclusión binaria en ingresos G1 + gastos + adquisiciones. 2. Cambio a compensación con `NETO_CUSTOM` (total − traslados + retenciones) contra el anticipo relacionado. 3. Clamp `GREATEST(0, ...)` para evitar contribuciones negativas cuando el anticipo está en periodo anterior (caso Husberto Jul 2025: una I/07 de $4,521 con anticipo de marzo 2024 de $398k restaba del mes). - **§19** — Pivote final a **Método A ingenuo** (suma I/07 completas + resta E/07 completas). La compensación + clamp falla en escenarios N:1 (un anticipo referenciado por múltiples I/07 produce sub-cuenta porque cada I/07 resta el anticipo completo). Aplicado a buckets económicos (ingresos G1, gastos, adquisiciones G01). IVA causado/acreditable y saldos CxP/CxC mantienen la lógica de compensación/exclusión por semánticas distintas. ### Otros cambios fiscales del 2026-04-24 - **§20** — Clamp defensivo del IVA en complementos P (`LEAST(iva_traslado_pago_mxn, monto_pago_mxn × 0.16)`). Protege contra XMLs malformados donde el proveedor reporta el IVA de la factura completa en vez del proporcional al pago parcial. - **§21** — Helper `determinarFormulaBaseGravable(clave, rfcLength)` para unificar la fórmula de base gravable entre el KPI ISR del periodo y el histórico mensual. Resuelve un bug donde RESICO PM mostraba `base = ingresos` en el histórico. - **§22** — Facturapi save post-emit usa `parseXml` del SAT parser sobre el XML descargado de Facturapi (en vez de leer del response del SDK que no trae los campos requeridos). ### Dashboard / Drill-down - **§14, §15** — Documentación de flujos previos (CSF → obligaciones, declaraciones ↔ alertas vía FK `declaracion_id` en `obligacion_periodos`). - **§16** — Drill-down respeta régimen + `TODOS_REGIMENES` + filtros E/07 (alineado con KPIs). Exporta `GRUPO_PF_EMPRESARIAL`, `GRUPO_SUELDOS`, `GRUPO_PM_OTROS` como exports públicos. - **§17** — Cache de métricas: `DELETE FROM metricas_mensuales WHERE (contrib, año, mes)` ANTES de las queries `calcular*` para que el read-through caiga al on-the-fly. La versión "DELETE antes de upsert" (primera iteración) fue insuficiente — valores stale se propagaban indefinidamente. ### Fix zona horaria en parser SAT (§18) - Bug: `new Date(comprobante['@_Fecha'])` interpretaba la hora según TZ del proceso; en CDMX (UTC-6) corría fecha 6h al UTC, sacando CFDIs de fin de mes/año de su periodo correcto. - Helper `parseCfdiDate()` en `sat-parser.service.ts` fuerza 'Z' cuando falta TZ indicator para preservar hora literal del XML. - **Backfill**: re-parseó `fecha_emision` + `fecha_cert_sat` desde `xml_original` para 10,658 CFDIs (todos estaban desfasados). - Validación: ingresos 2025 Carlos pasó de $335,905 → $554,905 (+$219k). ### Cambios fork-específicos (no en este doc) Los cambios que aplican solo al fork `Horux_despacho` están en docs separados: - `docs/plans/2026-04-24-session-fixes-and-features.md`: Facturapi multi-contribuyente, filtro inclusivo RFC en dashboard, refactor RFC como fuente de verdad, alerta TipoRelacion sospechoso, Método A en buckets económicos, clamp IVA P, helper base gravable. - `docs/plans/2026-04-25-despacho-tareas-papeleria.md`: módulo "Despacho" (3 páginas con métricas y filtro de periodo), sistema de Tareas operativas recurrentes, Papelería de Trabajo, banner CSD recién tramitado, preferencias de notificación por contribuyente, asignación de supervisor desde `/usuarios`. - `docs/plans/2026-04-26-i07-ppd-compensacion.md`: compensación I/07 PPD ↔ E mismo mes en gastos y Grupo 1 ingresos. Resuelve un bug donde una E (07 o 01) que referenciaba una I/07 PPD producía un gasto/ingreso negativo erróneo en el periodo (la I/07 PPD no entraba al bucket por filtro PUE). 23 casos detectados solo en Husberto agosto-2025. Algunos features del 25-abr son **portables conceptualmente** a Horux 360 cambiando `contribuyente_id` → `tenant_id`: - **Tareas operativas**: el sistema entero es reutilizable; basta con hacer las tareas per-tenant en lugar de per-contribuyente. Útil incluso para el flujo single-tenant (ej. checklist mensual del contador del cliente final). - **Papelería de Trabajo**: idem. - **Selector de periodo global** (zustand store + wrapper sobre `` de shared-ui): portable 1:1. - **Banner CSD recién tramitado**: requiere columna `facturapi_orgs.last_lco_rejection_at` o equivalente en `tenants` para Horux 360. ### Scripts nuevos - `apps/api/scripts/backfill-cfdis-relaciones.ts` — §12 (idempotente + dry-run). - `apps/api/scripts/backfill-saldo-pendiente.ts` — §13 (idempotente + dry-run). - `apps/api/scripts/backfill-fechas-tz.ts` — §19 re-parsea fechas desde XML. - `apps/api/scripts/invalidate-metricas-all.ts` — fuerza invalidación de cache para todos los periodos. - `apps/api/scripts/process-metricas-now.ts` — dispara recompute inmediato. - `apps/api/scripts/inspect-cfdi.ts` / `inspect-cfdi-full.ts` — debug de un UUID + sus relacionados. - `apps/api/scripts/check-saldo.ts` — validación de fórmula de saldo. - `apps/api/scripts/inspect-rfc.ts` / `find-contribuyente.ts` / `list-contribuyentes.ts` / `check-carlos-lco.ts` — debug contribuyentes. - `apps/api/scripts/validate-gastos.ts` / `validate-ingresos.ts` — tests de paridad dashboard vs drill / mes por mes del año. - `apps/api/scripts/breakdown-gastos.ts` — desglose por régimen para diagnóstico. - `apps/api/scripts/deep-egresos.ts` — análisis componente por componente. - `apps/api/scripts/check-cache.ts` — inspecciona `metricas_mensuales` de un contribuyente/periodo. - `apps/api/scripts/debug-i07.ts` — desglose de cada I/07 con NETO_CUSTOM y contribución al bucket. ### Cache invalidations aplicadas (iteraciones del día) 1. Post-§13 (saldo): no requiere (hook al insertar). 2. Post-§13b iter 1 (exclusión E/07 ingresos G1): 138+74=212 invalidaciones. 3. Post-§13b iter 2 (exclusión gastos + adquisiciones): 212. 4. Post-§13b iter 3 (compensación NETO_CUSTOM G1): 212. 5. Post-§13b iter 4 (compensación NETO_CUSTOM gastos): 212. 6. Post-clamp GREATEST(0, ...): 212. 7. Post-§19 (backfill fechas TZ): 212 → 401 filas (los CFDIs reubicados a su mes correcto generaron nuevas combinaciones régimen×mes en cache). ### Validaciones hechas - **Husberto Ignacio Torres (MO3NI6U8, d745a915-…)** — Feb, Jul, Oct 2025. Números finales congruentes tras cada iteración. - **Carlos Husberto Torres (MO3NI6U8, 414b22a8-…)** — Ingresos 2025 completo. Fix de §19 recuperó $219k en ingresos "perdidos" + 3 meses (jun, sep, nov) que reportaban $0 ahora muestran valores correctos. ### Pendientes activos - Propagar compensación NETO_CUSTOM a IVA causado/acreditable, flujo de efectivo, ISR retenido. - Investigar saldos negativos del backfill (`saldo_pendiente_mxn` < 0 en algunos CFDIs — indica P multi-docto o anticipos referenciados múltiples veces). - Carlos Torres: esperar 24-72h para que LCO del SAT propague el CSD nuevo. - Saldo en listado de CFDIs (hoy solo CxP/CxC lo usa). - `validate-ingresos.ts` (falta paridad-test para ingresos). - UX banner "CSD recién tramitado — LCO del SAT tarda 24-72h". --- ## 1. Fecha efectiva para CFDIs tipo P (complementos de pago) ### Problema Las queries de ingresos/egresos/IVA/ISR agrupaban CFDIs tipo P por `fecha_emision` del complemento, en vez de por `fecha_pago_p` (fecha real del cobro). Consecuencia: un pago de noviembre 2024 cuyo complemento se emitió en mayo 2025 se contabilizaba en mayo, no en noviembre. Caso real: Husberto mayo 2025 mostraba $239,925 en ingresos que en realidad correspondían a pagos de noviembre 2024. ### Solución Introducir un filtro de fecha CASE que evalúa por fila: ```sql FECHA_EFECTIVA = CASE WHEN tipo_comprobante = 'P' THEN fecha_pago_p ELSE fecha_emision END ``` En `impuestos.service.ts` se consolidó como `FECHA_RANGO` que usa la CASE. En `dashboard.service.ts` y `reportes.service.ts` se agregó `FECHA_PAGO_RANGO`/`getFechaPagoRango()` y cada query de P se separó en su propio bucket (`${FR_PAGO}`) — I y E siguen con `fecha_emision`, P con `fecha_pago_p`. ### Archivos - `apps/api/src/services/dashboard.service.ts`: helpers `FECHA_PAGO_RANGO`, `getFechaPagoRango()`. 4 queries de P en `calcularIngresosPorRegimen` y `calcularEgresosPorRegimen` usan `${FR_PAGO}`. - `apps/api/src/services/impuestos.service.ts`: constantes `FECHA_EFECTIVA`, `FECHA_RANGO`, `FECHA_RANGO_CONCILIACION`, `getFR()`. `EXTRACT` en `getIvaMensual` usa `FECHA_EFECTIVA`. `getIsrMensual` reescrito para delegar mes-a-mes a los servicios canónicos (`calcularIngresosPorRegimen`, etc.) en vez de su SQL propio. - `apps/api/src/services/reportes.service.ts`: `RANGO_PAGO` y `calcularFlujoPorMes.q()` branchea por tipo. - `apps/api/src/controllers/cfdi.controller.ts`: el drill-down de CFDIs (`GET /cfdi/drilldown`) ahora usa FECHA_EFECTIVA y acepta `tipoComprobante` como CSV (ej. `"I,P"`). El filtro `metodoPago` solo se aplica a tipos distintos de P. ### Validación Comparar totales contra declaración histórica de un mes conocido donde haya CFDIs P con retraso de emisión. Tolerancia $0.01 por redondeo. --- ## 2. Deduplicación de CFDIs por UUID case-insensitive ### Problema El SAT devuelve a veces el mismo UUID con mayúsculas distintas (ej. `e4656b47-…` y `E4656B47-…`). El `UNIQUE(uuid)` de Postgres es case-sensitive, así que se generaban duplicados. En Patito se encontraron 1426 filas duplicadas. ### Solución 1. **Normalizar al insertar:** `sat.service.ts` hace `uuid.toLowerCase()` en `saveCfdis` y `saveMetadata` antes del INSERT. 2. **Lookups case-insensitive:** el WHERE del UPSERT busca con `LOWER(uuid) = $1`. 3. **Cleanup de históricos:** migración `026_normalize_cfdi_uuid_case.sql` baja todos los UUIDs existentes a minúsculas y deja un solo representante por grupo. 4. **Constraint preventivo:** migración `027_cfdi_uuid_unique_case_insensitive.sql` agrega `CREATE UNIQUE INDEX ix_cfdis_uuid_ci ON cfdis (LOWER(uuid))`. ### Archivos - `apps/api/src/services/sat/sat.service.ts`: `saveCfdis` y `saveMetadata`. - `apps/api/src/migrations/tenant/026_normalize_cfdi_uuid_case.sql` — **nuevo** - `apps/api/src/migrations/tenant/027_cfdi_uuid_unique_case_insensitive.sql` — **nuevo** ### Nota sobre numeración Los números 026/027 son del fork. En Horux 360 ajustar a la numeración libre (p. ej. seguir desde la última migración actual). Mantener el orden: primero cleanup, luego constraint. --- ## 3. Timbres: reembolso al fallar emisión Facturapi ### Problema `facturacion.controller.ts` consumía un timbre **antes** de llamar a Facturapi. Si Facturapi rechazaba la factura (CSD inválido, datos malos, etc.), el timbre quedaba gastado sin factura emitida. ### Solución Consumir el timbre antes (para reservarlo y evitar carreras), y hacer **refund** (`refundTimbre`) en el `catch` si Facturapi responde con error o excepción. ### Archivos - `apps/api/src/controllers/facturacion.controller.ts`: rama `catch` en el handler de `POST /facturacion/emitir` llama a `refundTimbre(tenantId)`. - `apps/api/src/services/timbre.service.ts` (o equivalente): función `refundTimbre` que revierte el decremento. Si el consumo vino del pool mensual, el refund devuelve al pool; si vino de un paquete, devuelve al paquete con menor `expiraEn`. --- ## 4. Sistema hot/cold de métricas pre-calculadas ### Motivación Evitar recomputar KPIs desde raw CFDIs en cada request. Los meses cerrados (años pasados) no cambian; mantenerlos en tabla agregada reduce queries SQL por 90% en consultas históricas. ### 4.1 Schema Tabla `metricas_mensuales` con 35+ columnas por (año, mes, régimen): IVA trasladado/acreditable/retenido, ISR ingresos/deducciones/base/causado, cfdis counts, ingresos/egresos/utilidad devengados y cobrados, flujo entradas/salidas/neto, CxC/CxP saldo final. Tabla `metricas_invalidaciones`: marca (año, mes) que requieren recompute tras eventos que modifican CFDIs (sync SAT, upload manual, cancelación, conciliación). **En Horux 360 (single-tenant)**: omitir la columna `contribuyente_id` del esquema del fork. La PK es `(anio, mes, regimen_fiscal)`. ### 4.2 Archivos de schema - `apps/api/src/migrations/tenant/014_metricas_mensuales.sql` — tabla y índices. Adaptar quitando `contribuyente_id`. - Migración separada para `metricas_invalidaciones`. ### 4.3 Servicio de cómputo `apps/api/src/services/metricas-compute.service.ts` — **nuevo** - `computeMetricaMensual(pool, anio, mes)`: reúsa `calcularIngresosPorRegimen`, `calcularEgresosPorRegimen`, `getResumenIva` para poblar todas las columnas agregadas por régimen. - `backfillTenant(tenantId, opts)`: itera años × meses y llena histórico. - `processInvalidations(pool, tenantId)`: lee `metricas_invalidaciones`, recomputa, limpia. Fail-safe por entrada. - `processAllTenantsInvalidations()`: iterador para cron. ### 4.4 Servicio de acceso `apps/api/src/services/metricas.service.ts` — **nuevo** - `getMetricasMensuales(pool, anio, regimen?)`: lee tabla si año < actual, retorna vacío si año actual (forzar on-the-fly). - `upsertMetricaMensual`: INSERT con ON CONFLICT UPDATE. - `markForInvalidation`, `getPendingInvalidations`, `clearInvalidation`. - `closeMonth`, `closeYear`: marca cerrados (blindaje contra recompute). **BUG A EVITAR:** la versión inicial omitió `iva_retenido_cobrado` e `iva_retenido_pagado` en el `INSERT` y el `UPDATE SET` del `ON CONFLICT`. Las columnas existen en el schema con `DEFAULT 0`, la función recibe el valor en `data: Partial`, pero la SQL nunca escribía esas columnas. Resultado: `iva_retenido_cobrado` siempre quedaba en 0 y todos los cálculos que dependían de él devolvían mal. TypeScript no lo detecta porque `Partial<>` permite keys faltantes. Auditar cualquier función que use lista de columnas hand-written contra el tipo que recibe. ### 4.5 Cron de invalidaciones `apps/api/src/jobs/metricas-invalidations.job.ts` — **nuevo** - Cron cada 15 min con anti-overlap (lock en memoria: `let running = false`). - Llama `processAllTenantsInvalidations`. - En single-tenant (Horux 360) simplificar a `processInvalidations(pool)` para el único tenant. ### 4.6 Puntos de invalidación Cada path que modifica CFDIs debe llamar `markForInvalidation(pool, anio, mes, reason)`: - `cfdi.service.ts`: upload manual XML (líneas ~455, ~521 del fork). - `sat.service.ts`: `saveCfdis` y `saveMetadata` tras insert/update. - `facturacion.controller.ts`: cancelación. - `conciliacion.service.ts`: conciliar/desconciliar (mes de la conciliación). ### 4.7 Read-through cache en servicios consumidores **Utilitario compartido:** `apps/api/src/utils/metricas-cache.ts` — **nuevo** - `CacheRange` interface. - `planCache(fechaInicio, fechaFin, conciliacion)`: decide si el rango es elegible (año pasado + día 1 a último día del mes + conciliación desactivada + — para Horux 360 — sin restricción extra). Retorna `null` si no califica → caller usa on-the-fly. - Respeta `METRICAS_BYPASS_CACHE=1` del env (útil para validación). **Dashboard** (`dashboard.service.ts`): - `readIngresosFromCache`: suma `ingresos_cobrados` por régimen. - `readEgresosFromCache`: suma `egresos_pagados` por régimen. - `readIvaBalanceFromCache`: suma `iva_trasladado_total - iva_acreditable - iva_retenido_cobrado` por régimen (ver sección 5 sobre por qué resta `R`). - `calcularIngresosPorRegimen`, `calcularEgresosPorRegimen`, `calcularIvaBalancePorRegimen` integran el read-through al inicio: ```ts const cacheRange = planCache(fi, ff, conciliacion); if (cacheRange) { const cached = await readXFromCache(pool, cacheRange, ignorados, descMap); if (cached) return cached; } // ... on-the-fly ... ``` **Impuestos** (`impuestos.service.ts`): - `readResumenIvaFromCache`: lee T/A/R por régimen desde la tabla. El `acumuladoAnual` queda on-the-fly (su rango es enero→fechaFin, distinto al rango cacheado). ### 4.8 Scripts utilitarios - `apps/api/scripts/backfill-metricas.ts` — CLI de backfill histórico. Soporta `--dry`, filtros. Imprime resumen (meses procesados, filas escritas, errores). - `apps/api/scripts/validate-metricas.ts` — toma 5 muestras aleatorias, compara tabla vs on-the-fly. Detecta drift. - `apps/api/scripts/validate-dashboard-impuestos.ts` — compara `dashboard.balance` vs `impuestos.resultado` (ver sección 5). ### 4.9 Alcance recomendado en Horux 360 - Mínimo viable: `ingresos_cobrados`, `egresos_pagados` + IVA trasladado/ acreditable/retenido. Cubre dashboard + Control de Impuestos. - Fuera de alcance por ahora: ISR progresivo, IEPS, CxC/CxP, split PF vs PM. Se pueden agregar después sin romper la tabla. --- ## 5. Refactor de getResumenIva alineado con dashboard ### Problema El "Balance IVA" del dashboard y el "Resultado" de Control de Impuestos mostraban valores distintos para el mismo mes. Fórmulas heredadas: - Dashboard: `Balance = causado_neto − acreditable_neto` (IVA neto embebido en cada bucket, con las convenciones de PUE + notas de crédito). - Impuestos: `Resultado = Σtrasladado_emitido − Σacreditable_recibido − Σretenido_emitido` (sin filtrar PUE, sin manejo de NC, sin filtro por régimen del tenant). El dashboard es **correcto** (respeta las reglas de PUE/PPD por grupo de régimen y las convenciones contables de NC). El bug estaba en impuestos. ### Solución Reescribir `getResumenIva` para que las 3 tarjetas (Trasladado, Acreditable, Retenido) usen los **mismos 6 buckets** del dashboard, pero con retención exhibida en tarjeta separada (Control de Impuestos necesita mostrar la retención por separado para declaraciones). **Fórmula canónica unificada:** ``` Resultado = Trasladado − Acreditable − Retenido Trasladado = causado bruto (Emit+I+PUE) + (Emit+P) + (Recib+E+PUE) Acreditable = acreditable bruto (Recib+I+PUE) + (Recib+P) + (Emit+E+PUE) Retenido = retención(causado) − retención(acreditable) [neta] ``` Algebraicamente `Resultado == dashboard.balance` céntimo por céntimo. ### Implementación `impuestos.service.ts`: 1. Constantes SQL elevadas a file-level para reuso: - `IVA_TRAS_EXPR`: CASE por tipo_comprobante usando campos P vs I/E con EXCL aplicado. - `IVA_RET_EXPR`: análogo para retención. - `REGIMEN_TENANT`: CASE que retorna `regimen_fiscal_emisor` o `_receptor` según `type`. - `BUCKET_CAUSADO`: `(EMIT I PUE) OR (EMIT P) OR (RECIB E PUE)`. - `BUCKET_ACREDITABLE`: `(RECIB I PUE) OR (RECIB P) OR (EMIT E PUE)`. 2. Dos queries agregadas (una por lado), cada una devuelve trasladado + retención por régimen en una pasada: ```sql SELECT REGIMEN_TENANT as regimen, SUM(IVA_TRAS_EXPR) as trasladado, SUM(IVA_RET_EXPR) as retencion FROM cfdis WHERE BUCKET_CAUSADO AND VIGENTE AND FR AND REGIMEN_TENANT = ANY($3) GROUP BY REGIMEN_TENANT ``` 3. Filtro `TODOS_REGIMENES`: excluye 616 (extranjero) y similares. Dashboard lo hace; impuestos también debe. 4. Acumulado anual usa la misma fórmula (T−A−R) con rango enero→fechaFin. ### Bug secundario descubierto durante validación Al activar el cache de dashboard que lee `metricas_mensuales`, el balance seguía distinto. Causa: `readIvaBalanceFromCache` solo restaba `A`, no `R`. Después del refactor, las columnas `iva_trasladado_total` e `iva_acreditable` se almacenan en **bruto** (no neto), y la retención neta está separada en `iva_retenido_cobrado`. El cache debe replicar `T − A − R`. **Lección general:** cuando un refactor cambia la semántica de columnas almacenadas, toda función que **lea** esas columnas debe revisarse en lockstep, no solo las que escriben. ### Archivos - `apps/api/src/services/impuestos.service.ts`: `getResumenIva` completo, `readResumenIvaFromCache`, constantes elevadas. - `apps/api/src/services/dashboard.service.ts`: `readIvaBalanceFromCache` con fórmula T−A−R. - `apps/api/scripts/validate-dashboard-impuestos.ts` — **nuevo**: compara `dashboard.balance` vs `impuestos.resultado` en muestras aleatorias. --- ## 6. Drill-down de CFDIs: FECHA_EFECTIVA + tipoComprobante CSV ### Problema El endpoint `GET /cfdi/drilldown` filtraba por `fecha_emision` para todos los tipos (incluyendo P), y aceptaba un único `tipoComprobante` string. ### Solución - FECHA_EFECTIVA (ver sección 1). - `tipoComprobante` acepta CSV: `"I,P"` se traduce a `WHERE tipo_comprobante = ANY($n)`. - `metodoPago` se filtra solo cuando `tipo_comprobante != 'P'` (los P no tienen `metodo_pago` significativo). ### Archivos - `apps/api/src/controllers/cfdi.controller.ts`: `drillDown`. --- ## 7. Script de validación holístico `apps/api/scripts/validate-dashboard-impuestos.ts` merece portarse: - Toma 5 muestras aleatorias. - Compara `calcularIvaBalancePorRegimen().total` vs `getResumenIva().resultado`. - Soporta `METRICAS_BYPASS_CACHE=1` para forzar on-the-fly. - Exit code 0 si todo pasa, 1 si hay diferencias. Candidato a hook pre-commit o CI. --- ## 8. Watchdog CLI para jobs SAT stale ### Problema El cron horario `retryTimedOutJobs` en Horux 360 confía en que arranque siempre. Si no (proceso reiniciado con sync corriendo, cron inactivo en dev, despliegue largo), los jobs quedan huérfanos: - `pending` con `nextRetryAt` vencido → nadie los retoma → bloquean el lock para nuevos syncs. - `running` con `startedAt` muy atrás → proceso crasheó a mitad del sync; la solicitud al SAT se perdió pero la fila sigue con status=running. ### Solución `apps/api/scripts/sweep-stale-sat-jobs.ts` — **nuevo**. CLI que marca como `failed` ambas categorías con mensaje descriptivo. Dry-run por default; `--apply` para ejecutar. Thresholds via env: - `STALE_PENDING_HOURS` (default 12) - `STALE_RUNNING_HOURS` (default 4) ### Integración sugerida Wiring como cron cada 2h dentro de `sat-sync.job.ts`. Refactorizar la lógica del script a función exportable o duplicar inline. ```ts const WATCHDOG_CRON_SCHEDULE = '0 */2 * * *'; cron.schedule(WATCHDOG_CRON_SCHEDULE, () => sweepStaleSatJobs({ apply: true }), { timezone: 'America/Mexico_City' }); ``` ### Notas para Horux 360 En Horux 360 el thresholds de `running` pueden ser más altos (6h?) si los sync initial abarcan más años / más tenants. Validar contra métricas reales del tiempo que toma un initial en su entorno. --- ## 9. Crons en dev con flag ### Problema En Horux 360 todos los crons están gateados con `NODE_ENV === 'production'`. Esto significa que en dev: - Jobs pendientes de retry SAT no se retoman (los jobs stale se acumulan) - Cambios programados de suscripción no aplican - Métricas pendientes de invalidar no se procesan ### Solución En `apps/api/src/index.ts`, partir el gate en dos: `cronsEnabled` (activable con `ENABLE_CRONS_IN_DEV=1`) y `sendRealEmails` (solo prod). Los crons seguros (SAT sync/retry, métricas, suscripciones) arrancan con `cronsEnabled`; el weekly-update sigue prod-only para no mandar emails reales en dev. ```ts const cronsEnabled = env.NODE_ENV === 'production' || process.env.ENABLE_CRONS_IN_DEV === '1'; const sendRealEmails = env.NODE_ENV === 'production'; if (cronsEnabled) { startSatSyncJob(); startMetricasInvalidationsJob(); if (sendRealEmails) startWeeklyUpdateJob(); } ``` --- ## 10. Logging informativo de rejections SAT ### Problema Cuando el SAT rechaza una solicitud (`statusRequest.isTypeOf('Rejected')`), el mensaje que `verifySatRequest` retornaba era `result.getStatus().getMessage()` — el mensaje del wrapper HTTP, genéricamente `"Solicitud Aceptada"`. La razón real del rechazo (códigos 5001 tercero no autorizado, 5002 agotadas solicitudes, 5003 tope máximo, 5005 duplicada, etc.) quedaba enterrada. ### Solución En `apps/api/src/services/sat/sat-client.service.ts`, cuando `status` es `rejected` o `failed`, construir el message con `SAT code=N request=EntryId(value) msg="..."` que incluye: - `statusCode` — código numérico del SAT - `entryId` — etiqueta del `StatusRequest` (`Rejected`, `Failure`, etc.) - `value` — valor numérico del `StatusRequest` - `msg` — mensaje del wrapper (ya existente) Permite diagnosticar rechazos sin tener que inspeccionar los `[SAT Verify Debug]` logs a mano. Útil especialmente cuando ciertos rangos/contribuyentes fallan sistemáticamente (p. ej. FIEL recientemente renovada, solicitudes duplicadas, etc.). --- ## 11. IVA Mensual alineado con fórmula canónica + cache ### Problema `getIvaMensual` usaba una fórmula distinta a `getResumenIva` y al dashboard: - No filtraba PUE (incluía PPD en trasladado/acreditable) - No manejaba NC (notas de crédito) - Retenido era gross (no neto de causado vs acreditable) Resultado: la tabla "IVA Mensual" histórico no cuadraba con el KPI del mes activo ni con la tarjeta "Resultado" de Control de Impuestos. ### Solución Refactorizar `getIvaMensual` para usar los mismos 6 buckets del dashboard (3 causado + 3 acreditable) y `Retenido = retCausado − retAcreditable`. Grouped por mes. Además, agregar read-through cache desde `metricas_mensuales` para años pasados con contribuyente seleccionado. Helper `readIvaMensualFromCache` nuevo. ### Archivos - `apps/api/src/services/impuestos.service.ts` — refactor completo de `getIvaMensual`. --- ## 12. Persistir `CfdiRelacionados` (TipoRelacion + UUIDs) ### Problema El nodo `` del CFDI 4.0 no se estaba persistiendo. Sin estos datos no se puede: - Distinguir una nota de crédito genuina (E sin relación) de una aplicación de anticipo (E con TipoRelacion=07) — la primera reduce ingresos del período, la segunda NO (solo cancela un anticipo previo ya contabilizado). - Hacer trazabilidad entre un CFDI y los que sustituye/cancela/traslada. El campo pre-existente `uuid_relacionado` solo cubre `DoctoRelacionado` del complemento de Pagos (tipo P), no los `CfdiRelacionados` a nivel raíz del comprobante. ### Solución 1. **Migración `032_cfdis_relaciones.sql`** — agrega a la tabla `cfdis`: - `cfdi_tipo_relacion VARCHAR(2)` — clave SAT (01 NC, 02 Sustitución, 03 Devolución, 04 Sustitución CFDIs previos, 05 Traslados mercancía, 06 Factura por traslado previo, 07 Aplicación de anticipo). - `cfdis_relacionados TEXT` — UUIDs pipe-separated. - Partial index `WHERE cfdi_tipo_relacion IS NOT NULL` (la mayoría de CFDIs no tienen relación, mantener el índice pequeño). 2. **Parser (`sat-parser.service.ts`)** — función `extractCfdiRelacionados()` recorre los nodos `CfdiRelacionados` y concatena los `UUID` de todos los `CfdiRelacionado` hijos. Captura el **primer** `TipoRelacion` encontrado (raro ver >1 en la práctica, pero el schema SAT lo permite). Wired en `parseXml()` → `cfdiTipoRelacion` + `cfdisRelacionados`. 3. **SQL UPSERT (`sat.service.ts:saveCfdis`)** — UPDATE y INSERT incluyen las 2 columnas nuevas. Posiciones renumeradas (`sat_sync_job_id` pasa de $83 a $85). 4. **Manual upload path (`cfdi.service.ts:createCfdi`)** — `CreateCfdiData` añade `cfdiTipoRelacion?` + `cfdisRelacionados?` opcionales. INSERT los guarda. `CFDI_SELECT` los surface como `cfdiTipoRelacion` + `cfdisRelacionados`. 5. **Shared type (`packages/shared/src/types/cfdi.ts`)** — `Cfdi` interface incluye `cfdiTipoRelacion: string | null` y `cfdisRelacionados: string | null`. ### Backfill de CFDIs históricos **Los CFDIs previos a la migración quedan con NULL en las 2 columnas nuevas.** Opciones para rellenarlos: - **(a)** Re-parsear en memoria desde `xml_original` de cada CFDI con `source='sat'` — rápido, no requiere volver al SAT. Script: `apps/api/scripts/backfill-cfdis-relaciones.ts` — itera `WHERE xml_original IS NOT NULL AND cfdi_tipo_relacion IS NULL`, corre `parseXml()` y UPDATE solo si extrae `cfdiTipoRelacion` no-nulo. Soporta `--dry` para preview. Transaccional por tenant. Idempotente: una segunda corrida es no-op porque el WHERE ya no matchea las filas actualizadas; los CFDIs sin CfdiRelacionados sí se re-escanean pero no se escribe nada. - **(b)** Re-sync SAT del rango afectado — costoso, solo vale la pena si muchos CFDIs no tienen `xml_original` (p. ej. venían de `sat-metadata`). Para Horux 360 recomiendo (a) en deploy: el XML ya está en BD, la operación es idempotente. Uso: ``` pnpm --filter @horux/api exec tsx scripts/backfill-cfdis-relaciones.ts --dry # preview pnpm --filter @horux/api exec tsx scripts/backfill-cfdis-relaciones.ts # aplica ``` Run histórico en `Horux_despacho` (2026-04-23): 10,658 CFDIs escaneados, 1,168 actualizados (10.9% tenían relaciones). Desglose TipoRelacion: 07 (anticipo) 803, 01 (NC) 151, 04 (sustitución previos) 108, 03 (devolución) 85, 05 (traslados) 13, 02 (sustitución) 8. Confirma que el filtro futuro de E/07 va a excluir ~69% de los CFDIs tipo E con relación. ### Aplicación del filtro E/07 — Ingresos Grupo 1 (ejecutado 2026-04-23) Primera incorporación del filtro. Alcance deliberadamente acotado a un solo bucket porque la lógica fiscal de anticipos es distinta por régimen: **Cambio:** en `dashboard.service.ts:calcularIngresosPorRegimen()`, el query `g1NC` (NC restantes del Grupo 1 PF Empresarial — 606/612/621/625/626) agrega `AND COALESCE(cfdi_tipo_relacion, '') <> '07'`. Razón: las E/07 solo documentan la aplicación del anticipo contra la factura final — el anticipo original ya entró como ingreso (I PUE o complemento P). Restar la E/07 provoca doble resta y deflacta ingresos del período. **NO aplicado a Grupo 3 (PM y otros) ni a otras fórmulas** (gastos/causado/ acreditable/ISR/drill-down/reportes). El tratamiento de anticipos varía por régimen y modalidad SAT; evitar aplicar blanket hasta validar caso-por-caso. **Cache invalidation pipeline:** como `metricas_mensuales` tenía datos pre-calculados con la fórmula vieja, se crearon 2 scripts: - `apps/api/scripts/invalidate-metricas-all.ts` — marca todas las entradas `(contribuyente_id, anio, mes)` del cache para recompute. Acepta `--reason=` custom. Idempotente (`ON CONFLICT DO UPDATE`). - `apps/api/scripts/process-metricas-now.ts` — dispara `processAllTenantsInvalidations()` sin esperar al cron de 15 min. Run en `Horux_despacho` (2026-04-23): 173 invalidaciones marcadas, 236 procesadas (incluye algunas previas colgadas), 392 filas escritas, 0 errores, 2.5s. ### Aplicación del filtro E/07 — Gastos (ejecutado 2026-04-23) Segunda incorporación del filtro. A diferencia de ingresos, gastos NO se particiona por régimen — la fórmula es uniforme para todos los regímenes del receptor, así que el filtro también es uniforme. **Cambio:** en `dashboard.service.ts`: - `calcularEgresosPorRegimen()` — query `nc` agrega `AND COALESCE(cfdi_tipo_relacion, '') <> '07'`. - `calcularAdquisicionesMercancias()` — query `nc` agrega el mismo filtro (adquisiciones es subset de gastos por `uso_cfdi='G01'`; deben cuadrar). **Razón fiscal:** desde perspectiva del tenant-receptor, la E/07 documenta la aplicación de un anticipo previamente pagado (que ya se dedujo como I PUE del anticipo). La E/07 no es un reembolso — restarla bajaba gastos de más y duplicaba el efecto del anticipo en sentido inverso. **Nota modalidad SAT:** el XML no indica qué modalidad usó el proveedor (A = I final por total + E/07 correctiva; B = I final por remanente + E/07 informativa). Asumimos modalidad B (más común) donde la E/07 no debe restar. Si un proveedor usa modalidad A, hoy se deduce de más y ese escenario requeriría detección adicional (posiblemente cruzando el UUID en `cfdis_relacionados` contra el I del anticipo). Run en `Horux_despacho` (2026-04-23): 212 invalidaciones marcadas, 212 procesadas, 392 filas escritas, 0 errores, 2.4s. ### Aplicación del filtro E/07 — Balance IVA + reestructura de buckets (ejecutado 2026-04-23) Tercera incorporación. A diferencia de ingresos/gastos, en IVA el usuario pidió dos cambios simultáneos: **Cambio 1 — Reatribución de buckets (sin NC cruzadas).** Antes: ``` Causado = (EMIT I PUE) + (EMIT P) + (RECIB E PUE) ← NC cruzada Acreditable = (RECIB I PUE) + (RECIB P) + (EMIT E PUE) ← NC cruzada Balance = Causado − Acreditable ``` Ahora: ``` Causado = (EMIT I PUE) + (EMIT P) − (EMIT E PUE, excl. E/07) Acreditable = (RECIB I PUE) + (RECIB P) − (RECIB E PUE, excl. E/07) Balance = Causado − Acreditable ← matemáticamente equivalente al anterior ``` El balance total sale idéntico al algoritmo viejo (álgebra idéntica), pero la atribución de causado/acreditable ahora es por lado propio — cada tarjeta refleja sólo su lado sin cross-contamination. **Cambio 2 — Filtro E/07 uniforme en todas las fórmulas de IVA.** **Archivos:** - `apps/api/src/services/dashboard.service.ts:calcularIvaBalancePorRegimen()` — s3 y r3 agregan `AND COALESCE(cfdi_tipo_relacion,'') <> '07'`. Composición cambia a `causado = s1+s2-r3`, `acreditable = r1+r2-s3`. - `apps/api/src/services/impuestos.service.ts` — constantes BUCKET reestructuradas: `BUCKET_CAUSADO_POS/NEG`, `BUCKET_ACREDITABLE_POS/NEG`, `BUCKET_*_ANY`, `SIGNED_CAUSADO_TRAS/RET`, `SIGNED_ACREDITABLE_TRAS/RET`. Los `_NEG` ya incluyen el filtro E/07. - 4 queries actualizadas (2 en `getIvaMensual`, 2 en `getResumenIva`) + 2 acumulados anuales (1 en `readResumenIvaFromCache`, 1 en `getResumenIva`) usan las expresiones signed. **Run en `Horux_despacho` (2026-04-23):** 212 invalidaciones procesadas, 392 filas escritas, 0 errores, 2.6s. ### Aplicación del filtro E/07 — Flujo de Efectivo + Comparativo (ejecutado 2026-04-23) Cuarta incorporación. Flujo de efectivo y comparativo de periodos usan 6 queries SQL directas (no heredan del dashboard) porque operan con montos **brutos** (`total_mxn`, `monto_pago_mxn` sin restar IMP_TRAS/EXCL_MONTO) — el flujo representa dinero real que entró/salió de cuentas, no montos netos de impuestos. **Razón para aplicar E/07 aquí también:** la E/07 NO mueve dinero real, solo documenta la aplicación de un anticipo previamente pagado/cobrado. Sin el filtro, los usuarios verían diferencias inexplicables entre "Utilidad del P&L" y "Flujo neto" cuyo delta no se podría atribuir solo al devengado-vs-efectivo. **Archivos:** - `apps/api/src/services/reportes.service.ts:getFlujoEfectivo()` — queries `entradasNC` (EMIT E PUE) y `salidasNC` (RECIB E PUE) agregan `AND COALESCE(cfdi_tipo_relacion,'') <> '07'`. - `apps/api/src/services/reportes.service.ts:calcularFlujoPorMes()` — helper `q()` inyecta el filtro condicionalmente cuando `tc === 'E'`. Cubre las 2 queries de NC (emitidas + recibidas) en un solo punto. **Sin invalidación de cache:** `metricas_mensuales.flujo_entradas/salidas/neto` se popula con los ingresos/egresos del dashboard (fórmula neta), pero el reporte de flujo de efectivo NO lee del cache — siempre va on-the-fly contra `cfdis`. El cambio surte efecto inmediato. **Nota de diseño existente:** el cache de `flujo_*` no coincide con `getFlujoEfectivo` — el primero es neto (ingresos/egresos del dashboard) y el segundo es bruto (montos directos). Esto es pre-existente, no introducido aquí; solo hay que estar consciente si alguien expone el cache en UI. ### Decisiones cerradas / pendientes (tras revisión 2026-04-23) **Resueltos por decisión de diseño (no se hace cambio):** - **I/07 en Grupo 3 PM y otros**: sigue sumando completo. No se filtra. Solo Grupo 1 (PF Empresarial) excluye I/07 — ver §13b abajo. - **E/07** en cualquier grupo: queda filtrada donde aplica (ver §1-12). - **Grupo 3 PM en ingresos**: sigue restando todas las E/PUE (incluyendo E/07). Decidido: solo Grupo 1 excluye E/07. Motivo fiscal específico por régimen. - **ISR retenido sin filtro E/07**: E/07 con retención ISR es un escenario atípico del emisor. El query queda crudo. - **Cache `metricas_mensuales.flujo_*` vs `getFlujoEfectivo`** (neto vs bruto): diseño aceptado. Flagueado por si alguien expone el cache en UI. **Resueltos con implementación posterior:** - **Saldo CxP/CxC** → §13. - **Drill-down inconsistente con KPIs** → §16. - **Cache crece con residuos en recompute** → §17. --- ## 13b. Tratamiento de I/07 (aplicación de anticipo) Los I PUE con `cfdi_tipo_relacion='07'` son **aplicaciones de anticipo** — facturas finales que consumen un anticipo previo. El tratamiento varía según el lado (emisor vs receptor) y el grupo de régimen: ### Gastos (receptor) — Compensación con NETO_CUSTOM (uniforme) **I/07 recibidas SÍ se consideran** pero con la misma compensación que Grupo 1 ingresos — uniforme para todos los regímenes del receptor (no hay grupos en gastos). Aporte de una I/07 recibida al gasto del régimen: ``` contribucion = GREATEST(0, (NETO_CUSTOM(I/07) − EXCL_MONTO(I/07)) − Σ (NETO_CUSTOM(rel) − EXCL_MONTO(rel)) ) ``` donde `rel` son los UUIDs vigentes en `cfdis_relacionados` de la I/07 (los anticipos originales que el tenant pagó al proveedor). **Clamp a 0 (decisión D del user, 2026-04-24)**: si el anticipo relacionado está en un periodo anterior y su NETO_CUSTOM supera al de la I/07, la contribución bruta daría negativa — lo que produjo gastos negativos reportados por el user en julio 2025 de Husberto (una I/07 chica con un anticipo de marzo 2024 restaba ~$394k del mes). El clamp garantiza que la contribución nunca baje de 0, preservando la compensación del remanente cuando anticipo e I/07 están en el mismo periodo y cancelando el efecto cuando están en periodos distintos. Trade-off conocido: a nivel anual, si alguien mira un rango que incluye anticipo + I/07, el anticipo cuenta completo por su mes y la I/07 aporta 0 si estaba clampada → subcuenta ligeramente el remanente real. Decisión aceptada por el user. Semánticamente: restamos de la I/07 (factura final que ya incluye el anticipo aplicado) el NETO_CUSTOM del anticipo que ya se contabilizó previamente — efectivamente queda solo el remanente como gasto nuevo del periodo. Aplicado en: - `calcularEgresosPorRegimen:facturas` — CASE WHEN con compensación. - `calcularAdquisicionesMercancias:facturas` — mismo tratamiento. - Drill-down bucket=gastos lista las I/07 con `total_mxn` crudo (opción (a) del user: permite delta visual entre header y filas por la compensación que no se refleja fila-por-fila). **Validación Husberto Feb 2025**: 2 I/07 recibidas de $71,525.05 bruto aportan −$25,464.87 netos (compensados contra sus anticipos). Total de gastos pasó de $463,521.00 (si las excluimos) a $438,056.13 (con compensación). ### Ingresos Grupo 1 PF Empresarial — Compensación con NETO_CUSTOM **I/07 emitidas SÍ se consideran** pero con cálculo compensado contra las facturas relacionadas (el anticipo original). No es exclusión, es resta del anticipo ya contabilizado. Fórmula usada (definida en `dashboard.service.ts`): ``` NETO_CUSTOM = total_mxn − iva_traslado_mxn + iva_retencion_mxn + isr_retencion_mxn − ieps_traslado_mxn + ieps_retencion_mxn − impuestos_locales_trasladado_mxn + impuestos_locales_retenidos_mxn ``` Aporte de una I/07 al ingreso del régimen: ``` contribucion = GREATEST(0, (NETO_CUSTOM(I/07) − EXCL_MONTO(I/07)) − Σ (NETO_CUSTOM(rel) − EXCL_MONTO(rel)) ) ``` donde `rel` son los UUIDs vigentes listados en `cfdis_relacionados` de la I/07. Clamp a 0 para evitar contribuciones negativas cuando el anticipo original está en un periodo anterior al de la I/07 (mismo tratamiento que en gastos). Semántica: restamos de la I/07 (que es la factura final que ya incluye el anticipo) el `NETO_CUSTOM` del anticipo original que ya se contabilizó. Las retenciones suman porque fiscalmente son parte del ingreso aunque el retenedor se las haya quedado; los traslados restan porque son impuestos trasladados al cliente, no ingreso propio del emisor. Se aplica **solo en Grupo 1 PF Empresarial** (606, 612, 621, 625, 626). Grupo 3 PM sigue sumando I/07 completa (régimen devengado, sin compensación). Grupo 2 Sueldos N/A (no hay I emitidas). **Edge case**: si el anticipo relacionado no está en la BD (ej. no sincronizado o fuera del rango temporal), su aporte en el subquery es 0 y la I/07 contribuye `NETO_CUSTOM` completo. Decisión explícita del user. ### Drill-down El drill-down del dashboard **sí lista las I/07 como filas** (con su `total_mxn` normal). **El total del header puede no cuadrar fila-por-fila** porque la contribución de cada I/07 depende de otra factura (no visible en la fila). El header es la suma correcta; las filas son informativas. Alternativa considerada y descartada: pre-calcular la contribución por fila en el drill (mostrar monto compensado en la I/07). Descartada por complejidad + fragilidad de UX al mostrar números "raros" por fila. ### SQL helpers agregados ```ts const NETO_CUSTOM = (alias: string) => `( total_mxn - iva_traslado + iva_retencion + isr_retencion - ieps_traslado + ieps_retencion - impuestos_locales_trasladado + impuestos_locales_retenidos )`; const EXCL_MONTO_ALIAS = (alias: string) => ``; ``` ### Archivos - `apps/api/src/services/dashboard.service.ts` — helpers `NETO_CUSTOM` y `EXCL_MONTO_ALIAS` (globales, reutilizables). - `apps/api/src/services/dashboard.service.ts:calcularIngresosPorRegimen()` — query `g1Facturas` con CASE WHEN compensando I/07 del Grupo 1. - `apps/api/src/services/dashboard.service.ts:calcularEgresosPorRegimen()` — query `facturas` con CASE WHEN compensando I/07 recibidas (uniforme). - `apps/api/src/services/dashboard.service.ts:calcularAdquisicionesMercancias()` — query `facturas` con mismo CASE WHEN. - `apps/api/src/controllers/cfdi.controller.ts:drillDown()` — bucket=gastos e ingresos Grupo 1 listan I/07 con `total_mxn` crudo (opción (a) del user: delta visual entre header y filas aceptado). ### Cache Requiere invalidate + recompute tras cada cambio de fórmula. Ejecutado en cada iteración: 1. Excluir I/07 de gastos uniforme → recomputed. 2. Excluir I/07 de ingresos Grupo 1 → recomputed. 3. Compensar I/07 en ingresos Grupo 1 con NETO_CUSTOM → recomputed. 4. Compensar I/07 en gastos uniforme con NETO_CUSTOM → recomputed. ### Estado final (tabla resumen) | Bucket / grupo | I/07 | E/07 | |---|---|---| | Ingresos **1** PF Empresarial | Compensación NETO_CUSTOM | Exclusión | | Ingresos **2** Sueldos (605) | N/A | N/A | | Ingresos **3** PM y otros | Sumadas completas | Restadas completas | | Gastos (uniforme) | Compensación NETO_CUSTOM | Exclusión | | Adquisiciones G01 (uniforme) | Compensación NETO_CUSTOM | Exclusión | | IVA causado/acreditable | N/A (solo E) | Exclusión | | Flujo de efectivo | N/A (solo E) | Exclusión | --- ## 13. Saldo real en Cuentas por Pagar / Cuentas por Cobrar ### Problema La query de CxP/CxC hacía `SUM(COALESCE(saldo_pendiente_mxn, total_mxn))`, pero `saldo_pendiente_mxn` solo se populaba (parcialmente) para CFDIs tipo P — los I PPD lo tenían **siempre NULL**, así que el COALESCE caía a `total_mxn` y reportaba "todo pendiente" aunque hubiera pagos, NC y anticipos aplicados. Caso real (Horux_despacho, 2026-04-23): UUID `5c874749-748f-11f0-96b1-2b9310891836` ($454,000 I PPD RECIBIDO) mostraba $454,000 pendiente. Realidad: - 2 complementos P: $296,000 - 1 NC real (E/01): $10,000 - Anticipo aplicado (CFDI es I/07 referenciando anticipo 729109FC): $148,000 - Saldo real: $0 ### Solución (denormalizado con hooks + backfill) Decisión: persistir el saldo en la columna `saldo_pendiente_mxn` en vez de computarlo on-the-fly en cada query de CxP/CxC. Razones: 1. El listado de CFDIs (`/cfdi`) también puede aprovechar el campo sin replicar la subquery. 2. Queries de CxP/CxC quedan simples (`SUM(saldo_pendiente_mxn)`). 3. El cómputo vive en un solo lugar (helper central) — sola source of truth. **Utility central** `apps/api/src/utils/saldo.ts`: - `saldoComputadoExpr(alias)` — SQL expression con la fórmula. - `recomputarSaldoPendiente(pool, uuids[])` — UPDATE masivo por set de UUIDs. - `recomputarSaldoTodos(pool)` — recompute completo (backfill). - `uuidsAfectadosPorCfdi(cfdi)` — dado un CFDI, devuelve el set de UUIDs cuyo saldo debe recomputarse: - I PPD: su propio UUID (considera anticipo si es I/07). - P: los UUIDs referenciados en `uuid_relacionado` (pipe-separated). - E no-07: los UUIDs en `cfdis_relacionados`. - Otros tipos: ninguno. **Fórmula** (única, aplicada en hooks + backfill + cualquier consumer): ```sql saldo = total_mxn - Σ P.monto_pago_mxn (P con uuid_relacionado LIKE '%uuid%') - Σ E.total_mxn (E con cfdi_tipo_relacion <> '07', uuid ∈ string_to_array(cfdis_relacionados,'|')) - CASE cfdi_tipo_relacion = '07' THEN Σ anticipo.total_mxn (UUIDs en cfdis_relacionados) ELSE 0 ``` NO se clamp a 0 — un saldo negativo indica over-aplicación (señal útil, no bug). El filtro downstream `WHERE saldo > 0.01` excluye los ≤ 0 del reporte. **Hooks de escritura:** - `sat.service.ts:saveCfdis` — después del loop de inserts, colecta todos los UUIDs afectados por el batch y corre `recomputarSaldoPendiente` agregado (un solo UPDATE, no uno por CFDI). - `cfdi.service.ts:createCfdi` — después del INSERT, recompute individual para los UUIDs afectados (inserts manuales son 1:1, no batch). - Ambos fail-soft: error en recompute no rompe el insert (solo loggea warn). **Backfill** `apps/api/scripts/backfill-saldo-pendiente.ts`: - UPDATE masivo sobre todos los I PPD vigentes usando `saldoComputadoExpr`. - Soporta `--dry` (transaccional con ROLLBACK). - Reporta delta por tenant: saldo total antes vs después. **Queries downstream simplificadas:** `getCuentasXPagar` y `getCuentasXCobrar` ahora usan `SUM(COALESCE(saldo_pendiente_mxn, total_mxn))` directo — sin subqueries. `COALESCE` a `total_mxn` es safety net para CFDIs previos al backfill (una vez corrido, todos los I PPD tienen el campo poblado). ### Reglas de resta (referencia) - **Anticipo aplicado**: se resta el **total completo** del I anticipo referenciado (asumimos aplicación total, no parcial). - **NC**: se restan todas las E relacionadas con `cfdi_tipo_relacion <> '07'` (01/02/03/04/05/06). E/07 NO resta (es cancelación de anticipo, ya contabilizado en la resta del anticipo aplicado del I/07 padre). - **Pagos P**: suma de `monto_pago_mxn` de complementos P que referencien el UUID (puede matchear múltiples si el P tiene multi-docto pipe-separated). - Status `Cancelado` o `0` se excluye. ### Alcance expandido Ahora cubre: - Reportes de CxP/CxC (`/reportes`) — sigue igual desde UI. - Cualquier futuro consumer del campo `saldo_pendiente_mxn` (ej. listado de CFDIs) — ya tiene el dato fresco. - Sync SAT + upload manual de XML actualizan el campo automáticamente. ### Validación `scripts/check-saldo.ts 5c874749-…`: ``` total_mxn = 454,000 pagos_p = 296,000 ncs = 10,000 anticipo_aplicado = 148,000 saldo_computado = 0 ✓ ``` Run backfill inicial en Horux_despacho (2026-04-23): 784 I PPD vigentes, delta global -$11,764,854.61 (saldo que reportaba como pendiente pero ya tenía pagos/NC/anticipos aplicados). ### Nota sobre saldos negativos El backfill detectó saldos negativos (~-$1.7M en un tenant). Causas típicas: - Un complemento P con multi-docto (pipe-separated) cuyo `monto_pago` total se matchea contra varios UUIDs vía LIKE — over-count del pago dividido. - Un I anticipo referenciado por varios I/07 (cada uno resta el total completo del anticipo). - Datos erróneos del SAT. El reporte de CxP/CxC filtra `WHERE saldo > 0.01` así que no los muestra, pero el campo persistido los refleja como señal de investigación. ### Archivos - `apps/api/src/utils/saldo.ts` — **nuevo** utility central (expr SQL + helpers + detector de UUIDs afectados). - `apps/api/src/services/sat/sat.service.ts:saveCfdis()` — hook al final del batch (single UPDATE agregado). - `apps/api/src/services/cfdi.service.ts:createCfdi()` — hook post-insert. - `apps/api/src/services/reportes.service.ts:getCuentasXPagar/Cobrar()` — queries simplificadas, leen `saldo_pendiente_mxn` directo. - `apps/api/scripts/backfill-saldo-pendiente.ts` — **nuevo** script idempotente con dry-run. - `apps/api/scripts/inspect-cfdi.ts` — **nuevo** debug (CFDI + pagos + Es). - `apps/api/scripts/check-saldo.ts` — **nuevo** validación de fórmula. --- ## 14. Obligaciones del contribuyente desde la CSF ### Problema Horux 360 tenía catálogo estático `OBLIGACIONES_CATALOGO` con función `getRecomendaciones(rfc, regimenes, tieneNomina)` que sugería obligaciones por combinaciones régimen+tipo-persona. Limitaciones: - No refleja las obligaciones **reales** del contribuyente (p. ej. un PM con actividad específica que el SAT le asignó obligaciones atípicas). - No hay concepto de "fecha de baja" — todas las obligaciones del catálogo aparecen activas aunque el SAT las haya terminado. - No distingue obligaciones del propio SAT vs recomendaciones inferidas. ### Solución `initRecomendaciones(pool, contribuyenteId, rfc, regimenes, tieneNomina)` en `obligaciones.service.ts` ahora prioriza la **CSF real** sobre el catálogo: 1. Lee `datos->'obligaciones'` del último CSF activo (`constancias_situacion_fiscal` del tenant, ordenado por `created_at DESC`). 2. Si el CSF existe y tiene obligaciones: filtra las **activas** (sin `fechaFin` — el SAT publica obligaciones terminadas con fecha fin) e inserta cada una en `obligaciones_contribuyente` con flag `es_recomendada=true`. 3. Enriquece cada obligación con keywords contra `OBLIGACIONES_CATALOGO` vía `matchCsfToCatalog(descripcion, rfc)` para heredar `fundamento`, `categoria` y `frecuencia` del catálogo cuando hay match. 4. Si no hay match de catálogo, infiere frecuencia con `inferirFrecuencia(descripcionVencimiento)` (keywords: "mensual", "bimest", "trimest", "anual", "ejercicio"). 5. **Fallback**: si el CSF no existe (contribuyente nuevo sin sync), cae al catálogo estático como antes — mantiene compatibilidad. ### Re-inicialización Al re-sincronizar la CSF, `initRecomendaciones` se vuelve a llamar. Antes de insertar el set nuevo: 1. Borra alertas `ob--` de las obligaciones anteriores (SUBSTRING del tipo para extraer UUID). 2. Borra `obligacion_periodos` de las obligaciones anteriores (ON DELETE CASCADE hace que sea redundante, pero explícito por claridad). 3. Borra `obligaciones_contribuyente` donde `es_recomendada=true`. Las obligaciones marcadas `es_custom=true` (creadas manualmente por el contador) se preservan. ### Interacción con alertas Cada obligación activa genera alertas tipo `ob--` en la tabla `alertas` (via `alertas-manuales.service.ts`). Al re-init, las alertas de obligaciones "viejas" se borran; las del set nuevo se generan en el próximo cron de alertas. ### Archivos - `apps/api/src/services/obligaciones.service.ts:initRecomendaciones()` — lógica completa de CSF-first + fallback. - `apps/api/src/services/obligaciones.service.ts:matchCsfToCatalog()` — matching por keywords. - `apps/api/src/services/obligaciones.service.ts:inferirFrecuencia()` — parser de "descripcionVencimiento" del CSF. ### Dependencia CSF descargable vía Playwright (`apps/api/src/services/sat/sat-csf-*`). Ver `docs/Horux_despachos-vs-Horux360.md` — debe documentarse el parser de CSF si no existe aún en este doc (la CSF en sí es funcionalidad portable a Horux 360, solo que en el SaaS single-tenant la tabla `constancias_situacion_fiscal` queda sin FK a contribuyentes). --- ## 15. Declaraciones provisionales ↔ obligaciones ↔ alertas ### Problema Cuando el contador subía una declaración provisional (PDF), el sistema registraba el PDF pero: - No marcaba la obligación correspondiente como cumplida. - Las alertas tipo `ob--` seguían activas. - El usuario tenía que ir a `/alertas` y marcar manualmente "realizado". - No había trazabilidad — al ver una obligación completada no se sabía si fue por declaración subida o por el flujo manual. ### Solución `completarObligacionesPorDeclaracion()` en `declaraciones.service.ts`: 1. Lee todas las obligaciones activas del contribuyente. 2. Para cada impuesto de la declaración (IVA, ISR, IEPS, SUELDOS, DIOT, OTRO), filtra las obligaciones que matchean por keywords (`IMPUESTO_A_OBLIGACION_KEYWORDS[impuesto].include` / `.exclude`). 3. **Filtro de periodicidad**: una declaración mensual no cierra obligaciones anuales del mismo impuesto (ej. "ISR mensual" NO cubre "Declaración anual de ISR"). Si `ob.frecuencia` está definida y difiere de `declaracion.periodicidad`, skip. Las `eventual` nunca se tocan. 4. Inserta/upserta en `obligacion_periodos` con: - `completada = true` - `completada_por` = UUID del user que subió - `declaracion_id` = FK a la declaración (migración 030 agregó el campo). 5. Marca como resueltas las alertas `ob--` del set afectado. ### Comportamiento según `tipo` y `cubrePago` - `tipo='normal'` sin monto: solo cubre alertas `ob-*` (no `pago-*`). - `tipo='complementaria'`: además de `ob-*`, resuelve las `pago-*` del mismo mes (una complementaria sustituye el pago de la normal). - Al subir comprobante de pago después via `uploadComprobantePago`, resuelve las `pago-*` pendientes. ### Trazabilidad `obligacion_periodos.declaracion_id` (FK con `ON DELETE SET NULL`) permite que la UI muestre "Completada vía Declaración #123". Si la declaración se borra, el FK pasa a NULL pero el periodo sigue completado — el usuario decide si re-abrirlo manualmente. ### Robustez - El flujo "marcar manualmente desde /alertas" sigue funcionando para usuarios que no suben PDFs; la automatización es aditiva. - `IMPUESTO_A_OBLIGACION_KEYWORDS` tiene include+exclude para evitar falsos positivos (ej. subir declaración IVA marcaría "DIOT" si solo se usara include="iva"; el exclude "diot" previene eso). ### Schema - Migración **020** `obligacion_periodos` — tabla base con unique `(obligacion_id, periodo)`. - Migración **030** `obligacion_periodos_declaracion_id` — FK a `declaraciones_provisionales(id)` con `ON DELETE SET NULL` + índice parcial `WHERE declaracion_id IS NOT NULL`. ### Archivos - `apps/api/src/services/declaraciones.service.ts:completarObligacionesPorDeclaracion()` - `apps/api/src/migrations/tenant/020_obligacion_periodos.sql` - `apps/api/src/migrations/tenant/030_obligacion_periodos_declaracion_id.sql` ### Portabilidad a Horux 360 En Horux 360 (single-tenant), `contribuyente_id` no existe; todo aplica al tenant. La lógica es portable reemplazando `contribuyenteId` por el concepto equivalente (tenant-wide obligaciones/declaraciones). El schema de `obligacion_periodos` y la FK a declaraciones son directamente aplicables. --- ## 16. Drill-down por bucket respeta régimen + filtros E/07 ### Problema El endpoint `GET /cfdi/drilldown` aceptaba `bucket=ingresos|gastos|causado|acreditable` pero solo expandía a una cláusula plana `(EMIT I PUE OR EMIT P OR EMIT E PUE)` sin respetar: - Particionado por grupo de régimen (ingresos en Grupo 1/2/3 con reglas distintas — Grupo 2 usa nómina recibida, Grupo 3 incluye PPD). - Filtro E/07 (aplicado en dashboard: Grupo 1 ingresos, todos gastos, causado y acreditable). - Regímenes ignorados por el tenant (configurados en `/regimenes`). Consecuencia: un drill-down desde el KPI "Ingresos del mes" mostraba CFDIs que NO contribuyeron al KPI (ej. un I PPD de PF Empresarial no cuenta para Grupo 1, pero aparecía en el drill). Usuarios veían filas con total que no cuadraba al KPI del header. ### Solución `drillDown()` en `cfdi.controller.ts` ahora expande cada bucket aplicando la misma lógica que los cálculos del dashboard/impuestos: **`bucket=ingresos`** — unión de 3 grupos: ```sql ( -- Grupo 1 (PF Empresarial 606/612/621/625/626): type='EMITIDO' AND regimen_fiscal_emisor IN (g1) AND ( (I PUE) OR (P) OR (E PUE AND cfdi_tipo_relacion <> '07') ) ) OR ( -- Grupo 2 (Sueldos 605): nómina recibida type='RECIBIDO' AND tipo_comprobante='N' AND metodo_pago='PUE' AND regimen_fiscal_receptor='605' ) OR ( -- Grupo 3 (PM y otros): PUE+PPD, sin filtro E/07 por regla type='EMITIDO' AND regimen_fiscal_emisor IN (g3) AND ( (I PUE o PPD) OR (E PUE) ) ) ``` **`bucket=gastos`** — uniforme sobre todos los regímenes: ```sql type='RECIBIDO' AND ( (I PUE) OR (P) OR (E PUE AND cfdi_tipo_relacion <> '07') ) ``` **`bucket=causado`** (IVA): igual a gastos pero `type='EMITIDO'`. **`bucket=acreditable`** (IVA): igual a gastos con `type='RECIBIDO'`. ### Régimenes ignorados Se carga via `getRegimenesIgnoradosClaves(req.user.tenantId)` y se agrega al WHERE excluyendo el régimen del lado correspondiente: - Buckets con lado emisor (ingresos, causado): `regimen_fiscal_emisor NOT IN (...)`. - Buckets con lado receptor (gastos, acreditable): `regimen_fiscal_receptor NOT IN (...)`. - Ingresos mixed (3 grupos con lados distintos): expr CASE que usa el régimen del lado correcto por fila. ### Filtro TODOS_REGIMENES (añadido después de §16 inicial) Los buckets uniformes (gastos, causado, acreditable) también restringen el régimen del lado a `TODOS_REGIMENES` — el conjunto canónico del dashboard que excluye 616 (extranjero) y otros régimenes fuera del catálogo estándar. Sin este filtro, el drill incluía CFDIs con receptor=616 que el dashboard excluye vía el mismo filtro en `calcularEgresosPorRegimen`. Ingresos YA filtra implícitamente porque sus 3 grupos listan régimenes específicos (PF Empresarial, Sueldos 605, PM y otros). No necesita el filtro adicional. **Caso validador**: Husberto Ignacio Torres, Feb 2025. Antes del fix, dashboard $525,180.53 vs drill $525,740.86 (delta $560.33 = 1 pago con receptor=616 extranjero que el drill incluía). Post-fix: ambos $525,180.53. ### Exports reutilizados `dashboard.service.ts` ahora exporta `GRUPO_PF_EMPRESARIAL`, `GRUPO_SUELDOS`, `GRUPO_PM_OTROS`. Antes eran privados; se volvieron `export` para que el controller pueda reutilizarlos sin duplicar constantes — si un grupo cambia, queda un solo lugar donde actualizar. ### Consistencia con KPI Con este cambio, la suma de `total_mxn` de las filas del drill (considerando signo negativo para E y P computados como pagos) cuadra al total del KPI del header. El frontend ya aplicaba signo por tipo; ahora el set de filas es el correcto. ### Archivos - `apps/api/src/controllers/cfdi.controller.ts:drillDown()` — refactor completo del bloque de bucket. - `apps/api/src/services/dashboard.service.ts` — exports públicos de grupos de regímenes. --- ## 17. Cache de métricas: DELETE antes de CALCULAR (no antes de upsert) ### Problema inicial `computeMetricaMensual` solo hacía `upsertMetricaMensual` por régimen detectado en el mes. Si un cambio de fórmula dejaba un régimen sin datos (ej. aplicar filtro E/07 hace que un régimen X desaparezca del output), la fila vieja de ese régimen quedaba colgada en `metricas_mensuales` con valores pre-cambio. ### Primera iteración (INCORRECTA) Agregar `DELETE FROM metricas_mensuales WHERE ...` **antes del loop de upserts**. Parecía correcto pero **seguía dando valores stale** porque: 1. `computeMetricaMensual` llama `calcular{Ingresos,Egresos}PorRegimen` y `getResumenIva` al inicio para obtener los montos nuevos. 2. Esas funciones usan **read-through cache** vía `planCache()` — encuentran las filas viejas de `metricas_mensuales` y las devuelven como si fueran el cálculo fresco. 3. El código entonces hace `DELETE` → `INSERT` con los valores que acababa de leer del cache. 4. Resultado: el recompute efectivamente se copia a sí mismo — no hay refresh real. Cache se queda congelado en los valores que tuvo la primera vez que se computó. Síntoma real observado (Husberto Feb 2025): - Dashboard reportaba gastos $446,279.62 (valor viejo cacheado). - Cálculo on-the-fly fresco daba $525,180.53. - Delta $78,900.91 congelado en `metricas_mensuales` porque el recompute no podía salir del cache stale. ### Solución correcta Mover el DELETE al **principio** de `computeMetricaMensual`, **antes** de las queries `calcular*`. Así: 1. DELETE borra las filas del periodo. 2. `calcular*` ejecuta su read-through cache, no encuentra nada, cae al path on-the-fly y computa fresh desde `cfdis`. 3. `upsert` escribe los valores nuevos reales. ### Trade-offs Un mes con muchos régimenes (p. ej. 15) genera 15 INSERTs en cada recompute en vez de UPSERTs — irrelevante en performance (<100ms), y compensa con estado limpio garantizado. Sin tenant-level atomicity: si falla entre DELETE y INSERT, el mes queda sin cache hasta el próximo recompute (el dashboard cae al on-the-fly transparentemente). ### Archivos - `apps/api/src/services/metricas-compute.service.ts:computeMetricaMensual()` — DELETE movido al inicio de la función. ### Archivos - `apps/api/src/migrations/tenant/032_cfdis_relaciones.sql` — **nuevo** - `apps/api/src/services/sat/sat-parser.service.ts` — `CfdiParsed` interface + `extractCfdiRelacionados()` + wiring en `parseXml()`. - `apps/api/src/services/sat/sat.service.ts` — UPDATE y INSERT de `saveCfdis`. - `apps/api/src/services/cfdi.service.ts` — `CreateCfdiData` + `createCfdi()` + `CFDI_SELECT`. - `packages/shared/src/types/cfdi.ts` — `Cfdi` interface. - `apps/api/scripts/backfill-cfdis-relaciones.ts` — **nuevo** script idempotente. - `apps/api/src/services/dashboard.service.ts:calcularIngresosPorRegimen()` — query `g1NC` filtra `cfdi_tipo_relacion <> '07'`. - `apps/api/src/services/dashboard.service.ts:calcularEgresosPorRegimen()` — query `nc` filtra `cfdi_tipo_relacion <> '07'` uniforme. - `apps/api/src/services/dashboard.service.ts:calcularAdquisicionesMercancias()` — query `nc` filtra `cfdi_tipo_relacion <> '07'` uniforme. - `apps/api/src/services/dashboard.service.ts:calcularIvaBalancePorRegimen()` — s3, r3 filtran E/07; composición `causado = s1+s2-r3`, `acreditable = r1+r2-s3`. - `apps/api/src/services/impuestos.service.ts` — buckets `BUCKET_*_POS/NEG`, expresiones `SIGNED_*`, queries de `getIvaMensual` + `getResumenIva` + acumulados anuales reestructurados. - `apps/api/src/services/reportes.service.ts:getFlujoEfectivo()` — `entradasNC` y `salidasNC` filtran `cfdi_tipo_relacion <> '07'`. - `apps/api/src/services/reportes.service.ts:calcularFlujoPorMes()` — helper `q()` filtra E/07 condicional para `tc === 'E'`. - `apps/api/scripts/invalidate-metricas-all.ts` — **nuevo** invalida cache completo tras cambio de fórmula. - `apps/api/scripts/process-metricas-now.ts` — **nuevo** dispara recompute inmediato. --- ## 18. Fix zona horaria en parser SAT ### Problema El parser del SAT guardaba `fecha_emision` via `new Date(comprobante['@_Fecha'])`. El XML del SAT trae `Fecha="2025-12-31T18:37:51"` sin indicator de zona (hora local del contribuyente = México). Node interpreta según TZ del proceso: en CDMX (UTC-6) → UTC `"2026-01-01T00:37:51Z"`. Postgres guarda el UTC. Consecuencia: CFDIs emitidos después de las 18:00 hora México quedan en el día siguiente UTC. Si era fin de mes o fin de año, el CFDI cae fuera del periodo correcto. Caso real: factura de Carlos del 31-dic-2025 18:37 guardada como 2026-01-01 00:37 → desaparecía del año 2025. Fix descubierto cuando user reportó que ingresos 2025 tenían 3 meses en $0 (jun/sep/nov) y el total estaba $219k abajo de lo esperado. ### Solución — helper `parseCfdiDate()` + backfill Parser agrega helper que fuerza 'Z' si el string no trae TZ: ```ts function parseCfdiDate(str) { const hasTz = /[Zz]|[+-]\d{2}:?\d{2}$/.test(str); return new Date(hasTz ? str : str + 'Z'); } ``` Todos los `new Date(...)` del parser migrados al helper: - `fechaEmision` (XML) - `fechaCertSat` (XML — timbre fiscal) - Metadata CSV: `fechaEmision`, `fechaCertSat`, `fechaCancelacion`. **Backfill** (`scripts/backfill-fechas-tz.ts`): re-parsea `fecha_emision` y `fecha_cert_sat` desde `xml_original` con regex sobre los atributos `Fecha=""` y `FechaTimbrado=""` del XML. En el fork se aplicó a 10,658 CFDIs — **todos** tenían la fecha desfasada. ### Alcance del problema El bug afecta **solo CFDIs emitidos entre 18:00-23:59 hora México** (porque MX UTC-6 → esas horas caen en "día siguiente" UTC). De esos, los que cruzan mes o año caen en el bucket equivocado. En la práctica ~25% de los CFDIs tienen la fecha corrida un día, pero solo ~0.1-1% cambian de mes/año. Para detección: un CFDI con `fecha_emision` entre las 00:00-05:59 UTC (que son 18:00-23:59 hora México) es candidato a tener este corrimiento. El fix del parser los guarda literal sin TZ conversion. ### Validación Ingresos 2025 de Carlos (fork) antes del fix: $335,905.04. Post-fix: $554,905.56 (+$219k). 3 meses que reportaban $0 ahora muestran valores. ### Archivos - `apps/api/src/services/sat/sat-parser.service.ts` — helper `parseCfdiDate()` + 4 usos migrados (XML + CSV metadata). - `apps/api/scripts/backfill-fechas-tz.ts` — **nuevo** script idempotente con dry-run. ### Cache Requiere invalidate + recompute post-backfill porque varios CFDIs cambian de mes → reasignación de períodos en `metricas_mensuales`. --- ## 19. Pivote a Método A en buckets económicos (ingresos G1, gastos, adquisiciones) ### Problema La compensación con `NETO_CUSTOM` + clamp `GREATEST(0, ...)` (§13b) es robusta ante E/07 ausentes pero **subcuenta** cuando un anticipo es referenciado por múltiples I/07. Cada I/07 resta el anticipo completo, así que con 3 I/07 referenciando el mismo anticipo, este se "consume" 3 veces. Ejemplo: anticipo $200, 3 I/07 de $100 c/u, 3 E/07 de $60+$100+$40: - Real: $200 + $300 servicios − $200 cancelados = **$300** - Compensación: $200 + max(0, $100−$200)×3 + 0 = **$200** (subcuenta $100) - Método A: $200 + $300 − $200 = **$300** (correcto) ### Solución: Método A ingenuo Sumar todas las I PUE completas (incluyendo I/07) y restar todas las E PUE completas (incluyendo E/07). La cancelación algebraica `anticipo + I/07 − E/07` queda neta cuando los tres CFDIs están en el universo de la query. ### Cambios SQL En `dashboard.service.ts`: - `calcularIngresosPorRegimen` Grupo 1 facturas: `SUM(total − IMP_TRAS − EXCL_MONTO)` (sin CASE). - `calcularIngresosPorRegimen` Grupo 1 NC: sin filtro `AND cfdi_tipo_relacion <> '07'`. - `calcularEgresosPorRegimen` facturas + nc: mismo cambio. - `calcularAdquisicionesMercancias` facturas + nc: mismo cambio (más filtro `uso_cfdi='G01'`). En `cfdi.controller.ts`: - Bucket `ingresos` Grupo 1: removido `AND ${E_NO_ANTICIPO}`. - Bucket `gastos`: removido `AND ${E_NO_ANTICIPO}`. ### Trade-off Método A pierde la robustez ante E/07 ausentes (si el emisor olvida emitir la E/07, la I/07 cuenta completa = doble-cuenta del anticipo). Compensar: implementar la **alerta de TipoRelacion sospechoso** (ver session plan §14) que detecta E/07s faltantes vía análisis del grafo de `cfdis_relacionados`. ### Buckets que NO migran - **Saldos CxP/CxC** (`utils/saldo.ts`): conserva la exclusión E/07 y la resta del anticipo en I/07 — semántica per-factura. - **IVA causado/acreditable** (`impuestos.service.ts`): conserva la compensación con `NETO_CUSTOM` por ahora. ### Recompute necesario Invalidar `metricas_mensuales` con razones `FORMULA_CHANGE_METODO_A_INGRESOS_G1` y luego `METODO_A_GASTOS_Y_ADQUISICIONES`. 212 filas → 392 escritas en cada uno. --- ## 20. Clamp defensivo del IVA en complementos P ### Problema Algunos proveedores emiten complementos de pago (P) con `iva_traslado_pago_mxn` basado en el IVA de la factura I PPD completa en vez del proporcional al pago parcial. Caso real: una I PPD de $218k con IVA total $30,076; el cliente paga parcialmente $43,611 y el proveedor emite el P con `iva_traslado_pago_mxn=$30,076` cuando lo proporcional al pago sería $6,017 ($43,611 × 16% / 1.16). El IVA inflado bajaba el ingreso neto del receptor en ~$24k. ### Solución Clampar el IVA reportado al máximo legal posible (`monto_pago_mxn × 0.16`). PostgreSQL `LEAST` retorna el menor de dos valores. Como el SAT no permite tasa de IVA mayor al 16%, el cap es matemáticamente defensible incluso para tasas legítimas más bajas (0%, 8% frontera) porque el IVA real estará bajo el cap. ### Helpers actualizados `dashboard.service.ts`: ```sql IVA_TRAS_PAGO_CLAMPED = LEAST(COALESCE(iva_traslado_pago_mxn, 0), COALESCE(monto_pago_mxn, 0) * 0.16) IVA_RET_PAGO_CLAMPED = LEAST(COALESCE(iva_retencion_pago_mxn, 0), COALESCE(monto_pago_mxn, 0) * 0.16) IMP_TRAS_PAGO = IVA_TRAS_PAGO_CLAMPED + COALESCE(ieps_traslado_pago_mxn, 0) IVA_NETO_PAGO = IVA_TRAS_PAGO_CLAMPED − IVA_RET_PAGO_CLAMPED ``` `impuestos.service.ts`: `IVA_TRAS_EXPR`, `IVA_RET_EXPR` y las versiones `_ALIAS(alias)` aplican el mismo clamp inline en la rama `WHEN tipo_comprobante='P'`. ### Lo que NO se clampa - **IEPS**: NO (rates SAT van hasta 53%). - **Impuestos en CFDIs no-P** (I, E, N): NO — usan los campos base `iva_traslado_mxn` que ya vienen calculados al nivel del comprobante. ### Validación Horux 360 may-2025: $45,003 → $68,102 (+$23,099, exacto con la corrección del único P afectado). ### Recompute necesario Invalidar con razón `CLAMP_IVA_P_GLOBAL`. --- ## 21. Helper `determinarFormulaBaseGravable` (fix histórico ISR) ### Problema La tabla "Histórico ISR" en `/impuestos` mostraba `base = ingresos` para contribuyentes RESICO PM (régimen 626) en vez de `max(0, ingresos − deducciones)`. La causa: lógica duplicada entre `calcularResumenIsr` (KPI del periodo) y `getIsrMensual` (histórico): - `calcularResumenIsr` distinguía PM/PF en régimen 626 vía `rfcLength`. - `getIsrMensual` solo verificaba `REGIMENES_RESTA_DEDUCCIONES = ['606', '612']`. El 626 no estaba ahí → `formula = 'ingresos'` siempre. ### Solución Helper exportado `determinarFormulaBaseGravable(clave, rfcLength)` con la regla canónica: ```ts export function determinarFormulaBaseGravable( clave: string, rfcLength: number, ): 'ingresos-deducciones' | 'ingresos' { if (REGIMENES_RESTA_DEDUCCIONES.includes(clave)) return 'ingresos-deducciones'; if (clave === '626' && rfcLength === 12) return 'ingresos-deducciones'; return 'ingresos'; } ``` Usado en ambas funciones — single source of truth. ### Cambios adicionales en `getIsrMensual` - Resuelve `rfcLength` vía `resolveContribuyenteContext` al inicio. - El branch "sin régimen filtrado" ahora itera por régimen y aplica la fórmula correcta a cada uno antes de sumar — antes hacía `ing − ded` global lo cual falla con multi-régimen mixto (ej. PM con 626+601 que tienen fórmulas distintas). ### Sin recompute La base gravable se deriva en memoria desde `calcular{Ingresos,Egresos}PorRegimen` (que sí están cached). El fix toma efecto inmediato sin tocar `metricas_mensuales`. ### Archivos - `apps/api/src/services/impuestos.service.ts` — helper + 2 usages. --- ## 22. Facturapi save post-emit usando `parseXml` ### Problema Las facturas Facturapi guardadas en BD del tenant tenían campos del emisor vacíos: `rfc_emisor=''`, `nombre_emisor=''`, `regimen_fiscal_emisor=NULL`, `subtotal=0`, `iva_traslado=0` y `xml_original=NULL`. El timbrado en Facturapi y el SAT estaba correcto — el bug era solo en el guardado local. ### Causa raíz El response del SDK `client.invoices.create` no incluye `issuer`, `subtotal` ni `taxes` como campos top-level. El emisor vive en `invoice.issuer_info` y los impuestos viven dentro de cada `items[*].product.taxes`. El receptor sí funcionaba porque sigue siendo `invoice.customer.tax_id`. ### Solución Tras la emisión, descargar el XML real timbrado y reutilizar el mismo parser que procesa CFDIs del SAT (`parseXml` de `sat-parser.service.ts`). Los datos provienen del XML sellado, garantizando consistencia con CFDIs descargados via sync SAT. ```ts const xmlBuffer = await client.invoices.downloadXml(invoice.id); const xmlString = xmlBuffer.toString('utf-8'); const parsed = parseXml(xmlString, 'emitidos'); // Upsert RFCs y INSERT cfdis con parsed.{rfcEmisor, subtotal, ivaTraslado, ...} y xml_original = xmlString ``` ### Backfill Script one-shot que descarga el XML y reaplica la fórmula sobre filas existentes con `source='facturapi'` y campos vacíos. ### Beneficios adicionales - Se almacena el `xml_original` (antes vacío en CFDIs Facturapi). - Se populan `regimen_fiscal_emisor` y `regimen_fiscal_receptor` que destrababa el matching de KPIs por régimen. ### Portabilidad a Horux 360 Aplicación 1:1. La diferencia es que Horux 360 emite via la org del tenant, no de un contribuyente — usa `facturapiService.downloadXml` en vez del helper `downloadXmlContribuyente`. ### Archivos - `apps/api/src/controllers/facturacion.controller.ts` — refactor INSERT post-emit. - Script de backfill one-shot. --- ## Orden sugerido de implementación en Horux 360 1. **Fecha efectiva (§1)** — mayor valor, riesgo bajo, cambios aislados a queries. 2. **Deduplicación UUID (§2)** — migraciones + sat.service.ts. Cleanup primero, luego constraint. 3. **Timbres refund (§3)** — cambio localizado en controlador. 4. **Drill-down (§6)** — apoyo para UI. 5. **Refactor getResumenIva (§5)** — antes del sistema hot/cold para validar que la fórmula es correcta sin cache por el camino. 6. **Refactor getIvaMensual (§11)** — mismo alcance que §5, misma fórmula. 7. **Script de validación (§7)** — instalar para cubrir los refactors anteriores. 8. **Sistema hot/cold métricas (§4)** — una vez validada la fórmula on-the-fly. Seguir el orden: schema → compute service → upsert/read (¡con las columnas de retención desde el día 1!) → backfill → read-through en dashboard → read-through en impuestos → cron de invalidaciones. 9. **Watchdog stale jobs (§8)** — CLI primero, wiring como cron después. 10. **Crons en dev (§9)** — flag `ENABLE_CRONS_IN_DEV` para validar el watchdog. 11. **Logging de rejections SAT (§10)** — cambio chico, diagnóstico grande. 12. **CfdiRelacionados (§12)** — migración + parser + SQL. Corre el backfill (opción a) tras aplicar la migración para no dejar huecos en el histórico. 13. **Saldo real CxP/CxC (§13)** — helper SQL on-the-fly. Independiente del §12 pero lo aprovecha (lee `cfdis_relacionados`, `cfdi_tipo_relacion`). 14. **Obligaciones desde CSF (§14)** — requiere CSF descargable y tabla `constancias_situacion_fiscal` con el JSONB `datos`. Fallback al catálogo si no hay CSF preserva el comportamiento previo. 15. **Declaraciones↔obligaciones↔alertas (§15)** — migraciones 020 + 030, lógica en `completarObligacionesPorDeclaracion()`. Aditivo respecto al flujo manual de alertas. 16. **Drill-down consistente con KPIs (§16)** — refactor de `drillDown()` con reglas por bucket. Depende de §1, §6 y §12 (buckets definidos). Requiere exponer grupos de régimen como `export`. 17. **Cache DELETE antes de CALCULAR (§17)** — cambio de posición crítico: el DELETE tiene que estar antes de las queries `calcular*`, no antes de los upserts. Si está en la posición equivocada, el read-through cache secuestra el recompute y los valores se quedan congelados. 18. **Fix TZ parser SAT (§18)** — helper `parseCfdiDate` + backfill de fechas desde XML. El backfill es crítico — sin él, los CFDIs ya guardados siguen desfasados. Aplica a cualquier implementación del parser SAT, portable 1:1. 19. **Pivote a Método A (§19)** — quitar compensación I/07 + clamp y exclusión E/07 en buckets económicos. Acompañar con la alerta de TipoRelacion sospechoso (session plan §14) para detectar E/07 faltantes. Recompute `metricas_mensuales` requerido. 20. **Clamp IVA P (§20)** — cambio quirúrgico en helpers `IMP_TRAS_PAGO`/`IVA_TRAS_EXPR`. Recompute requerido. Útil independientemente del Método A (afecta cualquier P con IVA inflado). 21. **Helper base gravable (§21)** — refactor para single source of truth entre KPI ISR del periodo y histórico mensual. Sin recompute. 22. **Facturapi save con parseXml (§22)** — refactor del INSERT post-emit + backfill de filas existentes con `source='facturapi'`. En cada paso: correr `pnpm typecheck` y el validador §7. El objetivo es que cada cambio sea céntimo-por-céntimo idéntico al estado previo (o mejor, si era bug).