1090 lines
48 KiB
Markdown
1090 lines
48 KiB
Markdown
# 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)
|
||
```
|