# Sesión 2026-04-21 (Parte 2) — Setup cobro MP + bugfixes CSF y descartes

## Resumen ejecutivo

Sesión continuada del mismo día. Arrancó con intención de implementar cobro
MercadoPago para planes despacho (Tanda 1), pero durante la auditoría y pruebas
emergieron varios bugs fiscales severos que se resolvieron integralmente:

1. **CSF auto-fill rompe en despachos con contribuyentes multi-régimen** (Alexa) — schema.
2. **Descartes de CFDI no aparecen surtidos en el drilldown** — query filter.
3. **CFDIs duplicados por UUID case-sensitive** (1426 duplicados en Patito) — sync SAT + constraint.
4. **Drill-downs no filtraban por contribuyente** — fix en `cfdi.controller.ts` + frontend.
5. **Cobro MP para planes despacho (Tanda 1)** — enum, catálogo, flujo completo.
6. **Ingresos de CFDIs tipo P agrupados por fecha_emision en vez de fecha_pago_p** — afecta dashboard, reportes, impuestos IVA/ISR, drill-down. Fix integral (Opción B).

La extracción SAT que se interrumpió para aplicar la migración Prisma se dejó
en `pending` con `next_retry_at = +5 min` para que el cron horario la retome
automáticamente tras el reinicio del dev server.

---

## 1. Fix CSF auto-fill — regimen_fiscal ampliado a TEXT

**Problema reportado:** Al agregar a la contribuyente Alexa en el despacho Zorro,
se descargó la CSF correctamente pero los campos `regimen_fiscal`, `codigo_postal`
y `domicilio` de la tabla `contribuyentes` quedaron NULL.

**Causa raíz:**
- Migración tenant `010_contribuyentes.sql` declaró `regimen_fiscal varchar(3)`,
  asumiendo un solo régimen por contribuyente.
- `sincronizarDatosFiscales` en `constancia.service.ts` genera CSV cuando hay
  múltiples regímenes activos (ej. `"626,605"` para Alexa: RESICO + Sueldos).
- Postgres rechaza el UPDATE con `"el valor es demasiado largo para el tipo
  character varying(3)"`.
- El error se captura silenciosamente en `constancia.service.ts:386` → la CSF
  queda guardada pero la sincronización nunca se aplica.

**Por qué Patito sí funcionaba:** alguien aplicó un `ALTER TABLE` manual en el
pasado (sin migración). Verificado via `information_schema.columns`:
- `horux_despacho_mo3ni6u8_b9vgg` (Patito): `text`
- `horux_despacho_mo7je8bz_vdopr` (Zorro): `varchar(3)` (original)

**Fix:** Nueva migración tenant `025_contribuyentes_regimen_fiscal_text.sql`:

```sql
ALTER TABLE contribuyentes ALTER COLUMN regimen_fiscal TYPE text;

INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 25, '025_contribuyentes_regimen_fiscal_text')
ON CONFLICT (scope, version) DO NOTHING;
```

Aplicada manualmente via psql a Zorro (no se tocó Patito porque ya estaba
correcto). Idempotente: en tenants ya normalizados, es no-op funcional.

**Estado por despacho:**

| Despacho | Schema `regimen_fiscal` | Migración 025 registrada |
|---|---|---|
| Zorro | `text` (aplicado por esta sesión) | ✅ Sí |
| Patito | `text` (parche manual previo) | ⚠️ Se auto-registra al próximo lazy-migrate en `getPool()` |
| Nuevos despachos | `varchar(3)` → `text` (migraciones 010 y 025 en orden) | ✅ Sí |

**Data fix de Alexa:**

`UPDATE contribuyentes SET regimen_fiscal, codigo_postal, domicilio` con los
datos parseados de su CSF ya guardada en BD — evita regenerar otra solicitud al
SAT. Valores aplicados:

- `regimen_fiscal = '626,605'`
- `codigo_postal = '44230'`
- `domicilio` = JSON completo (calle PONTEVEDRA, colonia SANTA ELENA ESTADIO,
  GUADALAJARA, JALISCO, etc.)

**Archivos tocados:**
- `apps/api/src/migrations/tenant/025_contribuyentes_regimen_fiscal_text.sql` — **nuevo**
- Data directa: `horux_despacho_mo7je8bz_vdopr.contribuyentes` fila de Alexa

**Propagación:**
- Tenants existentes con el schema ya corregido: no requieren acción.
- Tenants existentes con el schema sin corregir: al próximo acceso a
  `getPool(tenantId)` se aplica la migración 025 automáticamente (lazy-migrate).
- Despachos nuevos: la migración 025 se aplica en orden via el migration runner
  tras la 010.

**Mejoras futuras (fuera de alcance de esta sesión):**
- El parser de CSF a veces mete el valor de "Nombre de la Colonia" dentro del
  campo `numeroInterior` por layout de dos columnas en el PDF. Ya existe código
  de cleanup (`cleanDomField`, `extractEmbedded`) pero no es 100% robusto.
- No hay backfill para otros contribuyentes que pudieran haber fallado el sync
  antes de este fix (verificado: Alexa era la única).

---

## 2. Fix discrepancia-regimen — drilldown excluye CFDIs descartados

**Problema reportado:** En `/alertas/discrepancia-regimen`, al seleccionar CFDIs
y pulsar "Descartar", los mismos CFDIs seguían apareciendo en la lista → parecía
que el botón no guardaba nada.

**Causa raíz:** Había dos queries separadas sobre discrepancias de régimen, y
solo una excluía CFDIs descartados:

| Query | Archivo | Excluía `cfdi_descartados`? |
|---|---|---|
| Panel de alertas (contador agregado) | `alertas-auto.service.ts:42` | ✅ Sí |
| Drilldown (`GET /alertas/drilldown/discrepancia-regimen`) | `alertas.controller.ts:286` | ❌ **No** |

Por eso:
- Contador de la alerta del panel bajaba al descartar (servicio sí filtraba).
- Lista del drilldown seguía mostrando los mismos CFDIs (controller no filtraba).
- El usuario percibía que el descarte no se guardaba.

De hecho **sí se guardaban**: verificado que Zorro ya tenía 27 filas en
`cfdi_descartados`.

**Fix:** una línea SQL en `alertas.controller.ts:306`:

```sql
AND id NOT IN (SELECT cfdi_id FROM cfdi_descartados WHERE tipo_alerta = 'discrepancia-regimen')
```

Con esto, el comentario del frontend en `page.tsx:70` (`// descartados already
excluded by backend`) se vuelve verdad. Antes era un supuesto incorrecto que
ocultó el defecto.

**Archivos tocados:**
- `apps/api/src/controllers/alertas.controller.ts` — 1 línea agregada a
  `getDiscrepanciaRegimen`

**Efecto:** los 27 CFDIs ya descartados en Zorro dejan de aparecer en la lista.
El endpoint de restauración (`DELETE /alertas/descartar`) existe para revertir
si algún descarte fue por error (el botón UI para restaurar no fue revisado
esta sesión).

---

## 3. Cobro MP para planes despacho (Tanda 1 — COMPLETADA)

**Objetivo:** Permitir que el owner de un despacho (ej. `jd@demo.com` para
Patito) se suscriba a `business_control` ($21,000/año) o `business_cloud`
($15,000/año) vía MercadoPago, con upgrade/change/cancel/reactivate soportados.

### Auditoría del estado previo

Lo que **ya existe**:

- Catálogo `DESPACHO_PLANS` en `packages/shared/src/constants/despacho-plans.ts`
  con 3 entradas: `trial`, `business_control`, `business_cloud` con límites,
  features y `dbMode`.
- Página `/configuracion/planes-despacho/page.tsx` con 2 cards (Business
  Control, Business Cloud) y precios ya mostrados.
- Endpoint `GET /api/despachos/me/plan` que devuelve `{plan, dbMode, trialEndsAt,
  isTrialActive}`.
