# Sesión 2026-04-22 — Cierre de pendientes y add-ons por contribuyente

Sesión continuación del trabajo de Tanda A / B documentado en
`docs/plans/2026-04-21-session-2-mp-setup-and-bugfixes.md`. Esta sesión cubrió:

1. **Tanda B.2-B.5** — Extensión del cache read-through, alineación
   dashboard ≡ impuestos, lock SAT por contribuyente, watchdog CLI
   (ver doc 2026-04-21 § "Tanda B.2" en adelante).
2. **Pendientes derivados de hoy** — A, B, C, D + mejora de logging SAT.
3. **Feature: Add-ons por contribuyente** — infraestructura para cobro
   recurrente mensual por RFC, con preapproval MP independiente de la
   licencia anual del despacho. Primer add-on: Lolita IA ($250/mes).

---

## Pendientes derivados cerrados

### A. Watchdog CLI de SAT stale jobs

**Problema:** jobs `pending` con `nextRetryAt` vencido o `running`
huérfanos (proceso crasheó a mitad del sync) quedaban invisibles y
bloqueaban futuros syncs por el lock.

**Solución:** `apps/api/scripts/sweep-stale-sat-jobs.ts`. Dos categorías
con thresholds sobreescribibles por env:

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

Dry-run por default; `--apply` ejecuta. Verificado con un dry-run
limpio (0 stale) mientras Manuel corría.

Pendiente: wiring como cron cada 2h en `sat-sync.job.ts`.

### B. Crons en dev con flag

**Problema:** todos los crons estaban gateados con
`env.NODE_ENV === 'production'`. En dev ningún cron arrancaba — por eso
el job de Alexa (status pending con `nextRetryAt = +5min`) quedó
colgado: el cron horario `retryTimedOutJobs` nunca corrió.

**Solución:** en `apps/api/src/index.ts`, partir el gate en dos:

```ts
const cronsEnabled = env.NODE_ENV === 'production' || process.env.ENABLE_CRONS_IN_DEV === '1';
const sendRealEmails = env.NODE_ENV === 'production';
if (cronsEnabled) {
  startSatSyncJob();
  startMetricasInvalidationsJob();
  if (sendRealEmails) startWeeklyUpdateJob();
}
```

`weekly-update` sigue prod-only para no mandar emails a owners reales
desde dev. Confirmado al restart: `[Cron] Jobs omitidos en dev (usar
ENABLE_CRONS_IN_DEV=1 para activar)`.

### C. Cache en `getIvaMensual` + refactor a fórmula canónica

**Problema doble:** la fórmula de `getIvaMensual` divergía de la del
dashboard/impuestos (no filtraba PUE, no manejaba NC, retenido gross).
Además no leía de `metricas_mensuales` para años cerrados.

**Solución:**
- Reescribir con los 6 buckets canónicos (ver `getResumenIva` en 2026-04-21 § Tanda B.3).
- Cache read-through desde `metricas_mensuales` cuando año < actual,
  sin conciliación, con contribuyente seleccionado. Helper nuevo
  `readIvaMensualFromCache` agrega T/A/R por mes.
- On-the-fly: 2 queries (una por lado causado/acreditable) grouped por mes.

`getIsrMensual` y `getResumenIsr` siguen on-the-fly — requieren tarifas
progresivas y no están en `metricas_mensuales`. Fuera de alcance.

### D. Cache en `calcularFlujoPorMes` — **fuera de alcance**

**Problema:** `calcularFlujoPorMes` usa `total_mxn`/`monto_pago_mxn`
(IVA incluido) pero los campos stored `flujo_entradas/salidas/neto` en
`metricas_mensuales` se poblan desde ingresos/egresos NETOS (sin IVA).

**Decisión:** no cachear hasta tener columnas `flujo_bruto_*`
separadas o reescribir el concepto. El cómputo on-the-fly ya es
eficiente (6 queries agregadas por año). Costo/beneficio no lo justifica
ahora. Documentado como pendiente.

### Extra. Logging informativo de rejections SAT

**Problema:** durante el sync de Manuel, 9 bloques consecutivos de
emitidos cayeron en `rejected`. El mensaje `verifyResult.message` era
el genérico `"Solicitud Aceptada"` del wrapper HTTP. La razón real
(códigos 5001, 5002, 5003, 5005, etc.) quedaba enterrada.

