Files
HoruxDespachosNuevo/docs/plans/2026-05-01-session.md

48 KiB
Raw Permalink Blame History

Sesión 2026-05-01 — Facturación Live multi-RFC + UX CFDI + pestaña Conceptos

Sesión enfocada en preparar el flujo Facturapi Live multi-RFC end-to-end (PUT live + cifrado AES-GCM + validaciones SAT), agregar la sección "CFDIs Relacionados" para tipo I/E con dropdown filtrado por contribuyente, abrir la pestaña "Conceptos" en /cfdi con filtros + sort + export, agregar deducción de nómina al cálculo ISR, y limpiar varios bugs de UX en facturación y CFDI (saldo pendiente vacío, complemento de pago sin fecha, exports paginados a una sola página, leak multi-RFC en 3 endpoints, etc.).

22 cambios shippeados; smoke test del flow Live de Facturapi pendiente (requiere CSD live de un contribuyente real).


Índice

  1. Setup .env + bug DATABASE_URL post-template
  2. Sección "CFDIs Relacionados" en facturación I/E
  3. Bug crítico cfdis-ppd: HAVING sin GROUP BY
  4. Leak multi-RFC en 3 endpoints (searchRfcs, cfdis-ppd, cfdis-relacionables)
  5. Auto-carga de listas al escribir RFC manualmente
  6. Complemento de pago: input "Fecha de pago"
  7. Descarga PDF/XML post-emisión + por fila en lista CFDI
  8. Columnas + estilo CFDI: Uso CFDI, centrado, Conceptos
  9. Export CFDI: traer todas las páginas con cap 10k
  10. Saldo Pendiente: usar saldoPendienteMxn (campo backfilleado)
  11. Deducciones ISR: bucket Nómina (tipo N emitidas)
  12. Pestaña "Conceptos" en /cfdi
  13. Pestaña Conceptos: filtros header + sort por importe
  14. Form Custom: invertir trial/custom + Primera fecha de pago
  15. Cleanup organización Facturapi de Carlos (Live multi-RFC)
  16. Trial timbres = 0 (alineación seed/BD)
  17. Integración Metabase (porteada de producción)
  18. Página registro: actualizar planes + toggle mensual/anual
  19. Cleanup final del legacy Horux 360
  20. Pendientes derivados

1. Setup .env + bug DATABASE_URL post-template

Problema

Tras copiar .env.example (24 variables) sobre el .env real, se perdió el DATABASE_URL con la password real (postgres:Hesoy@m11) y quedó el placeholder (postgres:postgres). Login fallaba con PrismaClientInitializationError: Authentication failed... credentials for (not available).

Fix

  • Restaurar DATABASE_URL con URL-encoding (@%40): postgresql://postgres:Hesoy%40m11@localhost:5432/horux_despachos?schema=public

Lección

  • Postgres password con caracteres especiales requiere URL-encoding: @%40, :%3A, /%2F, ?%3F, #%23, %%25, espacio → %20. Sin encoding, el connection string se parsea mal.
  • Prisma reporta "credentials for (not available)" cuando el parsing del URL falla — mensaje confuso, la causa real siempre es URL malformada.
  • .env.example quedó con 24 variables (subió de 11) agrupadas en 9 bloques con comentarios sobre dónde obtener cada valor.

2. Sección "CFDIs Relacionados" en facturación I/E

Problema

La pestaña Facturación solo permitía agregar UUID relacionado para tipo E (NC). Tipo I también necesita relacionados (sustituciones, anticipos, etc.). El input era manual (texto libre); el contador tenía que conocer el UUID o copiarlo desde otra pestaña.

Cambios

Backend — nuevo endpoint:

  • GET /facturacion/cfdis-relacionables?rfcReceptor=X&contribuyenteId=Y
  • Filtra: contribuyente_id = $caller, rfc_receptor = $X, tipo_comprobante IN ('I','E'), vigentes, ORDER BY fecha DESC, LIMIT 50

Frontend:

  • TIPO_CONFIG.I.needsRelated: false → true
  • Card "CFDIs Relacionados" reposicionada después de Conceptos (no antes)
  • Dropdown buscable con UUID + tipo + serie/folio + total + fecha
  • Tipos de relación expandidos a 7 opciones (01-07; antes 01-04)
  • Auto-carga al selectRfc cuando hay contribuyenteId activo
  • Fallback: si la lista viene vacía, el input acepta UUID escrito a mano

Archivos

apps/api/src/controllers/facturacion.controller.ts (handler getCfdisRelacionables)
apps/api/src/routes/facturacion.routes.ts          (route)
apps/web/lib/api/facturacion.ts                    (API client)
apps/web/app/(dashboard)/facturacion/page.tsx      (state, dropdown, JSX)

3. Bug crítico cfdis-ppd: HAVING sin GROUP BY

Problema

El endpoint getCfdisPpdPendientes usaba HAVING c.total_mxn - ... > 0 sin GROUP BY. Postgres lo rechaza con error 500: la columna "c.uuid" debe aparecer en la cláusula GROUP BY. MySQL es laxo y lo permite; Postgres no.

Fix

  • Cambiar el filtro de HAVING a WHERE (no es query agregada):
    WHERE ...
      AND COALESCE(c.saldo_pendiente_mxn, 0) > 0
    
  • De paso, migrar el cálculo de saldo a saldo_pendiente_mxn (campo denormalizado por utils/saldo.ts §13) en lugar de subquery sobre pagos P. El campo denormalizado considera también NCs no-07 + anticipos aplicados; la subquery solo cubría pagos P y sobreestimaba el saldo.

Resultado

Para CCA131111HU9 antes mostraba 9 PPDs con "saldo > 0" (incluyendo 6 con saldo real 0 por NCs/anticipos). Ahora muestra solo 3 con saldo real pendiente. Coherente con el resto del sistema (CxC/CxP, dashboard).

Performance

Filtro contra columna directa indexable (no subquery con SUM por fila) → ~10× más rápido. Vale la pena agregar índice cfdis(rfc_receptor, saldo_pendiente_mxn) WHERE type='EMITIDO' AND metodo_pago='PPD' si crecen mucho los CFDIs.


4. Leak multi-RFC en 3 endpoints

Problema

