18 KiB
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ó:
- 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).
- Pendientes derivados de hoy — A, B, C, D + mejora de logging SAT.
- 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:
pendingconnextRetryAt< now −STALE_PENDING_HOURS(default 12)runningconstartedAt< 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:
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
getResumenIvaen 2026-04-21 § Tanda B.3). - Cache read-through desde
metricas_mensualescuando año < actual, sin conciliación, con contribuyente seleccionado. Helper nuevoreadIvaMensualFromCacheagrega 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 delStatusRequest)value(valor numérico delStatusRequest)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):
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=CONTABLEcontribuyente_extra_business_cloud— $45/mes,verticalProfile=CONTABLE
Service (addon.service.ts):
subscribeAddonaceptacontribuyenteId: 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/addonsacepta{ addonCodename, quantity, contribuyenteId }en body.
Frontend:
apps/web/lib/api/addons.ts+use-addons.tshooks.apps/web/app/(dashboard)/contribuyentes/addons-dialog.tsx: catálogoADDONS_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ónSparklespor 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 = 0sin addon →'none'overage = 0con addon →'cancelled'(revoca preapproval)overage > 0sin addon →'created'(crea addon + preapproval, retornapaymentUrl)overage > 0con addon, quantity ya coincide →'none'(idempotente)overage > 0con addon, quantity distinto →'updated'(updatePreapprovalAmount)
Integración:
contribuyente.controller.ts:createy:deactivatellamancountActiveContribuyentes(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): siresult.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 conENABLE_CRONS_IN_DEVapps/api/src/services/sat/sat-client.service.ts— rejection logging informativoapps/api/src/services/impuestos.service.ts—getIvaMensualrefactor + cache (helperreadIvaMensualFromCache); 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—contribuyenteIdensubscribeAddon+listActiveAddons; nueva funciónadjustBusinessCloudOveragepara cableado automático del overageapps/api/src/controllers/subscription.controller.ts—getMyAddons+addMyAddonaceptan contribuyenteIdapps/api/src/controllers/contribuyente.controller.ts—createydeactivatellamanadjustBusinessCloudOveragetras la operación; helpercountActiveContribuyentesapps/api/prisma/schema.prisma—SubscriptionAddon.contribuyenteIdopcionalapps/api/prisma/migrations/20260422172323_subscription_addons_contribuyente_id/migration.sql— nuevoapps/api/prisma/seed.ts— 2 addons nuevosapps/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— nuevoapps/web/app/(dashboard)/contribuyentes/page.tsx— botón Sparkles + wiring del dialog
Data directa
horux_despachos(central):planAddonCatalogoupsert con 2 filas nuevas (lolita_ia_contribuyente$250/mes,contribuyente_extra_business_cloud$45/mes). Aplicado vía script temporal ya borrado.subscriptionsINSERT manual para Zorro (business_cloud, $15K/año) y Patito (business_control, $21K/año). Statusauthorized,mpPreapprovalId=null. Script temporal borrado.Tenant.planUPDATE en Zorro (deenterprise) y Patito (debusiness) al plan real.subscription_addonsINSERT para Alexa (Zorro) y Carlos (Patito) con codenamelolita_ia_contribuyente, preapproval MP real (sandbox). Posteriormente se marcaronauthorizedsimulando el webhook (script temporal que llamahandleAddonPayment(id, 'manual-sim', 'authorized'), ya borrado).
.env:- Agregado
MP_ACCESS_TOKEN(sandbox). FRONTEND_URLcambiado 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 ahttps://horuxfin.com.
- Agregado
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 ensat-sync.job.ts— completado 2026-04-23 (refactorizado a función exportablesweepStaleSatJobsenservices/sat/sweep-stale-jobs.service.ts; cronWATCHDOG_CRON_SCHEDULE = '0 */2 * * *'enstartSatSyncJob). - ✅ Cableado automático del add-on
contribuyente_extra_business_cloudcompletado en esta sesión. - Cloudflare Tunnel en prod para
MP_NOTIFICATION_URL— endpointPOST /api/webhooks/mercadopago. Sin esto, addons pagados en MP se quedanpendingen BD hasta que manualmente se llamehandleAddonPayment. FRONTEND_URLen dev vs MP sandbox — MP rechazahttp://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:verifySatRequestahora exponecodeRequest(métodogetCodeRequest()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.
- message descriptivo en el debug log y en el error message. Los 5
códigos SAT posibles son:
- 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
codeRequestreal 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