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