Tres endpoints leían tablas tenant-level sin filtrar por contribuyente_id. En multi-RFC esto causaba:

  • GET /facturacion/rfcs/search → mostraba RFCs de TODOS los contribuyentes del despacho, no solo del activo
  • GET /facturacion/cfdis-ppd → mostraba PPDs de cualquier contribuyente
  • GET /facturacion/cfdis-relacionables → idem (introducido este día sin el bug, pero auditado)

Fix uniforme

Cada endpoint acepta contribuyenteId opcional como query param:

  • searchRfcs: WHERE EXISTS (cfdis WHERE contribuyente_id = X AND (rfc_emisor_id = r.id OR rfc_receptor_id = r.id))
  • getCfdisPpdPendientes: AND c.contribuyente_id = X
  • getCfdisRelacionables: AND contribuyente_id = X (ya existía desde el feature de hoy)

Compat: sin contribuyenteId, retorna comportamiento legacy (todo el despacho). El frontend siempre lo manda desde selectedContribuyenteId.

Frontend

3 helpers actualizados con segundo parámetro opcional contribuyenteId:

getCfdisPpd(rfc, contribuyenteId?)
searchRfcs(q, contribuyenteId?)
getCfdisRelacionables(rfcReceptor, contribuyenteId)  // ya existía

5. Auto-carga de listas al escribir RFC manualmente

Problema

El getCfdisPpd solo se disparaba dentro de selectRfc (callback del click en dropdown de búsqueda RFC). Si el contador escribía el RFC a mano, la lista de PPDs nunca se cargaba → "No se encontraron facturas PPD pendientes para este RFC".

Fix

useEffect en facturacion/page.tsx que dispara la carga (PPDs o relacionables según tipo) cada vez que cambian:

  • receptor.taxId (con longitud ≥12 chars)
  • tipoComprobante
  • selectedContribuyenteId

Limpia las listas cuando RFC cae a <12 chars. Cubre 4 escenarios:

  • Click en dropdown RFC ✓
  • Escribir RFC a mano ✓
  • Cambiar tipo I → P con receptor ya elegido ✓
  • Cambiar contribuyente activo ✓

6. Complemento de pago: input "Fecha de pago"

Problema

El state pagoFecha existía en facturacion/page.tsx:314 pero nunca se renderizaba ni se mandaba al payload. Sin fecha, Facturapi usaba la fecha del servidor por default — incorrecta para pagos con fecha valor distinta a la captura.

Fix

  • Input <type=date> agregado en sección "Complemento de Pago" antes de "Forma de Pago"
  • Default: hoy. max=hoy (SAT no acepta fechas futuras en pagos)
  • Validación: alert si vacío al emitir
  • Payload: data.complements[0].data[0].date = "${pagoFecha}T12:00:00" (mediodía local — defensa contra TZ shift que tiraría la fecha al día anterior según huso del SAT)

7. Descarga PDF/XML post-emisión + por fila en lista CFDI

Cambio post-emisión

  • result state extendido con id (facturapi_id) además de uuid y total
  • Dos botones nuevos en el screen post-emit: "Descargar PDF" y "Descargar XML". Usan el endpoint GET /api/facturacion/pdf/:id y /xml/:id (ya existían). Trigger blob → forzar download con factura-{uuid}.pdf|xml

Cambio en lista CFDI

  • Nueva celda con ícono Printer, visible solo si cfdi.source === 'facturapi' && cfdi.facturapiId. Click → descarga PDF.
  • Solo PDF (no XML) por requerimiento. Los CFDIs SAT-sync o upload manual no tienen PDF descargable de Facturapi (404).

8. Columnas + estilo CFDI: Uso CFDI, centrado, Conceptos

Columna Uso CFDI

  • Nueva entre "Tipo Comp." y "UUID" en /cfdi
  • Muestra clave SAT (G01, G03, P01, S01, CP01, etc.) en font mono
  • Backend ya devolvía usoCfdi en SELECT, sin cambios

Centrado de columnas

  • <tr> del thead: text-left → text-center
  • <tbody>: agregado text-center
  • Excepción: Total queda text-right (estándar para montos)
  • Headers con flex container (Fecha, Emisor, Receptor) requirieron justify-center extra — text-center no afecta a flex children

Tabla Conceptos (sec. 12)

Ver más abajo.


9. Export CFDI: traer todas las páginas con cap 10k

Problema

El botón "Exportar" solo descargaba data.data (la página visible, ~20-50 filas). El contador esperaba el dataset filtrado completo.

Fix

Frontend exportToExcel:

  • En lugar de mapear data.data (paginado), hace await getCfdis({...filters, contribuyenteId: selectedContribuyenteId, page: 1, limit: 10_000}) para traer todo
  • Si el total backend supera 10k, confirm() con mensaje accionable

Backend cfdi.controller.ts:getCfdis:

  • limit: Math.min(parseInt(...) || 20, 10_000) — cap defensivo a 10k filas por request, ignora valores mayores

Bug derivado

El primer intento del export traía 10,186 CFDIs (TODO el despacho), no los 181 del contribuyente Horux 360 activo. Causa: el hook useCfdis inyecta selectedContribuyenteId automáticamente, pero getCfdis directo (sin hook) lo bypaseaba. Se agregó el contribuyenteId explícito al fetch del export.

Columnas Excel

17 columnas iniciales + 2 agregadas (Uso CFDI, Método Pago):

Fecha Emisión, Tipo Comprobante, Uso CFDI, Serie, Folio,
RFC Emisor, Nombre Emisor, RFC Receptor, Nombre Receptor,
Subtotal, Descuento, IVA, Total,
Moneda, Método Pago, Saldo Pendiente, Estatus, Fecha Cancelación, UUID

10. Saldo Pendiente: usar saldoPendienteMxn (campo backfilleado)

Problema

El Excel mostraba "Saldo Pendiente: vacío" para TODAS las facturas PPD del contribuyente Horux 360 (debería mostrar valor real para 6 vigentes con saldo > 0).

Diagnóstico

  • Backend devuelve saldoPendiente (moneda original) y saldoPendienteMxn (MXN convertido)
  • El backfill de utils/saldo.ts actualiza solo saldo_pendiente_mxn. La columna saldo_pendiente (sin sufijo MXN) quedó NULL para todas las filas
  • El export leía cfdi.saldoPendiente (NULL) → con ?? '' → vacío

Fix

Cambiar a cfdi.saldoPendienteMxn ?? ''. Para PUE/P/E (no aplica saldo) sigue vacío en Excel — coherente con "no aplica" en lugar de "0 = pagado". Para PPD emitidas con saldo > 0 ahora muestra el valor MXN.

