# 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 (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 `<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).
