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

18 KiB
Raw Permalink Blame History

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:

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 calcularFlujoPorMesfuera 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):

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.tsgetIvaMensual 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.tscontribuyenteId en subscribeAddon + listActiveAddons; nueva función adjustBusinessCloudOverage para cableado automático del overage
  • apps/api/src/controllers/subscription.controller.tsgetMyAddons + addMyAddon aceptan contribuyenteId
  • apps/api/src/controllers/contribuyente.controller.tscreate y deactivate llaman adjustBusinessCloudOverage tras la operación; helper countActiveContribuyentes
  • apps/api/prisma/schema.prismaSubscriptionAddon.contribuyenteId opcional
  • apps/api/prisma/migrations/20260422172323_subscription_addons_contribuyente_id/migration.sqlnuevo
  • apps/api/prisma/seed.ts — 2 addons nuevos
  • apps/api/scripts/sweep-stale-sat-jobs.tsnuevo (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.tsnuevo (cliente API)
  • apps/web/lib/hooks/use-addons.tsnuevo (hooks React Query)
  • apps/web/app/(dashboard)/contribuyentes/addons-dialog.tsxnuevo
  • 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