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

1683 lines
76 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
`<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:
```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<MetricaMensual>`, pero la SQL nunca escribía esas
columnas. Resultado: `iva_retenido_cobrado` siempre quedaba en 0 y todos
los cálculos que dependían de él devolvían mal. TypeScript no lo detecta
porque `Partial<>` permite keys faltantes. Auditar cualquier función que
use lista de columnas hand-written contra el tipo que recibe.
### 4.5 Cron de invalidaciones
`apps/api/src/jobs/metricas-invalidations.job.ts`**nuevo**
- Cron cada 15 min con anti-overlap (lock en memoria: `let running = false`).
- Llama `processAllTenantsInvalidations`.
- En single-tenant (Horux 360) simplificar a `processInvalidations(pool)` para
el único tenant.
### 4.6 Puntos de invalidación
Cada path que modifica CFDIs debe llamar `markForInvalidation(pool, anio, mes,
reason)`:
- `cfdi.service.ts`: upload manual XML (líneas ~455, ~521 del fork).
- `sat.service.ts`: `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 (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.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 `<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
```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) => `<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):
```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-<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:
```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).