Observación

Hay otros callers de cfdi.saldoPendiente (sin Mxn) en frontend que pueden estar leyendo NULL. Vale la pena grep saldoPendiente y migrar todos a MXN.


11. Deducciones ISR: bucket Nómina (tipo N emitidas)

Problema

El cálculo de deducciones para ISR (calcularEgresosPorRegimen) no incluía las nóminas que el contribuyente emite como patrón. Para regímenes que restan deducciones (606, 612, 626 PM), las nóminas pagadas son deducción auténtica.

Decisiones del owner

  1. Fecha de emisión (no respeta toggle Conciliación)
  2. Toggles considerarActivos/considerarNCs NO aplican (no es activo ni NC)
  3. Régimen: agrupar por regimen_fiscal_emisor (el del contribuyente como patrón)
  4. Régimen 605 (sueldos) sigue excluido del ISR; el bucket N nuevo no afecta a regímenes que no restan deducciones (RESICO PF, PM general)

Fórmula nueva por régimen

deducciones por régimen =
   facturas_I_PUE          (recibidas, lado receptor, sin impuestos)
 + pagos_P                 (recibidos, lado receptor, sin impuestos pago)
 + comp_I07_PPD            (compensación anticipos, lado receptor)
 + nomina_N                ← NUEVO: emitidas por contribuyente, total_mxn completo
  notas_credito_E_PUE     (recibidas, lado receptor, resta)

SQL nuevo (en dashboard.service.ts:calcularEgresosPorRegimen)

SELECT regimen_fiscal_emisor, SUM(total_mxn)
FROM cfdis
WHERE esEmisor                          -- contribuyente como patrón
  AND tipo_comprobante = 'N'
  AND status NOT IN ('Cancelado','0')
  AND fecha_emision IN [rango]          -- siempre fecha_emision
  AND regimen_fiscal_emisor = ANY(...)
GROUP BY regimen_fiscal_emisor

Sin restar impuestos trasladados, sin EXCL_MONTO, sin filtros de toggles.

Side effect

El mismo bucket también afecta el dashboard "Gastos del Mes" (porque calcularEgresosPorRegimen se reusa). Si se quiere split (afectar solo ISR), hay que mover el bucket N a getResumenIsr y dejar calcularEgresosPorRegimen sin nómina.


12. Pestaña "Conceptos" en /cfdi

Cambio

Pestañas tipo CFDIs | Conceptos en /cfdi. Ambas comparten el mismo Card de filtros globales (fechas, tipo, RFC, búsqueda, contribuyente).

Backend

GET /cfdi/conceptos — JOIN cfdi_conceptos cc con cfdis c. Devuelve fecha/uuid/RFCs del CFDI padre + todas las columnas de cc (incluye _mxn). Cap 10k filas defensivo.

Ruta registrada antes que /:id para que Express no lo trate como id.

Frontend

  • State activeTab: 'cfdis' | 'conceptos' + reset page=1 al cambiar tab
  • useQuery para conceptos, enabled solo en pestaña Conceptos (no fetch innecesario)
  • Tabla con columnas: Fecha, UUID (8 chars + tooltip), Clave, Descripción (truncate 280px), RFC Emisor, RFC Receptor, Cantidad (right), Unidad (clave + tooltip nombre), V. Unitario (right), Importe (right)
  • Paginación propia (mismo patrón que CFDIs, 50 conceptos por página)

Botón Exportar context-aware

  • En pestaña CFDIs → exportToExcel() (lo actual)
  • En pestaña Conceptos → exportConceptosToExcel() que descarga TODAS las columnas non-MXN (filtrado client-side con key.endsWith('_mxn')) más meta del CFDI padre. Cap 10k.

13. Pestaña Conceptos: filtros header + sort por importe

Cambio

Popovers en headers de la tabla Conceptos (estilo CFDIs):

Header Filtro
Fecha Reusa el Popover existente con columnFilters.fechaInicio/fechaFin (compartido con CFDIs)
UUID input texto, filtro server-side uuidLike ILIKE %x%
Clave input texto, filtro server-side claveProdServ ILIKE
Descripción input texto, filtro server-side descripcionConcepto ILIKE

Sort por Importe

Click en header alterna desc → asc → off:

  • ▼ desc (default al activar)
  • ▲ asc
  • ⇅ off (vuelve al default fecha DESC)

Backend extendido

getConceptosList acepta nuevos filtros:

  • uuidLike, claveProdServ, descripcionConcepto (todos ILIKE %x%)
  • orderBy: 'fecha' | 'importe' + orderDir: 'asc' | 'desc'

State local

Filtros de Conceptos en state separado conceptosFilters (no compartido con CFDIs) — al cambiar a CFDIs y volver, se preservan. Si se quiere que se limpien al cambiar de pestaña, basta agregar reset al setActiveTab.


14. Form Custom: invertir trial/custom + Primera fecha de pago

Bug invertido

  • Antes: trial mostraba "Monto Mensual"; custom mostraba texto "no genera cobro"
  • Ahora: trial NO muestra Monto (gratis 30 días); custom SÍ muestra Monto (variable según decisión 2026-04-30)

Texto custom actualizado

"Custom: monto variable. Si dejas $0, no se cobra ni se solicita tarjeta. Si pones >$0, se generará Subscription con preapproval MercadoPago mensual."

Texto trial agregado

"Plan de prueba: 30 días gratis sin tarjeta. Se convierte a un plan pagado al final del periodo."

Primera fecha de pago (Custom only)

Input <type=date> opcional, visible solo cuando plan === 'custom'. Default vacío. min = hoy.

Backend persistencia

tenants.service.ts:createTenant acepta firstPaymentDueAt?: string | null. Si plan custom + viene la fecha → Subscription.currentPeriodStart = now() + currentPeriodEnd = firstPaymentDueAt. Para otros planes se ignora.

Aprovecha el campo Subscription.currentPeriodEnd existente (no requiere schema nuevo). Sirve como deadline visible al cliente.


15. Cleanup organización Facturapi de Carlos (Live multi-RFC)

Caso de uso

Validar el flow eager Live multi-RFC del refactor del 2026-04-30 partiendo de cero para el contribuyente Carlos (TORC9611214CA en tenant Patito):

  1. Owner borró la org en panel Facturapi manualmente (era org de sandbox/test inválida en Live)
  2. Se borró el row de facturapi_orgs localmente para Carlos
  3. Owner creó org nueva en panel y dio el org_id (69f23a5a242e0af47a41fa0d)
  4. Se insertó row local apuntando al nuevo org_id (sin api_key cifrada)
  5. Al primer emit, el lazy fallback de getOrgApiKey hará PUT /apikeys/live + cifrar + persistir

