36 KiB
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
- Storage de CfdiRelacionados (CFDI 4.0)
- Saldo real en CxP/CxC
- Tratamiento I/07 y E/07 en ingresos/gastos
- Drill-down consistente con KPIs
- Cache de métricas: DELETE antes de calcular
- Facturapi multi-contribuyente
- Filtro inclusivo por RFC en dashboard (primera iteración)
- Fix zona horaria en parser SAT
- Refactor completo: RFC como fuente de verdad (fases 1-4)
- Scripts nuevos
- Validaciones hechas
- Pendientes activos
- Cache invalidations del día
- Alerta: TipoRelacion sospechoso en notas de crédito
- Facturapi save post-emit usando parseXml
- Pivote a Método A en Grupo 1 ingresos
- Clamp defensivo del IVA en complementos P
- Método A en gastos y adquisiciones
- 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:
-
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.
-
Iter 2: excluir I/07 (aplicación anticipo) de facturas en Grupo 1 ingresos + gastos uniforme. Decisión: también doble-cuentan.
-
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. -
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_mxncrudo (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_OTROSdesdedashboard.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:
- Valida que el régimen elegido esté en
contribuyentes.regimen_fiscal(CSV). - GET
/v2/organizations/{id}para leerlegalactual. - Si ya coincide
tax_system+legal_name→ no-op. - Si difiere → PUT
/v2/organizations/{id}/legalcon razón social,tax_systemelegido, y domicilio completo desdecontribuyentes.domicilioJSONB.
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_systemrefleja 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 queissuerno es válido.apps/web/app/(dashboard)/facturacion/page.tsx— propagaissuerTaxSystem.
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:
- Sync del primero → INSERT con
contribuyente_id = A, type = X. - Sync del segundo → UPSERT, el UPDATE no toca
contribuyente_idpero sí sobrescribetypey 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 localgetContribFilter(), 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
typepero NOcontribuyente_id. - Resultado:
typerefleja perspectiva del último sync, perocontribuyente_idrefleja 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 SQLUPPER(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 poresEmisor(ingresos) yesReceptor(G2 sueldos).calcularEgresosPorRegimen: 3 queries (facturas, pagos, NC) conesReceptor.calcularAdquisicionesMercancias: facturas y NC conesReceptor.calcularIvaBalancePorRegimen: 6 buckets (s1-s3, r1-r3) conesEmisor/esReceptorsegún el lado.getKpis: conteos por lado derivados deesEmisor/esReceptoren vez detype.getRegimenesDelPeriodo: UNION de emisor/receptor usando los filtros.- Helper local
getContribFiltereliminado.
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: helperq()acepta'EMITIDO'|'RECIBIDO'semántico en vez de literal type.getConcentradoRfc: clientes/proveedores vía RFC.getCuentasXPagar: filtroesReceptoren vez detype='RECIBIDO' AND contrib_id=X.getCuentasXCobrar: filtroesEmisor.
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 portype). - 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— propagatenantIdal 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) — poblasaldo_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— inspeccionametricas_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_iddesalineados 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-…) tieneiva_traslado_pago_mxninflado por error del proveedor en el XML — decisión: dejar como está (ver §11 cerrados).
12. Pendientes activos
Funcionalidad
- Propagar compensación
NETO_CUSTOMa 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_*vsgetFlujoEfectivo— diseño aceptado (neto vs bruto). - CFDIs P con
iva_traslado_pago_mxninflado: si el XML del proveedor tiene inconsistencia entreTotalTrasladosBaseIVA16yMontoTotalPagos(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: CFDI079ace7d-…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:
- Post-§2 (saldo): no requiere (hook).
- Post-§3 iter 1 (excluir E/07 G1): 212 invalidaciones → 392 filas.
- Post-§3 iter 2 (excluir E/07 gastos): 212 → 392.
- Post-§3 iter 3 (compensación NETO_CUSTOM G1): 212 → 392.
- Post-§3 iter 4 (compensación NETO_CUSTOM gastos): 212 → 392.
- Post-§3 iter 5 (clamp
GREATEST(0, ...)): 212 → 392. - Post-§8 (fix TZ + backfill fechas): 212 → 401 (CFDIs reubicados a mes correcto generaron nuevas combinaciones régimen×mes).
- 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_relacionadosno vacío- No está en
cfdi_descartadosbajotipo_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 prioridadalta, idtipo-relacion-sospechosa, drill-down/alertas/tipo-relacion-sospechosa.- Enganchada en
generarAlertasAutomaticas(11ª alerta).
alertas.controller.ts:getTipoRelacionSospechosa— drill-down reutilizando el mismoWHEREpara 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.
- Patrón de
cfdi_descartadoscontipo_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 INSERTapps/api/scripts/backfill-facturapi-cfdis.ts— one-shot
Beneficios adicionales
- Ahora se almacena el
xml_originalde las CFDIs emitidas (antes vacío). El drill-down y el visor de CFDIs pueden mostrarlo. - Se populan
regimen_fiscal_emisoryregimen_fiscal_receptorque 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_CUSTOMpor 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 conuso_cfdi='G01')cfdi.controller.tsbucketgastos(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íarfcLength.getIsrMensual(histórico mensual): solo verificaba la constanteREGIMENES_RESTA_DEDUCCIONES = ['606', '612']. El 626 no estaba ahí →formula = ingresossiempre →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 elif/else if/elseinline con la llamada al helper.getIsrMensual:- Resuelve
rfcLengthvíaresolveContribuyenteContextal 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 − dedglobal lo cual falla con multi-régimen mixto (ej. PM con 626+601 que tienen fórmulas distintas).
- Resuelve
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