- Flujo completo Horux360: `subscription.service.ts` con `subscribe`,
  `startTrial`, `scheduleChange`, `initiateUpgrade`, `reactivateSubscription`,
  `cancelSubscription`. Todos usan `getPlanPrice(plan, frequency)` que consulta
  tabla `plan_prices` en BD central.
- Webhook MP en `webhook.controller.ts` con routing por `external_reference`
  (`timbres-pack:`, `proration:`, `addon:`, o UUID tenant).
- Middleware `plan-limits.middleware.ts` que bloquea escrituras si la sub no
  está `authorized` o `pending` (permite GETs).
- Helper `isDespachoTenant(rfc)` en `despacho-plans.ts` que detecta por prefijo
  `DESPACHO_`.
- `Subscription.plan`, `pendingPlan`, `upgradeTargetPlan` y `Tenant.plan` todos
  son `Plan` (enum Prisma).

Lo que **falta** (gaps identificados):

1. Enum Prisma `Plan` no contiene `business_control` ni `business_cloud`. Sus
   valores actuales: `starter, business, business_ia, custom, enterprise`.
2. `VALID_PLANS` en `subscription.controller.ts` sólo acepta los 4 de Horux360.
3. `getPlanPrice` consulta `plan_prices` tabla; los precios despacho están en
   constants, no en BD.
4. `validatePlanFrequency` no fuerza `frequency: 'annual'` para planes despacho
   (el usuario pidió solo anual).
5. Página `/configuracion/planes-despacho` **no tiene botón "Suscribirse"** ni
   flujo de checkout.
6. Middleware de bloqueo permite GETs; el usuario pidió bloquear todo menos
   `/subscriptions/*` cuando no paga (pendiente de Tanda 2).
7. Add-on recurrente `$45/RFC/mes` para Business Cloud: el sistema de addons
   existe pero no hay catálogo para `rfc_despacho` ni wiring al crear
   contribuyente (pendiente de Tanda 2).

### Plan acordado

**Tanda 1 (esta sesión, cobro funcional):**
1. Extender enum Prisma `Plan` con `business_control`, `business_cloud`.
2. Agregar `DESPACHO_PLAN_PRICES` en `despacho-plans.ts`.
3. Extender type local `Plan` + `getPlanPrice` en `subscription.service.ts`
   para branchear a constantes cuando es despacho.
4. Extender `VALID_PLANS` en controller + validación `annual`-only para
   despacho.
5. Agregar botones "Suscribirse" / "Cambiar plan" / "Plan actual" en la página
   `/configuracion/planes-despacho`.
6. `window.open(paymentUrl, '_blank')` al recibir URL del endpoint.
7. `pnpm typecheck` final.

**Tanda 2 (próxima sesión):**
- Bloqueo total fuera de `/subscriptions/*` para despachos sin pago activo.
- Add-on recurrente `$45/RFC/mes` con multi-preapproval MP.

### Ejecución (continuación el mismo día, tras marcar la extracción en pending+retry)

La extracción se marcó como `pending` con `next_retry_at = NOW() + 5 min` para
que el cron horario del SAT sync la retomara automáticamente al relanzar el dev
server. Después:

1. **Enum Prisma extendido:**
   - `apps/api/prisma/schema.prisma` → agregados `business_control`,
     `business_cloud` al enum `Plan`.
   - `pnpm prisma migrate dev --name despacho_plan_enum_values` generó
     `apps/api/prisma/migrations/20260421062505_despacho_plan_enum_values/migration.sql`
     con solo `ALTER TYPE ... ADD VALUE` (verificado — no reescribe enum).
   - Prisma client regenerado con tipos actualizados.

2. **Catálogo de precios despacho** (`packages/shared/src/constants/despacho-plans.ts`):
   ```ts
   export const DESPACHO_PLAN_PRICES = {
     business_control: 21000,
     business_cloud: 15000,
   } as const;

   export type DespachoPaidPlan = keyof typeof DESPACHO_PLAN_PRICES;
   export function isDespachoPaidPlan(plan: string): plan is DespachoPaidPlan { ... }
   ```
   Fuente de verdad única de precios despacho. No se usa `plan_prices` (esa
   tabla es solo para planes Horux360 editables por admin).

3. **Branch de precio en servicio** (`apps/api/src/services/payment/subscription.service.ts`):
   - Type local `Plan` extendido con `business_control | business_cloud`.
   - `getPlanPrice` branchea: si `isDespachoPaidPlan(plan)` → valida que
     `frequency === 'annual'` y devuelve `DESPACHO_PLAN_PRICES[plan]`. Si no →
     sigue consultando `plan_prices`.
   - **Cero cambios a `subscribe`, `scheduleChange`, `initiateUpgrade`,
     `reactivateSubscription`** — todos reutilizan `getPlanPrice` y funcionan
     out-of-the-box para despacho. El webhook routing por `external_reference =
     tenantId` sigue igual.

4. **Validación en controller** (`apps/api/src/controllers/subscription.controller.ts`):
   - `VALID_PLANS` extendido a los 6 valores.
   - `validatePlanFrequency` rechaza `monthly` para planes despacho con mensaje
     claro.
   - Defensa-en-profundidad: `getPlanPrice` valida de nuevo en capa de servicio.

5. **UI de contratación** (`apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx`):
   - Reescrita con botón de acción por card.
   - Flujo inteligente: primer intento `subscribeMe({plan, frequency:'annual'})`;
     si el backend responde "Ya existe una suscripción...", cae automáticamente
     a `changeMyPlan` → el usuario ve UN botón sin tener que elegir entre
     acciones.
   - Botón contextual: "Contratar plan" / "Contratar (terminar prueba)" /
     "Cambiar a este plan" / "Plan actual".
   - Link pequeño abajo "Cancelar suscripción" → solo visible con plan pagable
     activo (llama `cancelMySubscription`).
   - `window.open(paymentUrl, '_blank')` tras subscribe exitoso.
   - Toast de éxito/error.

6. **Typecheck limpio** en api y web en los archivos tocados.

7. **Dev server reiniciado** con los cambios de Prisma + código aplicados.
   Extracción Zorro quedó en `pending` con retry automático por el cron del
   SAT sync.

### Lo que quedó fuera de Tanda 1 (va a Tanda 2)

- Bloqueo total fuera de `/subscriptions/*` para despachos sin pago activo.
- Add-on recurrente `$45/RFC/mes` para Business Cloud (tabla
  `plan_addon_catalogo` + wiring al crear contribuyente + multi-preapproval MP).

---

## 4. Fix fecha efectiva para CFDIs tipo P (flujo de efectivo real)

**Problema reportado:** KPI "Ingresos del Mes" para Husberto Astorga (TOAH,
régimen 612) en mayo 2025 mostraba $1,372,731 pero el valor fiscalmente correcto
validado por el usuario era **$1,132,806**. El drill-down mostraba además un
monto distinto ($1,314,054 con impuestos) que tampoco cuadraba.

**Causa raíz:** Todas las queries fiscales del sistema (dashboard, reportes,
impuestos) sumaban los CFDIs tipo P filtrando por `fecha_emision` — la fecha en
que se emitió el complemento de pago. Eso es incorrecto: el ingreso se reconoce
en la **fecha real del pago** (`fecha_pago_p`, del atributo `FechaPago` del nodo
`<pago20:Pago>`), no cuándo se emitió el complemento.

**Evidencia dura** (Astorga folio 60, mayo 2025):
```xml
<cfdi:Comprobante Fecha="2025-05-17T20:53:25" TipoDeComprobante="P">
  <pago20:Pago FechaPago="2024-11-11T12:00:00" Monto="278313.00">
    <pago20:DoctoRelacionado IdDocumento="1A9EF6D6-...-51262" Folio="1077" ...>
```
El cliente pagó el 11-nov-2024, pero el complemento se emitió 6 meses después.
Triangulado contra la PPD folio 1077 emitida el 14-nov-2024 — consistente.