Bug encontrado al emitir Horux 360

Org 69e489d57e6ca5168734068b no existía en cuenta Live → PUT live retornó 404. Mismo procedimiento que para Carlos: borrar row + insertar nuevo org_id (69f23a5a242e0af47a41fa0d también, reusable).

Otros tenants

Migración tenant 041_facturapi_orgs_api_key_enc.sql aplicada a los otros 2 tenants despacho (mo7je8bz_vdopr, mo3nhzvl_1xheu) que no la habían ejecutado lazy.

Mejora propuesta (no implementada)

Hacer getOrgApiKey resiliente al 404: en lugar de throw, devolver un AppError(400, 'Recréala desde Configuración') con mensaje accionable. Hoy el fix manual requiere SQL directo o re-crear desde la UI de gestión.


16. Trial timbres = 0 (alineación seed/BD)

Cambio

apps/api/prisma/seed.ts:154: timbresIncluidosMes: 20 → 0. La BD ya estaba en 0 (alguien editó vía UI admin); el seed estaba en 20. Ahora ambos están alineados.

Implicación

Plan trial NO permite emitir CFDIs (sin timbres). Forza al cliente a contratar un plan pagado para empezar a timbrar. Si se quiere permitir un par de pruebas durante el trial sin contratar, valor recomendado: 5-10. 0 es lo más estricto.

Inconsistencia residual

El DESPACHO_PLANS.trial en TS (packages/shared/src/constants/despacho-plans.ts:7) sigue diciendo timbresIncluidosMes: 20. Es default cosmético — el backend lee BD via getDespachoPlanLimits. Si se quiere alinear documentación, también actualizar el TS.


17. Integración Metabase (porteada de producción)

Qué se rescató

Producción tenía un service metabase.service.ts que el working dir nunca había recibido. Auto-registra cada BD postgres de tenant nueva en Metabase para que el equipo de BI pueda crear queries/dashboards sobre el dato. Al borrar tenant, se desregistra.

Cambios

Archivo Cambio
apps/api/src/services/metabase.service.ts NUEVO — copia byte-por-byte de prod (solo expandí comentario header)
apps/api/src/services/tenants.service.ts 3 callsites: createTenant, addTenantToOwner, deleteTenant. Todos fire-and-forget con .catch()
apps/api/src/config/env.ts 7 variables Zod (todas opcionales)
apps/api/.env.example Re-creado (lo había borrado el owner) con bloque Metabase + comentarios

Service highlights

  • getSessionToken() — POST /api/session con {username, password}, cache de 13 días (Metabase los expira a 14)
  • registerDatabase({nombre, dbName}) — POST /api/database con engine postgres + connection details. Idempotente: 400 "already exists" se logea como "ya registrado" sin error
  • deleteDatabase(databaseName) — busca por details.dbname o name contains, DELETE /api/database/{id}

Variables de entorno (7)

