Files
HoruxDespachosNuevo/docs/plans/2026-04-24-session-fixes-and-features.md

36 KiB
Raw Permalink Blame History

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)
  2. Saldo real en CxP/CxC
  3. Tratamiento I/07 y E/07 en ingresos/gastos
  4. Drill-down consistente con KPIs
  5. Cache de métricas: DELETE antes de calcular
  6. Facturapi multi-contribuyente
  7. Filtro inclusivo por RFC en dashboard (primera iteración)
  8. Fix zona horaria en parser SAT
  9. Refactor completo: RFC como fuente de verdad (fases 1-4)
  10. Scripts nuevos
  11. Validaciones hechas
  12. Pendientes activos
  13. Cache invalidations del día
  14. Alerta: TipoRelacion sospechoso en notas de crédito
  15. Facturapi save post-emit usando parseXml
  16. Pivote a Método A en Grupo 1 ingresos
  17. Clamp defensivo del IVA en complementos P
  18. Método A en gastos y adquisiciones
  19. Fix base gravable en histórico ISR (RESICO PM)

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:

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.tsnuevo 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.tsesEmisor/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):

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.

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):

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):

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:

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):

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