**Alcance del bug:** Cualquier P emitido en un mes distinto al del cobro real
caía en el mes equivocado. Común en despachos donde el contador consolida
pagos mensuales en complementos emitidos con retraso. Afectaba:
- Dashboard: "Ingresos del Mes" y "Gastos del Mes" (flujo efectivo PF Empresarial)
- Dashboard: "Balance IVA" (s2/r2)
- Reportes: flujo de efectivo mensual + serie anual
- Impuestos: IVA mensual trasladado/acreditable, `getResumenIva` (totales y
  por régimen), acumulado anual
- Drill-down: las fechas no coincidían con los KPIs

**Fix integral (Opción B):**

Dos patrones complementarios:

**A. `dashboard.service.ts` y `reportes.service.ts`** — helper paralelo
`FECHA_PAGO_RANGO` + `getFechaPagoRango()`:
```ts
const FECHA_PAGO_RANGO = `fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day')`;
function getFechaPagoRango(conciliacion?: boolean): string {
  return conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_PAGO_RANGO;
}
```
Las 4 queries de P en `calcularIngresosPorRegimen` Grupo PF, `calcularEgresosPorRegimen`,
y las 2 queries de P en `calcularIvaBalancePorRegimen` (`s2` y `r2`) cambiaron
de `${FR}` a `${FR_PAGO}`.

En reportes, los 2 queries de `getFlujoEfectivo` (entradasPago, salidasPago)
usan `TO_CHAR(fecha_pago_p, 'YYYY-MM')` + `RANGO_PAGO`. En `calcularFlujoPorMes`
la función `q()` branchea: si `tc === 'P'` usa `fecha_pago_p` para `EXTRACT` y
rango; si no, `fecha_emision`.

**B. `impuestos.service.ts`** — helper `FECHA_EFECTIVA` con CASE inline:
```ts
const FECHA_EFECTIVA = `CASE WHEN tipo_comprobante = 'P' THEN fecha_pago_p ELSE fecha_emision END`;
const FECHA_RANGO = `${FECHA_EFECTIVA} >= $1::date AND ${FECHA_EFECTIVA} < ($2::date + interval '1 day')`;
```
Con solo cambiar la definición del constant, **todas** las queries que usan
`${FR}` se arreglan automáticamente. Es la elegancia de indirection — no tocamos
ninguna query existente que ya usaba `${FR}`.

En `getIvaMensual` sí hubo que modificar las queries que usan `EXTRACT(MONTH FROM
fecha_emision)` directo (sin `${FR}`) para que usen `EXTRACT(MONTH FROM
${FECHA_EFECTIVA})` en trasladado y acreditable. El query de retención conserva
`fecha_emision` — los complementos P no tienen retención propia.

**C. `cfdi.controller.ts:drillDown`** — el filtro de fecha usa el mismo pattern
CASE para que el drill-down sea coherente con los KPIs. Un P que cobró en
noviembre 2024 y se emitió en mayo 2025 aparecerá en el drill-down de noviembre,
no en el de mayo.

**Validación del fix:**
Ejecutado SQL idéntico al que correrá el código. Para Husberto Astorga régimen
612 mayo 2025: `I PUE (915,134.22) + P con fecha_pago_p en mayo (217,671.37) =
**$1,132,805.59**` — match exacto con la validación del usuario.

**Efecto colateral esperado:** los ingresos de meses anteriores donde había
pagos recibidos pero con complemento P emitido después aparecerán correctamente
ahora. Si un mes se validó antes del fix, su número cambiará — correctamente.

**No cambia:** IVA retenido (los complementos P no cargan retención propia),
queries sobre N (nóminas), queries sobre E (notas de crédito), modo
conciliación (ese usa la tabla `conciliaciones` por diseño).

---

## 5. Dualidad de precio Business Control ($21K primer año, $15K renovaciones)

**Necesidad:** Business Control cobra $21,000 el primer año (setup + primera
anualidad) y $15,000 cada renovación anual. MP Preapproval no soporta "primer
año X, después Y" en una sola suscripción — siempre cobra el mismo monto.

**Opción B implementada (un solo click, un solo preapproval):**

1. Al contratar, el backend crea el preapproval MP con `amount = firstYear`
   ($21,000). El `reason` del preapproval explica ambos montos para que el
   usuario vea en MP: `"Plan business_control - $21,000 primer año, $15,000
   renovaciones"`.
2. User autoriza una vez en MP → paga $21,000 ahora.
3. MP envía webhook `payment.approved`.
4. El webhook, al detectar la transición `pending → authorized` (primer pago)
   de una sub de plan despacho con dualidad, llama `mpService.updatePreapprovalAmount`
   para bajar el monto recurrente a `renewal` ($15,000). Actualiza también
   `Subscription.amount = 15000`.
5. Al aniversario, MP cobra $15,000 automáticamente via preapproval. Sin
   intervención adicional.

Patrón tomado de `applyApprovedUpgrade` (usado en upgrades con prorateo) — ya
probado en producción. Fail-safe: si `updatePreapprovalAmount` falla, el
webhook no se rompe; queda log para intervención manual.

**Archivos tocados:**
- `packages/shared/src/constants/despacho-plans.ts` — `DESPACHO_PLAN_PRICES`
  ahora `{ firstYear, renewal }` por plan + nuevos helpers `DespachoPricePhase`
  y `despachoPlanTieneDualidad()`.
- `apps/api/src/services/payment/subscription.service.ts` — `getPlanPrice`
  acepta parámetro `phase: 'firstYear' | 'renewal'` con default `'renewal'`;
  `subscribe()` usa `firstYear` y arma `reason` explicativo si hay dualidad.
- `apps/api/src/controllers/webhook.controller.ts` — rama nueva al primer
  pago aprobado que baja preapproval y actualiza `Subscription.amount`.
- `apps/api/src/services/despacho.service.ts` — usa catálogo único
  `DESPACHO_PLAN_PRICES` en lugar de precios hardcoded.

**Planes que afecta:**

| Plan | firstYear | renewal | ¿Tiene dualidad? |
|---|---|---|---|
| business_control | $21,000 | $15,000 | ✅ Sí — webhook baja tras primer pago |
| business_cloud | $15,000 | $15,000 | ❌ No — flujo normal sin ajustes |

**Planes Horux360 estándar** (starter/business/business_ia/enterprise): el
parámetro `phase` se ignora para ellos. `getPlanPrice` sigue leyendo de la
tabla `plan_prices`. Cero regresión.

**Flujos con dualidad aplicada:**
- `subscribe()` (self-serve desde `/configuracion/planes-despacho`) → `firstYear` + webhook downgrade ✅
- `signupDespacho()` (registro nuevo en `/register-despacho`) — refactorizado para usar `subscribe()` internamente ✅

**Flujos sin dualidad (usan `renewal` por default):**
- `scheduleChange()` — el cambio se aplica en el próximo periodo; MP ya cobró
  el periodo actual en el otro plan; no hay "primer año" del nuevo plan.
- `initiateUpgrade()` — upgrade con prorateo; el cliente ya está en un plan,
  no es primera contratación.
- `reactivateSubscription()` — el cliente canceló pero está dentro de periodo
  pagado; reactivar lo pone de vuelta al ciclo recurrente normal.

Si en el futuro quieres que `reactivate` o `upgrade` apliquen `firstYear`, se
añade el parámetro `phase` explícito en el callsite correspondiente.

---

## 6. Refactor signup de despacho + UI de estado de suscripción

Dos seguimientos a la dualidad de precio (Tanda Opción B).

### 6a. `signupDespacho` ahora usa `subscribe()` internamente

**Bug encontrado:** El registro nuevo de despacho (`/register-despacho`) creaba
el preapproval MP directamente con `mpService.createPreapproval()`, **sin crear
la fila `Subscription` en BD**. Consecuencia: cuando MP enviaba el webhook
`payment.approved`, el handler buscaba una subscription por tenantId y no
encontraba nada → no podía aplicar la lógica de downgrade firstYear→renewal.
Los usuarios que se registraban por esa ruta pagaban $21K cada año en lugar
de bajar a $15K en la renovación.

