# 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 `` 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