76 KiB
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 desdexml_originalaplicado (1,168 CFDIs actualizados en 2 tenants).
Saldos
- §13 — Saldo real en CxP/CxC usando
saldo_pendiente_mxndenormalizado: fórmula centralizada enutils/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:
- Exclusión binaria en ingresos G1 + gastos + adquisiciones.
- Cambio a compensación con
NETO_CUSTOM(total − traslados + retenciones) contra el anticipo relacionado. - 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 mostrababase = ingresosen el histórico. - §22 — Facturapi save post-emit usa
parseXmldel 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_idenobligacion_periodos). - §16 — Drill-down respeta régimen +
TODOS_REGIMENES+ filtros E/07 (alineado con KPIs). ExportaGRUPO_PF_EMPRESARIAL,GRUPO_SUELDOS,GRUPO_PM_OTROScomo exports públicos. - §17 — Cache de métricas:
DELETE FROM metricas_mensuales WHERE (contrib, año, mes)ANTES de las queriescalcular*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()ensat-parser.service.tsfuerza 'Z' cuando falta TZ indicator para preservar hora literal del XML. - Backfill: re-parseó
fecha_emision+fecha_cert_satdesdexml_originalpara 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
<PeriodSelector />de shared-ui): portable 1:1. - Banner CSD recién tramitado: requiere columna
facturapi_orgs.last_lco_rejection_ato equivalente entenantspara 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— inspeccionametricas_mensualesde 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)
- Post-§13 (saldo): no requiere (hook al insertar).
- Post-§13b iter 1 (exclusión E/07 ingresos G1): 138+74=212 invalidaciones.
- Post-§13b iter 2 (exclusión gastos + adquisiciones): 212.
- Post-§13b iter 3 (compensación NETO_CUSTOM G1): 212.
- Post-§13b iter 4 (compensación NETO_CUSTOM gastos): 212.
- Post-clamp GREATEST(0, ...): 212.
- 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:
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: helpersFECHA_PAGO_RANGO,getFechaPagoRango(). 4 queries de P encalcularIngresosPorRegimenycalcularEgresosPorRegimenusan${FR_PAGO}.apps/api/src/services/impuestos.service.ts: constantesFECHA_EFECTIVA,FECHA_RANGO,FECHA_RANGO_CONCILIACION,getFR().EXTRACTengetIvaMensualusaFECHA_EFECTIVA.getIsrMensualreescrito 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_PAGOycalcularFlujoPorMes.q()branchea por tipo.apps/api/src/controllers/cfdi.controller.ts: el drill-down de CFDIs (GET /cfdi/drilldown) ahora usa FECHA_EFECTIVA y aceptatipoComprobantecomo CSV (ej."I,P"). El filtrometodoPagosolo 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
- Normalizar al insertar:
sat.service.tshaceuuid.toLowerCase()ensaveCfdisysaveMetadataantes del INSERT. - Lookups case-insensitive: el WHERE del UPSERT busca con
LOWER(uuid) = $1. - Cleanup de históricos: migración
026_normalize_cfdi_uuid_case.sqlbaja todos los UUIDs existentes a minúsculas y deja un solo representante por grupo. - Constraint preventivo: migración
027_cfdi_uuid_unique_case_insensitive.sqlagregaCREATE UNIQUE INDEX ix_cfdis_uuid_ci ON cfdis (LOWER(uuid)).
Archivos
apps/api/src/services/sat/sat.service.ts:saveCfdisysaveMetadata.apps/api/src/migrations/tenant/026_normalize_cfdi_uuid_case.sql— nuevoapps/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: ramacatchen el handler dePOST /facturacion/emitirllama arefundTimbre(tenantId).apps/api/src/services/timbre.service.ts(o equivalente): funciónrefundTimbreque revierte el decremento. Si el consumo vino del pool mensual, el refund devuelve al pool; si vino de un paquete, devuelve al paquete con menorexpiraEn.
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 quitandocontribuyente_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úsacalcularIngresosPorRegimen,calcularEgresosPorRegimen,getResumenIvapara poblar todas las columnas agregadas por régimen.backfillTenant(tenantId, opts): itera años × meses y llena histórico.processInvalidations(pool, tenantId): leemetricas_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<MetricaMensual>, 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:saveCfdisysaveMetadatatras 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
CacheRangeinterface.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). Retornanullsi no califica → caller usa on-the-fly.- Respeta
METRICAS_BYPASS_CACHE=1del env (útil para validación).
Dashboard (dashboard.service.ts):
readIngresosFromCache: sumaingresos_cobradospor régimen.readEgresosFromCache: sumaegresos_pagadospor régimen.readIvaBalanceFromCache: suma `iva_trasladado_total - iva_acreditable- iva_retenido_cobrado
por régimen (ver sección 5 sobre por qué restaR`).
- iva_retenido_cobrado
calcularIngresosPorRegimen,calcularEgresosPorRegimen,calcularIvaBalancePorRegimenintegran el read-through al inicio: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. ElacumuladoAnualqueda 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— comparadashboard.balancevsimpuestos.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:
- 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 retornaregimen_fiscal_emisoro_receptorsegúntype.BUCKET_CAUSADO:(EMIT I PUE) OR (EMIT P) OR (RECIB E PUE).BUCKET_ACREDITABLE:(RECIB I PUE) OR (RECIB P) OR (EMIT E PUE).
- Dos queries agregadas (una por lado), cada una devuelve trasladado +
retención por régimen en una pasada:
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 - Filtro
TODOS_REGIMENES: excluye 616 (extranjero) y similares. Dashboard lo hace; impuestos también debe. - 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:getResumenIvacompleto,readResumenIvaFromCache, constantes elevadas.apps/api/src/services/dashboard.service.ts:readIvaBalanceFromCachecon fórmula T−A−R.apps/api/scripts/validate-dashboard-impuestos.ts— nuevo: comparadashboard.balancevsimpuestos.resultadoen 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).
tipoComprobanteacepta CSV:"I,P"se traduce aWHERE tipo_comprobante = ANY($n).metodoPagose filtra solo cuandotipo_comprobante != 'P'(los P no tienenmetodo_pagosignificativo).
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().totalvsgetResumenIva().resultado. - Soporta
METRICAS_BYPASS_CACHE=1para 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:
pendingconnextRetryAtvencido → nadie los retoma → bloquean el lock para nuevos syncs.runningconstartedAtmuy 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.
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.
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 SATentryId— etiqueta delStatusRequest(Rejected,Failure, etc.)value— valor numérico delStatusRequestmsg— 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 degetIvaMensual.
12. Persistir CfdiRelacionados (TipoRelacion + UUIDs)
Problema
El nodo <cfdi:CfdiRelacionados TipoRelacion="07"><cfdi:CfdiRelacionado UUID="…"/>
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
-
Migración
032_cfdis_relaciones.sql— agrega a la tablacfdis: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).
-
Parser (
sat-parser.service.ts) — funciónextractCfdiRelacionados()recorre los nodosCfdiRelacionadosy concatena losUUIDde todos losCfdiRelacionadohijos. Captura el primerTipoRelacionencontrado (raro ver >1 en la práctica, pero el schema SAT lo permite). Wired enparseXml()→cfdiTipoRelacion+cfdisRelacionados. -
SQL UPSERT (
sat.service.ts:saveCfdis) — UPDATE y INSERT incluyen las 2 columnas nuevas. Posiciones renumeradas (sat_sync_job_idpasa de $83 a $85). -
Manual upload path (
cfdi.service.ts:createCfdi) —CreateCfdiDataañadecfdiTipoRelacion?+cfdisRelacionados?opcionales. INSERT los guarda.CFDI_SELECTlos surface comocfdiTipoRelacion+cfdisRelacionados. -
Shared type (
packages/shared/src/types/cfdi.ts) —Cfdiinterface incluyecfdiTipoRelacion: string | nullycfdisRelacionados: 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_originalde cada CFDI consource='sat'— rápido, no requiere volver al SAT. Script:apps/api/scripts/backfill-cfdis-relaciones.ts— iteraWHERE xml_original IS NOT NULL AND cfdi_tipo_relacion IS NULL, correparseXml()y UPDATE solo si extraecfdiTipoRelacionno-nulo. Soporta--drypara 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 desat-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— disparaprocessAllTenantsInvalidations()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()— queryncagregaAND COALESCE(cfdi_tipo_relacion, '') <> '07'.calcularAdquisicionesMercancias()— queryncagrega el mismo filtro (adquisiciones es subset de gastos poruso_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 agreganAND COALESCE(cfdi_tipo_relacion,'') <> '07'. Composición cambia acausado = 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_NEGya incluyen el filtro E/07.- 4 queries actualizadas (2 en
getIvaMensual, 2 engetResumenIva) + 2 acumulados anuales (1 enreadResumenIvaFromCache, 1 engetResumenIva) 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()— queriesentradasNC(EMIT E PUE) ysalidasNC(RECIB E PUE) agreganAND COALESCE(cfdi_tipo_relacion,'') <> '07'.apps/api/src/services/reportes.service.ts:calcularFlujoPorMes()— helperq()inyecta el filtro condicionalmente cuandotc === '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_*vsgetFlujoEfectivo(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_mxncrudo (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
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) => `<subquery con alias>`;
Archivos
apps/api/src/services/dashboard.service.ts— helpersNETO_CUSTOMyEXCL_MONTO_ALIAS(globales, reutilizables).apps/api/src/services/dashboard.service.ts:calcularIngresosPorRegimen()— queryg1Facturascon CASE WHEN compensando I/07 del Grupo 1.apps/api/src/services/dashboard.service.ts:calcularEgresosPorRegimen()— queryfacturascon CASE WHEN compensando I/07 recibidas (uniforme).apps/api/src/services/dashboard.service.ts:calcularAdquisicionesMercancias()— queryfacturascon mismo CASE WHEN.apps/api/src/controllers/cfdi.controller.ts:drillDown()— bucket=gastos e ingresos Grupo 1 listan I/07 contotal_mxncrudo (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:
- Excluir I/07 de gastos uniforme → recomputed.
- Excluir I/07 de ingresos Grupo 1 → recomputed.
- Compensar I/07 en ingresos Grupo 1 con NETO_CUSTOM → recomputed.
- 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:
- El listado de CFDIs (
/cfdi) también puede aprovechar el campo sin replicar la subquery. - Queries de CxP/CxC quedan simples (
SUM(saldo_pendiente_mxn)). - 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):
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 correrecomputarSaldoPendienteagregado (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_mxnde complementos P que referencien el UUID (puede matchear múltiples si el P tiene multi-docto pipe-separated). - Status
Canceladoo0se 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_pagototal 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, leensaldo_pendiente_mxndirecto.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:
- Lee
datos->'obligaciones'del último CSF activo (constancias_situacion_fiscaldel tenant, ordenado porcreated_at DESC). - Si el CSF existe y tiene obligaciones: filtra las activas (sin
fechaFin— el SAT publica obligaciones terminadas con fecha fin) e inserta cada una enobligaciones_contribuyentecon flages_recomendada=true. - Enriquece cada obligación con keywords contra
OBLIGACIONES_CATALOGOvíamatchCsfToCatalog(descripcion, rfc)para heredarfundamento,categoriayfrecuenciadel catálogo cuando hay match. - Si no hay match de catálogo, infiere frecuencia con
inferirFrecuencia(descripcionVencimiento)(keywords: "mensual", "bimest", "trimest", "anual", "ejercicio"). - 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:
- Borra alertas
ob-<id>-<periodo>de las obligaciones anteriores (SUBSTRING del tipo para extraer UUID). - Borra
obligacion_periodosde las obligaciones anteriores (ON DELETE CASCADE hace que sea redundante, pero explícito por claridad). - Borra
obligaciones_contribuyentedondees_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-<obligacion_id>-<periodo>
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-<obligacion_id>-<periodo>seguían activas. - El usuario tenía que ir a
/alertasy 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:
- Lee todas las obligaciones activas del contribuyente.
- 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). - 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.frecuenciaestá definida y difiere dedeclaracion.periodicidad, skip. Laseventualnunca se tocan. - Inserta/upserta en
obligacion_periodoscon:completada = truecompletada_por= UUID del user que subiódeclaracion_id= FK a la declaración (migración 030 agregó el campo).
- Marca como resueltas las alertas
ob-<obligacion_id>-<periodo>del set afectado.
Comportamiento según tipo y cubrePago
tipo='normal'sin monto: solo cubre alertasob-*(nopago-*).tipo='complementaria': además deob-*, resuelve laspago-*del mismo mes (una complementaria sustituye el pago de la normal).- Al subir comprobante de pago después via
uploadComprobantePago, resuelve laspago-*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_KEYWORDStiene 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 adeclaraciones_provisionales(id)conON DELETE SET NULL+ índice parcialWHERE declaracion_id IS NOT NULL.
Archivos
apps/api/src/services/declaraciones.service.ts:completarObligacionesPorDeclaracion()apps/api/src/migrations/tenant/020_obligacion_periodos.sqlapps/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:
(
-- 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:
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:
computeMetricaMensualllamacalcular{Ingresos,Egresos}PorRegimenygetResumenIvaal inicio para obtener los montos nuevos.- Esas funciones usan read-through cache vía
planCache()— encuentran las filas viejas demetricas_mensualesy las devuelven como si fueran el cálculo fresco. - El código entonces hace
DELETE→INSERTcon los valores que acababa de leer del cache. - 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_mensualesporque 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í:
- DELETE borra las filas del periodo.
calcular*ejecuta su read-through cache, no encuentra nada, cae al path on-the-fly y computa fresh desdecfdis.upsertescribe 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— nuevoapps/api/src/services/sat/sat-parser.service.ts—CfdiParsedinterface +extractCfdiRelacionados()+ wiring enparseXml().apps/api/src/services/sat/sat.service.ts— UPDATE y INSERT desaveCfdis.apps/api/src/services/cfdi.service.ts—CreateCfdiData+createCfdi()+CFDI_SELECT.packages/shared/src/types/cfdi.ts—Cfdiinterface.apps/api/scripts/backfill-cfdis-relaciones.ts— nuevo script idempotente.apps/api/src/services/dashboard.service.ts:calcularIngresosPorRegimen()— queryg1NCfiltracfdi_tipo_relacion <> '07'.apps/api/src/services/dashboard.service.ts:calcularEgresosPorRegimen()— queryncfiltracfdi_tipo_relacion <> '07'uniforme.apps/api/src/services/dashboard.service.ts:calcularAdquisicionesMercancias()— queryncfiltracfdi_tipo_relacion <> '07'uniforme.apps/api/src/services/dashboard.service.ts:calcularIvaBalancePorRegimen()— s3, r3 filtran E/07; composicióncausado = s1+s2-r3,acreditable = r1+r2-s3.apps/api/src/services/impuestos.service.ts— bucketsBUCKET_*_POS/NEG, expresionesSIGNED_*, queries degetIvaMensual+getResumenIva+ acumulados anuales reestructurados.apps/api/src/services/reportes.service.ts:getFlujoEfectivo()—entradasNCysalidasNCfiltrancfdi_tipo_relacion <> '07'.apps/api/src/services/reportes.service.ts:calcularFlujoPorMes()— helperq()filtra E/07 condicional paratc === '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:
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— helperparseCfdiDate()+ 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:
calcularIngresosPorRegimenGrupo 1 facturas:SUM(total − IMP_TRAS − EXCL_MONTO)(sin CASE).calcularIngresosPorRegimenGrupo 1 NC: sin filtroAND cfdi_tipo_relacion <> '07'.calcularEgresosPorRegimenfacturas + nc: mismo cambio.calcularAdquisicionesMercanciasfacturas + nc: mismo cambio (más filtrouso_cfdi='G01').
En cfdi.controller.ts:
- Bucket
ingresosGrupo 1: removidoAND ${E_NO_ANTICIPO}. - Bucket
gastos: removidoAND ${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 conNETO_CUSTOMpor 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:
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_mxnque 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):
calcularResumenIsrdistinguía PM/PF en régimen 626 víarfcLength.getIsrMensualsolo verificabaREGIMENES_RESTA_DEDUCCIONES = ['606', '612']. El 626 no estaba ahí →formula = 'ingresos'siempre.
Solución
Helper exportado determinarFormulaBaseGravable(clave, rfcLength) con la
regla canónica:
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
rfcLengthvíaresolveContribuyenteContextal 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 − dedglobal 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.
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_emisoryregimen_fiscal_receptorque 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
- Fecha efectiva (§1) — mayor valor, riesgo bajo, cambios aislados a queries.
- Deduplicación UUID (§2) — migraciones + sat.service.ts. Cleanup primero, luego constraint.
- Timbres refund (§3) — cambio localizado en controlador.
- Drill-down (§6) — apoyo para UI.
- Refactor getResumenIva (§5) — antes del sistema hot/cold para validar que la fórmula es correcta sin cache por el camino.
- Refactor getIvaMensual (§11) — mismo alcance que §5, misma fórmula.
- Script de validación (§7) — instalar para cubrir los refactors anteriores.
- 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.
- Watchdog stale jobs (§8) — CLI primero, wiring como cron después.
- Crons en dev (§9) — flag
ENABLE_CRONS_IN_DEVpara validar el watchdog. - Logging de rejections SAT (§10) — cambio chico, diagnóstico grande.
- 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.
- Saldo real CxP/CxC (§13) — helper SQL on-the-fly. Independiente del §12
pero lo aprovecha (lee
cfdis_relacionados,cfdi_tipo_relacion). - Obligaciones desde CSF (§14) — requiere CSF descargable y tabla
constancias_situacion_fiscalcon el JSONBdatos. Fallback al catálogo si no hay CSF preserva el comportamiento previo. - Declaraciones↔obligaciones↔alertas (§15) — migraciones 020 + 030,
lógica en
completarObligacionesPorDeclaracion(). Aditivo respecto al flujo manual de alertas. - 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 comoexport. - 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. - 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. - 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_mensualesrequerido. - 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). - Helper base gravable (§21) — refactor para single source of truth entre KPI ISR del periodo y histórico mensual. Sin recompute.
- Facturapi save con parseXml (§22) — refactor del INSERT post-emit
- backfill de filas existentes con
source='facturapi'.
- backfill de filas existentes con
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).