1644 lines
67 KiB
Markdown
1644 lines
67 KiB
Markdown
# Sesión 2026-05-02 — Refactor fiscal ISR: separación NCs, compensación I/07 PPD, no deducibles efectivo > $2k
|
||
|
||
Sesión enfocada en limpiar las fórmulas de cálculo fiscal para ISR — separar
|
||
las notas de crédito (E PUE) de los cálculos de ingresos/deducciones para
|
||
exhibirlas en cards independientes, ajustar la base gravable para regímenes
|
||
con fórmula `ingresos-deducciones`, restaurar la compensación I/07 PPD ↔ E
|
||
con interpretación fiscal explícita, agregar visibilidad de gastos no
|
||
deducibles por Art. 27 fracción III LISR, alinear drill-downs y persistir
|
||
las nuevas métricas en cache `metricas_mensuales`.
|
||
|
||
**Contexto fiscal:** las NCs recibidas/emitidas estaban opacas dentro del
|
||
cálculo de ingresos/deducciones. El contador no podía ver "qué facturé en
|
||
bruto vs qué cancelé"; ambos quedaban netos en una sola card. Además los
|
||
gastos pagados en efectivo > $2,000 (no deducibles por Art. 27) se
|
||
deducían incorrectamente, sub-calculando el ISR a pagar.
|
||
|
||
---
|
||
|
||
## Índice
|
||
|
||
1. [Eliminar E PUE de cálculo de ingresos (Grupos 1 y 3)](#1-eliminar-e-pue-de-cálculo-de-ingresos)
|
||
2. [Eliminar E PUE de cálculo de deducciones](#2-eliminar-e-pue-de-cálculo-de-deducciones)
|
||
3. [Cards NCs Emitidas + NCs Recibidas (surface)](#3-cards-ncs-emitidas--ncs-recibidas)
|
||
4. [Restauración compensación I/07 PPD ↔ E (opción C)](#4-restauración-compensación-i07-ppd--e)
|
||
5. [Base gravable: nueva fórmula con NCs](#5-base-gravable-nueva-fórmula-con-ncs)
|
||
6. [Persistencia en `metricas_mensuales` (migración 042)](#6-persistencia-en-metricas_mensuales-migración-042)
|
||
7. [Switch "Considerar NCs" extendido](#7-switch-considerar-ncs-extendido)
|
||
8. [Drill-downs: remover E de ingresos/gastos + propagar régimen + drill propio para NCs](#8-drill-downs)
|
||
9. [Art. 27 fracción III LISR — gastos efectivo > $2k no deducibles](#9-art-27-fracción-iii-lisr--gastos-efectivo--2k-no-deducibles)
|
||
10. [Layout final cards en /impuestos > ISR](#10-layout-final)
|
||
11–21. (secciones agregadas durante la sesión — ver headers)
|
||
22. [Auto-facturación con datos del cliente (Fases 1 + 2)](#22-auto-facturación-con-datos-del-cliente-fases-1--2)
|
||
23. [Onboarding auto-dismiss (4 logins ó pasos completados)](#23-onboarding-auto-dismiss-4-logins-ó-pasos-completados)
|
||
24. [Soporte wide-screen (breakpoints 3xl/4xl + 3 páginas)](#24-soporte-wide-screen-breakpoints-3xl4xl--3-páginas)
|
||
25. [Alerta RESICO PF cerca del límite anual ($2.5M / $3M)](#25-alerta-resico-pf-cerca-del-límite-anual-25m--3m)
|
||
26. [SAT — reuso de requestIds en retries + políticas de retry por tipo](#26-sat--reuso-de-requestids-en-retries--políticas-de-retry-por-tipo)
|
||
|
||
---
|
||
|
||
## 1. Eliminar E PUE de cálculo de ingresos
|
||
|
||
### Grupo 3 (PMs y otros — 601, 603, 607...)
|
||
|
||
**Antes:**
|
||
```
|
||
ingresos = I (PUE+PPD) − E PUE
|
||
```
|
||
|
||
**Ahora:**
|
||
```
|
||
ingresos = I (PUE+PPD)
|
||
```
|
||
|
||
`apps/api/src/services/dashboard.service.ts:calcularIngresosPorRegimen` —
|
||
removida la query `g3NC` y el término `− nc` del loop. Las E PUE se
|
||
contabilizan del lado del receptor (gastos), no como reducción del ingreso
|
||
del emisor. Criterio fiscal vigente para PMs.
|
||
|
||
### Grupo 1 (PF Empresarial — 606, 612, 621, 625, 626)
|
||
|
||
**Antes:**
|
||
```
|
||
ingresos = I PUE + P + I/07_PPD_compensación − E PUE
|
||
```
|
||
|
||
**Después del cambio inicial:**
|
||
```
|
||
ingresos = I PUE + P
|
||
```
|
||
|
||
(Eliminada también la compensación I/07 PPD ↔ E porque su raison d'être era
|
||
neutralizar la sobre-resta de E. Sin E restando, no hay ciclo que cerrar.)
|
||
|
||
**Tras restauración de la compensación (sección 4):**
|
||
```
|
||
ingresos = I PUE + P + I/07_PPD_compensación
|
||
```
|
||
|
||
Más detalle del razonamiento en sección 4.
|
||
|
||
## 2. Eliminar E PUE de cálculo de deducciones
|
||
|
||
`apps/api/src/services/dashboard.service.ts:calcularEgresosPorRegimen`.
|
||
|
||
**Antes:**
|
||
```
|
||
deducciones = I PUE + P + I/07_PPD_compensación + N − E PUE
|
||
```
|
||
|
||
**Después del cambio inicial:**
|
||
```
|
||
deducciones = I PUE + P + N
|
||
```
|
||
|
||
**Tras restauración de la compensación (sección 4):**
|
||
```
|
||
deducciones = I PUE + P + I/07_PPD_compensación + N
|
||
```
|
||
|
||
Removidas las queries `nc` (E PUE recibidas) y restaurada `i07PpdComp`. Las
|
||
E recibidas se exhiben aparte como "NCs Recibidas" (surface-only).
|
||
|
||
## 3. Cards NCs Emitidas + NCs Recibidas
|
||
|
||
Nuevas funciones backend surface-only (no afectan cálculos de ingresos /
|
||
deducciones / ISR):
|
||
|
||
| Función | Lado | Descripción |
|
||
|---------|------|-------------|
|
||
| `calcularNcsEmitidasPorRegimen` | EMISOR (`rfc_emisor`) | E PUE que el contribuyente emitió a sus clientes |
|
||
| `calcularNcsRecibidasPorRegimen` | RECEPTOR (`rfc_receptor`) | E PUE que el contribuyente recibió de proveedores |
|
||
|
||
**Misma fórmula neta** que ingresos/deducciones:
|
||
```
|
||
neto = total_mxn − (IVA_traslado + IEPS + impuestos_locales) − conceptos_excluidos
|
||
```
|
||
|
||
Excluye conceptos con `clave_prod_serv` en `('84121603','93161608','85101501','85121800')`.
|
||
|
||
Wire en `getResumenIsr` via `Promise.all` con ingresos/deducciones up-front
|
||
(necesario para fórmula de base gravable, ver sección 5).
|
||
|
||
**Tipo shared extendido** (`packages/shared/src/types/impuestos.ts:ResumenIsr`):
|
||
```ts
|
||
ncsEmitidas: number;
|
||
ncsEmitidasPorRegimen: IsrRegimenDetalle[];
|
||
ncsRecibidas: number;
|
||
ncsRecibidasPorRegimen: IsrRegimenDetalle[];
|
||
```
|
||
|
||
**Renombre semántico:** la card empezó como "Egresos Emitidos" pero se renombró
|
||
a "NCs Emitidas" para simetría con "NCs Recibidas" — refleja mejor el
|
||
contenido (notas de crédito tipo E PUE).
|
||
|
||
## 4. Restauración compensación I/07 PPD ↔ E
|
||
|
||
**Decisión fiscal explícita** del cliente (opción C en la conversación). En
|
||
lugar de eliminar la compensación junto con la E (matemáticamente
|
||
consistente pero quita una pieza fiscal), se restauró en ambos lados con
|
||
**interpretación fiscal nueva**:
|
||
|
||
> La compensación reconoce que la porción del servicio asociada al anticipo
|
||
> se reconoce como ingreso/gasto al emitir/recibir la I/07 PPD,
|
||
> independientemente de si la E del mismo mes resta o no.
|
||
|
||
### Estado matemático
|
||
|
||
Para un par I/07 PPD ↔ E en el mismo mes/año:
|
||
|
||
| Cobro real | Antes (E restaba) | Ahora (E no resta, comp se mantiene) |
|
||
|------------|-------------------|--------------------------------------|
|
||
| Anticipo I PUE $100 | $100 + $100 − $100 = **$100** | $100 + $100 = **$200** |
|
||
|
||
La nueva interpretación reconoce ambos: el anticipo cobrado **y** la porción
|
||
del servicio aplicada vía la I/07 PPD. Semánticamente discutible pero
|
||
queda bajo criterio fiscal del despacho y documentado.
|
||
|
||
### Aplica a
|
||
|
||
- Grupo 1 ingresos (PF Empresarial 606, 612, 621, 625, 626)
|
||
- Deducciones (todos los regímenes via lado RECEPTOR)
|
||
|
||
NO aplica a Grupo 3 ingresos (PMs) — esos nunca tuvieron compensación
|
||
porque devengan al emitir, no al cobrar.
|
||
|
||
## 5. Base gravable: nueva fórmula con NCs
|
||
|
||
**Antes:**
|
||
```
|
||
baseGravable = max(0, ingresos − deducciones) ← ingresos-deducciones
|
||
baseGravable = max(0, ingresos) ← ingresos (sin cambio)
|
||
```
|
||
|
||
**Ahora:**
|
||
```
|
||
baseGravable = max(0, ingresos − ncsEm − deducciones + ncsRec) ← ingresos-deducciones
|
||
baseGravable = max(0, ingresos) ← ingresos (sin cambio)
|
||
```
|
||
|
||
Donde:
|
||
- `ingresosNeto = ingresosNominales − ncsEmitidas`
|
||
- `deduccionNeta = deducciones − ncsRecibidas`
|
||
- `base = max(0, ingresosNeto − deduccionNeta)` simplificado
|
||
|
||
Solo aplica a regímenes con fórmula `ingresos-deducciones` (606, 612, 626 PM).
|
||
|
||
`Promise.all` movido al inicio de `getResumenIsr` para tener NCs disponibles
|
||
antes del loop. Set `regimenesConDatos` extendido para incluir regímenes que
|
||
solo tengan NCs (sin ingresos ni deducciones — caso edge cubierto).
|
||
|
||
## 6. Persistencia en `metricas_mensuales` (migración 042)
|
||
|
||
Nuevas columnas para cache:
|
||
|
||
```sql
|
||
-- 042_metricas_ncs.sql
|
||
ALTER TABLE metricas_mensuales
|
||
ADD COLUMN IF NOT EXISTS ncs_emitidas numeric(18,2) DEFAULT 0,
|
||
ADD COLUMN IF NOT EXISTS ncs_recibidas numeric(18,2) DEFAULT 0;
|
||
```
|
||
|
||
Aplicada a 4 tenants (Patito, Zorro, Demo, Horux 360). Writer `metricas-compute.service.ts:computeMetricaMensual` extendido — calcula NCs por régimen via `Promise.all` con ingresos/egresos/IVA y las pasa al `upsertMetricaMensual`.
|
||
|
||
Reader `getMetricasMensuales` SELECT extendido. Cron de invalidaciones
|
||
recompute las NCs junto con las demás métricas — sin cambios estructurales.
|
||
|
||
**Script `apps/api/scripts/refresh-metricas-cache.ts`** sigue funcionando
|
||
para invalidación masiva post-cambio fiscal.
|
||
|
||
## 7. Switch "Considerar NCs" extendido
|
||
|
||
**Antes:** OFF excluía solo `tipo_comprobante = 'E' AND tipo_relacion = '01'`.
|
||
|
||
**Ahora:** OFF excluye **TODAS las E** (cualquier `tipo_relacion`: 01-07) **+
|
||
salta la compensación I/07 PPD ↔ E**.
|
||
|
||
`apps/api/src/services/_shared/cfdi-filters.ts`:
|
||
```ts
|
||
parts.push(`AND NOT (tipo_comprobante = 'E')`);
|
||
```
|
||
|
||
Compensación gateada en `dashboard.service.ts`:
|
||
```ts
|
||
const g1I07PpdComp = considerarNCs
|
||
? (await pool.query(...)).rows
|
||
: [];
|
||
```
|
||
|
||
Aplica al lado EMISOR (Grupo 1 ingresos) y RECEPTOR (deducciones).
|
||
Tooltip del botón actualizado. Ahora el switch tiene impacto significativo
|
||
en la base gravable cuando el contador quiere simular "sin notas de crédito".
|
||
|
||
## 8. Drill-downs
|
||
|
||
### Buckets `ingresos` y `gastos` — remover E
|
||
|
||
`apps/api/src/controllers/cfdi.controller.ts:drillDown` actualizado para
|
||
alinear con las nuevas fórmulas (sin E PUE):
|
||
|
||
| Bucket | Antes | Ahora |
|
||
|--------|-------|-------|
|
||
| `ingresos` Grupo 1 | I PUE + P + E PUE | I PUE + P |
|
||
| `ingresos` Grupo 3 | I (PUE+PPD) + E PUE | I (PUE+PPD) |
|
||
| `gastos` | I PUE + P + E PUE | I PUE + P |
|
||
| `causado` (IVA) | sin cambio | sin cambio |
|
||
| `acreditable` (IVA) | sin cambio | sin cambio |
|
||
|
||
### Régimen filter para todos los buckets
|
||
|
||
`drillUrl` en `/impuestos` y `/dashboard` ahora propaga el régimen
|
||
seleccionado para cualquier bucket (no solo cuando hay `type` explícito):
|
||
|
||
```ts
|
||
if (filters.bucket === 'ingresos') {
|
||
if (regimenSeleccionado === '605') p.set('regimenReceptor', '605');
|
||
else p.set('regimenEmisor', regimenSeleccionado);
|
||
}
|
||
else if (filters.bucket === 'causado' || filters.bucket === 'ncs_emitidas') p.set('regimenEmisor', regimenSeleccionado);
|
||
else if (filters.bucket === 'gastos' || filters.bucket === 'acreditable' || filters.bucket === 'ncs_recibidas' || filters.bucket === 'no_deducibles_efectivo') p.set('regimenReceptor', regimenSeleccionado);
|
||
```
|
||
|
||
Edge case 605 (Sueldos) — en bucket `ingresos` va por `regimenReceptor`
|
||
porque es nómina recibida.
|
||
|
||
### Buckets nuevos: `ncs_emitidas`, `ncs_recibidas`, `no_deducibles_efectivo`
|
||
|
||
```ts
|
||
} else if (bucketStr === 'ncs_emitidas') {
|
||
where += ` AND (
|
||
${esEmisor}
|
||
AND tipo_comprobante = 'E' AND metodo_pago = 'PUE'
|
||
AND regimen_fiscal_emisor IS NOT NULL
|
||
) ${NO_IGNORADO_EMISOR}`;
|
||
}
|
||
```
|
||
|
||
Cards "NCs Emitidas", "NCs Recibidas" y "No Deducibles" tienen `href={drillUrl(...)}`.
|
||
|
||
**Fix de alineación con calcular**: el drill quitó la restricción
|
||
`regimen_fiscal_emisor IN (TODOS_REGS)` que sí estaba en buckets
|
||
ingresos/gastos. El calcular function no la tenía — aceptaba cualquier
|
||
régimen no-NULL no-ignorado (incluyendo 616 Extranjero, etc.). Los drills
|
||
mostraban menos filas que lo que la card sumaba; ahora alineados con
|
||
`IS NOT NULL` + `NO_IGNORADO_*`.
|
||
|
||
## 9. Art. 27 fracción III LISR — gastos efectivo > $2k no deducibles
|
||
|
||
**Regla fiscal:** las erogaciones cuyo monto exceda $2,000 MXN solo son
|
||
deducibles si el pago se hace por transferencia, cheque nominativo, tarjeta
|
||
crédito/débito o monedero electrónico — NO en efectivo (`forma_pago='01'`).
|
||
|
||
### Implementación completa (Opción A)
|
||
|
||
| Capa | Cambio |
|
||
|------|--------|
|
||
| Filtro en `calcularEgresosPorRegimen` | Excluye I PUE recibidas y P recibidos con `forma_pago='01' AND total_mxn (o monto_pago_mxn) > 2000` |
|
||
| Surface backend | Nueva `calcularGastosNoDeduciblesEfectivoPorRegimen` — computa el monto excluido por régimen |
|
||
| Wire `getResumenIsr` | Retorna `gastosNoDeduciblesEfectivo` + `gastosNoDeduciblesEfectivoPorRegimen` |
|
||
| Drill-down | Bucket `no_deducibles_efectivo` en cfdi controller |
|
||
| Cache | Migración 043 + columna `gastos_no_deducibles_efectivo` + writer + reader |
|
||
| Alerta | `alertaRiesgoTransaccional` reemplazada — ahora apunta al monto $$ no deducible específicamente, no al % de facturas en efectivo |
|
||
| Shared types | `ResumenIsr` extendido + `MetricaMensual` extendido |
|
||
| Frontend card | "No Deducibles" en `/impuestos > ISR` con subtitle "Efectivo > $2,000" |
|
||
| Frontend drill | drillUrl mapea bucket → `regimen_fiscal_receptor` |
|
||
|
||
### Constantes SQL
|
||
|
||
```ts
|
||
const NO_DEDUCIBLE_EFECTIVO_I_PUE = `(forma_pago = '01' AND COALESCE(total_mxn, 0) > 2000)`;
|
||
const NO_DEDUCIBLE_EFECTIVO_P = `(forma_pago = '01' AND COALESCE(monto_pago_mxn, 0) > 2000)`;
|
||
```
|
||
|
||
**Particularidades:**
|
||
- Para CFDIs tipo I PUE: comparación con `total_mxn`
|
||
- Para complementos P: comparación con `monto_pago_mxn` (cada P es pago independiente; un P de $3k efectivo aplicado a una I PPD de $10k bloquea solo esos $3k)
|
||
- Nómina (N) no aplica — tiene sus propias reglas
|
||
- Combustibles (regla especial Art. 27 fracción III párrafo último) no se modela aún
|
||
|
||
### Migración 043
|
||
|
||
```sql
|
||
ALTER TABLE metricas_mensuales
|
||
ADD COLUMN IF NOT EXISTS gastos_no_deducibles_efectivo numeric(18,2) DEFAULT 0;
|
||
```
|
||
|
||
Aplicada a 4 tenants.
|
||
|
||
### Alerta automática nueva
|
||
|
||
`alertas-auto.service.ts:alertaRiesgoTransaccional` reemplazada:
|
||
- **Antes:** medía `% de facturas` con forma_pago=01, sin distinguir monto ni lado emisor/receptor
|
||
- **Ahora:** mide `monto $$` específico de I PUE recibidas con forma_pago=01 y total > $2,000. Threshold $50k para prioridad alta. Mensaje cita Art. 27 fracción III LISR.
|
||
|
||
## 10. Layout final
|
||
|
||
`/impuestos > ISR` con **5 cards en fila 1, 3 en fila 2** (`lg:grid-cols-5`):
|
||
|
||
| | Col 1 | Col 2 | Col 3 | Col 4 | Col 5 |
|
||
|---|---|---|---|---|---|
|
||
| **Fila 1** | Ingresos Nominales | NCs Emitidas | Deducciones | NCs Recibidas | Base Gravable |
|
||
| **Fila 2** | ISR a Pagar | Utilidad del Periodo | No Deducibles | — | — |
|
||
|
||
5 cards informacionales en fila 1, 3 cards de "resumen final" en fila 2.
|
||
|
||
## Pendiente abierto
|
||
|
||
**Art. 5 LIVA fracción I**: el IVA acreditable de los gastos en efectivo
|
||
> $2k tampoco es válido. Hoy: la deducción para ISR se filtra ✓, pero el
|
||
IVA acreditable sigue acreditándose. Implementación similar al filtro de
|
||
ISR pero en `getResumenIva` — ~30 min si se decide cubrirlo.
|
||
|
||
## Archivos modificados
|
||
|
||
### Backend
|
||
```
|
||
apps/api/src/services/dashboard.service.ts (calcular* sin E PUE, +calcularNcsEmitidas/Recibidas/GastosNoDeducibles, compensación I/07 PPD gateada por considerarNCs)
|
||
apps/api/src/services/impuestos.service.ts (getResumenIsr con Promise.all up-front, base gravable nueva fórmula, surface NCs + no deducibles)
|
||
apps/api/src/services/_shared/cfdi-filters.ts (considerarNCs=false excluye TODAS las E)
|
||
apps/api/src/services/metricas-compute.service.ts (Promise.all extendido, upsert con NCs + no deducibles)
|
||
apps/api/src/services/metricas.service.ts (interface MetricaMensual + SELECT + upsert con nuevas columnas)
|
||
apps/api/src/services/alertas-auto.service.ts (alertaRiesgoTransaccional reescrita: monto $ específico)
|
||
apps/api/src/controllers/cfdi.controller.ts (drillDown: 3 buckets nuevos + remover E de ingresos/gastos)
|
||
apps/api/src/migrations/tenant/042_metricas_ncs.sql (NUEVO)
|
||
apps/api/src/migrations/tenant/043_metricas_no_deducibles.sql (NUEVO)
|
||
apps/api/scripts/apply-migration-042.ts (NUEVO — aplicar migraciones a todos los tenants)
|
||
apps/api/scripts/refresh-metricas-cache.ts (NUEVO — DELETE FROM metricas_mensuales por tenant)
|
||
apps/api/scripts/debug-drill-buckets.ts (NUEVO — debug ad-hoc de buckets)
|
||
```
|
||
|
||
### Shared
|
||
```
|
||
packages/shared/src/types/impuestos.ts (ResumenIsr +ncsEmitidas/Recibidas/gastosNoDeducibles + porRegimen)
|
||
```
|
||
|
||
### Frontend
|
||
```
|
||
apps/web/app/(dashboard)/impuestos/page.tsx (Ingresos Nominales rename, +cards NCs Emitidas/Recibidas/No Deducibles, drillUrl extendido para nuevos buckets, layout grid-cols-5 con 2 filas, tooltip "Considerar NCs" actualizado)
|
||
apps/web/app/(dashboard)/dashboard/page.tsx (drillUrl extendido para propagar régimen en buckets)
|
||
```
|
||
|
||
---
|
||
|
||
## 11. IVA No Acreditable (Art. 5 LIVA fracción I)
|
||
|
||
Implementación end-to-end del paralelo IVA del Art. 27 fracción III LISR. Si
|
||
un gasto no es deducible por pagarse en efectivo > $2k, su IVA tampoco es
|
||
acreditable.
|
||
|
||
| Capa | Cambio |
|
||
|------|--------|
|
||
| Constantes compartidas | `NO_DEDUCIBLE_EFECTIVO_I_PUE` y `_P` exportadas desde dashboard.service.ts para reuso en impuestos.service.ts |
|
||
| Filtro IVA Acreditable | `bucketAcreditablePos` (impuestos.service.ts) excluye I PUE + P recibidos en efectivo > $2k |
|
||
| Filtro dashboard IVA | `calcularIvaBalancePorRegimen` r1/r2 mismo filtro |
|
||
| Surface backend | Nueva `calcularIvaNoAcreditableEfectivoPorRegimen` — IVA neto excluido por régimen |
|
||
| Wire `getResumenIva` | Retorna `ivaNoAcreditableEfectivo` + `ivaNoAcreditableEfectivoPorRegimen` |
|
||
| Cache fallback | `readResumenIvaFromCache` retorna 0/empty para los nuevos campos (cache aún no los persiste — TODO) |
|
||
| Shared type | `ResumenIva` extendido |
|
||
| Frontend card | "IVA No Acreditable" en `/impuestos > IVA` con subtitle "Efectivo > $2,000" |
|
||
|
||
### Layout final `/impuestos > IVA`
|
||
|
||
| | Col 1 | Col 2 | Col 3 | Col 4 | Col 5 |
|
||
|---|---|---|---|---|---|
|
||
| **Fila 1** | IVA Trasladado | IVA Acreditable | IVA Retenido | Resultado del Periodo | Acumulado Anual |
|
||
| **Fila 2** | IVA No Acreditable | — | — | — | — |
|
||
|
||
### Coherencia fiscal end-to-end
|
||
|
||
| Sección | Antes | Ahora |
|
||
|---------|-------|-------|
|
||
| ISR Deducciones | Filtra | Filtra |
|
||
| ISR Card "No Deducibles" | Surface | Surface |
|
||
| ISR Base Gravable | Correcta | Correcta |
|
||
| **IVA Acreditable** | **Acreditaba todo** | **Filtra** |
|
||
| **IVA Card "No Acreditable"** | **N/A** | **Surface** |
|
||
| **IVA Resultado** | **Sub-pago** | **Correcto** |
|
||
|
||
## 12. Restauración guards admin global (post-testing)
|
||
|
||
Post-validación del flujo MP, se restauraron los guards `[TEMP]` que se habían
|
||
dejado abiertos para probar pago desde Horux 360:
|
||
|
||
| Archivo | Restaurado |
|
||
|---------|------------|
|
||
| `subscription-banner.tsx` | `if (isGlobalAdmin || viewingTenantId) return null;` |
|
||
| `use-nav-gate.ts` | `const needsRenewal = !isGlobalAdmin && ...` |
|
||
| `.env` | `MP_USE_SANDBOX=false`, `MP_TEST_PAYER_EMAIL=` (vacío) |
|
||
|
||
Tarea #35 cerrada.
|
||
|
||
### Side-fix: Zod `MP_TEST_PAYER_EMAIL=` rechazado
|
||
|
||
Al vaciar la línea, Zod tiraba `Invalid email`. Fix con `z.preprocess`:
|
||
|
||
```ts
|
||
MP_TEST_PAYER_EMAIL: z.preprocess(
|
||
v => (v === '' ? undefined : v),
|
||
z.string().email().optional(),
|
||
),
|
||
```
|
||
|
||
Permite que prod tenga la línea declarada vacía sin romper arranque. Patrón
|
||
aplicable a cualquier env opcional con validación específica.
|
||
|
||
## 13. Bug fix: drill-down ignoraba toggles "Considerar activos/NCs"
|
||
|
||
**Diagnóstico:** un usuario reportó que el CFDI `8ec2eaf3-7879-11f0-81a8-8daae9822b10`
|
||
(P de pago $295,100 cuyo `uuid_relacionado` apunta a una I con `uso_cfdi=I03`
|
||
= Equipo de transporte) seguía apareciendo en el drill-down de Deducciones
|
||
con "Considerar activos" desactivado.
|
||
|
||
Script `scripts/debug-cfdi-activos.ts` confirmó que el predicado lo
|
||
detectaba correctamente como activo (`regla2 (P paga I activo): true`). El
|
||
bug estaba en el endpoint del drill-down — no aplicaba `buildExtraFilters`.
|
||
|
||
**Fix:**
|
||
- `apps/api/src/controllers/cfdi.controller.ts:drillDown` acepta query params
|
||
`considerarActivos` y `considerarNCs`, aplica `extra` al WHERE final
|
||
- `drillUrl` en `/impuestos` propaga los toggles cuando están OFF (default ON
|
||
omite el param, backend interpreta como true por convención)
|
||
- `drillUrl` en `/dashboard` no se modifica (esa página no tiene toggles
|
||
propios)
|
||
|
||
```ts
|
||
const considerarActivosBool = considerarActivos !== '0' && considerarActivos !== 'false';
|
||
const considerarNCsBool = considerarNCs !== '0' && considerarNCs !== 'false';
|
||
const extra = buildExtraFilters(considerarActivosBool, considerarNCsBool);
|
||
// ...
|
||
where += extra;
|
||
```
|
||
|
||
## 14. Bug fix: cache `metricas_mensuales` ignoraba toggles
|
||
|
||
**Síntoma:** al desactivar "Considerar activos", la card de Deducciones solo
|
||
bajaba ~$2,000 cuando el CFDI excluido valía ~$295,100.
|
||
|
||
**Diagnóstico (parte 1):** el cache `metricas_mensuales` se consultaba sin
|
||
considerar los toggles. El cache se escribió con flags default (true), por lo
|
||
tanto al consultar con `considerarActivos=false`, devolvía el valor cacheado
|
||
sin aplicar el filtro.
|
||
|
||
Patrón ya correcto en `getResumenIva` (línea 695); replicado en los 3
|
||
calcular del dashboard:
|
||
|
||
```ts
|
||
const cacheRange = considerarActivos && considerarNCs
|
||
? planCache(fechaInicio, fechaFin, conciliacion, contribuyenteId)
|
||
: null;
|
||
```
|
||
|
||
Aplicado a:
|
||
- `calcularIngresosPorRegimen`
|
||
- `calcularEgresosPorRegimen`
|
||
- `calcularIvaBalancePorRegimen` (no recibía los toggles, no aplica)
|
||
|
||
## 15. Bug fix: NULL semantics en `NO_DEDUCIBLE_EFECTIVO_*`
|
||
|
||
**Diagnóstico (parte 2 — el bug real):** después del fix de cache, las
|
||
deducciones seguían bajando solo $1,549 al desactivar activos. Script
|
||
`scripts/debug-deducciones-husberto.ts` reveló:
|
||
|
||
```
|
||
Total P recibidos en agosto 2025 (sin filtros): 8 (suma $317,979)
|
||
P recibidos SIN filtro activos (CON filtro no-deducible): n=6, bruto=$3,064
|
||
```
|
||
|
||
**Solo 6 de 8 P entraban al cálculo.** El P de $295,100 y otro de $19,815
|
||
quedaban fuera. Sus `forma_pago` eran `NULL`. El predicado:
|
||
|
||
```sql
|
||
NO_DEDUCIBLE_EFECTIVO_P = (forma_pago = '01' AND COALESCE(monto_pago_mxn, 0) > 2000)
|
||
```
|
||
|
||
Cuando `forma_pago=NULL`:
|
||
- `(NULL = '01' AND ...)` → `NULL`
|
||
- `NOT NULL` → `NULL`
|
||
- `WHERE NULL` excluye el row
|
||
|
||
Postgres trata `WHERE NULL` como `WHERE FALSE`. Eso significa que **TODOS los
|
||
CFDIs sin forma_pago se excluían de las deducciones** vía
|
||
`AND NOT NO_DEDUCIBLE_EFECTIVO_*`. Para Husberto agosto 2025: $317,979 brutos
|
||
en P se reducían a $3,064 — pérdida del 99% de las deducciones de pagos.
|
||
|
||
**Fix:** `COALESCE` para hacer el predicado NULL-safe:
|
||
|
||
```ts
|
||
export const NO_DEDUCIBLE_EFECTIVO_I_PUE = `(COALESCE(forma_pago, '') = '01' AND COALESCE(total_mxn, 0) > 2000)`;
|
||
export const NO_DEDUCIBLE_EFECTIVO_P = `(COALESCE(forma_pago, '') = '01' AND COALESCE(monto_pago_mxn, 0) > 2000)`;
|
||
```
|
||
|
||
### Verificación post-fix
|
||
|
||
```
|
||
Antes: Después:
|
||
SIN filtro activos: n=6, bruto=$3,064 n=8, bruto=$317,979 ✓
|
||
CON filtro activos: n=4, bruto=$1,515 n=5, bruto=$21,330 ✓
|
||
Reducción esperada: $1,549 $296,649 brutos / $255,720 neto ✓
|
||
```
|
||
|
||
### Auditoría sugerida
|
||
|
||
NULL en SQL es viral. Cualquier expresión que toca NULL retorna NULL, y
|
||
`WHERE NULL` excluye el row. Predicados sospechosos en el codebase con el
|
||
mismo patrón:
|
||
- `cfdi_tipo_relacion = '07'`
|
||
- `uso_cfdi IN (...)`
|
||
- `metodo_pago = 'PUE'`
|
||
|
||
Si alguno se usa con `NOT (...)` y la columna puede ser NULL, hay riesgo del
|
||
mismo bug. Vale una pasada de tests sobre el path "calcular con flags
|
||
non-default" para evitar más sorpresas.
|
||
|
||
## Archivos modificados (post-V.1.0.15 / parte 2)
|
||
|
||
### Backend
|
||
```
|
||
apps/api/src/services/dashboard.service.ts (+calcularIvaNoAcreditableEfectivoPorRegimen, NO_DEDUCIBLE_EFECTIVO_* exportadas + COALESCE fix, cache gating por toggles en calcularIngresos/Egresos, filtros NO_DEDUCIBLE en r1/r2 IVA)
|
||
apps/api/src/services/impuestos.service.ts (bucketAcreditablePos con filtro efectivo, getResumenIva con surface IVA No Acreditable, cache fallback con 0/empty)
|
||
apps/api/src/controllers/cfdi.controller.ts (drillDown acepta considerarActivos/NCs + aplica buildExtraFilters)
|
||
apps/api/src/config/env.ts (MP_TEST_PAYER_EMAIL con z.preprocess para empty string)
|
||
apps/api/scripts/debug-cfdi-activos.ts (NUEVO — debug ad-hoc del filtro de activos para un CFDI)
|
||
apps/api/scripts/debug-deducciones-husberto.ts (NUEVO — debug del cálculo de deducciones de un contribuyente)
|
||
```
|
||
|
||
### Shared
|
||
```
|
||
packages/shared/src/types/impuestos.ts (ResumenIva +ivaNoAcreditableEfectivo + porRegimen)
|
||
```
|
||
|
||
### Frontend
|
||
```
|
||
apps/web/app/(dashboard)/impuestos/page.tsx (+card "IVA No Acreditable" en pestaña IVA, drillUrl propaga toggles)
|
||
apps/web/app/(dashboard)/dashboard/page.tsx (drillUrl: comentario aclaratorio sobre toggles)
|
||
apps/web/components/subscription-banner.tsx (guard admin global RESTAURADO)
|
||
apps/web/lib/hooks/use-nav-gate.ts (guard admin global RESTAURADO)
|
||
```
|
||
|
||
### .env
|
||
```
|
||
apps/api/.env MP_USE_SANDBOX=false, MP_TEST_PAYER_EMAIL= (vaciado, no eliminado)
|
||
```
|
||
|
||
---
|
||
|
||
## 16. Eliminación definitiva de la compensación I/07 PPD ↔ E
|
||
|
||
**Decisión del cliente** tras revisar el caso de Husberto agosto 2025: eliminar
|
||
completamente la compensación I/07 PPD ↔ E del cálculo de ingresos y
|
||
deducciones. Rationale:
|
||
|
||
> "Es una buena idea, pero va a confundir a los contadores, además de que no
|
||
> es un cálculo oficial del SAT. Lo mejor va a ser que ellos hagan su
|
||
> conciliación."
|
||
|
||
Esto invalida la decisión anterior (sección 4) de restaurar la compensación
|
||
con interpretación fiscal explícita. La fórmula final no usa términos
|
||
derivados de E.
|
||
|
||
### Cambios
|
||
|
||
| Lugar | Antes | Ahora |
|
||
|-------|-------|-------|
|
||
| `calcularIngresosPorRegimen` Grupo 1 (PF Empresarial) | `I PUE + P + I/07_PPD_comp` | `I PUE + P` |
|
||
| `calcularEgresosPorRegimen` lado RECEPTOR | `I PUE + P + I/07_PPD_comp + N` | `I PUE + P + N` |
|
||
|
||
Comentarios en código incluyen explicit "no reintroducir" + fecha + razón
|
||
para evitar que alguien la "redescubra" como mejora fiscal en el futuro.
|
||
|
||
### Caso Husberto agosto 2025 (validación)
|
||
|
||
- **Antes:** la I/07 PPD `5c874749...` ($454K, uso_cfdi=I03) aportaba $136,206.90
|
||
a la compensación de deducciones vía las E del mismo mes que la cancelaban
|
||
- **Ahora:** no aporta nada — el contador concilia el ciclo
|
||
`anticipo → I/07 PPD → E` manualmente al armar la declaración
|
||
|
||
## 17. Filtro de activos — regla 4: anticipos vinculados a activos
|
||
|
||
**Diagnóstico:** un anticipo I PUE no tiene `uso_cfdi` de activo (SAT no
|
||
permite I01-I08 en facturas de anticipo según Anexo 20). El usuario observó
|
||
que cuando se desactiva "Considerar activos", el anticipo seguía contando
|
||
como deducción aunque eventualmente alimenta una compra de activo.
|
||
|
||
**Solución:** nueva regla 4 que mira hacia adelante en la cadena fiscal —
|
||
si el anticipo es referenciado por una I/07 PPD con `uso_cfdi` de activo,
|
||
también se filtra.
|
||
|
||
```sql
|
||
AND NOT (tipo_comprobante = 'I' AND EXISTS (
|
||
SELECT 1 FROM cfdis i07_act
|
||
WHERE i07_act.tipo_comprobante = 'I'
|
||
AND i07_act.metodo_pago = 'PPD'
|
||
AND COALESCE(i07_act.cfdi_tipo_relacion, '') = '07'
|
||
AND i07_act.uso_cfdi IN ('I01','I02','I03','I04','I05','I06','I07','I08')
|
||
AND i07_act.status NOT IN ('Cancelado', '0')
|
||
AND i07_act.cfdis_relacionados IS NOT NULL
|
||
AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(i07_act.cfdis_relacionados), '|'))
|
||
))
|
||
```
|
||
|
||
### Cobertura completa post-fix
|
||
|
||
| Regla | Captura |
|
||
|-------|---------|
|
||
| 1 | I directo con uso_cfdi I01-I08 |
|
||
| 2 | P pagando una I de activo |
|
||
| 3 | E referenciando una I/P de activo |
|
||
| **4 (nuevo)** | **Anticipo (I) referenciado por una I/07 PPD con uso_cfdi de activo** |
|
||
|
||
Aplicado en `cfdi-filters.ts:activosExclusionNoAlias` y `activosExclusionAlias`.
|
||
Comentario header del archivo actualizado.
|
||
|
||
### Verificación caso Husberto
|
||
|
||
Anticipo `729109fc...` ($148,000, uso_cfdi=CP01):
|
||
- regla 1: false
|
||
- regla 2: false
|
||
- regla 3: false
|
||
- **regla 4: TRUE** ← capturado
|
||
- → FILTRADO al desactivar "Considerar activos"
|
||
|
||
## 18. ISR causado para 626 RESICO PM — tasa 30% directa
|
||
|
||
**Decisión del cliente** sobre el cálculo de ISR para régimen 626 RESICO PM
|
||
(RFC 12 chars):
|
||
|
||
- **Base gravable:** misma fórmula que PF Empresarial:
|
||
`max(0, ingresos − ncsEm − deducciones + ncsRec)`
|
||
- **ISR causado:** **30% directo** sobre la base gravable (NO Art. 96
|
||
progresivo, NO `base × coeficiente × 30%`)
|
||
|
||
Razonamiento fiscal: RESICO PM ya restó sus deducciones efectivas en la
|
||
base, así que aplicar coeficiente_utilidad encima sería doble descuento.
|
||
La tasa fija de 30% es la oficial Art. 206-208 LISR para este régimen.
|
||
|
||
### Estado final del switch case
|
||
|
||
```ts
|
||
if (reg.regimenClave === '626' && rfcLength === 13) {
|
||
// RESICO PF: tasa plana por bracket (Art. 113-E)
|
||
regIsrCausado = await calcularIsrResicoPF(reg.baseGravable, anio);
|
||
} else if (reg.regimenClave === '626' && rfcLength === 12) {
|
||
// RESICO PM: tasa fija 30% directa
|
||
regIsrCausado = reg.baseGravable * 0.30;
|
||
} else if (['606', '612', '621', '625'].includes(reg.regimenClave)) {
|
||
// PF Empresarial: tarifa progresiva Art. 96
|
||
regIsrCausado = await calcularIsrProgresivo(reg.baseGravable, anio);
|
||
} else {
|
||
// PM Grupo 3: base × coeficiente × 30% (Art. 14)
|
||
const basePM = reg.baseGravable * (coeficiente || 0.30);
|
||
regIsrCausado = basePM * 0.30;
|
||
}
|
||
```
|
||
|
||
### Diferencia clave RESICO PM vs PM Grupo 3
|
||
|
||
| | RESICO PM (626) | PM Grupo 3 (601, 603, 607...) |
|
||
|---|---|---|
|
||
| Base gravable | `ing − ncsEm − ded + ncsRec` (resta deducciones) | `ingresos` (no resta deducciones aquí) |
|
||
| Tasa | **30% directo** | `× coeficiente_utilidad × 30%` |
|
||
|
||
PM Grupo 3 usa coeficiente como proxy de "qué % del ingreso es utilidad"
|
||
porque sus deducciones reales (depreciación, costo de ventas) requieren
|
||
contabilidad fiscal completa. RESICO PM tiene contabilidad simplificada
|
||
basada en flujo efectivo, así que las deducciones SÍ entran a la base.
|
||
|
||
## 19. Utilidad del Periodo — fórmula simétrica con Base Gravable
|
||
|
||
**Antes:** `utilidad = ingresos − deducciones` (no consideraba NCs).
|
||
|
||
**Después:** `utilidad = ingresos − ncsEmitidas − deducciones + ncsRecibidas`.
|
||
|
||
Misma fórmula que `baseGravable` para regímenes con formula
|
||
`ingresos-deducciones`, con dos diferencias deliberadas:
|
||
|
||
| | Utilidad del Periodo | Base Gravable |
|
||
|---|---|---|
|
||
| Clamp a 0 | NO (puede ser negativa) | SÍ (`max(0, ...)`) |
|
||
| Aplica a todos los regímenes | SÍ | Solo `ingresos-deducciones`; en `ingresos` la base es solo `max(0, ingresos)` |
|
||
|
||
La utilidad **debe** poder ser negativa para que el contador vea la pérdida
|
||
real del período. El clamp de base gravable es por regla SAT (no se puede
|
||
declarar ISR negativo provisional), no es un principio universal.
|
||
|
||
Subtitle removido por petición del cliente.
|
||
|
||
## 20. Bug fix CRÍTICO — Facturapi no persistía campos del complemento P
|
||
|
||
**Diagnóstico:** el usuario reportó que 2 P emitidos vía Facturapi
|
||
(`CFACB97E...` y `384CF943...`) no aparecían como ingresos para Horux 360.
|
||
|
||
El XML traía datos correctos:
|
||
```xml
|
||
<pago20:Pago FechaPago="2026-04-27T12:00:00" Monto="600.00">
|
||
<pago20:ImpuestosP>
|
||
<pago20:TrasladosP>
|
||
<pago20:TrasladoP ImporteP="82.758400" .../>
|
||
</pago20:TrasladosP>
|
||
</pago20:ImpuestosP>
|
||
</pago20:Pago>
|
||
```
|
||
|
||
Pero la BD tenía:
|
||
- `monto_pago_mxn`: NULL
|
||
- `fecha_pago_p`: NULL
|
||
- `iva_traslado_pago_mxn`: NULL
|
||
|
||
**Causa:** `apps/api/src/controllers/facturacion.controller.ts:218-250` —
|
||
el INSERT que persiste CFDIs emitidos vía Facturapi NO incluía las
|
||
columnas del complemento P, aunque el `parseXml` SÍ las extraía.
|
||
Comparado con `sat.service.ts:232` (sync SAT) que sí las maneja
|
||
correctamente, el path Facturapi quedó incompleto desde la integración
|
||
inicial.
|
||
|
||
### Impacto histórico
|
||
|
||
**Cualquier complemento P emitido vía Facturapi desde la integración
|
||
inicial quedó con `fecha_pago_p` y `monto_pago_mxn` NULL → no aportaba
|
||
ingresos al cálculo.** En PostgreSQL, `NULL >= '2026-05-01'` evalúa
|
||
NULL → row excluido del WHERE. Por eso ni en mayo ni en abril aparecían.
|
||
|
||
Si el cliente emite muchos P (cobros parciales de PPD), el reporte de
|
||
ingresos estaba subreportando significativamente. Solo los CFDIs tipo I
|
||
(no P) entraban correctamente.
|
||
|
||
### Fix
|
||
|
||
`facturacion.controller.ts:218-274` — INSERT extendido con las 7
|
||
columnas del complemento P:
|
||
- `monto_pago` + `monto_pago_mxn`
|
||
- `fecha_pago_p`
|
||
- `iva_traslado_pago` + `iva_traslado_pago_mxn`
|
||
- `iva_retencion_pago` + `iva_retencion_pago_mxn`
|
||
- `ieps_traslado_pago` + `ieps_traslado_pago_mxn`
|
||
|
||
El parseXml ya devuelve todos esos campos en `parsed.montoPago`,
|
||
`parsed.fechaPagoP`, etc. Solo faltaba pasarlos al INSERT.
|
||
|
||
```ts
|
||
const fechaPagoP = parsed.fechaPagoP
|
||
? new Date(String(parsed.fechaPagoP).split('|')[0])
|
||
: null;
|
||
```
|
||
|
||
(El parser concatena múltiples FechaPago con '|' cuando un complemento
|
||
trae varios; tomamos la primera.)
|
||
|
||
### Backfill histórico
|
||
|
||
`scripts/backfill-pago-fields.ts` — itera todos los tenants, busca CFDIs
|
||
con `source='facturapi' AND tipo_comprobante='P' AND xml_original IS NOT NULL
|
||
AND (monto_pago_mxn IS NULL OR fecha_pago_p IS NULL)`, re-parsea el XML y
|
||
hace UPDATE con COALESCE para no sobrescribir valores ya presentes.
|
||
|
||
Idempotente. Ejecutado en local tras el fix:
|
||
```
|
||
DESPACHO_MO3NI6U8_B9VGG (Patito): 2 P por backfill
|
||
384CF943-EFB0-475A-B6B6-240E96088B37: monto=$1856 fecha_pago=2026-04-27 iva=$256
|
||
CFACB97E-5426-48D4-A3B9-06B5D160F307: monto=$600 fecha_pago=2026-04-27 iva=$82.7584
|
||
[Backfill] 2/2 actualizadas
|
||
```
|
||
|
||
**En producción debe ejecutarse después de deploy** para corregir el
|
||
historial completo de P emitidos. El cache `metricas_mensuales` de los
|
||
meses afectados debe invalidarse después con `refresh-metricas-cache.ts`.
|
||
|
||
## Archivos modificados (post-V.1.0.15 / parte 3)
|
||
|
||
### Backend
|
||
```
|
||
apps/api/src/services/dashboard.service.ts (compensación I/07 PPD ELIMINADA en Grupo 1 ingresos + deducciones, NULL-safe en NO_DEDUCIBLE_EFECTIVO_*, cache gating por toggles)
|
||
apps/api/src/services/_shared/cfdi-filters.ts (regla 4: anticipos vinculados a activos via I/07 PPD)
|
||
apps/api/src/services/impuestos.service.ts (RESICO PM 626 ahora usa 30% directo)
|
||
apps/api/src/controllers/facturacion.controller.ts (INSERT con campos del complemento P — bug crítico)
|
||
apps/api/scripts/backfill-pago-fields.ts (NUEVO — backfill P de Facturapi)
|
||
apps/api/scripts/debug-i07-ppd.ts (NUEVO — debug de compensación)
|
||
apps/api/scripts/debug-compensacion-cfdi.ts (NUEVO — debug de aporte de un CFDI específico)
|
||
apps/api/scripts/debug-deducciones-husberto.ts (NUEVO — debug suma de deducciones)
|
||
apps/api/scripts/debug-cfdi-activos.ts (actualizado — incluye regla 4)
|
||
apps/api/scripts/debug-p-mayo.ts (NUEVO — debug búsqueda CFDI multi-tenant)
|
||
```
|
||
|
||
### Frontend
|
||
```
|
||
apps/web/app/(dashboard)/impuestos/page.tsx (Utilidad del Periodo con NCs simétrica, subtitle removido)
|
||
```
|
||
|
||
## 21. Sección "Cálculo de ISR del Periodo" — incluye NCs
|
||
|
||
El card de desglose mensual del ISR (`/impuestos > ISR > Cálculo de ISR del
|
||
Periodo`) mostraba la fórmula vieja: `ingresos − deducciones = base gravable`.
|
||
Ahora refleja la fórmula vigente con NCs.
|
||
|
||
### Líneas agregadas
|
||
|
||
```
|
||
Ingresos del periodo
|
||
(+) Ingresos acumulados anteriores
|
||
(−) NCs Emitidas del periodo ← NUEVO
|
||
(−) NCs Emitidas acumuladas anteriores ← NUEVO
|
||
(−) Deducciones del periodo
|
||
(−) Deducciones acumuladas anteriores
|
||
(+) NCs Recibidas del periodo ← NUEVO
|
||
(+) NCs Recibidas acumuladas anteriores ← NUEVO
|
||
(=) Base gravable acumulada
|
||
ISR causado (acumulado)
|
||
(−) ISR retenido (acumulado)
|
||
ISR a pagar
|
||
```
|
||
|
||
### Lógica de visibilidad (`showNcs`)
|
||
|
||
Las 4 líneas de NCs solo se muestran cuando aplican fiscalmente:
|
||
|
||
| Caso | Visible |
|
||
|------|---------|
|
||
| Régimen seleccionado con `formula='ingresos-deducciones'` (606, 612, 626 RESICO PM) | ✅ |
|
||
| Régimen seleccionado con `formula='ingresos'` (RIF, Plataformas, RESICO PF, PMs Grupo 3) | ❌ |
|
||
| Sin régimen seleccionado + alguna NC > 0 | ✅ |
|
||
| Sin régimen seleccionado + todas las NCs = 0 | ❌ |
|
||
|
||
Se usa el campo `formula` que ya viene en `BaseGravableRegimen` — single
|
||
source of truth con `determinarFormulaBaseGravable`. Si en el futuro
|
||
alguien modifica qué regímenes usan ingresos-deducciones, el desglose se
|
||
actualiza automáticamente.
|
||
|
||
### Coherencia visual con la fórmula
|
||
|
||
El orden de las líneas refleja exactamente:
|
||
```
|
||
ingresos − ncsEm − ded + ncsRec = baseGravable
|
||
```
|
||
|
||
NCs Emitidas restan justo después de ingresos (cancelaciones del lado emisor),
|
||
NCs Recibidas suman justo después de deducciones (reducen el gasto efectivo).
|
||
Lectura arriba-abajo coincide con la mate.
|
||
|
||
### Campos consumidos
|
||
|
||
`useResumenIsrDesglosado` retorna `delPeriodo`, `anteriores`, `total` — cada
|
||
uno con `ncsEmitidas`, `ncsEmitidasPorRegimen`, `ncsRecibidas`,
|
||
`ncsRecibidasPorRegimen` (campos que ya estaban en `ResumenIsr` desde
|
||
sección 3). No requirió cambios backend.
|
||
|
||
## Archivos modificados (post-V.1.0.15 / parte 4)
|
||
|
||
### Frontend
|
||
```
|
||
apps/web/app/(dashboard)/impuestos/page.tsx (Cálculo de ISR del Periodo: +4 líneas NCs con showNcs gating)
|
||
```
|
||
|
||
## 22. Auto-facturación con datos del cliente (Fases 1 + 2)
|
||
|
||
### Contexto
|
||
|
||
Hasta este punto, `invoicing.service.ts` siempre emitía CFDIs al **Público en
|
||
General** (XAXX010101000) — un fallback conservador heredado de cuando no
|
||
sabíamos si el tenant tenía datos fiscales completos. Pero hoy ya guardamos:
|
||
- CSF cargada con `sincronizarDatosFiscales(tenantId)` que llena
|
||
`tenants.codigo_postal/calle/colonia/...` automáticamente
|
||
- `tenant_regimenes_activos` con los regímenes parseados del CSF
|
||
|
||
Si el cliente paga $399/mes y la factura sale al Público en General, **no
|
||
puede deducirla**. Era un bug de UX serio para clientes B2B.
|
||
|
||
### Fase 1 — Auto-detección con CSF
|
||
|
||
**`apps/api/src/services/payment/invoicing.service.ts`**
|
||
|
||
Helper nuevo `getCustomerFromTenant(payerTenantId)`:
|
||
```ts
|
||
async function getCustomerFromTenant(payerTenantId: string): Promise<CustomerData | null> {
|
||
const tenant = await prisma.tenant.findUnique({
|
||
where: { id: payerTenantId },
|
||
select: {
|
||
rfc: true, nombre: true, codigoPostal: true,
|
||
factPreferencia: true, factRegimenPreferido: true,
|
||
regimenesActivos: { select: { regimen: { select: { clave: true } } }, orderBy: { createdAt: 'asc' } },
|
||
},
|
||
});
|
||
if (!tenant) return null;
|
||
if (tenant.factPreferencia === 'publico_general') return null;
|
||
if (!tenant.rfc || !tenant.nombre || !tenant.codigoPostal) return null;
|
||
|
||
// Si el tenant elige régimen explícito, usarlo. Sino primer activo por createdAt.
|
||
let regimenClave: string | undefined;
|
||
if (tenant.factRegimenPreferido) {
|
||
const match = tenant.regimenesActivos.find(ra => ra.regimen.clave === tenant.factRegimenPreferido);
|
||
regimenClave = match?.regimen.clave;
|
||
}
|
||
if (!regimenClave && tenant.regimenesActivos.length > 0) {
|
||
regimenClave = tenant.regimenesActivos[0].regimen.clave;
|
||
}
|
||
if (!regimenClave) return null;
|
||
...
|
||
}
|
||
```
|
||
|
||
`buildInvoicePayload(params: { ..., customer: CustomerData | null, usoCfdi: string })`
|
||
acepta un `customer` opcional. Si es `null`, cae al fallback (XAXX010101000 + S01);
|
||
si tiene valor, usa los datos reales con el `usoCfdi` configurado.
|
||
|
||
`emitInvoiceIfApplicable` orquesta:
|
||
```ts
|
||
const customer = await getCustomerFromTenant(payment.tenantId);
|
||
const tenantPref = await prisma.tenant.findUnique({
|
||
where: { id: payment.tenantId },
|
||
select: { factUsoCfdi: true },
|
||
});
|
||
const usoCfdi = customer ? (tenantPref?.factUsoCfdi || DEFAULT_USE_CFDI) : FALLBACK_USE_CFDI;
|
||
|
||
const payload = buildInvoicePayload({ ..., customer, usoCfdi });
|
||
```
|
||
|
||
### Fase 2 — Preferencias persistidas + UI
|
||
|
||
**Migración `20260502170000_add_tenant_fact_preferencias`:**
|
||
```sql
|
||
ALTER TABLE tenants
|
||
ADD COLUMN fact_preferencia VARCHAR(20) NOT NULL DEFAULT 'mis_datos',
|
||
ADD COLUMN fact_uso_cfdi VARCHAR(5) NOT NULL DEFAULT 'G03',
|
||
ADD COLUMN fact_regimen_preferido VARCHAR(3);
|
||
```
|
||
|
||
**Schema Prisma (`Tenant`):**
|
||
```prisma
|
||
factPreferencia String @default("mis_datos") @map("fact_preferencia") @db.VarChar(20)
|
||
factUsoCfdi String @default("G03") @map("fact_uso_cfdi") @db.VarChar(5)
|
||
factRegimenPreferido String? @map("fact_regimen_preferido") @db.VarChar(3)
|
||
```
|
||
|
||
**Service `tenants.service.ts`:**
|
||
- `getPreferenciasFacturacion(id)` — devuelve `{ factPreferencia, factUsoCfdi,
|
||
factRegimenPreferido, regimenesActivos: [{clave, descripcion}] }` (regímenes
|
||
joineados para que el dropdown UI no requiera segunda llamada)
|
||
- `updatePreferenciasFacturacion(id, data)` — patch idempotente
|
||
|
||
**Endpoints `facturacion.routes.ts`:**
|
||
```ts
|
||
router.get('/preferencias-facturacion', facturacionController.getPreferenciasFacturacion);
|
||
router.put('/preferencias-facturacion', facturacionController.updatePreferenciasFacturacion);
|
||
```
|
||
|
||
**Controller con Zod (`facturacion.controller.ts`):**
|
||
```ts
|
||
const PreferenciasFacturacionSchema = z.object({
|
||
factPreferencia: z.enum(['publico_general', 'mis_datos']).optional(),
|
||
factUsoCfdi: z.string().min(2).max(5).optional(),
|
||
factRegimenPreferido: z.string().max(3).nullable().optional(),
|
||
});
|
||
```
|
||
|
||
**Página `apps/web/app/(dashboard)/configuracion/facturacion/page.tsx`:**
|
||
- Toggle "Mis datos fiscales" / "Público en general" (cards seleccionables grandes)
|
||
- Dropdown Uso CFDI **limitado a G03 (Gastos en general) y S01 (Sin obligaciones)**
|
||
via filter client-side sobre `getUsosCfdi()`
|
||
- Dropdown Régimen preferido (solo si tiene >1 régimen activo)
|
||
- Banner ámbar con `<AlertCircle>` si eligió "mis datos" pero no tiene regímenes
|
||
(CSF pendiente)
|
||
- Botón "Guardar preferencias" con feedback de éxito/error
|
||
|
||
### Comportamiento end-to-end
|
||
|
||
| Estado del tenant | factPreferencia | Resultado |
|
||
|---|---|---|
|
||
| Sin CSF | mis_datos (default) | **Público en General** (fallback automático) |
|
||
| Sin CSF | publico_general | Público en General |
|
||
| Con CSF + 1 régimen | mis_datos | **Datos reales del cliente**, régimen único |
|
||
| Con CSF + N regímenes | mis_datos, sin preferido | **Datos reales**, primer régimen por createdAt |
|
||
| Con CSF + N regímenes | mis_datos, preferido='612' | **Datos reales**, régimen 612 |
|
||
| Con CSF | publico_general (override explícito) | Público en General |
|
||
|
||
### Por qué limitar el dropdown a G03/S01
|
||
|
||
El catálogo SAT `cat_uso_cfdi` tiene 24 valores oficiales, pero para una
|
||
factura de servicios SaaS realísticamente solo aplican 2:
|
||
- **G03** Gastos en general — el 95% de los casos (servicio deducible)
|
||
- **S01** Sin obligaciones fiscales — para PFs que no requieren deducir
|
||
|
||
Otros usos como D02 (gastos médicos), I01 (construcciones), etc. el SAT los
|
||
rechaza con `CFDI40165` cuando el concepto no matchea. Filtrar el UI
|
||
client-side previene el error sin tocar el endpoint compartido
|
||
`/catalogos/uso-cfdi` (que el módulo de emisión de CFDIs sí necesita completo).
|
||
|
||
### UX adicionales
|
||
|
||
- **Card en `/configuracion`** con icono `Receipt` y descripción explicando que
|
||
define cómo se factura la suscripción a Horux 360. Posicionado entre
|
||
"Notificaciones" y "Seguridad" siguiendo el orden visual existente.
|
||
- **Página sin `max-w-3xl`** — abarca el ancho completo del main para
|
||
consistencia con el resto del dashboard que usa `p-6 space-y-6` sin clamp.
|
||
|
||
### Archivos modificados/creados
|
||
|
||
```
|
||
NUEVO apps/api/prisma/migrations/20260502170000_add_tenant_fact_preferencias/migration.sql
|
||
MOD apps/api/prisma/schema.prisma (+3 fields en Tenant)
|
||
MOD apps/api/src/services/payment/invoicing.service.ts (getCustomerFromTenant + signature de buildInvoicePayload)
|
||
MOD apps/api/src/services/tenants.service.ts (getPreferenciasFacturacion + update)
|
||
MOD apps/api/src/controllers/facturacion.controller.ts (2 endpoints + Zod)
|
||
MOD apps/api/src/routes/facturacion.routes.ts (+2 routes)
|
||
NUEVO apps/web/app/(dashboard)/configuracion/facturacion/page.tsx
|
||
MOD apps/web/app/(dashboard)/configuracion/page.tsx (Card "Preferencias de Facturación")
|
||
```
|
||
|
||
### Nota deploy
|
||
|
||
Cero migración de datos requerida — los tenants existentes heredan los
|
||
defaults (`mis_datos` + `G03` + null). Como el primer pago de cada tenant
|
||
sigue siendo skip-eado por el Gate 4 (`isFirstApprovedPayment`), incluso si un
|
||
tenant existente tiene CSF cargada y datos correctos, el primer cobro
|
||
post-deploy lo facturará el admin manualmente y los subsecuentes ya saldrán
|
||
con datos reales automáticamente.
|
||
|
||
---
|
||
|
||
## 23. Onboarding auto-dismiss (4 logins ó pasos completados)
|
||
|
||
### Contexto
|
||
|
||
La pantalla `/onboarding` (los pasos: cuenta creada → contribuyente → FIEL →
|
||
CSD → equipo opcional → plan opcional) se mostraba **cada login** porque el
|
||
único mecanismo para marcarla como vista era el flag de localStorage
|
||
`horux360:onboarding_seen_v1`, que **solo lo seteaba el `OnboardingScreen`
|
||
del video de bienvenida** — un componente paralelo casi en desuso. La página
|
||
de pasos nunca tocaba el flag, así que owner/contador la veían eternamente
|
||
hasta que fueran al video, lo cual nunca pasaba.
|
||
|
||
### Reglas de auto-dismiss
|
||
|
||
El onboarding ya no se muestra cuando se cumple cualquiera de estas
|
||
condiciones (lo que pase primero):
|
||
|
||
1. **El user acumuló > 4 logins exitosos** — significa que ya navegó la
|
||
plataforma varias veces, presumiblemente porque conoce el flujo.
|
||
2. **El user completó todos los pasos requeridos** — `cuenta + contribuyente
|
||
+ FIEL + CSD` (los 4 no-opcionales del array `steps`).
|
||
|
||
Los logins se cuentan en backend para sobrevivir cambio de dispositivo /
|
||
limpieza de cookies. El dismiss se persiste como timestamp `users.onboarding_dismissed_at`.
|
||
|
||
### Schema (migración `20260502190000_add_user_login_count_onboarding_dismissed`)
|
||
|
||
```sql
|
||
ALTER TABLE "users"
|
||
ADD COLUMN "login_count" INTEGER NOT NULL DEFAULT 0,
|
||
ADD COLUMN "onboarding_dismissed_at" TIMESTAMP(3);
|
||
```
|
||
|
||
Prisma `User`:
|
||
```prisma
|
||
loginCount Int @default(0) @map("login_count")
|
||
onboardingDismissedAt DateTime? @map("onboarding_dismissed_at")
|
||
```
|
||
|
||
### Backend
|
||
|
||
**`auth.service.ts:login()`** ahora incrementa `loginCount` en la misma
|
||
transacción que actualiza `lastLogin` y `lastTenantId`:
|
||
```ts
|
||
const updatedUser = await prisma.user.update({
|
||
where: { id: user.id },
|
||
data: {
|
||
lastLogin: new Date(),
|
||
lastTenantId: activeTenant.id,
|
||
loginCount: { increment: 1 },
|
||
},
|
||
select: { loginCount: true, onboardingDismissedAt: true },
|
||
});
|
||
```
|
||
|
||
> **Importante:** se incrementa **solo en `login()`, NO en `refreshTokens()`**.
|
||
> Los refresh tokens disparan cada hora — si los contáramos, el threshold
|
||
> se cumpliría en horas, no en sesiones reales.
|
||
|
||
El `LoginResponse.user` extendido con los 2 campos:
|
||
```ts
|
||
loginCount: updatedUser.loginCount,
|
||
onboardingDismissedAt: updatedUser.onboardingDismissedAt,
|
||
```
|
||
|
||
**`auth.service.ts:dismissOnboarding(userId)`** — idempotente. Si ya estaba
|
||
dismissed, devuelve el timestamp original (no sobrescribe):
|
||
```ts
|
||
if (user.onboardingDismissedAt) {
|
||
return { onboardingDismissedAt: user.onboardingDismissedAt };
|
||
}
|
||
const updated = await prisma.user.update({
|
||
where: { id: userId },
|
||
data: { onboardingDismissedAt: new Date() },
|
||
select: { onboardingDismissedAt: true },
|
||
});
|
||
```
|
||
|
||
**Endpoint:** `POST /auth/onboarding/dismiss` (autenticado, sin body).
|
||
|
||
### Frontend
|
||
|
||
**`apps/web/lib/onboarding.ts` (nuevo):**
|
||
```ts
|
||
export const ONBOARDING_LOGIN_THRESHOLD = 4;
|
||
|
||
export function shouldShowOnboarding(user): boolean {
|
||
if (!user) return false;
|
||
if (user.onboardingDismissedAt) return false;
|
||
if ((user.loginCount ?? 0) > ONBOARDING_LOGIN_THRESHOLD) return false;
|
||
return true;
|
||
}
|
||
```
|
||
|
||
**`app/(auth)/login/page.tsx`** — la lógica de redirect post-login para
|
||
owner/cfo/contador pasa de chequear localStorage a usar el helper:
|
||
```ts
|
||
} else {
|
||
router.push(shouldShowOnboarding(response.user) ? '/onboarding' : '/dashboard');
|
||
}
|
||
```
|
||
|
||
**`app/(dashboard)/onboarding/page.tsx`** — useEffect que dispara el dismiss
|
||
cuando `allRequiredDone` se vuelve true:
|
||
```ts
|
||
useEffect(() => {
|
||
if (!allRequiredDone || dismissed || !user || user.onboardingDismissedAt) return;
|
||
setDismissed(true);
|
||
dismissOnboarding()
|
||
.then((res) => {
|
||
// Sync al store para que el siguiente login vaya directo a dashboard
|
||
// sin esperar a que el backend incremente loginCount > threshold.
|
||
setUser({ ...user, onboardingDismissedAt: res.onboardingDismissedAt });
|
||
})
|
||
.catch((err) => {
|
||
console.warn('[onboarding] Failed to mark as dismissed:', err);
|
||
setDismissed(false); // permite reintentar
|
||
});
|
||
}, [allRequiredDone, dismissed, user, setUser]);
|
||
```
|
||
|
||
`dismissed` es un flag local que evita el round-trip duplicado si la página
|
||
se re-renderiza por refetch de queries. El `setUser({...user, onboardingDismissedAt})`
|
||
sincroniza el store para que el helper `shouldShowOnboarding` lo respete
|
||
en futuros logins sin esperar al backend.
|
||
|
||
### Tabla de comportamiento
|
||
|
||
| loginCount | onboardingDismissedAt | Pasos requeridos | Resultado |
|
||
|---|---|---|---|
|
||
| 1–4 | null | incompletos | Muestra onboarding |
|
||
| 1–4 | null | **todos completos** | Muestra onboarding (con auto-dismiss en useEffect) → próximo login va a dashboard |
|
||
| 1–4 | timestamp | cualquiera | Va directo a dashboard |
|
||
| 5+ | null/timestamp | cualquiera | Va directo a dashboard |
|
||
|
||
### Decisión: backend vs localStorage
|
||
|
||
Se eligió backend (`User.loginCount`) sobre localStorage por:
|
||
1. **Cross-device:** un owner que usa Horux 360 en laptop + celular acumula
|
||
logins en ambos. Con localStorage cada dispositivo arrancaría su propio
|
||
contador y vería el onboarding 4 veces más por cada uno.
|
||
2. **Resistente a `localStorage.clear()`:** el contador no se pierde si el
|
||
user limpia caché del browser.
|
||
3. **Consistencia con el resto de preferencias persistentes** (todo en BD).
|
||
|
||
El localStorage `horux360:onboarding_seen_v1` queda **huérfano** — ya no se
|
||
lee en ningún lado. No se borra explícitamente porque es 1 línea de tech-debt
|
||
sin impacto. Si se quiere limpieza, agregar a `auth-store.logout()`:
|
||
```ts
|
||
localStorage.removeItem('horux360:onboarding_seen_v1');
|
||
```
|
||
|
||
### Threshold = 4
|
||
|
||
Constante exportada en `apps/web/lib/onboarding.ts`. Ajustable en un solo
|
||
punto. Lectura literal del requerimiento ("después del 4to inicio de
|
||
sesión"): se muestra durante logins 1–4, desaparece a partir del 5to.
|
||
Condición: `loginCount > 4`.
|
||
|
||
### Nota deploy
|
||
|
||
Tenants/users existentes tras el deploy arrancan con `loginCount = 0` (default
|
||
SQL). Aunque hayan iniciado sesión cientos de veces históricamente, el
|
||
contador se construye desde cero post-deploy — verán onboarding hasta
|
||
acumular 5 sesiones nuevas O hasta completar pasos requeridos. Dado que el
|
||
flujo viejo ya les mostraba el onboarding cada login, esto no es regresión.
|
||
|
||
### Archivos modificados/creados
|
||
|
||
```
|
||
NUEVO apps/api/prisma/migrations/20260502190000_add_user_login_count_onboarding_dismissed/migration.sql
|
||
MOD apps/api/prisma/schema.prisma (User: +2 fields)
|
||
MOD apps/api/src/services/auth.service.ts (login increment + dismissOnboarding)
|
||
MOD apps/api/src/controllers/auth.controller.ts (handler dismissOnboarding)
|
||
MOD apps/api/src/routes/auth.routes.ts (POST /auth/onboarding/dismiss)
|
||
MOD packages/shared/src/types/auth.ts (UserInfo: +2 optional fields)
|
||
NUEVO apps/web/lib/onboarding.ts (helper + threshold constant)
|
||
MOD apps/web/lib/api/auth.ts (dismissOnboarding fetcher)
|
||
MOD apps/web/app/(auth)/login/page.tsx (usa shouldShowOnboarding)
|
||
MOD apps/web/app/(dashboard)/onboarding/page.tsx (useEffect auto-dismiss + setUser sync)
|
||
```
|
||
|
||
---
|
||
|
||
## 24. Soporte wide-screen (breakpoints 3xl/4xl + 3 páginas)
|
||
|
||
### Contexto
|
||
|
||
Tailwind defaults terminan en `2xl: 1536px`. A 2560×1600 (target del cliente
|
||
Horux 360), grids tipo `lg:grid-cols-3` se quedan en 3 columnas y dejan
|
||
mucho whitespace lateral. Y páginas con `max-w-5xl mx-auto` (1024px) dejan
|
||
un canal central angosto rodeado de vacío.
|
||
|
||
### Cambio 1 — Breakpoints custom
|
||
|
||
`apps/web/tailwind.config.ts`:
|
||
```ts
|
||
screens: {
|
||
'3xl': '1920px',
|
||
'4xl': '2560px',
|
||
},
|
||
```
|
||
|
||
Tailwind tree-shakea las clases por content scan — agregarlas al config sin
|
||
usarlas en el código no las activa. Por eso solo aparecen las que se aplican
|
||
en las páginas modificadas abajo.
|
||
|
||
### Cambio 2 — 3 páginas top con peor adaptación
|
||
|
||
Auditoría previa ranqueó las páginas por impacto (lista en mensaje del user
|
||
+ asistente del chat). Top 3 elegidas:
|
||
|
||
**`/dashboard` línea 256** — desglose por régimen escala a 3 cols a 1920px+:
|
||
```diff
|
||
- <div className="grid gap-4 md:grid-cols-2">
|
||
+ <div className="grid gap-4 md:grid-cols-2 3xl:grid-cols-3">
|
||
```
|
||
(Hasta 3 cards: ingresos/egresos/IVA por régimen — antes quedaba la 3ª en
|
||
fila aparte a wide.)
|
||
|
||
**`/contribuyentes`** — lista de RFCs gestionados:
|
||
```diff
|
||
- <div className="p-6 max-w-5xl mx-auto space-y-6">
|
||
+ <div className="p-6 space-y-6">
|
||
```
|
||
```diff
|
||
- <div className="grid gap-3">{contribuyentes.map(...)}
|
||
+ <div className="grid gap-3 lg:grid-cols-2 3xl:grid-cols-3 4xl:grid-cols-4">{contribuyentes.map(...)}
|
||
```
|
||
Lista en stack vertical → grid escalable. A 2560px = hasta 4 cards por fila.
|
||
|
||
**`/despachos/contribuyentes`** — vista de stats del despacho (3 cards):
|
||
```diff
|
||
- <main className="p-6 max-w-5xl mx-auto">
|
||
+ <main className="p-6 max-w-7xl mx-auto">
|
||
```
|
||
(2 ocurrencias: vista "no enabled" y vista "enabled".)
|
||
Las 3 cards ya escalan a `lg:grid-cols-3` — solo se desclampea el contenedor
|
||
de 1024 a 1280px. No se quita totalmente porque 3 cards a ancho infinito se
|
||
ven ridículamente estiradas.
|
||
|
||
### Páginas NO tocadas y por qué
|
||
|
||
| Página | Motivo |
|
||
|---|---|
|
||
| `/configuracion` hub | Cards `<Link>` están en stack vertical (`space-y-6`), no grid. Refactorizar a grid implicaría tocar la estructura de cada card. ROI bajo. |
|
||
| `/onboarding`, `/seguridad`, `/configuracion/notificaciones`, `/mis-empresas` | Forms — `max-w-*` es **intencional** (forms anchos cansan vista) |
|
||
| `/cfdi` (lista principal) | Tabla expande naturalmente. Modales con `max-w-lg/3xl` están bien. |
|
||
| `/dashboard` KPI cards (línea 206) | 4 KPIs fijos en `lg:grid-cols-4`. Agregar variantes wide significaría columnas vacías. |
|
||
| `/impuestos` | Ya usa `lg:grid-cols-5` en KPIs |
|
||
| `/configuracion/planes-despacho` | Ya escala con `max-w-7xl + lg:grid-cols-4` |
|
||
|
||
### Forms vs listas — la regla
|
||
|
||
Regla de pulgar aplicada en este cambio:
|
||
- **Listas / tablas / dashboards** → full width o `max-w-7xl` (1280px) máximo
|
||
- **Forms / wizards / inputs** → clamp a 800-1000px (`max-w-3xl`-`max-w-5xl`)
|
||
|
||
El codebase ya respeta esto en muchos lados — los cambios fueron quirúrgicos
|
||
sobre los outliers que rompían la regla.
|
||
|
||
### Archivos modificados
|
||
|
||
```
|
||
MOD apps/web/tailwind.config.ts (+screens 3xl/4xl)
|
||
MOD apps/web/app/(dashboard)/dashboard/page.tsx (+3xl:grid-cols-3 desglose régimen)
|
||
MOD apps/web/app/(dashboard)/contribuyentes/page.tsx (-max-w-5xl + grid escalable)
|
||
MOD apps/web/app/(dashboard)/despachos/contribuyentes/page.tsx (max-w-5xl → max-w-7xl)
|
||
```
|
||
|
||
### Testing recomendado
|
||
|
||
Si no tenés monitor 4K nativo, hacé Ctrl+− (zoom 50%) en una pantalla
|
||
1920×1080 — equivale a renderizar a ~3840×2160 efectivo, cubre el caso
|
||
2560 con margen.
|
||
|
||
### Pendientes (segunda capa, no urgentes)
|
||
|
||
| Página | Mejora propuesta |
|
||
|---|---|
|
||
| `/reportes` | 7+ grids `md:grid-cols-2-4` sin variantes wide |
|
||
| `/calendario` | `lg:grid-cols-3` del layout principal |
|
||
| `/despachos/mis-asignados`, `/despachos/equipo` | `max-w-6xl mx-auto` clamp innecesario |
|
||
| `/configuracion` hub | Refactor cards a grid escalable |
|
||
|
||
---
|
||
|
||
## 25. Alerta RESICO PF cerca del límite anual ($2.5M / $3M)
|
||
|
||
### Contexto fiscal
|
||
|
||
LISR Art. 113-E — el contribuyente Persona Física que tributa en RESICO
|
||
debe **salir del régimen** si sus ingresos del ejercicio exceden
|
||
**$3,500,000 MXN**. Ahora bien:
|
||
|
||
> **Importante:** el SAT considera ingresos acumulados de **TODOS los
|
||
> regímenes** del contribuyente, no solo los del 626. Un PF con 626 + 612 +
|
||
> 606 ve la suma total para el cálculo del límite.
|
||
|
||
Esto era un punto ciego del sistema — los contadores tenían que sumar
|
||
manualmente ingresos de varios regímenes para saber si su cliente RESICO
|
||
estaba en riesgo de salir del régimen.
|
||
|
||
### Threshold elegido
|
||
|
||
| Ingresos del año | Prioridad | Mensaje |
|
||
|---|---|---|
|
||
| < $2,500,000 | (sin alerta) | — |
|
||
| $2,500,000 – $2,999,999 | media | "RESICO PF cerca del límite anual" |
|
||
| $3,000,000 – $3,499,999 | alta | "RESICO PF: cerca del límite ($3M+)" |
|
||
| ≥ $3,500,000 | alta | "RESICO PF: límite anual EXCEDIDO" — debe salir del régimen |
|
||
|
||
> **Nota:** el user pidió alerta a $2.5M citando el límite como "$3M". El
|
||
> límite legal real es **$3.5M** (Art. 113-E LISR reformado 2023). Se
|
||
> implementó el threshold pedido ($2.5M para arrancar la alerta), pero el
|
||
> mensaje y los escalones referencian el límite legal correcto.
|
||
|
||
### Reglas de aplicación
|
||
|
||
La alerta solo se genera cuando se cumplen las 3 condiciones:
|
||
1. **Hay un contribuyente seleccionado** — la alerta es per-entidad fiscal,
|
||
no per-tenant
|
||
2. **RFC de 13 caracteres** — Persona Física (RESICO PM no tiene este
|
||
límite, se filtra por longitud de RFC)
|
||
3. **Régimen 626 está en `contribuyentes.regimen_fiscal`** (CSV)
|
||
|
||
### Cálculo de ingresos
|
||
|
||
Query directo agregado, **sin filtrar por `regimen_fiscal_emisor`**:
|
||
```sql
|
||
SELECT COALESCE(SUM(
|
||
CASE
|
||
WHEN tipo_comprobante = 'I' AND metodo_pago = 'PUE' THEN COALESCE(total_mxn, 0)
|
||
WHEN tipo_comprobante = 'P' THEN COALESCE(monto_pago_mxn, 0)
|
||
WHEN tipo_comprobante = 'E' AND metodo_pago = 'PUE' THEN -COALESCE(total_mxn, 0)
|
||
ELSE 0
|
||
END
|
||
), 0)::numeric AS ingresos
|
||
FROM cfdis
|
||
WHERE type = 'EMITIDO'
|
||
AND status NOT IN ('Cancelado', '0')
|
||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||
AND contribuyente_id = $2
|
||
```
|
||
|
||
Componentes:
|
||
- **+ I PUE** — facturas cobradas al emitir (flujo efectivo simplificado)
|
||
- **+ P** — complementos de pago (cobros de PPD anteriores)
|
||
- **− E PUE** — notas de crédito netan los ingresos
|
||
|
||
Decisiones simplificatorias:
|
||
- **Sin desglose por régimen** — para la alerta basta el agregado bruto
|
||
- **Sin filtro de conciliación** — el toggle "Considerar conciliación" del
|
||
dashboard NO aplica aquí; el SAT mira todos los CFDIs vigentes
|
||
- **Sin exclusión de claves de conceptos** (seguros, salud, gobierno) —
|
||
conservador, el SAT los cuenta para el límite
|
||
- **`total_mxn` con IVA** — sobreestima ~16%, pero es proxy conservador
|
||
apropiado para alerta preventiva; si ya supera $2.5M con IVA, el
|
||
contador debe revisar manualmente
|
||
|
||
### Implementación
|
||
|
||
`apps/api/src/services/alertas-auto.service.ts`:
|
||
- Nueva función `alertaResicoPfLimiteIngresos(pool, contribuyenteId)`
|
||
- Registrada en `generarAlertasAutomaticas` array de `Promise.all`
|
||
- Sigue el patrón de las otras 12 alertas: shape `AlertaAuto` + return
|
||
`null` si no aplica
|
||
|
||
ID de la alerta: `resico-pf-limite-ingresos`. Tipo: `limite-regimen` (nuevo
|
||
tipo, no había alertas de este tipo antes).
|
||
|
||
### Por qué no hay drill-down
|
||
|
||
A diferencia de otras alertas (`discrepancia-regimen`, `lista-negra-clientes`),
|
||
esta no tiene `detalle: '/alertas/...'` porque no hay un listado de "CFDIs
|
||
problemáticos" — el problema es la suma agregada del año. El contador puede
|
||
ir a `/cfdi` con filtro de año si quiere ver el desglose, pero no es un
|
||
drill-down específico de la alerta.
|
||
|
||
### Archivos modificados
|
||
|
||
```
|
||
MOD apps/api/src/services/alertas-auto.service.ts (+~95 líneas: función nueva + registro)
|
||
```
|
||
|
||
Cero migraciones. Cero schema changes. Cero frontend changes (la página
|
||
`/alertas` ya consume `generarAlertasAutomaticas` y muestra cualquier alerta
|
||
que devuelva el array).
|
||
|
||
### Pendiente futuro
|
||
|
||
- **Threshold y límite configurables** — si LISR cambia el límite, hoy hay
|
||
que editar 3 constantes en código. Mover a `apps/api/src/config/fiscal.ts`
|
||
o a una tabla `parametros_fiscales` en BD central.
|
||
- **Variantes para otros regímenes con límite** — IF (621) tiene límite
|
||
$2M, PF Empresarial sin límite pero con tope de RESICO si quiere optar.
|
||
Refactorizar a una función genérica `alertaLimiteRegimen(pool, regimen, limite)`.
|
||
- **Si el cálculo "TODOS los regímenes" debe excluir alguno** — por ejemplo
|
||
régimen 605 (sueldos) o 614 (intereses) que se reportan distinto. Por ahora
|
||
suma todos. Confirmar con contador si es exacto.
|
||
|
||
---
|
||
|
||
## 26. SAT — reuso de requestIds en retries + políticas de retry por tipo
|
||
|
||
### Contexto: 2 problemas relacionados
|
||
|
||
**Problema 1 (reuse):** El SAT impone un límite de solicitudes activas/recientes
|
||
por RFC. Cada `requestAndDownload` creaba una **nueva** solicitud al SAT,
|
||
incluso en reintentos — el `satRequestId` original se sobrescribía sin
|
||
consultarlo. Si una sync diaria timeouteaba, los 3 reintentos generaban 3
|
||
requests adicionales. En jobs `initial` con N bloques, podía agotarse la
|
||
cuota del SAT y empezar a recibir rechazos.
|
||
|
||
**Problema 2 (timing):** El cálculo de `nextRetryAt` usaba `Date.now() + 6h`
|
||
en el momento del timeout, NO `startedAt + 6h`. Si timeoutea a T+45min, el
|
||
reintento quedaba programado para T+6h45min en vez de T+6h. Más críticamente:
|
||
todos los tipos de sync usaban la misma política (3 retries × 6h), sin
|
||
diferenciar urgencia/contexto.
|
||
|
||
### Cambio 1 — Reuso de requestIds (mapa por job)
|
||
|
||
**Schema (migración `20260502210000_add_sat_sync_jobs_request_ids_map`):**
|
||
```sql
|
||
ALTER TABLE "sat_sync_jobs"
|
||
ADD COLUMN "sat_request_ids" JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||
```
|
||
|
||
```prisma
|
||
satRequestIds Json @default("{}") @map("sat_request_ids")
|
||
```
|
||
|
||
**`kindKey` para identificar requests dentro de un job:**
|
||
```ts
|
||
function makeRequestKindKey(fechaInicio, fechaFin, tipoCfdi, requestType) {
|
||
return `${requestType}-${tipoCfdi}-${fechaInicio.slice(0,10)}-${fechaFin.slice(0,10)}`;
|
||
}
|
||
```
|
||
Cubre los N requests del initial (varios bloques de fechas) y los 4 del daily.
|
||
|
||
**Persistencia atómica con merge SQL** (evita race conditions):
|
||
```ts
|
||
async function persistSatRequestId(jobId, kindKey, requestId) {
|
||
await prisma.$executeRawUnsafe(
|
||
`UPDATE sat_sync_jobs
|
||
SET sat_request_ids = COALESCE(sat_request_ids, '{}'::jsonb) || $1::jsonb,
|
||
sat_request_id = $2
|
||
WHERE id = $3`,
|
||
JSON.stringify({ [kindKey]: requestId }),
|
||
requestId,
|
||
jobId,
|
||
);
|
||
}
|
||
```
|
||
Conserva `satRequestId` (singular) actualizándolo al último, para backward
|
||
compat de queries existentes (`getSyncStatus`, UI). El mapa plural es la
|
||
fuente de verdad del tracking.
|
||
|
||
**Refactor `requestAndDownload`:**
|
||
1. Lee `job.satRequestIds[kindKey]` → si existe, intenta reuso
|
||
2. `verifySatRequest(existingId)`:
|
||
- `ready` → salta polling, descarga directa
|
||
- `processing`/`pending` → entra al polling con MISMO id
|
||
- `failed`/`rejected`/excepción → fallback a `querySat` (crea nuevo)
|
||
3. Si crea nuevo, persiste con `persistSatRequestId`
|
||
|
||
**Beneficio principal:** las 6h de espera del retry ya no son tiempo
|
||
desperdiciado — el SAT puede terminar el request en background, y el retry
|
||
solo verifica + descarga.
|
||
|
||
### Cambio 2 — Políticas de retry por tipo
|
||
|
||
**Schema (migración `20260502230000_add_sat_sync_jobs_is_custom_range`):**
|
||
```sql
|
||
ALTER TABLE "sat_sync_jobs"
|
||
ADD COLUMN "is_custom_range" BOOLEAN NOT NULL DEFAULT false;
|
||
```
|
||
|
||
`isCustomRange = type === 'initial' && (!!dateFrom || !!dateTo)` — distingue
|
||
bootstrap puro (sin fechas, default 6 años atrás) de UI custom range.
|
||
|
||
**Constantes:**
|
||
```ts
|
||
const RETRY_POLICIES = {
|
||
daily: { maxRetries: 2, retryAtHours: [6, 12] },
|
||
custom: { maxRetries: 2, retryAtHours: [6, 12] },
|
||
initial: { maxRetries: 3, retryAtHours: [6, 12, 24] },
|
||
incremental: { maxRetries: 0, retryAtHours: [] },
|
||
};
|
||
|
||
function getRetryPolicy(job) {
|
||
if (job.type === 'initial' && job.isCustomRange) return RETRY_POLICIES.custom;
|
||
return RETRY_POLICIES[job.type];
|
||
}
|
||
```
|
||
|
||
**Helper `computeNextRetryAt`** — basado en `startedAt`, no `Date.now()`:
|
||
```ts
|
||
function computeNextRetryAt(startedAt, nextRetryNumber, policy) {
|
||
const idx = nextRetryNumber - 1;
|
||
if (idx >= policy.retryAtHours.length) return null;
|
||
return new Date(startedAt.getTime() + policy.retryAtHours[idx] * 3600_000);
|
||
}
|
||
```
|
||
|
||
**Removidas:** constantes `MAX_RETRIES = 3` y `RETRY_DELAY_HOURS = 6`.
|
||
|
||
### Tabla resumen del comportamiento
|
||
|
||
| Tipo | Max retries | Tiempos absolutos desde `startedAt` |
|
||
|---|---|---|
|
||
| `daily` | 2 | T+6h, T+12h |
|
||
| `initial` + custom range | 2 | T+6h, T+12h |
|
||
| `initial` bootstrap (sin fechas) | 3 | T+6h, T+12h, T+24h |
|
||
| `incremental` | **0** | — (next cron cubre el gap) |
|
||
|
||
### Línea de tiempo: daily timeoutea a las 3:00 AM
|
||
|
||
| Hora | Evento |
|
||
|---|---|
|
||
| 3:00 | `startSync` crea Job. Genera REQ-1, persiste en mapa. Polling cada 60s |
|
||
| 3:00 → 3:45 | 45 polls. SAT responde `processing` siempre |
|
||
| **3:45** | `MAX_POLL_ATTEMPTS` agotado → timeout. `nextRetryAt = startedAt + 6h = 9:00 AM` (no 9:45). Job → `pending`, retryCount=1 |
|
||
| 4:00 | Cron retry corre. nextRetryAt > now, no toca |
|
||
| 4:30 (T+90min) | **Job durmiendo.** Watchdog ignora (no aplica thresholds) |
|
||
| **9:00** | Cron retry corre, `nextRetryAt <= now`. Levanta job. Verify REQ-1 → si SAT terminó en las 6h, `ready` → download directo (sin polling). Si sigue procesando → polling con MISMO REQ-1 |
|
||
| Si retry 1 falla 9:45 | `nextRetryAt = 15:00`, retryCount=2 |
|
||
| Si retry 2 falla 15:45 | `2 + 1 = 3 > maxRetries(2)` → `failed` |
|
||
|
||
### Línea de tiempo: incremental timeoutea
|
||
|
||
| Hora | Evento |
|
||
|---|---|
|
||
| 11:00 | Cron incremental dispara, crea job. Polling |
|
||
| 11:45 | Timeout. `maxRetries=0` → `nextRetryNumber(1) > 0` → directo a `failed` con mensaje *"Timeout en sync incremental — sin reintentos por política. Próximo cron incremental cubrirá el gap."* |
|
||
| 15:00 | Cron incremental siguiente arranca un job NUEVO (no es retry). La ventana de 8h cubre los CFDIs perdidos del intento 11:00 (dedup por UUID) |
|
||
|
||
### Decisiones del diseño
|
||
|
||
- **Tiempos absolutos desde `startedAt`** (no desde último intento ni desde
|
||
ahora): respeta la regla "6h después" sin acumular tiempo de polling.
|
||
- **`isCustomRange` como flag separado** vs nuevo enum value (`type='custom'`):
|
||
evita migrar el enum SatSyncType y romper queries/tipos existentes. El flag
|
||
es ortogonal al tipo y solo aplica a `initial`.
|
||
- **`incremental` sin retries**: la ventana del cron (cada 4h, lookup 8h) se
|
||
solapa con el siguiente — un fallo aislado se recupera automáticamente sin
|
||
duplicar trabajo. Reintentar agregaría carga al SAT sin beneficio.
|
||
- **No filtrar `retryCount < MAX_RETRIES` en query SQL** (`retryTimedOutJobs`):
|
||
el max es por-policy (varía por type+isCustomRange). El catch del retry ya
|
||
valida y marca failed si excede. Filtrar en SQL requeriría CASE complejo.
|
||
|
||
### Archivos modificados
|
||
|
||
```
|
||
NUEVO apps/api/prisma/migrations/20260502210000_add_sat_sync_jobs_request_ids_map/migration.sql
|
||
NUEVO apps/api/prisma/migrations/20260502230000_add_sat_sync_jobs_is_custom_range/migration.sql
|
||
MOD apps/api/prisma/schema.prisma (+satRequestIds Json, +isCustomRange Boolean)
|
||
MOD apps/api/src/services/sat/sat.service.ts (helpers + refactor catch en runSyncJob y retryTimedOutJobs)
|
||
```
|
||
|
||
### Pendientes
|
||
|
||
- **Limpieza del mapa `satRequestIds` cuando job completa**: hoy queda como
|
||
audit trail. Si crece descontrolado (jobs initial con muchos bloques),
|
||
considerar purgar via cron mensual.
|
||
- **Cancelar requestIds huérfanos en SAT**: cuando `verifySatRequest` devuelve
|
||
`failed/rejected` y el sistema crea uno nuevo, el viejo sigue existiendo
|
||
en el SAT hasta que expire (~72h). No hay API explícita de "cancelar
|
||
request" en el SDK actual; si la hubiera, valdría llamarla en el fallback.
|
||
|
||
---
|
||
|
||
## Pendientes documentados
|
||
|
||
1. **IVA No Acreditable** sin drill-down ni cache (sección 11)
|
||
2. **Backfill en producción** de P emitidos vía Facturapi (sección 20) —
|
||
correr `apps/api/scripts/backfill-pago-fields.ts` después de deploy +
|
||
`refresh-metricas-cache.ts` para invalidar meses afectados
|
||
3. **Refresh-metricas-cache** debe correrse cada vez que cambia una fórmula
|
||
fiscal — automatizar como part del deploy hook si los cambios son
|
||
frecuentes
|
||
4. **Predicados NULL-unsafe sospechosos** en el codebase (sección 15) —
|
||
auditar `cfdi_tipo_relacion = '07'`, `metodo_pago = 'PUE'`, etc. cuando
|
||
se usan con NOT
|
||
5. **Auto-facturación: opción de saltar gate del primer pago** — hoy el primer
|
||
cobro de cualquier tenant lo factura el admin a mano. Si la confianza en
|
||
la calidad de los CSF aumenta, podríamos eliminar el Gate 4 cuando el
|
||
tenant tenga `factPreferencia='mis_datos' + CSF + datos completos`
|