1683 lines
76 KiB
Markdown
1683 lines
76 KiB
Markdown
# 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).
|