**Fix:** `despacho.service.ts:signupDespacho` refactorizado para llamar
`subscriptionService.subscribe({ tenantId, plan, frequency: 'annual', payerEmail })`
tras crear tenant/user/membership. Esto crea la fila `Subscription` correcta
(con status=pending, amount=firstYear, mpPreapprovalId guardado) y retorna
`paymentUrl` igual que antes — misma API para el frontend.

Manejo de errores preservado: si el cobro falla, se elimina tenant/user/
membership (rollback) con mensajes diferenciados según causa (MP no configurado
vs otro error).

Después del fix, el flujo de signup completo soporta dualidad.

### 6b. UI de estado de suscripción

**Problema:** Tras contratar, el usuario veía "Plan actual" en su card pero no
tenía forma de saber si el primer pago fue exitoso, cuándo será la próxima
renovación ni cuánto le cobrarán.

**Fix:**

- **Backend:** `/api/despachos/me/plan` ahora retorna campo `subscription: { status, plan, amount, currentPeriodStart, currentPeriodEnd }` además del plan info básico. Query a la tabla `subscriptions` filtra por `status ∈ { authorized, pending, paused, trial }` tomando la más reciente.
- **Frontend `/configuracion/planes-despacho/page.tsx`:** nuevo banner verde debajo del trial banner (solo visible con plan pagable activo) que muestra:
  - Plan contratado
  - Fecha de próxima renovación
  - Monto del próximo cobro
  - Nota "(tarifa de renovación — el primer año ya fue cubierto)" cuando detecta que el amount actual es `$15K` para business_control (heurística de dualidad aplicada).

**Patrón del banner:** similar al trial banner — layout, colores, iconografía
consistentes, usa `CheckCircle2` en verde.

---

## 7. Activación sistema hot/cold métricas — Tanda A (cimientos)

**Objetivo:** poblar y mantener actualizada la tabla `metricas_mensuales` para
que consumers (dashboard/impuestos/reportes) puedan en Tanda B leer métricas
pre-calculadas en lugar de recomputar. Infraestructura encontrada al 30%
(tablas + funciones write/read + invalidación parcial), el 70% restante era
compute/backfill/cron/invalidación completa.

### Archivos nuevos

- `apps/api/src/services/metricas-compute.service.ts` — core:
  `computeMetricaMensual` (agrega desde cfdis raw y upsert 1 fila por régimen),
  `backfillTenant`, `processInvalidations`, `processAllTenantsInvalidations`.
- `apps/api/src/jobs/metricas-invalidations.job.ts` — cron cada 15 min con
  lock anti-solapamiento.
- `apps/api/scripts/backfill-metricas.ts` — CLI con `--dry`, filtros por
  tenant y rango de años via env vars.
- `apps/api/scripts/validate-metricas.ts` — validación automatizada: toma 5
  muestras aleatorias por contribuyente y compara tabla vs on-the-fly.

### Archivos modificados

- `apps/api/src/index.ts` — registra el cron (solo `NODE_ENV=production`).
- `apps/api/src/services/sat/sat.service.ts` — `saveCfdis` y `saveMetadata`
  marcan invalidación por cada CFDI insertado/updated, usando `fecha_pago_p`
  para tipo P.
- `apps/api/src/controllers/facturacion.controller.ts` — `cancelar` captura
  fecha del CFDI antes del UPDATE y marca invalidación para el mes afectado.

### Campos poblados (MVP)

`ingresos_cobrados`, `egresos_pagados`, `iva_trasladado_total`, `iva_acreditable`,
`iva_retenido_cobrado`, `iva_resultado`, `utilidad_realizada`, `flujo_entradas/
salidas/neto`, `cfdis_emitidos_count`, `cfdis_recibidos_count`, `cfdis_cancelados_count`.

### Campos en 0 (iteración futura)

Desglose IVA por tasa (16/8/0/exento), `iva_retenido_pagado`, `iva_a_favor_mes`,
ISR completo (`isr_ingresos_brutos`, `base`, `causado`, `retenido`, `a_pagar`),
IEPS, CxC/CxP, `ingresos_devengados/egresos_devengados/utilidad_devengada`.

### Backfill inicial aplicado

- Patito: 3 contribuyentes → 284 filas en `metricas_mensuales`.
- Zorro: 1 contribuyente (Alexa) → 62 filas.
- **Total: 346 filas.**

### Validación ejecutada

20 muestras aleatorias (5 por contribuyente × 4 contribuyentes) — cobertura:
régimenes 601/605/612/616/621/626, años 2020-2025, ambos tenants. Cada muestra
compara 6 campos (ingresos, egresos, 4 de IVA). **Total 120 comparaciones con
tolerancia $0.01: 100% PASS.**

Esto confirma que el pipeline de escritura reutiliza la lógica canónica de los
servicios on-the-fly y produce los mismos valores — condición necesaria para
Tanda B (switch de consumers).

### Invalidación: puntos cubiertos vs pendientes

**Cubiertos:**
- Upload manual XML (ya existía) — `cfdi.service.ts:455, :521`
- SAT sync `saveCfdis` (nuevo) — con `fecha_pago_p` para tipo P
- SAT sync `saveMetadata` (nuevo)
- Cancelación via Facturapi (`cancelar` en facturacion.controller) (nuevo)

**Limitación conocida:** cambios en el flag `id_conciliacion` de CFDIs (toggle
de conciliación) no disparan invalidación. Si el tenant usa el modo conciliación
activa, las métricas cached no reflejarán cambios hasta el próximo backfill
completo. Queda como iteración futura.

---

## 8. Activación sistema hot/cold métricas — Tanda B (read-through)

**Objetivo:** que los consumers (dashboard/impuestos/reportes) lean métricas
pre-calculadas de `metricas_mensuales` para meses pasados en vez de recomputar
desde CFDIs raw. Solo para rangos de meses completos en años pasados con
contribuyente seleccionado; año actual y rangos parciales siguen on-the-fly.

### Alcance de Tanda B MVP

Solo `calcularIngresosPorRegimen` y `calcularEgresosPorRegimen` en
`dashboard.service.ts` tienen read-through cache activo. Por transitividad se
benefician también:
- `getIsrMensual` (llama `calcularIngresosPorRegimen` por mes)
- `getResumenIsr` (idem)
- Los KPIs del dashboard principal
- Cualquier otro consumer que dependa de esas dos funciones

**NO cubiertos por cache** (siguen on-the-fly al 100%):
- `getIvaMensual`, `getResumenIva` en impuestos (usan queries directas sobre
  cfdis, no via `calcularIngresosPorRegimen`)
- `getFlujoEfectivo`, `calcularFlujoPorMes` en reportes
- Queries directas de drill-down en cfdi.controller.ts

Ampliar a estos consumers es iteración futura — requiere agregar campos al
cache (desglose IVA por tasa, retenidos por direction, etc.).

### Helper `planCache`

`dashboard.service.ts` ahora incluye un helper que determina si una query
puede servirse desde cache:
- `contribuyenteId` debe estar presente (sin él, no hay filas en la tabla).
- `conciliacion = false` (la tabla no modela id_conciliacion).
- `fechaFin < primer día del año actual` (año en curso siempre on-the-fly).
- `fechaInicio` debe ser día 1 del mes.
- `fechaFin` debe ser último día del mes.

Si cumple: `readIngresosFromCache`/`readEgresosFromCache` hace `SUM` agregando
sobre `make_date(anio, mes, 1) BETWEEN $start AND $end` y retorna `porRegimen`
como lo haría on-the-fly. Si no hay filas cacheadas: retorna `null` y el
caller cae al path on-the-fly existente.

### Validación

Re-corrida del script `validate-metricas.ts`: 3 ejecuciones consecutivas, 20
muestras cada una (5 por contribuyente × 4 contribuyentes), **100% PASS para
campos `ingresos_cobrados` y `egresos_pagados`** — los campos servidos por el
cache.