**Solución:** en `sat-client.service.ts:verifySatRequest`, cuando
`status` es `rejected`/`failed`, construir el message con
`SAT code=N request=EntryId(value) msg="..."` que incluye:
- `statusCode` (código numérico SAT)
- `entryId` (etiqueta del `StatusRequest`)
- `value` (valor numérico del `StatusRequest`)
- `msg` (mensaje del wrapper, ya existente)

---

## Feature: Add-ons por contribuyente

### Modelo de negocio
- **Lolita IA** — $250/mes por cada contribuyente que lo active.
  Cualquier plan puede contratarlo.
- **Contribuyente adicional Business Cloud** — $45/mes por RFC extra
  (el plan incluye 3; del 4º en adelante). Automático por count,
  no opt-in. Modelado como add-on para que el preapproval MP lo cubra.

Ambos add-ons son **mensuales**; la licencia del despacho es **anual**.
→ Requieren preapproval MP **independiente** por add-on — cancelación
granular sin tocar la suscripción base.

### Ruta descartada
Primer intento fue una tabla tenant `contribuyente_addons
(contribuyente_id, addon_key, enabled, config)` con feature-toggles
(facturación/conciliación/documentos/calendario/reportes). Modelo
incorrecto: los add-ons reales son servicios de cobro recurrente, no
switches de features. Revertido completo antes de iterar.

### Ruta correcta — extender `SubscriptionAddon` existente

Ya existía infraestructura a nivel tenant (`plan_addon_catalogo` +
`subscription_addons` + `addon.service.ts` con preapproval MP por
add-on). Extensión:

**Schema (Prisma):**
```prisma
model SubscriptionAddon {
  contribuyenteId  String?   @map("contribuyente_id")  // NULL = tenant-level
  // ... resto igual
  @@index([subscriptionId, contribuyenteId])
  // @@unique([subscriptionId, planAddonCatalogoId])  ← REMOVIDO
}
```

Sin `@@unique` compuesto porque Postgres trata `NULL != NULL` y no hay
forma trivial de enforcar "un solo addon activo por (sub, codename,
contribuyente?)" con Prisma. Validación queda a nivel app en
`subscribeAddon.findFirst`.

**Migration SQL:**
- Agrega `contribuyente_id TEXT NULL`
- Elimina UNIQUE(subscription_id, plan_addon_catalogo_id)
- Agrega índice (subscription_id, contribuyente_id)

**Catálogo (seed.ts):** 2 nuevos add-ons:
- `lolita_ia_contribuyente` — $250/mes, `verticalProfile=CONTABLE`
- `contribuyente_extra_business_cloud` — $45/mes, `verticalProfile=CONTABLE`

**Service (`addon.service.ts`):**
- `subscribeAddon` acepta `contribuyenteId: string | null`. El reason
  del preapproval incluye prefix del RFC cuando aplica
  (`"Horux Despachos - Lolita IA (RFC abcd1234) x1 - Zorro Despacho"`).
- `listActiveAddons(tenantId, contribuyenteId?)` filtra por RFC cuando
  se pasa el param. Sin param → retorna todos los add-ons del tenant
  (incluye tenant-level y per-contribuyente).
- La validación "ya tienes activo" ahora considera `contribuyenteId`:
  mismo addon en 2 contribuyentes distintos es OK; 2 veces para el
  mismo contribuyente rechaza.

**Controller (`subscription.controller.ts`):**
- `GET /subscriptions/me/addons?contribuyenteId=...` — filtra por RFC.
- `POST /subscriptions/me/addons` acepta `{ addonCodename, quantity,
  contribuyenteId }` en body.

**Frontend:**
- `apps/web/lib/api/addons.ts` + `use-addons.ts` hooks.
- `apps/web/app/(dashboard)/contribuyentes/addons-dialog.tsx`:
  catálogo `ADDONS_POR_CONTRIBUYENTE` (hoy solo Lolita IA). Muestra
  precio, descripción, estado (activo/pending/sin contratar), fecha
  del próximo cobro. Botón "Contratar" abre MP init_point en nueva
  pestaña; "Cancelar" pide confirmación y revoca el preapproval.
- `contribuyentes/page.tsx`: botón `Sparkles` por contribuyente abre
  el dialog.

### Cableado automático del overage Business Cloud

El add-on `contribuyente_extra_business_cloud` ($45/mes) ahora se ajusta
automáticamente al crear o desactivar un contribuyente.

