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