### Bug encontrado durante validación — RESUELTO

Al correr `validate-metricas.ts` detecté que `iva_retenido_cobrado` en
`metricas_mensuales` estaba almacenado como `0.00` cuando el valor on-the-fly
calculado por `getResumenIva` era distinto de cero (caso confirmado: Horux
360 Dec 2025 régimen 612 debía ser `$3,904.96`).

**Diagnóstico inicial incorrecto:** sospeché contaminación entre llamadas
secuenciales a `getResumenIva` (por pool compartido bajo límite de 3
conexiones). Instrumenté `DEBUG_RESUMEN_IVA` en `impuestos.service.ts:279` y
`contribuyente-context.ts:26`. El log demostró que `getResumenIva` retornaba
los valores correctos en cada llamada — no había contaminación.

**Causa raíz:** `upsertMetricaMensual` en `apps/api/src/services/metricas.service.ts`
nunca escribía los campos `iva_retenido_cobrado` ni `iva_retenido_pagado`.
Las columnas existen en el schema (migración 014) con `DEFAULT 0`, el tipo
`MetricaMensual` las declara, `computeMetricaMensual` las calcula
correctamente y las pasa en el objeto `data` — pero la lista de columnas
del `INSERT` (y el `UPDATE SET` del `ON CONFLICT`) las omitía. Todas las
filas heredaban silenciosamente el `DEFAULT 0`.

TypeScript no lo detectó porque `data: Partial<MetricaMensual>` permite
campos faltantes por diseño. El default de la columna enmascaraba el gap:
un mes sin retenciones legítimamente se veía idéntico a un mes con
retenciones no persistidas.

**Fix aplicado (`metricas.service.ts:148-191`):**
- Agregadas las columnas `iva_retenido_cobrado` e `iva_retenido_pagado` al
  `INSERT`, la cláusula `VALUES`, el `UPDATE SET` del `ON CONFLICT`, y el
  array de parámetros.
- Renumerados placeholders: ahora `$1..$29` (antes `$1..$27`).

**Backfill re-ejecutado:** 346 filas rescritas (Patito 284 + Zorro 62).

**Validación:** `validate-metricas.ts` — 20/20 PASS (5 muestras aleatorias
× 4 contribuyentes). Todos los campos IVA, no solo `iva_retenido_cobrado`,
validan céntimo por céntimo contra el cálculo on-the-fly.

**Limpieza:** removida la instrumentación `DEBUG_RESUMEN_IVA` de
`impuestos.service.ts` y `contribuyente-context.ts` (solo se usó durante
el diagnóstico).

### Archivos modificados

- `apps/api/src/services/dashboard.service.ts` — nuevos helpers `planCache`,
  `readIngresosFromCache`, `readEgresosFromCache` + integración al top de
  `calcularIngresosPorRegimen` y `calcularEgresosPorRegimen`.

### Efecto esperado en performance

Para consultas históricas de dashboard (meses pasados con contribuyente
seleccionado), el cache reduce de ~10-15 queries SQL (los 3 grupos de
ingresos/egresos con sus sub-queries) a 1 query SQL agregada. Reducción
aproximada de 90% en queries al Postgres del tenant. El año actual y rangos
parciales siguen on-the-fly — sin regresión.

### Tanda B.2 — Extensión a IVA (dashboard + impuestos)

Tras resolver el bug de `iva_retenido_cobrado`, las columnas IVA de
`metricas_mensuales` son confiables y se usan como fuente del cache.

**Cobertura agregada:**
- Dashboard: `calcularIvaBalancePorRegimen` ahora hace read-through. Lee
  `iva_trasladado_total - iva_acreditable` por régimen agregado del rango
  cacheado. Beneficia también a `calcularIvaAFavorAcumulado` que llama a esta
  función mes-a-mes en bucle (cache hit por mes).
- Impuestos: `getResumenIva` ahora hace read-through. Lee `iva_trasladado_total`,
  `iva_acreditable`, `iva_retenido_cobrado` por régimen. Calcula totales y
  desgloses desde la fila cacheada. El `acumuladoAnual` (rango distinto:
  enero-fechaFin) sigue on-the-fly — una sola query.

**Refactor:** se extrajo `planCache` y `CacheRange` de `dashboard.service.ts`
a `apps/api/src/utils/metricas-cache.ts` para que ambos servicios lo importen
sin duplicación.

**Escape hatch para validación:** `planCache` respeta `METRICAS_BYPASS_CACHE=1`
y retorna `null` aunque el rango califique. Permite que `validate-metricas.ts`
compare cache-vs-stored y on-the-fly-vs-stored sin mantener una segunda copia
de la lógica.

**Validación:**
- `METRICAS_BYPASS_CACHE=1 pnpm exec tsx scripts/validate-metricas.ts` →
  20/20 PASS (raw on-the-fly vs metricas_mensuales).
- Sin la flag → 20/20 PASS (cache path vs metricas_mensuales).
- Combinados: cache path ≡ raw on-the-fly céntimo por céntimo para los 6
  campos validados (ingresos, egresos, ivaTras, ivaAcr, ivaRet, ivaResultado).

**Fuera de alcance Tanda B.2:** `getIvaMensual`, `getIsrMensual`,
`getResumenIsr` siguen on-the-fly (requieren tarifas progresivas / lógica que
no está pre-calculada). `calcularFlujoPorMes` en reportes igual — pendiente
de evaluación si vale el esfuerzo dado que el flujo en cache (`flujo_neto`)
es por mes pero el reporte presenta serie temporal.

### Tanda B.3 — Alineación dashboard.balance ≡ impuestos.resultado

**Problema reportado:** el usuario notó diferencia entre "Balance IVA" del
dashboard y "Resultado" de Control de Impuestos. Ambos deberían coincidir, y
el usuario confirmó que la fórmula correcta es la del dashboard (IVA neto
embebido en cada bucket); el bug estaba en Control de Impuestos.

**Fórmula canónica (post-refactor):** `Resultado = Trasladado − Acreditable − Retenido`
donde las tres tarjetas usan los 6 buckets del dashboard, con retención
exhibida en su propia tarjeta:

- `Trasladado` = Σ causado **bruto** sobre 3 buckets: (Emit+I+PUE), (Emit+P), (Recib+E+PUE) — NC recibida
- `Acreditable` = Σ acreditable **bruto** sobre 3 buckets: (Recib+I+PUE), (Recib+P), (Emit+E+PUE) — NC emitida
- `Retenido` = retención(causado) − retención(acreditable) — retención **neta**

Algebraicamente `T − A − R ≡ dashboard.balance`. La ventaja es que Control de
Impuestos ahora puede mostrar la retención por separado (útil para
declaraciones) sin perder alineación con el balance.

**Cambios:**
- `impuestos.service.ts`: `getResumenIva` reescrito. 2 queries agregadas
  (una por lado causado/acreditable, agrupada por régimen del tenant con
  `CASE WHEN type='EMITIDO' THEN regimen_fiscal_emisor ELSE regimen_fiscal_receptor END`).
  Filtra por `TODOS_REGIMENES` para alinear exclusión de régimen 616/etc.
  Acumulado anual aplicó la misma fórmula y el mismo filtro.
- Se elevaron a constantes de archivo: `IVA_TRAS_EXPR`, `IVA_RET_EXPR`,
  `REGIMEN_TENANT`, `BUCKET_CAUSADO`, `BUCKET_ACREDITABLE` — usados tanto
  por la función on-the-fly como por el cache.
- `dashboard.service.ts`: `readIvaBalanceFromCache` reescrito para leer las
  tres columnas (`iva_trasladado_total`, `iva_acreditable`, `iva_retenido_cobrado`)
  y computar `monto = T − A − R`. Antes solo hacía `T − A`, lo que dejaba
  fuera la retención neta cuando el cache devolvía datos post-refactor
  (los valores almacenados son brutos, no netos).