METABASE_URL              (default http://192.168.10.170:3000)
METABASE_USERNAME         (default ialcarazsalazar@consultoria-as.com)
METABASE_PASSWORD         ← required para que funcione
METABASE_PG_HOST          (default 192.168.10.90)
METABASE_PG_PORT          (default 5432)
METABASE_PG_USER          (default postgres)
METABASE_PG_PASSWORD      ← required para que funcione

Impacto en operación actual

Cero impacto si las credenciales no están seteadas:

  • Sin METABASE_PASSWORDgetSessionToken() retorna null → todas las llamadas logean [METABASE] Skipping... y retornan sin tirar error
  • Sin METABASE_PG_PASSWORD → similar — registerDatabase skip-ea
  • Llamadas fire-and-forget con .catch() en tenants.service.ts → Metabase caído NO bloquea creación/borrado de tenant
  • No se registran routes nuevas en app.ts (cero superficie HTTP nueva)
  • No hay migraciones de schema

Cuándo activarlo

Llenar METABASE_PASSWORD y METABASE_PG_PASSWORD en .env. Si los IPs default (192.168.10.170 / 192.168.10.90) no aplican al setup local, ajustar METABASE_URL y METABASE_PG_HOST también.


18. Página registro: actualizar planes + toggle mensual/anual

Problema

La página /register-despacho mostraba 3 planes con datos desactualizados:

  • trial: 20 timbres (BD ya en 0)
  • business_control: $21,000 1er año / $15,000 renov (catálogo dice $25,850 fijo)
  • business_cloud: $15,000/año + $45/RFC (catálogo dice $43,000 fijo)
  • Faltaban mi_empresa y mi_empresa_plus
  • Custom (admin-only) correctamente NO aparece

Cambios

Frontend (apps/web/app/(auth)/register-despacho/page.tsx):

  • 5 cards: trial / mi_empresa / mi_empresa_plus / business_control / Enterprise (display de business_cloud)
  • Grid md:grid-cols-2 lg:grid-cols-5 para acomodar las 5 cards
  • Datos alineados con catálogo BD despacho_plan_prices
  • "Más popular" badge en Business Control (decisión del owner)
  • Iconos: Clock (trial), Building (mi_empresa), Sparkles (mi_empresa_plus), Server (business_control), Cloud (Enterprise)

Toggle Mensual / Anual:

  • State billingFrequency: 'monthly' | 'annual' con default 'annual' (sesgo intencional al cash-flow del negocio)
  • UI pill "Mensual / Anual" arriba de las cards. Anual tiene badge verde "Ahorra 17%"
  • Solo afecta el precio mostrado de Mi Empresa y Mi Empresa+ (los demás solo annual)
  • Payload manda frequency: billingFrequency siempre — backend ignora cuando no aplica

Backend (apps/api/src/controllers/despacho.controller.ts):

  • signupSchema.despacho.plan ahora acepta los 5 planes (antes solo 3)
  • Nuevo campo opcional signupSchema.despacho.frequency: 'monthly' | 'annual' con default 'annual'

Backend (apps/api/src/services/despacho.service.ts):

  • signupDespacho pasa data.despacho.frequency a subscriptionService.subscribe (antes hardcoded 'annual')

Shared types (packages/shared/src/types/despacho.ts):

  • DespachoSignupPlan extendido a 5 valores
  • DespachoSignupRequest.despacho.frequency?: 'monthly' | 'annual'

Defensa server-side

subscriptionService.subscribe valida permiteFrecuenciaMensualDb(plan) y rechaza monthly cuando no aplica (business_control / business_cloud / custom). Si el front manda frequency mensual para un plan annual-only, el backend devuelve AppError(400, 'El plan X solo está disponible con frecuencia anual').


19. Cleanup final del legacy Horux 360

Contexto

Después del refactor del 2026-04-30 quedaron 5 piezas residuales del legacy. Se atacaron 3 (las 2 restantes son cosméticas — defaults .env y nombre del script bootstrap-horux360-admin.ts, dejadas intencionalmente).

Cambio 1: Drop modelo PlanPrice + tabla plan_prices

Schema:

  • Eliminado model PlanPrice de apps/api/prisma/schema.prisma
  • Migración 20260501160000_drop_plan_prices_legacy/migration.sql: DROP TABLE "plan_prices"

Backend (subscription.controller.ts y subscription.routes.ts):

  • Eliminados getPlans y updatePlanPrice controllers
  • Eliminadas routes GET /subscriptions/plans y PUT /subscriptions/plans/:id
  • Reemplazo: el catálogo despacho vive en despacho_plan_prices con endpoints /api/planes/despacho

Frontend (apps/web/lib/api/subscription.ts y lib/hooks/use-subscription.ts):

  • Eliminados helpers getPlans() y updatePlanPrice()
  • Eliminados hooks usePlans() y useUpdatePlanPrice()
  • Verificado con grep: cero callers en pages del frontend

Cambio 2: Fix setup-despachos-db.ts:30

-    plan: 'business',  // valor inexistente en enum actual
+    plan: 'trial',

Sin esto, ejecutar el script fallaba con error opaco de Prisma sobre el enum.

Cambio 3: Alinear DESPACHO_PLANS.trial.timbresIncluidosMes

-  timbresIncluidosMes: 20,  // TS decía 20
+  timbresIncluidosMes: 0,   // BD/seed/UI ahora todos en 0

packages/shared/src/constants/despacho-plans.ts:7 — ya el TS está alineado con BD y seed.

Lo que se mantiene (no es legacy técnico)

  • bootstrap-horux360-admin.ts (nombre del script) — la lógica interna ya usa plan: 'custom'. Solo el nombre del archivo es legacy. Cosmético.
  • Defaults .env con sufijo horux360-...JWT_SECRET, FIEL_ENCRYPTION_KEY cosméticos del archivo local. .env.example ya tiene placeholders neutros.
  • Tenant llamado "Horux 360" (RFC HTS240708LJA) — es la firma real del platform admin Carlos & Ivan. Vigente.
  • XMLs en apps/api/data/xmls/... con Nombre="HORUX 360" — son CFDIs reales sincronizados del SAT. Data, no código.

Estado final del legacy

Pieza Estado
Modelo PlanPrice eliminado
Tabla plan_prices BD dropeada
Endpoints getPlans / updatePlanPrice eliminados
Hooks frontend usePlans / useUpdatePlanPrice eliminados
Catálogo TS PLANS (Horux 360) eliminado en sesión 2026-04-30
Enum Plan (BD) con valores legacy estrechado en sesión 2026-04-30
setup-despachos-db.ts con plan: 'business' ✓ migrado a 'trial'
DESPACHO_PLANS.trial.timbresIncluidosMes ✓ alineado a 0
Defaults cosméticos .env con horux360-... mantenidos (no afectan funcionalidad)
Script bootstrap-horux360-admin.ts (nombre) mantenido (cosmético)
Tenant "Horux 360" + XMLs reales mantenidos (data real)

Cero referencias legacy activas en código.


20. Pendientes derivados

Bloqueantes funcionales pequeños

  • Smoke test del flow Live de Facturapi end-to-end (crear contribuyente → org + Live key cifrada → CSD → emitir factura I real con SAT) — owner tiene que disparar desde UI con CSD live de un RFC real
  • Validación de fecha futura server-side en firstPaymentDueAt — frontend lo limita con min=hoy pero el backend acepta cualquier fecha si se manda en body directo. Conviene Zod check si se quiere endurecer

Mejoras de UX

  • getOrgApiKey resiliente al 404 de Facturapi (en lugar de throw, devolver mensaje accionable que linkee a /configuracion para recrear)
  • Banner deadline para tenants Custom con currentPeriodEnd en /configuracion/suscripcion o /mis-empresas — hoy se persiste pero no se rendea visible al cliente
  • Sort cycle de Conceptos en 3 estados (desc → asc → off) — si se prefiere alternar entre 2 (asc/desc), simplificar toggleImporteSort

Deuda técnica

  • Otros consumidores de cfdi.saldoPendiente (sin Mxn) en frontend pueden estar leyendo NULL. Hacer grep saldoPendiente y migrar todos a Mxn
  • Bucket Nómina afecta también dashboard "Gastos del Mes" — si solo se quería para ISR, mover el bucket de calcularEgresosPorRegimen a getResumenIsr
  • Frequency type en subscription.service.ts y los literales 'monthly' | 'annual' que recibe getPrecioDespachoDb son tipos paralelos. Si se agrega una frecuencia (e.g. 'quarterly'), sincronizar ambos lados
  • Errores typecheck pre-existentes en web (cfdi/page.tsx con loadingCfdi, ivaTrasladoTraslado, usuarios/page.tsx UserInvite) — arrastrados de antes, no relacionados a esta sesión

Pendientes Metabase

  • Setear METABASE_PASSWORD y METABASE_PG_PASSWORD en .env del entorno donde se quiera activar — sin ellos el service skip-ea silenciosamente
  • Ajustar IPs default (192.168.10.170 / 192.168.10.90) si no aplican al setup local — vienen heredadas de producción
  • Backfill de tenants existentes en Metabase: el porte solo registra tenants nuevos. Para los 4 tenants ya provisionados (Horux 360, Patito, Zorro, Empresa Demo) habría que correr un script one-shot llamando metabaseService.registerDatabase para cada uno

Mejora de seguridad opcional

  • MP_WEBHOOK_SECRET en dev: hoy si no está configurado, el webhook se rechaza con 401. Si se quiere skip de verificación solo en NODE_ENV=development con log warning, son ~3 líneas en mercadopago.service.ts:verifyWebhookSignature

Archivos tocados (resumen)

Backend

apps/api/src/controllers/cfdi.controller.ts       (listConceptos + extends getCfdis cap)
apps/api/src/controllers/facturacion.controller.ts (getCfdisRelacionables, validaciones emit, fix HAVING, multi-RFC en searchRfcs/cfdisPpd)
apps/api/src/controllers/tenants.controller.ts    (firstPaymentDueAt)
apps/api/src/services/cfdi.service.ts             (getConceptosList con filtros + sort)
apps/api/src/services/dashboard.service.ts        (bucket Nómina en deducciones)
apps/api/src/services/tenants.service.ts          (firstPaymentDueAt en createTenant)
apps/api/src/routes/cfdi.routes.ts                (GET /cfdi/conceptos antes de /:id)
apps/api/src/routes/facturacion.routes.ts         (GET /facturacion/cfdis-relacionables)
apps/api/.env.example                             (24 vars en 9 bloques)
apps/api/prisma/seed.ts                           (trial timbres 20→0)
apps/api/prisma/schema.prisma                     (drop PlanPrice model + 7 vars Metabase env)
apps/api/prisma/migrations/20260501160000_drop_plan_prices_legacy  (DROP TABLE)
apps/api/.env.example                             (recreado con bloque Metabase + 7 vars)
apps/api/scripts/setup-despachos-db.ts            (plan: 'business' → 'trial')
apps/api/src/services/metabase.service.ts         (NUEVO — porteado de prod)
apps/api/src/services/tenants.service.ts          (3 callsites Metabase fire-and-forget)
apps/api/src/controllers/despacho.controller.ts   (signupSchema acepta mi_empresa(+) + frequency)
apps/api/src/services/despacho.service.ts         (frequency dinámico en signup)
apps/api/src/config/env.ts                        (7 vars Metabase opcionales en Zod)

Frontend

apps/web/lib/api/cfdi.ts                          (getConceptosList con filtros + sort, CfdiConceptoRow)
apps/web/lib/api/facturacion.ts                   (getCfdisRelacionables, getCfdisPpd/searchRfcs con contribuyenteId)
apps/web/lib/api/tenants.ts                       (firstPaymentDueAt)
apps/web/lib/api/subscription.ts                  (eliminados getPlans + updatePlanPrice helpers)
apps/web/lib/hooks/use-subscription.ts            (eliminados usePlans + useUpdatePlanPrice hooks)
apps/web/app/(auth)/register-despacho/page.tsx    (5 planes + toggle mensual/anual + Más popular en Business Control)
apps/web/app/(dashboard)/cfdi/page.tsx            (Uso CFDI, centrado, PDF Facturapi, Conceptos tab + filtros + sort, export todas las páginas, saldoPendienteMxn)
apps/web/app/(dashboard)/clientes/page.tsx        (form Custom con Monto + Primera fecha de pago, trial sin Monto)
apps/web/app/(dashboard)/facturacion/page.tsx     (CFDIs Relacionados Card, fecha pago, descarga PDF/XML post-emit, useEffect auto-load, structure SAT 4.0 relatedDocuments, dropdown CFDIs relacionables)

Shared package

packages/shared/src/types/despacho.ts             (DespachoSignupPlan extendido + frequency en SignupRequest)
packages/shared/src/constants/despacho-plans.ts   (trial timbresIncluidosMes 20→0)

Backend cleanup legacy

apps/api/src/controllers/subscription.controller.ts  (eliminados getPlans + updatePlanPrice)
apps/api/src/routes/subscription.routes.ts           (eliminadas routes /plans y /plans/:id)

BD local (sin migración)

  • facturapi_orgs row de Horux 360 contribuyente borrado + insertado con nuevo org_id 69f23a5a242e0af47a41fa0d (en BD horux_despacho_mo3ni6u8_b9vgg)
  • Migración tenant 041_facturapi_orgs_api_key_enc.sql aplicada a los otros 2 tenants despacho (mo7je8bz_vdopr, mo3nhzvl_1xheu)

V.1.0.15 — Subscription lifecycle: banner global, sidebar gate, emails pre/post-vencimiento, flujo MP self-serve consolidado en /planes-despacho

Contexto

Punto de partida: el cliente que se quedaba sin suscripción activa lo descubría cuando intentaba escribir y le saltaba un 403 frío. Sin avisos previos por email, sin banner persistente, sin un único lugar donde gestionar el plan. La gestión estaba dispersa entre /configuracion/suscripcion (vista rich) y /configuracion/planes-despacho (cards de planes). El admin global tampoco tenía un buen path porque sus exenciones bloqueaban probar el flujo.

Esta sección entrega: banner global persistente, sidebar que colapsa al expirar, emails pre-vencimiento + payment-failed, middleware corregido, página de gestión consolidada en /planes-despacho y toggle prod/sandbox de MP.

1. Foundation: helper compartido getSubscriptionState

Single source of truth para "qué puede hacer este tenant ahora". Backend (middleware, cron) y frontend (banner, sidebar, navGate) consumen el mismo helper, evitando que cada componente derive flags distintos del mismo Subscription object.

Archivo: packages/shared/src/types/subscription.ts

getSubscriptionState(sub)  {
  status, daysUntilEnd, isExpired,
  isTrial, isTrialExpired, isActive, isPending,
  isCancelledInPeriod, isCancelledExpired,
  needsRenewal,  // bloqueante: tenant no puede escribir
  isWarning,     // ≤7 días pre-vencimiento
}

needsRenewal = !(isActive || isPending || isTrial || isCancelledInPeriod). isWarning = !needsRenewal && daysUntilEnd > 0 && daysUntilEnd <= 7.

2. Middleware plan-limits corregido (#5)

Bug pre-existente: allowedStatuses = ['authorized', 'pending'] excluía trial. Cualquier trial que escribiera CFDI/dashboard/reportes chocaba con un 403 "Suscripción inactiva".

Cambio: apps/api/src/middlewares/plan-limits.middleware.ts

// GETs siempre pasan (modo lectura para historial y export)
if (req.method === 'GET') return next();
// Admin global impersonation bypass (sin cambio)
if (req.headers['x-view-tenant'] && await isGlobalAdmin(...)) return next();

const state = getSubscriptionState(subscription);
if (state.needsRenewal) {
  return res.status(403).json({
    code: 'SUBSCRIPTION_INACTIVE',
    message: ..., // mensaje según trial_expired vs cancelled_expired
    redirectTo: '/configuracion/planes-despacho',
    subscriptionStatus: state.status,
  });
}

Cierra también la divergencia "cron 02:30 vs frontend": el helper compara currentPeriodEnd además de status, así un trial cuyo período pasó pero aún no fue marcado trial_expired queda bloqueado igual.

3. Frontend interceptor 403 estructurado (#2)

apps/web/lib/api/client.ts — interceptor de axios detecta el 403 con code: 'SUBSCRIPTION_INACTIVE' → redirige automático a la URL del backend. Cualquier botón "Guardar" en cualquier pantalla manda al usuario al lugar correcto sin código adicional en cada componente.

4. Sidebar gate (#7)

Hook apps/web/lib/hooks/use-nav-gate.ts — cuando needsRenewal === true, oculta todos los items del sidebar excepto Planes, Configuración y Mis empresas. Aplicado en los 4 sidebars (sidebar, sidebar-compact, sidebar-floating, topnav). Admin global exento (su tenant Horux 360 siempre activo + opera vía impersonación).

Pattern: filteredNav.filter(item => navGate.isAllowed(item.href)).

5. Banner global <SubscriptionBanner /> (#1 + #6)

Nuevo componente apps/web/components/subscription-banner.tsx montado en apps/web/app/(dashboard)/layout.tsx arriba del contenido. 6 variantes:

Estado Color Mensaje CTA
Trial activo, ≤7d Azul "Tu prueba gratuita termina en N días" Elegir plan
Trial vencido Rojo "Tu prueba gratuita terminó" Elegir plan
Cancelled in period Naranja "Suscripción cancelada — N días más" Reactivar
Cancelled vencido Rojo "Tu suscripción venció" Renovar
Sin sub Rojo "No tienes una suscripción activa" Ver planes
Authorized, ≤7d Amber "Tu período termina en N días" Ver suscripción

Estado authorized con daysUntilEnd > 7 → no aparece (sin ruido). Admin global e impersonación: no se muestra (suprimido por diseño).

CTA va a /configuracion/planes-despacho (no a /suscripcion).

6. Cron pre-vencimiento (#3)

Nueva función sendExpiryReminders() en apps/api/src/services/payment/subscription.service.ts:

  • Cron diario 9:00 AM CDMX (0 9 * * *) registrado en sat-sync.job.ts
  • Buckets de días restantes: 7 → 3 → 1 → 0
  • Idempotencia vía nuevas columnas subscriptions.lastReminderDay + lastReminderSentAt (migración 20260501170000_add_subscription_reminder_tracking)
  • Detecta período renovado: si bucket > lastReminderDay, actualiza tracker pero NO envía email (período rolló, está lejos de vencer)
  • Trial usa sendTrialReminder / sendTrialExpired; paid usa sendSubscriptionExpiring

Templates ya existían sin caller (deuda silenciosa) — ahora conectados.

7. Email payment-failed wired (#4)

apps/api/src/services/payment/subscription.service.ts:recordPayment ahora es idempotente por mpPaymentId: detecta duplicados de webhook y solo envía email en transición real de status (no en cada notificación).

3 flows cubiertos:

  • Recurring (subscribe/upgrade vía recordPayment): rejected/cancelled → email
  • Timbres-pack (webhook.controller.ts directo): tracking de previousStatus inline
  • Addon (addon.service.ts:handleAddonPayment): branch nuevo para rejected/cancelled

Todos fail-soft: el email no rompe el webhook si SMTP falla.

8. Página /configuracion/suscripcion consolidada en /planes-despacho

Antes: dos páginas con UI superpuesta. /suscripcion tenía la vista rich (banners, picker, change, cancel, payment history, reactivate); /planes-despacho tenía las cards de planes y "contratar". Confuso para el usuario.

Ahora: /planes-despacho es el único lugar donde un cliente gestiona su plan. /suscripcion queda exclusivamente como panel agregado del admin global.

Cambios:

  • /configuracion/suscripcion redirige a /planes-despacho para no-admin
  • Borrado ~490 líneas de código self-serve duplicado + imports muertos
  • Banner CTA y middleware redirectTo apuntan a /planes-despacho

9. /configuracion/planes-despacho — flujo de pago completo

Mejoras:

  • Cards visibles para custom: removido {!isCustomPlan && ...} — Carlos con plan custom ahora puede cambiar a un plan público.
  • hasActiveSub nuevo flag basado en subscription.status (no en tenant.plan). Reemplaza hasPaidPlan para gating de cancel + label de botón. Custom + trial cuentan como sub activa.
  • Upgrade flow encadenado en handleContratar:
    subscribeMe → si "ya existe" → upgradeMe (prorrateado, MP) → si rechaza
    (downgrade) → changeMyPlan (programado fin período)
    
    Si hay diferencia de precio, abre MP para cobro inmediato. Si es downgrade, programa cambio sin cobro.
  • Botón "Pagar mi período actual — $X": nueva sección con <CreditCard> icon que dispara generatePaymentLink → MP one-off. Visible cuando hasActiveSub && amount > 0. Útil para custom plans, pending subs, o pre-pagar antes del cobro automático.
  • Cancel ahora visible para cualquier sub corriendo (paid, trial, custom), no solo paid.

Hook agregado: usePlans() en use-subscription.ts — adapta el catálogo despacho (/planes/despacho) al shape legacy {plan, frequency, amount}[] que /configuracion/suscripcion esperaba pero estaba importando del hook eliminado. Corrige el TypeError: usePlans is not a function que estaba oculto por el early-return de admin global.

10. Auth middleware: authorize() con bypass de platform superset

apps/api/src/middlewares/auth.middleware.ts — cualquier user con platform_admin o platform_ti ahora hace bypass del role check.

Antes: Iván (rol tenant contador, platformRole platform_ti) recibía 403 en cualquier endpoint protegido por authorize('owner', 'cfo'). Esto contradecía el comportamiento documentado en CLAUDE.md ("supersets implican todos los demás").

const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']);
if (req.user.platformRoles?.some(r => PLATFORM_SUPERSET.has(r))) return next();

Antes vivía solo en utils/platform-admin.ts:SUPERSET_ROLES; ahora duplicado en el middleware (comentado para mantener sincronizado). Idealmente se exporta de shared en un futuro refactor.

11. MP service: dev-friendly toggles

apps/api/src/services/payment/mercadopago.service.ts añade 3 helpers para desbloquear testing local sin tocar el flujo de producción:

a. backUrlBase() — fallback público para localhost

MP rechaza back_url=http://localhost:3000 con 400. Si FRONTEND_URL contiene localhost/127.0.0.1, sustituye por https://horuxfin.com con warning. Production: no-op.

b. MP_USE_SANDBOX toggle + MP_ACCESS_TOKEN_SANDBOX

Permite tener AMBOS tokens en .env (prod + sandbox) y togglear con un flag. Cuando MP_USE_SANDBOX=true && MP_ACCESS_TOKEN_SANDBOX seteado, el service usa el TEST token. Si está activado pero el sandbox token no está, warning + cae al de prod (defensivo: ningún arranque silencioso en modo prod cuando creías estar en sandbox).

c. resolvePayerEmail() con MP_TEST_PAYER_EMAIL

Override del payer email cuando el owner del tenant es la misma cuenta del seller (problema MP "Payer and collector cannot be the same user"). Aplica a las 3 llamadas a MP (preapproval, proration preference, timbres-pack preference). Production: no-op.

12. Auth fix: admin global sin memberships

apps/api/src/services/auth.service.ts — fallback para platform admin que queda sin membership activa (caso edge: tenant raíz borrado, admin nuevo sin tenant propio). Antes: 401 "No tienes acceso". Ahora: si el user es platform staff, login OK con el primer tenant activo de la BD como "nominal" + rol visor defensivo. Su trabajo real va via impersonación.

13. Estado de validación MP

Componente Status
Frontend → API request
Auth (Iván platform_ti pasa)
Backend genera preapproval correctamente
back_url resolución localhost
Override de payer email
Toggle prod/sandbox
MP recibe el request y lo VALIDA (rechaza con 400 semántico, no 500 de red)
MP abre página de checkout Bloqueado por sandbox MP

El bloqueo es de configuración MP, no de código. MP sandbox requiere test seller (creado vía API + login + crear app dentro) además del test buyer. La cuenta sandbox actual de Horux es "modo test del seller real" (user_id 1966893850 = mismo que prod), MP la categoriza como real. Mezcla real-seller + test-buyer → rechazo.

En producción: payer (cliente real) y collector (Horux 360) son entidades naturales distintas → MP acepta sin restricción. El flujo se valida automáticamente con el primer cobro real.

14. Horux 360 plan custom configurado

Script apps/api/scripts/set-horux-custom.ts — pone la suscripción del tenant Horux 360 (HTS240708LJA) en plan custom $10 con currentPeriodEnd en 7 días. Idempotente. Útil para probar visualmente el banner amber (≤7 días) sin esperar.

Cambios [TEMP] pendientes de revertir antes de prod

Tarea #35 registrada. 2 hits en apps/web:

  1. apps/web/components/subscription-banner.tsx:33-37 — admin global ya no se exenta del banner. Restaurar:

    if (isGlobalAdmin || viewingTenantId) return null;
    
  2. apps/web/lib/hooks/use-nav-gate.ts:31-37 — admin global ya no se exenta del navGate. Restaurar:

    const needsRenewal = !isGlobalAdmin && subscription !== undefined && ...
    

Estos guards se removieron temporalmente para que Carlos/Iván pudieran probar el flujo desde Horux 360. En prod no aplica porque admin global opera sobre tenants de clientes vía impersonación.

  1. apps/api/.env — variables solo dev:
    • MP_USE_SANDBOX=true → cambiar a false o quitar
    • MP_TEST_PAYER_EMAIL=test_user_8835312808309856761@testuser.com → vaciar
    • MP_ACCESS_TOKEN_SANDBOX=TEST-... → puede quedarse, no se usa con MP_USE_SANDBOX=false

Archivos modificados

Shared

packages/shared/src/types/subscription.ts          (NUEVO — getSubscriptionState helper)
packages/shared/src/index.ts                       (export del nuevo módulo)

Backend

apps/api/src/middlewares/plan-limits.middleware.ts (status + currentPeriodEnd, structured 403, 'trial' acepted)
apps/api/src/middlewares/auth.middleware.ts        (platform superset bypass)
apps/api/src/services/auth.service.ts              (admin global sin memberships fallback)
apps/api/src/services/payment/mercadopago.service.ts (backUrlBase, useSandbox toggle, resolvePayerEmail)
apps/api/src/services/payment/subscription.service.ts (recordPayment idempotente, sendExpiryReminders)
apps/api/src/services/payment/addon.service.ts     (sendPaymentFailed branch para rejected/cancelled)
apps/api/src/controllers/webhook.controller.ts     (timbres-pack failed email + idempotencia)
apps/api/src/jobs/sat-sync.job.ts                  (cron expiryReminders 9:00 AM)
apps/api/src/config/env.ts                         (MP_USE_SANDBOX, MP_ACCESS_TOKEN_SANDBOX, MP_TEST_PAYER_EMAIL)
apps/api/prisma/schema.prisma                      (Subscription.lastReminderDay/SentAt)
apps/api/prisma/migrations/20260501170000_add_subscription_reminder_tracking/migration.sql (NUEVO)
apps/api/scripts/set-horux-custom.ts               (NUEVO — set Horux 360 a custom $10 / 7 días)

Frontend

apps/web/lib/api/client.ts                         (interceptor 403 SUBSCRIPTION_INACTIVE → redirect)
apps/web/lib/hooks/use-nav-gate.ts                 (NUEVO — gate de sidebar por needsRenewal)
apps/web/lib/hooks/use-subscription.ts             (usePlans adapter contra /planes/despacho)
apps/web/components/subscription-banner.tsx       (NUEVO — 6 variantes según estado)
apps/web/components/layouts/sidebar.tsx            (navGate filter)
apps/web/components/layouts/sidebar-compact.tsx    (navGate filter)
apps/web/components/layouts/sidebar-floating.tsx   (navGate filter)
apps/web/components/layouts/topnav.tsx             (navGate filter)
apps/web/app/(dashboard)/layout.tsx                (mount SubscriptionBanner)
apps/web/app/(dashboard)/configuracion/suscripcion/page.tsx (redirect non-admin → planes; admin → tabla; -490 LOC)
apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx (cards para custom, hasActiveSub, upgrade flow, "Pagar ahora", cancel para sub activa)

.env

apps/api/.env  +MP_ACCESS_TOKEN_SANDBOX, +MP_USE_SANDBOX=true, +MP_TEST_PAYER_EMAIL (todos para revert pre-prod)