# 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`