- `apps/api/src/services/dashboard.service.ts`:`calcularIvaBalancePorRegimen`
  ahora se exporta para que el validador la pueda llamar externamente.

**Backfill:** re-ejecutado dos veces — una tras el refactor de fórmula, otra
tras agregar el filtro `TODOS_REGIMENES`. 346 filas rescritas cada vez.

**Validación:** nuevo script `scripts/validate-dashboard-impuestos.ts` que
toma 5 muestras aleatorias por contribuyente y compara
`dashboard.calcularIvaBalancePorRegimen().total` vs `impuestos.getResumenIva().resultado`.
- `METRICAS_BYPASS_CACHE=1` → 20/20 PASS (on-the-fly puro).
- Sin bypass → 20/20 PASS (cache + cache).
- Juntos: el cache de dashboard y el cache de impuestos leen de las mismas
  columnas almacenadas y producen totales idénticos.

**Bug sutil descubierto durante la validación:** el primer intento con cache
reportó 1 FAIL en Horux Dec 2025 (Δ=$3,904.96). Causa: `readIvaBalanceFromCache`
en dashboard solo restaba A, no R. Fix aplicado arriba. Cuando el refactor
cambia la semántica de las columnas almacenadas (de "IVA neto embebido" a
"componentes brutos + retención separada"), todas las funciones que leen del
cache deben actualizarse en lockstep — si no, hit rates divergen de on-the-fly.

### Tanda B.4 — Lock de sync SAT por contribuyente (no tenant)

**Problema reportado:** al iniciar la sincronización inicial de Manuel el
usuario recibió "Ya hay una sincronización en curso". La investigación mostró
que había un job `pending` de Alexa del **2026-04-21 05:12** con
`errorMessage = "Interrupted for MP setup; auto-retry scheduled"` — quedó
colgado porque el cron horario no lo retomó (posiblemente por ventana entre
`nextRetryAt` y el tick siguiente, o cron inactivo en dev).

**Causa raíz estructural:** el lock en `satSyncJob` era a nivel **tenant**
(`where: { tenantId, status: in [pending, running] }`). En un despacho con N
contribuyentes, cada uno tiene su propio FIEL y su propio conjunto de CFDIs
— serializarlos no tiene sentido.

**Cambios:**
- `apps/api/src/services/sat/sat.service.ts:945` — `startSync` usa ahora
  `where: { tenantId, contribuyenteId: contribuyenteId ?? null, status: ... }`.
  Contribuyentes distintos dentro del mismo despacho pueden sincronizar en
  paralelo; `null` (modo Horux 360) solo choca contra sí mismo.
- `apps/api/src/services/sat/sat.service.ts:1058` — `retryFailedJobs` aplica
  el mismo patrón: solo pospone el reintento si hay otro sync corriendo
  para el **mismo** (tenant, contribuyente).
- Job stale de Alexa (`id=830bac32-…`) marcado como `failed` manualmente
  con mensaje `"Abandoned: stale job from MP setup session"`. La sincronización
  inicial de Alexa se puede relanzar desde el UI cuando se requiera.

**Pendiente derivado:** revisar el cron de retry (`sat-sync.job.ts`) para
confirmar que en dev con `NODE_ENV !== 'production'` igual se ejecuta. Si el
cron solo arranca en prod, habría que cambiar el gate o correr retry
manualmente tras reinicios largos. **Confirmado 2026-04-22:** `apps/api/src/index.ts:16`
gate es `env.NODE_ENV === 'production'` → en dev **ningún cron arranca**,
incluyendo retry y watchdog. Por eso Alexa quedó colgada. Fix pendiente:
levantar los crons también en dev, o proveer endpoint admin para dispararlos.

### Tanda B.5 — Watchdog CLI para SAT stale jobs

**Contexto:** tras Tanda B.4 quedó claro que el cron `retryTimedOutJobs`
confía en que sí corra. Si no (dev, crash, despliegue largo), los jobs
pendientes con `nextRetryAt` pasado quedan invisibles hasta que alguien
choca con el lock. Además, jobs `running` pueden quedar huérfanos si el
proceso de Node se reinicia con un sync de fondo en curso — la solicitud al
SAT se pierde pero el registro en BD queda para siempre.

**Solución:** `apps/api/scripts/sweep-stale-sat-jobs.ts` — **nuevo**. CLI
que marca como `failed` dos categorías:

- `pending` con `nextRetryAt < now − STALE_PENDING_HOURS` (default 12h).
- `running` con `startedAt < now − STALE_RUNNING_HOURS` (default 4h).

Dry-run por default, requiere `--apply` para escribir. Thresholds
sobreescribibles vía env. Pattern similar al que usé manualmente para
marcar el job de Alexa.

**Verificado 2026-04-22:** dry-run limpio (0 pending stale, 0 running stale)
con Manuel corriendo a los ~35 min (bajo el umbral de 4h).

**Pendiente:** integrar como cron en `sat-sync.job.ts` (p. ej. cada 2h)
para que sea automático. Requiere reinicio del API para wiring — queda
documentado abajo.

```ts
// Agregar en sat-sync.job.ts junto a los demás cron.schedule:
const WATCHDOG_CRON_SCHEDULE = '0 */2 * * *'; // cada 2 horas
let watchdogTask: ReturnType<typeof cron.schedule> | null = null;

watchdogTask = cron.schedule(WATCHDOG_CRON_SCHEDULE, async () => {
  try {
    // importar de scripts/sweep-stale-sat-jobs.ts refactorizado a función exportable,
    // o duplicar la lógica inline.
    await sweepStaleSatJobs({ apply: true });
  } catch (err) {
    console.error('[SAT Watchdog] Error:', (err as Error).message);
  }
}, { timezone: 'America/Mexico_City' });
```

(Para Horux 360, las mismas constantes y la misma CLI — ver
`docs/Horux_despachos-vs-Horux360.md`.)

---

## Archivos tocados esta sesión

