Files
HoruxDespachosNuevo/docs/plans/2026-04-22-pendientes-y-addons.md

392 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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