Initial commit: Horux Despachos project

This commit is contained in:
consultoria-as
2026-04-27 01:11:06 -06:00
commit 56a05ba767
604 changed files with 121723 additions and 0 deletions

View File

@@ -0,0 +1,861 @@
# 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