### Backend
- `apps/api/src/migrations/tenant/025_contribuyentes_regimen_fiscal_text.sql` — **nuevo** (fix CSF)
- `apps/api/src/migrations/tenant/026_normalize_cfdi_uuid_case.sql` — **nuevo** (cleanup duplicados UUID case-sensitive)
- `apps/api/src/migrations/tenant/027_cfdi_uuid_unique_case_insensitive.sql` — **nuevo** (índice funcional defensivo)
- `apps/api/src/controllers/alertas.controller.ts` — 1 línea en `getDiscrepanciaRegimen` (fix descartes)
- `apps/api/src/controllers/cfdi.controller.ts` — `drillDown` filtra por `contribuyenteId` + usa `FECHA_EFECTIVA` (CASE por tipo P)
- `apps/api/prisma/schema.prisma` — `enum Plan` extendido con 2 valores (Tanda 1 MP)
- `apps/api/prisma/migrations/20260421062505_despacho_plan_enum_values/migration.sql` — **nuevo** (Tanda 1 MP)
- `apps/api/src/services/payment/subscription.service.ts` — import + type `Plan` + branch en `getPlanPrice` (Tanda 1 MP)
- `apps/api/src/controllers/subscription.controller.ts` — `VALID_PLANS` + `DESPACHO_ONLY_ANNUAL` + validación annual-only (Tanda 1 MP)
- `apps/api/src/services/sat/sat.service.ts` — `saveCfdis` + `saveMetadata` normalizan UUID a lowercase y matchean case-insensitive
- `apps/api/src/services/dashboard.service.ts` — `FECHA_PAGO_RANGO` + `getFechaPagoRango()`; 4 queries de P usan `${FR_PAGO}` (fix fecha efectiva)
- `apps/api/src/services/reportes.service.ts` — `RANGO_PAGO` + `TO_CHAR(fecha_pago_p)`; `calcularFlujoPorMes.q()` branchea por tipo (fix fecha efectiva)
- `apps/api/src/services/impuestos.service.ts` — `FECHA_EFECTIVA` (CASE inline) en `FECHA_RANGO`; `EXTRACT` en `getIvaMensual` (fix fecha efectiva)
- `apps/api/src/services/documentos-extras.service.ts` — **nuevo** (tab Extras #12)
- `apps/api/src/migrations/tenant/028_documentos_extras.sql` — **nuevo** (tab Extras #12)
- `apps/api/src/controllers/documentos.controller.ts` — 5 handlers de extras (tab Extras #12)
- `apps/api/src/routes/documentos.routes.ts` — 5 rutas `/documentos/extras/*` (tab Extras #12)
- `apps/api/src/services/metricas.service.ts` — **fix bug Tanda A**: `upsertMetricaMensual` ahora incluye `iva_retenido_cobrado` e `iva_retenido_pagado` en INSERT/UPDATE (antes se quedaban en DEFAULT 0)
- `apps/api/src/utils/metricas-cache.ts` — **nuevo** (Tanda B.2): `planCache` + `CacheRange` extraídos para reuso entre dashboard e impuestos; respeta `METRICAS_BYPASS_CACHE=1` para validación
- `apps/api/src/services/impuestos.service.ts` — Tanda B.2: `getResumenIva` lee `metricas_mensuales` (trasladado/acreditable/retenido por régimen) cuando rango cae en años pasados con contribuyente seleccionado. **Tanda B.3:** refactor completo para alinear con dashboard — `Trasladado`/`Acreditable` usan los 6 buckets del dashboard (3 causado + 3 acreditable con PUE + NC); `Retenido` = retCausado − retAcreditable (neta). Filtra `TODOS_REGIMENES`. Constantes SQL elevadas a file-level: `IVA_TRAS_EXPR`, `IVA_RET_EXPR`, `REGIMEN_TENANT`, `BUCKET_CAUSADO`, `BUCKET_ACREDITABLE`
- `apps/api/src/services/dashboard.service.ts` — Tanda B.2: `calcularIvaBalancePorRegimen` lee `iva_trasladado_total - iva_acreditable` desde `metricas_mensuales`; `planCache`/`CacheRange` ahora importados del util compartido. **Tanda B.3:** `readIvaBalanceFromCache` ahora resta también `iva_retenido_cobrado` (fórmula `T − A − R`); `calcularIvaBalancePorRegimen` exportada para validación externa
- `apps/api/scripts/validate-dashboard-impuestos.ts` — **nuevo** (Tanda B.3): compara `dashboard.balance` vs `impuestos.resultado` en 5 muestras por contribuyente. Soporta `METRICAS_BYPASS_CACHE=1` para on-the-fly puro
- `apps/api/src/services/sat/sat.service.ts` — **Tanda B.4**: lock de `startSync` y `retryFailedJobs` ahora es a nivel `(tenantId, contribuyenteId)`, no tenant-wide. Permite sync paralelo entre contribuyentes de un mismo despacho
- `apps/api/scripts/sweep-stale-sat-jobs.ts` — **nuevo (Tanda B.5)**: CLI watchdog para marcar como `failed` jobs `pending` con `nextRetryAt` > 12h atrás o `running` con `startedAt` > 4h atrás. Dry-run por default; `--apply` ejecuta
- `apps/api/src/index.ts` — **B**: crons ahora arrancan en dev con `ENABLE_CRONS_IN_DEV=1` (weekly-update sigue prod-only para no mandar emails reales)
- `apps/api/src/services/sat/sat-client.service.ts` — **rejection logging**: cuando status es `rejected`/`failed`, `verifySatRequest` retorna mensaje con `SAT code=N request=EntryId(value) msg="..."` en vez del genérico wrapper
- `apps/api/src/services/impuestos.service.ts` — **C**: `getIvaMensual` refactorizado. On-the-fly usa los 6 buckets del dashboard (alineación con getResumenIva); cache read-through desde metricas_mensuales para años pasados con contribuyente. Helper `readIvaMensualFromCache` nuevo
- `apps/api/prisma/migrations/20260422172323_subscription_addons_contribuyente_id/migration.sql` — **nuevo**: agrega `contribuyente_id` a `subscription_addons`, elimina UNIQUE(subscription, addon), agrega índice (subscription, contribuyente)
- `apps/api/prisma/schema.prisma` — `SubscriptionAddon.contribuyenteId String?` opcional; sin `@@unique` compuesto (validación a nivel app)
- `apps/api/prisma/seed.ts` — agrega 2 add-ons al catálogo central: `lolita_ia_contribuyente` ($250/mes) y `contribuyente_extra_business_cloud` ($45/mes)
- `apps/api/src/services/payment/addon.service.ts` — `subscribeAddon` acepta `contribuyenteId` opcional; validación "ya tienes activo" ahora considera contribuyenteId; `listActiveAddons` filtra por contribuyenteId; el `reason` del preapproval incluye prefix del RFC cuando aplica
- `apps/api/src/controllers/subscription.controller.ts` — `getMyAddons` lee `?contribuyenteId=` del query; `addMyAddon` lee `contribuyenteId` del body
- `apps/web/lib/api/addons.ts` + `apps/web/lib/hooks/use-addons.ts` — **nuevos**: cliente API + hooks para `/subscriptions/me/addons?contribuyenteId=`
- `apps/web/app/(dashboard)/contribuyentes/addons-dialog.tsx` — **nuevo**: dialog dedicado con catálogo `ADDONS_POR_CONTRIBUYENTE` (ahora solo `lolita_ia_contribuyente` a $250/mes). Muestra estado, fecha próximo cobro, botones Contratar (abre MP en nueva pestaña) / Cancelar
- `apps/web/app/(dashboard)/contribuyentes/page.tsx` — icono `Sparkles` por contribuyente abre el dialog
- **Revertido**: el primer modelo (tabla tenant `contribuyente_addons` con feature toggles) fue el diseño equivocado. Los add-ons reales son servicios de cobro recurrente ligados a `SubscriptionAddon` (BD central) con preapproval MP independiente (la licencia es anual, los add-ons mensuales)
- `apps/api/src/controllers/webhook.controller.ts` — downgrade preapproval tras primer pago (Opción B dualidad)
- `apps/api/src/services/payment/subscription.service.ts` — `getPlanPrice(phase)` + `subscribe()` con firstYear y reason explicativo (Opción B dualidad)
- `apps/api/src/services/despacho.service.ts` — `signupDespacho` refactorizado para usar `subscribe()` (registra Subscription en BD + aplica dualidad)
- `apps/api/src/controllers/despacho.controller.ts` — `getMyPlan` retorna objeto `subscription` con status/plan/amount/periodo

### Shared
- `packages/shared/src/constants/despacho-plans.ts` — `DESPACHO_PLAN_PRICES` con shape `{ firstYear, renewal }`, `DespachoPaidPlan`, `DespachoPricePhase`, `isDespachoPaidPlan`, `despachoPlanTieneDualidad` (Tanda 1 MP + Opción B dualidad)

### Frontend
- `apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx` — flujo de contratar/cambiar/cancelar + banner de estado de suscripción activa (Tanda 1 MP + UI dualidad)
- `apps/web/app/(dashboard)/documentos/page.tsx` — nueva pestaña "Extras" con componente `ExtrasTab` + `UploadExtraDialog` (#12)
- `apps/web/lib/api/documentos.ts` — 5 funciones cliente para extras (#12)
- `apps/web/app/(auth)/register-despacho\page.tsx` — precios actualizados $21K/$45

### Data directa
- `horux_despacho_mo7je8bz_vdopr` (Zorro):
  - `ALTER TABLE contribuyentes ALTER COLUMN regimen_fiscal TYPE text;`
  - `INSERT INTO tenant_migrations (...) VALUES ('vertical-contable', 25, ...)`
  - `UPDATE contribuyentes SET regimen_fiscal, codigo_postal, domicilio` para
    Alexa (RFC `TORA0007099R6`)
  - `UPDATE sat_sync_jobs SET status='pending', next_retry_at=NOW()+5min` para
    el job de extracción inicial de 6 años (se marcó pending para dejar que el
    cron retomara tras reinicio).
- `horux_despachos` (central):
  - `ALTER TYPE "Plan" ADD VALUE 'business_control'`
  - `ALTER TYPE "Plan" ADD VALUE 'business_cloud'`
  - (Estos fueron después formalizados por `prisma migrate dev` en el archivo
    de migración listado arriba.)

### Externo
- Ninguno.

---

## Pendientes derivados de esta sesión

### Nuevos (agregados al cierre)

1. **Filtro "solo vigentes" en discrepancia-régimen.** Verificar que el
   drilldown `GET /alertas/drilldown/discrepancia-regimen` y el panel de
   alertas (`alertas-auto.service.ts`) excluyan CFDIs cancelados. Revisar:
   - Ahora mismo la query ya filtra `status NOT IN ('Cancelado', '0') AND
     fecha_cancelacion IS NULL` — verificar que sea suficiente, o si hay CFDIs
     con estatus raro (p.ej. "Proceso de cancelación") que se estén colando.
   - Considerar añadir filtro explícito `status = 'Vigente'` como defense-in-depth.

2. **UI para restaurar CFDIs descartados.** El endpoint
   `DELETE /alertas/descartar` ya existe pero no hay botón en UI. Propuesta:
   - Tab "Descartados" en `/alertas/discrepancia-regimen` que muestra los CFDIs
     en `cfdi_descartados` con botón "Restaurar".
   - O mostrarlos en la lista principal con un toggle "Mostrar descartados"
     con badge y botón por fila.

### De sesión anterior (siguen abiertos)

3. **Recrear org Facturapi de Carlos** (TORC9611214CA): el usuario elimina +
   crea nueva; después hay que actualizar `facturapi_orgs.facturapi_org_id` en
   BD tenant de Patito.
4. **Validación preventiva CSD↔RFC** en `uploadCsdContribuyente`: que el RFC
   del certificado coincida con el RFC del contribuyente antes de subirlo.
5. **Prueba cross-contribuyente end-to-end**: Horux 360 → Carlos → (sin org) →
   validar aislamiento, reset de formulario, mensajes de error.
6. **Regeneración de métricas ISR pre-calculadas**: las tablas
   `metricas_mensuales` / `acumuladas_anuales` (años pasados en hot/cold) pueden
   haberse calculado con la lógica antigua de `getIsrMensual`. Verificar si
   requieren recompute para años previos.

### Activación del sistema hot/cold de métricas (nuevo — grande)

La infraestructura existe al 30%:
- Tabla `metricas_mensuales` con 35+ columnas (IVA por tasa, ISR, ingresos
  devengados/cobrados, flujo, CxC/CxP) + tabla `metricas_invalidaciones`.
- Funciones `upsertMetricaMensual`, `getMetricasMensuales`, `markForInvalidation`,
  `getPendingInvalidations`, `clearInvalidation`, `closeMonth`, `closeYear`.
- Invalidación ya activa en `cfdi.service.ts:455` y `:521` (upload manual XML).

Lo que falta (70%):

**Tanda A — cimientos (1 sesión dedicada):**
- `computeMetricaMensual(pool, contribuyenteId, anio, mes, regimen)` que agregue
  los 35+ campos desde CFDIs raw. Consolidar lógica que hoy vive dispersa en
  dashboard/impuestos/reportes services.
- Script `backfillMetricas()` para poblar histórico (~540 filas en Patito: 3
  contribuyentes × 5 años × 12 meses × ~3 regímenes).
- Cron de recomputación que consume `metricas_invalidaciones` cada N minutos,
  recomputa y llama `clearInvalidation`. Lock para evitar double-compute.
- Invalidación en los paths faltantes: SAT sync (`saveCfdis`, `saveMetadata`),
  cancelaciones, conciliación, cancelaciones retroactivas.
- **Ningún consumer cambia en Tanda A** — la tabla se llena y mantiene, pero
  dashboard/impuestos/reportes siguen on-the-fly. Riesgo bajo.

**Tanda B — read-through (1 sesión):**
- Modificar `calcularIngresosPorRegimen`, `calcularEgresosPorRegimen`,
  `calcularIvaBalancePorRegimen` en dashboard para leer de `metricas_mensuales`
  cuando el mes es pasado.
- Ídem `getIvaMensual`, `getIsrMensual`, `getResumenIva`, `getResumenIsr` en
  impuestos.
- Ídem `calcularFlujoPorMes`, `getFlujoEfectivo` en reportes.
- Cada consumer se valida en paralelo (tabla vs on-the-fly) para 1 mes conocido
  antes de commitir — cualquier diferencia céntimo por céntimo es regresión.
- Riesgo alto: afecta KPIs principales del sistema.

**Alternativa mínima (si solo importa performance del dashboard principal):**
Implementar Tanda A cubriendo solo `ingresos_cobrados`, `egresos_pagados`,
`utilidad_realizada`. Sin IVA ni ISR. Reduce a 1 sesión total, no cubre
impuestos ni reportes.

**Cuándo vale hacerlo:** cuando el dashboard tarde notoriamente al consultar
años pasados con volumen alto, o al escalar a múltiples despachos con histórico
grande. Hoy en Patito (3 contribuyentes, 5 años) el costo on-the-fly es bajo y
el beneficio es teórico.

---

### Tanda 2 MP (cobro despacho, continuación)

7. **Bloqueo total fuera de `/subscriptions/*`** para despachos sin pago activo.
   Modificar `plan-limits.middleware.ts` para que despachos bloqueados (sub
   inactiva + trial vencido) solo puedan acceder a endpoints de suscripción.
   Frontend interceptor que redirija a `/configuracion/planes-despacho` al
   recibir 403 con mensaje específico.
8. **Add-on recurrente $45/RFC/mes** para Business Cloud. Requiere:
   - Row en `plan_addon_catalogo` con `key='rfc_despacho_managed'`, precio $45.
   - Hook al crear contribuyente: agregar addon al `Subscription` activo.
   - Hook al eliminar contribuyente: cancelar addon correspondiente.
   - Multi-preapproval MP (patrón ya existe en `addon.service.ts` para Horux360).

### Features originales del pivote (de `2026-04-19-pending-features.md`)

9. Convertir `/pendientes` → "Despacho" con KPIs del despacho.
10. Pestaña "Extras" en `/documentos` (PDFs libres con categoría).
11. Avisos por correo al subir declaraciones / documentos.

---

## Estado TS al cierre

`pnpm typecheck` en `@horux/api`: **0 errores**.

Errores pendientes en `@horux/web`: todos pre-existentes, no tocados esta
sesión.

---

## Riesgos / notas operacionales

- **Extracción SAT en Zorro**: se dejó en `pending` con `next_retry_at =
  2026-04-21 06:28:42` UTC antes de reiniciar el dev server. El cron horario
  del SAT sync la retomará automáticamente. Si al siguiente chequeo sigue sin
  progresar, reiniciarla manualmente con rango menor desde la UI.
- **Migración tenant 025 no registrada en Patito**: se auto-registrará via
  lazy-migrate en el próximo `getPool(patitoId)`. No requiere acción manual.
- **27 CFDIs descartados en Zorro**: dejan de aparecer en el drilldown tras el
  fix. El botón UI para restaurar es pendiente #2 (ver arriba).
- **Migración Prisma `20260421062505_despacho_plan_enum_values`**: archivo
  ligado a la BD local. En prod `prisma migrate deploy` aplicará los mismos
  `ALTER TYPE ... ADD VALUE` — son compatibles hacia atrás, no requieren
  ventana de mantenimiento.
- **Cobro MP en dev**: requiere `MP_ACCESS_TOKEN` válido en `.env` del api para
  crear preapprovals reales (o sandbox). Si no está configurado, el endpoint
  devuelve 503 con mensaje claro.
