48 KiB
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
- Setup .env + bug DATABASE_URL post-template
- Sección "CFDIs Relacionados" en facturación I/E
- Bug crítico cfdis-ppd: HAVING sin GROUP BY
- Leak multi-RFC en 3 endpoints (searchRfcs, cfdis-ppd, cfdis-relacionables)
- Auto-carga de listas al escribir RFC manualmente
- Complemento de pago: input "Fecha de pago"
- Descarga PDF/XML post-emisión + por fila en lista CFDI
- Columnas + estilo CFDI: Uso CFDI, centrado, Conceptos
- Export CFDI: traer todas las páginas con cap 10k
- Saldo Pendiente: usar
saldoPendienteMxn(campo backfilleado) - Deducciones ISR: bucket Nómina (tipo N emitidas)
- Pestaña "Conceptos" en /cfdi
- Pestaña Conceptos: filtros header + sort por importe
- Form Custom: invertir trial/custom + Primera fecha de pago
- Cleanup organización Facturapi de Carlos (Live multi-RFC)
- Trial timbres = 0 (alineación seed/BD)
- Integración Metabase (porteada de producción)
- Página registro: actualizar planes + toggle mensual/anual
- Cleanup final del legacy Horux 360
- 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_URLcon 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.examplequedó 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
selectRfccuando 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
HAVINGaWHERE(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 porutils/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 activoGET /facturacion/cfdis-ppd→ mostraba PPDs de cualquier contribuyenteGET /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 = XgetCfdisRelacionables: 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)tipoComprobanteselectedContribuyenteId
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
resultstate extendido conid(facturapi_id) además deuuidytotal- Dos botones nuevos en el screen post-emit: "Descargar PDF" y "Descargar
XML". Usan el endpoint
GET /api/facturacion/pdf/:idy/xml/:id(ya existían). Trigger blob → forzar download confactura-{uuid}.pdf|xml
Cambio en lista CFDI
- Nueva celda con ícono
Printer, visible solo sicfdi.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
usoCfdien SELECT, sin cambios
Centrado de columnas
<tr>del thead:text-left → text-center<tbody>: agregadotext-center- Excepción: Total queda
text-right(estándar para montos) - Headers con flex container (Fecha, Emisor, Receptor) requirieron
justify-centerextra —text-centerno 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), haceawait 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) ysaldoPendienteMxn(MXN convertido) - El backfill de
utils/saldo.tsactualiza solosaldo_pendiente_mxn. La columnasaldo_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
- Fecha de emisión (no respeta toggle Conciliación)
- Toggles
considerarActivos/considerarNCsNO aplican (no es activo ni NC) - Régimen: agrupar por
regimen_fiscal_emisor(el del contribuyente como patrón) - 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'+ resetpage=1al cambiar tab useQuerypara conceptos,enabledsolo 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 conkey.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):
- Owner borró la org en panel Facturapi manualmente (era org de sandbox/test inválida en Live)
- Se borró el row de
facturapi_orgslocalmente para Carlos - Owner creó org nueva en panel y dio el
org_id(69f23a5a242e0af47a41fa0d) - Se insertó row local apuntando al nuevo org_id (sin api_key cifrada)
- Al primer emit, el lazy fallback de
getOrgApiKeyhará 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/sessioncon{username, password}, cache de 13 días (Metabase los expira a 14)registerDatabase({nombre, dbName})— POST/api/databasecon engine postgres + connection details. Idempotente: 400 "already exists" se logea como "ya registrado" sin errordeleteDatabase(databaseName)— busca pordetails.dbnameonamecontains, 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_PASSWORD→getSessionToken()retorna null → todas las llamadas logean[METABASE] Skipping...y retornan sin tirar error - Sin
METABASE_PG_PASSWORD→ similar —registerDatabaseskip-ea - Llamadas fire-and-forget con
.catch()entenants.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_empresaymi_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-5para 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: billingFrequencysiempre — backend ignora cuando no aplica
Backend (apps/api/src/controllers/despacho.controller.ts):
signupSchema.despacho.planahora 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):
signupDespachopasadata.despacho.frequencyasubscriptionService.subscribe(antes hardcoded'annual')
Shared types (packages/shared/src/types/despacho.ts):
DespachoSignupPlanextendido a 5 valoresDespachoSignupRequest.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 PlanPricedeapps/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
getPlansyupdatePlanPricecontrollers - Eliminadas routes
GET /subscriptions/plansyPUT /subscriptions/plans/:id - Reemplazo: el catálogo despacho vive en
despacho_plan_pricescon endpoints/api/planes/despacho
Frontend (apps/web/lib/api/subscription.ts y lib/hooks/use-subscription.ts):
- Eliminados helpers
getPlans()yupdatePlanPrice() - Eliminados hooks
usePlans()yuseUpdatePlanPrice() - 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 usaplan: 'custom'. Solo el nombre del archivo es legacy. Cosmético.- Defaults
.envcon sufijohorux360-...—JWT_SECRET,FIEL_ENCRYPTION_KEYcosméticos del archivo local..env.exampleya 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/...conNombre="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 conmin=hoypero el backend acepta cualquier fecha si se manda en body directo. Conviene Zod check si se quiere endurecer
Mejoras de UX
getOrgApiKeyresiliente al 404 de Facturapi (en lugar de throw, devolver mensaje accionable que linkee a /configuracion para recrear)- Banner deadline para tenants Custom con
currentPeriodEnden/configuracion/suscripciono/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. Hacergrep saldoPendientey 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
calcularEgresosPorRegimenagetResumenIsr Frequencytype ensubscription.service.tsy los literales'monthly' | 'annual'que recibegetPrecioDespachoDbson 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.tsxUserInvite) — arrastrados de antes, no relacionados a esta sesión
Pendientes Metabase
- Setear
METABASE_PASSWORDyMETABASE_PG_PASSWORDen.envdel 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.registerDatabasepara 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=developmentcon log warning, son ~3 líneas enmercadopago.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_orgsrow de Horux 360 contribuyente borrado + insertado con nuevo org_id69f23a5a242e0af47a41fa0d(en BDhorux_despacho_mo3ni6u8_b9vgg)- Migración tenant
041_facturapi_orgs_api_key_enc.sqlaplicada 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 ensat-sync.job.ts - Buckets de días restantes: 7 → 3 → 1 → 0
- Idempotencia vía nuevas columnas
subscriptions.lastReminderDay+lastReminderSentAt(migración20260501170000_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 usasendSubscriptionExpiring
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.tsdirecto): 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/suscripcionredirige a/planes-despachopara 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. hasActiveSubnuevo flag basado ensubscription.status(no entenant.plan). ReemplazahasPaidPlanpara gating de cancel + label de botón. Custom + trial cuentan como sub activa.- Upgrade flow encadenado en
handleContratar:Si hay diferencia de precio, abre MP para cobro inmediato. Si es downgrade, programa cambio sin cobro.subscribeMe → si "ya existe" → upgradeMe (prorrateado, MP) → si rechaza (downgrade) → changeMyPlan (programado fin período) - Botón "Pagar mi período actual — $X": nueva sección con
<CreditCard>icon que disparageneratePaymentLink→ MP one-off. Visible cuandohasActiveSub && 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:
-
apps/web/components/subscription-banner.tsx:33-37— admin global ya no se exenta del banner. Restaurar:if (isGlobalAdmin || viewingTenantId) return null; -
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.
apps/api/.env— variables solo dev:MP_USE_SANDBOX=true→ cambiar afalseo quitarMP_TEST_PAYER_EMAIL=test_user_8835312808309856761@testuser.com→ vaciarMP_ACCESS_TOKEN_SANDBOX=TEST-...→ puede quedarse, no se usa conMP_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)