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

1090 lines
48 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<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`)
```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 `<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_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 `<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").
```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)
```