Files
HoruxDespachosNuevo/docs/Horux_despachos-vs-Horux360.md

76 KiB
Raw Blame History

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_idtenant_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_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:

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.sqlnuevo
  • apps/api/src/migrations/tenant/027_cfdi_uuid_unique_case_insensitive.sqlnuevo

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.tsnuevo

  • 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.tsnuevo

  • 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.tsnuevo

  • 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.tsnuevo

  • 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_cobradopor régimen (ver sección 5 sobre por qué restaR`).
  • calcularIngresosPorRegimen, calcularEgresosPorRegimen, calcularIvaBalancePorRegimen integran 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. 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:
    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 (TAR) 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 TAR.
  • apps/api/scripts/validate-dashboard-impuestos.tsnuevo: 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.tsnuevo. 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 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 <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

  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

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 — 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):

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.tsnuevo 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.tsnuevo script idempotente con dry-run.
  • apps/api/scripts/inspect-cfdi.tsnuevo debug (CFDI + pagos + Es).
  • apps/api/scripts/check-saldo.tsnuevo 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-<id>-<periodo> 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-<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 /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-<obligacion_id>-<periodo> 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:

(
  -- 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:

  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 DELETEINSERT 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.sqlnuevo
  • apps/api/src/services/sat/sat-parser.service.tsCfdiParsed interface + extractCfdiRelacionados() + wiring en parseXml().
  • apps/api/src/services/sat/sat.service.ts — UPDATE y INSERT de saveCfdis.
  • apps/api/src/services/cfdi.service.tsCreateCfdiData + createCfdi() + CFDI_SELECT.
  • packages/shared/src/types/cfdi.tsCfdi interface.
  • apps/api/scripts/backfill-cfdis-relaciones.tsnuevo 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.tsnuevo invalida cache completo tras cambio de fórmula.
  • apps/api/scripts/process-metricas-now.tsnuevo 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 — helper parseCfdiDate() + 4 usos migrados (XML + CSV metadata).
  • apps/api/scripts/backfill-fechas-tz.tsnuevo 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:

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:

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.

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).