# 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)
```
