# 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](#1-setup-env--bug-database_url-post-template) 2. [Sección "CFDIs Relacionados" en facturación I/E](#2-sección-cfdis-relacionados-en-facturación-ie) 3. [Bug crítico cfdis-ppd: HAVING sin GROUP BY](#3-bug-crítico-cfdis-ppd-having-sin-group-by) 4. [Leak multi-RFC en 3 endpoints (searchRfcs, cfdis-ppd, cfdis-relacionables)](#4-leak-multi-rfc-en-3-endpoints) 5. [Auto-carga de listas al escribir RFC manualmente](#5-auto-carga-de-listas-al-escribir-rfc-manualmente) 6. [Complemento de pago: input "Fecha de pago"](#6-complemento-de-pago-input-fecha-de-pago) 7. [Descarga PDF/XML post-emisión + por fila en lista CFDI](#7-descarga-pdfxml-post-emisión--por-fila-en-lista-cfdi) 8. [Columnas + estilo CFDI: Uso CFDI, centrado, Conceptos](#8-columnas--estilo-cfdi-uso-cfdi-centrado-conceptos) 9. [Export CFDI: traer todas las páginas con cap 10k](#9-export-cfdi-traer-todas-las-páginas-con-cap-10k) 10. [Saldo Pendiente: usar `saldoPendienteMxn` (campo backfilleado)](#10-saldo-pendiente-usar-saldopendientemxn-campo-backfilleado) 11. [Deducciones ISR: bucket Nómina (tipo N emitidas)](#11-deducciones-isr-bucket-nómina-tipo-n-emitidas) 12. [Pestaña "Conceptos" en /cfdi](#12-pestaña-conceptos-en-cfdi) 13. [Pestaña Conceptos: filtros header + sort por importe](#13-pestaña-conceptos-filtros-header--sort-por-importe) 14. [Form Custom: invertir trial/custom + Primera fecha de pago](#14-form-custom-invertir-trialcustom--primera-fecha-de-pago) 15. [Cleanup organización Facturapi de Carlos (Live multi-RFC)](#15-cleanup-organización-facturapi-de-carlos-live-multi-rfc) 16. [Trial timbres = 0 (alineación seed/BD)](#16-trial-timbres--0-alineación-seedbd) 17. [Integración Metabase (porteada de producción)](#17-integración-metabase-porteada-de-producción) 18. [Página registro: actualizar planes + toggle mensual/anual](#18-página-registro-actualizar-planes--toggle-mensualanual) 19. [Cleanup final del legacy Horux 360](#19-cleanup-final-del-legacy-horux-360) 20. [Pendientes derivados](#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): ```sql 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`: ```ts 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 `` 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 - `` del thead: `text-left → text-center` - ``: 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`) ```sql 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 `` 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_PASSWORD` → `getSessionToken()` 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` ```diff - 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` ```diff - 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` ```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` ```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 `` (#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 `` 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"). ```ts 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: ```ts 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: ```ts 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. 3. `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) ```