Files
HoruxDespachos/docs/plans/2026-04-24-session-fixes-and-features.md
2026-04-27 22:09:36 -06:00

862 lines
36 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Sesión 2026-04-24 — Fixes y features
Cambios hechos en `Horux_despacho` durante la sesión del 23-24 de abril 2026.
Cubre tanto lógica fiscal como tema específico del fork (multi-contribuyente,
Facturapi, filtro por RFC).
Los cambios **portables a Horux 360** están también en
`docs/Horux_despachos-vs-Horux360.md` (§12-§17 y §19 parcial). Este doc
consolida **todo** lo del día, incluyendo fork-específicos.
---
## Índice
1. [Storage de CfdiRelacionados (CFDI 4.0)](#1-storage-de-cfdirelacionados-cfdi-40)
2. [Saldo real en CxP/CxC](#2-saldo-real-en-cxpcxc)
3. [Tratamiento I/07 y E/07 en ingresos/gastos](#3-tratamiento-i07-y-e07-en-ingresosgastos)
4. [Drill-down consistente con KPIs](#4-drill-down-consistente-con-kpis)
5. [Cache de métricas: DELETE antes de calcular](#5-cache-de-métricas-delete-antes-de-calcular)
6. [Facturapi multi-contribuyente](#6-facturapi-multi-contribuyente-fork-específico)
7. [Filtro inclusivo por RFC en dashboard (primera iteración)](#7-filtro-inclusivo-por-rfc-fork-específico)
8. [Fix zona horaria en parser SAT](#8-fix-zona-horaria-en-parser-sat-portable)
9. [Refactor completo: RFC como fuente de verdad (fases 1-4)](#9-refactor-completo-rfc-como-fuente-de-verdad-fork-específico)
10. [Scripts nuevos](#10-scripts-nuevos)
11. [Validaciones hechas](#11-validaciones-hechas)
12. [Pendientes activos](#12-pendientes-activos)
13. [Cache invalidations del día](#13-cache-invalidations-del-día)
14. [Alerta: TipoRelacion sospechoso en notas de crédito](#14-alerta-tiporelacion-sospechoso-en-notas-de-crédito-portable)
15. [Facturapi save post-emit usando parseXml](#15-facturapi-save-post-emit-usando-parsexml-portable)
16. [Pivote a Método A en Grupo 1 ingresos](#16-pivote-a-método-a-en-grupo-1-ingresos-portable)
17. [Clamp defensivo del IVA en complementos P](#17-clamp-defensivo-del-iva-en-complementos-p-portable)
18. [Método A en gastos y adquisiciones](#18-método-a-en-gastos-y-adquisiciones-portable)
19. [Fix base gravable en histórico ISR (RESICO PM)](#19-fix-base-gravable-en-histórico-isr-resico-pm-portable)
---
## 1. Storage de CfdiRelacionados (CFDI 4.0)
**Portable** — detalle en vs doc §12.
Migración 032 agrega `cfdi_tipo_relacion VARCHAR(2)` y `cfdis_relacionados TEXT`
a la tabla `cfdis`. Parser extrae los nodos `<cfdi:CfdiRelacionados>` y los
guarda. Backfill idempotente desde `xml_original`.
- **Aplicado en fork**: 1,168 CFDIs actualizados (de 10,658 escaneados).
- **Archivos**: migración, `sat-parser.service.ts`, `sat.service.ts`,
`cfdi.service.ts`, `packages/shared/src/types/cfdi.ts`.
---
## 2. Saldo real en CxP/CxC
**Portable** — detalle en vs doc §13.
Problema: `saldo_pendiente_mxn` quedaba NULL para I PPD, así el reporte
CxP/CxC mostraba el `total_mxn` como "todo pendiente" aunque hubiera
pagos/NC/anticipos.
Solución: denormalizar el campo con fórmula compensada, hook al insertar,
backfill.
- **Utility central**: `apps/api/src/utils/saldo.ts` (`saldoComputadoExpr`,
`recomputarSaldoPendiente`, `uuidsAfectadosPorCfdi`).
- **Hooks**: `sat.service.ts:saveCfdis` (batch UPDATE al final del loop),
`cfdi.service.ts:createCfdi` (por CFDI).
- **Backfill**: 784 I PPD vigentes en fork. Delta global: -$11,764,854 de
"saldo pendiente" que ya estaba cubierto.
---
## 3. Tratamiento I/07 y E/07 en ingresos/gastos
**Portable** — detalle completo en vs doc §13b.
Evolución iterativa durante el día:
1. **Iter 1**: excluir E/07 (cancelación anticipo) de NC en Grupo 1 ingresos
+ gastos uniforme + adquisiciones G01. Decisión del user: las E/07 no
son devoluciones reales, no deben restar.
2. **Iter 2**: excluir I/07 (aplicación anticipo) de facturas en Grupo 1
ingresos + gastos uniforme. Decisión: también doble-cuentan.
3. **Iter 3**: reemplazar exclusión por **compensación con NETO_CUSTOM**:
```
contribución_I07 = (NETO_CUSTOM(I/07) EXCL_MONTO(I/07))
Σ (NETO_CUSTOM(rel) EXCL_MONTO(rel))
```
donde `NETO_CUSTOM = total traslados + retenciones`. Aplica a
ingresos G1 + gastos + adquisiciones.
4. **Iter 4**: `GREATEST(0, ...)` clamp — cuando el anticipo está en
periodo anterior a la I/07, el resultado era negativo (caso Husberto
julio 2025 con anticipos de mayo 2025 y marzo 2024). Clamp a 0
garantiza que nunca genere contribución negativa.
**Estado final por bucket/grupo**:
| Bucket / grupo | I/07 | E/07 |
|---|---|---|
| Ingresos G1 PF Empresarial | Compensación `NETO_CUSTOM` clampada a 0 | Exclusión |
| Ingresos G2 Sueldos (605) | N/A | N/A |
| Ingresos G3 PM y otros | Sumadas completas | Restadas completas |
| Gastos (uniforme) | Compensación `NETO_CUSTOM` clampada a 0 | Exclusión |
| Adquisiciones G01 (uniforme) | Compensación `NETO_CUSTOM` clampada a 0 | Exclusión |
| IVA causado/acreditable | N/A (solo E) | Exclusión |
| Flujo de efectivo | N/A (solo E) | Exclusión |
**Archivos**: `dashboard.service.ts` (helpers `NETO_CUSTOM` y
`EXCL_MONTO_ALIAS`, queries `g1Facturas`, `facturas` de egresos y
adquisiciones), `cfdi.controller.ts` (drill-down buckets).
---
## 4. Drill-down consistente con KPIs
**Portable** — detalle en vs doc §16.
Cambios:
- Drill-down ahora respeta los mismos filtros que el dashboard (régimen,
`TODOS_REGIMENES`, régimenes ignorados, E/07 donde aplica).
- Las I/07 se listan con `total_mxn` crudo (el dashboard aplica
compensación invisible para la fila → aceptable delta visual entre
header y suma de filas por decisión del user).
- Exportados los constants `GRUPO_PF_EMPRESARIAL`, `GRUPO_SUELDOS`,
`GRUPO_PM_OTROS` desde `dashboard.service.ts`.
---
## 5. Cache de métricas: DELETE antes de calcular
**Portable** — detalle en vs doc §17.
Bug descubierto: agregar `DELETE FROM metricas_mensuales WHERE (contrib,
año, mes)` **antes del upsert** no era suficiente, porque las queries
`calcular{Ingresos,Egresos}` leen del mismo cache via read-through. Si el
DELETE está DESPUÉS de `calcular*`, el recompute lee valores viejos y
los propaga.
Fix: DELETE al **inicio** de `computeMetricaMensual`, antes del
`Promise.all([calcular*])`.
**Impacto**: Husberto Feb 2025 gastos bajó de $525k (stale) a $463k (real).
---
## 6. Facturapi multi-contribuyente (FORK-ESPECÍFICO)
**No portable a Horux 360** tal cual — Horux 360 es single-tenant con una
sola org Facturapi. Pero varias lecciones generales sí aplican.
### Bug 1: `createOrg` no idempotente
`createOrgContribuyente` lanzaba 409 "El contribuyente ya tiene una
organización Facturapi" cuando la fila local en `facturapi_orgs` existía,
sin importar el estado real en Facturapi.
Escenario: fila local huérfana (org borrada manualmente en Facturapi, API
key cambiada) → UI muestra "no hay org" (porque `retrieve` falla y
`orgStatus` retorna `configured: false`) → user pulsa "Crear Organización"
→ backend 409 → user bloqueado.
**Fix** en `contribuyente-facturapi.service.ts:createOrgContribuyente()`:
- Si hay fila local + org viva en Facturapi → devolver la existente (`reused: true`).
- Si hay fila local + Facturapi 404 → crear nueva y actualizar el FK local (`recreated: true`).
- Si no hay fila local → crear fresh.
### Bug 2: `issuer` no es campo válido en invoice create
La primera versión del fix para multi-régimen intentaba pasar
`invoicePayload.issuer = { tax_system }`. Facturapi rechaza con
`"issuer" is not allowed` — el `tax_system` del emisor se toma
**exclusivamente** de `legal.tax_system` de la organización.
**Fix**: nuevo helper `ensureOrgLegalForEmit()` que se llama ANTES del
`invoices.create`:
1. Valida que el régimen elegido esté en `contribuyentes.regimen_fiscal` (CSV).
2. GET `/v2/organizations/{id}` para leer `legal` actual.
3. Si ya coincide `tax_system` + `legal_name` → no-op.
4. Si difiere → PUT `/v2/organizations/{id}/legal` con razón social,
`tax_system` elegido, y domicilio completo desde
`contribuyentes.domicilio` JSONB.
### Bug 3: frontend no enviaba el régimen
El form de emisión tenía un selector "Régimen del Emisor" con estado
`emisorRegimen`, pero no lo incluía en el payload al backend.
**Fix** (`apps/web/app/(dashboard)/facturacion/page.tsx`): agregar
`issuerTaxSystem: emisorRegimen` al `data` del emit.
### Errores SAT post-fix (no son código)
- **"RegimenFiscal no corresponde al tipo de persona"**: resolvido al
implementar sync legal (el `tax_system` refleja régimen válido).
- **"No se encontró el RFC en LCO"**: la LCO (Lista de Contribuyentes
Obligados) del SAT tarda 24-72h en propagar CSDs nuevos. No hay fix
de código — esperar. Documentar como UX banner pendiente.
### Archivos
- `apps/api/src/services/contribuyente-facturapi.service.ts` — 3 fixes
listados arriba.
- `apps/api/src/services/facturapi.service.ts:createInvoice()` —
comentario explicando que `issuer` no es válido.
- `apps/web/app/(dashboard)/facturacion/page.tsx` — propaga
`issuerTaxSystem`.
### Portabilidad parcial a Horux 360
El patrón de `ensureOrgLegalForEmit()` sí aplica: si Horux 360 permite
múltiples regímenes por tenant, se debe sincronizar el `legal.tax_system`
de la org antes del emit cuando el user elija uno distinto al default.
---
## 7. Filtro inclusivo por RFC — PRIMERA ITERACIÓN (FORK-ESPECÍFICO)
**Superado por §9**: esta iteración usó un filtro OR inclusivo que resolvía
el bug de "CFDI invisible" pero introducía el bug de "CFDI en lado
equivocado". Se mantiene aquí como contexto histórico — la solución final
es §9 (refactor completo a RFC).
**No portable a Horux 360** — el bug que resuelve solo ocurre en
multi-contribuyente dentro del mismo tenant.
### Problema
Cuando dos contribuyentes del mismo tenant tienen relación emisor-receptor
(ej. Carlos emite factura a Horux 360, ambos contribuyentes del mismo
despacho), el mismo UUID entra dos veces al sync SAT:
1. Sync del primero → INSERT con `contribuyente_id = A, type = X`.
2. Sync del segundo → UPSERT, el UPDATE **no toca** `contribuyente_id`
pero sí sobrescribe `type` y otros campos.
Resultado: CFDI queda con `contribuyente_id` del primer sync pero `type`
del segundo — inconsistente. El dashboard filtra por `contribuyente_id = X`
y excluye el CFDI.
### Solución — filtro por RFC
`utils/contribuyente-context.ts:resolveContribuyenteContext()` genera:
```sql
AND (
contribuyente_id = 'X'
OR UPPER(rfc_emisor) = 'X_RFC'
OR UPPER(rfc_receptor) = 'X_RFC'
)
```
El `type` del CFDI + el lado del query (EMITIDO/RECIBIDO) ya determina
si es ingreso o gasto del contribuyente — no se requiere
`contribuyente_id` para la atribución correcta.
Helper equivalente `getContribFilter(pool, id)` en
`dashboard.service.ts`. Las 6 ocurrencias de `cf = contribuyenteId ? ...`
migradas al helper async.
### Alcance
Fix en dashboard.service + impuestos.service (vía
`resolveContribuyenteContext`). Otros servicios (reportes, listado de
CFDIs) conservan su filtro original — si aparecen inconsistencias
similares, migrar con el mismo patrón.
### Archivos
- `apps/api/src/utils/contribuyente-context.ts` — filtro inclusivo.
- `apps/api/src/services/dashboard.service.ts` — helper local
`getContribFilter()`, 6 usos migrados.
---
## 8. Fix zona horaria en parser SAT (portable)
**Portable** — aplica también a Horux 360. Detalle en vs doc §19.
### Problema
`new Date(comprobante['@_Fecha'])` interpreta el string ISO sin TZ según
la zona horaria del proceso Node. En CDMX (UTC-6), `"2025-12-31T18:37:51"`
se convierte a UTC `"2026-01-01T00:37:51Z"`. Postgres guarda el UTC,
desalineando el mes/año del CFDI.
Alcance: cualquier CFDI emitido después de las 18:00 hora México queda
en el día siguiente UTC. Fin de mes o fin de año cae fuera del periodo
correcto.
### Solución
Helper `parseCfdiDate(str)` en `sat-parser.service.ts` fuerza 'Z' si el
string no trae TZ indicator. Todos los `new Date(...)` del XML + metadata
CSV migrados al helper.
**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=""`. En fork: 10,658 CFDIs actualizados
(todos estaban desfasados).
### Archivos
- `apps/api/src/services/sat/sat-parser.service.ts` — helper +
4 usos migrados (XML + CSV metadata).
- `apps/api/scripts/backfill-fechas-tz.ts` — **nuevo** script idempotente.
---
## 9. Refactor completo: RFC como fuente de verdad (fork-específico)
### Contexto
El fix §7 (filtro inclusivo `contribuyente_id OR rfc_emisor OR rfc_receptor`)
resolvió el bug de "CFDI que no aparecía para un contribuyente", pero
introdujo otro bug: si el CFDI tiene `contribuyente_id = A` (del primer
sync) y `type = 'EMITIDO'` (del segundo sync que sobrescribió), entonces
para el contribuyente A aparece como EMITIDO aunque él sea en realidad
receptor. El user reportó caso real: CFDI `a2f1f589` donde Horux 360
(receptor) lo veía como ingreso emitido.
### Diagnóstico raíz
El par `(type, contribuyente_id)` en BD es inconsistente cuando dos
contribuyentes del mismo tenant se facturan entre sí:
- Primer sync inserta con su perspectiva.
- Segundo sync UPSERT: actualiza `type` pero NO `contribuyente_id`.
- Resultado: `type` refleja perspectiva del último sync, pero
`contribuyente_id` refleja perspectiva del primero — desalineados.
### Solución: usar RFC directamente
**Dejar de confiar en `type` y `contribuyente_id`** en los filtros del
dashboard. El RFC del contribuyente comparado contra `rfc_emisor` /
`rfc_receptor` del CFDI es fuente de verdad inmutable:
- Si `rfc_emisor = X_RFC` → el contribuyente X emitió este CFDI.
- Si `rfc_receptor = X_RFC` → el contribuyente X recibió este CFDI.
`type` y `contribuyente_id` se conservan en BD (legacy), pero ya no se
usan como filtros en dashboard/impuestos/reportes/drill.
### Fase 1 — Helper central (`utils/contribuyente-context.ts`)
Extendido `resolveContribuyenteContext` para retornar:
- `esEmisor`: fragmento SQL `UPPER(rfc_emisor) = 'X_RFC'`.
- `esReceptor`: `UPPER(rfc_receptor) = 'X_RFC'`.
- Fallback (sin contribuyenteId, Horux 360 single-tenant): RFC del tenant.
Si no hay tenant tampoco, fallback a `type = 'EMITIDO/RECIBIDO'`.
El campo `contribFilter` (filtro inclusivo) se marcó como deprecated pero
se mantiene para queries legacy.
### Fase 2 — Dashboard (`dashboard.service.ts`)
- `calcularIngresosPorRegimen`: 3 grupos (G1 PF Empresarial, G2 Sueldos,
G3 PM) migrados. Filtro por `esEmisor` (ingresos) y `esReceptor` (G2 sueldos).
- `calcularEgresosPorRegimen`: 3 queries (facturas, pagos, NC) con `esReceptor`.
- `calcularAdquisicionesMercancias`: facturas y NC con `esReceptor`.
- `calcularIvaBalancePorRegimen`: 6 buckets (s1-s3, r1-r3) con `esEmisor`/`esReceptor`
según el lado.
- `getKpis`: conteos por lado derivados de `esEmisor`/`esReceptor` en vez de `type`.
- `getRegimenesDelPeriodo`: UNION de emisor/receptor usando los filtros.
- Helper local `getContribFilter` eliminado.
Firmas intactas — ninguna función cambió su contrato externo.
### Fase 3 — Impuestos (`impuestos.service.ts`)
Los `BUCKET_*` constantes (que eran strings con `type = 'EMITIDO'` hard-coded)
convertidos a **factories** que reciben `ctx`:
- `bucketCausadoPos(ctx)`, `bucketCausadoNeg(ctx)`, `bucketCausadoAny(ctx)`
- `bucketAcreditablePos(ctx)`, `bucketAcreditableNeg(ctx)`, `bucketAcreditableAny(ctx)`
- `signedCausadoTras/Ret(ctx)`, `signedAcreditableTras/Ret(ctx)` — SUM expressions signed.
- `regimenTenantExpr(ctx)`: CASE WHEN esEmisor THEN regimen_emisor ELSE regimen_receptor.
Funciones migradas: `getIvaMensual`, `getResumenIva`, `readResumenIvaFromCache`
(ahora recibe `ctx` completo en vez de `contribFilter`), `getResumenIsr`
(query de ISR retenido usa `(esEmisor OR esReceptor)`).
### Fase 4 — Reportes (`reportes.service.ts`)
Helper local `resolveEmisorReceptor(pool, contribuyenteId)` — versión ligera
del context resolver, no depende de tenantId. Migradas:
- `getFlujoEfectivo`: 6 queries (entradas/salidas × I/P/E).
- `calcularFlujoPorMes`: helper `q()` acepta `'EMITIDO'|'RECIBIDO'` semántico
en vez de literal type.
- `getConcentradoRfc`: clientes/proveedores vía RFC.
- `getCuentasXPagar`: filtro `esReceptor` en vez de `type='RECIBIDO' AND contrib_id=X`.
- `getCuentasXCobrar`: filtro `esEmisor`.
### Fase 4b — Drill-down (`cfdi.controller.ts:drillDown`)
Importa `resolveContribuyenteContext`. Los 4 buckets (ingresos G1/G2/G3,
gastos, causado, acreditable) migrados a `esEmisor`/`esReceptor`. El
filtro final `AND contribuyente_id = X` solo aplica cuando NO hay bucket
(drill crudo sin semantic de lado).
### Validación
Horux 360 (`b3761db6-…`) ingresos 2025:
- Pre-refactor: contaminado con CFDIs de Carlos/Husberto donde Horux era
receptor pero `type='EMITIDO'` en BD (del sync del emisor).
- Post-refactor: $305,904 solo régimen 626 (su RESICO). ✓ Coherente.
Husberto (`d745a915-…`) ingresos 2025:
- $9,507,265 solo régimen 612. ✓
CFDI `a2f1f589-…` (caso reportado): Husberto→Horux 360. Ahora aparece
en ingresos de Husberto (emisor) y gastos de Horux 360 (receptor),
nunca en ingresos de Horux 360.
### Alcance
Solo dashboard + impuestos + reportes + drill-down + conteos. **No tocado**:
- Listado `/cfdi` (usa filtro propio por `type`).
- Alertas, calendario, conciliación — si aparecen inconsistencias
similares, migrar con el mismo patrón.
### Archivos
- `apps/api/src/utils/contribuyente-context.ts` — `esEmisor`/`esReceptor`.
- `apps/api/src/services/dashboard.service.ts` — 8+ queries, helper local
eliminado.
- `apps/api/src/services/impuestos.service.ts` — factories + queries.
- `apps/api/src/services/reportes.service.ts` — helper local + queries.
- `apps/api/src/controllers/cfdi.controller.ts:drillDown` — 4 buckets.
- `apps/api/src/controllers/dashboard.controller.ts:getRegimenesDelPeriodo`
— propaga `tenantId` al service.
### Cache
Recomputed tras el refactor — 212 invalidaciones, 392 filas escritas, 0 errores.
### Nota de portabilidad a Horux 360
El refactor aplica conceptualmente a Horux 360 también (single-tenant), pero
el bug original (`type` y `contribuyente_id` desalineados) no existe allá
porque no hay multi-contribuyente. En Horux 360 single-tenant, el fallback
del helper usa el RFC del tenant y sigue funcionando. Si se decide portar,
el código funciona idéntico sin cambios.
---
## 10. Scripts nuevos
- `backfill-cfdis-relaciones.ts` (§1) — re-parsea CfdiRelacionados.
- `backfill-saldo-pendiente.ts` (§2) — pobla `saldo_pendiente_mxn`.
- `backfill-fechas-tz.ts` (§8) — re-parsea fechas desde XML.
- `invalidate-metricas-all.ts` — fuerza invalidación de cache.
- `process-metricas-now.ts` — dispara recompute inmediato.
- `inspect-cfdi.ts` / `inspect-cfdi-full.ts` — debug de UUID.
- `check-saldo.ts` — valida fórmula de saldo.
- `inspect-rfc.ts`, `find-contribuyente.ts`, `list-contribuyentes.ts`,
`check-carlos-lco.ts` — debug contribuyentes.
- `validate-gastos.ts` / `validate-ingresos.ts` — tests de paridad
dashboard vs drill.
- `breakdown-gastos.ts` — desglose por régimen.
- `deep-egresos.ts` — análisis componente a componente.
- `check-cache.ts` — inspecciona `metricas_mensuales`.
- `debug-i07.ts` — desglose de I/07 con NETO_CUSTOM y contribución.
---
## 11. Validaciones hechas
### Husberto Ignacio Torres (TOAH680201RA2, d745a915-...)
- **Feb 2025 gastos**: 6 iteraciones de validación hasta cuadrar
$438,056.13 post compensación.
- **Jul 2025 gastos**: descubrió contribuciones negativas de I/07 con
anticipos de meses anteriores → motivó el clamp `GREATEST(0, ...)`.
Post-clamp: $361,967.39.
- **Oct 2025 gastos**: $384,375.93 (cuadran dashboard + drill).
- **Ingresos 2025 post-refactor RFC**: $9,507,265 solo régimen 612.
### Carlos Husberto Torres (TORC9611214CA, 414b22a8-...)
- **Ingresos 2025 completo**: problema reportado — 3 meses (jun/sep/nov)
en $0 y total muy bajo ($335,905).
- Diagnóstico: una factura específica (43fd3e58) con `fecha_emision`
= 2026-01-01 00:37 UTC pero XML decía 2025-12-31 18:37 México → bug TZ.
- Post-fix (§8): total 2025 = $554,905.56 (+$219k). Los 3 meses ya
muestran valores.
### Horux 360 (HTS240708LJA, b3761db6-...)
- **Caso reportado `a2f1f589`**: aparecía en emitidos de Horux 360 aunque
Husberto era el emisor. Raíz: `type/contribuyente_id` desalineados
post-UPSERT. Fix: refactor §9 usa RFC directamente.
- **Mayo 2025 ingresos post-refactor**: $45,003 (régimen 626 RESICO).
Detectado que un CFDI P (`079ace7d-…`) tiene `iva_traslado_pago_mxn`
inflado por error del proveedor en el XML — decisión: dejar como está
(ver §11 cerrados).
---
## 12. Pendientes activos
### Funcionalidad
- **Propagar compensación `NETO_CUSTOM` a otros buckets**: hoy solo
aplica a ingresos G1 + gastos + adquisiciones. Pendiente evaluar:
IVA causado/acreditable, flujo de efectivo, ISR retenido.
- **Saldo en listado de CFDIs**: hoy solo CxP/CxC aprovechan
`saldo_pendiente_mxn`. Si se expone en `/cfdi`, hay que extender a
más tipos (no solo I PPD).
### Datos a investigar
- **Saldos negativos en backfill**: -$1.7M detectado en MO3NI6U8. Indica
P multi-docto over-counteado o anticipos referenciados múltiples veces.
No bloquea el reporte (filtro `saldo > 0.01`) pero vale auditar.
- **Carlos — LCO del SAT**: esperar 24-72h desde trámite del CSD.
### UX / Operativa
- **Banner "CSD recién tramitado"**: warning amigable cuando user intenta
emitir en las primeras 24h tras subir CSD. Evita tickets repetidos.
### Cerrados por decisión
- I/07 en Grupo 3 PM en ingresos — suma completa (no se toca).
- ISR retenido — queda como está (no distingue EMITIDO/RECIBIDO).
- Cache `flujo_*` vs `getFlujoEfectivo` — diseño aceptado (neto vs bruto).
- **CFDIs P con `iva_traslado_pago_mxn` inflado**: si el XML del proveedor
tiene inconsistencia entre `TotalTrasladosBaseIVA16` y `MontoTotalPagos`
(el proveedor reporta la base del IVA de la factura original en vez de
la base proporcional al pago parcial), el dashboard resta de más en el
neteo y el ingreso sale bajo. Ejemplo: CFDI `079ace7d-…` con
pago=$43,611 pero IVA trasladado=$30,076 → neto=$13,534. Decisión
del user (2026-04-24): dejar como está — el cálculo refleja el XML
timbrado, la corrección es pedirle al proveedor que reemita con los
totales correctos. Sin fix de código, sin clamp.
---
## 13. Cache invalidations del día
Secuencia de recomputes:
1. Post-§2 (saldo): no requiere (hook).
2. Post-§3 iter 1 (excluir E/07 G1): 212 invalidaciones → 392 filas.
3. Post-§3 iter 2 (excluir E/07 gastos): 212 → 392.
4. Post-§3 iter 3 (compensación NETO_CUSTOM G1): 212 → 392.
5. Post-§3 iter 4 (compensación NETO_CUSTOM gastos): 212 → 392.
6. Post-§3 iter 5 (clamp `GREATEST(0, ...)`): 212 → 392.
7. Post-§8 (fix TZ + backfill fechas): 212 → **401** (CFDIs reubicados a
mes correcto generaron nuevas combinaciones régimen×mes).
8. Post-§9 (refactor RFC): 212 → 392 filas (revirtió a 392 porque los
CFDIs atribuidos erróneamente a un contribuyente vía filtro inclusivo
viejo dejaron de poblar régimenes que no les correspondían).
---
## 14. Alerta: TipoRelacion sospechoso en notas de crédito (portable)
### Origen
Caso `9de39173-738d-48df-bf86-af3c6ed1d748` (Husberto Ago 2025): nota de
crédito recibida con `cfdi_tipo_relacion = '01'` cuya `cfdis_relacionados`
apuntaba a `b1390d12-93c9-449b-94c3-c760d980af01`. El user siguió la
cadena de referencia y descubrió que `b1390d12` era un anticipo — el
emisor debió haber puesto TipoRelacion `07` (aplicación de anticipo),
no `01` (NC por errores). El error inflaba gastos e IVA acreditable
porque nuestras compensaciones de anticipo (§3) solo activan cuando el
TipoRelacion es 07.
### Heurística
Para cada CFDI **X** con:
- `tipo_comprobante = 'E'`
- `cfdi_tipo_relacion IS NOT NULL AND cfdi_tipo_relacion <> '07'`
- `cfdis_relacionados` no vacío
- No está en `cfdi_descartados` bajo `tipo_alerta='tipo-relacion-sospechosa'`
…buscar si existe otro CFDI **Y** (`Y.id <> X.id`) con
`Y.cfdi_tipo_relacion = '07'` cuyos `cfdis_relacionados` compartan al
menos un UUID con los de X. Si sí → ese UUID referenciado ya fue
tratado como anticipo en otra parte → X probablemente debió emitirse
como `07` también.
SQL clave (en `alertas-auto.service.ts`, exportado como
`SOSPECHOSA_TIPO_RELACION_WHERE_EXPORT`):
```sql
AND EXISTS (
SELECT 1 FROM cfdis y
WHERE y.id <> c.id
AND y.cfdi_tipo_relacion = '07'
AND y.status NOT IN ('Cancelado', '0')
AND y.cfdis_relacionados IS NOT NULL
AND y.cfdis_relacionados <> ''
AND string_to_array(LOWER(y.cfdis_relacionados), '|')
&& string_to_array(LOWER(c.cfdis_relacionados), '|')
)
```
El operador `&&` de PostgreSQL hace overlap entre dos arrays — si
comparten al menos un UUID devuelve `true`. Más conciso que `INTERSECT`
o `unnest() + IN (...)`.
### Implementación
- `alertas-auto.service.ts`:
- `SOSPECHOSA_TIPO_RELACION_WHERE` (fragmento SQL, exportado).
- `alertaTipoRelacionSospechosa(pool, contribuyenteId)` → alerta
prioridad `alta`, id `tipo-relacion-sospechosa`, drill-down
`/alertas/tipo-relacion-sospechosa`.
- Enganchada en `generarAlertasAutomaticas` (11ª alerta).
- `alertas.controller.ts`:
- `getTipoRelacionSospechosa` — drill-down reutilizando el mismo
`WHERE` para coherencia.
- `alertas.routes.ts`:
- `GET /alertas/drilldown/tipo-relacion-sospechosa`.
- `apps/web/app/(dashboard)/alertas/tipo-relacion-sospechosa/page.tsx`:
- Patrón de `discrepancia-regimen` (toggle Activos/Descartados,
filtros fecha + TipoRelacion, export Excel, modal viewer).
- Columnas: Fecha, Dirección (EMITIDO/RECIBIDO), Emisor, Receptor,
TipoRel (en rojo), Referenciados (UUIDs cortos), Total MXN.
- `cfdi_descartados` con `tipo_alerta='tipo-relacion-sospechosa'` sirve
como whitelist — si el contador confirma que un match es falso
positivo, puede descartarlo y ya no reaparece.
### Alcance actual (decidido con user)
- Solo `tipo_comprobante='E'` (el user explícitamente dijo "solo E").
- Prioridad `alta`.
- Drill-down = lista simple de sospechosos (no enseñamos el CFDI 07
que "ya consumió" el anticipo — el user dijo "basta con ver los
CFDIs sospechosos").
### Falsos positivos conocidos
- Caso teórico: el mismo UUID referenciado legítimamente por una NC
01 real y por una 07 de otro emisor/contexto. Se resuelve via
`cfdi_descartados`.
### Portable a Horux 360
Sí, con un solo cambio: quitar el filtro `contribuyente_id` (Horux 360
es single-contribuyente por tenant, esa columna no existe). Todo el
resto del SQL es idéntico.
---
## 15. Facturapi save post-emit usando parseXml (portable)
### Problema detectado
Al inspeccionar las facturas Facturapi guardadas en BD del tenant (CFDIs con
`source='facturapi'`), todas 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
En `apps/api/src/controllers/facturacion.controller.ts` el INSERT post-emit
leía `invoice.issuer?.tax_id`, `invoice.subtotal`, `invoice.taxes` del
response de `client.invoices.create`. El SDK de Facturapi NO incluye esos
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` (esa parte no cambió).
### 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 de la fuente autoritativa
(el XML sellado), garantizando consistencia con CFDIs descargados via
sync SAT.
```ts
const xmlBuffer = contribuyenteId
? await downloadXmlContribuyente(pool, contribuyenteId, invoice.id)
: await facturapiService.downloadXml(tenantId, 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 `scripts/backfill-facturapi-cfdis.ts` reaplica la misma lógica para
las filas insertadas con la versión vieja. Iteró sobre las 4 CFDIs
Facturapi del tenant DESPACHO_MO3NI6U8_B9VGG y las completó.
### Archivos
- `apps/api/src/controllers/facturacion.controller.ts` — refactor del INSERT
- `apps/api/scripts/backfill-facturapi-cfdis.ts` — one-shot
### Beneficios adicionales
- Ahora se almacena el `xml_original` de las CFDIs emitidas (antes vacío). El
drill-down y el visor de CFDIs pueden mostrarlo.
- Se populan `regimen_fiscal_emisor` y `regimen_fiscal_receptor` que antes
faltaban — esto destrabó el matching para los KPIs por régimen.
---
## 16. Pivote a Método A en Grupo 1 ingresos (portable)
### Motivación
El §3 de este doc evolucionó la lógica de I/07 a "compensación NETO_CUSTOM
con clamp `GREATEST(0, ...)`". El user descubrió que esa fórmula falla en
escenarios N:1 — un anticipo referenciado por múltiples I/07 produce
sub-cuenta porque cada I/07 resta el anticipo COMPLETO.
Ejemplo: anticipo $200 + 3 I/07 de $100 c/u (todas referenciando el mismo
anticipo) + 3 E/07 de $60/$100/$40:
- Real: anticipo $200 + 3 servicios $100 = $500 brutos $200 cancelados = **$300**
- Compensación con clamp: $200 + max(0, $100$200)×3 + 0 (E/07 excluidas) = **$200**
- Método A ingenuo: $200 + $300 $200 = **$300**
### Decisión
Migrar Grupo 1 (PF Empresarial: 606, 612, 621, 625, 626) a **Método A
ingenuo** — sumar todas las I PUE incluyendo I/07, restar todas las E PUE
incluyendo E/07. La cancelación algebraica `anticipo + I/07 E/07` es
correcta cuando los tres CFDIs están en el universo de la query.
### Cambios SQL en `dashboard.service.ts:calcularIngresosPorRegimen`
**Antes** (Grupo 1 facturas):
```sql
SUM(CASE
WHEN cfdi_tipo_relacion = '07' THEN
GREATEST(0, NETO_CUSTOM(cfdi) Σ NETO_CUSTOM(rel))
ELSE total_mxn IMP_TRAS EXCL_MONTO
END)
```
**Después** (Grupo 1 facturas):
```sql
SUM(total_mxn IMP_TRAS EXCL_MONTO)
```
**Antes** (Grupo 1 NC): incluía `AND cfdi_tipo_relacion <> '07'`.
**Después** (Grupo 1 NC): filtro removido — E/07 también restan.
### Drill-down
`apps/api/src/controllers/cfdi.controller.ts` bucket `ingresos` Grupo 1:
removido `AND ${E_NO_ANTICIPO}` para que el drill liste E/07 igual que el KPI.
### Trade-off documentado
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). Para esto
existe la alerta de §14 (`tipo-relacion-sospechosa`) que detecta E/07s
faltantes y emisores que pusieron 01 cuando debió ser 07.
### Buckets que NO migran a Método A
- **Saldos CxP/CxC** (`utils/saldo.ts`): conserva la exclusión E/07 y la
resta del anticipo en I/07 — semántica per-factura distinta a la del
dashboard agregado.
- **IVA causado/acreditable**: queda con compensación `NETO_CUSTOM` por ahora
(revisable después).
---
## 17. Clamp defensivo del IVA en complementos P (portable)
### Caso real que motivó el fix
CFDI `079ace7d-…` (P emitido por Horux 360 en may-2025): cobró $43,611.20
de una I PPD de $218k. El proveedor reportó `iva_traslado_pago_mxn=$30,076`
(el IVA de la factura completa $218k) en vez del proporcional al pago
($43,611 × 16% / 1.16 ≈ $6,017). Esto inflaba la resta del IVA del aporte
del P y bajaba ingresos artificialmente $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. Dado que el SAT no
permite tasa de IVA mayor al 16%, el cap es matemáticamente defensible
incluso para casos legítimos (tasa 0%, 8% frontera) porque el IVA real
estará bajo el cap.
### Helpers actualizados
`dashboard.service.ts:11`:
```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 SÍ y NO se clampa
- **IVA**: se clampa
- **IEPS**: NO se clampa (rates SAT van hasta 53%)
- **ISR retención**: NO se ve afectado (no usa los campos `_pago_`)
### Resultado validación Horux 360 may-2025
Ingresos pre-clamp: $45,003.48 → post-clamp: $68,102.38 (+$23,098.90, exacto
con la corrección esperada del único P afectado).
### Recompute
212 filas en `metricas_mensuales` invalidadas y recomputadas con razón
`CLAMP_IVA_P_GLOBAL`.
---
## 18. Método A en gastos y adquisiciones (portable)
### Cambio
Aplicar el mismo Método A de §16 simétricamente en:
- `dashboard.service.ts:calcularEgresosPorRegimen` (facturas + nc)
- `dashboard.service.ts:calcularAdquisicionesMercancias` (facturas + nc, mismo SQL con `uso_cfdi='G01'`)
- `cfdi.controller.ts` bucket `gastos` (drill-down)
### SQL antes/después
Idéntico al §16 — quitar el `CASE WHEN cfdi_tipo_relacion='07' THEN ... ELSE ...`
en facturas, y quitar el filtro `AND cfdi_tipo_relacion <> '07'` en NC.
### Estado consolidado post-cambio
| Bucket | I/07 | E/07 |
|---|---|---|
| Ingresos G1 | suma completa | resta completa |
| Ingresos G2 (sueldos) | n/a | n/a |
| Ingresos G3 (PM y otros) | suma completa | resta completa |
| Gastos | suma completa | resta completa |
| Adquisiciones G01 | suma completa | resta completa |
| IVA causado/acreditable | compensada | excluida |
| Saldos CxP/CxC | resta anticipo | excluida |
Los 5 buckets económicos del dashboard ya son Método A uniforme. Los buckets
fiscales de IVA y de saldos mantienen su lógica especializada por las razones
que hablamos en §16.
### Recompute
212 filas invalidadas + 392 escritas tras `METODO_A_GASTOS_Y_ADQUISICIONES`.
---
## 19. Fix base gravable en histórico ISR (RESICO PM) (portable)
### Bug detectado
La tabla "Histórico ISR" en `/impuestos` mostraba **base gravable = ingresos**
para Horux 360 (régimen 626 PM), cuando debería ser
`max(0, ingresos deducciones)`.
### Causa raíz
Lógica duplicada y desincronizada entre dos funciones de
`apps/api/src/services/impuestos.service.ts`:
- `calcularResumenIsr` (KPI del periodo): tenía la lógica completa que
distingue PM/PF en régimen 626 vía `rfcLength`.
- `getIsrMensual` (histórico mensual): solo verificaba la constante
`REGIMENES_RESTA_DEDUCCIONES = ['606', '612']`. El 626 no estaba ahí
→ `formula = ingresos` siempre → `base = max(0, ing) = ing`.
### Solución — extracción a single source of truth
Helper exportado `determinarFormulaBaseGravable(clave, rfcLength)`:
```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';
}
```
Reglas que codifica:
- 606 (Arrendamiento) y 612 (PF Empresarial): **siempre** restan deducciones.
- 626 (RESICO): PM (RFC 12 chars) resta deducciones; PF (RFC 13) usa tasa
plana sobre ingresos.
- Resto de regímenes PM (601, 603, 607...): no restan en base — sus
deducciones se modelan vía coeficiente de utilidad en el ISR causado
(Art. 14 LISR).
### Cambios
- `calcularResumenIsr`: reemplazado el `if/else if/else` inline con la
llamada al helper.
- `getIsrMensual`:
- Resuelve `rfcLength` vía `resolveContribuyenteContext` al inicio.
- Usa el helper en el branch "con régimen filtrado".
- 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