**Modelo:** un único `SubscriptionAddon` a nivel tenant
(`contribuyenteId = null`, `codename = contribuyente_extra_business_cloud`)
con `quantity = max(0, activeCount − 3)`. El monto del preapproval MP
refleja `precio × quantity`. Cuando `quantity` cambia, se actualiza vía
`updatePreapprovalAmount` (sin re-autorización del usuario).

**Función:** `adjustBusinessCloudOverage(tenantId, activeContribuyenteCount)`
en `addon.service.ts`. Idempotente. Maneja los 5 casos:

- Plan ≠ `business_cloud` → `'skipped'`
- `overage = 0` sin addon → `'none'`
- `overage = 0` con addon → `'cancelled'` (revoca preapproval)
- `overage > 0` sin addon → `'created'` (crea addon + preapproval, retorna `paymentUrl`)
- `overage > 0` con addon, quantity ya coincide → `'none'` (idempotente)
- `overage > 0` con addon, quantity distinto → `'updated'` (updatePreapprovalAmount)

**Integración:**
- `contribuyente.controller.ts:create` y `:deactivate` llaman
  `countActiveContribuyentes(pool)` + `adjustBusinessCloudOverage(tenantId, count)`
  tras la operación. Fail-soft: si el ajuste falla, el contribuyente queda
  creado/desactivado y el error se loguea (no bloquea la respuesta).
- Frontend (`contribuyentes/page.tsx`): si `result.overage.action === 'created'`
  + `paymentUrl`, muestra alerta y abre MP en nueva pestaña. Para `'updated'`
  o `'cancelled'` muestra toast informativo.

**Transparencia de cobro:**
- Plan `business_cloud` = $15K/año (licencia, anual).
- Addon overage = $45/mes × quantity (mensual).
- MercadoPago cobra ambos independientemente. Cancelar la licencia
  cancela su preapproval; cancelar RFCs baja el quantity del addon
  automáticamente.

### Casos de uso validados

| Escenario | Estado |
|---|---|
| Tenant nuevo, crea 3 RFCs | Sin addon (no excede) |
| Crea 4º RFC → overage=1 | Addon `created`, paymentUrl devuelto, $45/mes pending |
| Crea 5º RFC → overage=2 | Addon `updated`, `updatePreapprovalAmount($90)` |
| Desactiva 1 (quedan 4) → overage=1 | Addon `updated`, `updatePreapprovalAmount($45)` |
| Desactiva otro (quedan 3) → overage=0 | Addon `cancelled`, preapproval MP revocado |
| Tenant en `business_control` crea 10º RFC | `skipped` (plan no aplica) |
| Tenant sin suscripción activa | `skipped` (catch-all) |

---

## Puesta en marcha de datos para testing

### Backfill de suscripciones de despacho

Los tenants Zorro (`DESPACHO_MO7JE8BZ_VDOPR`) y Patito (`DESPACHO_MO3NI6U8_B9VGG`)
fueron provisionados directamente como admin (sin pasar por el flujo
self-serve de MP), por lo que no tenían `Subscription` en BD central. Esto
bloqueaba el testing de add-ons (gate en `subscribeAddon`).

Se insertaron manualmente suscripciones `authorized` con `mpPreapprovalId=null`
(licencia por arreglo directo, cobro de add-ons va por separado):

| Tenant | Plan | Amount | Frequency | Period |
|---|---|---|---|---|
| Zorro | `business_cloud` | $15,000 | annual | 2026-04-23 → 2027-04-23 |
| Patito | `business_control` | $21,000 | annual | 2026-04-23 → 2027-04-23 |

`Tenant.plan` también se actualizó al valor correcto (antes Zorro estaba
en `enterprise` y Patito en `business`).

### Configuración MercadoPago sandbox

Agregado a `.env`:
```
MP_ACCESS_TOKEN=TEST-...
```

**Gotcha descubierto:** MP rechaza `http://localhost:3000` como `back_url`
del preapproval (requiere HTTPS público). Durante el testing se cambió
`FRONTEND_URL` a `https://horuxfin.com` temporalmente y se revirtió al
terminar. Solución durable pendiente (doc más abajo).

### Add-ons Lolita IA activos

| Contribuyente | Despacho | addonId | preapprovalId | status |
|---|---|---|---|---|
| Alexa G. Torres Romero (TORA0007099R6) | Zorro | `0cfb5c0b-…` | `b0dd70c3…` | `authorized` |
| Carlos H. Torres Romero (TORC9611214CA) | Patito | `17ed5185-…` | `48e20f17…` | `authorized` |

