# 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