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