Preapprovals reales en MP sandbox. Status movido manualmente a `authorized`
via `handleAddonPayment(addonId, 'manual-sim', 'authorized')` porque no hay
webhook configurado. En prod esto lo hace automáticamente
`POST /api/webhooks/mercadopago`.

Period mensual: 2026-04-23 → 2026-05-23. El próximo ciclo se renovaría con
MP webhook real (pendiente Cloudflare Tunnel).

---

## Archivos tocados esta sesión

### Backend
- `apps/api/src/index.ts` — gate de crons con `ENABLE_CRONS_IN_DEV`
- `apps/api/src/services/sat/sat-client.service.ts` — rejection logging informativo
- `apps/api/src/services/impuestos.service.ts` — `getIvaMensual` refactor + cache (helper `readIvaMensualFromCache`); constantes SQL elevadas a file-level en Tanda B.3 (§ sesión 2026-04-21)
- `apps/api/src/services/dashboard.service.ts` — (ver Tanda B.3 en sesión 2026-04-21)
- `apps/api/src/services/sat/sat.service.ts` — (ver Tanda B.4 en sesión 2026-04-21)
- `apps/api/src/services/metricas.service.ts` — (ver Tanda A bugfix en sesión 2026-04-21)
- `apps/api/src/services/payment/addon.service.ts` — `contribuyenteId` en `subscribeAddon` + `listActiveAddons`; **nueva función `adjustBusinessCloudOverage`** para cableado automático del overage
- `apps/api/src/controllers/subscription.controller.ts` — `getMyAddons` + `addMyAddon` aceptan contribuyenteId
- `apps/api/src/controllers/contribuyente.controller.ts` — `create` y `deactivate` llaman `adjustBusinessCloudOverage` tras la operación; helper `countActiveContribuyentes`
- `apps/api/prisma/schema.prisma` — `SubscriptionAddon.contribuyenteId` opcional
- `apps/api/prisma/migrations/20260422172323_subscription_addons_contribuyente_id/migration.sql` — **nuevo**
- `apps/api/prisma/seed.ts` — 2 addons nuevos
- `apps/api/scripts/sweep-stale-sat-jobs.ts` — **nuevo** (watchdog CLI)
- `apps/api/scripts/validate-dashboard-impuestos.ts` — (ver Tanda B.3 en sesión 2026-04-21)

### Frontend
- `apps/web/lib/api/addons.ts` — **nuevo** (cliente API)
- `apps/web/lib/hooks/use-addons.ts` — **nuevo** (hooks React Query)
- `apps/web/app/(dashboard)/contribuyentes/addons-dialog.tsx` — **nuevo**
- `apps/web/app/(dashboard)/contribuyentes/page.tsx` — botón Sparkles + wiring del dialog

### Data directa
- `horux_despachos` (central):
  - `planAddonCatalogo` upsert con 2 filas nuevas (`lolita_ia_contribuyente`
    $250/mes, `contribuyente_extra_business_cloud` $45/mes). Aplicado vía
    script temporal ya borrado.
  - `subscriptions` INSERT manual para Zorro (`business_cloud`, $15K/año) y
    Patito (`business_control`, $21K/año). Status `authorized`,
    `mpPreapprovalId=null`. Script temporal borrado.
  - `Tenant.plan` UPDATE en Zorro (de `enterprise`) y Patito (de `business`)
    al plan real.
  - `subscription_addons` INSERT para Alexa (Zorro) y Carlos (Patito) con
    codename `lolita_ia_contribuyente`, preapproval MP real (sandbox).
    Posteriormente se marcaron `authorized` simulando el webhook (script
    temporal que llama `handleAddonPayment(id, 'manual-sim', 'authorized')`,
    ya borrado).
- `.env`:
  - Agregado `MP_ACCESS_TOKEN` (sandbox).
  - `FRONTEND_URL` cambiado temporalmente a HTTPS y revertido a localhost
    al cerrar. **Próxima vez que se teste MP en dev:** cambiarlo a una URL
    HTTPS pública (Cloudflare Tunnel, ngrok) o a `https://horuxfin.com`.

### Documentación
- `docs/plans/2026-04-21-session-2-mp-setup-and-bugfixes.md` — extendido
  con Tanda B.2-B.5 y referencia al add-on model.
- `docs/Horux_despachos-vs-Horux360.md` — extendido con §9 crons dev,
  §10 rejection logging, §11 getIvaMensual refactor. Add-ons
  NO incluidos (exclusivos del fork multi-contribuyente).

---

## Pendientes vigentes al cierre

### Derivados de hoy
- ✅ Wiring del watchdog (`sweep-stale-sat-jobs.ts`) como cron cada 2h en
  `sat-sync.job.ts` — completado 2026-04-23 (refactorizado a función
  exportable `sweepStaleSatJobs` en `services/sat/sweep-stale-jobs.service.ts`;
  cron `WATCHDOG_CRON_SCHEDULE = '0 */2 * * *'` en `startSatSyncJob`).
- ✅ Cableado automático del add-on `contribuyente_extra_business_cloud`
  completado en esta sesión.
- **Cloudflare Tunnel en prod** para `MP_NOTIFICATION_URL` — endpoint
  `POST /api/webhooks/mercadopago`. Sin esto, addons pagados en MP se
  quedan `pending` en BD hasta que manualmente se llame `handleAddonPayment`.
- `FRONTEND_URL` en dev vs MP sandbox — MP rechaza `http://localhost`.
  Solución durable: setear una URL HTTPS de dev (Cloudflare Tunnel,
  ngrok) o un dominio propio permanente.
- **Investigación SAT rejections** — completado 2026-04-23:
  - `sat-client.service.ts:verifySatRequest` ahora expone `codeRequest`
    (método `getCodeRequest()` de la lib) con su valor numérico + entryId
    + message descriptivo en el debug log y en el error message. Los 5
    códigos SAT posibles son: `5000 Accepted`, `5002 Exhausted`, `5003
    MaximumLimit`, `5004 EmptyResult`, `5005 Duplicated`.
  - Patrón observado en Manuel: 9 rejections de emitidos (bloques 3-9
    y 12-13), pero bloques 10-11 sí funcionaron — NO es rate limit
    constante. Hipótesis más probable: **5005 Duplicated** (solicitudes
    previas stale para rangos similares que quedaron huérfanas y nuevo
    re-sync es considerado duplicado por el SAT). Requiere capturar
    un caso nuevo con el código mejorado para confirmar.
  - Si se confirma 5005: solución es limpiar solicitudes previas en el
    SAT antes de reintentar (no trivial — SAT no ofrece endpoint de
    cancelación), o esperar ~72h entre intentos. Si es 5003 (MaximumLimit):
    reducir tamaño de rango. Si es 5002 (Exhausted): cambiar FIEL /
    esperar 24h.
- Re-sync custom de los rangos de emitidos faltantes de Manuel (bloques
  3-9 del XML initial) — pendiente, depende del diagnóstico del punto
  anterior (capturar el `codeRequest` real cuando vuelva a ocurrir).
- ✅ Validación preventiva CSD↔RFC en `uploadCsdContribuyente` —
  completado 2026-04-23. Ahora valida: (1) cert no es FIEL, (2) RFC del
  cert coincide con contribuyente, (3) no vencido. Mensajes de error
  específicos. Usa `@nodecfdi/credentials`.
- Recomputar overage al cambiar de plan (ej. downgrade business_cloud →
  business_control debería cancelar el addon overage si existe). Hoy
  solo se dispara desde create/deactivate contribuyente.

### De sesiones anteriores (abiertos)
- Recrear org Facturapi de Carlos (TORC9611214CA)
- Validación preventiva CSD↔RFC en `uploadCsdContribuyente`
- Prueba cross-contribuyente end-to-end
- Typecheck web cleanup (~12 errores preexistentes en sidebar/cfdi/usuarios)

### Features pending
Ver `docs/plans/2026-04-19-pending-features.md`. De esa lista:
- ✅ #8 Extras en Documentos — completado en sesión anterior
- ✅ #5 Add-ons por contribuyente — **Lolita IA completado hoy**; falta
  overage business_cloud automático
- #1 Editar contribuyentes asignados a cliente
- #2 Convertir Pendientes → Despacho con métricas
- #6 Enlazar obligaciones ↔ declaraciones
- #7 Colores obligaciones en calendario
- #9 Avisos por correo al subir declaración / doc extra
- #10 Alertas de obligaciones — bug de filtros per-contribuyente
