Update: nueva version Horux Despachos

This commit is contained in:
consultoria-as
2026-04-27 22:09:36 -06:00
commit 6b36db1403
614 changed files with 125926 additions and 0 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,799 @@
# Horux360 SaaS - Documento de Diseño
**Fecha:** 2026-01-22
**Versión:** 1.0
**Estado:** Aprobado
---
## 1. Visión General
**Horux360** es una plataforma SaaS de análisis financiero y gestión fiscal para empresas mexicanas, con enfoque en:
- **Experiencia visual superior** - 4 temas con diferentes layouts
- **Control fiscal completo** - IVA, ISR, CFDI en todos los planes
- **Inteligencia financiera** - Dashboards, proyecciones, alertas proactivas
- **Multi-tenant** - Schema por empresa en PostgreSQL
### Propósito
Transformar datos financieros en decisiones estratégicas mediante análisis automatizado y reportes en tiempo real para empresas mexicanas.
### Funcionalidades Core
- Dashboard financiero con KPIs (ingresos, egresos, utilidad, ROI)
- Gestión de CFDI (facturas electrónicas del SAT)
- Control de IVA (trasladado vs acreditable, balance mensual)
- Control de ISR
- Conciliación bancaria automatizada
- Reportes y alertas en tiempo real
- Calendario fiscal con recordatorios
- Proyecciones financieras (forecasting)
---
## 2. Stack Tecnológico
| Capa | Tecnología |
|------|------------|
| **Frontend** | Next.js 14 + TypeScript + Tailwind CSS |
| **Backend** | Node.js + Express + TypeScript |
| **Base de datos** | PostgreSQL (schema por tenant) |
| **Autenticación** | JWT personalizado |
| **ORM** | Prisma |
| **Gráficos** | Recharts |
| **Exportación** | xlsx + @react-pdf/renderer |
| **Estado** | Zustand |
| **Fetching** | TanStack Query |
| **Forms** | React Hook Form + Zod |
| **Tablas** | TanStack Table |
| **UI Base** | Radix UI |
---
## 3. Modelo de Negocio - Planes
| Plan | CFDI/mes | Usuarios | Funcionalidades |
|------|----------|----------|-----------------|
| **Starter** | 100 | 1 | Dashboard, IVA/ISR, CFDI básico |
| **Business** | 500 | 3 | + Reportes, Alertas, Calendario fiscal |
| **Professional** | 2,000 | 10 | + Conciliación, Forecasting, XML SAT |
| **Enterprise** | Ilimitado | Ilimitado | + API, Multi-empresa, Soporte prioritario |
**Nota:** Control de IVA e ISR incluido en todos los planes.
---
## 4. Arquitectura del Sistema
### 4.1 Estructura de Carpetas
```
horux360/
├── apps/
│ ├── web/ # Frontend Next.js
│ │ ├── app/
│ │ │ ├── (auth)/ # Login, registro, recuperar contraseña
│ │ │ ├── (dashboard)/ # Rutas protegidas
│ │ │ │ ├── dashboard/
│ │ │ │ ├── cfdi/
│ │ │ │ ├── impuestos/
│ │ │ │ ├── reportes/
│ │ │ │ ├── conciliacion/
│ │ │ │ ├── calendario/
│ │ │ │ ├── configuracion/
│ │ │ │ └── usuarios/
│ │ │ └── (marketing)/ # Landing, precios, contacto
│ │ ├── components/
│ │ │ ├── ui/ # Componentes base
│ │ │ ├── charts/ # Gráficos reutilizables
│ │ │ ├── layouts/ # Los 4 layouts de temas
│ │ │ └── modules/ # Componentes por módulo
│ │ ├── lib/
│ │ │ ├── api/ # Cliente API
│ │ │ ├── hooks/ # Custom hooks
│ │ │ └── utils/ # Utilidades
│ │ ├── stores/ # Estado global (Zustand)
│ │ └── themes/ # Configuración de 4 temas
│ │
│ └── api/ # Backend Express
│ ├── src/
│ │ ├── controllers/
│ │ ├── services/
│ │ ├── models/
│ │ ├── middlewares/
│ │ │ ├── auth.ts
│ │ │ └── tenant.ts # Middleware multi-tenant
│ │ ├── routes/
│ │ └── utils/
│ └── prisma/
│ └── schema.prisma
├── packages/
│ └── shared/ # Tipos y utilidades compartidas
└── docker-compose.yml
```
### 4.2 Flujo Multi-Tenant
```
Request → Auth Middleware → Tenant Middleware → Set Schema → Controller
↓ ↓
Valida JWT Extrae tenant_id
del token/header
SET search_path TO tenant_schema
```
---
## 5. Modelo de Datos
### 5.1 Schema Público (compartido)
```sql
-- Empresas/Tenants
tenants
id (UUID, PK)
nombre
rfc
plan (starter|business|professional|enterprise)
schema_name (único)
cfdi_limit
users_limit
active
created_at
expires_at
-- Usuarios globales
users
id (UUID, PK)
tenant_id (FK)
email (único)
password_hash
nombre
role (admin|contador|visor)
active
last_login
created_at
```
### 5.2 Schema por Tenant (tenant_xxx)
```sql
-- Catálogo de cuentas
cuentas
id (PK)
codigo
nombre
tipo (activo|pasivo|capital|ingreso|egreso)
padre_id (FK, self)
active
-- CFDIs/Facturas
cfdis
id (UUID, PK)
uuid_fiscal
tipo (ingreso|egreso|traslado|pago|nomina)
serie
folio
fecha_emision
fecha_timbrado
rfc_emisor
nombre_emisor
rfc_receptor
nombre_receptor
subtotal
descuento
iva
isr_retenido
iva_retenido
total
moneda
tipo_cambio
metodo_pago
forma_pago
uso_cfdi
estado (vigente|cancelado)
xml_url
pdf_url
created_at
-- Conceptos de CFDI
cfdi_conceptos
id (PK)
cfdi_id (FK)
clave_producto
descripcion
cantidad
unidad
valor_unitario
importe
descuento
objeto_impuesto
-- Control de IVA mensual
iva_mensual
id (PK)
año
mes
iva_trasladado (cobrado)
iva_acreditable (pagado)
iva_retenido
resultado
acumulado
estado (pendiente|declarado|acreditado)
fecha_declaracion
-- Control de ISR mensual
isr_mensual
id (PK)
año
mes
ingresos_acumulados
deducciones
base_gravable
isr_causado
isr_retenido
isr_a_pagar
estado
fecha_declaracion
-- Movimientos bancarios
movimientos_bancarios
id (PK)
banco
cuenta
fecha
referencia
descripcion
tipo (cargo|abono)
monto
saldo
cfdi_id (FK, nullable)
estado_conciliacion (pendiente|conciliado|error)
notas
-- Alertas
alertas
id (PK)
tipo (vencimiento|discrepancia|iva_favor|declaracion)
titulo
mensaje
prioridad (alta|media|baja)
fecha_vencimiento
leida
resuelta
created_at
-- Calendario fiscal
calendario_fiscal
id (PK)
titulo
descripcion
tipo (declaracion|pago|obligacion)
fecha_limite
recurrencia (mensual|bimestral|anual|unica)
completado
notas
```
---
## 6. Módulos y Funcionalidades
### 6.1 Dashboard Principal
| Componente | Descripción |
|------------|-------------|
| **KPIs principales** | Ingresos, Egresos, Utilidad, IVA a favor/por pagar |
| **Selector de período** | Semana, Mes, Trimestre, Año, Personalizado |
| **Gráfico ingresos vs egresos** | Barras comparativas por mes |
| **Métricas de rentabilidad** | Margen, ROI, Crecimiento % |
| **Alertas activas** | Widget con alertas pendientes |
| **Resumen fiscal** | Estado de declaraciones del mes |
### 6.2 Gestión de CFDI
| Funcionalidad | Descripción |
|---------------|-------------|
| **Lista de CFDIs** | Tabla con filtros (tipo, estado, fecha, RFC) |
| **Detalle de factura** | Vista completa con conceptos e impuestos |
| **Importar XML** | Carga manual de archivos XML |
| **Descarga SAT** | Conexión automática para descargar CFDIs (Professional+) |
| **Exportar** | Excel/PDF con filtros aplicados |
| **Búsqueda** | Por UUID, RFC, concepto, monto |
### 6.3 Control de Impuestos (IVA/ISR)
| Funcionalidad | Descripción |
|---------------|-------------|
| **Balance IVA** | Trasladado vs Acreditable, resultado mensual |
| **Histórico IVA** | Tabla mensual con acumulados |
| **Cálculo ISR** | Ingresos, deducciones, base gravable, ISR a pagar |
| **Retenciones** | IVA e ISR retenido por terceros |
| **Prellenado DIOT** | Generación de archivo para declaración |
### 6.4 Reportes
| Reporte | Descripción |
|---------|-------------|
| **Estado de resultados** | Ingresos - Egresos = Utilidad |
| **Flujo de efectivo** | Entradas y salidas por período |
| **Comparativo períodos** | Año vs año, mes vs mes |
| **Concentrado por RFC** | Totales por cliente/proveedor |
| **Proyección financiera** | Forecast basado en histórico (Professional+) |
| **Exportación** | Excel y PDF para todos los reportes |
### 6.5 Conciliación Bancaria (Professional+)
| Funcionalidad | Descripción |
|---------------|-------------|
| **Importar estados** | Carga de archivos bancarios (CSV, OFX) |
| **Match automático** | Asociar movimientos con CFDIs |
| **Pendientes** | Lista de movimientos sin conciliar |
| **Errores** | Discrepancias detectadas |
| **Métricas** | Tasa de éxito, pendientes, conciliados |
### 6.6 Calendario Fiscal (Business+)
| Funcionalidad | Descripción |
|---------------|-------------|
| **Vista calendario** | Mensual con obligaciones marcadas |
| **Alertas automáticas** | Recordatorios 7, 3 y 1 día antes |
| **Obligaciones SAT** | Precargadas según régimen fiscal |
| **Personalización** | Agregar eventos propios |
| **Marcar completado** | Tracking de cumplimiento |
### 6.7 Gestión de Usuarios (Business+)
| Funcionalidad | Descripción |
|---------------|-------------|
| **Roles** | Admin (todo), Contador (operación), Visor (solo lectura) |
| **Invitar usuarios** | Por email con rol asignado |
| **Permisos por módulo** | Configuración granular |
| **Auditoría** | Log de acciones por usuario |
---
## 7. Sistema de Temas
### 7.1 Los 4 Temas
| Tema | Paleta | Layout | Ideal para |
|------|--------|--------|------------|
| **Light** | Blancos, grises suaves, acentos azules | Sidebar fija izquierda, contenido centrado con máximo 1200px | Uso diario prolongado |
| **Vibrant** | Colores vivos (púrpura, cyan, coral) | Sidebar colapsable, cards grandes con bordes redondeados | Usuarios que prefieren color |
| **Corporate** | Azul marino, grises oscuros, dorado | Multi-panel denso, tablas compactas, sin espacios desperdiciados | Contadores, mucha data |
| **Dark** | Fondo #0a0a0a, acentos verdes/cyan | Sidebar minimalista, widgets flotantes con glassmorphism | Trabajo nocturno |
### 7.2 Estructura de Layouts
```
┌─────────────────────────────────────────────────────────────┐
│ LIGHT THEME │
├────────┬────────────────────────────────────────────────────┤
│ │ Header con breadcrumb │
│ Logo ├────────────────────────────────────────────────────┤
│ │ │
│ Nav │ Contenido centrado (max 1200px) │
│ Items │ │
│ │ Cards con sombras suaves │
│ Fixed │ │
│ 240px │ │
└────────┴────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ VIBRANT THEME │
├──┬──────────────────────────────────────────────────────────┤
│ │ Header con selector de período │
│☰ ├──────────────────────────────────────────────────────────┤
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│C │ │ KPI 1 │ │ KPI 2 │ │ KPI 3 │ │ KPI 4 │ │
│O │ │ Grande │ │ Grande │ │ Grande │ │ Grande │ │
│L │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
│L │ ┌───────────────────────────────────────────────┐ │
│A │ │ Gráfico principal │ │
│P │ └───────────────────────────────────────────────┘ │
│S │ │
│E │ Bordes redondeados 16px, gradientes en headers │
└──┴──────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ CORPORATE THEME │
├────────┬───────────────────────┬────────────────────────────┤
│ │ Panel Superior │ Panel Lateral │
│ Nav │ KPIs compactos │ Alertas │
│ ├───────────────────────┤ Lista densa │
│ Menú │ Tabla principal │ │
│ jerár- │ Muchas filas │────────────────────────── │
│ quico │ Columnas compactas │ Acciones rápidas │
│ │ Sin paginación │ Botones pequeños │
│ │ Scroll virtual │ │
└────────┴───────────────────────┴────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ DARK THEME ○ ○ ○ │
├──┬──────────────────────────────────────────────────────────┤
│ │ │
│▪ │ ╭─────────╮ ╭─────────╮ ╭─────────╮ │
│▪ │ │ Widget │ │ Widget │ │ Widget │ Glassmorphism│
│▪ │ │ flotante│ │ flotante│ │ flotante│ con blur │
│▪ │ ╰─────────╯ ╰─────────╯ ╰─────────╯ │
│ │ │
│ │ ╭────────────────────────────╮ │
│ │ │ Gráfico con glow │ │
│ │ │ Líneas cyan/verde │ │
│ │ ╰────────────────────────────╯ │
│64│ │
│px│ Fondo casi negro, acentos neón, bordes sutiles │
└──┴──────────────────────────────────────────────────────────┘
```
### 7.3 Implementación Técnica
```typescript
// themes/index.ts
export const themes = {
light: {
name: 'Light',
layout: 'sidebar-fixed',
colors: {
background: '#ffffff',
surface: '#f8fafc',
primary: '#3b82f6',
text: '#1e293b',
border: '#e2e8f0',
success: '#22c55e',
danger: '#ef4444',
},
radius: '8px',
sidebar: { width: '240px', collapsible: false },
},
vibrant: {
name: 'Vibrant',
layout: 'sidebar-collapsible',
colors: {
background: '#faf5ff',
surface: '#ffffff',
primary: '#8b5cf6',
secondary: '#06b6d4',
accent: '#f97316',
text: '#1e1b4b',
},
radius: '16px',
sidebar: { width: '280px', collapsible: true },
},
corporate: {
name: 'Corporate',
layout: 'multi-panel',
colors: {
background: '#f1f5f9',
surface: '#ffffff',
primary: '#1e3a5f',
accent: '#d4a853',
text: '#0f172a',
},
radius: '4px',
density: 'compact',
sidebar: { width: '200px', collapsible: false },
},
dark: {
name: 'Dark',
layout: 'minimal-floating',
colors: {
background: '#0a0a0a',
surface: 'rgba(255,255,255,0.05)',
primary: '#22d3ee',
accent: '#4ade80',
text: '#f1f5f9',
glow: '0 0 20px rgba(34,211,238,0.3)',
},
radius: '12px',
blur: '10px',
sidebar: { width: '64px', collapsible: false, iconsOnly: true },
},
}
```
---
## 8. API y Endpoints
### 8.1 Autenticación
| Método | Endpoint | Descripción |
|--------|----------|-------------|
| POST | `/api/auth/register` | Registrar empresa + usuario admin |
| POST | `/api/auth/login` | Iniciar sesión, retorna JWT |
| POST | `/api/auth/refresh` | Renovar token |
| POST | `/api/auth/forgot-password` | Solicitar reset de contraseña |
| POST | `/api/auth/reset-password` | Cambiar contraseña con token |
| GET | `/api/auth/me` | Obtener usuario actual |
### 8.2 Tenants/Empresas
| Método | Endpoint | Descripción |
|--------|----------|-------------|
| GET | `/api/tenant` | Obtener datos de la empresa actual |
| PUT | `/api/tenant` | Actualizar datos de la empresa |
| GET | `/api/tenant/usage` | Uso actual (CFDIs, usuarios) vs límites |
| PUT | `/api/tenant/plan` | Cambiar plan |
### 8.3 Usuarios
| Método | Endpoint | Descripción |
|--------|----------|-------------|
| GET | `/api/users` | Listar usuarios del tenant |
| POST | `/api/users` | Invitar nuevo usuario |
| GET | `/api/users/:id` | Obtener usuario |
| PUT | `/api/users/:id` | Actualizar usuario/rol |
| DELETE | `/api/users/:id` | Desactivar usuario |
### 8.4 CFDIs
| Método | Endpoint | Descripción |
|--------|----------|-------------|
| GET | `/api/cfdi` | Listar CFDIs con filtros y paginación |
| GET | `/api/cfdi/:id` | Detalle de CFDI con conceptos |
| POST | `/api/cfdi/import` | Importar XML (uno o varios) |
| POST | `/api/cfdi/sync-sat` | Descargar del SAT (Professional+) |
| GET | `/api/cfdi/:id/xml` | Descargar XML original |
| GET | `/api/cfdi/:id/pdf` | Generar/descargar PDF |
| GET | `/api/cfdi/export` | Exportar listado a Excel |
**Query params:**
```
?tipo=ingreso|egreso
&estado=vigente|cancelado
&fecha_inicio=2024-01-01
&fecha_fin=2024-12-31
&rfc=XAXX010101000
&search=concepto
&page=1
&limit=50
&sort=fecha_emision
&order=desc
```
### 8.5 Impuestos
| Método | Endpoint | Descripción |
|--------|----------|-------------|
| GET | `/api/impuestos/iva` | Resumen IVA del período |
| GET | `/api/impuestos/iva/mensual` | Desglose mensual IVA |
| GET | `/api/impuestos/isr` | Resumen ISR del período |
| GET | `/api/impuestos/isr/mensual` | Desglose mensual ISR |
| GET | `/api/impuestos/retenciones` | Retenciones recibidas |
| POST | `/api/impuestos/iva/:id/declarar` | Marcar mes como declarado |
| GET | `/api/impuestos/diot` | Generar archivo DIOT |
### 8.6 Dashboard
| Método | Endpoint | Descripción |
|--------|----------|-------------|
| GET | `/api/dashboard/kpis` | KPIs principales del período |
| GET | `/api/dashboard/ingresos-egresos` | Datos para gráfico comparativo |
| GET | `/api/dashboard/rentabilidad` | Métricas de rentabilidad |
| GET | `/api/dashboard/resumen-fiscal` | Estado de obligaciones |
### 8.7 Reportes
| Método | Endpoint | Descripción |
|--------|----------|-------------|
| GET | `/api/reportes/estado-resultados` | Estado de resultados |
| GET | `/api/reportes/flujo-efectivo` | Flujo de efectivo |
| GET | `/api/reportes/comparativo` | Comparativa entre períodos |
| GET | `/api/reportes/por-rfc` | Concentrado por cliente/proveedor |
| GET | `/api/reportes/proyeccion` | Forecast financiero (Professional+) |
| GET | `/api/reportes/:tipo/export` | Exportar a Excel o PDF |
### 8.8 Conciliación Bancaria
| Método | Endpoint | Descripción |
|--------|----------|-------------|
| GET | `/api/conciliacion` | Estadísticas de conciliación |
| GET | `/api/conciliacion/movimientos` | Listar movimientos bancarios |
| POST | `/api/conciliacion/import` | Importar estado de cuenta |
| POST | `/api/conciliacion/match` | Conciliar movimiento con CFDI |
| PUT | `/api/conciliacion/:id` | Actualizar movimiento |
| GET | `/api/conciliacion/pendientes` | Movimientos sin conciliar |
### 8.9 Calendario Fiscal
| Método | Endpoint | Descripción |
|--------|----------|-------------|
| GET | `/api/calendario` | Obligaciones del mes/año |
| POST | `/api/calendario` | Crear obligación personalizada |
| PUT | `/api/calendario/:id` | Actualizar/marcar completado |
| DELETE | `/api/calendario/:id` | Eliminar obligación propia |
| GET | `/api/calendario/proximas` | Próximas obligaciones (alertas) |
### 8.10 Alertas
| Método | Endpoint | Descripción |
|--------|----------|-------------|
| GET | `/api/alertas` | Listar alertas activas |
| PUT | `/api/alertas/:id/leer` | Marcar como leída |
| PUT | `/api/alertas/:id/resolver` | Marcar como resuelta |
| GET | `/api/alertas/count` | Contador para badge |
### 8.11 Configuración
| Método | Endpoint | Descripción |
|--------|----------|-------------|
| GET | `/api/config/theme` | Obtener tema actual |
| PUT | `/api/config/theme` | Cambiar tema |
| GET | `/api/config/preferencias` | Preferencias del usuario |
| PUT | `/api/config/preferencias` | Actualizar preferencias |
---
## 9. Flujo de Pantallas
### 9.1 Mapa de Navegación
```
┌─────────────────────────────────────────────────────────────────┐
│ PÚBLICO │
├─────────────────────────────────────────────────────────────────┤
│ Landing (/) │
│ ├── Características (/features) │
│ ├── Precios (/pricing) │
│ ├── Contacto (/contact) │
│ ├── Login (/login) │
│ └── Registro (/register) │
└─────────────────────────────────────────────────────────────────┘
[Autenticación]
┌─────────────────────────────────────────────────────────────────┐
│ ÁREA PRIVADA │
├────────────┬────────────────────────────────────────────────────┤
│ SIDEBAR │ /app/dashboard - KPIs, gráficos, alertas │
│ │ /app/cfdi - Lista, detalle, importar │
│ Dashboard │ /app/impuestos - IVA, ISR, Retenciones, DIOT │
│ CFDI │ /app/reportes - Todos los reportes │
│ Impuestos │ /app/conciliacion - Movimientos bancarios │
│ Reportes │ /app/calendario - Obligaciones fiscales │
│ Concil. │ /app/usuarios - Gestión de equipo │
│ Calendario│ /app/config - Preferencias y tema │
│ Config │ │
└────────────┴────────────────────────────────────────────────────┘
```
---
## 10. Roadmap de Implementación
### Fase 1: Fundación (Core)
- [ ] Setup del proyecto (monorepo, Next.js, Express)
- [ ] Base de datos PostgreSQL + Prisma
- [ ] Sistema multi-tenant (schemas)
- [ ] Autenticación JWT
- [ ] Sistema de temas (4 temas + layouts)
- [ ] Componentes UI base
- [ ] Landing page + registro + login
### Fase 2: Módulos Core
- [ ] Dashboard con KPIs
- [ ] Gestión de CFDI (CRUD + importación XML)
- [ ] Control de IVA (cálculos automáticos)
- [ ] Control de ISR
- [ ] Gráficos y visualizaciones
### Fase 3: Funcionalidades Avanzadas
- [ ] Reportes (estado resultados, flujo efectivo)
- [ ] Exportación Excel/PDF
- [ ] Sistema de alertas
- [ ] Calendario fiscal
- [ ] Gestión de usuarios y roles
### Fase 4: Premium Features
- [ ] Conciliación bancaria
- [ ] Proyecciones/Forecasting
- [ ] Descarga automática XML del SAT
- [ ] Generación DIOT
- [ ] API pública (Enterprise)
### Fase 5: Producción
- [ ] Testing completo
- [ ] Optimización de rendimiento
- [ ] Documentación
- [ ] Despliegue y CI/CD
- [ ] Monitoreo y logging
---
## 11. Datos Demo (Seed)
```typescript
// Empresa demo con datos precargados
{
tenant: {
nombre: "Empresa Demo SA de CV",
rfc: "EDE123456AB1",
plan: "professional"
},
users: [
{ email: "admin@demo.com", role: "admin", password: "demo123" },
{ email: "contador@demo.com", role: "contador", password: "demo123" },
{ email: "visor@demo.com", role: "visor", password: "demo123" }
],
cfdis: 150, // Facturas de ejemplo (6 meses)
movimientos: 200, // Movimientos bancarios
alertas: 5 // Alertas activas
}
```
---
## 12. Dependencias Principales
| Categoría | Librería | Propósito |
|-----------|----------|-----------|
| **UI Components** | Radix UI | Componentes accesibles |
| **Estilos** | Tailwind CSS + clsx | Utilidades CSS |
| **Gráficos** | Recharts | Visualizaciones |
| **Tablas** | TanStack Table | Tablas avanzadas |
| **Forms** | React Hook Form + Zod | Formularios + validación |
| **Estado** | Zustand | Estado global |
| **Fetching** | TanStack Query | Cache y fetching |
| **Fechas** | date-fns | Manipulación fechas |
| **Excel** | xlsx (SheetJS) | Exportación Excel |
| **PDF** | @react-pdf/renderer | Generación PDF |
| **XML Parser** | fast-xml-parser | Parsear CFDI |
| **ORM** | Prisma | Base de datos |
| **Auth** | jsonwebtoken + bcrypt | JWT + hashing |
---
## 13. Análisis de Competencia
### Principales Competidores en México
| Plataforma | Enfoque | Precio/mes | Fortaleza |
|------------|---------|------------|-----------|
| **Siigo Aspel** | ERP completo | $291-$354 | Líder tradicional |
| **BIND ERP** | ERP PyMEs | $890-$3,590 | Todo integrado |
| **CONTPAQi** | Contabilidad | $890+ | Estándar en despachos |
| **Alegra** | Facturación | $499+ | Fácil de usar |
| **Gigstack** | Automatización fiscal | Variable | Integración Stripe |
| **Facturama** | Facturación CFDI | Variable | API robusta |
### Oportunidades de Diferenciación
1. **UX/Diseño** - La mayoría tienen interfaces anticuadas
2. **Dashboards de análisis** - Pocos ofrecen visualización moderna
3. **Alertas proactivas** - Calendarios fiscales inteligentes son raros
4. **Forecasting** - Proyecciones financieras casi inexistentes
5. **Temas personalizables** - Nadie ofrece esto
6. **Precios accesibles** - Competidores premium son caros
---
## 14. Consideraciones de Seguridad
- Autenticación JWT con refresh tokens
- Passwords hasheados con bcrypt (cost factor 12)
- Aislamiento de datos por schema de PostgreSQL
- Rate limiting en endpoints de auth
- Validación de inputs con Zod
- Sanitización de XML importados
- HTTPS obligatorio
- Headers de seguridad (Helmet.js)
---
## 15. Servicios Eliminados
Los siguientes servicios del backend anterior **NO** se incluirán:
-**Clerk** (autenticación) - Reemplazado por JWT propio
-**OpenPay** (pagos) - Se implementará después si es necesario
-**Syntage** - En proceso de retiro
---
*Documento generado el 2026-01-22*

View File

@@ -0,0 +1,327 @@
# Diseño: Sincronización con SAT
## Resumen
Implementar sincronización automática de CFDIs desde el portal del SAT usando la e.firma (FIEL).
## Requisitos
| Aspecto | Decisión |
|---------|----------|
| Autenticación | FIEL (archivos .cer y .key + contraseña) |
| Tipos de CFDI | Emitidos y recibidos |
| Ejecución | Programada diaria a las 3:00 AM |
| Almacenamiento credenciales | Encriptadas en PostgreSQL (AES-256-GCM) |
| Primera extracción | Últimos 10 años |
| Extracciones posteriores | Solo mes actual |
| Duplicados | Actualizar con versión del SAT |
---
## Arquitectura General
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────┐
│ Frontend │────▶│ API Horux │────▶│ SAT WSDL │
│ (Configuración)│ │ (sat.service) │ │ Web Service│
└─────────────────┘ └──────────────────┘ └─────────────┘
┌──────────────┐
│ PostgreSQL │
│ - fiel_credentials
│ - sat_sync_jobs
│ - cfdis
└──────────────┘
```
---
## Integración con Web Services del SAT
### Flujo de Descarga
```
1. AUTENTICACIÓN (Token válido por 5 minutos)
- Crear timestamp (Created + Expires)
- Generar digest SHA-1 del timestamp
- Firmar digest con llave privada (.key) usando RSA-SHA1
- Enviar SOAP con certificado (.cer) + firma
- Recibir token SAML para usar en siguientes llamadas
2. SOLICITUD DE DESCARGA
Parámetros:
- RfcSolicitante: RFC de la empresa
- FechaInicio: YYYY-MM-DDTHH:MM:SS
- FechaFin: YYYY-MM-DDTHH:MM:SS
- TipoSolicitud: "CFDI" o "Metadata"
- TipoComprobante: "I"(ingreso), "E"(egreso), "T", "N", "P"
- RfcEmisor / RfcReceptor: Filtrar por contraparte (opcional)
Respuesta:
- IdSolicitud: UUID para tracking
- CodEstatus: 5000 = Aceptada
3. VERIFICACIÓN (Polling cada 30-60 segundos)
Estados posibles:
- 1: Aceptada (en proceso)
- 2: En proceso
- 3: Terminada (lista para descargar)
- 4: Error
- 5: Rechazada
- 6: Vencida
Respuesta exitosa incluye:
- IdsPaquetes: Array de IDs de paquetes ZIP a descargar
- NumeroCFDIs: Total de comprobantes encontrados
4. DESCARGA DE PAQUETES
- Por cada IdPaquete, solicitar descarga
- Respuesta: Paquete en Base64 (archivo ZIP)
- Decodificar y extraer XMLs
- Cada ZIP puede contener hasta 200,000 CFDIs
5. PROCESAMIENTO DE XMLs
Por cada XML:
- Parsear con @nodecfdi/cfdi-core
- Extraer: UUID, emisor, receptor, total, impuestos, fecha
- Buscar en BD por UUID
- Si existe → UPDATE
- Si no existe → INSERT
- Guardar XML original
```
### Endpoints del SAT
| Servicio | URL |
|----------|-----|
| Autenticación | `https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/Autenticacion/Autenticacion.svc` |
| Solicitud | `https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/SolicitaDescargaService.svc` |
| Verificación | `https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/VerificaSolicitudDescargaService.svc` |
| Descarga | `https://cfdidescargamasiva.clouda.sat.gob.mx/DescargaMasivaService.svc` |
### Estructura SOAP para Autenticación
```xml
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
<s:Header>
<o:Security xmlns:o="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
<u:Timestamp u:Id="_0">
<u:Created>2026-01-25T00:00:00.000Z</u:Created>
<u:Expires>2026-01-25T00:05:00.000Z</u:Expires>
</u:Timestamp>
<o:BinarySecurityToken
ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3"
EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary"
u:Id="uuid-cert">
<!-- Certificado .cer en Base64 -->
</o:BinarySecurityToken>
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
<SignedInfo>
<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
<Reference URI="#_0">
<Transforms>
<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
</Transforms>
<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
<DigestValue><!-- SHA1 del Timestamp --></DigestValue>
</Reference>
</SignedInfo>
<SignatureValue><!-- Firma RSA-SHA1 --></SignatureValue>
<KeyInfo>
<o:SecurityTokenReference>
<o:Reference URI="#uuid-cert"/>
</o:SecurityTokenReference>
</KeyInfo>
</Signature>
</o:Security>
</s:Header>
<s:Body/>
</s:Envelope>
```
### Dependencias Node.js
```json
{
"@nodecfdi/credentials": "^2.0",
"@nodecfdi/cfdi-core": "^0.5",
"node-forge": "^1.3",
"fast-xml-parser": "^4.0",
"adm-zip": "^0.5",
"node-cron": "^3.0"
}
```
### Códigos de Error del SAT
| Código | Significado | Acción |
|--------|-------------|--------|
| 5000 | Solicitud recibida | Continuar con verificación |
| 5002 | Se agotó límite de solicitudes | Esperar 24 horas |
| 5004 | No se encontraron CFDIs | Registrar, no es error |
| 5005 | Solicitud duplicada | Usar IdSolicitud existente |
| 404 | Paquete no encontrado | Reintentar en 1 minuto |
| 500 | Error interno SAT | Reintentar con backoff |
### Estrategia de Extracción Inicial (10 años)
- Dividir en solicitudes mensuales (~121 solicitudes)
- Procesar 3-4 meses por día para no saturar
- Guardar progreso en sat_sync_jobs
- Si falla, continuar desde último mes exitoso
### Tiempos Estimados
| Operación | Tiempo |
|-----------|--------|
| Autenticación | 1-2 segundos |
| Solicitud aceptada | 1-2 segundos |
| Verificación (paquete listo) | 1-30 minutos |
| Descarga 10,000 CFDIs | 30-60 segundos |
| Procesamiento 10,000 XMLs | 2-5 minutos |
---
## Modelo de Datos
### Nuevas Tablas (schema public)
```sql
-- Credenciales FIEL por tenant (encriptadas)
CREATE TABLE fiel_credentials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
rfc VARCHAR(13) NOT NULL,
cer_data BYTEA NOT NULL,
key_data BYTEA NOT NULL,
key_password_encrypted BYTEA NOT NULL,
serial_number VARCHAR(50),
valid_from TIMESTAMP NOT NULL,
valid_until TIMESTAMP NOT NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(tenant_id)
);
-- Jobs de sincronización
CREATE TABLE sat_sync_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
type VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
date_from DATE NOT NULL,
date_to DATE NOT NULL,
cfdi_type VARCHAR(10),
sat_request_id VARCHAR(50),
sat_package_ids TEXT[],
cfdis_found INTEGER DEFAULT 0,
cfdis_downloaded INTEGER DEFAULT 0,
cfdis_inserted INTEGER DEFAULT 0,
cfdis_updated INTEGER DEFAULT 0,
progress_percent INTEGER DEFAULT 0,
error_message TEXT,
started_at TIMESTAMP,
completed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
retry_count INTEGER DEFAULT 0,
next_retry_at TIMESTAMP
);
CREATE INDEX idx_sat_sync_jobs_tenant ON sat_sync_jobs(tenant_id);
CREATE INDEX idx_sat_sync_jobs_status ON sat_sync_jobs(status);
```
### Modificaciones a tabla cfdis
```sql
ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS source VARCHAR(20) DEFAULT 'manual';
ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS sat_sync_job_id UUID;
ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS xml_original TEXT;
ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS last_sat_sync TIMESTAMP;
```
---
## Estructura de Archivos
```
apps/api/src/
├── services/
│ ├── sat/
│ │ ├── sat.service.ts
│ │ ├── sat-auth.service.ts
│ │ ├── sat-download.service.ts
│ │ ├── sat-parser.service.ts
│ │ └── sat-crypto.service.ts
│ └── fiel.service.ts
├── controllers/
│ ├── sat.controller.ts
│ └── fiel.controller.ts
├── routes/
│ ├── sat.routes.ts
│ └── fiel.routes.ts
└── jobs/
└── sat-sync.job.ts
```
---
## API Endpoints
```
POST /api/fiel/upload # Subir .cer, .key y contraseña
GET /api/fiel/status # Estado de FIEL configurada
DELETE /api/fiel # Eliminar credenciales
POST /api/sat/sync # Sincronización manual
GET /api/sat/sync/status # Estado actual
GET /api/sat/sync/history # Historial
GET /api/sat/sync/:id # Detalle de job
POST /api/sat/sync/:id/retry # Reintentar job fallido
```
---
## Interfaz de Usuario
### Sección en Configuración
- Estado de FIEL (configurada/no configurada, vigencia)
- Botones: Actualizar FIEL, Eliminar
- Sincronización automática (frecuencia, última sync, total CFDIs)
- Botón: Sincronizar Ahora
- Historial de sincronizaciones (tabla)
### Modal de Carga FIEL
- Input para archivo .cer
- Input para archivo .key
- Input para contraseña
- Mensaje de seguridad
- Botones: Cancelar, Guardar y Validar
---
## Notificaciones
| Evento | Mensaje |
|--------|---------|
| Sync completada | "Se descargaron X CFDIs del SAT" |
| Sync fallida | "Error al sincronizar: [mensaje]" |
| FIEL por vencer (30 días) | "Tu e.firma vence el DD/MMM/YYYY" |
| FIEL vencida | "Tu e.firma ha vencido" |
---
## Seguridad
- Solo rol `admin` puede gestionar FIEL
- Credenciales nunca se devuelven en API
- Logs de auditoría para accesos
- Rate limiting en endpoints de sincronización
- Encriptación AES-256-GCM para credenciales

View File

@@ -0,0 +1,228 @@
# Plan de Implementación: Sincronización SAT
## Fase 1: Base de Datos y Modelos
### 1.1 Migraciones Prisma
- [ ] Agregar modelo `FielCredential` en schema.prisma
- [ ] Agregar modelo `SatSyncJob` en schema.prisma
- [ ] Agregar campos a modelo `Cfdi`: source, sat_sync_job_id, xml_original, last_sat_sync
- [ ] Ejecutar migración
### 1.2 Tipos TypeScript
- [ ] Crear `packages/shared/src/types/sat.ts` con interfaces
- [ ] Exportar tipos en index.ts
## Fase 2: Servicios de Criptografía y FIEL
### 2.1 Servicio de Criptografía
- [ ] Crear `apps/api/src/services/sat/sat-crypto.service.ts`
- [ ] Implementar encrypt() con AES-256-GCM
- [ ] Implementar decrypt()
- [ ] Tests unitarios
### 2.2 Servicio de FIEL
- [ ] Crear `apps/api/src/services/fiel.service.ts`
- [ ] uploadFiel() - validar y guardar credenciales encriptadas
- [ ] getFielStatus() - obtener estado sin exponer datos sensibles
- [ ] deleteFiel() - eliminar credenciales
- [ ] validateFiel() - verificar que .cer y .key coincidan
- [ ] isExpired() - verificar vigencia
### 2.3 Dependencias
- [ ] Instalar @nodecfdi/credentials
- [ ] Instalar node-forge
## Fase 3: Servicios de Comunicación SAT
### 3.1 Servicio de Autenticación SAT
- [ ] Crear `apps/api/src/services/sat/sat-auth.service.ts`
- [ ] buildAuthSoapEnvelope() - construir XML de autenticación
- [ ] signWithFiel() - firmar con llave privada
- [ ] getToken() - obtener token SAML del SAT
- [ ] Manejo de errores y reintentos
### 3.2 Servicio de Descarga SAT
- [ ] Crear `apps/api/src/services/sat/sat-download.service.ts`
- [ ] requestDownload() - solicitar descarga de CFDIs
- [ ] verifyRequest() - verificar estado de solicitud
- [ ] downloadPackage() - descargar paquete ZIP
- [ ] Polling con backoff exponencial
### 3.3 Dependencias
- [ ] Instalar fast-xml-parser
- [ ] Instalar adm-zip
## Fase 4: Procesamiento de CFDIs
### 4.1 Servicio de Parser
- [ ] Crear `apps/api/src/services/sat/sat-parser.service.ts`
- [ ] extractZip() - extraer XMLs del ZIP
- [ ] parseXml() - parsear XML a objeto
- [ ] mapToDbModel() - mapear a modelo de BD
### 4.2 Dependencias
- [ ] Instalar @nodecfdi/cfdi-core
## Fase 5: Orquestador Principal
### 5.1 Servicio Principal SAT
- [ ] Crear `apps/api/src/services/sat/sat.service.ts`
- [ ] startSync() - iniciar sincronización
- [ ] processInitialSync() - extracción de 10 años
- [ ] processDailySync() - extracción mensual
- [ ] saveProgress() - guardar progreso en sat_sync_jobs
- [ ] handleError() - manejo de errores y reintentos
## Fase 6: Job Programado
### 6.1 Cron Job
- [ ] Crear `apps/api/src/jobs/sat-sync.job.ts`
- [ ] Configurar ejecución a las 3:00 AM
- [ ] Obtener tenants con FIEL activa
- [ ] Ejecutar sync para cada tenant
- [ ] Logging y monitoreo
### 6.2 Dependencias
- [ ] Instalar node-cron
## Fase 7: API Endpoints
### 7.1 Controlador FIEL
- [ ] Crear `apps/api/src/controllers/fiel.controller.ts`
- [ ] POST /upload - subir credenciales
- [ ] GET /status - obtener estado
- [ ] DELETE / - eliminar credenciales
### 7.2 Controlador SAT
- [ ] Crear `apps/api/src/controllers/sat.controller.ts`
- [ ] POST /sync - iniciar sincronización manual
- [ ] GET /sync/status - estado actual
- [ ] GET /sync/history - historial
- [ ] GET /sync/:id - detalle de job
- [ ] POST /sync/:id/retry - reintentar
### 7.3 Rutas
- [ ] Crear `apps/api/src/routes/fiel.routes.ts`
- [ ] Crear `apps/api/src/routes/sat.routes.ts`
- [ ] Registrar en app.ts
## Fase 8: Frontend
### 8.1 Componentes
- [ ] Crear `apps/web/components/sat/FielUploadModal.tsx`
- [ ] Crear `apps/web/components/sat/SyncStatus.tsx`
- [ ] Crear `apps/web/components/sat/SyncHistory.tsx`
### 8.2 Página de Configuración
- [ ] Crear `apps/web/app/(dashboard)/configuracion/sat/page.tsx`
- [ ] Integrar componentes
- [ ] Conectar con API
### 8.3 API Client
- [ ] Agregar métodos en `apps/web/lib/api.ts`
- [ ] uploadFiel()
- [ ] getFielStatus()
- [ ] deleteFiel()
- [ ] startSync()
- [ ] getSyncStatus()
- [ ] getSyncHistory()
## Fase 9: Testing y Validación
### 9.1 Tests
- [ ] Tests unitarios para servicios de criptografía
- [ ] Tests unitarios para parser de XML
- [ ] Tests de integración para flujo completo
- [ ] Test con FIEL de prueba del SAT
### 9.2 Validación
- [ ] Probar carga de FIEL
- [ ] Probar sincronización manual
- [ ] Probar job programado
- [ ] Verificar CFDIs descargados
## Orden de Implementación
```
Fase 1 (BD)
Fase 2 (Crypto + FIEL)
Fase 3 (Auth + Download SAT)
Fase 4 (Parser)
Fase 5 (Orquestador)
Fase 6 (Cron Job)
Fase 7 (API)
Fase 8 (Frontend)
Fase 9 (Testing)
```
## Archivos a Crear/Modificar
### Nuevos Archivos (16)
```
apps/api/src/services/sat/sat-crypto.service.ts
apps/api/src/services/sat/sat-auth.service.ts
apps/api/src/services/sat/sat-download.service.ts
apps/api/src/services/sat/sat-parser.service.ts
apps/api/src/services/sat/sat.service.ts
apps/api/src/services/fiel.service.ts
apps/api/src/controllers/fiel.controller.ts
apps/api/src/controllers/sat.controller.ts
apps/api/src/routes/fiel.routes.ts
apps/api/src/routes/sat.routes.ts
apps/api/src/jobs/sat-sync.job.ts
packages/shared/src/types/sat.ts
apps/web/components/sat/FielUploadModal.tsx
apps/web/components/sat/SyncStatus.tsx
apps/web/components/sat/SyncHistory.tsx
apps/web/app/(dashboard)/configuracion/sat/page.tsx
```
### Archivos a Modificar (5)
```
apps/api/prisma/schema.prisma
apps/api/src/app.ts
apps/api/src/index.ts
packages/shared/src/index.ts
apps/web/lib/api.ts
```
## Dependencias a Instalar
```bash
# En apps/api
pnpm add @nodecfdi/credentials @nodecfdi/cfdi-core node-forge fast-xml-parser adm-zip node-cron
# Tipos
pnpm add -D @types/node-forge @types/node-cron
```
## Estimación por Fase
| Fase | Descripción | Complejidad |
|------|-------------|-------------|
| 1 | Base de datos | Baja |
| 2 | Crypto + FIEL | Media |
| 3 | Comunicación SAT | Alta |
| 4 | Parser | Media |
| 5 | Orquestador | Alta |
| 6 | Cron Job | Baja |
| 7 | API | Media |
| 8 | Frontend | Media |
| 9 | Testing | Media |

View File

@@ -0,0 +1,126 @@
# Diseño: Visor de CFDI
**Fecha:** 2026-02-17
**Estado:** Aprobado
## Resumen
Agregar funcionalidad para visualizar facturas CFDI en formato PDF-like, recreando la representación visual desde el XML almacenado. Incluye descarga de PDF y XML.
## Decisiones de Diseño
- **Tipo de vista:** PDF-like (representación visual similar a factura impresa)
- **Acceso:** Botón "Ver" (icono ojo) en cada fila de la tabla
- **Acciones:** Descargar PDF, Descargar XML
- **Enfoque técnico:** Componente React + html2pdf.js para generación de PDF en cliente
## Arquitectura de Componentes
```
CfdiPage (existente)
├── Tabla de CFDIs
│ └── Botón "Ver" (Eye icon) → abre modal
└── CfdiViewerModal (NUEVO)
├── Header: Título + Botones (PDF, XML, Cerrar)
└── CfdiInvoice (NUEVO)
├── Encabezado (Emisor + Receptor)
├── Datos del comprobante
├── Tabla de conceptos (parseados del XML)
├── Totales e impuestos
└── Timbre fiscal (UUID, fechas)
```
## Componentes Nuevos
| Componente | Ubicación | Responsabilidad |
|------------|-----------|-----------------|
| `CfdiViewerModal` | `components/cfdi/cfdi-viewer-modal.tsx` | Modal con visor y botones de acción |
| `CfdiInvoice` | `components/cfdi/cfdi-invoice.tsx` | Renderiza la factura estilo PDF |
## Diseño Visual
```
┌──────────────────────────────────────────────────────────────┐
│ ┌─────────────────┐ FACTURA │
│ │ [LOGO] │ Serie: A Folio: 001 │
│ │ placeholder │ Fecha: 15/Ene/2025 │
│ └─────────────────┘ │
├──────────────────────────────────────────────────────────────┤
│ EMISOR │ RECEPTOR │
│ Empresa Emisora SA de CV │ Cliente SA de CV │
│ RFC: XAXX010101000 │ RFC: XAXX010101001 │
│ │ Uso CFDI: G03 │
├──────────────────────────────────────────────────────────────┤
│ DATOS DEL COMPROBANTE │
│ Tipo: Ingreso Método: PUE Forma: 03 - Transferencia │
│ Moneda: MXN Tipo Cambio: 1.00 │
├──────────────────────────────────────────────────────────────┤
│ CONCEPTOS │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Descripción │ Cant │ P. Unit │ Importe │ │
│ ├──────────────────────────────────────────────────────┤ │
│ │ Servicio consultoría │ 1 │ 10,000 │ 10,000.00 │ │
│ └──────────────────────────────────────────────────────┘ │
├──────────────────────────────────────────────────────────────┤
│ Subtotal: $10,000.00 │
│ IVA 16%: $1,600.00 │
│ TOTAL: $11,600.00 │
├──────────────────────────────────────────────────────────────┤
│ TIMBRE FISCAL DIGITAL │
│ UUID: 12345678-1234-1234-1234-123456789012 │
│ Fecha Timbrado: 2025-01-15T12:30:45 │
└──────────────────────────────────────────────────────────────┘
```
## Flujo de Datos
1. Usuario hace clic en "Ver" (Eye icon)
2. Se abre CfdiViewerModal con el CFDI seleccionado
3. Si existe xmlOriginal:
- Parsear XML para extraer conceptos
- Mostrar factura completa
4. Si no existe XML:
- Mostrar factura con datos de BD (sin conceptos)
5. Acciones disponibles:
- Descargar PDF (html2pdf genera PDF)
- Descargar XML (si existe)
## Cambios en Backend
### Nuevo Endpoint
```
GET /api/cfdi/:id/xml
```
Retorna el XML original del CFDI.
### Modificar Endpoint Existente
```
GET /api/cfdi/:id
```
Agregar campo `xmlOriginal` a la respuesta.
## Dependencias
```json
{
"html2pdf.js": "^0.10.1"
}
```
## Archivos a Crear/Modificar
### Nuevos
- `apps/web/components/cfdi/cfdi-viewer-modal.tsx`
- `apps/web/components/cfdi/cfdi-invoice.tsx`
- `apps/api/src/controllers/cfdi.controller.ts` (nuevo método getXml)
### Modificar
- `apps/web/app/(dashboard)/cfdi/page.tsx` (agregar botón Ver y modal)
- `apps/api/src/routes/cfdi.routes.ts` (agregar ruta /xml)
- `apps/api/src/services/cfdi.service.ts` (agregar método getXmlById)
- `packages/shared/src/types/cfdi.ts` (agregar xmlOriginal a Cfdi)

View File

@@ -0,0 +1,816 @@
# CFDI Viewer Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add PDF-like invoice visualization for CFDIs with PDF and XML download capabilities.
**Architecture:** React modal component with invoice renderer. Backend returns XML via new endpoint. html2pdf.js generates PDF client-side from rendered HTML.
**Tech Stack:** React, TypeScript, html2pdf.js, Tailwind CSS
---
## Task 1: Install html2pdf.js Dependency
**Files:**
- Modify: `apps/web/package.json`
**Step 1: Install the dependency**
Run:
```bash
cd /root/Horux/apps/web && pnpm add html2pdf.js
```
**Step 2: Verify installation**
Run:
```bash
grep html2pdf apps/web/package.json
```
Expected: `"html2pdf.js": "^0.10.x"`
**Step 3: Commit**
```bash
git add apps/web/package.json apps/web/pnpm-lock.yaml
git commit -m "chore: add html2pdf.js for PDF generation"
```
---
## Task 2: Add xmlOriginal to Cfdi Type
**Files:**
- Modify: `packages/shared/src/types/cfdi.ts:4-31`
**Step 1: Add xmlOriginal field to Cfdi interface**
In `packages/shared/src/types/cfdi.ts`, add after line 29 (`pdfUrl: string | null;`):
```typescript
xmlOriginal: string | null;
```
**Step 2: Verify types compile**
Run:
```bash
cd /root/Horux && pnpm build --filter=@horux/shared
```
Expected: Build succeeds
**Step 3: Commit**
```bash
git add packages/shared/src/types/cfdi.ts
git commit -m "feat(types): add xmlOriginal field to Cfdi interface"
```
---
## Task 3: Update Backend Service to Return XML
**Files:**
- Modify: `apps/api/src/services/cfdi.service.ts:77-95`
**Step 1: Update getCfdiById to include xml_original**
Replace the `getCfdiById` function:
```typescript
export async function getCfdiById(schema: string, id: string): Promise<Cfdi | null> {
const result = await prisma.$queryRawUnsafe<Cfdi[]>(`
SELECT
id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado",
rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
subtotal, descuento, iva, isr_retenido as "isrRetenido",
iva_retenido as "ivaRetenido", total, moneda,
tipo_cambio as "tipoCambio", metodo_pago as "metodoPago",
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
xml_original as "xmlOriginal",
created_at as "createdAt"
FROM "${schema}".cfdis
WHERE id = $1
`, id);
return result[0] || null;
}
```
**Step 2: Add getXmlById function**
Add after `getCfdiById`:
```typescript
export async function getXmlById(schema: string, id: string): Promise<string | null> {
const result = await prisma.$queryRawUnsafe<[{ xml_original: string | null }]>(`
SELECT xml_original FROM "${schema}".cfdis WHERE id = $1
`, id);
return result[0]?.xml_original || null;
}
```
**Step 3: Verify API compiles**
Run:
```bash
cd /root/Horux/apps/api && pnpm build
```
Expected: Build succeeds
**Step 4: Commit**
```bash
git add apps/api/src/services/cfdi.service.ts
git commit -m "feat(api): add xmlOriginal to getCfdiById and add getXmlById"
```
---
## Task 4: Add XML Download Endpoint
**Files:**
- Modify: `apps/api/src/controllers/cfdi.controller.ts`
- Modify: `apps/api/src/routes/cfdi.routes.ts`
**Step 1: Add getXml controller function**
Add to `apps/api/src/controllers/cfdi.controller.ts` after `getCfdiById`:
```typescript
export async function getXml(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
}
const xml = await cfdiService.getXmlById(req.tenantSchema, String(req.params.id));
if (!xml) {
return next(new AppError(404, 'XML no encontrado para este CFDI'));
}
res.set('Content-Type', 'application/xml');
res.set('Content-Disposition', `attachment; filename="cfdi-${req.params.id}.xml"`);
res.send(xml);
} catch (error) {
next(error);
}
}
```
**Step 2: Add route for XML download**
In `apps/api/src/routes/cfdi.routes.ts`, add after line 13 (`router.get('/:id', ...)`):
```typescript
router.get('/:id/xml', cfdiController.getXml);
```
**Step 3: Verify API compiles**
Run:
```bash
cd /root/Horux/apps/api && pnpm build
```
Expected: Build succeeds
**Step 4: Commit**
```bash
git add apps/api/src/controllers/cfdi.controller.ts apps/api/src/routes/cfdi.routes.ts
git commit -m "feat(api): add GET /cfdi/:id/xml endpoint"
```
---
## Task 5: Add API Client Function for XML Download
**Files:**
- Modify: `apps/web/lib/api/cfdi.ts`
**Step 1: Add getCfdiXml function**
Add at the end of `apps/web/lib/api/cfdi.ts`:
```typescript
export async function getCfdiXml(id: string): Promise<string> {
const response = await apiClient.get<string>(`/cfdi/${id}/xml`, {
responseType: 'text'
});
return response.data;
}
```
**Step 2: Commit**
```bash
git add apps/web/lib/api/cfdi.ts
git commit -m "feat(web): add getCfdiXml API function"
```
---
## Task 6: Create CfdiInvoice Component
**Files:**
- Create: `apps/web/components/cfdi/cfdi-invoice.tsx`
**Step 1: Create the component**
Create `apps/web/components/cfdi/cfdi-invoice.tsx`:
```typescript
'use client';
import { forwardRef } from 'react';
import type { Cfdi } from '@horux/shared';
interface CfdiConcepto {
descripcion: string;
cantidad: number;
valorUnitario: number;
importe: number;
claveUnidad?: string;
claveProdServ?: string;
}
interface CfdiInvoiceProps {
cfdi: Cfdi;
conceptos?: CfdiConcepto[];
}
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
}).format(value);
const formatDate = (dateString: string) =>
new Date(dateString).toLocaleDateString('es-MX', {
day: '2-digit',
month: 'long',
year: 'numeric',
});
const tipoLabels: Record<string, string> = {
ingreso: 'Ingreso',
egreso: 'Egreso',
traslado: 'Traslado',
pago: 'Pago',
nomina: 'Nomina',
};
const formaPagoLabels: Record<string, string> = {
'01': 'Efectivo',
'02': 'Cheque nominativo',
'03': 'Transferencia electrónica',
'04': 'Tarjeta de crédito',
'28': 'Tarjeta de débito',
'99': 'Por definir',
};
const metodoPagoLabels: Record<string, string> = {
PUE: 'Pago en una sola exhibición',
PPD: 'Pago en parcialidades o diferido',
};
export const CfdiInvoice = forwardRef<HTMLDivElement, CfdiInvoiceProps>(
({ cfdi, conceptos }, ref) => {
return (
<div
ref={ref}
className="bg-white text-black p-8 max-w-[800px] mx-auto text-sm"
style={{ fontFamily: 'Arial, sans-serif' }}
>
{/* Header */}
<div className="flex justify-between items-start border-b-2 border-gray-800 pb-4 mb-4">
<div className="w-32 h-20 bg-gray-200 flex items-center justify-center text-gray-500 text-xs">
[LOGO]
</div>
<div className="text-right">
<h1 className="text-2xl font-bold text-gray-800">FACTURA</h1>
<p className="text-gray-600">
{cfdi.serie && `Serie: ${cfdi.serie} `}
{cfdi.folio && `Folio: ${cfdi.folio}`}
</p>
<p className="text-gray-600">Fecha: {formatDate(cfdi.fechaEmision)}</p>
<span
className={`inline-block px-2 py-1 text-xs font-semibold rounded mt-1 ${
cfdi.estado === 'vigente'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{cfdi.estado === 'vigente' ? 'VIGENTE' : 'CANCELADO'}
</span>
</div>
</div>
{/* Emisor / Receptor */}
<div className="grid grid-cols-2 gap-6 mb-6">
<div className="border border-gray-300 p-4 rounded">
<h3 className="font-bold text-gray-700 border-b border-gray-200 pb-1 mb-2">
EMISOR
</h3>
<p className="font-semibold">{cfdi.nombreEmisor}</p>
<p className="text-gray-600">RFC: {cfdi.rfcEmisor}</p>
</div>
<div className="border border-gray-300 p-4 rounded">
<h3 className="font-bold text-gray-700 border-b border-gray-200 pb-1 mb-2">
RECEPTOR
</h3>
<p className="font-semibold">{cfdi.nombreReceptor}</p>
<p className="text-gray-600">RFC: {cfdi.rfcReceptor}</p>
{cfdi.usoCfdi && (
<p className="text-gray-600">Uso CFDI: {cfdi.usoCfdi}</p>
)}
</div>
</div>
{/* Datos del Comprobante */}
<div className="border border-gray-300 p-4 rounded mb-6">
<h3 className="font-bold text-gray-700 border-b border-gray-200 pb-1 mb-2">
DATOS DEL COMPROBANTE
</h3>
<div className="grid grid-cols-4 gap-4 text-sm">
<div>
<span className="text-gray-500">Tipo:</span>
<p className="font-medium">{tipoLabels[cfdi.tipo] || cfdi.tipo}</p>
</div>
<div>
<span className="text-gray-500">Método de Pago:</span>
<p className="font-medium">
{cfdi.metodoPago ? metodoPagoLabels[cfdi.metodoPago] || cfdi.metodoPago : '-'}
</p>
</div>
<div>
<span className="text-gray-500">Forma de Pago:</span>
<p className="font-medium">
{cfdi.formaPago ? formaPagoLabels[cfdi.formaPago] || cfdi.formaPago : '-'}
</p>
</div>
<div>
<span className="text-gray-500">Moneda:</span>
<p className="font-medium">
{cfdi.moneda}
{cfdi.tipoCambio !== 1 && ` (TC: ${cfdi.tipoCambio})`}
</p>
</div>
</div>
</div>
{/* Conceptos */}
{conceptos && conceptos.length > 0 && (
<div className="mb-6">
<h3 className="font-bold text-gray-700 border-b border-gray-200 pb-1 mb-2">
CONCEPTOS
</h3>
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-100">
<th className="text-left p-2 border">Descripción</th>
<th className="text-center p-2 border w-20">Cant.</th>
<th className="text-right p-2 border w-28">P. Unit.</th>
<th className="text-right p-2 border w-28">Importe</th>
</tr>
</thead>
<tbody>
{conceptos.map((concepto, idx) => (
<tr key={idx} className="border-b">
<td className="p-2 border">{concepto.descripcion}</td>
<td className="text-center p-2 border">{concepto.cantidad}</td>
<td className="text-right p-2 border">
{formatCurrency(concepto.valorUnitario)}
</td>
<td className="text-right p-2 border">
{formatCurrency(concepto.importe)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Totales */}
<div className="flex justify-end mb-6">
<div className="w-64">
<div className="flex justify-between py-1 border-b">
<span className="text-gray-600">Subtotal:</span>
<span>{formatCurrency(cfdi.subtotal)}</span>
</div>
{cfdi.descuento > 0 && (
<div className="flex justify-between py-1 border-b">
<span className="text-gray-600">Descuento:</span>
<span className="text-red-600">-{formatCurrency(cfdi.descuento)}</span>
</div>
)}
{cfdi.iva > 0 && (
<div className="flex justify-between py-1 border-b">
<span className="text-gray-600">IVA (16%):</span>
<span>{formatCurrency(cfdi.iva)}</span>
</div>
)}
{cfdi.ivaRetenido > 0 && (
<div className="flex justify-between py-1 border-b">
<span className="text-gray-600">IVA Retenido:</span>
<span className="text-red-600">-{formatCurrency(cfdi.ivaRetenido)}</span>
</div>
)}
{cfdi.isrRetenido > 0 && (
<div className="flex justify-between py-1 border-b">
<span className="text-gray-600">ISR Retenido:</span>
<span className="text-red-600">-{formatCurrency(cfdi.isrRetenido)}</span>
</div>
)}
<div className="flex justify-between py-2 font-bold text-lg border-t-2 border-gray-800 mt-1">
<span>TOTAL:</span>
<span>{formatCurrency(cfdi.total)}</span>
</div>
</div>
</div>
{/* Timbre Fiscal */}
<div className="border-t-2 border-gray-800 pt-4">
<h3 className="font-bold text-gray-700 mb-2">TIMBRE FISCAL DIGITAL</h3>
<div className="grid grid-cols-2 gap-4 text-xs">
<div>
<p className="text-gray-500">UUID:</p>
<p className="font-mono break-all">{cfdi.uuidFiscal}</p>
</div>
<div>
<p className="text-gray-500">Fecha de Timbrado:</p>
<p>{cfdi.fechaTimbrado}</p>
</div>
</div>
</div>
</div>
);
}
);
CfdiInvoice.displayName = 'CfdiInvoice';
```
**Step 2: Commit**
```bash
git add apps/web/components/cfdi/cfdi-invoice.tsx
git commit -m "feat(web): add CfdiInvoice component for PDF-like rendering"
```
---
## Task 7: Create CfdiViewerModal Component
**Files:**
- Create: `apps/web/components/cfdi/cfdi-viewer-modal.tsx`
**Step 1: Create the modal component**
Create `apps/web/components/cfdi/cfdi-viewer-modal.tsx`:
```typescript
'use client';
import { useRef, useState, useEffect } from 'react';
import type { Cfdi } from '@horux/shared';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { CfdiInvoice } from './cfdi-invoice';
import { getCfdiXml } from '@/lib/api/cfdi';
import { Download, FileText, X, Loader2 } from 'lucide-react';
interface CfdiConcepto {
descripcion: string;
cantidad: number;
valorUnitario: number;
importe: number;
}
interface CfdiViewerModalProps {
cfdi: Cfdi | null;
open: boolean;
onClose: () => void;
}
function parseConceptosFromXml(xmlString: string): CfdiConcepto[] {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(xmlString, 'text/xml');
const conceptos: CfdiConcepto[] = [];
// Find all Concepto elements
const elements = doc.getElementsByTagName('*');
for (let i = 0; i < elements.length; i++) {
if (elements[i].localName === 'Concepto') {
const el = elements[i];
conceptos.push({
descripcion: el.getAttribute('Descripcion') || '',
cantidad: parseFloat(el.getAttribute('Cantidad') || '1'),
valorUnitario: parseFloat(el.getAttribute('ValorUnitario') || '0'),
importe: parseFloat(el.getAttribute('Importe') || '0'),
});
}
}
return conceptos;
} catch {
return [];
}
}
export function CfdiViewerModal({ cfdi, open, onClose }: CfdiViewerModalProps) {
const invoiceRef = useRef<HTMLDivElement>(null);
const [conceptos, setConceptos] = useState<CfdiConcepto[]>([]);
const [downloading, setDownloading] = useState<'pdf' | 'xml' | null>(null);
const [xmlContent, setXmlContent] = useState<string | null>(null);
useEffect(() => {
if (cfdi?.xmlOriginal) {
setXmlContent(cfdi.xmlOriginal);
setConceptos(parseConceptosFromXml(cfdi.xmlOriginal));
} else {
setXmlContent(null);
setConceptos([]);
}
}, [cfdi]);
const handleDownloadPdf = async () => {
if (!invoiceRef.current || !cfdi) return;
setDownloading('pdf');
try {
const html2pdf = (await import('html2pdf.js')).default;
const opt = {
margin: 10,
filename: `factura-${cfdi.uuidFiscal.substring(0, 8)}.pdf`,
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2, useCORS: true },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
};
await html2pdf().set(opt).from(invoiceRef.current).save();
} catch (error) {
console.error('Error generating PDF:', error);
alert('Error al generar el PDF');
} finally {
setDownloading(null);
}
};
const handleDownloadXml = async () => {
if (!cfdi) return;
setDownloading('xml');
try {
let xml = xmlContent;
if (!xml) {
xml = await getCfdiXml(cfdi.id);
}
if (!xml) {
alert('No hay XML disponible para este CFDI');
return;
}
const blob = new Blob([xml], { type: 'application/xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `cfdi-${cfdi.uuidFiscal.substring(0, 8)}.xml`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Error downloading XML:', error);
alert('Error al descargar el XML');
} finally {
setDownloading(null);
}
};
if (!cfdi) return null;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center justify-between">
<DialogTitle>Vista de Factura</DialogTitle>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleDownloadPdf}
disabled={downloading !== null}
>
{downloading === 'pdf' ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Download className="h-4 w-4 mr-1" />
)}
PDF
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDownloadXml}
disabled={downloading !== null || !xmlContent}
title={!xmlContent ? 'XML no disponible' : 'Descargar XML'}
>
{downloading === 'xml' ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<FileText className="h-4 w-4 mr-1" />
)}
XML
</Button>
</div>
</div>
</DialogHeader>
<div className="border rounded-lg overflow-hidden bg-gray-50 p-4">
<CfdiInvoice ref={invoiceRef} cfdi={cfdi} conceptos={conceptos} />
</div>
</DialogContent>
</Dialog>
);
}
```
**Step 2: Commit**
```bash
git add apps/web/components/cfdi/cfdi-viewer-modal.tsx
git commit -m "feat(web): add CfdiViewerModal with PDF and XML download"
```
---
## Task 8: Integrate Viewer into CFDI Page
**Files:**
- Modify: `apps/web/app/(dashboard)/cfdi/page.tsx`
**Step 1: Add imports at top of file**
Add after the existing imports (around line 14):
```typescript
import { Eye } from 'lucide-react';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
import { getCfdiById } from '@/lib/api/cfdi';
```
**Step 2: Add state for viewer modal**
Inside `CfdiPage` component, after line 255 (`const deleteCfdi = useDeleteCfdi();`), add:
```typescript
const [viewingCfdi, setViewingCfdi] = useState<Cfdi | null>(null);
const [loadingCfdi, setLoadingCfdi] = useState<string | null>(null);
const handleViewCfdi = async (id: string) => {
setLoadingCfdi(id);
try {
const cfdi = await getCfdiById(id);
setViewingCfdi(cfdi);
} catch (error) {
console.error('Error loading CFDI:', error);
alert('Error al cargar el CFDI');
} finally {
setLoadingCfdi(null);
}
};
```
**Step 3: Add import for Cfdi type**
Update the import from `@horux/shared` at line 12 to include `Cfdi`:
```typescript
import type { CfdiFilters, TipoCfdi, Cfdi } from '@horux/shared';
```
**Step 4: Add View button in table**
In the table header (around line 1070), add a new column header before the delete column:
```typescript
<th className="pb-3 font-medium"></th>
```
In the table body (inside the map, around line 1125), add before the delete button:
```typescript
<td className="py-3">
<Button
variant="ghost"
size="icon"
onClick={() => handleViewCfdi(cfdi.id)}
disabled={loadingCfdi === cfdi.id}
title="Ver factura"
>
{loadingCfdi === cfdi.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</td>
```
**Step 5: Add modal component**
At the end of the return statement, just before the closing `</>`, add:
```typescript
<CfdiViewerModal
cfdi={viewingCfdi}
open={viewingCfdi !== null}
onClose={() => setViewingCfdi(null)}
/>
```
**Step 6: Verify build**
Run:
```bash
cd /root/Horux/apps/web && pnpm build
```
Expected: Build succeeds
**Step 7: Commit**
```bash
git add apps/web/app/\(dashboard\)/cfdi/page.tsx
git commit -m "feat(web): integrate CFDI viewer modal into CFDI page"
```
---
## Task 9: Build and Test
**Step 1: Build all packages**
Run:
```bash
cd /root/Horux && pnpm build
```
Expected: All packages build successfully
**Step 2: Restart services**
Run:
```bash
systemctl restart horux-api horux-web
```
**Step 3: Manual verification**
1. Open browser to CFDI page
2. Click Eye icon on any CFDI row
3. Verify modal opens with invoice preview
4. Click PDF button - verify PDF downloads
5. Click XML button (if XML exists) - verify XML downloads
**Step 4: Final commit with all changes**
```bash
git add -A
git status
# If any uncommitted changes:
git commit -m "feat: complete CFDI viewer implementation"
```
---
## Summary of Changes
| File | Change |
|------|--------|
| `apps/web/package.json` | Added html2pdf.js dependency |
| `packages/shared/src/types/cfdi.ts` | Added xmlOriginal field |
| `apps/api/src/services/cfdi.service.ts` | Updated getCfdiById, added getXmlById |
| `apps/api/src/controllers/cfdi.controller.ts` | Added getXml controller |
| `apps/api/src/routes/cfdi.routes.ts` | Added GET /:id/xml route |
| `apps/web/lib/api/cfdi.ts` | Added getCfdiXml function |
| `apps/web/components/cfdi/cfdi-invoice.tsx` | NEW - Invoice renderer |
| `apps/web/components/cfdi/cfdi-viewer-modal.tsx` | NEW - Modal wrapper |
| `apps/web/app/(dashboard)/cfdi/page.tsx` | Integrated viewer |

View File

@@ -0,0 +1,174 @@
# Auto-facturación de pagos MercadoPago con Facturapi
## Resumen
Cuando MercadoPago confirma un pago aprobado (webhook), Horux 360 emite automáticamente un CFDI al **público en general** vía Facturapi, sin intervención manual. Aplica a pagos recurrentes (preapproval) y pagos de prorateo de upgrade (preference).
## Motivación
Antes: los pagos se registraban en tabla `payments` pero la emisión de factura era 100% manual. Riesgo de olvido → pagos cobrados sin CFDI, cliente se queja, SAT audita.
Ahora: emisión automática como default. El admin solo toca la primera factura de cada cliente nuevo (para capturar/verificar datos fiscales); el resto va solo a público en general.
## Reglas
| Condición | ¿Se factura? |
|-----------|--------------|
| Payment `status === 'approved'` + `amount > 0` + **hay al menos 1 payment aprobado previo** del mismo tenant | **Sí** — auto-emit a público en general |
| Payment es el **primer** aprobado de este tenant | **No** — admin emite manual para capturar datos fiscales del cliente |
| `amount === 0` (trial) | No |
| `status !== 'approved'` (pending, rejected) | No |
| `Payment.facturapiInvoiceId` ya existe | No (idempotente — webhook reintentó) |
| Tenant emisor (Horux 360) sin `facturapiOrgId` configurado | No — log warning, admin configura en `/configuracion` |
| Tenant emisor sin CP en datos fiscales | No — log warning |
## Emisor: Horux 360 (RESICO PM)
- **RFC:** `HTS240708LJA`
- **Régimen fiscal:** 626 (RESICO PM)
- **Retenciones:** ninguna (RESICO PM no retiene IVA ni ISR en sus facturas emitidas)
La organización Facturapi del tenant Horux 360 es la que emite — `facturapiService.createInvoice(horux360TenantId, payload)` enruta al API key correcto.
## Receptor: Público en general
| Campo | Valor |
|-------|-------|
| `taxId` | `XAXX010101000` (RFC genérico) |
| `legalName` | `PUBLICO EN GENERAL` |
| `taxSystem` | `616` (Sin obligaciones fiscales) |
| `zip` | CP del emisor (patrón SAT) |
**CFDI `use`:** `S01` — Sin efectos fiscales.
**Nota:** esto NO es "factura global consolidada" (que requiere `periodicidad`/`mes`/`año` y acumula varios tickets). Es una factura simple de tipo I a público en general por cada pago.
## Concepto
| Campo | Valor |
|-------|-------|
| `description` | `Suscripción ${plan} ${mensual\|anual} a Horux 360` |
| `productKey` | `81112502` — Servicios de alojamiento de aplicaciones |
| `unitKey` | `E48` — Unidad de servicio |
| `unitName` | `Servicio` |
| `quantity` | `1` |
| `price` | `Payment.amount` (ya incluye IVA) |
| `taxIncluded` | `true` — Facturapi desagrega subtotal + IVA 16% |
| `taxes` | `[{ type: 'IVA', rate: 0.16, factor: 'Tasa' }]` — sin retenciones |
## Forma de pago
Mapeo del `paymentMethodId` que manda MercadoPago al código SAT:
| MP paymentMethodId | SAT forma pago |
|--------------------|-----------------|
| `credit_card` | `04` — Tarjeta de crédito |
| `debit_card` | `28` — Tarjeta de débito |
| `account_money` | `03` — Transferencia |
| `bank_transfer` | `03` |
| *otro / desconocido* | `03` (default conservador) |
**`paymentMethod`:** `PUE` (pago en una sola exhibición — MP ya cobró cuando el webhook dispara).
## Flujo
```
1. Usuario paga en MercadoPago
2. MP POST /webhooks/mercadopago { type: 'payment', data: { id } }
3. webhook.controller detecta tipo
↓ ↓
proration:* tenantId (UUID)
→ recordPayment → recordPayment
→ applyApprovedUpgrade → subscription.status = 'authorized'
→ emitInvoiceIfApplicable(payment.id) → emitInvoiceIfApplicable(payment.id)
4. invoicing.service.emitInvoiceIfApplicable:
- Gate: ya facturado? approved? amount>0? primer pago?
- Gate: emisor Horux 360 bien configurado?
- Build payload (público general + concepto)
- facturapiService.createInvoice(horux360TenantId, payload)
- UPDATE Payment.facturapiInvoiceId
```
## Fail-soft: el webhook nunca muere por facturación
Si Facturapi está caído o devuelve error:
- `emitInvoiceIfApplicable` catchea todo, logea `[Invoicing] Error emitiendo factura...`
- `Payment.facturapiInvoiceId` queda `null`
- Webhook retorna 200 a MP → no hay reintento → no se duplica Payment
El admin ve `Payment.facturapiInvoiceId IS NULL AND status = 'approved'` y puede re-emitir manualmente. Mejora futura: cron de reintento.
## Schema
Campo nuevo en `Payment` (BD central):
```prisma
facturapiInvoiceId String? @map("facturapi_invoice_id")
```
Aplicar con: `pnpm prisma db push` (idempotente) o `prisma migrate deploy` en prod.
## Archivos
### Nuevos
- `apps/api/src/services/payment/invoicing.service.ts` — lógica completa del auto-emit
### Modificados
- `apps/api/prisma/schema.prisma``Payment.facturapiInvoiceId`
- `apps/api/src/controllers/webhook.controller.ts` — llama `emitInvoiceIfApplicable(paymentRecord.id)` en ambos caminos (proration + recurring) después de `recordPayment` si `status === 'approved'`
## Constantes ajustables
Viven como `const` al inicio de `invoicing.service.ts`:
| Constante | Valor actual | Cuándo cambiar |
|-----------|--------------|----------------|
| `CONCEPT_PRODUCT_KEY` | `81112502` | Si cambias clasificación SAT del servicio |
| `CUSTOMER_TAX_ID` | `XAXX010101000` | Nunca (RFC genérico público general) |
| `USE_CFDI` | `S01` | Nunca para público en general |
| `IVA_RATE` | `0.16` | Solo si cambia la ley fiscal |
| `FORMA_PAGO_POR_METHOD` | mapeo | Si MP agrega método no contemplado |
Para cambiar:
1. Edita la constante en el archivo
2. `pnpm typecheck` (verifica que nada más se rompa)
3. Restart server
## Pruebas (requiere `MP_ACCESS_TOKEN` + Facturapi configurado)
### Caso 1: Primer pago (manual)
1. Usuario nuevo subscribe plan Business mensual → crea preapproval
2. MP cobra primer pago → webhook → `recordPayment``emitInvoiceIfApplicable`
3. En log: `[Invoicing] Payment X es el PRIMER pago aprobado del tenant Y, skip (factura manual)`
4. `Payment.facturapiInvoiceId = null`
5. Admin emite manual desde Facturapi dashboard
### Caso 2: Segundo pago en adelante (auto)
1. MP cobra recurrente del mismo tenant → webhook
2. En log: `[Invoicing] Emitiendo factura para Payment X (tenant Y, $480)`
3. `Payment.facturapiInvoiceId = fac_xxxxx`
4. Ver factura en Facturapi dashboard: PUBLICO EN GENERAL, concepto "Suscripción Business mensual a Horux 360"
### Caso 3: Upgrade con proration
1. Usuario cambia de Business → Business+IA mid-period → MP Preference por diff prorateado
2. Usuario paga → webhook con `external_reference: proration:...`
3. Si NO es primer pago aprobado del tenant: auto-emit con descripción del nuevo plan
4. Si ES primer pago (nuevo tenant que upgradea inmediatamente): skip manual
### Caso 4: Facturapi caído
1. Webhook fires
2. `createInvoice` lanza error
3. Log: `[Invoicing] Error emitiendo factura para Payment X: ...`
4. `Payment.facturapiInvoiceId = null` — admin puede re-emitir después
5. Webhook retorna 200 → MP no reintenta → no hay duplicación
## Pendientes / mejoras posibles
1. **Cron de reintento** para payments con `facturapiInvoiceId IS NULL AND status='approved'`. Hoy requiere acción manual del admin.
2. **Email confirmación al cliente** con el PDF/XML — Facturapi tiene `sendByEmail` que podría llamarse después de la emisión exitosa. Requiere capturar el email del admin del tenant.
3. **UI para admin de pagos sin factura** — vista que liste `Payment.status='approved' AND facturapiInvoiceId IS NULL` con botón "Emitir manualmente".
4. **Cancelación de factura** si el pago se reembolsa — actualmente si MP reembolsa un pago, la factura queda emitida. Necesitaría llamar `cancelInvoice` de Facturapi.
5. **Reporte fiscal mensual** — query que consolide ingresos emitidos en Facturapi del tenant Horux 360 (para declaración ISR).

View File

@@ -0,0 +1,85 @@
# CFDI: Export individual y filtros adicionales
## Resumen
Tres mejoras en la página `/cfdi`:
1. **Export a Excel por fila**: cada CFDI de la tabla tiene un botón de descarga que genera un `.xlsx` con los campos básicos de esa factura.
2. **Filtro por Tipo de Comprobante** (I/E/P/T/N) en la barra superior.
3. **Columna Tipo Comp.** en la tabla para dar correspondencia visual al filtro.
## Motivación
El export masivo existente sirve para análisis de rango, pero el contador frecuentemente necesita **una sola factura** en Excel para adjuntar a un correo, al expediente de un cliente o a una conciliación. Abrir el visor del CFDI y exportar desde ahí era fricción innecesaria.
El filtro por Tipo de Comprobante permite, por ejemplo, aislar sólo los complementos de Pago (tipo P) del mes — caso de uso habitual al cerrar el mes fiscal.
## Cambios en UI
### Barra de filtros
Antes:
```
[Buscar...] [🔍] | [Todos] [Emitidos] [Recibidos] | [Exportar] [Agregar] [Carga Masiva]
```
Después:
```
[Buscar...] [🔍] | [Todos] [Emitidos] [Recibidos] | [Tipo Comprobante ▼] | [Exportar] [Agregar] [Carga Masiva]
```
Valores del Select: `Todos los comprobantes`, `I - Ingreso`, `E - Egreso`, `P - Pago`, `T - Traslado`, `N - Nómina`.
### Tabla
Nueva columna **Tipo Comp.** entre "Tipo" y "UUID". Muestra la letra (I/E/P/T/N) con tooltip del nombre completo.
### Acción por fila
Nuevo botón `Download` junto al ojo de "Ver factura". Un clic → descarga `.xlsx` sin abrir modal, sin llamada al backend adicional.
## Estructura del Excel por fila
| Campo | Origen |
|-------|--------|
| Fecha Emisión | `cfdi.fechaEmision` formato `dd/mm/yyyy` es-MX |
| Tipo | `Emitido` / `Recibido` según `cfdi.type` |
| Tipo Comprobante | `I - Ingreso` / `E - Egreso` / etc. vía `formatTipoComprobante()` |
| Serie, Folio | Strings directos |
| RFC Emisor, Nombre Emisor | Strings directos |
| RFC Receptor, Nombre Receptor | Strings directos |
| Subtotal, IVA, Total | Números |
| Moneda | String |
| Estatus | `Vigente` / `Cancelado` normalizado desde `cfdi.status` |
| Fecha Cancelación | `dd/mm/yyyy` si el CFDI fue cancelado, vacío si vigente |
| UUID | String |
El export masivo usa exactamente la misma forma de columnas para que concatenar archivos posteriormente sea trivial.
**Nombre del archivo:** `cfdi_<serie>-<folio>.xlsx`, con fallback a los primeros 8 chars del UUID si el CFDI no tiene serie ni folio.
## Filtros ya existentes (no requirieron cambios)
Estos filtros ya funcionaban y cubren el resto del listado que pidió el usuario:
| Filtro | Dónde vive | Cómo funciona |
|--------|-----------|--------------|
| Fecha (rango) | Popover en cabecera columna "Fecha" | Inputs `fechaInicio` / `fechaFin`, se aplican juntos |
| Tipo (Emitido/Recibido) | Botones en barra superior | Filtro simple |
| RFC / Nombre Emisor | Popover en cabecera "Emisor" con autocomplete | Backend hace `rfc_emisor ILIKE OR nombre_emisor ILIKE`, acepta RFC o razón social |
| RFC / Nombre Receptor | Popover en cabecera "Receptor" con autocomplete | Igual que emisor pero contra `rfc_receptor` / `nombre_receptor` |
El backend ya exponía todos estos campos en `CfdiFilters` (ver `packages/shared/src/types/cfdi.ts`), así que los filtros antiguos sólo necesitaron UI; los de esta iteración (tipo de comprobante) tampoco requirieron cambios de backend porque `cfdi.service.ts:83` ya aplicaba `filters.tipoComprobante` al WHERE.
## Archivos modificados
| Archivo | Cambio |
|---------|--------|
| `apps/web/app/(dashboard)/cfdi/page.tsx` | `exportSingleCfdiToExcel`, helpers `TIPO_COMPROBANTE_LABELS` y `formatTipoComprobante`, `Select` de Tipo de Comprobante, columna en la tabla, columna `Fecha Cancelación` en ambos exports, rename `Estado``Estatus` |
## Decisiones de diseño
- **Sin llamada al backend para el export individual:** los datos ya están cargados en `data.data` vía `useCfdis()`. Llamar a `getCfdiById` sería latencia innecesaria.
- **`Estatus` vs `Estado`:** el SAT usa "Estatus SAT" como término oficial. El código interno mantiene `cfdi.status` pero la columna del Excel se renombró para alinear con terminología usuario/SAT.
- **Tipo de Comprobante como Select (no como botones):** 5 valores más "Todos" satura la barra si se usan botones. Un Select ocupa menos ancho y es más escaneable.
- **No agregué filtro de RFC separado:** los popovers existentes de Emisor/Receptor ya aceptan RFC (ILIKE sobre ambos campos), así que duplicarlo sería confuso.

View File

@@ -0,0 +1,163 @@
# Rename del rol `admin` → `owner` (label UI: "Dueño")
## Resumen
Rename del rol per-tenant `admin` a `owner` en todo el código, con label visible en UI como **"Dueño"**. El concepto ortogonal de **"admin global"** (tenant RFC `HTS240708LJA` con acceso transversal a todos los tenants) se preservó sin cambios de nombre.
## Motivación
Tener dos cosas llamadas "admin" generaba confusión recurrente:
1. **Admin del tenant** — rol local del tenant con permisos elevados (invitar usuarios, configurar, etc.)
2. **Admin global** — Horux 360 como dueño de la plataforma, con poder sobre todos los tenants
En UI, mensajes, código y conversaciones era ambiguo: "Solo el admin puede..." ¿cuál admin? El rename disambigua sin perder el concepto de admin global.
## Cambios
### Código (identificadores)
| Antes | Ahora |
|-------|-------|
| `Role = 'admin' \| 'cfo' \| ...` | `Role = 'owner' \| 'cfo' \| ...` |
| `ROLES.admin` | `ROLES.owner` |
| `role === 'admin'` | `role === 'owner'` |
| `authorize('admin', 'cfo')` | `authorize('owner', 'cfo')` |
| `where: { rol: { nombre: 'admin' } }` | `where: { rol: { nombre: 'owner' } }` |
| `prisma.rol.findUnique({ where: { nombre: 'admin' } })` | `{ nombre: 'owner' }` |
### UI (labels visibles)
| Antes | Ahora |
|-------|-------|
| "Administrador" | "Dueño" |
| "Solo administradores pueden..." | "Solo los dueños pueden..." |
| "Datos del Administrador" (registro) | "Datos del Dueño" |
| "Administrador del Cliente" (crear tenant) | "Dueño del Cliente" |
| "Nombre del Administrador" | "Nombre del Dueño" |
| "Email del Administrador" | "Email del Dueño" |
| "Contacta al administrador" (CSD sin timbres) | "Contacta al dueño de la cuenta" |
| "Pide al administrador que configure MP" | "Pide al dueño de la cuenta..." |
### BD (`roles` table central)
Row id=1: `nombre = 'admin'``nombre = 'owner'`. `descripcion = 'Administrador - acceso completo'``'Dueño - acceso completo'`.
### Demo user
`admin@demo.com` (email preservado) ahora tiene rol `owner` en BD. Password sigue siendo `demo123`. El Dueño Demo es el label que verás en UI.
## Qué se preservó deliberadamente
1. **"Admin global" como concepto:**
- Función `isGlobalAdmin(tenantId, role)` en `apps/api/src/utils/global-admin.ts` — sigue llamándose igual, internamente checa `role === 'owner'`
- Función `isGlobalAdminRfc(tenantRfc, role)` en `packages/shared/src/constants/roles.ts` — idem
- Constante `GLOBAL_ADMIN_RFC = 'HTS240708LJA'` — sin cambios
- Mensajes de error como "Solo el administrador global puede..." quedan intactos
2. **Email `admin@demo.com`:** es una constante de test, el rename solo afecta el rol interno. El email sobrevive como identificador estable.
3. **Variables semánticas `adminEmail`, `adminNombre`** en payloads de `createTenant`: representan "el email/nombre del admin del cliente que estás creando". No son identificadores de rol. Podrían renombrarse en un pase separado si se desea, pero no afecta funcionalidad.
4. **Script `bootstrap-horux360-admin.ts`:** nombre del script preservado — "bootstrap del admin global" es el propósito, no el rol. Internamente crea un user con rol `owner`.
## Archivos tocados
### Shared
- `packages/shared/src/types/auth.ts`
- `packages/shared/src/constants/roles.ts`
### Backend API (17 archivos)
- `prisma/seed.ts` — migración idempotente + role seed con `nombre: 'owner'` + demo user con `rolNombre: 'owner'`
- `src/utils/global-admin.ts`
- `src/services/auth.service.ts`, `tenants.service.ts`, `payment/subscription.service.ts`, `payment/mercadopago.service.ts`
- `src/controllers/bancos.controller.ts`, `calendario.controller.ts`, `cfdi.controller.ts`, `conciliacion.controller.ts`, `facturacion.controller.ts`, `impuestos.controller.ts`, `regimen.controller.ts`, `usuarios.controller.ts`
- `src/routes/documentos.routes.ts`, `facturacion.routes.ts`, `sat.routes.ts`, `subscription.routes.ts`
### Frontend Web (11 archivos)
- `app/(auth)/register/page.tsx`
- `app/(dashboard)/usuarios/page.tsx`, `admin/usuarios/page.tsx`
- `app/(dashboard)/cfdi/page.tsx`, `calendario/page.tsx`, `documentos/page.tsx`
- `app/(dashboard)/configuracion/page.tsx`, `configuracion/csd/page.tsx`
- `app/(dashboard)/clientes/page.tsx`
- `components/layouts/sidebar.tsx`, `sidebar-compact.tsx`, `sidebar-floating.tsx`, `topnav.tsx`
### Docs
- `CLAUDE.md` — tabla de roles actualizada, explicación clara de "admin global" como concepto ortogonal
- `README.md` — bullet nuevo en changelog v0.9.1
- `docs/plans/2026-04-13-subscriptions-self-serve.md` — referencias a admin pasan a owner donde corresponde
## Migración idempotente
El seed incluye este SQL al inicio de la sección de roles:
```typescript
// Migración: renombra el rol legacy 'admin' a 'owner' si sobrevive de un seed viejo.
// Idempotente (no-op si ya se renombró o nunca existió).
await prisma.$executeRawUnsafe(`UPDATE roles SET nombre = 'owner' WHERE nombre = 'admin'`);
```
Esto permite que:
- Desarrolladores con BD vieja solo corran `pnpm db:seed` una vez para converger
- Deploys a prod apliquen el rename sin necesidad de migración SQL manual
- Re-seedear no rompe la BD actualizada
## Impacto en runtime
### JWT invalidados
Los tokens JWT emitidos **antes del rename** tienen `role: 'admin'`. Después del rename, el middleware de auth usa Zod para validar el payload contra el nuevo enum `Role`, que ya no acepta `'admin'`. Resultado: cualquier sesión activa obtiene 401 en la siguiente request → se fuerza re-login.
**Mitigación:** anuncio en release notes y email a clientes si se despliega a producción. En dev, simplemente cerrar sesión y volver a entrar.
### Queries SQL directas
Si hay scripts externos al repo (reportes, jobs de ETL, etc.) que filtran por `role = 'admin'` en la tabla `users` o joins, **fallarán silenciosamente** (filtro no matchea nada). Revisar integraciones externas antes de desplegar a prod.
### API consumidores externos
Si el API tiene consumidores que envían `role: 'admin'` en payloads de creación/update de usuarios, esos requests fallarán la validación de Zod. Actualizar docs de API + comunicar a integradores.
## Verificación
```bash
# Backend
cd apps/api
pnpm typecheck # 0 errores
# BD
pnpm db:seed # idempotente — renombra legacy admin → owner, crea rol 'owner' si falta
```
Al correr seed post-rename, deberías ver en log:
```
✅ 5 roles cargados
✅ User created: admin@demo.com (owner)
```
### Tests manuales
1. Login con `admin@demo.com / demo123` → JWT nuevo trae `role: 'owner'`
2. Página `/usuarios` muestra "Dueño" como role label para el usuario demo
3. Select al invitar nuevo usuario: opción "Dueño" (antes "Administrador")
4. Mensajes de error tipo "Solo los dueños pueden invitar usuarios" cuando un contador intenta
5. **"Admin global" preservado**: ingresando como `admin@horux360.com` (si corriste `bootstrap:admin-global`) se ve la sección Clientes + Admin Usuarios + Todas las Suscripciones — comportamiento idéntico al de antes del rename
## Decisiones descartadas
### Renombrar `isGlobalAdmin` → `isGlobalOwner`
**Tentación:** consistencia léxica.
**Por qué no:** el concepto a nivel producto sigue llamándose "admin global" — así lo dice la UI, los docs, y así lo entienden los usuarios. Renombrar solo la función interna crea disonancia entre código y lenguaje del producto. 8 callsites tocados sin ganancia funcional.
### Renombrar `GLOBAL_ADMIN_RFC` → `GLOBAL_OWNER_RFC`
Mismo razonamiento. El RFC `HTS240708LJA` identifica al admin global de la plataforma; su nombre constante refleja ese concepto.
### Renombrar `bootstrap-horux360-admin.ts` → `bootstrap-horux360-owner.ts`
El script provisiona el tenant del admin global. Renombrarlo confunde el propósito (alguien pensaría que bootstrappea cualquier owner).
## Pendientes opcionales
1. **Variables `adminEmail` / `adminNombre` en tipos de API:** renombrarlas a `ownerEmail` / `ownerNombre` por consistencia total. Requiere actualizar integraciones externas. No se hizo porque no es bloqueante.
2. **Revisar integraciones externas/scripts** que asuman `role = 'admin'` — fuera del alcance de este rename (no viven en este repo).
3. **Tests automatizados de authorization:** confirmarían que el rename no rompió ningún endpoint. Actualmente solo hay verificación manual + typecheck.

View File

@@ -0,0 +1,135 @@
# Sincronización Incremental SAT para Enterprise
## Resumen
Los clientes con plan **Enterprise** reciben sincronización con el SAT 3 veces al día (11:00, 15:00 y 19:00, zona horaria `America/Mexico_City`) adicional al `daily` de las 03:00 que aplica a todos los planes. Cada corrida incremental descarga XMLs y metadata de una ventana fija de las últimas 8 horas.
## Motivación
El `daily` del cron nocturno cubre el ciclo fiscal completo pero deja al cliente Enterprise con una latencia de ~24h para ver CFDIs nuevos. El incremental de 3 corridas intradiarias reduce esa latencia a ~4h en horas de oficina, que es cuando se emite la mayoría del tráfico. Fuera de ese rango (19:00 → 03:00 del día siguiente) el `daily` se encarga.
## Decisiones
| Aspecto | Decisión | Razón |
|---------|----------|-------|
| Cron | `0 11,15,19 * * *` | 3 disparos en horas con tráfico fiscal real |
| Zona horaria | `America/Mexico_City` | Consistencia con el resto de crons del proyecto |
| Ventana por corrida | 8 horas hacia atrás | Cubre el gap máximo (03:00 → 11:00) sin dejar huecos |
| Elegibilidad | `tenant.active && tenant.plan === 'enterprise' && hasFielConfigured(tenantId)` | `tenant.plan` es la fuente de verdad usada por feature-gate y plan-limits |
| Requisito previo | Tenant debe tener `initial` completado | El incremental no debe actuar como primera descarga |
| Deduplicación | `UNIQUE(uuid)` en tabla `cfdis` | El solape entre ventanas consecutivas no genera duplicados |
| Nuevo valor de enum | `SatSyncType.incremental` | Diferenciable en historial, simplifica métricas/debugging |
## Cobertura horaria
```
03:00 ── daily (XMLs últimos 7 días + metadata año fiscal completo)
11:00 ── incremental Enterprise ventana 03:0011:00
15:00 ── incremental Enterprise ventana 07:0015:00 (solape con anterior)
19:00 ── incremental Enterprise ventana 11:0019:00 (solape con anterior)
19:00 → 03:00 del día siguiente: cubierto por el daily al arrancar
```
El solape entre disparos consecutivos es deliberado: la ventana de 8h es mayor que el gap entre corridas (4h). Esto cubre CFDIs que llegan a los servidores del SAT con retraso respecto a su fecha de emisión, sin duplicar datos en la BD.
## Arquitectura
```
┌────────────────────────────┐
│ cron "0 11,15,19 * * *" │
│ (node-cron, America/MX) │
└──────────┬─────────────────┘
┌────────────────────────────────────────┐
│ runIncrementalSyncJob() │
│ - Guard: isIncrementalRunning │
│ - getEnterpriseTenantsWithFiel() │
│ - Batch de CONCURRENT_SYNCS (3) tenants│
└──────────┬─────────────────────────────┘
┌────────────────────────────────────────┐
│ incrementalSyncTenant(tenantId) │
│ - Omite si hay sync activo │
│ - Omite si no hay initial completado │
│ - startSync(tenantId, 'incremental') │
└──────────┬─────────────────────────────┘
┌────────────────────────────────────────┐
│ processIncrementalSync(ctx, jobId) │
│ - ventana [now - 8h, now] │
│ - processDateRange × (emitidos, │
│ recibidos) │
│ - processMetadataRange × (emitidos, │
│ recibidos) │
└────────────────────────────────────────┘
```
## Archivos modificados
| Archivo | Cambio |
|---------|--------|
| `apps/api/prisma/schema.prisma` | `enum SatSyncType` ahora incluye `incremental` |
| `packages/shared/src/types/sat.ts` | `SatSyncType = 'initial' \| 'daily' \| 'incremental'` |
| `apps/api/src/services/sat/sat.service.ts` | Nueva función `processIncrementalSync`. Branches en `startSync` y `retryTimedOutJobs` |
| `apps/api/src/jobs/sat-sync.job.ts` | Constante `INCREMENTAL_CRON_SCHEDULE`, funciones `getEnterpriseTenantsWithFiel`, `incrementalSyncTenant`, `runIncrementalSyncJob`. Export `runIncrementalSyncJobManually`. Registro/desregistro en `startSatSyncJob`/`stopSatSyncJob` |
## Guardrails
- **Un incremental a la vez por proceso:** flag `isIncrementalRunning` evita reentradas si una corrida toma más de lo esperado.
- **Un sync a la vez por tenant:** `incrementalSyncTenant` revisa `getSyncStatus(tenantId)` y omite si ya hay un job corriendo (initial, daily, custom u otro incremental).
- **Primera sync debe ser `initial`:** si el tenant no tiene un `SatSyncJob` con `type: 'initial'` y `status: 'completed'`, el incremental se omite con un log y no crea job. La backfill debe correrse explícitamente (manual desde UI o el daily la detecta).
- **Concurrencia limitada:** `CONCURRENT_SYNCS = 3` — mismo límite que el daily para no saturar el SAT con solicitudes simultáneas desde la misma IP.
- **Reintentos:** si el incremental falla por timeout del SAT, el cron horario de retries (`retryTimedOutJobs`) lo reintenta hasta 3 veces con 6h de espera, igual que los otros tipos.
- **Deduplicación:** constraint `UNIQUE(uuid)` en tabla `cfdis` (BD tenant) maneja solapes entre ventanas sin lógica adicional.
## Deploy
### Migración requerida (BD central)
Agregar el valor `incremental` al enum Postgres:
```bash
cd apps/api
pnpm prisma migrate dev --name add_incremental_sat_sync_type # dev
pnpm prisma migrate deploy # prod
```
Sin esta migración, al insertar `SatSyncJob` con `type: 'incremental'` el driver fallará con error de enum value.
### Rebuild de shared
El tipo `SatSyncType` cambió en `packages/shared`:
```bash
pnpm build
```
### Restart API
```bash
pm2 restart horux-api
```
Al arrancar, el log confirma el registro del cron:
```
[SAT Cron Inc] Incremental Enterprise programado para: 0 11,15,19 * * * (America/Mexico_City)
```
## Testing manual
```typescript
// Desde un script o endpoint de admin
import { runIncrementalSyncJobManually } from './jobs/sat-sync.job';
await runIncrementalSyncJobManually();
```
O en BD, verificar que se creó un `SatSyncJob` con `type = 'incremental'` para el tenant Enterprise esperado.
## Futuro / pendientes
- **Ventana dinámica:** podría calcularse desde el último `incremental` completado del tenant en vez de fijarse en 8h. Reduciría solicitudes al SAT en solape pero agrega complejidad. No se hizo porque la dedup por UUID ya hace el solape gratuito en BD.
- **Frecuencia configurable por tenant:** si algún Enterprise pide cadencia distinta, el schedule actual es global para todo el plan. Se podría mover a una columna `syncFrequency` en `tenants`.
- **Alerta en dashboard Admin Global:** mostrar cuándo fue el último `incremental` exitoso por tenant Enterprise, para detectar silencios.

View File

@@ -0,0 +1,316 @@
# Suscripciones self-serve con MercadoPago
## Resumen
Sistema completo de gestión de suscripciones donde los tenants pueden elegir plan, activar prueba gratis de 30 días, cambiar de plan, cancelar, y el admin global puede editar precios. Integración con MercadoPago para cobros recurrentes (preapproval) y cobros one-time prorateados (preference) para upgrades inmediatos.
## Motivación
Antes: las suscripciones solo las creaba el admin global al provisionar tenants. El tenant no tenía UI para elegir plan, y cualquier cambio requería que el admin global lo hiciera manualmente. No había trial gratuito.
Ahora: el tenant tiene flujo end-to-end (trial → subscribe → change → cancel) desde `/configuracion/suscripcion`, sin intervención del admin global salvo para plan Custom o ajustes administrativos (mark-paid, listar todos).
## Precios
Editables en tabla `plan_prices` desde BD central. 8 filas: 4 planes × 2 frecuencias. Custom no está aquí — se fija por tenant al provisionar.
| Plan | Mensual | Anual |
|------|---------|-------|
| Starter | $199 | $1,990 |
| Business | $480 | $4,800 |
| Business + IA | $780 | $7,800 |
| Enterprise | $900 | $9,000 |
El admin global edita vía UI (card "Precios de Planes" en `/configuracion/suscripcion`) o SQL directo. Los cambios aplican **solo a suscripciones nuevas o renovaciones futuras** — suscripciones vigentes conservan el `amount` con el que se crearon.
## Schema (BD central)
### Tabla `plan_prices`
```
id SERIAL PK
plan Plan (enum)
frequency String ('monthly' | 'annual')
amount Decimal(10,2)
updated_at TIMESTAMP
UNIQUE (plan, frequency)
```
### Campos nuevos en `Subscription`
| Campo | Tipo | Propósito |
|-------|------|-----------|
| `pendingPlan` | Plan? | Cambio programado al próximo período |
| `pendingFrequency` | String? | Frecuencia del cambio programado |
| `pendingEffectiveAt` | DateTime? | Cuándo se aplica (== currentPeriodEnd cuando se schedula) |
| `upgradePreferenceId` | String? | ID de MP Preference para cobro prorateado en curso |
| `upgradeTargetPlan` | Plan? | Plan nuevo que activará el upgrade |
| `upgradeTargetAmount` | Decimal? | Monto recurrente nuevo (snapshot del precio al iniciar upgrade) |
### Campo nuevo en `Tenant`
| Campo | Tipo | Propósito |
|-------|------|-----------|
| `trialEndsAt` | DateTime? | Marca fin de trial. Si set → el tenant ya usó su prueba |
## Estados de `Subscription.status`
| Status | Significado |
|--------|-------------|
| `trial` | Trial 30 días activo, sin pago |
| `trial_converted` | Usuario convirtió trial → subscribe (histórico) |
| `trial_expired` | Trial venció sin convertir (seteado por cron) |
| `pending` | Preapproval MP creado, esperando autorización del usuario |
| `authorized` | MP autorizó, suscripción activa |
| `paused` | MP pausó (reintento fallido largo) |
| `cancelled` | Usuario canceló. Acceso continúa hasta `currentPeriodEnd` |
## Endpoints
### Public-ish (cualquier admin/cfo autenticado)
```
GET /api/subscriptions/plans
→ lista de precios vigentes (para plan picker)
```
### Self-serve (actúan sobre el tenant del JWT)
```
POST /api/subscriptions/me/trial { plan, frequency }
POST /api/subscriptions/me/subscribe { plan, frequency } → { subscription, paymentUrl }
POST /api/subscriptions/me/change { plan, frequency } → { subscription, effectiveAt }
POST /api/subscriptions/me/upgrade { plan } → { subscription, checkoutUrl, proratedAmount }
POST /api/subscriptions/me/upgrade/cancel → { ok: true }
POST /api/subscriptions/me/cancel → { subscription }
```
### Own-tenant OR global-admin
```
GET /api/subscriptions/:tenantId (estado actual)
GET /api/subscriptions/:tenantId/payments (historial de pagos)
POST /api/subscriptions/:tenantId/generate-link (regenera paymentUrl si pending)
```
### Solo admin global (HTS240708LJA)
```
GET /api/subscriptions/ (todas las suscripciones)
POST /api/subscriptions/:tenantId/mark-paid { amount } (transferencia manual)
PUT /api/subscriptions/plans/:id { amount } (editar precio)
```
## Flujos
### Trial
1. Usuario elige plan + frecuencia en el picker (estado "primera vez")
2. Click "Probar 30 días gratis"
3. Backend crea `Subscription(status='trial', amount=0, currentPeriodEnd=now+30d)` + setea `Tenant.trialEndsAt`
4. El usuario ve toda la app con acceso del plan elegido (feature-gate lee `tenant.plan`)
5. Antes de que venza: puede clickear "Contratar ahora" → flujo de subscribe
6. Si vence sin convertir: cron `expireTrials` cambia status a `trial_expired`, feature-gate lo degrada
Un trial por tenant — validado por la presencia de `trialEndsAt` O por cualquier subscription con status en `('trial','trial_expired','trial_converted')`.
### Subscribe (primera contratación)
1. Backend lee precio de `plan_prices` para (plan, frequency)
2. Crea preapproval en MP con ese monto y frecuencia (`months/1` o `months/12`)
3. Marca trials previos como `trial_converted`
4. Crea `Subscription(status='pending', mpPreapprovalId=...)`
5. Retorna `paymentUrl` — el frontend lo abre en nueva pestaña
6. Usuario autoriza en MP → webhook → `status='authorized'`
### Cancel
1. Usuario click "Cancelar suscripción" → modal confirmatorio
2. Backend: `status='cancelled'`, limpia pending*, llama `cancelPreapproval` en MP
3. El middleware `plan-limits` sigue permitiendo acceso porque respeta `currentPeriodEnd`
4. Cuando vence: middleware empieza a degradar
### Change de plan / frecuencia (scheduled)
Cubre **downgrades** y **cambios de frecuencia** (incluso si el precio subiera al cambiar mensual→anual).
1. Usuario elige plan + frecuencia en modal
2. Frontend `classifyChange` determina `'scheduled'` (caso NO upgrade)
3. Backend `scheduleChange`: guarda `pendingPlan`, `pendingFrequency`, `pendingEffectiveAt = currentPeriodEnd`
4. Banner morado "Tu plan cambiará a X el Y"
5. Cron diario 2:30 AM (`applyPendingChanges`) revisa `pendingEffectiveAt <= now`:
- Cancela preapproval viejo en MP
- Crea preapproval nuevo con nuevo plan/frecuencia/monto
- Actualiza subscription y tenant.plan
- Limpia pending*
- Status queda `pending` hasta que el usuario autorice el nuevo preapproval
### Upgrade inmediato con proration
Solo se dispara cuando **se mantiene la frecuencia** Y el **plan nuevo es más caro** que el actual.
**Fórmula:**
```
daysRemaining = ceil((currentPeriodEnd - now) / 1 día)
periodDays = ceil((currentPeriodEnd - currentPeriodStart) / 1 día)
fraction = min(1, daysRemaining / periodDays)
diff = newAmount - currentAmount
prorated = round(diff × fraction, 2 decimales)
```
**Flujo:**
1. Usuario elige plan más caro en modal (misma frecuencia)
2. Frontend `classifyChange` determina `'upgrade'`, muestra preview azul
3. Click "Pagar y activar" → `POST /me/upgrade`
4. Backend:
- Valida que sea upgrade real (precio nuevo > actual)
- Calcula prorated amount
- Crea MP Preference one-time con `external_reference = 'proration:${tenantId}:${subscriptionId}'`
- Guarda `upgradePreferenceId`, `upgradeTargetPlan`, `upgradeTargetAmount` en Subscription
- Retorna `{ checkoutUrl, proratedAmount }`
5. Frontend abre checkoutUrl en nueva pestaña
6. Usuario paga en MP → webhook payment aprobado
7. Webhook detecta prefijo `proration:`, llama `applyApprovedUpgrade(subscriptionId)`:
- `updatePreapprovalAmount` en MP → próximo cobro recurrente será el nuevo monto
- Transacción DB: actualiza subscription.plan/amount, limpia upgrade*, actualiza tenant.plan
8. Frontend ve el banner "Upgrade pendiente" desaparecer
**Abortar upgrade:** botón "Cancelar upgrade" en el banner → `POST /me/upgrade/cancel` → limpia campos. La preference queda huérfana en MP, expirará sola.
**Racing:** si el usuario paga antes de que el backend registre la preference, el webhook reintentaría. Si falla `updatePreapprovalAmount`, el webhook re-lanza y MP reintenta — eventualmente converge.
## MercadoPago
### Preapproval (recurring)
- `createPreapproval({ amount, frequency: 'monthly'|'annual', payerEmail })` — crea con `auto_recurring.frequency_type: 'months'`, `frequency: 1 | 12`
- `cancelPreapproval(id)` — tolerante a not-found
- `updatePreapprovalAmount(id, newAmount)` — modifica `auto_recurring.transaction_amount`
- `external_reference = tenantId` para que webhook enrute a flujo recurrente
### Preference (one-time checkout para proration)
- `createProrationPreference({ amount, subscriptionId, tenantId, payerEmail, description })` — devuelve `{ preferenceId, checkoutUrl }`
- `external_reference = proration:${tenantId}:${subscriptionId}` — marcador que el webhook usa para enrutar a `applyApprovedUpgrade`
### Webhook routing
```
external_reference empieza con 'proration:' → applyApprovedUpgrade
external_reference == tenantId (UUID) → flujo recurrente existente
```
### Guardrails si MP no está configurado
Si `MP_ACCESS_TOKEN` falta o es inválido:
- `createPreapproval` y `createProrationPreference` lanzan error con mensaje explícito
- Los controllers capturan y retornan 503 al frontend con message legible
- Trial sigue funcionando sin MP (no llega a MP)
## Cron jobs
### `applyPendingChanges` — diario 2:30 AM (`30 2 * * *`)
- Busca subscriptions con `pendingEffectiveAt <= now AND pendingPlan IS NOT NULL`
- Para cada una:
- Cancela preapproval viejo en MP
- Crea preapproval nuevo con pendingPlan/pendingFrequency/priceFromTable
- Actualiza subscription + tenant
- Limpia pending*
### `expireTrials` — mismo cron
- Busca subscriptions con `status='trial' AND currentPeriodEnd < now`
- Cambia a `status='trial_expired'`
Registrados en `sat-sync.job.ts` junto con los demás crons.
## UI — estados de la página `/configuracion/suscripcion`
| Estado del tenant | Lo que ve |
|-------------------|-----------|
| Sin suscripción previa | Plan picker con toggle Mensual/Anual, botón "Probar 30 días gratis" + botón "Contratar" |
| Trial activo | Banner "Te quedan X días", card de sub actual, botón "Contratar ahora" + "Cancelar" |
| Trial vencido | Banner rojo, plan picker sin trial button |
| Pago pendiente (pending) | Banner amarillo con botón "Completar pago" (abre MP) |
| Activa (authorized) | Card de sub, botones "Cambiar plan" + "Cancelar" |
| Cambio programado (pendingPlan) | Banner morado "Cambiará a X el Y" |
| Upgrade pendiente (upgradePreferenceId) | Banner azul "Completa el pago", botón "Cancelar upgrade" |
| Cancelada en período | Banner naranja "Acceso hasta X" |
| Cancelada + vencida | Banner rojo, plan picker para re-contratar |
## Admin global
Si el usuario tiene RFC `HTS240708LJA`:
- Ve vista completa: 4 cards resumen, sección "Precios de Planes" (editable inline), tabla "Todas las Suscripciones"
- NO ve su propia UI de suscripción personal en esa página (se dedica a vista admin)
## Archivos tocados
### Backend
- `prisma/schema.prisma` — tabla `PlanPrice`, campos nuevos en Subscription y Tenant
- `prisma/seed.ts` — refactor completo: usa `migrate()` del runner en lugar de CREATE TABLE hardcodeado, siembra 8 filas de precios
- `src/services/payment/mercadopago.service.ts``createPreapproval` con frequency, `cancelPreapproval`, `updatePreapprovalAmount`, `createProrationPreference`
- `src/services/payment/subscription.service.ts``getPlanPrice`, `startTrial`, `subscribe`, `scheduleChange`, `cancelSubscription`, `applyPendingChanges`, `expireTrials`, `calculateProration`, `initiateUpgrade`, `applyApprovedUpgrade`, `cancelPendingUpgrade`. Cambio en `updateSubscriptionStatus` para usar relación `rol.nombre` en include
- `src/controllers/subscription.controller.ts` — 10+ nuevos handlers + guard `requireOwnTenantOrGlobalAdmin`
- `src/controllers/webhook.controller.ts` — rama de `proration:*` routing
- `src/routes/subscription.routes.ts` — 7 rutas nuevas
- `src/jobs/sat-sync.job.ts` — cron `SUBSCRIPTION_LIFECYCLE_CRON` a las 2:30 AM
### Frontend
- `lib/api/subscription.ts` — tipos extendidos + 7 funciones nuevas
- `lib/hooks/use-subscription.ts` — 7 hooks nuevos
- `app/(dashboard)/configuracion/suscripcion/page.tsx` — reescritura completa (~550 líneas): `PlanGrid`, `FrequencyToggle`, `PlanPricesSection`, clasificador `classifyChange`, 8 estados visuales, 2 modales
## Testing
### Sin MP credentials (todo lo no-MP)
- Trial funciona end-to-end
- Cancel, change (scheduled), admin editar precios funcionan sin tocar MP
- `subscribe` y `upgrade` retornan 503 con mensaje explícito
### Con `MP_ACCESS_TOKEN` sandbox
1. Subscribe: click "Contratar" → abrir checkout MP → autorizar → webhook → status authorized
2. Cancel: status cancelled, preapproval cancelled en MP panel, acceso hasta fin de período
3. Change (downgrade): banner morado, cron aplica, status vuelve a pending hasta nueva autorización
4. Upgrade: click plan más caro misma frecuencia → checkout proration → pagar → subscription actualiza plan + preapproval actualiza monto
5. Upgrade abortado: iniciar upgrade, cerrar checkout, cancelar → campos upgrade* se limpian
## Decisiones descartadas (y por qué)
### Proration entre frecuencias (mensual → anual)
- ¿Cuánto "vale" un mes dentro de un período anual? Ambiguo.
- Decisión: cualquier cambio de frecuencia se scheduluea al próximo período, sin proration.
### Emails de pago a CFO también
- CFO tiene mismo nivel de acceso que admin, técnicamente podría recibir notificaciones.
- Pero cambiar a quién llegan emails de pago es una decisión UX, no un fix de tipos.
- Decisión: sigue emailando solo a `rol.nombre = 'admin'`. Si se quiere incluir CFO en el futuro, cambiar el `where` a `{ in: ['admin', 'cfo'] }`.
### Plan Custom en self-serve
- Custom se reserva para clientes especiales activados por admin global con monto negociado.
- Decisión: el picker no muestra Custom. `subscribe`/`upgrade`/`scheduleChange` fallan si se les pasa `plan: 'custom'` con mensaje explícito.
### Payment history con marcador de proration
- Cuando se cobra proration, el `paymentMethod` se guarda como `proration-${mpPaymentMethod}` (ej: `proration-credit_card`)
- Esto permite distinguir en la tabla de historial pagos recurrentes vs upgrades prorateados sin agregar una columna nueva.
## Deploy
### Migración Prisma
Requerido antes de arrancar en prod:
```bash
cd apps/api
pnpm prisma migrate deploy # agrega PlanPrice + nuevos campos en Subscription y Tenant
```
Si no hay `migrations/` (usa `db push`):
```bash
pnpm prisma db push
```
### Seed de precios (solo una vez)
```bash
pnpm db:seed # idempotente — upsert de las 8 filas de plan_prices
```
Si el seed ya corrió antes (demo tenant ya existe), solo agrega los precios sin tocar el tenant.
### Variables de entorno (apps/api/.env)
```
MP_ACCESS_TOKEN=APP_USR-xxxxxxxx # live key para producción
MP_WEBHOOK_SECRET=tu-secret-de-webhooks # configurado en el panel MP
MP_NOTIFICATION_URL=https://horuxfin.com/api/webhooks/mercadopago
FRONTEND_URL=https://horuxfin.com # usado en back_url de preapproval y preference
```
## Pendientes / mejoras posibles
1. **Email de confirmación al aplicar upgrade** — actualmente no se envía nada cuando `applyApprovedUpgrade` termina. Debería enviar "Tu upgrade a X está activo".
2. **Notificación de trial por vencer** — cron adicional que emailee 3 días antes de `trialEndsAt`.
3. **Re-intento de subscribe si webhook de preapproval no llega** — hoy queda en pending indefinidamente.
4. **Permitir múltiples upgrades consecutivos sin esperar período completo** — actualmente si ya hay `upgradePreferenceId`, el segundo intento falla. Correcto para MVP, pero podría relajarse.
5. **Frontend para editar precios soportar bulk edit** — hoy cada celda se edita individualmente.
6. **Auditoría de cambios de precio** — registrar quién cambió cada precio y cuándo (solo hay `updatedAt` ahora).

View File

@@ -0,0 +1,99 @@
# Limpieza de deuda técnica TypeScript
## Resumen
`tsc --noEmit` pasa de ~25 errores a **0** en `@horux/api` y `@horux/shared`. El proyecto corre con `tsx watch` que transpila sin chequear tipos, por eso la deuda había crecido sin afectar runtime. Algunos fixes resolvieron bugs latentes que no se habían manifestado por cobertura parcial de features.
## Motivación
Sin typecheck limpio:
- Regresiones se detectan sólo en runtime (después de desplegar)
- CI no puede bloquear PRs que rompen tipos
- IDE señala falsos positivos por todas partes, lo que entrena al desarrollador a ignorar el linter
- Fixes legítimos (como el bug runtime de `uploadCertificate`) quedan ocultos entre el ruido
## Alcance
Tocados **11 archivos** en 3 categorías:
### Quick wins (mecánicos, cero riesgo)
| Archivo | Problema | Fix |
|---------|----------|-----|
| `packages/shared/src/constants/roles.ts` | Constante `ROLES` sin entradas para `cfo` y `auxiliar` — los nuevos roles de v0.9.0 no se habían reflejado aquí | Agregadas dos entradas con permisos equivalentes (CFO = admin; Auxiliar = contador) |
| `apps/api/tsconfig.json` | Código usaba `document.querySelectorAll()` sin `DOM` lib; iteración `for...of` sobre `NodeList` sin `DOM.Iterable` | `lib: ["ES2022", "DOM", "DOM.Iterable"]` |
| `apps/api/src/controllers/bancos.controller.ts` | `parseInt(req.params.id)``req.params.id` es `string \| string[]` bajo `@types/express@5` | `parseInt(String(req.params.id))` |
| `apps/api/src/controllers/calendario.controller.ts` | Igual | Igual |
| `apps/api/src/controllers/conciliacion.controller.ts` | Igual | Igual |
| `apps/api/src/controllers/documentos.controller.ts` | Igual | Igual |
| `apps/api/src/controllers/facturacion.controller.ts` | `const { id } = req.params; facturapiService.downloadPdf(..., id)` — id tipado `string \| string[]` | `const id = String(req.params.id)` |
| `apps/api/src/controllers/alertas.controller.ts` | Igual al patrón de params | `String(req.params.id)` |
### Cambios de lógica (riesgo medio, comportamiento preservado)
| Archivo | Problema | Fix |
|---------|----------|-----|
| `apps/api/src/services/payment/subscription.service.ts` | `include: { users: { where: { role: 'admin' } } }` — el schema migró `User.role``User.rolId` con FK a tabla `Rol`. Los 3 include-nested donde se buscaba admin fallaban, lo que a su vez cascadeaba en ~7 errores TS2339 sobre `tenant.users[0]` porque el include inválido perdía la inferencia del shape con relación | `where: { rol: { nombre: 'admin' } }` navega la relación correctamente. **Comportamiento preservado:** sigue emailando únicamente a usuarios con rol exacto `'admin'`. CFO no recibe emails de pago — si se desea, es un cambio UX separado |
| `apps/api/src/services/impuestos.service.ts` | `result.push({ mes, ivaTrasladado, ... })` — el tipo `IvaMensual` exigía `id`, `año`, `estado`, `fechaDeclaracion` pero el cálculo nunca los producía. Frontend en `/impuestos` usa `row.estado === 'declarado'` — siempre resolvía `undefined !== 'declarado'` → badge "Pendiente" por accidente | Poblados 4 campos con semántica "calculado, sin declaración persistida": `id: 0`, `año`, `estado: 'pendiente'`, `fechaDeclaracion: null`. **Arregla bug silencioso del frontend** además del tipo |
### Fixes de runtime disfrazados de errores de tipos
| Archivo | Problema | Fix |
|---------|----------|-----|
| `apps/api/src/services/facturapi.service.ts` | `uploadCertificate(id, { cer, key, password })` — firma del SDK cambió a 4 args posicionales: `(id, cerFile: BinaryInput, keyFile: BinaryInput, password)`. Código pasaba un objeto donde el SDK esperaba un Buffer de certificado | `uploadCertificate(id, Buffer.from(cer, 'base64'), Buffer.from(key, 'base64'), password)`. **Este era un bug runtime latente** — habría fallado al primer intento real de subir CSD en producción. Nadie lo notó porque Facturapi está en modo test donde CSD no es requerido |
| `apps/api/src/services/facturapi.service.ts` | Tipo de `taxes` no declaraba `withholding`, pero el código usaba `...(t.withholding ? { withholding: true } : {})` | Agregado `withholding?: boolean` al tipo. Documenta lo que el código ya asumía |
| `apps/api/src/services/facturapi.service.ts` | `logoUrl: org.logo_url` donde `org.logo_url: string \| null` pero target esperaba `string \| undefined` | `org.logo_url ?? undefined` (coerción estándar null → undefined) |
### Infraestructura (no-cambio de arquitectura)
| Archivo | Problema | Fix |
|---------|----------|-----|
| `apps/api/src/config/tenant-migrations.ts` | `import.meta.url` + `fileURLToPath` para resolver path. TS con `NodeNext` + sin `"type": "module"` en package.json compila a CJS, donde `import.meta` es inválido | Reemplazado por `__dirname` directo — global nativo de Node en CJS. Cero cambios a deps, tsconfig o package.json |
## Decisiones descartadas (y por qué)
### Downgradear `@types/express@5.0.0` → `@types/express@4.17.21`
**Tentación:** los 7 errores de `string | string[]` son porque el tipado v5 anticipa params array de Express 5, pero el runtime es Express 4.21. Un downgrade de types los habría resuelto a todos en una línea.
**Por qué no:** alterar `package.json` + `pnpm install` requiere más testing de lo que el fix merece. Los casts `String(req.params.id)` son explícitos y funcionan en ambas versiones. Cuando migren a Express 5, esos casts seguirán siendo correctos.
### Cambiar a `"type": "module"` para eliminar `tenant-migrations.ts` con `import.meta`
**Tentación:** una línea en `package.json` y el error desaparece.
**Por qué no:** disparó 4 errores nuevos en `facturapi.service.ts`. El SDK de Facturapi no declara campo `"exports"` en su package.json, sólo `main`/`module`. Bajo ESM estricto, TS trata el default import como namespace en vez de clase, rompiendo `new Facturapi()` y `: Facturapi` como tipo. Resolverlo requeriría workarounds feos (`(Facturapi as any)`, destructuración de `.default`) en 4 sitios. **Regresión neta.**
### Expandir CFO a receptor de emails de pago
**Tentación:** según `CLAUDE.md`, CFO tiene mismo nivel de acceso que admin. Lógicamente debería recibir notificaciones.
**Por qué no:** este es un cambio UX explícito sobre quién recibe qué correos, no un fix de tipos. Si se desea, el cambio es:
```diff
- where: { rol: { nombre: 'admin' } }
+ where: { rol: { nombre: { in: ['admin', 'cfo'] } } }
```
## Verificación
```bash
# En apps/api
pnpm typecheck # 0 errores
# En packages/shared
pnpm typecheck # 0 errores
```
Dev server (`pnpm dev`) corre limpio después de todos los cambios:
- API `:4000` responde, Prisma queries ejecutan
- Web `:3000` sirve `/cfdi`, `/login`, `/dashboard`, `/impuestos` con 200
## Pendientes post-cleanup
1. **Verificar subida de CSD con certificado real** ahora que `uploadCertificate` usa la firma correcta. Caso de prueba: en modo producción de Facturapi, cargar un `.cer`/`.key` válido y confirmar que `is_production_ready` de la organización cambia a `true`.
2. **Verificar webhooks de MercadoPago** siguen llegando al admin — la query de include cambió de shape (de enum-role a relation-nombre).
3. **Decidir si CFO debe recibir emails de pago** (cambio UX opcional, no de tipos).
4. **Audit regular de typecheck en CI:** agregar `pnpm typecheck` como check obligatorio en PRs para que la deuda no vuelva a crecer.
## Decisiones que no se tocaron (siguen como deuda)
Ninguna relacionada con este cleanup. Los pendientes de producto (Nómina tipo N, Carta Porte, notificaciones email de alertas/recordatorios, SMTP local) siguen como estaban en v0.9.0.

View File

@@ -0,0 +1,237 @@
# Audit log de acciones críticas
**Estado:****IMPLEMENTADO** (2026-04-14) — MVP operativo. 10 eventos instrumentados, endpoint + UI para admin global. La sección final "Implementación ejecutada" resume qué quedó vs qué se postergó.
## Problema
Hoy no hay registro de quién hizo qué. Acciones con implicaciones fiscales, financieras o de seguridad ocurren sin dejar rastro auditable:
- Admin global editó precios → ¿cuándo? ¿de $X a $Y? ¿quién?
- Se emitió factura manual → ¿por qué payment? ¿quién la emitió?
- Cliente canceló/reactivó suscripción → sí tenemos `updatedAt` pero no quién ni por qué
- FIEL re-subida → timestamp sí, autor no
- `platform_admin` creó/borró tenant → sin rastro
- Roles de plataforma asignados/removidos → sin rastro
El SAT en auditoría puede pedir registros de quién emitió facturas y cuándo. En disputa con cliente ("yo nunca cancelé"), no hay forma de defender.
## Propuesta
Tabla genérica + helper simple. Se instrumenta en los ~15 endpoints críticos.
### Schema
```prisma
model AuditLog {
id String @id @default(uuid())
userId String? @map("user_id") // Quién (null = sistema/cron)
tenantId String? @map("tenant_id") // Sobre qué tenant (si aplica)
action String // Evento: "price.updated", "subscription.cancelled", etc.
entityType String? @map("entity_type") // "Subscription", "Tenant", "PlanPrice", ...
entityId String? @map("entity_id") // ID del recurso afectado
metadata Json? // Antes/después, contexto, IP, UA
createdAt DateTime @default(now()) @map("created_at")
@@index([userId, createdAt])
@@index([tenantId, createdAt])
@@index([action, createdAt])
@@index([entityType, entityId])
@@map("audit_log")
}
```
Nullable `userId` cubre eventos del sistema (cron jobs, webhooks automáticos). Nullable `tenantId` cubre acciones de admin global sin tenant específico.
### Helper
```typescript
// apps/api/src/utils/audit.ts
export async function auditLog(params: {
userId?: string;
tenantId?: string;
action: string;
entityType?: string;
entityId?: string;
metadata?: Record<string, any>;
}): Promise<void> {
// Fire-and-forget: auditar NUNCA debe romper la acción principal.
try {
await prisma.auditLog.create({ data: { ...params } });
} catch (error) {
console.error('[Audit] Falló registrar evento:', error);
}
}
// Helper para controllers — extrae user+tenant del request
export async function auditFromReq(
req: Request,
action: string,
extra?: Partial<Parameters<typeof auditLog>[0]>,
) {
await auditLog({
userId: req.user?.userId,
tenantId: req.user?.tenantId,
action,
metadata: {
ip: req.ip,
userAgent: req.get('user-agent'),
...extra?.metadata,
},
...extra,
});
}
```
### Eventos a instrumentar (MVP)
| Action | Dónde | Metadata mínima |
|--------|-------|-----------------|
| `user.login` | `auth.service.ts:login` | `success: boolean`, email, IP |
| `user.logout` | `auth.service.ts:logout` | — |
| `user.password_changed` | `auth.service.ts` | — |
| `tenant.created` | `tenants.service.ts:createTenant` | nombre, rfc, plan |
| `tenant.deleted` | `tenants.service.ts` (si existe) | — |
| `subscription.created` | `subscription.service.ts:subscribe` | plan, frequency, amount |
| `subscription.cancelled` | `subscription.service.ts:cancelSubscription` | reason (si se pide) |
| `subscription.reactivated` | `subscription.service.ts:reactivateSubscription` | — |
| `subscription.plan_changed` | `subscription.service.ts:scheduleChange`/`applyApprovedUpgrade` | from/to plan, from/to frequency |
| `trial.started` | `startTrial` | plan, rfc |
| `price.updated` | `subscription.controller.ts:updatePlanPrice` | plan, frequency, from/to amount |
| `invoice.emitted_auto` | `invoicing.service.ts:emitInvoiceIfApplicable` | paymentId, facturapiInvoiceId, amount |
| `invoice.emitted_manual` | `facturacion.controller.ts:emitir` | cliente RFC, conceptos, folio |
| `invoice.cancelled` | facturapi cancellation | uuid |
| `fiel.uploaded` | `fiel.service.ts` | rfc, vigencia |
| `fiel.deleted` | | — |
| `payment.recorded` | webhook | source: `mercadopago`, amount, status |
| `payment.marked_paid_manually` | `subscription.controller.ts:markAsPaid` | amount |
| `platform_role.granted` | (futuro, ver plan admin roles) | role |
| `platform_role.revoked` | (futuro) | role |
### Lo que NO logear
- Lecturas normales (GET /cfdi, dashboard, etc.) — demasiado volumen
- Operaciones de cron silenciosas que ya logean en consola
- Errores (esos van a Sentry, no a audit)
## Frontend — visualización
Página `/admin/audit-log` (visible solo para `platform_admin` cuando exista):
- Filtros: por user, tenant, action, fecha
- Export CSV (útil en auditoría del SAT)
- Paginación
## Retention
Inicialmente guardar indefinidamente. Si el volumen crece, política:
- Cron mensual que archiva a S3 (Parquet o JSON comprimido) eventos > 2 años
- Tabla caliente con últimos 2 años solo
## Alcance
| Tarea | Estimación |
|-------|-----------|
| Schema + migración | 30 min |
| Helper `auditLog`/`auditFromReq` | 1 h |
| Instrumentar 15-20 callsites | 2-3 h |
| UI admin `/admin/audit-log` | 1 día (tabla + filtros + export) |
| Tests | 2-3 h |
| **Total** | **1-2 días** |
## Riesgos
1. **Performance:** cada write = INSERT extra. Para 100 req/s ≈ 100 INSERT/s en `audit_log`. Postgres lo aguanta fácilmente, pero worth monitoring.
2. **PII en metadata:** IP y user agent pueden considerarse PII. Revisar si hay que cifrar o limitar retention.
3. **Logging del propio audit:** si el INSERT al `audit_log` falla, no bloquear la acción. El helper ya hace catch silencioso.
## Archivos a tocar
- `apps/api/prisma/schema.prisma` — modelo `AuditLog`
- `apps/api/src/utils/audit.ts` — helper nuevo
- `apps/api/src/services/*.service.ts` — llamadas a `auditLog` en acciones críticas
- `apps/api/src/controllers/*.controller.ts` — ídem
- `apps/web/app/(dashboard)/admin/audit-log/page.tsx` — UI (cuando se implemente platform roles)
## Relación con otros planes
- **`2026-04-14-platform-admin-roles.md`:** la UI de audit-log vive gated tras `platform_admin`. El schema puede coexistir sin ese rol (acciones del admin global hoy se audita igual).
- **`2026-04-14-jwt-revocation.md`:** la revocación de JWT puede auditarse como `user.session_revoked`.
---
## Implementación ejecutada (2026-04-14)
### Lo que se construyó
**Schema:**
- Tabla `audit_log` con 4 índices (`userId+createdAt`, `tenantId+createdAt`, `action+createdAt`, `entityType+entityId`). Aplicada vía `prisma db push` contra la BD local.
**Helper:**
- `apps/api/src/utils/audit.ts` con:
- `auditLog(params)` — fire-and-forget básico
- `auditFromReq(req, action, extra)` — extrae user/tenant del request y enriquece metadata con `ip` + `userAgent`
**Eventos instrumentados (10):**
| Action | Dónde quedó | Metadata guardada |
|--------|-------------|-------------------|
| `user.login` | `auth.service.ts:login` | email, tenantRfc |
| `user.logout` | `auth.service.ts:logout` | — |
| `trial.started` | `subscription.service.ts:startTrial` | plan, frequency, rfc, trialEndsAt |
| `subscription.created` | `subscription.service.ts:subscribe` | plan, frequency, amount |
| `subscription.cancelled` | `subscription.service.ts:cancelSubscription` | plan, currentPeriodEnd |
| `subscription.reactivated` | `subscription.service.ts:reactivateSubscription` | plan, frequency, nextChargeAt |
| `subscription.plan_changed` (scheduled) | `subscription.service.ts:scheduleChange` | kind:'scheduled', fromPlan/toPlan, fromFrequency/toFrequency, effectiveAt |
| `subscription.plan_changed` (upgrade) | `subscription.service.ts:applyApprovedUpgrade` | kind:'upgrade_immediate', fromPlan/toPlan, frequency, newAmount |
| `price.updated` | `subscription.controller.ts:updatePlanPrice` | plan, frequency, fromAmount, toAmount |
| `invoice.emitted_auto` | `invoicing.service.ts:emitInvoiceIfApplicable` | facturapiInvoiceId, amount, plan, frequency |
| `payment.marked_paid_manually` | `subscription.service.ts:markAsPaidManually` | amount, subscriptionId |
**Endpoint:** `GET /api/audit-log`
- Admin global only (`requireGlobalAdmin` via controller)
- Query params: `action` (prefix match), `tenantId`, `userId`, `from`, `to`, `page`, `limit` (max 200)
- Respuesta enriquecida con `user.email/nombre` y `tenant.nombre/rfc` (join en memoria, no en schema)
- Archivos: `controllers/audit-log.controller.ts`, `routes/audit-log.routes.ts`, registrada en `app.ts`
**Frontend:**
- `lib/api/audit-log.ts`, `lib/hooks/use-audit-log.ts`
- `app/(dashboard)/admin/audit-log/page.tsx` — tabla con filtros, badges por tipo de acción, expandible para ver JSON metadata, paginación prev/next
- Sidebar: nuevo item "Audit Log" en `adminNavigation` (icon `FileWarning`) — solo visible para admin global
### Eventos que NO se instrumentaron (deliberadamente pospuestos)
| Action | Razón |
|--------|-------|
| `user.password_changed` | No existe endpoint de cambio de password en el repo actual. Se agrega al implementar el plan de `jwt-revocation`. |
| `tenant.created` / `tenant.deleted` | `createTenant` lo usa el admin global para provisionar; decidí priorizar los eventos con mayor valor auditable primero. Quedó para un siguiente pase de instrumentación. |
| `invoice.emitted_manual` | Requiere tocar `facturacion.controller.ts` que es área sensible (Facturapi); se pospuso para evitar tocar múltiples concerns en el mismo commit. |
| `invoice.cancelled` | Flujo de cancelación en Facturapi no está completamente implementado en el MVP del sistema de facturación; se instrumenta cuando se cierre ese loop. |
| `fiel.uploaded` / `fiel.deleted` | Pendiente — va en el próximo pase. |
| `payment.recorded` | **Decisión:** NO logear. Cada webhook de MP dispararía uno. Ruido sin valor auditable nuevo — el payment ya queda en tabla `payments`. Solo `payment.marked_paid_manually` importa auditar (intervención humana). |
### UI admin
La página `/admin/audit-log`:
- Gate de acceso doble: (a) frontend checa `isGlobalAdminRfc` y si no es admin global muestra card "Acceso restringido"; (b) backend devuelve 403 si el request no viene de admin global (defense in depth).
- Filtros con `<Select>` agrupado por familia ("Suscripciones" → prefix `subscription.`), más inputs libres para tenantId/userId/fechas.
- Cada row tiene "Ver detalle" que expande un card con el JSON completo de `metadata` formateado.
- Paginación simple prev/next (no jump-to-page — scope mínimo MVP).
### Pendientes para siguiente iteración
1. **CSV export** — el endpoint soporta paginación hasta 200 rows por request; para exports grandes habría que implementar streaming. Útil en auditoría SAT.
2. **Instrumentar los 7 eventos pospuestos** (lista arriba).
3. **Retention policy** — tabla crece indefinidamente. Cron mensual que archiva > 2 años a S3 o simplemente borre (depende de compliance fiscal que aplica).
4. **Filtro por entityId** — útil para "¿qué pasó con la suscripción X?". Backend ya soporta vía query param, solo falta input en UI.
5. **Vista individual por user** — "todas las acciones de este contador en el último mes".
### Verificación manual post-deploy
```
1. Logueate como admin@demo.com → aparece row user.login
2. Ve a /configuracion/suscripcion y cancela → aparece subscription.cancelled
3. Reactiva (si aplica) → aparece subscription.reactivated
4. Como admin global, edita un precio → aparece price.updated con from/to
5. Ve a /admin/audit-log → aparece sidebar solo si eres admin global
6. Intenta navegar a /admin/audit-log como admin@demo.com → ve "Acceso restringido"
7. Filtra por "Usuarios" → ve solo login/logout
```

View File

@@ -0,0 +1,108 @@
# Documentos: Declaraciones Provisionales + Constancia de Situación Fiscal
Fecha: 2026-04-14
Autores: Carlos e Ivan (Horux360)
## Contexto
`/documentos` solo tenía Opinión de Cumplimiento. Pedimos:
1. Panel donde el contador sube el PDF de declaraciones mensuales del cliente (normal + complementarias) y se desactivan los recordatorios automáticamente.
2. Descarga automática mensual de la Constancia de Situación Fiscal (CSF) del portal SAT, con auto-fill del domicilio fiscal y regímenes activos del tenant.
## Alcance
`/documentos` se reorganizó en 3 pestañas:
- **Opinión de Cumplimiento** (pre-existente)
- **Constancia de Situación Fiscal** (nueva)
- **Declaraciones Provisionales** (nueva)
## Declaraciones Provisionales
### Schema
- Migración `003_create_declaraciones_provisionales.sql`: tabla con `pdf_declaracion BYTEA`, `pdf_pago BYTEA`, `impuestos TEXT[]` (IVA/ISR/IEPS/SUELDOS/DIOT/OTRO), `tipo` (normal/complementaria). UNIQUE INDEX parcial para 1 normal por (año, mes).
- Migración `004_declaraciones_liga_pago_pdf.sql`: reemplaza `link_pago TEXT` por `pdf_liga_pago BYTEA` + `pdf_liga_pago_filename` (la liga de pago es PDF de línea de captura, no URL).
### Reglas de auto-resolución de alertas
- Tipo `normal`: resuelve alertas `decl-<impuesto>-YYYY-MM-*` de los impuestos seleccionados.
- Tipo `complementaria`: resuelve `decl-*` y `pago-*` (la complementaria sustituye a la normal en pago — ya no hay que pagar la normal).
- Mapping `Impuesto → prefijo de alerta`:
- `IVA``decl-iva` + `pago-iva`
- `ISR``decl-isr` + `pago-isr`
- `IEPS``decl-ieps` + `pago-ieps`
- `SUELDOS``decl-sueldos` (no tiene pago)
- `DIOT``diot` (solo declaración)
- `OTRO` → sin mapping
### Subida de comprobante de pago
Endpoint separado `POST /documentos/declaraciones/:id/comprobante-pago` que solo acepta el PDF. Al subir, se resuelven las alertas `pago-*` del mes.
### Retención
5 años (CFF Art. 30). Purge en cron lifecycle 2:30 AM.
## Constancia de Situación Fiscal
### Schema
- Migración `005_create_constancias_situacion_fiscal.sql`: `pdf BYTEA`, `datos JSONB` (shape completo), `rfc`, `id_cif`, `razon_social`, `estatus_padron`, `fecha_emision`. Index DESC por `fecha_consulta`.
### Scraper portado del prototipo
Del proyecto standalone `sat-csf-prototype/` (en Downloads):
- `sat-csf-login.ts`: navegación portal SAT → popup SERVICIO → login FIEL. Fix crítico: el click sintético a "e.firma" a veces no dispara el handler del SAT, por eso si tras 10s no aparece el `input[type=file]` se reintenta con `dispatchEvent('click')`.
- `sat-csf-scraper.ts`: el botón "Generar Constancia" vive en un iframe JSF legacy (`rfcampc.siat.sat.gob.mx/PTSC/...`). Iteramos `appPage.frames()`. 3 rutas de extracción del PDF: download event, popup viewer (blob:/data:/http), response interception.
- `sat-csf-parser.ts`: parser PF+PM. Labels `key:value` con lookahead a la siguiente label o sección. Tablas (actividades/regímenes/obligaciones) agrupadas por "chunk termina en dd/mm/yyyy" + filtro de page-break noise. Obligaciones PM requieren regex extendido (`Dentro de`, `Mensualmente`, etc. además de `A más tardar`).
### Auto-fill de domicilio y regímenes
`sincronizarDatosFiscales(tenantId, csf)` en `constancia.service.ts`:
- **Domicilio fiscal** — actualiza campos `tenants`: `codigo_postal`, `calle` (compuesta por `tipoVialidad + nombreVialidad`), `num_exterior`, `num_interior` (ignora "SIN NUMERO"), `colonia`, `ciudad` (← localidad), `municipio`, `estado` (← entidadFederativa). Solo actualiza campos cuando el CSF trae valor no-vacío — nunca pisa con null.
- **Regímenes activos** — matchea `csf.regimenes` (nombre libre) contra catálogo `regimenes` (clave SAT + descripcion). Normalización: strip "Régimen", "de las/los", NFD+accents, lowercase; matching por `===` o `includes`. Reemplaza toda la lista `tenant_regimenes_activos` con lo que diga la CSF (solo regímenes sin `fechaFin`).
El usuario puede sobreescribir manualmente después desde `/configuracion` — cada consulta de CSF vuelve a pisar.
### Disparadores
1. **Cron mensual** día 1 04:00 AM (`0 4 1 * *`) — en `sat-sync.job.ts`, junto con los demás.
2. **Primer upload de FIEL** — en `fiel.service.ts` se detecta `!existingFiel || !existingFiel.isActive` y se disparan Opinión + CSF con `import()` fire-and-forget. No bloquea la respuesta.
3. **Manual**`POST /documentos/constancias/consultar` (owner/cfo, rate limit 2/día).
### Retención
5 años. Purge en cron lifecycle 2:30 AM junto con declaraciones.
### Headless
`chromium.launch({ headless: true })` por default (no se ve ventana al usuario). Flag `SAT_HEADLESS=false` en `.env` para debug visual temporal. El fix del `dispatchEvent` hace que headless sea confiable.
## UI
- `apps/web/app/(dashboard)/documentos/page.tsx` reescrito con `Tabs` de 3 pestañas.
- `OpinionTab`: preserva funcionalidad existente.
- `ConstanciaTab`: detalle de último CSF (identificación / domicilio / regímenes / obligaciones) + historial de 12 con detalle expandible por row + descarga PDF + "Consultar ahora".
- `DeclaracionesTab`: selector de año, tabla mensual, dialog para subir declaración (multi-select de impuestos, PDF declaración obligatorio, liga de pago y notas opcionales), dialog para subir comprobante de pago, descarga PDFs.
## Archivos creados
Backend:
- `apps/api/src/migrations/tenant/003_create_declaraciones_provisionales.sql`
- `apps/api/src/migrations/tenant/004_declaraciones_liga_pago_pdf.sql`
- `apps/api/src/migrations/tenant/005_create_constancias_situacion_fiscal.sql`
- `apps/api/src/services/declaraciones.service.ts`
- `apps/api/src/services/constancia.service.ts`
- `apps/api/src/services/sat/sat-csf-login.ts`
- `apps/api/src/services/sat/sat-csf-scraper.ts`
- `apps/api/src/services/sat/sat-csf-parser.ts`
Frontend:
- `apps/web/lib/api/declaraciones.ts`
- `apps/web/lib/api/constancias.ts`
- `apps/web/lib/hooks/use-declaraciones.ts`
- `apps/web/lib/hooks/use-constancias.ts`
## Archivos modificados
- `apps/api/src/controllers/documentos.controller.ts` — endpoints declaraciones + constancias
- `apps/api/src/routes/documentos.routes.ts` — rutas nuevas
- `apps/api/src/jobs/sat-sync.job.ts` — cron CSF mensual + purge 5 años
- `apps/api/src/services/fiel.service.ts` — trigger on first upload
- `apps/web/app/(dashboard)/documentos/page.tsx` — tabs + UI completa
## Pendientes futuros
1. Al detectar cambios en domicilio/régimen/obligaciones entre consultas de CSF, levantar alerta al contador y owner (notificación in-app + email opcional).
2. Si el `estatusPadron` != ACTIVO, alerta de alta prioridad (análoga a la de Opinión Negativa).
3. Eventualmente exponer `actividadesEconomicas` en la UI de configuración para que el contador las revise.

View File

@@ -0,0 +1,250 @@
# Revocación de JWT por tokenVersion
**Estado:****IMPLEMENTADO** (2026-04-14) — mecanismo de revocación vía `User.tokenVersion` operativo. Password change + "cerrar todas las sesiones" invalidan todos los access tokens del user en el siguiente request (≤30s por cache). Ver sección final "Implementación ejecutada".
## Problema
Hoy los access tokens son válidos 15 min y refresh tokens 7 días. No hay manera de **revocar un token antes de su expiración natural**. Si un token leak (laptop robada, XSS, compromiso de sesión en un café internet, phishing), el atacante tiene:
- Hasta **15 min** de acceso con el access token activo
- Hasta **7 días** de capacidad para refresh via refresh token
Para un admin global (acceso a datos fiscales de todos los clientes) esto es inaceptable. Incluso para un user normal, si descubre compromise debería poder "cerrar todas las sesiones" inmediatamente.
## Propuesta
Enfoque **tokenVersion** — contador incremental por usuario, incluido en JWT. Al incrementar, todos los tokens viejos quedan inválidos en el siguiente request.
### Schema
```prisma
model User {
// ... campos existentes
tokenVersion Int @default(0) @map("token_version")
}
```
### Flujo
1. **Al loguear:** `generateAccessToken` incluye `tokenVersion: user.tokenVersion` en el payload
2. **En `authenticate` middleware:** después de verificar la firma JWT, compara `payload.tokenVersion` contra `user.tokenVersion` actual en BD. Si no coincide → 401.
3. **Al invalidar:** `UPDATE users SET token_version = token_version + 1 WHERE id = ?`. Todas las sesiones del user mueren en el siguiente request.
### Disparadores de incremento
| Evento | Acción |
|--------|--------|
| Cambio de password | Incrementar tokenVersion → fuerza re-login en todas las sesiones |
| "Cerrar todas las sesiones" (UI nueva) | Incrementar |
| Detección de sesión sospechosa (futuro) | Incrementar |
| Borrar user | N/A (user ya no existe) |
| Logout normal | **NO incrementar** — solo invalida el refresh token actual, resto de sesiones del user sobreviven |
### Performance
Cada request autenticada = 1 lookup extra a `User.tokenVersion`. Mitigación:
- **Cache in-memory por worker** con TTL 30s — 1 DB hit cada 30s por user activo
- Al incrementar tokenVersion, broadcast vía `process.send` (ya hay patrón en `invalidate-tenant-cache`) para propagar la invalidación entre workers
- Para altísimo tráfico: Redis con el tokenVersion. Hoy no aplica al tamaño del sistema
Sin cache, 1 query por request: medida. Con Postgres local y user indexed by PK, ~0.3ms. Aceptable para el stage actual.
### Middleware modificado
```typescript
// apps/api/src/middlewares/auth.middleware.ts
const tokenVersionCache = new Map<string, { version: number; expires: number }>();
const TOKEN_VERSION_TTL = 30 * 1000; // 30 segundos
export async function authenticate(req, res, next) {
// ... verify JWT signature como hoy
const payload = jwt.verify(token, env.JWT_SECRET) as JWTPayload;
// Check token version
let current = tokenVersionCache.get(payload.userId);
if (!current || current.expires < Date.now()) {
const user = await prisma.user.findUnique({
where: { id: payload.userId },
select: { tokenVersion: true },
});
if (!user) return res.status(401).json({ message: 'Usuario no existe' });
current = { version: user.tokenVersion, expires: Date.now() + TOKEN_VERSION_TTL };
tokenVersionCache.set(payload.userId, current);
}
if ((payload.tokenVersion ?? 0) !== current.version) {
return res.status(401).json({ message: 'Sesión expirada, vuelve a iniciar sesión' });
}
req.user = payload;
next();
}
// Llamar desde auth.service.ts cuando cambia password, o desde nuevo endpoint /auth/logout-all
export function invalidateTokenVersionCache(userId: string) {
tokenVersionCache.delete(userId);
if (typeof process.send === 'function') {
process.send({ type: 'invalidate-token-version', userId });
}
}
```
Cluster broadcast idéntico al que ya existe para `invalidate-tenant-cache`.
### Nuevos endpoints
```
POST /auth/password-change # Cambia password + incrementa tokenVersion
POST /auth/logout-all # "Cerrar todas las sesiones" (incrementa tokenVersion)
GET /auth/sessions # (opcional) Lista sesiones activas (refresh tokens)
POST /auth/sessions/:id/revoke # (opcional) Revocar una sesión específica
```
### UI
Agregar en `/configuracion/seguridad` (página nueva o sección):
- Botón "Cerrar todas las sesiones excepto esta" → llama `/auth/logout-all`
- (Opcional) Lista de sesiones activas con device/IP/última actividad
## Consideraciones
### Type en JWTPayload
`JWTPayload` en `packages/shared` debe incluir `tokenVersion?: number`. Optional para compatibilidad con tokens viejos al momento del deploy — default 0.
### Rollout
1. Deploy con `tokenVersion` default 0 en todos los users
2. Los JWT viejos no incluyen el campo → interpretamos como `payload.tokenVersion ?? 0` → matcheará y seguirán funcionando
3. Cuando user cambia password o se invoca logout-all, se incrementa
4. Funciona retroactivamente sin forzar re-login masivo
### Auditoría
Cada incremento de `tokenVersion` debe logearse vía `auditLog({ action: 'user.token_version_incremented', userId, reason: 'password_changed'|'logout_all'|... })`.
## Alcance
| Tarea | Estimación |
|-------|-----------|
| Schema + migración (campo en User) | 30 min |
| Middleware con cache | 1 h |
| Incrementar en changePassword | 30 min |
| Endpoint `/auth/logout-all` | 1 h |
| JWT payload type + seed | 30 min |
| UI "Cerrar sesiones" | 1 h |
| Tests | 1 h |
| **Total** | **~1 día** |
## Riesgos
1. **Cache miss burst:** después del deploy, 0% cache hit ratio durante primeros 30s. Suma carga a Postgres temporal.
2. **Clock skew entre workers:** `Date.now()` basta, no hay dependencia entre workers más allá del broadcast de invalidación.
3. **Usuarios confundidos:** "¿por qué mi sesión expiró sin razón?" — necesario copy claro en error message.
## Archivos a tocar
- `apps/api/prisma/schema.prisma``User.tokenVersion`
- `apps/api/src/middlewares/auth.middleware.ts` — check + cache
- `apps/api/src/services/auth.service.ts` — endpoints nuevos, incremento en password change
- `apps/api/src/utils/token.ts` — incluir `tokenVersion` en payload
- `packages/shared/src/types/auth.ts``JWTPayload.tokenVersion`
- `apps/web/app/(dashboard)/configuracion/seguridad/page.tsx` — UI (nueva página)
- `apps/web/lib/api/auth.ts` — clients para endpoints nuevos
## Relación con otros planes
- **`2026-04-14-audit-log.md`:** cada incremento de tokenVersion debería auditarse.
- **`2026-04-14-platform-admin-roles.md`:** cuando un `platform_admin` revoca un rol de otro staff, puede ser útil forzar logout-all del afectado (incrementar tokenVersion).
---
## Implementación ejecutada (2026-04-14)
### Lo que se construyó
**Schema:**
- Campo `User.tokenVersion Int @default(0) @map("token_version")` aplicado vía `pnpm prisma db push` (no se generó migración SQL porque usamos db push en desarrollo).
**Middleware (`auth.middleware.ts`):**
- Cache in-memory `Map<userId, { version, expires }>` con TTL **30 segundos**
- `getCurrentTokenVersion(userId)` — lee cache o Postgres (también valida `user.active`)
- `authenticate` compara `payload.tokenVersion ?? 0` vs version actual → 401 si no coincide
- `invalidateTokenVersionCache(userId)` — limpia cache local + `process.send({ type: 'invalidate-token-version', userId })` para broadcast PM2
- Listener `process.on('message')` recibe el broadcast en otros workers y limpia su cache local
- Backward compat: JWTs emitidos antes del deploy llevan `undefined` → se interpreta como `0`, matchea con el default → **no hay re-login forzado masivo**
**Auth service (`auth.service.ts`):**
- `login()` y `refreshTokens()` incluyen `tokenVersion: user.tokenVersion` en el payload JWT
- `confirmPasswordReset()` — ahora incrementa `tokenVersion` dentro del transaction (antes solo borraba refresh tokens, dejaba access tokens vivos hasta 15min)
- `changePassword({ userId, currentPassword, newPassword })` — nuevo. Valida password actual, incrementa tokenVersion, borra refresh tokens, auditLog `user.password_changed`
- `logoutAllSessions(userId)` — nuevo. Incrementa tokenVersion, borra refresh tokens, auditLog `user.sessions_invalidated` con `reason: logout_all`
**Endpoints nuevos (`auth.controller.ts` + `auth.routes.ts`):**
- `POST /auth/password-change` (auth required) — body `{ currentPassword, newPassword }`, zod validado, min 8 chars, current ≠ new
- `POST /auth/logout-all` (auth required) — sin body, cierra todas las sesiones del caller
**Frontend:**
- `lib/api/auth.ts``changePassword(currentPassword, newPassword)` + `logoutAll()`
- `app/(dashboard)/configuracion/seguridad/page.tsx` — página nueva con 2 cards:
1. Cambiar contraseña (form con 3 campos, validación client-side, redirect a /login tras 2.5s con success message)
2. Cerrar todas las sesiones (botón con confirm nativo, redirect inmediato a /login)
- `configuracion/page.tsx` — tarjeta "Seguridad" con icono `KeyRound` linkeando a la nueva página
**JWT payload (`packages/shared/src/types/auth.ts`):**
- `tokenVersion?: number` agregado al interface — opcional para compat con tokens viejos
### Auditoría
Cada invalidación queda logeada en `audit_log`:
- `user.password_changed` — change de password autenticado
- `user.password_reset_completed` — reset por flujo "olvidé contraseña" (ya existía, ahora además incrementa tokenVersion)
- `user.sessions_invalidated` — botón "cerrar todas las sesiones"
### Archivos tocados
**Backend:**
- `prisma/schema.prisma``tokenVersion` en User
- `src/middlewares/auth.middleware.ts` — cache + check + broadcast PM2
- `src/services/auth.service.ts` — payload includes tokenVersion, new `changePassword` + `logoutAllSessions`, `confirmPasswordReset` incrementa
- `src/controllers/auth.controller.ts` — handlers `changePassword` y `logoutAll`
- `src/routes/auth.routes.ts` — 2 rutas nuevas
**Shared:**
- `src/types/auth.ts``JWTPayload.tokenVersion?: number`
**Frontend:**
- `lib/api/auth.ts` — 2 métodos nuevos
- `app/(dashboard)/configuracion/seguridad/page.tsx` (nuevo)
- `app/(dashboard)/configuracion/page.tsx` — tarjeta "Seguridad"
### Decisiones de diseño
- **Cache TTL 30s (no Redis):** a este tamaño de tráfico el lookup a Postgres por PK es ~0.3ms. Un worker PM2 con 50 requests/s de un mismo user haría 1 DB hit cada 30s (1500 requests cacheados). Redis se añadirá solo si el perfil muestra contención.
- **Cross-worker broadcast:** reutiliza el mismo patrón `process.send` que `invalidate-tenant-cache` — evita tener un usuario con "media sesión inválida" porque solo 1 de N workers vio el cambio.
- **Logout-all cierra la sesión actual también:** por diseño — si el user está en /configuracion/seguridad y hace clic, espera que "todas" signifique todas. Se le redirige a /login inmediato.
- **`current === new` password bloqueado:** defensivo — evita el caso de usuario que quiere "refrescar" su password pero por error escribe la misma. Sin esto, tokenVersion se incrementa sin razón y cierra otras sesiones gratis.
### Verificación manual
```
1. Login → el JWT ahora incluye tokenVersion (decode en jwt.io para verificar)
2. /configuracion → aparece tarjeta "Seguridad"
3. /configuracion/seguridad:
- Cambiar contraseña con current incorrecto → error "Contraseña actual incorrecta"
- Cambiar con current = new → error "debe ser distinta"
- Cambiar con new < 8 chars → error client
- Cambiar correcto → mensaje verde + redirect a /login en 2.5s
- Re-loguear con nueva password → OK
4. En 2 browsers logueados como mismo user:
- Browser A: /configuracion/seguridad → "Cerrar todas las sesiones"
- Browser A: redirect a /login
- Browser B: en siguiente request (max 30s) → 401 "Sesión expirada..."
5. Audit log: aparecen user.password_changed y user.sessions_invalidated
```
### Pendientes / futuro
- **`GET /auth/sessions`** — listar sesiones activas (refresh tokens) con device/IP/lastUsed. Requiere columnas adicionales en `refresh_tokens`.
- **`POST /auth/sessions/:id/revoke`** — revocar una sesión específica sin cerrar las demás.
- **Auto logout-all en grant/revoke de platform_role:** cuando admin da/quita rol de plataforma, podría forzar logout-all del afectado para que su nuevo JWT refleje el cambio sin esperar refresh.
- **Migración de `db push` a migrate:** el campo `tokenVersion` se aplicó con `db push`. Cuando se genere la próxima migración SQL central, incluirla formalmente.

View File

@@ -0,0 +1,473 @@
# Owner con múltiples RFCs y suscripción por tenant
**Estado:****IMPLEMENTADO COMPLETO** (2026-04-14) — fases 1-6 cerradas. F6.1 ✅ (auth via memberships), F6.2 ✅ (usuarios.service via memberships), F6.3 ✅ (subscription owner queries via memberships), F6.4 ✅ (drop `User.tenantId`/`User.rolId` del schema). Refactor multi-tenant terminado, deuda dual eliminada. Ver sección final "Progreso por fase".
## Problema
Modelo actual asume 1:1:1 entre Usuario-Owner, Tenant y RFC:
```
User ──(belongs_to)──> Tenant ──(1:1)──> RFC
└──(has_many)──> Subscription (una activa a la vez)
```
En la práctica, un dueño de múltiples empresas (caso común en México: contador o empresario con 2-5 RFCs) hoy tendría que:
- Crear un user distinto por cada RFC
- Darse login distinto en cada uno
- Pagar por separado sin visibilidad consolidada
Además, si el mismo dueño registra un segundo RFC, el sistema actual le da **30 días gratis otra vez** a ese nuevo RFC — el gate de `trial_usages` bloquea el mismo RFC, pero no relaciona "este humano ya tuvo trial antes" porque cada tenant es un user distinto para el sistema.
## Propuesta
### Modelo target
```
User ──(has_many via TenantMembership)──> Tenant ──(1:1)──> RFC
└──(has_many)──> Subscription
```
```prisma
model TenantMembership {
id Int
userId String @map("user_id")
tenantId String @map("tenant_id")
rolId Int @map("rol_id") // Rol dentro de este tenant (owner/cfo/contador/...)
isOwner Boolean @default(false) @map("is_owner") // Fast-lookup de "quién es owner de este tenant"
joinedAt DateTime @default(now()) @map("joined_at")
active Boolean @default(true)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
rol Rol @relation(fields: [rolId], references: [id])
@@unique([userId, tenantId])
@@index([userId, active])
@@index([tenantId, active])
@@map("tenant_memberships")
}
```
`User.tenantId` / `User.rolId` **se eliminan** (o quedan como "default tenant" para UX al login). La relación verdadera vive en `TenantMembership`.
Un user puede pertenecer a N tenants, con rol distinto en cada uno:
| user | tenant | rol | isOwner |
|------|--------|-----|---------|
| carlos | Empresa A (RFC X) | owner | ✓ |
| carlos | Empresa B (RFC Y) | owner | ✓ |
| carlos | Empresa C (RFC Z) | cfo | — |
| ivan | Empresa A (RFC X) | contador | — |
### Suscripción sigue siendo por-tenant
Cada `Tenant` tiene su propia `Subscription`. Como hoy. No cambia.
La diferencia está en el acceso: el owner de múltiples tenants ve cada suscripción desde la misma sesión, vía un selector en la UI.
### Regla de trial al agregar RFC nuevo
Cuando un Owner existente agrega un RFC nuevo (crea un tenant nuevo via flow "Agregar empresa"):
1. Se crea el Tenant + TenantMembership(userId=owner, rolId=owner, isOwner=true)
2. Al llamar `startTrial`, además del check por RFC, se agrega un check por Owner:
```typescript
// Ya existe: RFC en trial_usages → bloquea
// Nuevo: ¿este user (owner) ya tiene otro tenant con trial consumido?
const priorOwned = await prisma.tenantMembership.findFirst({
where: {
userId: ownerUserId,
isOwner: true,
tenant: { trialEndsAt: { not: null } },
NOT: { tenantId: newTenantId },
},
});
if (priorOwned) {
throw new Error(
'Ya consumiste una prueba gratuita con otro RFC. Los RFCs adicionales ' +
'requieren contratar un plan directamente.'
);
}
```
La lógica es simétrica con la del RFC: "una prueba por humano, no por empresa".
Se puede expresar como una vista o tabla nueva `owner_trial_consumed(user_id)` para query más rápida, pero para MVP el join está bien.
## Impacto
### Backend
**Auth:**
- Login response ya no incluye `tenantId` como single value — incluye `tenants: [{ id, nombre, rfc, rol, isOwner }]` + `activeTenantId` (default: el último usado o el primero)
- JWT lleva `userId` + `activeTenantId`
- Endpoint nuevo `POST /auth/switch-tenant` que regenera JWT con nuevo `activeTenantId` (validando que el user sí es miembro)
**Middleware:**
- `authenticate` ya lo hace bien (decodifica JWT)
- `tenantMiddleware` resuelve `req.activeTenantId = req.user.activeTenantId`
- `X-View-Tenant` de admin global sigue funcionando igual (impersonación)
**Services y controllers:**
- Donde hoy usan `req.user.tenantId`, usar `req.user.activeTenantId` o `effectiveTenantId(req)`
- Query de "todas las suscripciones del owner actual": `SELECT s.* FROM subscriptions s JOIN tenant_memberships tm ON tm.tenant_id = s.tenant_id WHERE tm.user_id = ? AND tm.is_owner = true`
- `createTenant` nuevo flow: lo llama el owner desde UI self-serve, NO solo admin global. Resultado: nuevo Tenant + TenantMembership del caller como owner.
**Trial check ampliado:**
- El check existente de `trial_usages.rfc` se mantiene
- Se agrega el check de "owner con otro tenant trial-consumed" descrito arriba
### Frontend
**Auth store:**
```typescript
{
user: { id, email, nombre },
tenants: [{ id, nombre, rfc, rol, isOwner }],
activeTenantId: string,
activeTenant: computed,
}
```
**Tenant switcher:**
- Se agrega un dropdown visible en el header para CUALQUIER user con `tenants.length > 1` (no solo admin global)
- Seleccionar otro tenant → llama `POST /auth/switch-tenant` → nuevo JWT → refresca pages
- Admin global mantiene su X-View-Tenant para ver OTROS tenants ajenos (comportamiento distinto)
**Navegación:**
- Toda page actual pasa a trabajar con el `activeTenantId`. No hay cambio semántico — solo cambia dónde viene el valor.
**Página `/mis-empresas` (nueva):**
- Lista las empresas del owner autenticado
- Cada fila: nombre, RFC, plan, estado de suscripción (activa/pending/cancelled/etc.), siguiente cobro
- Acciones por fila: "Gestionar" (switch a ese tenant) | "Ver suscripción" (ir al `/configuracion/suscripcion` del tenant)
- Botón "+ Agregar empresa" → flow de crear nuevo RFC
**Flujo "Agregar empresa":**
1. Form pide: nombre, RFC, plan inicial
2. Backend valida RFC (no duplicado), crea Tenant + TenantMembership owner para el user actual
3. Redirect a `/configuracion/suscripcion` del nuevo tenant → el owner contrata un plan (sin trial disponible por el check)
4. Desde ahí flujo normal de subscribe
### Schema central — migración
Una migración pesada en 3 pasos:
```sql
-- 1. Crear tabla tenant_memberships
CREATE TABLE tenant_memberships (...);
-- 2. Copiar los users existentes al formato nuevo
INSERT INTO tenant_memberships (user_id, tenant_id, rol_id, is_owner, joined_at, active)
SELECT u.id, u.tenant_id, u.rol_id, (r.nombre = 'owner'), u.created_at, u.active
FROM users u
JOIN roles r ON u.rol_id = r.id;
-- 3. (Opcional, después de verificar) eliminar u.tenant_id y u.rol_id
-- ALTER TABLE users DROP COLUMN tenant_id;
-- ALTER TABLE users DROP COLUMN rol_id;
-- Dejar por un tiempo para compatibilidad
```
Mantener `User.tenantId` como "default" (el último tenant usado) es útil para UX — al login, ir directo al dashboard de ese tenant en vez de mostrar un selector.
## Riesgos
1. **Massive touchpoint.** Cualquier endpoint que asumió `req.user.tenantId` como única identidad de tenant activo se puede romper sutilmente. Requiere audit completo. Estimación: ~40-60 archivos con cambios.
2. **Sessions existentes rotas.** Los JWT no llevan `activeTenantId` hoy — al primer deploy, todos los sessions activos se invalidan. Mitigar con announce + re-login forzado.
3. **Invalidación de cache y query keys.** React Query usa `tenantId` en query keys — al cambiar de tenant activo, tienen que re-fetchear. Ya existe patrón con `viewingTenantId` de admin global (similar).
4. **Pagos en MP.** Cada tenant tiene su propio preapproval. Un owner con 3 tenants tiene 3 preapprovals activos, 3 tarjetas o la misma tarjeta 3 veces. Separación de billing es buena fiscalmente (cada RFC tiene su CFDI de su suscripción) — no hay problema pero hay que estar conscientes.
5. **Trial por Owner:** escenario borde — ¿qué pasa si un Owner le **cede** su tenant a otra persona? El trial de ese tenant ya se consumió (el nuevo user lo ve como consumed), pero si el nuevo user crea otro tenant, ¿debería recibir trial? Sí — es un Owner nuevo sin historia. Condición: check en tabla `trial_usages` por RFC + check por `TenantMembership.userId` como owner con tenant trial-consumed.
## Alcance estimado
| Área | Estimación |
|------|-----------|
| Schema + migración | 1 día |
| Backend auth + switching | 1 día |
| Backend controllers/services refactor | 2 días |
| Frontend store + UI tenant switcher | 1 día |
| Frontend nuevas páginas (`/mis-empresas`, `+ Agregar empresa`) | 1 día |
| Testing + regression | 1-2 días |
| **Total** | **~7-8 días calendario** |
Scope similar a la implementación completa de suscripciones self-serve que se hizo en v0.9.1.
## Beneficios
- Experiencia mejor para contadores y grupos empresariales (un login, muchas empresas)
- Eliminación del hack "crear user distinto por cada RFC"
- Cierre del hueco de abuso de trial por Owner
- Base para features futuras: reports consolidados, dashboard "todas mis empresas"
## Decisiones que posponemos hasta implementar
- ¿Un owner puede tener múltiples roles en el mismo tenant? (Probablemente no — un membership por user-tenant)
- ¿Transferencia de ownership entre users? ¿Con aprobación del nuevo owner?
- ¿Límite de tenants por user? (Probablemente no, pero podría haber spam)
- ¿Invitación de nuevo user a un tenant existente genera email + link? (Probablemente sí, ya hay infra de email)
- ¿Owner puede cancelar membership de otros users? (Sí, es dueño del tenant)
## Archivos a tocar cuando se implemente
### Backend
- `apps/api/prisma/schema.prisma``TenantMembership` model, `User.tenantId`/`User.rolId` deprecados
- `apps/api/prisma/seed.ts` — migración idempotente que llena `tenant_memberships` desde los users existentes
- `apps/api/src/services/auth.service.ts` — login response con `tenants[]`, endpoint `switch-tenant`
- `apps/api/src/services/tenants.service.ts` — flujo de agregar tenant nuevo por owner no-admin-global
- `apps/api/src/services/payment/subscription.service.ts` — check ampliado en `startTrial` (RFC + Owner)
- `apps/api/src/middlewares/tenant.middleware.ts` — resolver `activeTenantId` de forma nueva
- `apps/api/src/utils/token.ts` — JWT payload extendido
- Todos los controllers que hoy hacen `req.user.tenantId` — audit y cambio a `req.user.activeTenantId` o helper
### Frontend
- `apps/web/stores/auth-store.ts` — shape nuevo
- `apps/web/components/tenant-selector.tsx` — ampliar a owners multi-tenant, no solo admin global
- `apps/web/app/(dashboard)/mis-empresas/page.tsx` — nueva
- `apps/web/app/(dashboard)/mis-empresas/nueva/page.tsx` — nueva (o modal)
- `apps/web/app/(auth)/register/page.tsx` — review (registro inicial crea user + primer tenant como owner)
- `apps/web/lib/api/tenants.ts` — endpoints nuevos
### Docs
- `CLAUDE.md` — sección multi-tenant actualizada
- `README.md` — changelog
- Doc de implementación al final
## Relación con otros planes
- **`2026-04-14-platform-admin-roles.md`** es complementario: staff interno obtiene permisos transversales via `UserPlatformRole`; owners manejan sus propios tenants via `TenantMembership`. Ambos se pueden implementar independientemente pero suman capa de claridad en autorización.
- **`2026-04-14-trial-abuse-prevention.md`** es precursor: agrega check por RFC. Este plan extiende a check por Owner, cubriendo un segundo vector de abuso.
---
## Progreso por fase
### ✅ Fase 1 — Schema + backfill (commit `7a80db1`, 2026-04-14)
- Nuevo modelo `TenantMembership` en `prisma/schema.prisma` con `@@unique([userId, tenantId])` + índices `[userId, active]` y `[tenantId, active]`. FK con `onDelete: Cascade` desde User y Tenant.
- Relaciones agregadas en User, Tenant, Rol.
- `User.tenantId` y `User.rolId` **se mantienen** (default tenant para UX al login).
- Backfill idempotente en `prisma/seed.ts`:
```sql
INSERT INTO tenant_memberships (user_id, tenant_id, rol_id, is_owner, active, joined_at)
SELECT u.id, u.tenant_id, u.rol_id, (r.nombre IN ('owner', 'cfo')), u.active, u.created_at
FROM users u JOIN roles r ON u.rol_id = r.id
ON CONFLICT (user_id, tenant_id) DO NOTHING
```
Verificación local: 5 memberships (3 owners, 2 no-owners) cubriendo admin global + demo + test.
Non-breaking: todos los consumidores siguen usando `User.tenantId` como antes.
### ✅ Fase 2 — Auth devuelve `tenants[]` + switch-tenant (commit `c333ae2`, 2026-04-14)
- Shared types:
- `TenantMembership` interface (id, nombre, rfc, plan, role, isOwner)
- `UserInfo.tenants?: TenantMembership[]` (opcional, backward compat)
- Helper nuevo `apps/api/src/utils/memberships.ts`:
- `getUserTenants(userId)` — lista memberships activos con tenant+rol joineados, filtra tenants desactivados
- `verifyMembership(userId, tenantId)` — valida acceso antes de emitir JWT
- `auth.service.ts`:
- `login()` ahora pobla `user.tenants[]` vía `getUserTenants()`
- `switchTenant({ userId, currentRefreshToken, targetTenantId })`:
* Valida membership activa en el target (403 si no)
* Invalida el refresh token actual (deleteMany idempotente)
* Emite nuevo par de tokens con `role` del target tenant
* Audit event `user.tenant_switched` con `from` + `to` + `targetRfc`
* Retorna `LoginResponse` completo
- `POST /auth/switch-tenant` (authenticated) con zod `{ tenantId: uuid, refreshToken: string }`.
- Verificado con curl: `admin@demo.com` → `tenants: [{ id, nombre: "Empresa Demo SA de CV", rfc: "EDE123456AB1", plan: "business_ia", role: "owner", isOwner: true }]`.
Non-breaking: JWT sigue con `tenantId` single; frontend aún no consume `tenants[]`.
### ✅ Fase 3 — Tenant switcher en UI (commit `6ce7daf`, 2026-04-14)
- Componente nuevo `apps/web/components/membership-switcher.tsx`:
- Visible solo si `user.tenants.length > 1` Y NO es admin global (admin global usa `TenantSelector` para impersonar via `X-View-Tenant` — modelo distinto)
- Dropdown muestra cada tenant con nombre, RFC, role, y corona dorada si `isOwner`
- Tenant activo marcado con `bg-primary/10` + check
- Click en otra empresa:
1. `POST /auth/switch-tenant { tenantId, refreshToken }`
2. `setTokens()` con el par nuevo (el refresh anterior queda revocado server-side)
3. `setUser()` con la nueva `LoginResponse` (incluye `tenants[]` actualizado)
4. `queryClient.clear()` + `window.location.reload()` para que React Query re-fetche desde cero con el JWT del nuevo tenant
- API client: `switchTenant(tenantId)` en `apps/web/lib/api/auth.ts`
- `auth-store` no requirió cambios — `setUser(response.user)` ya guarda `tenants[]` automáticamente porque vive dentro de `UserInfo`
Coexistencia clara con TenantSelector existente:
- **Owner regular con multi-membership** → `MembershipSwitcher` (cambia JWT real)
- **Admin global** → `TenantSelector` (impersonación, los demás tenants no son suyos)
Para probar antes de Fase 4, se puede insertar una membership manual en SQL:
```sql
INSERT INTO tenant_memberships (user_id, tenant_id, rol_id, is_owner, active, joined_at)
SELECT u.id, t.id, u.rol_id, true, true, NOW()
FROM users u, tenants t
WHERE u.email='admin@demo.com' AND t.rfc='TEST123456XX1';
```
### ✅ Fase 4 — `/mis-empresas` + "Agregar empresa" (commits `e0ef001`, follow-up access control fix)
**Backend (`tenants.service.ts`):**
- `addTenantToOwner({ userId, nombre, rfc, plan? })`: crea tenant nuevo + membership owner para el user existente. Subscription pending.
- `getMyTenantsDetailed(userId, onlyOwner = true)`: lista memberships con subscription joined (plan, status, currentPeriodEnd, pendingPlan, pendingEffectiveAt, amount, frequency). **Default `onlyOwner=true`** — solo muestra tenants donde el user es owner.
**Backend (controller + routes):**
- `GET /api/tenants/mine` — lista filtrada (solo owner).
- `POST /api/tenants/mine` — gateado por `isOwnerSomewhere(userId)`: si el user no es owner en ningún tenant, devuelve 403. Esto evita que un contador invitado a una empresa ajena cree RFCs nuevos.
**Backend (`utils/memberships.ts`):**
- `isOwnerSomewhere(userId)` helper nuevo — query optimizado (LIMIT 1) para el gate.
**Consistencia de memberships:**
- `register()`, `createTenant()` (admin global) y `inviteUser()` ahora crean `TenantMembership` automáticamente junto con `User`. Los users invitados via `/usuarios` son siempre `isOwner=false`.
**Frontend (`/mis-empresas`):**
- Cards por empresa: nombre, RFC, corona dorada si isOwner, plan, badge de status, próximo cobro, pending changes.
- Botones "Ir a esta empresa" (switch + reload) y "Ver suscripción".
- Modal "Agregar empresa": form RFC + nombre + plan, copy explica que no hay trial para RFCs adicionales (preludio a fase 5).
**Frontend (sidebar):**
- Item "Mis empresas" usa nuevo flag `requireOwnerSomewhere` (no `roles[]`). Visible si `user.tenants.some(t => t.isOwner)`. Esto asegura que un user con rol activo `contador` (porque está en una empresa ajena) pero owner en otra, **siga viendo** el link.
**Casuística cubierta:**
- Owner con 1 empresa → ve "Mis empresas" + puede agregar más.
- Contador puro (sin tenant propio) → no ve "Mis empresas", `GET /tenants/mine` retorna `[]`, `POST` retorna 403.
- Contador-Owner híbrido (owner en empresa A, contador en empresa B): ve "Mis empresas" desde cualquier contexto activo, en la página solo aparece A; en el header switcher aparecen ambas.
**Bug fix descubierto durante implementación:** había 2 clases `AppError` distintas (`utils/errors.ts` vs `middlewares/error.middleware.ts`); el middleware solo reconocía la suya, así que un `throw new AppError(403, …)` desde controllers que importaran de utils caía a 500. Tenants controller migrado a la versión del middleware. Nota técnica: queda deuda en otros controllers que importan de `utils/errors.ts` — convergir a una sola fuente en otra pasada.
**Verificación:**
```
GET /tenants/mine admin@demo.com → [{Empresa Demo, isOwner:true}]
GET /tenants/mine contador@demo.com → []
POST /tenants/mine contador@demo.com → 403 "Solo los dueños pueden registrar..."
POST /tenants/mine admin@demo.com → 201 con tenant nuevo
```
### ✅ Fase 5 — Trial check por Owner (commit `437ef6c`, 2026-04-14)
`startTrial({ tenantId, plan, frequency, ownerUserId? })`:
- Nuevo param opcional `ownerUserId`. Si se pasa, agrega el **gate 4**:
```typescript
const ownedTenantWithTrial = await prisma.tenantMembership.findFirst({
where: {
userId: params.ownerUserId,
isOwner: true,
active: true,
tenantId: { not: params.tenantId },
tenant: { trialEndsAt: { not: null } },
},
select: { tenant: { select: { rfc: true } } },
});
if (ownedTenantWithTrial) throw new Error(`Ya consumiste... (${rfc})`);
```
- Mensaje cita el RFC del trial previo para que el user entienda por qué se bloqueó.
Es **opcional** para no romper otros callers (scripts admin, bootstrap). El controller `startMyTrial` siempre lo pasa con `req.user.userId`. El handler de errores agrega "Ya consumiste" / "ya consumió" a los mensajes reconocidos como 400 (no 500).
**Combinado con el gate por RFC pre-existente (`trial_usages`):**
- Mismo RFC en distintos tenants → bloqueado por `trial_usages`
- Mismo humano con distintos RFCs → bloqueado por owner gate
Verificación E2E:
1. `admin@demo.com` (owner EDE con trial usado) crea TRT con `POST /tenants/mine`
2. switch-tenant a TRT (membership owner)
3. `POST /subscriptions/me/trial` → **400** "Ya consumiste una prueba gratuita con otro RFC (EDE123456AB1). Cada dueño tiene derecho a una sola prueba de 30 días..."
### 🚧 Fase 6 — Cleanup `User.tenantId`/`User.rolId` (en progreso)
Refactor para eliminar la deuda dual del modelo viejo (User → Tenant 1:1). Sub-fases:
#### ✅ F6.1 — Auth resuelve tenant activo via memberships (commit `fbf0f5a`, 2026-04-14)
Schema:
- Nuevo campo `User.lastTenantId String?` — tracker del último tenant activo. Persiste UX "remember last tenant" sin depender de `User.tenantId`.
`auth.service.ts`:
- `login()`: ya no carga `User.tenant`/`User.rol`. Resuelve `activeMembership` desde `tenant_memberships`:
1. Si `lastTenantId` set Y user tiene membership activa ahí → ese
2. Sino → primer membership por `joinedAt` ASC
3. Sino → 401 "No tienes acceso a ninguna empresa activa"
El JWT lleva `role`/`tenantId` derivados de la membership activa. Cada login persiste `lastTenantId = tenant activo elegido`.
- `refreshTokens()`: re-valida que el user sigue teniendo membership activa en el tenant del JWT. Si lo removieron, cae al primer membership disponible.
- `switchTenant()`: persiste `targetTenant.id` en `User.lastTenantId` antes de emitir tokens.
Verificación E2E:
1. Login `admin@demo.com` (lastTenantId null) → EDE (primer membership)
2. switch-tenant TEST → `lastTenantId = TEST`
3. Re-login → cae directo en TEST con role=contador
**Bug fix asociado (auth-store):** durante el testing apareció un 403 cuando un user no-admin entraba después de que un admin global hubiera usado el `TenantSelector` en el mismo browser. El localStorage `horux-tenant-view` quedaba huérfano y el siguiente user heredaba `X-View-Tenant`, que el `tenantMiddleware` rechaza si el caller no es admin global. Fix: `auth-store.logout()` ahora borra ese key del localStorage. Pre-existente, no introducido por F6.1.
#### ✅ F6.2 — Refactor `usuarios.service.ts` para listar via memberships (commit `010d756`, 2026-04-14)
`usuarios.service.ts` ya no consume `User.tenantId/rolId` para listar/invitar/borrar. Todo va por `tenant_memberships`:
- `getUsuarios(tenantId)` — query memberships activos del tenant; `role` refleja `membership.rol.nombre` (per-tenant).
- `inviteUsuario(tenantId, data)`:
- Si el email ya existe como user global → agrega membership en este tenant en vez de crear duplicado. Cubre el caso "contador X ya trabaja en otra empresa, ahora me invitan a la mía".
- Limit check via `tenant_memberships.count` del tenant, no `User.count` global.
- Upsert por `(userId, tenantId)` — re-invitación tras delete reactiva la membership.
- `updateUsuario(tenantId, userId, data)` — `role` cambia per-tenant (membership.rolId); `active` y `nombre` son globales del user.
- `deleteUsuario(tenantId, userId)` — `prisma.tenantMembership.deleteMany({where:{userId, tenantId}})` — soft-delete por tenant. El user sigue si tiene otros memberships activos.
- `getAllUsuarios()` (admin global) — lista por `(user, tenant)`. Un user con N memberships aparece N veces. Cada row con su tenant explícito.
- `updateUsuarioGlobal(userId, data)` — si pasa `tenantId`, role cambia esa membership; active es global.
- `deleteUsuarioGlobal(userId)` — hard-delete user + cascade limpia memberships.
`User.tenantId/rolId` se siguen poblando al crear (constraint NOT NULL del schema). F6.4 los borra.
**Shared types:** `UserListItem.role` widened de `'admin'|'contador'|'visor'` a `Role` (incluye owner/cfo/auxiliar). El tipo viejo era pre-rename y no reflejaba la realidad.
Verificación E2E: `GET /api/usuarios` as `admin@demo.com` (contexto TEST) → `[contador (owner), admin (contador), test (owner)]` — roles correctos per-tenant.
#### ✅ F6.3 — Refactor `subscription.service.ts` queries de owner via memberships (commit `b6ec37b`, 2026-04-14)
Helper nuevo en `utils/memberships.ts`:
```typescript
export async function getTenantOwnerEmail(tenantId: string): Promise<string | null> {
const m = await prisma.tenantMembership.findFirst({
where: { tenantId, isOwner: true, active: true },
include: { user: { select: { email: true } } },
orderBy: { joinedAt: 'asc' },
});
return m?.user.email ?? null;
}
```
5 callsites en `subscription.service.ts` migrados de `tenant.users.where(rol.nombre='owner').take(1)` a `getTenantOwnerEmail()`:
1. `updateSubscriptionStatus(cancelled)` — notification email al cancelar
2. `recordPayment(approved/rejected)` — payment notifications
3. `generatePaymentLink` — `payerEmail` del preapproval MP
4. `cancelMySubscription` — notification email
5. `applyPendingChanges` (cron) — `adminEmail` dentro del loop por sub
Cero referencias a `tenant.users[]` restantes en este servicio. Los `include` prisma desaparecen — el query es más limpio y la lógica de "quién es el dueño" queda centralizada en memberships.
**Bug fix asociado (jti único en JWT):** durante la testing aparecieron errores `Unique constraint failed on the fields: (token)` cuando React Query disparaba 2 refreshes paralelos. El JWT firmado era idéntico (mismo payload + mismo `iat` segundo). Fix: `generateAccessToken` y `generateRefreshToken` ahora pasan `jwtid: randomBytes(8).toString('hex')` en `SignOptions` — cada token tiene 16 chars hex únicos garantizados. Sin cambio de schema. Pre-existente, no introducido por F6.x. Commit `4351bf0`.
#### ✅ F6.4 — Drop `users.tenant_id` y `users.rol_id` del schema (commit junto con baseline migration)
Schema final:
- `User.tenantId` y `User.rolId` **eliminados** del modelo Prisma. Sus relaciones (`tenant`, `rol`) también removidas. La relación inversa `Tenant.users[]` y `Rol.users[]` desaparecen — ahora todo va por `User.memberships[]`.
- `prisma db push --accept-data-loss` aplicado al DB de desarrollo (las columnas se borraron físicamente).
Código limpiado:
- `auth.service.register()` — `prisma.user.create` ya no setea `tenantId/rolId`. El role del JWT inicial se hardcodea como `'owner'` (es el flujo de signup, siempre crea un owner). El `lastTenantId` se setea al tenant recién creado para que el siguiente login caiga ahí.
- `auth.service` — 5 `select: { tenantId: true }` cambiados a `{ lastTenantId: true }`. 5 `auditLog({ tenantId: user.tenantId })` cambiados a `user.lastTenantId ?? undefined`.
- `tenants.service.createTenant` — `prisma.user.create` solo email/passwordHash/nombre/lastTenantId. `getAllTenants` `_count` ahora cuenta `memberships` (where active) en vez de `users`.
- `usuarios.service.inviteUsuario` — `prisma.user.create` sin `tenantId/rolId`.
- `platform-staff.controller` — `searchUsers` y `listStaff` resuelven el tenant del staff via `user.memberships.where(isOwner=true).take(1)` con `orderBy: joinedAt asc`.
- `platform-admin.isGlobalAdmin` — busca user con superset role + membership activa en el tenant (en vez de `user.tenantId`).
**Baseline migration generada:** Antes de F6.4 la BD central no tenía `prisma/migrations/` — todos los cambios se aplicaban con `prisma db push` (sin trail versionado). Aprovechando F6.4, se generó la migración consolidada `20260414152220_initial_schema_v0_9_2/migration.sql` (634 líneas con todo el DDL acumulado: enums, tabla central, FKs, índices) y se marcó como aplicada con `prisma migrate resolve --applied`. A partir de ahora cada cambio del schema central debe generarse con `pnpm prisma migrate dev --name <descripción>`.
**Verificación E2E post-drop:**
- Login `admin@demo.com` → resuelve EDE, role=owner ✅
- `GET /tenants/mine` → Empresa Demo con sub joined ✅
- `GET /usuarios` → 3 users del tenant con roles per-membership ✅
- `prisma migrate status` → "Database schema is up to date!" ✅

View File

@@ -0,0 +1,119 @@
# Recuperación de contraseña
**Estado:** ✅ IMPLEMENTADO (2026-04-14)
## Problema
El login no tenía opción de "¿Olvidaste tu contraseña?". Si un user olvidaba la suya, la única recuperación posible era: el admin del tenant le reseteaba manualmente (cambiando el hash directo en BD o recreando el user). Fricción alta + riesgo de que el admin viera la nueva contraseña.
## Flujo implementado
```
/login → "¿Olvidaste tu contraseña?" link
/forgot-password → email → POST /auth/password-reset/request (rate-limit 3/h)
Backend: valida user, invalida tokens previos, genera token 32-bytes hex,
guarda en DB con expiresAt = now + 1h, envía email vía Nodemailer
Email con link https://…/reset-password?token=abc123
Usuario abre link → /reset-password?token=xxx → nueva password + confirmación
POST /auth/password-reset/confirm (rate-limit 10/h)
Backend: valida token (exists, !used, !expired), actualiza passwordHash,
marca token usado, borra TODOS los refresh tokens del user
(cierra sesiones activas), audita
Redirect a /login con mensaje de éxito
```
## Schema
```prisma
model PasswordResetToken {
id String @id @default(uuid())
userId String @map("user_id")
token String @unique
expiresAt DateTime @map("expires_at")
usedAt DateTime? @map("used_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
@@map("password_reset_tokens")
}
```
## Endpoints
| Endpoint | Rate limit | Request | Response |
|---------|-----------|---------|----------|
| `POST /auth/password-reset/request` | 3/h per IP | `{ email }` | `{ message }` genérico (siempre 200 aunque email no exista) |
| `POST /auth/password-reset/confirm` | 10/h per IP | `{ token, newPassword }` | `{ message }` success, o 400 con razón (inválido/usado/expirado/pwd corto) |
## Seguridad
| Vector de ataque | Defensa |
|------------------|---------|
| **Enumeration** (descubrir qué emails existen) | Request endpoint responde 200 con mensaje idéntico independiente de si el email existe o no. `console.log` interno diferencia pero no al cliente. |
| **Brute force del token** | Token 32 bytes hex = 256-bit entropía, imposible adivinar. Además rate limit 10/h por IP en confirm endpoint. |
| **Spam de emails de reset** | Rate limit 3/h por IP en request endpoint (previene atacante que intenta enviar emails phishing-like desde nuestro sistema a múltiples victims). |
| **Token reuse** | Campo `usedAt` marca single-use. Segundo intento falla con "Este enlace ya fue usado". |
| **Captura del token en tránsito** | HTTPS + token va en URL como query param. Aceptable para email links — si el usuario controla su email, controla el token. |
| **Session hijack post-compromise** | Al completar reset, **`DELETE FROM refresh_tokens WHERE user_id = ?`** — todas las sesiones activas del user quedan inválidas. Forza re-login. |
| **Password débil** | Mínimo 8 caracteres validado en backend (Zod) + frontend. |
| **Token activo tras nuevo request** | Al generar nuevo token, todos los tokens previos no usados del mismo user se marcan como `usedAt = now()` (efectivamente invalidados). |
## Audit
Dos eventos nuevos en `audit_log`, visibles en `/admin/audit-log`:
- `user.password_reset_requested` — metadata `{ email }`
- `user.password_reset_completed` — metadata `{ email }`
Útil en forense: "¿cuándo este user reseteó? ¿múltiples requests indican actividad sospechosa?"
## Archivos
### Backend
- `apps/api/prisma/schema.prisma` — modelo `PasswordResetToken` + relación en `User`
- `apps/api/src/services/auth.service.ts``requestPasswordReset()`, `confirmPasswordReset()`
- `apps/api/src/services/email/email.service.ts``sendPasswordReset()`
- `apps/api/src/services/email/templates/password-reset.ts` (nuevo) — template con link + advertencia de expiración
- `apps/api/src/controllers/auth.controller.ts``requestPasswordReset`, `confirmPasswordReset` handlers + Zod validation
- `apps/api/src/routes/auth.routes.ts` — 2 rutas nuevas + 2 rate limiters
### Frontend
- `apps/web/lib/api/auth.ts``requestPasswordReset()`, `confirmPasswordReset()`
- `apps/web/app/(auth)/login/page.tsx` — link "¿Olvidaste tu contraseña?"
- `apps/web/app/(auth)/forgot-password/page.tsx` (nuevo) — form email + vista de confirmación
- `apps/web/app/(auth)/reset-password/page.tsx` (nuevo) — form nueva password con confirmación, handling de token inválido/ausente
## Consideraciones operacionales
### SMTP en dev
Sin `SMTP_USER`/`SMTP_PASS` en `.env`, los emails se logean a consola del API. El link de reset aparece en el log con el token — útil para testing local sin infra de email configurada.
### Email en producción
Requiere SMTP configurado (Nodemailer + Gmail Workspace ya está en el stack). Template usa `baseTemplate` que incluye logo y footer consistente con los demás emails.
### Retención de tokens
Los tokens expirados o usados no se borran automáticamente. No es crítico (índice en `expiresAt` + FK Cascade al user). Para limpieza futura: cron que borre `WHERE expiresAt < NOW() - INTERVAL '30 days' OR usedAt IS NOT NULL AND usedAt < NOW() - INTERVAL '30 days'`.
### Password policy
Mínimo 8 caracteres. No validación de complejidad (mayúsculas, números, símbolos) porque:
- La policy complica sin agregar seguridad real contra ataques modernos (brute-force offline con hash → no importa la complejidad si es suficientemente larga)
- bcrypt 12 rounds + rate limit ya previenen ataques online
Si en el futuro se quiere endurecer, agregar validación en `authService.confirmPasswordReset` + mensaje claro al user.
## Pendientes
1. **UI "cambiar contraseña desde mi cuenta"** — user autenticado cambiando su propia password (sin flow de email). Comparte helper `hashPassword` + incrementa lógica similar. Pospuesto hasta que se implemente `jwt-revocation` (que también necesita endpoint de password change).
2. **Cron de limpieza** de tokens expirados/usados > 30 días.
3. **Notificación al completar** — email adicional "tu contraseña fue cambiada, si no fuiste tú contacta soporte". Previene takeover silencioso si hay compromiso.
4. **2FA para recuperación** — si el user tiene 2FA activado (feature futura), pedir código además del token del email antes de resetear.

View File

@@ -0,0 +1,284 @@
# Roles administrativos para staff interno de Horux 360
**Estado:****IMPLEMENTADO** (2026-04-14) — MVP operativo con 5 roles (admin, TI, support, sales, finance). La sección final "Implementación ejecutada" resume qué quedó, qué se pospuso, y cómo se resolvió el rol TI añadido durante la implementación.
## Problema
Actualmente, todo el poder administrativo transversal (ver todos los tenants, gestionar clientes, editar precios, emitir facturas manuales, consultar payments globales) está amarrado a un solo RFC hardcodeado:
```typescript
// packages/shared/src/constants/roles.ts
export const GLOBAL_ADMIN_RFC = 'HTS240708LJA';
export function isGlobalAdminRfc(tenantRfc, role) {
return role === 'owner' && tenantRfc === GLOBAL_ADMIN_RFC;
}
```
Esto tiene 3 limitaciones:
1. **Un solo nivel de privilegio.** O eres admin global (puedes todo) o no eres nadie transversal. No hay "soporte" (ver tenants pero no tocar facturación) ni "ventas" (crear clientes pero no tocar precios) ni "finanzas" (ver pagos, emitir facturas manuales, editar precios).
2. **Shared account o scalability issues.** Para sumar una segunda persona del equipo Horux 360 con poderes admin, hoy tiene que (a) compartir login con el primer admin, o (b) crearle un user adicional dentro del tenant `HTS240708LJA`. Esto funciona pero no escala y no permite permisos granulares.
3. **Hardcode disperso.** Cada vez que se agrega un endpoint admin, hay que recordar llamar `isGlobalAdmin()` o `requireGlobalAdmin()`. Fácil olvidar. Fácil de equivocarse en la condición (p.ej. la bug que encontramos con `tenant-selector` que disparaba `/tenants` para cualquier admin, no solo global).
## Propuesta
Introducir **roles de plataforma** — una dimensión ortogonal al rol per-tenant (`owner`, `cfo`, etc.).
### Schema propuesto
```prisma
enum PlatformRole {
platform_admin // Todo: precios, clientes, facturas, suscripciones, roles de staff
platform_support // Ver todos los tenants, resolver tickets, NO tocar facturación ni precios
platform_sales // Crear/editar tenants (onboarding), ver suscripciones, NO editar precios
platform_finance // Ver payments, emitir facturas manuales, editar precios, exportar reportes fiscales
}
model UserPlatformRole {
id Int @id @default(autoincrement())
userId String @map("user_id")
role PlatformRole
createdAt DateTime @default(now()) @map("created_at")
createdBy String? @map("created_by") // User.id de quien asignó (audit trail)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, role])
@@index([role])
@@map("user_platform_roles")
}
```
Usuarios de staff pueden tener 0, 1 o varios `UserPlatformRole`. `platform_admin` es el superrol (implícitamente cubre todos los otros).
### Autorización
Helpers nuevos en `apps/api/src/utils/platform-admin.ts` (reemplaza `global-admin.ts`):
```typescript
export async function hasPlatformRole(userId: string, role: PlatformRole): Promise<boolean> { ... }
export async function canManageTenants(userId: string): Promise<boolean> {
// platform_admin O platform_sales O platform_support
}
export async function canEditPrices(userId: string): Promise<boolean> {
// platform_admin O platform_finance
}
export async function canEmitInvoices(userId: string): Promise<boolean> {
// platform_admin O platform_finance
}
export async function isPlatformStaff(userId: string): Promise<boolean> {
// cualquier platform_* role
}
```
Middleware nuevo en routes:
```typescript
router.use(requirePlatformRole('platform_admin', 'platform_finance')); // OR
```
### Migración
**Paso 1:** crear la tabla via Prisma schema.
**Paso 2:** poblar con los users actuales del tenant HTS240708LJA:
```sql
INSERT INTO user_platform_roles (user_id, role, created_at)
SELECT u.id, 'platform_admin', NOW()
FROM users u
JOIN tenants t ON u.tenant_id = t.id
JOIN roles r ON u.rol_id = r.id
WHERE t.rfc = 'HTS240708LJA' AND r.nombre = 'owner'
ON CONFLICT DO NOTHING;
```
**Paso 3:** reemplazar `isGlobalAdmin` / `isGlobalAdminRfc` por llamadas a los nuevos helpers en todos los callsites (hay ~15-20 en backend, ~5-8 en frontend).
**Paso 4:** el frontend consume un endpoint nuevo `GET /me/platform-roles` que devuelve los roles del usuario autenticado. El frontend gatea el sidebar, páginas admin, etc. con eso.
### Conservar el "admin global" como concepto UX
El término "admin global" sobrevive en UI/copy/docs — pero internamente corresponde a `platform_admin`. `GLOBAL_ADMIN_RFC = 'HTS240708LJA'` queda como referencia histórica para el tenant dueño de la plataforma, pero el check ya no es por RFC sino por rol de plataforma.
### UI admin para gestionar staff
Página nueva `/admin/staff` (visible solo para `platform_admin`):
- Lista de users con sus `UserPlatformRole[]`
- Invitar nuevo staff (crear user + asignar roles)
- Editar roles existentes
- Quitar roles
- Audit log de cambios (opcional)
## Alcance
| Área | Impacto |
|------|---------|
| Schema | +1 tabla `user_platform_roles`, +1 enum `PlatformRole` |
| Seed | Migración idempotente que convierte users existentes del tenant HTS240708LJA a `platform_admin` |
| Backend utils | `global-admin.ts``platform-admin.ts` con helpers granulares |
| Backend routes | `requireGlobalAdmin` middleware reemplazado por `requirePlatformRole(...)` variante |
| Backend endpoints | Cada uno que hoy hace `requireGlobalAdmin` reclasificado: ¿admin? ¿finance? ¿support? ¿sales? |
| Frontend shared types | +`PlatformRole` enum compartido |
| Frontend store | +`platformRoles: PlatformRole[]` en auth-store |
| Frontend hook | `usePlatformRole(role)` para conditional render |
| Frontend pages | Sidebar/topnav, páginas admin actualmente con `isGlobalAdminRfc` usan el hook |
| Docs | CLAUDE.md sección roles, README changelog |
Estimación: ~2-3 días de implementación + testing.
## Riesgos y consideraciones
1. **Compatibilidad con JWT existentes.** Si los JWT hoy no llevan `platformRoles`, todos los que estén activos al momento de deploy van a fallar el gate. Alternativas: (a) incluir `platformRoles` en el claim al login, re-login forzado tras deploy; (b) resolver `platformRoles` en cada request desde BD (costo extra por request pero sin re-login).
2. **`isGlobalAdminRfc` usado en frontend.** El frontend hoy lee `user.tenantRfc` del store — no puede consultar el nuevo padrón desde ahí sin un round-trip al API. Patrón: al login incluir `platformRoles` en la response y guardarlo en el store.
3. **Superposición con rol per-tenant.** Un user puede ser `platform_finance` + `owner` de su tenant. Ambos roles aplican en contextos distintos — NO son excluyentes. El código debe checar cada uno por su lado.
4. **Custodia del rol `platform_admin`.** Inicialmente solo 1-2 personas. Protección contra bootstrap problem: si el único admin se queda sin acceso, hay que poder recrearlo via script (`scripts/grant-platform-admin.ts`).
## Decisiones que posponemos hasta implementar
- ¿Dashboard específico para cada rol de plataforma o uno unificado con sections filtrados?
- ¿Notificaciones de seguridad cuando se agrega/quita un rol de plataforma?
- ¿Expiración automática de roles (ej: "acceso temporal de soporte por 7 días")?
- ¿Rate limits específicos por rol de plataforma?
## Archivos que tocar cuando se implemente
- `apps/api/prisma/schema.prisma`
- `apps/api/prisma/seed.ts` (migración idempotente)
- `apps/api/src/utils/global-admin.ts` → renombrar a `platform-admin.ts`
- `apps/api/src/controllers/tenants.controller.ts`, `sat.controller.ts`, `subscription.controller.ts`, `usuarios.controller.ts`, `facturacion.controller.ts` — reclasificar permisos
- `apps/api/src/services/auth.service.ts` — incluir `platformRoles` en response del login
- `packages/shared/src/constants/roles.ts``PlatformRole` enum, helpers
- `packages/shared/src/types/auth.ts``JWTPayload` con `platformRoles?: PlatformRole[]`
- `apps/web/stores/auth-store.ts` — campo nuevo
- `apps/web/components/tenant-selector.tsx`, `sidebar*.tsx`, `admin/usuarios/page.tsx`, `clientes/page.tsx`, `configuracion/suscripcion/page.tsx` — reemplazar `isGlobalAdminRfc` por `hasPlatformRole(...)`
- Doc nuevo `docs/plans/YYYY-MM-DD-platform-admin-roles-implementation.md` documentando la ejecución
---
## Implementación ejecutada (2026-04-14)
### Lo que se construyó
**Schema:**
- Enum `PlatformRole` con **5 valores**: `platform_admin`, `platform_ti`, `platform_support`, `platform_sales`, `platform_finance`. Los primeros dos son **supersets** (implican todos los demás roles).
- Tabla `user_platform_roles` con `@unique([userId, role])`, FK a `User` con `onDelete: Cascade`, campo `createdBy` para audit trail.
**Helpers (`apps/api/src/utils/platform-admin.ts`):**
- `hasPlatformRole(userId, role)` — check específico, superset roles implican todo
- `hasAnyPlatformRole(userId, ...roles)` — OR de varios
- `canManageTenants`, `canEditPrices`, `canEmitInvoicesManual`, `isPlatformStaff` — atajos granulares
- `getPlatformRoles(userId)` — lista completa (para JWT)
- `isGlobalAdmin()` — compat que checa supersets en tabla, fallback a RFC si vacía
- Cache 5 min + `invalidatePlatformRolesCache(userId)`
- Constante interna `SUPERSET_ROLES = ['platform_admin', 'platform_ti']` centraliza el concepto "superset"
**Backward compat:**
- `utils/global-admin.ts` ahora re-exporta `isGlobalAdmin` de `platform-admin.ts` — todos los ~20 callsites existentes (controllers, services) siguen funcionando sin cambios.
- `isGlobalAdminRfc()` en shared acepta tercer parámetro opcional `platformRoles`. Los 8 callsites del frontend se actualizaron para pasar `user?.platformRoles`.
**JWT + Login:**
- `JWTPayload` y `UserInfo` en shared incluyen `platformRoles?: PlatformRole[]`.
- `auth.service.ts:login()` y `refreshTokens()` pueblan via `getPlatformRoles(userId)`.
**Seed — backfill idempotente:**
```sql
INSERT INTO user_platform_roles (user_id, role, created_at)
SELECT u.id, 'platform_admin'::"PlatformRole", NOW()
FROM users u JOIN tenants t ON u.tenant_id = t.id JOIN roles r ON u.rol_id = r.id
WHERE t.rfc = 'HTS240708LJA' AND r.nombre = 'owner'
ON CONFLICT (user_id, role) DO NOTHING
```
Owners del tenant dueño pasan automáticamente a `platform_admin`. Re-correr seed no duplica.
**Endpoints (`/api/platform-staff/*`):**
- `GET /` — lista staff con roles agrupados por user
- `GET /search?q=...` — busca candidatos por email/nombre
- `POST /grant` `{ userId, role }` — asigna rol (upsert idempotente)
- `POST /revoke` `{ userId, role }` — quita rol. Protección: **no te puedes quitar tu último rol superset** (admin O TI) — evita bootstrap problem
**UI `/admin/staff`:**
- Gate doble: frontend (`isGlobalAdminRfc` con `platformRoles`) + backend (`requirePlatformAdmin`)
- Tabla de staff con badges por rol (icon + color)
- Modal "Agregar staff" con búsqueda en vivo + selector de rol con preview
- Botón X en cada badge para revocar
- Card inferior con descripciones de los 5 roles
- Sidebar: nuevo item "Staff" con icono `Shield` (solo admin global)
**Audit:**
- `platform_role.granted` y `platform_role.revoked` se instrumentan automáticamente vía `auditFromReq`.
### Rol `platform_ti` — agregado durante la implementación
El plan original contemplaba solo 4 roles (admin/support/sales/finance). Durante la ejecución se añadió **`platform_ti`** con los mismos permisos que admin pero separación semántica (trazabilidad distinta en audit).
Decisión de diseño: en vez de tratar admin como el único superset, se introdujo la constante `SUPERSET_ROLES = ['platform_admin', 'platform_ti']` en backend y shared. Todos los checks de "¿es admin global?" ahora preguntan "¿tiene algún rol superset?". Esto permite:
- Agregar futuros supersets (ej: `platform_ceo`) actualizando solo esa constante
- Diferenciar en audit log: "quitó un rol = admin hizo" vs "quitó un rol = TI hizo"
- Protección "último superset" considera admin + TI juntos (no te quitas si serías el único con acceso transversal)
El rol TI aparece en la UI con badge gris (`slate`) e icono `Cpu`.
### Archivos tocados
**Backend:**
- `prisma/schema.prisma` — enum + tabla + relación inversa en User
- `prisma/seed.ts` — backfill
- `src/utils/platform-admin.ts` (nuevo) — helpers
- `src/utils/global-admin.ts` — shim de compat
- `src/services/auth.service.ts` — platformRoles en login + refresh
- `src/controllers/platform-staff.controller.ts` (nuevo)
- `src/routes/platform-staff.routes.ts` (nuevo)
- `src/app.ts` — registro de ruta
**Shared:**
- `src/types/auth.ts` — PlatformRole + JWTPayload + UserInfo
- `src/constants/roles.ts` — SUPERSET_ROLES + helpers extendidos
**Frontend:**
- `lib/api/platform-staff.ts` (nuevo)
- `lib/hooks/use-platform-staff.ts` (nuevo)
- `app/(dashboard)/admin/staff/page.tsx` (nuevo)
- `components/layouts/sidebar.tsx` — item Staff
- 8 callsites de `isGlobalAdminRfc` actualizados con 3er parámetro `platformRoles`
### Lo que NO se reclasificó (deliberadamente pospuesto)
Los endpoints existentes (`subscription.controller`, `tenants.controller`, `sat.controller`, `usuarios.controller`, etc.) siguen usando `isGlobalAdmin()` / `requireGlobalAdmin()` sin cambios. Funcionan igual que antes porque la lógica interna migró a tabla. **Siguiente iteración:** reclasificar caso por caso:
| Endpoint | Debería requerir |
|----------|-----------------|
| `subscription.controller.ts:updatePlanPrice` | `canEditPrices` (admin + finance + TI) |
| `subscription.controller.ts:markAsPaid` | `canEmitInvoicesManual` (admin + finance + TI) |
| `subscription.controller.ts:getAllSubscriptions` | cualquier platform staff (read-only) |
| `tenants.controller.ts:create/update/delete` | `canManageTenants` (admin + sales + support + TI) |
| `sat.controller.ts:cronInfo/runCron` | `hasPlatformRole('platform_admin')` o TI (operacional) |
Esto se hará en un pase separado — no bloquea esta entrega, y diferirlo permite revisar cada caso con cuidado.
### Verificación manual post-deploy
```
1. Re-logueate — ahora el JWT incluye platformRoles
2. Admin global (si corriste bootstrap:admin-global o heredaste el user):
- Ve sidebar con "Staff" + "Audit Log"
- Navega a /admin/staff — ve su propio user con badge Admin
- Busca otro user, asigna role TI → ahora ese user tendrá acceso como admin
- Intenta quitarte tu propio Admin → bloqueado si eres el último superset
3. User sin platform roles — no ve Staff ni Audit Log, ve "Acceso restringido" si entra directo a la URL
4. Audit log de la acción (grant/revoke) aparece con action = platform_role.granted/revoked
```
### Pendientes para siguiente iteración
1. **Reclasificar endpoints existentes** por `canX()` granular (tabla arriba).
2. **UI para quitar al usuario completo de staff** (ahora solo se quitan roles individuales — si quitas todos, el user sigue existiendo como tenant user normal).
3. **Expiración de roles** (ej: acceso temporal de soporte por 7 días) — requiere campo `expiresAt` en la tabla + cron de limpieza.
4. **Agregar rol a user sin tenant activo** — caso edge: staff de Horux 360 que no tiene tenant asignado (solo existe para propósitos administrativos). Hoy la UI requiere que el user exista como miembro de algún tenant.
5. **Renombrar funciones compat** — cuando los endpoints se reclasifiquen, `isGlobalAdmin` puede removerse completamente.

View File

@@ -0,0 +1,161 @@
# PostgreSQL Point-in-Time Recovery (PITR)
**Estado:** PENDIENTE — no implementado. Plan capturado para ejecución posterior. Es una tarea de infraestructura, no de código.
## Problema
Hoy el backup es un dump diario a las 01:00 AM (`scripts/backup.sh`). Si el disco o la BD fallan a las 23:59, **se pierden hasta 24 horas** de datos:
- CFDIs sincronizados del SAT
- CFDIs emitidos vía Facturapi (críticos — ya se cobraron)
- Payments registrados
- Cambios de suscripción
- Alertas resueltas
- Cualquier configuración modificada ese día
Para datos fiscales esto es **legalmente problemático**. El SAT tiene plazos de declaración; perder un día completo puede significar multas o rechazos.
PITR permite recovery a cualquier momento en el pasado (granularidad minutos/segundos) combinando:
- **Base backup** periódico (ej: diario)
- **WAL (Write-Ahead Log) archiving** continuo
## Propuesta
Migrar de `pg_dump` diario a **pgBackRest** o **wal-g** con archivos WAL subidos a almacenamiento externo.
### Stack recomendado
**Opción A: pgBackRest** (más robusto, más maduro)
- Pros: backups incrementales, encryption, verificación de integridad, restoration testing
- Cons: más complejo de setup, más moving parts
- Docs: https://pgbackrest.org/
**Opción B: wal-g** (más simple, cloud-native)
- Pros: setup más rápido, buen fit para S3/GCS
- Cons: menos features que pgBackRest
- Docs: https://github.com/wal-g/wal-g
**Recomendación:** wal-g si ya usamos/planeamos usar S3; pgBackRest si queremos más control y el almacenamiento es NFS/disco dedicado.
### Almacenamiento de WAL archives
Opciones, de mejor a peor:
1. **S3 bucket** (AWS o compatible: Backblaze B2, Wasabi, DigitalOcean Spaces) — off-site, durable, ~$5/mes para este volumen
2. **Servidor remoto dedicado** via SSH (rsync/SFTP) — más lento, más control
3. **Disco adicional local** — NO recomendado (falla del server = pierde backup)
### Configuración Postgres
```ini
# postgresql.conf
wal_level = replica
archive_mode = on
archive_command = 'wal-g wal-push %p' # O equivalente pgBackRest
archive_timeout = 300 # Force a WAL segment at least every 5 min
```
### Frequency
- **Base backup:** 1x / semana (domingos 02:00 AM)
- **WAL archive:** continuo (cada WAL de 16MB o cada 5 min, lo que ocurra primero)
Recuperación: base del último domingo + WAL segments hasta el momento T → reconstruye exactamente el estado en T.
### RPO / RTO target
- **RPO (Recovery Point Objective):** 5 minutos de pérdida máxima (por el `archive_timeout`)
- **RTO (Recovery Time Objective):** 30-60 min (download base backup + replay WAL + switch)
vs. hoy: RPO 24h, RTO ~1h con el dump.
## Pasos para implementar
1. **Elegir destino de WAL** (recomiendo Backblaze B2 o Wasabi por precio / durabilidad)
2. **Instalar wal-g** en el server de Postgres
3. **Configurar credenciales** (env vars para S3, o archivo de config)
4. **Modificar `postgresql.conf`** con `archive_mode`, `archive_command`, etc. + reiniciar Postgres
5. **Tomar primer base backup** manual con `wal-g backup-push`
6. **Crontab:** base backup semanal, monitoreo
7. **Script de restore** documentado y probado (¡sin probar no es backup!)
8. **Monitoreo:** alerta si no hay WAL push en >10 min (indica que archive falla)
9. **Runbook de disaster recovery** — pasos exactos para restaurar
## Testing de restore
Componente crítico — sin haberlo probado, no hay backup real.
Ritual cada trimestre:
1. Elegir timestamp arbitrario en el pasado (ej: hace 3 días a las 14:00)
2. Spawn Postgres nuevo en máquina de staging
3. `wal-g backup-fetch` del backup base
4. `wal-g wal-fetch` del WAL hasta el timestamp
5. Verificar que la BD está consistente + data esperada está presente
6. Documentar tiempo real de restore (ajusta RTO estimado)
## Consideraciones extra
### Encriptación
wal-g soporta encryption de WAL + backups con AWS KMS o pgp. **Recomendado para datos fiscales** — incluso si alguien compromete el bucket S3, no puede leer los archivos.
### Retention
- Backups/WAL de últimos 30 días: retain
- 31-90 días: 1 backup base semanal
- 90+ días: 1 backup base mensual (para compliance SAT de 5 años)
Configurable en wal-g via `WALG_DELTA_MAX_STEPS` y policy manual.
### Costo estimado
Volumen actual (estimado):
- Base backup: ~1-5 GB comprimido (depende de CFDIs acumulados)
- WAL diario: ~100-500 MB/día
S3 cost (Backblaze B2 ~$6/TB/mes):
- 30 días hot retention ≈ 30 GB ≈ $0.20/mes
- 5 años retention (archivos mensuales) ≈ 60 GB ≈ $0.40/mes
**Total estimado: ~$1-5/mes** dependiendo del vendor. Peanuts comparado con lo que protege.
## Alcance
| Tarea | Estimación |
|-------|-----------|
| Elegir vendor + crear bucket | 30 min |
| Instalar + configurar wal-g | 2 h |
| Configurar Postgres + reiniciar | 1 h |
| Primer base backup + verificar | 1 h |
| Script de restore + primer drill | 2 h |
| Crontab + monitoreo básico | 1 h |
| Runbook de DR escrito | 2 h |
| **Total** | **1-2 días** |
## Riesgos
1. **Archive command backup pressure:** si S3 está lento o hay ratelimit, `archive_command` se enchola y Postgres se satura. Mitigación: usar `archive_library` en Postgres 15+ con async archiving.
2. **Reinicio de Postgres requerido:** cambiar `wal_level` o `archive_mode` requiere restart. Coordinar con deploy window.
3. **Credenciales de S3 en server:** si server se compromete, las keys permiten borrar backups. Mitigación: usar IAM policy con "write only" (no delete) al bucket.
4. **Billing de S3:** vigilar primeros meses por si el volumen es mayor al estimado.
5. **Testing real importante:** sin drill de restore, es teatro de seguridad.
## Out of scope
- Replicación streaming (hot standby) — mucho más caro, diferente problem (HA vs DR)
- Multi-region — hasta que haya tráfico significativo fuera de CDMX
- Backup de BDs tenant individualmente — las BDs tenant viven en el mismo cluster Postgres, backup a nivel cluster las cubre todas
## Archivos a tocar
- `scripts/backup.sh` — deprecar o convertir en wrapper de wal-g
- `deploy/wal-g.conf` (nuevo) — config de wal-g
- `deploy/postgres/postgresql.conf` snippet (nuevo) — cambios a aplicar
- `docs/architecture/disaster-recovery.md` (nuevo) — runbook
- `docs/architecture/deployment.md` — actualizar sección de backups
## Relación con otros planes
- **`2026-04-14-audit-log.md`:** si un evento se perdió en la ventana de PITR, el audit log en S3 puede complementar la reconstrucción.
- **Infra futura (HA / multi-region):** PITR es precursor lógico — una vez que tenemos WAL archiving, agregar una standby que consuma esos WAL es incremental.

View File

@@ -0,0 +1,295 @@
# Expansión de rate limiting más allá de auth
**Estado:****IMPLEMENTADO** (2026-04-14) — middleware `rate-limit.middleware.ts` con 4 tiers aplicados a endpoints costosos. Admin global (superset platform_admin/platform_ti) exento. Ver sección final "Implementación ejecutada".
## Problema
Hoy `express-rate-limit` está configurado **solo en rutas de autenticación**:
- `/auth/login`: 10 intentos / 15 min
- `/auth/register`: 3 / hora
El resto del API acepta cualquier volumen de requests de usuarios autenticados. Vectores posibles:
- **Usuario malicioso autenticado** exporta CFDIs en loop (endpoint `/cfdi` con filtros, genera carga pesada en BD)
- **Script automatizado** bombardea `/dashboard` para alguna razón (scraping, prueba de carga no autorizada)
- **Cliente competidor** con login legítimo intenta mapear la BD haciendo queries masivas
- **Cron accidentalmente mal configurado** por un integrador golpea `/api/cfdi/sync-manual` cada segundo
A nivel de operatividad no es un ataque catastrófico (el API no se cae), pero:
- Degrada performance para otros usuarios
- Cuesta $ en cómputo
- En caso extremo, satura Facturapi / MP quota
## Propuesta
Aplicar rate limiting por-endpoint con tiers distintos según sensibilidad / costo computacional.
### Tiers propuestos
| Tier | Rate | Uso |
|------|------|-----|
| **strict** | 10 req / hora | Operaciones costosas o sensibles (SAT sync manual, export bulk, emisión facturas) |
| **normal** | 100 req / 15 min | APIs de negocio típicas (dashboard, cfdi list, reportes) |
| **relaxed** | 500 req / 15 min | Endpoints de lectura simple (catálogos, metadata) |
| **auth** | ya existe | login/register (10 y 3) |
### Aplicación por route
```typescript
// apps/api/src/middlewares/rate-limit.middleware.ts
import rateLimit from 'express-rate-limit';
export const strictLimit = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hora
max: 10,
keyGenerator: (req) => req.user?.userId || req.ip, // Por user autenticado, no por IP
message: { error: 'Demasiadas solicitudes. Intenta de nuevo en una hora.' },
standardHeaders: true,
legacyHeaders: false,
});
export const normalLimit = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
keyGenerator: (req) => req.user?.userId || req.ip,
});
export const relaxedLimit = rateLimit({
windowMs: 15 * 60 * 1000,
max: 500,
keyGenerator: (req) => req.user?.userId || req.ip,
});
```
Clave importante: `keyGenerator` basado en `userId`, no IP. Usuarios legítimos detrás de NAT (ej: oficina con 50 contadores) compartirían IP y se bloquearían entre sí.
### Endpoints críticos para `strictLimit`
| Endpoint | Razón |
|----------|-------|
| `POST /sat/sync/manual` | Syncs tardan minutos, son pesados |
| `POST /sat/sync/custom-range` | Ídem con rango personalizado |
| `POST /facturacion/emitir` | Cada emisión cuesta un timbre (dinero) |
| `POST /facturacion/cancelar` | Facturapi API + SAT interaction |
| `POST /cfdi/bulk` (carga masiva) | Procesa hasta 50MB por request |
| `POST /documentos/opiniones/consultar` | Lanza Playwright (cómputo pesado) |
| `GET /cfdi/export` (si existe) | Excel generation puede ser costoso |
| `POST /auth/password-change` (futuro) | Prevenir brute force de password actual |
| `POST /subscriptions/me/upgrade` | Crea MP preference (side effect en tercero) |
### Endpoints para `normalLimit`
| Endpoint | Razón |
|----------|-------|
| `GET /dashboard` | Query pesada (aggregations) |
| `GET /cfdi` | List con filtros |
| `GET /reportes/*` | Reportes custom |
| `GET /impuestos/*` | Cálculos fiscales |
| `POST /subscriptions/me/subscribe` | Creates preapproval |
| `POST /subscriptions/me/cancel` | |
| Default para todos los demás autenticados |
### Endpoints para `relaxedLimit`
| Endpoint | Razón |
|----------|-------|
| `GET /catalogos/*` (claves SAT, unidades, etc.) | Solo lectura, datos pequeños |
| `GET /regimenes` | Lista fija |
| `GET /subscriptions/plans` | Solo 8 filas |
### Headers de respuesta
Los headers `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` se envían automáticamente con `standardHeaders: true`. El frontend puede leerlos y mostrar warnings preventivos antes del hard block.
### Error handling en frontend
Cuando el backend devuelve 429:
```typescript
// apps/web/lib/api/client.ts
if (error.response?.status === 429) {
toast.error('Demasiadas solicitudes. Espera unos minutos e intenta de nuevo.');
}
```
Si es una acción crítica (emisión factura, pago), mostrar mensaje más específico.
### Whitelist para admin global
El admin global probablemente necesita bypassar los límites para operaciones administrativas masivas. Opción:
```typescript
const strictLimit = rateLimit({
...,
skip: async (req) => {
if (!req.user) return false;
return await isGlobalAdmin(req.user.tenantId, req.user.role);
},
});
```
Trade-off: si la cuenta admin global se compromete, sin rate limit. Acceptable dado que el admin global tiene poder total de todas formas.
## Alcance
| Tarea | Estimación |
|-------|-----------|
| Crear middleware `rate-limit.middleware.ts` con 3 tiers | 1 h |
| Aplicar middleware a endpoints críticos (~15) | 2 h |
| Frontend toast handler para 429 | 30 min |
| Tests manuales (curl loop verificando block) | 1 h |
| Docs de límites en API reference | 30 min |
| **Total** | **~medio día** |
## Riesgos
1. **Falsos positivos en dev/testing.** Al correr tests automatizados con sesión real, se hittean los límites. Solución: modo dev con límites muy permisivos via env var, o usar el skip.
2. **Contadores de límite viven en memoria por worker.** Con PM2 cluster mode, un usuario distribuye entre N workers y efectivamente tiene N× el límite. Para MVP aceptable; si crece, migrar a Redis store (`rate-limit-redis`).
3. **Rate limit bypassable si se rota tenantId con X-View-Tenant.** El admin global puede hacerlo pero ya está en whitelist. Otros roles no deberían tener esa capacidad.
4. **Si se integra IA (Lolita) con endpoints del API, puede necesitar tier propio.** Revisar cuando se implemente.
## Testing
Script simple de verificación:
```bash
# Ejecutar desde terminal (requiere token válido)
TOKEN=<jwt>
for i in {1..15}; do
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer $TOKEN" \
http://localhost:4000/api/sat/sync/manual -X POST
done
# Esperado: primeros 10 → 200/403, #11+ → 429
```
## Archivos a tocar
- `apps/api/src/middlewares/rate-limit.middleware.ts` — nuevo
- `apps/api/src/routes/*.routes.ts` — aplicar middleware en rutas elegidas
- `apps/web/lib/api/client.ts` — handler 429
- `docs/architecture/api-reference.md` — documentar límites
## Relación con otros planes
- **`2026-04-14-audit-log.md`:** rate limits pueden dispararse por abuso; audit de 429 recurrentes indica potencial atacante.
- **`2026-04-14-jwt-revocation.md`:** si rate limit detecta patrón de brute force, puede disparar tokenVersion invalidación.
---
## Implementación ejecutada (2026-04-14)
### Decisión de tiers (vs plan original)
El plan proponía 3 tiers (strict/normal/relaxed). Durante la ejecución se agregó un **4º tier más restrictivo (`veryStrict`: 2/día)** para operaciones extremadamente costosas que disparan jobs largos con terceros:
| Tier | Rate | Uso |
|------|------|-----|
| **veryStrict** | 2 / 24h | SAT sync manual, opinión de cumplimiento |
| **strict** | 10 / 1h | Emisión/cancelación factura, CFDI bulk, subs (subscribe/change/upgrade), password-change |
| **normal** | 100 / 15m | Dashboard, reportes, impuestos |
| **relaxed** | 500 / 15m | Catálogos SAT, regímenes |
Razón del 4º tier: syncs del SAT y scraping de Opinión de Cumplimiento (Playwright) tardan **minutos** cada uno y golpean APIs externas. 10/hora era demasiado permisivo; 2/día cubre el uso legítimo (usuario refresca datos 1-2 veces al día) y bloquea loops accidentales o maliciosos de manera contundente.
### Middleware (`src/middlewares/rate-limit.middleware.ts`)
- Todas las keys se generan por `req.user.userId` (fallback a `req.ip` para anónimo). **No** por IP → oficinas con NAT compartido no se bloquean entre sí.
- `skip: skipForGlobalAdmin` vía `hasPlatformRole(userId, 'platform_admin')` — retorna true para supersets (admin o TI). Otros platform roles (support/sales/finance) sí respetan rate limits.
- `standardHeaders: true` → el cliente recibe `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`.
- Mensajes en español con duración específica del tier ("intenta mañana" para veryStrict, "intenta en una hora" para strict).
### Dónde se aplica
**Aplicado a rutas específicas (POST costosos):**
```
POST /api/sat/sync → veryStrictLimit
POST /api/documentos/opiniones/consultar → veryStrictLimit
POST /api/facturacion/emitir → strictLimit
POST /api/facturacion/cancelar/:uuid → strictLimit
POST /api/cfdi/bulk → strictLimit
POST /api/subscriptions/me/subscribe → strictLimit
POST /api/subscriptions/me/change → strictLimit
POST /api/subscriptions/me/upgrade → strictLimit
POST /api/auth/password-change → strictLimit
```
**Aplicado a routers completos (GET principalmente):**
```
/api/dashboard → normalLimit
/api/reportes → normalLimit
/api/impuestos → normalLimit
/api/catalogos → relaxedLimit
/api/regimenes → relaxedLimit
```
**Deliberadamente NO limitados** (uso infrecuente o crítico no-abusable): `/auth/login` y `/auth/register` ya tenían sus propios limiters específicos; `/cfdi` GET, `/bancos`, `/calendario`, `/alertas`, `/conciliacion`, `/usuarios`, `/tenants`, `/fiel`, `/webhooks`, `/audit-log`, `/platform-staff` quedan sin tope explícito — se pueden agregar después si se observa abuso.
### Frontend (`apps/web/lib/api/client.ts`)
Interceptor nuevo en el axios response handler:
- Detecta HTTP 429 antes del bloque 401 existente
- Preserva el `message` del backend para que los `try/catch` existentes lo muestren (ya usan `err?.response?.data?.message`)
- Fallback: `console.warn` con el mensaje si nadie maneja el error explícitamente
- Flag `_rateLimitHandled` en el `originalRequest` evita loggear dos veces si el caller re-throws
Se consideró agregar un toast global pero el proyecto no usa librería de toasts; agregar Sonner/react-hot-toast sólo para esto sería sobre-ingeniería. Los call sites críticos (emisión, pago, sync SAT) ya tienen sus propios alert/try-catch.
### Behavior con admin global
Verificado: el usuario `admin@horux360.com` (platform_admin) pasa el `skip` y no es rate-limited. Backfill útil cuando necesite hacer operaciones masivas sin trabas (corrección manual, cargas de datos, etc.). Otros platform staff (support/sales/finance) sí respetan los límites.
### Archivos tocados
**Backend:**
- `src/middlewares/rate-limit.middleware.ts` (nuevo) — 4 tiers exportados
- `src/routes/sat.routes.ts` — veryStrictLimit en `/sync`
- `src/routes/documentos.routes.ts` — veryStrictLimit en `/opiniones/consultar`
- `src/routes/facturacion.routes.ts` — strictLimit en `/emitir` y `/cancelar`
- `src/routes/cfdi.routes.ts` — strictLimit en `/bulk`
- `src/routes/subscription.routes.ts` — strictLimit en `/me/{subscribe,change,upgrade}`
- `src/routes/auth.routes.ts` — strictLimit en `/password-change`
- `src/routes/dashboard.routes.ts` — normalLimit (router.use)
- `src/routes/reportes.routes.ts` — normalLimit (router.use)
- `src/routes/impuestos.routes.ts` — normalLimit (router.use)
- `src/routes/catalogos.routes.ts` — relaxedLimit (router.use)
- `src/routes/regimen.routes.ts` — relaxedLimit (router.use)
**Frontend:**
- `lib/api/client.ts` — interceptor 429
### Verificación manual
```bash
# Con admin global logueado → NO debería bloquearse
TOKEN=<jwt del admin global>
for i in {1..5}; do
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer $TOKEN" \
http://localhost:4000/api/sat/sync -X POST
done
# Esperado: todas 200/400 (el skip exime al admin)
# Con user normal → debería bloquearse al 3er intento
TOKEN=<jwt de user sin roles de plataforma>
for i in {1..5}; do
curl -s -w "%{http_code}\n" \
-H "Authorization: Bearer $TOKEN" \
http://localhost:4000/api/sat/sync -X POST
done
# Esperado: 2 pasan, #3+ retornan 429 con message en español
```
### Caveats conocidos
1. **Counters in-memory por worker.** Con PM2 cluster mode, un user distribuye entre N workers → efectivamente N× el límite. Para tier veryStrict esto importa más (2 × N_workers = hasta 8-16 syncs/día en cluster de 4-8). Si importa, migrar a `rate-limit-redis`. Por ahora MVP acepta la holgura.
2. **`X-View-Tenant` no bypassa rate limit.** El admin global al impersonar sigue exento (por su propio userId superset); otros roles no pueden usar X-View-Tenant así que no hay vector de escape.
3. **Dev/testing sin `.env` especial.** Scripts de test automatizados que corran contra un usuario normal hittearán límites. Solución: usar el admin global (ya exento) o añadir env var `DISABLE_RATE_LIMITS=true` en el futuro si se necesita.
### Pendientes / futuro
1. **Migrar a Redis store** cuando se escale a PM2 cluster con >2 workers o múltiples nodos.
2. **Aplicar limits a endpoints heredados sin tope** (`/cfdi` GET con filtros pesados, exports Excel) si se detecta abuso en audit log.
3. **Tier específico para Lolita** (agente IA) cuando se integre con endpoints del API — probablemente merece su propio limiter (más generoso, porque el agente consulta muchos endpoints en secuencia).
4. **UI visible de rate-limit hit** (toast/banner) cuando se adopte una librería de toasts.

View File

@@ -0,0 +1,95 @@
# Reactivar suscripción cancelada dentro del período pagado
## Resumen
Cuando un cliente cancela su suscripción, el acceso continúa hasta `currentPeriodEnd` (política existente). Antes, durante esa ventana no había manera de revertir la cancelación — el cliente tenía que esperar a que expirara para re-contratar. Ahora puede reactivarla con un botón desde `/configuracion/suscripcion`.
## Motivación
UX real: cliente cancela por error o cambia de opinión, quiere volver. Forzar que espere a fin de período + re-contratar desde cero es fricción innecesaria (y posible pérdida si se van con la competencia en esa ventana).
## Mecánica MercadoPago
Un preapproval cancelado en MP es **terminal** — no se puede reactivar. Reactivar = crear un preapproval **nuevo** con los mismos parámetros (plan, amount, frequency) y `auto_recurring.start_date` apuntando al final del período actual para evitar doble cobro.
```
T0 (hoy): cancelled, currentPeriodEnd = T0+15d
T0: Usuario click "Reactivar"
→ backend crea NUEVO preapproval
reason: "Reactivación Plan X (frequency) - Tenant"
amount: mismo
frequency: mismo
start_date: T0+15d ← clave para no cobrar doble
→ Subscription.status = 'pending'
→ Subscription.mpPreapprovalId = nuevo ID
→ Retorna paymentUrl
T0..T0+14d: Usuario sigue teniendo acceso (período ya pagado)
T0+15d: MP ejecuta primer cobro → webhook → status 'authorized'
T0+15d+30d (o +365d): siguiente cobro
```
## Reglas y validaciones
| Condición | Resultado |
|-----------|-----------|
| Tenant no tiene suscripción cancelada | 400 "No hay suscripción cancelada para reactivar" |
| Suscripción cancelled pero `currentPeriodEnd` ya venció | 400 "El período pagado ya venció — contrata un nuevo plan desde el selector" (UI ya lo sabe: `isCancelledExpired` muestra el picker) |
| Plan cancelado era custom | 400 "Reactivación de plan custom requiere coordinación con el admin global" |
| MP no configurado | 503 con mensaje claro |
| MP rechaza (token inválido) | 503 "MercadoPago rechazó la solicitud" |
| Reactivación exitosa | Limpia `pendingPlan`, `upgradeTargetPlan`, etc. El estado queda limpio como una sub recién contratada |
## Archivos
### Backend
- `apps/api/src/services/payment/mercadopago.service.ts``createPreapproval` ahora acepta `startDate?: Date` opcional (traduce a `auto_recurring.start_date` ISO si es fecha futura; MP rechaza fechas pasadas)
- `apps/api/src/services/payment/subscription.service.ts``reactivateSubscription({ tenantId, payerEmail })`
- `apps/api/src/controllers/subscription.controller.ts``reactivateMe` handler
- `apps/api/src/routes/subscription.routes.ts``POST /api/subscriptions/me/reactivate`
### Frontend
- `apps/web/lib/api/subscription.ts``reactivateMe()`
- `apps/web/lib/hooks/use-subscription.ts``useReactivateMe()`
- `apps/web/app/(dashboard)/configuracion/suscripcion/page.tsx`:
- Handler `handleReactivate` — llama mutation, abre `paymentUrl` en nueva pestaña
- Banner naranja `isCancelledInPeriod` incluye botón "Reactivar suscripción"
- Card "Tu Suscripción" muestra "Reactivar" como acción principal (primary) en vez de "Cancelar"
- Limpia la condición redundante `(isActive || ... || isCancelledInPeriod) && !isCancelledInPeriod`
## Flow UI
```
Estado: isCancelledInPeriod (status='cancelled', currentPeriodEnd en el futuro)
Banner naranja "Suscripción cancelada — tienes acceso hasta X"
+ botón inline "Reactivar suscripción"
Card "Tu Suscripción" con botón primary "Reactivar suscripción"
Click → POST /me/reactivate → { paymentUrl }
window.open(paymentUrl, '_blank') → MP checkout
Usuario autoriza en MP
Webhook `subscription_preapproval` → status='authorized'
UI refresca (React Query invalidation) → muestra "Activa"
```
## Decisiones descartadas
### Reactivar con cambio de plan en el mismo flow
**Tentación:** mostrar modal con picker en vez de botón "Reactivar" plano.
**Por qué no:** complica el flujo (reactivar + cambiar plan = dos semánticas mezcladas). MVP = simple: reactiva con mismo plan. Si quiere otro plan, usa "Cambiar plan" después de reactivar (ya existe).
### "Undo cancel" retentivo (sin re-autorización de MP)
**Tentación:** si la cancelación fue hace < 24h y el preapproval MP aún está `paused` (no `cancelled`), teóricamente podríamos re-activarlo sin crear uno nuevo.
**Por qué no:** `cancelSubscription` llama `cancelPreapproval` inmediatamente → MP lo marca `cancelled` terminal. Recuperarlo requeriría: (a) no cancelar el preapproval al cancelar, (b) cron que lo cancele 7 días después. Complejidad innecesaria para un caso marginal.
## Pendientes
1. **Email confirmación de reactivación** — análogo al de cancelación, para cerrar el loop de comunicación.
2. **Métrica de reactivaciones** — útil para el admin global: cuántos clientes cancelan pero reactivan antes del fin de período. Indicador de UX y de churn real.

View File

@@ -0,0 +1,126 @@
# Prevención de abuso de prueba gratuita por RFC
## Resumen
Bloqueo persistente de `startTrial` por RFC: cada RFC tiene derecho a **una sola** prueba gratuita de 30 días en toda la vida del sistema, independiente del ciclo de vida del Tenant (borrado, soft-delete, recreación).
## Motivación
Vector de abuso detectado: alguien registra empresa (Tenant A con RFC X), consume trial, cancela o borra. Crea nueva empresa (Tenant B intenta mismo RFC X). Sin este fix, si el Tenant A desapareciera, el flag `tenant.trialEndsAt` también, y el Tenant B obtendría otro trial gratis.
**Escenarios que esto bloquea:**
- Borrado manual + recreación del tenant con mismo RFC
- Soft-delete (`active=false`) + creación de otro con mismo RFC (si ese flujo se permitiera)
- Race condition: dos requests simultáneas de `startTrial` para el mismo RFC
**Escenarios que esto NO bloquea (fuera de scope):**
- Persona con varias empresas legales (RFCs distintos) — cada RFC legítimamente recibe su trial
- Mismo RFC + diferente email/admin — es el mismo RFC, mismo trial
- Detección de "misma persona física detrás de múltiples RFCs" — requiere KYC
## Mecanismo
Tabla nueva en BD central:
```prisma
model TrialUsage {
id Int @id @default(autoincrement())
rfc String @unique @db.VarChar(13)
tenantId String? @map("tenant_id")
startedAt DateTime @default(now()) @map("started_at")
@@map("trial_usages")
}
```
Cuando `startTrial(tenantId, plan, frequency)` corre:
1. Carga `tenant.rfc`, lo normaliza a uppercase
2. `SELECT FROM trial_usages WHERE rfc = <normalized>` — si existe, 400 con mensaje explícito
3. Dentro de la transacción que crea la Subscription:
- `UPDATE tenants SET trialEndsAt=..., plan=...`
- `INSERT INTO trial_usages (rfc, tenantId, startedAt)` — unique constraint previene race
- `INSERT INTO subscriptions (...)`
Si cualquier paso falla, la transacción hace rollback y el padrón queda sin la marca (consistente con la no-creación del trial).
## Por qué @unique en `rfc`
- **Race protection:** dos procesos podrían leer `trial_usages` simultáneamente y ambos ver "no existe" → ambos intentarían insert. El constraint hace que la segunda inserción falle con violación de unique → propaga como error al request, usuario ve que alguien más está procesando.
- **Invariante de datos:** imposible tener dos rows para el mismo RFC. Simplifica la lógica de lectura (solo un resultado posible).
## Normalización a uppercase
`HTS240708LJA` y `hts240708lja` son el mismo RFC legalmente. Guardando todo uppercase en `trial_usages.rfc`:
- Evita que un bug de normalización cree dos marcas para el mismo RFC real
- El query de check usa `tenant.rfc.toUpperCase()` antes de `findUnique`
- El `insert` también normaliza
## Backfill de tenants existentes
El seed ejecuta idempotentemente:
```sql
INSERT INTO trial_usages (rfc, tenant_id, started_at)
SELECT UPPER(rfc), id, COALESCE(created_at, NOW())
FROM tenants
WHERE trial_ends_at IS NOT NULL
ON CONFLICT (rfc) DO NOTHING
```
Tenants que ya consumieron trial antes de esta feature quedan registrados. Re-correr seed no duplica (ON CONFLICT DO NOTHING).
## Tolerancia al borrado de tenant
El campo `tenantId` en `trial_usages` es **nullable** a propósito:
- Si el tenant se hard-delete (por ejemplo, GDPR request), el RFC sigue bloqueado aunque `tenant_id` quede huérfano (null si agregas FK con ON DELETE SET NULL; hoy sin FK para máxima resiliencia)
- Histórico: útil saber qué tenant original consumió el trial (traceability) aunque ya no exista
## Mensajes de error
Cuando el RFC ya consumió trial:
```
"El RFC HTS240708LJA ya consumió su prueba gratuita. Cada RFC tiene derecho
a una sola prueba de 30 días. Contrata un plan para continuar."
```
El frontend lo propaga tal cual al usuario vía `err.response.data.message`.
## Archivos
- `apps/api/prisma/schema.prisma` — modelo `TrialUsage`
- `apps/api/prisma/seed.ts` — backfill idempotente post-rename-roles
- `apps/api/src/services/payment/subscription.service.ts` — gate + insert en `startTrial`
## Deploy
```bash
cd apps/api
pnpm prisma db push # crea trial_usages
pnpm db:seed # idempotente — renombra roles, rellena plan_prices Y hace backfill trial_usages
```
## Decisiones descartadas
### Email unique dedupe
**Tentación:** bloquear también por email del admin.
**Por qué no:** emails son fáciles de generar (aliasing con Gmail `+tag`). RFC es legalmente único por empresa. Además, bloquear por email castiga casos legítimos (admin que rotó su email).
### Device fingerprinting
**Tentación:** rastrear navegador/IP para detectar mismo usuario creando múltiples tenants.
**Por qué no:** falsos positivos altos (oficina con 50 empleados compartiendo IP). Requiere stack adicional (fingerprint library, GDPR compliance). Scope muy distinto al fix simple por RFC.
### Foreign key `tenantId → Tenant.id ON DELETE SET NULL`
**Tentación:** referencial integrity explícita.
**Por qué no:** el punto de `trial_usages` es **sobrevivir** al borrado del tenant. FK sin `ON DELETE SET NULL` bloquearía el borrado del tenant; con `SET NULL` funcionaría pero agrega complejidad de migración. Por ahora sin FK — el RFC es el identificador funcional.
## Pendientes
1. **UI admin global para ver/resetear trial_usages** — caso de soporte: cliente legítimo con RFC nuevo obtenido por error humano. Hoy solo se puede vía SQL directo.
2. **Métricas de intentos bloqueados** — log los RFCs que intentaron re-consumir trial; útil para detectar patrones de abuso sistemático.
3. **Endpoint `GET /trial-usages` (admin global)** — listado para auditoría.

View File

@@ -0,0 +1,266 @@
# Sesión 2026-04-18 / 2026-04-19 — Fixes y Features
## Resumen
Sesión intensiva de correcciones del pivot Tenant→Contribuyente, mejoras de UX, nuevas funcionalidades (declaraciones, carteras, ISR mensual), y correcciones fiscales.
---
## 1. Página cancelaciones-periodo-anterior (NUEVA)
**Problema:** La alerta "Facturas de periodos anteriores canceladas" generaba un link a `/alertas/cancelaciones-periodo-anterior` que no existía (404).
**Solución:** Creada la página drilldown similar a `/alertas/cancelaciones`, mostrando CFDIs cuya `fecha_cancelacion` cae en el mes actual pero `fecha_emision` es de meses anteriores.
**Archivos:**
- `apps/web/app/(dashboard)/alertas/cancelaciones-periodo-anterior/page.tsx` (nuevo)
---
## 2. Filtro de regímenes por contribuyente
**Problema:** El dropdown de regímenes en Dashboard/Impuestos/Reportes mostraba regímenes de TODOS los contribuyentes del despacho, no solo del seleccionado.
**Solución:** Propagación de `selectedContribuyenteId` a través de toda la cadena: hook → API client → controller → service (`getRegimenesDelPeriodo` ahora acepta `contribuyenteId` y filtra con `AND contribuyente_id = '...'`).
**Archivos:**
- `apps/api/src/services/dashboard.service.ts``getRegimenesDelPeriodo` + `contribuyenteId`
- `apps/api/src/controllers/dashboard.controller.ts` — extrae `contribuyenteId` de query
- `apps/web/lib/api/dashboard.ts` — pasa `contribuyenteId`
- `apps/web/lib/hooks/use-dashboard.ts``useRegimenesDelPeriodo` usa `selectedContribuyenteId`
---
## 3. Discrepancia de régimen — regímenes del contribuyente
**Problema:** La alerta y drilldown de discrepancia comparaba CFDIs contra los regímenes del tenant (central), no del contribuyente seleccionado.
**Solución:** Nueva función `getRegimenesActivosClavesEfectivos(tenantId, pool, contribuyenteId?)` que lee de `contribuyentes.regimen_fiscal` cuando hay contribuyenteId, o fallback a `TenantRegimenActivo`.
**Archivos:**
- `apps/api/src/services/regimen.service.ts` — nueva función
- `apps/api/src/services/alertas-auto.service.ts` — usa la nueva función
- `apps/api/src/controllers/alertas.controller.ts` — usa la nueva función en drilldown
---
## 4. Discrepancia de régimen — filtros de fecha y régimen
**Problema:** No había forma de filtrar los CFDIs con discrepancia por fecha o por régimen específico.
**Solución:** Filtros client-side (Desde/Hasta/Régimen) en la página de discrepancia. El dropdown de régimen se construye dinámicamente de los valores únicos presentes.
**Archivos:**
- `apps/web/app/(dashboard)/alertas/discrepancia-regimen/page.tsx` — filtros + `contribuyenteId`
---
## 5. Discrepancias — excluir cancelados
**Problema:** CFDIs cancelados aparecían en la alerta de discrepancia.
**Solución:** Doble filtro: `status NOT IN ('Cancelado', '0') AND fecha_cancelacion IS NULL`.
**Archivos:**
- `apps/api/src/services/alertas-auto.service.ts` — alerta count
- `apps/api/src/controllers/alertas.controller.ts` — drilldown query
---
## 6. Obligaciones — demasiadas y hacia el pasado
**Problema 1:** CSF importaba obligaciones históricas (con `fechaFin`). **Fix:** Filtro `!ob.fechaFin`.
**Problema 2:** Obligaciones aparecían como "atrasadas" para meses anteriores a su creación. **Fix:** `periodo >= obStartPeriodo` (derivado de `created_at`).
**Problema 3:** Re-ejecutar "Generar recomendaciones" duplicaba. **Fix:** `DELETE WHERE es_recomendada = true` antes de insertar.
**Archivos:**
- `apps/api/src/services/obligaciones.service.ts`
---
## 7. Matching de obligaciones CSF → catálogo (MEJORA)
**Problema:** Match por "primeras 3 palabras" fallaba con variantes del SAT (ej: "Pago provisional mensual" vs "Pago provisional de").
**Solución:** Sistema de keyword sets discriminantes. 15 reglas con múltiples variantes, normalización sin acentos, distinción PM/PF por longitud de RFC.
**Análisis base:** 17 CSFs reales analizadas → 22 obligaciones únicas del SAT mapeadas.
**Archivos:**
- `apps/api/src/services/obligaciones.service.ts``CATALOG_MATCH_RULES` + `matchCsfToCatalog()`
---
## 8. Calendario — fix crash por tipo desconocido
**Problema:** `tipoIcons[e.tipo]` retornaba `undefined` para eventos con tipo no mapeado, crasheando React.
**Solución:** Fallback `|| Calendar` en el grid del calendario (la lista lateral ya lo tenía).
**Archivos:**
- `apps/web/app/(dashboard)/calendario/page.tsx`
---
## 9. Alertas del dashboard filtradas por contribuyente
**Problema:** Las alertas automáticas del dashboard (`/dashboard/alertas`) no filtraban por contribuyente, mostrando alertas de todos los RFCs. El drilldown sí filtraba, causando "alerta visible pero drilldown vacío".
**Solución:** Propagación de `contribuyenteId` al endpoint `/dashboard/alertas` y al hook `useAlertas`.
**Archivos:**
- `apps/api/src/controllers/dashboard.controller.ts``getAlertas` + `contribuyenteId`
- `apps/web/lib/api/dashboard.ts``getAlertas` acepta `contribuyenteId`
- `apps/web/lib/hooks/use-dashboard.ts``useAlertas` usa `selectedContribuyenteId`
---
## 10. Alertas page — filtro por contribuyente
**Problema:** La página `/alertas` tenía queries inline que no pasaban `contribuyenteId`. Al cambiar contribuyente, los datos no se actualizaban.
**Solución:** Ambas queries (`alertas-automaticas` y `alertas-manuales`) ahora incluyen `selectedContribuyenteId` en query key y lo pasan como parámetro.
**Archivos:**
- `apps/web/app/(dashboard)/alertas/page.tsx`
---
## 11. CFDI — eliminar staleTime
**Problema:** `useCfdis` tenía `staleTime: 30_000` que podía mostrar datos del contribuyente anterior por hasta 30 segundos.
**Solución:** Eliminado `staleTime` y `gcTime`.
**Archivos:**
- `apps/web/lib/hooks/use-cfdi.ts`
---
## 12. Declaraciones — renombrada + periodicidad + monto
**Cambios:**
- Tab renombrada de "Declaraciones Provisionales" a "Declaraciones"
- Selector de periodicidad (mensual/bimestral/trimestral/semestral/anual) con opciones dinámicas de periodo
- Campo "Monto a pagar" — si $0, auto-marca como pagado y resuelve alertas de pago
- Año seleccionable dentro del formulario (para declaración anual de ejercicio anterior)
- Filtro de fecha (Desde/Hasta) basado en `created_at` en lugar de filtro por año fiscal
- Columna "Fecha subida" en la tabla
**Migración:** `021_declaraciones_periodicidad_monto.sql`
**Archivos:**
- `apps/api/src/migrations/tenant/021_declaraciones_periodicidad_monto.sql`
- `apps/api/src/services/declaraciones.service.ts` — nuevos campos + auto-pago $0
- `apps/api/src/controllers/documentos.controller.ts` — Zod schema + filtro por fecha
- `apps/web/lib/api/declaraciones.ts` — tipos actualizados
- `apps/web/lib/hooks/use-declaraciones.ts` — filtro por fecha
- `apps/web/app/(dashboard)/documentos/page.tsx` — UI completa
---
## 13. Carteras y subcarteras (NUEVO)
**Modelo:**
```
Cartera (top-level) → supervisor_user_id
└── Subcartera → auxiliar_user_id, parent_id
└── Entidades (RFCs asignados)
```
**Flujo:**
- Owner crea cartera: si hay supervisores → selector; si no → se asigna a sí mismo
- Supervisor (u Owner) crea subcarteras dentro de una cartera, asignando un auxiliar
- Cada subcartera tiene su propio subset de RFCs
- Auxiliar ve solo los RFCs de sus subcarteras (`entidades-visibles.ts`)
**Invite auxiliar:** Requiere seleccionar supervisor. Se almacena en tabla `auxiliar_supervisores`.
**Migración:** `022_carteras_subcarteras.sql``parent_id`, `auxiliar_user_id` en carteras + tabla `auxiliar_supervisores`
**Archivos:**
- `apps/api/src/migrations/tenant/022_carteras_subcarteras.sql`
- `apps/api/src/services/cartera.service.ts` — subcarteras + getSupervisores
- `apps/api/src/controllers/cartera.controller.ts` — endpoints subcarteras
- `apps/api/src/routes/cartera.routes.ts` — rutas nuevas
- `apps/api/src/controllers/usuarios.controller.ts` — invite con `supervisorUserId`
- `apps/api/src/utils/entidades-visibles.ts` — auxiliar ve subcarteras
- `apps/web/app/(dashboard)/carteras/page.tsx` — página completa reescrita
- `apps/web/app/(dashboard)/usuarios/page.tsx` — selector supervisor al invitar auxiliar
- `apps/web/lib/api/carteras.ts` — API client actualizado
- `apps/web/lib/hooks/use-carteras.ts` — hooks actualizados
- `packages/shared/src/types/user.ts``UserInvite.supervisorUserId`
---
## 14. ISR Mensual — tabla + Excel (NUEVO)
**Backend:** Nuevo endpoint `GET /impuestos/isr/mensual` que calcula ingresos, deducciones y base gravable por cada mes del año (excluyendo régimen 605).
**Frontend:** Tabla "Histórico ISR" con 12 meses + fila de totales + botón Excel. Similar a la tabla IVA existente.
**Excel IVA:** También agregado botón Excel a la tabla de Histórico IVA.
**Archivos:**
- `apps/api/src/services/impuestos.service.ts``getIsrMensual()`
- `apps/api/src/controllers/impuestos.controller.ts` — handler
- `apps/api/src/routes/impuestos.routes.ts` — ruta
- `apps/web/lib/api/impuestos.ts` — API client
- `apps/web/lib/hooks/use-impuestos.ts` — hook
- `apps/web/app/(dashboard)/impuestos/page.tsx` — tabla + Excel ambas
---
## 15. ISR — exclusión régimen 605 (Sueldos)
**Problema:** El régimen 605 (Sueldos y Salarios) se incluía en ingresos ISR, pero el patrón ya retuvo el ISR. No debe generar ingreso/deducción para ISR del contribuyente.
**Solución:**
- `getIsrMensual`: SQL filtra `AND regimen_fiscal_emisor != '605'`
- `getResumenIsr`: Filtra 605 de `ingresosPorRegimen`, `deduccionesPorRegimen`, y `regimenesConDatos`
**Dashboard:** 605 sigue mostrándose como ingreso general (correcto).
**Archivos:**
- `apps/api/src/services/impuestos.service.ts`
---
## 16. ISR — cálculo por régimen correcto
**Problema:** El frontend hacía `baseGravable * 0.30` para todos los regímenes. Incorrecto para PF (612 usa tarifa progresiva, no 30%).
**Solución:** `BaseGravableRegimen` ahora incluye `isrCausado` calculado en el backend con la fórmula correcta por régimen:
- 606, 612, 621, 625 → tarifa progresiva Art. 96
- 626 PF → tasa plana RESICO por bracket
- PM (601, etc.) → base × coeficiente × 30%
**KPI "ISR a Pagar":** Ahora usa el `isrCausado` del régimen seleccionado, consistente con la tabla de abajo.
**Coeficiente de Utilidad:** Se oculta cuando se selecciona un régimen PF (no aplica).
**Archivos:**
- `packages/shared/src/types/impuestos.ts``BaseGravableRegimen.isrCausado`
- `apps/api/src/services/impuestos.service.ts` — calcula y devuelve `isrCausado` per régimen
- `apps/web/app/(dashboard)/impuestos/page.tsx` — KPI usa per-régimen + oculta coeficiente PF
---
## Migraciones aplicadas
| # | Archivo | Descripción |
|---|---------|-------------|
| 021 | `021_declaraciones_periodicidad_monto.sql` | Periodicidad + monto_pago en declaraciones |
| 022 | `022_carteras_subcarteras.sql` | parent_id + auxiliar_user_id en carteras + auxiliar_supervisores |
## Usuarios creados
| Email | Rol | Despacho |
|---|---|---|
| supervisor@patito.com | Supervisor | Patito |
| auxiliar@patito.com | Auxiliar | Patito |
| cliente@patito.com | Cliente | Patito |
Contraseña: `Admin12345!`

View File

@@ -0,0 +1,237 @@
# Features Pendientes — Horux Despachos
> Documentado 2026-04-19. Actualizado 2026-04-22 con estado real.
## Índice de estado al 2026-04-22
| # | Feature | Estado |
|---|---|---|
| 1 | Editar contribuyentes asignados a Cliente | ✅ **Completado** |
| 2 | Pendientes → Despacho + métricas de seguimiento | ⏸ Abierto |
| 3 | Cobro del plan desde Planes (MP) | ✅ **Completado** (Tanda 1 MP sesión 2026-04-21) |
| 4 | Timbres asignados al despacho | ✅ **Verificado** (ya usa `consumeTimbre(tenantId)`) |
| 5 | Add-ons por contribuyente | ✅ **Completado** (sesión 2026-04-22) |
| 6 | Enlazar obligaciones ↔ declaraciones | ✅ **Completado 2026-04-23** (backend + UI trazabilidad) |
| 7 | Calendario — obligaciones con colores | ✅ **Completado 2026-04-23** (backend + colores + íconos + leyenda) |
| 8 | Sección "Extras" en Documentos | ✅ **Completado** (sesión 2026-04-21) |
| 9 | Avisos por correo al subir declaración / doc extra | ✅ **Completado 2026-04-23** |
| 10 | Alertas obligaciones — filtros per-contribuyente | ✅ **Investigado 2026-04-23** — bug no reproducible; protecciones verificadas |
---
## 1. Editar contribuyentes asignados a usuario tipo Cliente — ✅ COMPLETADO
**Implementación verificada al 2026-04-22:**
- Backend: `GET /usuarios/:id/accesos` (`getClienteAccesos`) y `POST /usuarios/:id/accesos` (`setClienteAccesos`, reemplaza todos los accesos) en `usuarios.controller.ts:162-194`.
- Frontend: `apps/web/app/(dashboard)/usuarios/page.tsx` tiene botón "Editar RFCs con acceso" por cada usuario tipo `cliente` (línea 366), abre modal con checkboxes por contribuyente. Solo visible para owner en despacho.
- Guardrails: endpoint gateado por `req.user.role === 'owner'`.
---
## 2. Convertir "Pendientes" a "Despacho" + métricas de seguimiento
**Estado:** La página `/pendientes` muestra obligaciones por periodo con barras de progreso por contribuyente.
**Cambios necesarios:**
- Renombrar a "Despacho" en sidebar
- Agregar métricas de seguimiento del despacho:
- Total de contribuyentes activos
- Contribuyentes con FIEL vencida o sin FIEL
- Contribuyentes con opinión de cumplimiento negativa
- Declaraciones pendientes del mes
- Progreso de obligaciones del mes (% completado global)
- CFDIs sincronizados vs pendientes
- Resumen de alertas activas por prioridad
- Mantener la vista de pendientes/obligaciones actual como sección inferior
---
## 3. Cobro del plan desde Planes — ✅ COMPLETADO (Tanda 1 MP, sesión 2026-04-21)
Integración MP completa para planes despacho (`business_control` y
`business_cloud`), con dualidad (año 1 $21K / renovación $15K) via Opción B
(updatePreapprovalAmount tras primer pago). Ver
`docs/plans/2026-04-21-session-2-mp-setup-and-bugfixes.md` §5 y §7.
---
## 4. Timbres asignados al despacho — ✅ VERIFICADO
Confirmado al 2026-04-22: `facturacion.controller.ts:60` llama
`consumeTimbre(tenantId)` pasando el tenantId del despacho (no contribuyenteId).
El pool de timbres (`timbre_suscripciones` + `timbre_paquetes`) es compartido
entre todos los contribuyentes del despacho. La UI de timbres ya lo refleja
así. No se necesitan cambios.
---
## 5. Add-ons por contribuyente — ✅ COMPLETADO (sesión 2026-04-22)
**Implementado:**
- Schema: `SubscriptionAddon.contribuyenteId String?` (opcional; NULL = tenant-level)
- Migration `20260422172323_subscription_addons_contribuyente_id`
- Service `addon.service.ts`: `subscribeAddon(contribuyenteId)`, `listActiveAddons(tenantId, contribuyenteId?)` con preapproval MP propio por add-on
- Controller `subscription.controller.ts`: `GET /me/addons?contribuyenteId=...`, `POST /me/addons { addonCodename, contribuyenteId }`
- UI: botón ✨ Sparkles en `/contribuyentes` por cada RFC → dialog con catálogo `ADDONS_POR_CONTRIBUYENTE` (hoy solo Lolita IA $250/mes)
- Cableado automático del overage Business Cloud: `adjustBusinessCloudOverage` en `addon.service.ts`, llamado desde `contribuyente.controller.ts:create` y `:deactivate`
**Modelo descartado:** primer intento fue tabla tenant `contribuyente_addons`
con feature-toggles (facturación/conciliación/documentos/calendario/reportes).
Revertido — los add-ons reales son servicios de cobro recurrente, no
switches de features. Los gates por módulo quedan como feature futura
(requerirían middleware `requireAddon(key)` en rutas existentes).
Ver `docs/plans/2026-04-22-pendientes-y-addons.md` § "Feature: Add-ons por
contribuyente" para detalle completo.
---
## 6. Enlazar obligaciones con Declaraciones — ✅ COMPLETADO 2026-04-23
**Backend (ya existía parcialmente, se completó la trazabilidad):**
- `completarObligacionesPorDeclaracion` en `declaraciones.service.ts` hace
matching por keyword (`IVA → 'iva'`, `ISR → 'isr'`, `SUELDOS → 'sueldos'|'salarios'|'nómina'`, etc.)
contra `obligaciones_contribuyente.nombre` y hace `INSERT ... ON CONFLICT
DO UPDATE` en `obligacion_periodos` marcando `completada=true`.
- `createDeclaracion` llama esta función tras crear la declaración; recibe
el `id` de la declaración y lo propaga.
**Nuevo en esta sesión (trazabilidad):**
- **Migration 030** `obligacion_periodos.declaracion_id INT REFERENCES
declaraciones_provisionales(id) ON DELETE SET NULL` + índice parcial.
Aplicada a Zorro + Patito vía `pnpm db:migrate-tenants`.
- `completarObligacionesPorDeclaracion(..., declaracionId)` guarda el FK.
- `getObligacionesPorPeriodo` hace LEFT JOIN a `declaraciones_provisionales`
y devuelve el objeto `declaracion: { id, año, mes, tipo, pdfFilename } | null`
por periodo completado. Nuevo tipo exportado `DeclaracionLink`.
- UI `/pendientes` (vista single-contribuyente) muestra link
`↗ Declaración MM/YYYY [Compl.]` junto a cada obligación completada
que tenga FK. Click abre el PDF en nueva pestaña via
`/documentos/declaraciones/:id/pdf/declaracion`.
**Qué pasa al borrar la declaración:** `ON DELETE SET NULL` — el periodo
sigue marcado `completada=true` pero pierde la referencia. Decisión
intencional: el usuario puede volver a abrir manualmente si corresponde,
pero el estado se preserva.
**Obligaciones marcadas manualmente** (sin declaración asociada): ya
funcionaban antes, siguen funcionando. El campo `declaracion_id` queda
NULL y la UI no muestra el link.
---
## 7. Calendario — obligaciones con colores — ✅ COMPLETADO 2026-04-23
**Backend** (`calendario-fiscal.service.ts:generarEventosDesdeObligaciones`):
- Lee `obligacion_periodos` para determinar completitud por (obligación, periodo).
- Emite eventos con uno de 3 tipos:
- `obligacion-completada` — si `obligacion_periodos.completada = true` para el periodo.
- `obligacion-atrasada` — si no completada y `fechaLimite < now()`.
- `obligacion-pendiente` — si no completada y aún en ventana.
- `fechaLimite` ajustada a día hábil más próximo (considera inhábiles del año).
**Frontend** (`apps/web/app/(dashboard)/calendario/page.tsx`):
- `tipoColors`: amber / green / red para los 3 estados.
- `tipoIcons`: `Clock` (pendiente), `Check` (completada), `AlertTriangle` (atrasada).
- Leyenda visible en el CardContent del calendario que explica los colores + `custom` (violet).
- El fetch `useEventos(año)` pasa el `selectedContribuyenteId` del store; el controller
detecta despacho y usa `generarEventosDesdeObligaciones` en vez del catálogo estático
(`generarEventosFiscales`) de Horux 360.
---
## 8. Sección "Extras" en Documentos — ✅ COMPLETADO (sesión 2026-04-21)
Implementado: tabla `documentos_extras`, endpoints CRUD, pestaña en UI.
Ver `docs/plans/2026-04-21-session-2-mp-setup-and-bugfixes.md` §12.
---
## 9. Avisos por correo electrónico — ✅ COMPLETADO 2026-04-23
**Implementación:**
- **Template** `apps/api/src/services/email/templates/documento-subido.ts` — usa
`baseTemplate` de la marca. Parametrizado por `kind: 'declaracion' | 'extra'`
y bloques condicionales para periodo/tipo/impuestos/monto (declaración) o
nombre/categoría/descripción (extra). HTML escapado para evitar XSS.
- **`emailService.sendDocumentoSubido(recipients, data)`** —
`apps/api/src/services/email/email.service.ts`. Loop por recipient con
try/catch individual para que un fallo en un destinatario no bloquee los
demás. Subject incluye RFC + periodo/nombre.
- **Helpers de resolución en `utils/memberships.ts`:**
- `getTenantOwnerEmails(tenantId)` — lista todos los owners activos.
- `getUserEmailById(userId)` — resolver supervisor por UUID.
- **Orquestador** `apps/api/src/services/notify-upload.service.ts:notifyDocumentoSubido`
— lee `entidades_gestionadas.supervisor_user_id` desde BD tenant, resuelve
email, dedupea con owners, EXCLUYE al uploader (no notifica su propia
acción). Usa `FRONTEND_URL/documentos` como link al sistema.
- **Callsites** en `controllers/documentos.controller.ts`:
- `crearDeclaracion` dispara notify tras el INSERT con periodo "Abril 2026",
tipo, impuestos, montoPago.
- `crearExtra` dispara notify con nombre + categoría + descripción.
- **Fire-and-forget**: `.catch(err => console.error(...))` en ambos
call-sites — el response HTTP ya retornó cuando el email viaja.
Si SMTP no está configurado, el transport de `@horux/core` loguea a
consola en vez de fallar (dev mode).
**Fuera de alcance:** flag por despacho para activar/desactivar
notificaciones (feature futura cuando haya preferencias de notificación
a nivel tenant/user).
---
## 10. Revisar alertas de obligaciones (posible bug) — ✅ INVESTIGADO 2026-04-23
**Reporte original:** "las alertas manuales de obligaciones muestran más
obligaciones de las que tiene el contribuyente".
**Investigación:** auditoría SQL sobre los despachos activos (Patito, Zorro):
- 0 alertas `ob-*` con `obligacion_id` inexistente (huérfanas)
- 0 alertas para obligaciones con `activa=false`
- 0 alertas para periodos ya completados
- Count per-contribuyente: alertas ≤ obligaciones activas en todos los casos
- Todas las alertas actuales son del periodo actual (`2026-04`), 1 por obligación
**Protecciones verificadas en el código:**
1. **`removeObligacion` (`obligaciones.service.ts:296-311`)** — al
desactivar una obligación hace soft-delete (`activa=false`) +
`DELETE alertas WHERE tipo LIKE 'ob-{id}-%'` + `DELETE obligacion_periodos
WHERE obligacion_id=$1`. Evita alertas huérfanas incluso si queda
residuo por pools/caches.
2. **`inactiveFilter` en `getAlertasManualesPendientes`
(`alertas-manuales.service.ts:269-273`)** — defense-in-depth: excluye
en query alertas cuyo obligacion_id esté `activa=false`.
3. **`contribuyenteFilter` strict
(`alertas-manuales.service.ts:223-227`)** — cuando se pasa
`contribuyenteId`, el WHERE solo incluye alertas cuyo SUBSTRING del
`tipo` coincida con un `id` de `obligaciones_contribuyente` del RFC.
Cross-contribuyente leak imposible.
4. **`sincronizarDesdeObligacionesContribuyente` genera solo current +
previous month** — sin acumulación histórica espontánea.
**Conclusión:** el bug reportado fue probablemente corregido
implícitamente en la sesión 2026-04-18/19 cuando se agregó el cleanup
en `removeObligacion` y los filtros en `getAlertasManualesPendientes`.
El escenario único donde persistiría acumulación es un usuario que
deje periodos sin completar durante meses — pero eso refleja
correctamente la realidad fiscal (cada periodo incumplido es una
obligación pendiente propia).
**Acción:** cerrar #10. Si reaparece el síntoma, correr auditoría
SQL previa al reporte para identificar el drift específico.
---
## Cambios completados en esta sesión (2026-04-18 / 2026-04-19)
Ver `docs/plans/2026-04-18-session-fixes-and-features.md` para el detalle completo de los 16 cambios implementados, incluyendo:
- Filtro contribuyente en regímenes, alertas, CFDIs, CSD, bancos
- Declaraciones con periodicidad + monto + filtro por fecha
- Carteras y subcarteras
- ISR mensual + exclusión 605 + cálculo correcto por régimen
- Matching de obligaciones CSF mejorado
- Descarte persistente de discrepancias
- 4 migraciones (021-024)
- 3 usuarios creados (supervisor, auxiliar, cliente)

View File

@@ -0,0 +1,184 @@
# Sesión 2026-04-19 (Parte 2) — Fixes, Roles y Features
## Resumen
Sesión enfocada en corregir el sistema de alertas/obligaciones, implementar permisos por rol, y enlazar declaraciones con obligaciones.
---
## 1. Alertas de obligaciones — generación per-contribuyente (#10)
**Problema:** `sincronizarAlertasManuales` generaba alertas desde el calendario fiscal estático (catálogo genérico), mostrando obligaciones que no correspondían al contribuyente.
**Fix:** Para despachos (`isDespachoTenant`):
- Genera alertas desde `obligaciones_contribuyente` del contribuyente seleccionado
- Respeta frecuencia, `created_at` y periodos completados
- "Todos los RFCs" no genera alertas nuevas, solo muestra las existentes
- Alertas legacy (`decl-*`, `pago-*`) eliminadas del despacho
**Archivos:** `apps/api/src/services/alertas-manuales.service.ts`
---
## 2. Calendario — colores por status de obligación (#7)
**Fix:** `generarEventosDesdeObligaciones` ahora genera tipos diferenciados:
- `obligacion-pendiente` (amber)
- `obligacion-completada` (green)
- `obligacion-atrasada` (red)
Frontend: iconos y colores agregados en `tipoIcons` / `tipoColors`.
**Archivos:** `apps/api/src/services/calendario-fiscal.service.ts`, `apps/web/app/(dashboard)/calendario/page.tsx`
---
## 3. Editar accesos de clientes (#1)
**Backend:** Endpoints `GET/POST /usuarios/:id/accesos` para listar y reemplazar accesos de un cliente.
**Frontend:** Botón "Accesos" en la lista de usuarios para clientes. Modal con checkboxes de RFCs.
**Archivos:** `apps/api/src/controllers/usuarios.controller.ts`, `apps/api/src/routes/usuarios.routes.ts`, `apps/web/app/(dashboard)/usuarios/page.tsx`
---
## 4. Enlazar declaraciones con obligaciones (#6)
**Implementación:** Al subir una declaración:
1. Se matchean los impuestos seleccionados (IVA, ISR, IEPS, etc.) contra las obligaciones del contribuyente por keywords
2. Se marcan como completadas en `obligacion_periodos`
3. Se resuelven las alertas `ob-{id}-{periodo}` correspondientes
4. Frontend invalida queries de alertas y calendario
**Mapeo:** IVA→"iva", ISR→"isr", IEPS→"ieps", DIOT→"proveedores de iva", SUELDOS→"sueldos"/"salarios"
**Archivos:** `apps/api/src/services/declaraciones.service.ts`, `apps/api/src/controllers/documentos.controller.ts`, `apps/web/lib/api/declaraciones.ts`, `apps/web/lib/hooks/use-declaraciones.ts`, `apps/web/app/(dashboard)/documentos/page.tsx`
---
## 5. Obligaciones — limpieza al desactivar
**Fix:** `removeObligacion` ahora elimina alertas (`DELETE FROM alertas WHERE tipo LIKE 'ob-{id}-%'`) y periodos completados al desactivar. `initRecomendaciones` limpia alertas y periodos antes de reemplazar.
**Prevención:** `getAlertasManualesPendientes` excluye alertas de obligaciones inactivas (`activa = false`).
**Archivos:** `apps/api/src/services/obligaciones.service.ts`, `apps/api/src/services/alertas-manuales.service.ts`, `apps/web/app/(dashboard)/configuracion/obligaciones/page.tsx`
---
## 6. Filtro alertas por rol (auxiliar, supervisor, cliente)
**Fix:** `getAlertasManualesPendientes` ahora filtra por rol:
- **Owner:** todas las alertas
- **Supervisor:** solo de contribuyentes en sus carteras
- **Auxiliar:** solo de contribuyentes en sus subcarteras
- **Cliente:** solo de contribuyentes en `cliente_accesos`
Dashboard (`/dashboard/alertas`) usa `getAlertasManualesPendientes` con filtro por rol en lugar de `dashboardService.getAlertas` sin filtro.
**Archivos:** `apps/api/src/services/alertas-manuales.service.ts`, `apps/api/src/controllers/dashboard.controller.ts`, `apps/api/src/controllers/alertas.controller.ts`
---
## 7. Sidebar — roles de despacho
**Cambios:**
- **Supervisor:** agregado a Pendientes, Dashboard, Reportes, Facturación
- **Cliente:** agregado a Dashboard, Reportes
- **Carteras:** visible para supervisor y auxiliar
- **Onboarding:** oculto para cliente; login redirige directo a dashboard para cliente/auxiliar/supervisor
**Archivos:** `apps/web/components/layouts/sidebar.tsx`, `apps/web/app/(auth)/login/page.tsx`
---
## 8. Permisos de carteras — niveles por rol
| Acción | Owner | Supervisor | Auxiliar |
|---|---|---|---|
| Ver carteras | Todas | Sus asignadas | Sus subcarteras |
| Crear cartera | Sí | No | No |
| Editar/eliminar cartera | Sí | No | No |
| Agregar/quitar RFCs a cartera | Sí | No | No |
| Crear subcarteras | Sí | Sí | No |
| Agregar RFCs a subcarteras | Sí | Sí | No |
**Backend:** Lógica de permisos en controller con verificación de parent para subcarteras.
**Frontend:** Props `canEdit` y `canManageSubcarteras` condicionan botones.
**Archivos:** `apps/api/src/controllers/cartera.controller.ts`, `apps/api/src/routes/cartera.routes.ts`, `apps/web/app/(dashboard)/carteras/page.tsx`
---
## 9. Supervisor — visibilidad de contribuyentes
**Problema:** El supervisor veía todos los contribuyentes porque `entidades-visibles.ts` buscaba `supervisor_user_id` (null) y `listContribuyentes([])` devolvía todo.
**Fix:**
- `entidades-visibles.ts`: supervisor busca en `cartera_entidades` de sus carteras
- `listContribuyentes`: array vacío = lista vacía (no todos)
**Archivos:** `apps/api/src/utils/entidades-visibles.ts`, `apps/api/src/services/contribuyente.service.ts`
---
## 10. Pendientes — filtro "Mis asignados"
**Problema:** Usaba `supervisorUserId` directo (siempre null en despachos).
**Fix:** Filtra por `contribuyentes` visibles del usuario actual (ya filtrados por `useContribuyentes` según rol).
**Archivos:** `apps/web/app/(dashboard)/pendientes/page.tsx`
---
## 11. Auto-selección de contribuyente
**Fix:** Si un usuario solo tiene 1 contribuyente (ej: cliente), se auto-selecciona. No muestra "Todos los RFCs" cuando solo hay 1.
**Archivos:** `apps/web/components/contribuyente-selector.tsx`
---
## 12. Conciliación — permisos expandidos
**Fix:** Ahora `owner`, `cfo`, `contador`, `auxiliar` y `supervisor` pueden conciliar/desconciliar (antes solo owner+contador).
**Archivos:** `apps/api/src/controllers/conciliacion.controller.ts`
---
## 13. Calendario — permisos recordatorios
**Fix:** Ahora `owner`, `cfo`, `contador`, `auxiliar` y `supervisor` pueden crear/editar recordatorios (antes solo owner+contador).
**Archivos:** `apps/web/app/(dashboard)/calendario/page.tsx`
---
## 14. Dropdown regímenes — posición
**Fix:** Dropdown se despliega `left-0` en lugar de `right-0` para evitar desbordamiento.
**Archivos:** `packages/shared-ui/src/form/regimen-selector.tsx`
---
## 15. ISR Base Gravable — KPI fallback
**Problema:** Al seleccionar un régimen sin datos (605, 621), el KPI mostraba el total global en lugar de $0.
**Fix:** `value={regimenSeleccionado ? (bg?.baseGravable ?? 0) : resumenIsr?.baseGravable || 0}`
**Archivos:** `apps/web/app/(dashboard)/impuestos/page.tsx`
---
## 16. CFDIs — filtro expandido solo para listado
**Problema:** El filtro `OR rfc_emisor/rfc_receptor` se aplicó a todos los servicios, causando doble conteo en métricas fiscales.
**Fix:** Filtro expandido solo en `cfdi.service.ts` (listado). Dashboard, Impuestos, Reportes, Alertas, Conciliación usan solo `contribuyente_id`.
**Archivos:** `apps/api/src/utils/contribuyente-context.ts`, `apps/api/src/services/dashboard.service.ts`, `apps/api/src/services/alertas-auto.service.ts`, `apps/api/src/services/reportes.service.ts`, `apps/api/src/services/conciliacion.service.ts`, `apps/api/src/controllers/alertas.controller.ts`

View File

@@ -0,0 +1,339 @@
# Sesión 2026-04-21 — Facturación, ISR, aislamiento entre contribuyentes
## Resumen ejecutivo
Sesión enfocada en el pipeline de facturación del fork Horux Despacho: bugs de
routing por contribuyente, cómputo ISR mensual, manejo de timbres ante fallos, y
dos errores TS pre-existentes del fork. 10 cambios de código + 1 script de
backfill + 1 data fix manual (timbres al despacho Patito).
---
## 1. Selector de régimen emisor + filtro por contribuyente en "Conceptos previos" (#factura-regimen)
**Problema:** Contribuyentes con múltiples regímenes (p.ej. Carlos: 606,612,614)
no tenían forma de elegir cuál régimen usar al facturar. Además, la búsqueda de
"conceptos previos" traía conceptos de TODOS los contribuyentes del tenant, no
solo del seleccionado.
**Fix:**
- `apps/api/src/controllers/facturacion.controller.ts``searchConceptos` acepta
query param `contribuyenteId`, lo sanitiza con regex `[^a-f0-9-]` (convención
del repo) y aplica `AND c.contribuyente_id = '...'` al join con `cfdis`.
- `apps/web/lib/api/facturacion.ts``searchConceptos(q, tipo?, contribuyenteId?)`.
- `apps/web/app/(dashboard)/facturacion/page.tsx`:
- Estado `emisorRegimenes` (lista completa, antes solo guardaba el primero).
- `handleEmisorRegimenChange` recalcula recomendaciones de retenciones para
todos los conceptos en pantalla al cambiar régimen.
- Selector UI en la Card "Datos del Comprobante" junto a "Tipo de Comprobante"
(posición final tras mover desde "Conceptos"). Visible solo si
`tipoComprobante === 'I'` **y** hay ≥2 regímenes detectados.
- `handleConceptoSearch` pasa `selectedContribuyenteId` al backend.
---
## 2. Bug ISR — tabla "Histórico ISR" no coincidía con cards
**Problema:** Card mostraba $941,359 para Husberto régimen 612 Octubre 2025;
tabla mostraba $1,745,862.95. Diferencia ~$800K.
**Causa raíz:** `getIsrMensual` usaba un SQL inline simple
(`tipo_comprobante IN ('I','P')`) que:
1. No replicaba la lógica por grupo de régimen de las cards.
2. No filtraba por `metodo_pago` (contaba facturas PPD no pagadas para el grupo
PF Empresarial).
3. No restaba notas de crédito (tipo E).
Las cards usan `calcularIngresosPorRegimen` en `dashboard.service.ts` que separa:
- **Grupo PF Empresarial** (606, 612, 621, 625, 626): `I PUE + P E PUE`
- **Grupo PM y otros**: `I PUE+PPD E PUE`
**Fix:** Reescrito `getIsrMensual` en `impuestos.service.ts` para invocar
`calcularIngresosPorRegimen`/`calcularEgresosPorRegimen` **mes a mes** (12
iteraciones). Garantiza que la tabla cuadre célula a célula con las cards.
Costo: ~72 queries por carga — aceptable en dev y sub-segundo en prod.
Propagación de `regimenClave` en toda la stack:
- `impuestos.service.ts:getIsrMensual` — nuevo param.
- `impuestos.controller.ts` — lee query `regimenClave`.
- `apps/web/lib/api/impuestos.ts` — propaga.
- `apps/web/lib/hooks/use-impuestos.ts` — incluye en queryKey.
- `apps/web/app/(dashboard)/impuestos/page.tsx:43` — pasa `regimenSeleccionado`.
Cuando `regimenClave` está presente, usa fórmula por régimen
(`REGIMENES_RESTA_DEDUCCIONES.includes(clave)` determina si resta deducciones).
---
## 3. Dos TS errors pre-existentes — `pnpm typecheck` vuelve a cero
**Problema:** `pnpm typecheck` en `@horux/api` tenía 2 errores heredados del pivot.
### 3a. `constancia.service.ts:331` — código muerto
```ts
calle: cleanDomField(rawDom.nombreVialidad) || cleanDomField(rawDom.calle) || ''
// ^^^^^^^^^^^^^^
// Domicilio no tiene campo 'calle' — solo nombreVialidad y yCalle
```
**Fix:** Eliminado el fallback muerto.
**Impacto runtime:** Cero — era código inalcanzable.
### 3b. `sat/sat.service.ts:1073` — retry path con CFDIs huérfanos
```ts
// El retry handler construía SyncContext SIN contribuyenteId,
// aunque la interfaz lo requería como 'string | null'.
const ctx: SyncContext = { fielData, service, rfc, tenantId, databaseName, getPool };
// ^ faltaba contribuyenteId
```
**Fix:** Agregado `contribuyenteId: job.contribuyenteId ?? null` (el modelo
`SatSyncJob` ya tiene esa columna como `String?`).
**Impacto runtime (grave en prod despacho):** antes del fix, CFDIs descargados
por retry del cron SAT se insertaban con `contribuyente_id = NULL` en vez del
UUID correcto → quedaban huérfanos y no aparecían en métricas per-contribuyente.
Solo afectaba despachos (Horux360 clásico no tiene contribuyentes).
---
## 4. Script de backfill de `cfdis.contribuyente_id`
**Archivo nuevo:** `apps/api/scripts/backfill-cfdi-contribuyente.ts`
Asocia CFDIs huérfanos (contribuyente_id NULL) con el contribuyente cuyo RFC
coincide con `rfc_emisor` (EMITIDO) o `rfc_receptor` (RECIBIDO). Idempotente,
transaccional por tenant, soporta `--dry` flag.
Uso:
```bash
pnpm --filter @horux/api exec tsx scripts/backfill-cfdi-contribuyente.ts --dry
pnpm --filter @horux/api exec tsx scripts/backfill-cfdi-contribuyente.ts
```
**Resultado en la instancia dev:** 0 CFDIs huérfanos (no hubo sync por retry
previo al fix 3b). Script queda listo para cuando aparezcan huérfanos en prod.
---
## 5. Data fix — 20 timbres al despacho Patito
`INSERT INTO timbre_paquetes` con:
- `tenant_id = 31400c73-6dec-4c29-86cd-1184d86c58b7` (Patito)
- `payment_id = NULL` (admin grant manual, soportado por el schema)
- `cantidad = 20`, `precio = 0`, `expira_en = 2027-04-21`
---
## 6. Bug crítico — `contribuyenteId` no se enviaba al emitir
**Problema:** POST `/facturacion/emitir` devolvía 500 "Organización Facturapi
sin API key" al intentar facturar con Horux 360 como contribuyente.
**Causa raíz:** El frontend no incluía `contribuyenteId` en el payload. El
controller cae al fallback `facturapiService.createInvoice(tenantId, ...)` que
intentaba usar la org del **despacho** (Patito, sin API key) en vez de la org
del **contribuyente** (Horux 360, configurada).
**Fix:** `handleSubmit` en `facturacion/page.tsx` agrega:
```ts
...(selectedContribuyenteId ? { contribuyenteId: selectedContribuyenteId } : {})
```
---
## 7. Error middleware propaga mensaje real de Facturapi
**Problema:** Cualquier error Facturapi se convertía en 500 "Internal server
error" genérico (errorMiddleware solo propaga mensajes de `AppError`). El user
solo veía "500" en el navegador; el mensaje real quedaba enterrado en logs.
**Fix en `facturacion.controller.ts:emitir`:**
```ts
try {
invoice = contribuyenteId
? await createInvoiceContribuyente(...)
: await facturapiService.createInvoice(...);
} catch (err: any) {
console.error('[facturacion.emitir] Rechazo al crear factura:', {
tenantId, contribuyenteId, type, items, error: err.message,
});
throw new AppError(400, err?.message || 'Error al emitir factura');
}
```
Ahora el frontend (que ya tiene `alert(err.response?.data?.message || ...)`)
muestra mensajes descriptivos como `"items[0].product.taxes[0].rate" must be
one of [...]`.
---
## 8. Org Facturapi de Carlos — CSD eliminado vía API
**Diagnóstico:** La org Facturapi de Carlos (`69e6eeb14c9600bdf19c8b29`) tenía
datos inconsistentes — `legal.name = "CARLOS HUSBERTO TORRES ROMERO"` pero
`legal.tax_id = "HTS240708LJA"` (RFC de Horux 360, no de Carlos). Probablemente
se subió el CSD incorrecto al crear la org. Facturapi rechaza porque
`nombre ≠ RFC` en su padrón.
**Acciones:**
- `DELETE /v2/organizations/{id}/certificate` exitoso → CSD removido.
- PUT a `/legal` con `tax_id` rechazado: Facturapi no permite cambiar `tax_id`
post-creación (protección por diseño).
- **Conclusión:** El user elimina la org manualmente y la recrea con el CSD
correcto de Carlos (o uno de prueba: Facturapi publica CSDs de prueba en
https://docs.facturapi.io/guides/quick-start con RFC `EKU9003173C9`).
**Paso pendiente:** al recrear, actualizar referencia en BD:
```sql
UPDATE facturapi_orgs
SET facturapi_org_id = '<nuevo-id>', csd_uploaded = true
WHERE contribuyente_id = '414b22a8-c6e2-4f39-be0f-7537a848107e';
```
---
## 9. Bugs graves de aislamiento entre contribuyentes
**Problema encontrado en auditoría:** El fork añadió `createInvoiceContribuyente`
para emitir con la org del contribuyente, pero **cancelar, descargar PDF/XML y
enviar email siguen usando la org del despacho**. Como el `facturapi_id` de la
factura solo existe en la org del contribuyente, todas esas operaciones fallan
con 404 silenciosamente o generan inconsistencia BD vs SAT.
### Funciones nuevas en `contribuyente-facturapi.service.ts`
- `cancelInvoiceContribuyente(pool, contribuyenteId, facturapiId, motive, substitution?)`
- `downloadPdfContribuyente(pool, contribuyenteId, facturapiId)`
- `downloadXmlContribuyente(pool, contribuyenteId, facturapiId)`
- `sendInvoiceByEmailContribuyente(pool, contribuyenteId, facturapiId, email)`
- Helper `streamToBuffer` (copia del que está en `facturapi.service.ts`)
### Routing en `facturacion.controller.ts`
| Endpoint | Cómo decide qué org usar |
|---|---|
| `emitir``sendInvoiceByEmail` post-emisión | Usa `contribuyenteId` del request body |
| `cancelar` | `SELECT facturapi_id, contribuyente_id FROM cfdis WHERE uuid = $1` y rutea |
| `downloadPdf` / `downloadXml` | Helper `resolveCfdiContribuyenteId(pool, facturapiId)` lee la fila |
### Efectos que esto previene
- Emails de facturas de contribuyentes al receptor **ahora sí se envían** (antes
caían en `.catch(log)` silencioso).
- Cancelación de CFDIs de contribuyentes actualiza Facturapi/SAT, no solo BD
local → evita inconsistencia donde BD dice `Cancelado` pero SAT sigue vigente.
- PDF/XML descargables desde la lista de CFDIs para facturas de contribuyentes.
---
## 10. Reset de formulario al cambiar contribuyente (aislamiento UI)
**Problema:** Al cambiar contribuyente en el selector, los campos del formulario
(receptor, conceptos, retenciones calculadas, uso CFDI, etc.) quedaban pegados.
Escenario concreto: emites con Carlos (PF → retenciones auto) y cambias a
Horux 360 (PM → sin retenciones), pero los conceptos mantienen las retenciones
que ya no aplican.
**Fix:** `useEffect` en `facturacion/page.tsx` con `firstRenderRef` guard que, al
cambiar `selectedContribuyenteId` (después del primer render), resetea:
- receptor (taxId, legalName, taxSystem, email, zip)
- isGlobal flag, extranjeroTaxId, extranjeroCountry
- usoCfdi, formaPago, metodoPago, moneda, exportacion (defaults según tipoComprobante)
- serie, folio, condiciones
- conceptos → uno vacío con `unitKey` default según tipo
- relatedUuid, relatedRelationship
- pagoUuid, pagoMonto, pagoParcialidad, pagoSaldoAnterior, pagoFormaPago,
pagoIvaBase, pagoIvaTasa
Conservados intencionalmente: `tipoComprobante` (decisión activa del usuario),
`emisorRegimen`/`emisorRegimenes` (el otro useEffect los recarga al cambiar RFC).
---
## 11. Timbres ya no se gastan en emisiones fallidas
**Problema:** `consumeTimbre(tenantId)` se invocaba **antes** de llamar a
Facturapi; si Facturapi rechazaba, el timbre ya estaba descontado. El outer
catch tenía un comentario engañoso que explícitamente decía "No revertir".
**Fix:**
### `apps/api/src/services/facturapi.service.ts`
Nueva función:
```ts
export async function refundTimbre(
tenantId: string,
consumed: { source: 'mensual' | 'paquete'; paqueteId?: number },
): Promise<void>
```
Usa `prisma.$transaction` (igual que `consumeTimbre` → atómico). Tiene guards
para no bajar de 0. Decrementa por fuente exacta (mensual vs paquete específico
via `paqueteId`).
### `apps/api/src/controllers/facturacion.controller.ts`
- `consumeTimbre` ahora captura su retorno en `consumedTimbre`.
- El inner catch (fallo Facturapi) dispara `refundTimbre` fire-and-forget con
log de inconsistencia si el refund mismo falla.
- Removido el comentario engañoso del outer catch + añadida explicación de la
semántica nueva: refund solo aplica a fallo de emisión; errores post-timbrado
(INSERT en `cfdis`) NO hacen refund porque el CFDI ya está sellado en el SAT.
### Tabla de comportamiento nuevo
| Escenario | ¿Gasta timbre? |
|---|---|
| Emisión exitosa | ✅ |
| Facturapi rechaza (validación, CSD, rate inválido, etc.) | ❌ se revierte |
| Error antes del consume (no hay timbres) | ❌ nunca se intentó |
| Error post-timbrado (INSERT falla con CFDI ya sellado) | ⚠️ sí — inconsistencia logeada |
---
## Archivos tocados
### Backend (`apps/api/src/`)
- `controllers/facturacion.controller.ts` — múltiples edits (cambios 1, 6, 7, 9, 11)
- `services/facturapi.service.ts` — +`refundTimbre` (cambio 11)
- `services/contribuyente-facturapi.service.ts` — +4 funciones (cambio 9)
- `services/impuestos.service.ts` — reescrita `getIsrMensual` (cambio 2)
- `services/constancia.service.ts` — fix TS (cambio 3a)
- `services/sat/sat.service.ts` — fix TS (cambio 3b)
- `controllers/impuestos.controller.ts` — propagar `regimenClave` (cambio 2)
- `scripts/backfill-cfdi-contribuyente.ts`**nuevo** (cambio 4)
### Frontend (`apps/web/`)
- `app/(dashboard)/facturacion/page.tsx` — múltiples edits (cambios 1, 6, 10)
- `app/(dashboard)/impuestos/page.tsx` — pasar `regimenSeleccionado` (cambio 2)
- `lib/api/facturacion.ts``searchConceptos` param (cambio 1)
- `lib/api/impuestos.ts``getIsrMensual` param (cambio 2)
- `lib/hooks/use-impuestos.ts` — propagar `regimenClave` (cambio 2)
### Data / externo
- Patito: `INSERT` en `timbre_paquetes` (cambio 5)
- Carlos: `DELETE /certificate` en Facturapi (cambio 8, user recrea manualmente)
---
## Estado TS al cierre
`pnpm typecheck` en `@horux/api`: **0 errores** (primera vez limpio desde antes
de los fixes).
Errores pendientes en `@horux/web` son todos pre-existentes y no relacionados
con esta sesión (cfdi, admin/usuarios, sidebar-compact, etc.).
---
## Pendientes relacionados (para seguimiento)
1. **Org de Carlos en Facturapi** — user la elimina y recrea con CSD correcto;
después actualizar `facturapi_orgs.facturapi_org_id` en BD tenant de Patito.
2. **Prevención del bug de org incorrecta** — agregar validación en
`uploadCsdContribuyente` que verifique que el RFC del certificado coincide
con el RFC del contribuyente (pending_step "legal" + cert mismatch → reject).
3. **Emitir pruebas cross-contribuyente** una vez Carlos esté recreado:
Horux 360 → Carlos → (contribuyente sin org) → validar que timbres pool sea
compartido, formularios se resetean, y mensajes de error son claros.
4. **Backfill ISR hot/cold** — las métricas pre-calculadas de años pasados
(tablas `metricas_mensuales`, `acumuladas_anuales`) pueden estar basadas en
el SQL antiguo de `getIsrMensual`. Verificar si requieren regeneración.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,391 @@
# Sesión 2026-04-22 — Cierre de pendientes y add-ons por contribuyente
Sesión continuación del trabajo de Tanda A / B documentado en
`docs/plans/2026-04-21-session-2-mp-setup-and-bugfixes.md`. Esta sesión cubrió:
1. **Tanda B.2-B.5** — Extensión del cache read-through, alineación
dashboard ≡ impuestos, lock SAT por contribuyente, watchdog CLI
(ver doc 2026-04-21 § "Tanda B.2" en adelante).
2. **Pendientes derivados de hoy** — A, B, C, D + mejora de logging SAT.
3. **Feature: Add-ons por contribuyente** — infraestructura para cobro
recurrente mensual por RFC, con preapproval MP independiente de la
licencia anual del despacho. Primer add-on: Lolita IA ($250/mes).
---
## Pendientes derivados cerrados
### A. Watchdog CLI de SAT stale jobs
**Problema:** jobs `pending` con `nextRetryAt` vencido o `running`
huérfanos (proceso crasheó a mitad del sync) quedaban invisibles y
bloqueaban futuros syncs por el lock.
**Solución:** `apps/api/scripts/sweep-stale-sat-jobs.ts`. Dos categorías
con thresholds sobreescribibles por env:
- `pending` con `nextRetryAt` < now `STALE_PENDING_HOURS` (default 12)
- `running` con `startedAt` < now `STALE_RUNNING_HOURS` (default 4)
Dry-run por default; `--apply` ejecuta. Verificado con un dry-run
limpio (0 stale) mientras Manuel corría.
Pendiente: wiring como cron cada 2h en `sat-sync.job.ts`.
### B. Crons en dev con flag
**Problema:** todos los crons estaban gateados con
`env.NODE_ENV === 'production'`. En dev ningún cron arrancaba — por eso
el job de Alexa (status pending con `nextRetryAt = +5min`) quedó
colgado: el cron horario `retryTimedOutJobs` nunca corrió.
**Solución:** en `apps/api/src/index.ts`, partir el gate en dos:
```ts
const cronsEnabled = env.NODE_ENV === 'production' || process.env.ENABLE_CRONS_IN_DEV === '1';
const sendRealEmails = env.NODE_ENV === 'production';
if (cronsEnabled) {
startSatSyncJob();
startMetricasInvalidationsJob();
if (sendRealEmails) startWeeklyUpdateJob();
}
```
`weekly-update` sigue prod-only para no mandar emails a owners reales
desde dev. Confirmado al restart: `[Cron] Jobs omitidos en dev (usar
ENABLE_CRONS_IN_DEV=1 para activar)`.
### C. Cache en `getIvaMensual` + refactor a fórmula canónica
**Problema doble:** la fórmula de `getIvaMensual` divergía de la del
dashboard/impuestos (no filtraba PUE, no manejaba NC, retenido gross).
Además no leía de `metricas_mensuales` para años cerrados.
**Solución:**
- Reescribir con los 6 buckets canónicos (ver `getResumenIva` en 2026-04-21 § Tanda B.3).
- Cache read-through desde `metricas_mensuales` cuando año < actual,
sin conciliación, con contribuyente seleccionado. Helper nuevo
`readIvaMensualFromCache` agrega T/A/R por mes.
- On-the-fly: 2 queries (una por lado causado/acreditable) grouped por mes.
`getIsrMensual` y `getResumenIsr` siguen on-the-fly — requieren tarifas
progresivas y no están en `metricas_mensuales`. Fuera de alcance.
### D. Cache en `calcularFlujoPorMes` — **fuera de alcance**
**Problema:** `calcularFlujoPorMes` usa `total_mxn`/`monto_pago_mxn`
(IVA incluido) pero los campos stored `flujo_entradas/salidas/neto` en
`metricas_mensuales` se poblan desde ingresos/egresos NETOS (sin IVA).
**Decisión:** no cachear hasta tener columnas `flujo_bruto_*`
separadas o reescribir el concepto. El cómputo on-the-fly ya es
eficiente (6 queries agregadas por año). Costo/beneficio no lo justifica
ahora. Documentado como pendiente.
### Extra. Logging informativo de rejections SAT
**Problema:** durante el sync de Manuel, 9 bloques consecutivos de
emitidos cayeron en `rejected`. El mensaje `verifyResult.message` era
el genérico `"Solicitud Aceptada"` del wrapper HTTP. La razón real
(códigos 5001, 5002, 5003, 5005, etc.) quedaba enterrada.
**Solución:** en `sat-client.service.ts:verifySatRequest`, cuando
`status` es `rejected`/`failed`, construir el message con
`SAT code=N request=EntryId(value) msg="..."` que incluye:
- `statusCode` (código numérico SAT)
- `entryId` (etiqueta del `StatusRequest`)
- `value` (valor numérico del `StatusRequest`)
- `msg` (mensaje del wrapper, ya existente)
---
## Feature: Add-ons por contribuyente
### Modelo de negocio
- **Lolita IA** — $250/mes por cada contribuyente que lo active.
Cualquier plan puede contratarlo.
- **Contribuyente adicional Business Cloud** — $45/mes por RFC extra
(el plan incluye 3; del 4º en adelante). Automático por count,
no opt-in. Modelado como add-on para que el preapproval MP lo cubra.
Ambos add-ons son **mensuales**; la licencia del despacho es **anual**.
→ Requieren preapproval MP **independiente** por add-on — cancelación
granular sin tocar la suscripción base.
### Ruta descartada
Primer intento fue una tabla tenant `contribuyente_addons
(contribuyente_id, addon_key, enabled, config)` con feature-toggles
(facturación/conciliación/documentos/calendario/reportes). Modelo
incorrecto: los add-ons reales son servicios de cobro recurrente, no
switches de features. Revertido completo antes de iterar.
### Ruta correcta — extender `SubscriptionAddon` existente
Ya existía infraestructura a nivel tenant (`plan_addon_catalogo` +
`subscription_addons` + `addon.service.ts` con preapproval MP por
add-on). Extensión:
**Schema (Prisma):**
```prisma
model SubscriptionAddon {
contribuyenteId String? @map("contribuyente_id") // NULL = tenant-level
// ... resto igual
@@index([subscriptionId, contribuyenteId])
// @@unique([subscriptionId, planAddonCatalogoId]) ← REMOVIDO
}
```
Sin `@@unique` compuesto porque Postgres trata `NULL != NULL` y no hay
forma trivial de enforcar "un solo addon activo por (sub, codename,
contribuyente?)" con Prisma. Validación queda a nivel app en
`subscribeAddon.findFirst`.
**Migration SQL:**
- Agrega `contribuyente_id TEXT NULL`
- Elimina UNIQUE(subscription_id, plan_addon_catalogo_id)
- Agrega índice (subscription_id, contribuyente_id)
**Catálogo (seed.ts):** 2 nuevos add-ons:
- `lolita_ia_contribuyente` — $250/mes, `verticalProfile=CONTABLE`
- `contribuyente_extra_business_cloud` — $45/mes, `verticalProfile=CONTABLE`
**Service (`addon.service.ts`):**
- `subscribeAddon` acepta `contribuyenteId: string | null`. El reason
del preapproval incluye prefix del RFC cuando aplica
(`"Horux Despachos - Lolita IA (RFC abcd1234) x1 - Zorro Despacho"`).
- `listActiveAddons(tenantId, contribuyenteId?)` filtra por RFC cuando
se pasa el param. Sin param → retorna todos los add-ons del tenant
(incluye tenant-level y per-contribuyente).
- La validación "ya tienes activo" ahora considera `contribuyenteId`:
mismo addon en 2 contribuyentes distintos es OK; 2 veces para el
mismo contribuyente rechaza.
**Controller (`subscription.controller.ts`):**
- `GET /subscriptions/me/addons?contribuyenteId=...` — filtra por RFC.
- `POST /subscriptions/me/addons` acepta `{ addonCodename, quantity,
contribuyenteId }` en body.
**Frontend:**
- `apps/web/lib/api/addons.ts` + `use-addons.ts` hooks.
- `apps/web/app/(dashboard)/contribuyentes/addons-dialog.tsx`:
catálogo `ADDONS_POR_CONTRIBUYENTE` (hoy solo Lolita IA). Muestra
precio, descripción, estado (activo/pending/sin contratar), fecha
del próximo cobro. Botón "Contratar" abre MP init_point en nueva
pestaña; "Cancelar" pide confirmación y revoca el preapproval.
- `contribuyentes/page.tsx`: botón `Sparkles` por contribuyente abre
el dialog.
### Cableado automático del overage Business Cloud
El add-on `contribuyente_extra_business_cloud` ($45/mes) ahora se ajusta
automáticamente al crear o desactivar un contribuyente.
**Modelo:** un único `SubscriptionAddon` a nivel tenant
(`contribuyenteId = null`, `codename = contribuyente_extra_business_cloud`)
con `quantity = max(0, activeCount 3)`. El monto del preapproval MP
refleja `precio × quantity`. Cuando `quantity` cambia, se actualiza vía
`updatePreapprovalAmount` (sin re-autorización del usuario).
**Función:** `adjustBusinessCloudOverage(tenantId, activeContribuyenteCount)`
en `addon.service.ts`. Idempotente. Maneja los 5 casos:
- Plan ≠ `business_cloud` → `'skipped'`
- `overage = 0` sin addon → `'none'`
- `overage = 0` con addon → `'cancelled'` (revoca preapproval)
- `overage > 0` sin addon → `'created'` (crea addon + preapproval, retorna `paymentUrl`)
- `overage > 0` con addon, quantity ya coincide → `'none'` (idempotente)
- `overage > 0` con addon, quantity distinto → `'updated'` (updatePreapprovalAmount)
**Integración:**
- `contribuyente.controller.ts:create` y `:deactivate` llaman
`countActiveContribuyentes(pool)` + `adjustBusinessCloudOverage(tenantId, count)`
tras la operación. Fail-soft: si el ajuste falla, el contribuyente queda
creado/desactivado y el error se loguea (no bloquea la respuesta).
- Frontend (`contribuyentes/page.tsx`): si `result.overage.action === 'created'`
+ `paymentUrl`, muestra alerta y abre MP en nueva pestaña. Para `'updated'`
o `'cancelled'` muestra toast informativo.
**Transparencia de cobro:**
- Plan `business_cloud` = $15K/año (licencia, anual).
- Addon overage = $45/mes × quantity (mensual).
- MercadoPago cobra ambos independientemente. Cancelar la licencia
cancela su preapproval; cancelar RFCs baja el quantity del addon
automáticamente.
### Casos de uso validados
| Escenario | Estado |
|---|---|
| Tenant nuevo, crea 3 RFCs | Sin addon (no excede) |
| Crea 4º RFC → overage=1 | Addon `created`, paymentUrl devuelto, $45/mes pending |
| Crea 5º RFC → overage=2 | Addon `updated`, `updatePreapprovalAmount($90)` |
| Desactiva 1 (quedan 4) → overage=1 | Addon `updated`, `updatePreapprovalAmount($45)` |
| Desactiva otro (quedan 3) → overage=0 | Addon `cancelled`, preapproval MP revocado |
| Tenant en `business_control` crea 10º RFC | `skipped` (plan no aplica) |
| Tenant sin suscripción activa | `skipped` (catch-all) |
---
## Puesta en marcha de datos para testing
### Backfill de suscripciones de despacho
Los tenants Zorro (`DESPACHO_MO7JE8BZ_VDOPR`) y Patito (`DESPACHO_MO3NI6U8_B9VGG`)
fueron provisionados directamente como admin (sin pasar por el flujo
self-serve de MP), por lo que no tenían `Subscription` en BD central. Esto
bloqueaba el testing de add-ons (gate en `subscribeAddon`).
Se insertaron manualmente suscripciones `authorized` con `mpPreapprovalId=null`
(licencia por arreglo directo, cobro de add-ons va por separado):
| Tenant | Plan | Amount | Frequency | Period |
|---|---|---|---|---|
| Zorro | `business_cloud` | $15,000 | annual | 2026-04-23 → 2027-04-23 |
| Patito | `business_control` | $21,000 | annual | 2026-04-23 → 2027-04-23 |
`Tenant.plan` también se actualizó al valor correcto (antes Zorro estaba
en `enterprise` y Patito en `business`).
### Configuración MercadoPago sandbox
Agregado a `.env`:
```
MP_ACCESS_TOKEN=TEST-...
```
**Gotcha descubierto:** MP rechaza `http://localhost:3000` como `back_url`
del preapproval (requiere HTTPS público). Durante el testing se cambió
`FRONTEND_URL` a `https://horuxfin.com` temporalmente y se revirtió al
terminar. Solución durable pendiente (doc más abajo).
### Add-ons Lolita IA activos
| Contribuyente | Despacho | addonId | preapprovalId | status |
|---|---|---|---|---|
| Alexa G. Torres Romero (TORA0007099R6) | Zorro | `0cfb5c0b-…` | `b0dd70c3…` | `authorized` |
| Carlos H. Torres Romero (TORC9611214CA) | Patito | `17ed5185-…` | `48e20f17…` | `authorized` |
Preapprovals reales en MP sandbox. Status movido manualmente a `authorized`
via `handleAddonPayment(addonId, 'manual-sim', 'authorized')` porque no hay
webhook configurado. En prod esto lo hace automáticamente
`POST /api/webhooks/mercadopago`.
Period mensual: 2026-04-23 → 2026-05-23. El próximo ciclo se renovaría con
MP webhook real (pendiente Cloudflare Tunnel).
---
## Archivos tocados esta sesión
### Backend
- `apps/api/src/index.ts` — gate de crons con `ENABLE_CRONS_IN_DEV`
- `apps/api/src/services/sat/sat-client.service.ts` — rejection logging informativo
- `apps/api/src/services/impuestos.service.ts` — `getIvaMensual` refactor + cache (helper `readIvaMensualFromCache`); constantes SQL elevadas a file-level en Tanda B.3 (§ sesión 2026-04-21)
- `apps/api/src/services/dashboard.service.ts` — (ver Tanda B.3 en sesión 2026-04-21)
- `apps/api/src/services/sat/sat.service.ts` — (ver Tanda B.4 en sesión 2026-04-21)
- `apps/api/src/services/metricas.service.ts` — (ver Tanda A bugfix en sesión 2026-04-21)
- `apps/api/src/services/payment/addon.service.ts` — `contribuyenteId` en `subscribeAddon` + `listActiveAddons`; **nueva función `adjustBusinessCloudOverage`** para cableado automático del overage
- `apps/api/src/controllers/subscription.controller.ts` — `getMyAddons` + `addMyAddon` aceptan contribuyenteId
- `apps/api/src/controllers/contribuyente.controller.ts` — `create` y `deactivate` llaman `adjustBusinessCloudOverage` tras la operación; helper `countActiveContribuyentes`
- `apps/api/prisma/schema.prisma` — `SubscriptionAddon.contribuyenteId` opcional
- `apps/api/prisma/migrations/20260422172323_subscription_addons_contribuyente_id/migration.sql` — **nuevo**
- `apps/api/prisma/seed.ts` — 2 addons nuevos
- `apps/api/scripts/sweep-stale-sat-jobs.ts` — **nuevo** (watchdog CLI)
- `apps/api/scripts/validate-dashboard-impuestos.ts` — (ver Tanda B.3 en sesión 2026-04-21)
### Frontend
- `apps/web/lib/api/addons.ts` — **nuevo** (cliente API)
- `apps/web/lib/hooks/use-addons.ts` — **nuevo** (hooks React Query)
- `apps/web/app/(dashboard)/contribuyentes/addons-dialog.tsx` — **nuevo**
- `apps/web/app/(dashboard)/contribuyentes/page.tsx` — botón Sparkles + wiring del dialog
### Data directa
- `horux_despachos` (central):
- `planAddonCatalogo` upsert con 2 filas nuevas (`lolita_ia_contribuyente`
$250/mes, `contribuyente_extra_business_cloud` $45/mes). Aplicado vía
script temporal ya borrado.
- `subscriptions` INSERT manual para Zorro (`business_cloud`, $15K/año) y
Patito (`business_control`, $21K/año). Status `authorized`,
`mpPreapprovalId=null`. Script temporal borrado.
- `Tenant.plan` UPDATE en Zorro (de `enterprise`) y Patito (de `business`)
al plan real.
- `subscription_addons` INSERT para Alexa (Zorro) y Carlos (Patito) con
codename `lolita_ia_contribuyente`, preapproval MP real (sandbox).
Posteriormente se marcaron `authorized` simulando el webhook (script
temporal que llama `handleAddonPayment(id, 'manual-sim', 'authorized')`,
ya borrado).
- `.env`:
- Agregado `MP_ACCESS_TOKEN` (sandbox).
- `FRONTEND_URL` cambiado temporalmente a HTTPS y revertido a localhost
al cerrar. **Próxima vez que se teste MP en dev:** cambiarlo a una URL
HTTPS pública (Cloudflare Tunnel, ngrok) o a `https://horuxfin.com`.
### Documentación
- `docs/plans/2026-04-21-session-2-mp-setup-and-bugfixes.md` — extendido
con Tanda B.2-B.5 y referencia al add-on model.
- `docs/Horux_despachos-vs-Horux360.md` — extendido con §9 crons dev,
§10 rejection logging, §11 getIvaMensual refactor. Add-ons
NO incluidos (exclusivos del fork multi-contribuyente).
---
## Pendientes vigentes al cierre
### Derivados de hoy
- ✅ Wiring del watchdog (`sweep-stale-sat-jobs.ts`) como cron cada 2h en
`sat-sync.job.ts` — completado 2026-04-23 (refactorizado a función
exportable `sweepStaleSatJobs` en `services/sat/sweep-stale-jobs.service.ts`;
cron `WATCHDOG_CRON_SCHEDULE = '0 */2 * * *'` en `startSatSyncJob`).
- ✅ Cableado automático del add-on `contribuyente_extra_business_cloud`
completado en esta sesión.
- **Cloudflare Tunnel en prod** para `MP_NOTIFICATION_URL` — endpoint
`POST /api/webhooks/mercadopago`. Sin esto, addons pagados en MP se
quedan `pending` en BD hasta que manualmente se llame `handleAddonPayment`.
- `FRONTEND_URL` en dev vs MP sandbox — MP rechaza `http://localhost`.
Solución durable: setear una URL HTTPS de dev (Cloudflare Tunnel,
ngrok) o un dominio propio permanente.
- **Investigación SAT rejections** — completado 2026-04-23:
- `sat-client.service.ts:verifySatRequest` ahora expone `codeRequest`
(método `getCodeRequest()` de la lib) con su valor numérico + entryId
+ message descriptivo en el debug log y en el error message. Los 5
códigos SAT posibles son: `5000 Accepted`, `5002 Exhausted`, `5003
MaximumLimit`, `5004 EmptyResult`, `5005 Duplicated`.
- Patrón observado en Manuel: 9 rejections de emitidos (bloques 3-9
y 12-13), pero bloques 10-11 sí funcionaron — NO es rate limit
constante. Hipótesis más probable: **5005 Duplicated** (solicitudes
previas stale para rangos similares que quedaron huérfanas y nuevo
re-sync es considerado duplicado por el SAT). Requiere capturar
un caso nuevo con el código mejorado para confirmar.
- Si se confirma 5005: solución es limpiar solicitudes previas en el
SAT antes de reintentar (no trivial — SAT no ofrece endpoint de
cancelación), o esperar ~72h entre intentos. Si es 5003 (MaximumLimit):
reducir tamaño de rango. Si es 5002 (Exhausted): cambiar FIEL /
esperar 24h.
- Re-sync custom de los rangos de emitidos faltantes de Manuel (bloques
3-9 del XML initial) — pendiente, depende del diagnóstico del punto
anterior (capturar el `codeRequest` real cuando vuelva a ocurrir).
- ✅ Validación preventiva CSD↔RFC en `uploadCsdContribuyente` —
completado 2026-04-23. Ahora valida: (1) cert no es FIEL, (2) RFC del
cert coincide con contribuyente, (3) no vencido. Mensajes de error
específicos. Usa `@nodecfdi/credentials`.
- Recomputar overage al cambiar de plan (ej. downgrade business_cloud →
business_control debería cancelar el addon overage si existe). Hoy
solo se dispara desde create/deactivate contribuyente.
### De sesiones anteriores (abiertos)
- Recrear org Facturapi de Carlos (TORC9611214CA)
- Validación preventiva CSD↔RFC en `uploadCsdContribuyente`
- Prueba cross-contribuyente end-to-end
- Typecheck web cleanup (~12 errores preexistentes en sidebar/cfdi/usuarios)
### Features pending
Ver `docs/plans/2026-04-19-pending-features.md`. De esa lista:
-#8 Extras en Documentos — completado en sesión anterior
-#5 Add-ons por contribuyente — **Lolita IA completado hoy**; falta
overage business_cloud automático
- #1 Editar contribuyentes asignados a cliente
- #2 Convertir Pendientes → Despacho con métricas
- #6 Enlazar obligaciones ↔ declaraciones
- #7 Colores obligaciones en calendario
- #9 Avisos por correo al subir declaración / doc extra
- #10 Alertas de obligaciones — bug de filtros per-contribuyente

View File

@@ -0,0 +1,297 @@
# Sesión 2026-04-23 — Cierre de features pending + derivados técnicos
Sesión extensa que cerró **4 features pending** (#6 #7 #9 + investigación #10)
y **3 derivados técnicos** (#3 watchdog cron, #4 validación CSD, #5 logging SAT),
además de 7 bugfixes descubiertos en el proceso.
## 1. Feature #6 — Enlazar obligaciones ↔ declaraciones (trazabilidad)
El backend del matching impuesto→obligación ya existía (`completarObligacionesPorDeclaracion`
en `declaraciones.service.ts` hacía el UPSERT en `obligacion_periodos`). Faltaba la
trazabilidad reversa: poder clickear una obligación completada y ver la declaración
que la cubrió.
**Migration 030** `obligacion_periodos.declaracion_id INT REFERENCES
declaraciones_provisionales(id) ON DELETE SET NULL` + índice parcial.
**Backend:**
- `completarObligacionesPorDeclaracion` ahora acepta `declaracionId` y lo persiste
en el INSERT/UPDATE del `ON CONFLICT`.
- `createDeclaracion` pasa `declaracion.id` tras el INSERT.
- `getObligacionesPorPeriodo` hace `LEFT JOIN declaraciones_provisionales` y devuelve
un objeto `declaracion: DeclaracionLink | null` por cada periodo completado
(nuevo tipo exportado).
**Frontend (`/pendientes/page.tsx`):**
- `ObligacionPeriodo` extendido con `declaracion: DeclaracionLink | null`.
- Link `↗ Declaración MM/YYYY [Compl.]` junto a obligaciones completadas con FK.
Click abre PDF en nueva pestaña vía `/documentos/declaraciones/:id/pdf/declaracion`.
**Semántica:** `ON DELETE SET NULL` — el periodo sigue marcado completado si la
declaración se borra, pero pierde la referencia. El usuario decide si reabrirlo.
---
## 2. Feature #7 — Calendario con colores por status
Backend + frontend **ya estaban implementados** end-to-end (tipos
`obligacion-pendiente|completada|atrasada` generados desde `obligacion_periodos`,
colores amber/green/red mapeados). Solo agregué 2 mejoras UX:
- **`AlertTriangle` icon** para `obligacion-atrasada` (antes usaba el mismo
`Clock` que pendiente).
- **Leyenda de colores** visible en el header del grid del calendario:
amber/green/red + violet (recordatorio custom).
---
## 3. Feature #10 — Alertas obligaciones per-contribuyente (investigado)
El bug reportado "las alertas manuales muestran más obligaciones de las que
tiene el contribuyente" **no se reproduce** al 2026-04-23.
Auditoría SQL sobre los despachos activos:
- 0 alertas `ob-*` con obligacion_id inexistente (huérfanas)
- 0 alertas para obligaciones `activa=false`
- 0 alertas para periodos ya completados
- Count per-contribuyente: alertas ≤ obligaciones activas en todos los casos
Protecciones verificadas en código:
1. `removeObligacion` hace soft-delete + DELETE alertas + DELETE periodos
2. `inactiveFilter` en `getAlertasManualesPendientes` excluye por `activa=false`
3. `contribuyenteFilter` strict previene leak cross-contribuyente
4. `sincronizarDesdeObligacionesContribuyente` solo genera current + previous month
Probablemente fue arreglado implícitamente al agregar el cleanup en
`removeObligacion` en una sesión anterior. Si reaparece el síntoma, hay que
capturar el drift específico antes de tocar código.
---
## 4. Feature #9 — Emails al subir declaración / documento extra
**Destinatarios:** owners activos del despacho + supervisor del contribuyente
(`entidades_gestionadas.supervisor_user_id`), excluyendo al uploader mismo.
**Archivos:**
- `services/email/templates/documento-subido.ts`**nuevo**, template
parametrizable por `kind: 'declaracion' | 'extra'`, con bloques condicionales
para periodo/impuestos/monto (declaración) o nombre/categoría/descripción
(extra). HTML escapado para evitar XSS.
- `services/email/email.service.ts:sendDocumentoSubido(recipients, data)`
loop por recipient con try/catch individual.
- `utils/memberships.ts`:
- `getTenantOwnerEmails(tenantId)`**nuevo**, lista todos los owners.
- `getUserEmailById(userId)`**nuevo**, resolver supervisor.
- `services/notify-upload.service.ts:notifyDocumentoSubido(...)`**nuevo**
orquestador: resuelve recipients, dedupea, excluye uploader, envía.
- `controllers/documentos.controller.ts`:
- `crearDeclaracion` y `crearExtra` disparan notify con `.catch()` (fire-and-forget).
**Dev mode:** sin SMTP configurado, transport de `@horux/core` loguea a consola.
---
## 5. Bugfixes descubiertos en el camino
### 5.1 `feature-gate.middleware.ts` crash con planes despacho
`hasFeature(plan)` asumía `PLANS[plan]` siempre existe. Con planes `business_cloud`/
`business_control` (que viven en `DESPACHO_PLANS`, no `PLANS`) → `undefined.features`
→ crash del API. Fix en 2 capas:
- Middleware ahora detecta plan despacho y rutea a `hasDespachoFeature`.
- `shared/constants/plans.ts:hasFeature` defensive con `?.` + `?? false`.
### 5.2 Declaraciones sin filtro por contribuyente
`declaraciones_provisionales` no tenía columna `contribuyente_id`; todas las
declaraciones se mezclaban entre RFCs de un despacho.
**Migration 031** agrega `contribuyente_id UUID NULL` con `ON DELETE SET NULL`.
Reemplaza el UNIQUE `(año, mes) WHERE tipo='normal'` por
`(año, mes, contribuyente_id) WHERE tipo='normal'` para que cada RFC tenga su
propia normal mensual.
Backend + controller + API client + hook + UI actualizados para pasar
`contribuyenteId` end-to-end. Declaraciones legacy quedan con NULL
(interpretadas como "tenant-wide / legacy") — invisibles cuando se filtra por
un RFC específico.
### 5.3 `completada_por` UUID/email mismatch
`obligacion_periodos.completada_por` es UUID, pero `createDeclaracion` pasaba
el email del usuario. Crash silencioso con `invalid input syntax for type uuid:
"jd@demo.com"`.
Separé en 2 campos:
- `creadoPor: string` (email) — va a `declaraciones_provisionales.creado_por VARCHAR`.
- `creadoPorUserId: string` (UUID) — va a `obligacion_periodos.completada_por UUID`.
### 5.4 Alertas de pago desaparecían al subir declaración normal con monto>0
La llamada a `completarObligacionesPorDeclaracion` se hacía SIEMPRE que hubiera
`contribuyenteId`, incluyendo declaraciones normales cuyo pago aún está pendiente.
Esto cerraba tanto alertas `decl-*` como `ob-*` (per-obligación), dejando al
usuario sin visibilidad del pago pendiente.
**Fix:** `completarObligacionesPorDeclaracion` solo se llama si la declaración
cubre el pago (`tipo='complementaria' || montoPago=0`). Además,
`uploadComprobantePago` ahora también llama `completarObligacionesPorDeclaracion`
para cerrar las obligaciones cuando se sube el comprobante de pago.
### 5.5 Keyword matching IVA → DIOT y mensual → anual
Subir una declaración con `impuestos: ['IVA']` cerraba también obligaciones
"Declaración de proveedores de IVA" (DIOT) y "Declaración anual de ISR" porque
la sustring "iva"/"isr" matchaba demasiado amplio.
**Fix doble:**
1. `IMPUESTO_A_OBLIGACION_KEYWORDS` ahora tiene `include + exclude`:
- `IVA: include=['iva'], exclude=['diot','proveedores de iva','informativa']`
- `ISR: include=['isr'], exclude=['retenciones','asimilados a salarios']`
2. Filtro por `periodicidad``obligacion.frecuencia`: una declaración mensual
no cierra obligaciones anuales.
Datos revertidos en BD: 3 obligaciones que se habían marcado completadas por
error (Husberto DIOT, Husberto anual ISR, Horux 360 anual ISR).
### 5.6 Drill-down: falta tipo E + columna Monto Pago
Los drill-downs desde dashboard/impuestos pasaban `tipoComprobante: 'I,P'`
excluían tipo E (notas de crédito), que sí entran en los cálculos de ingresos
(NC recibida) y gastos (NC emitida).
**Fix:**
- Nuevo param `bucket` en `GET /cfdi/drill-down`:
- `bucket=ingresos``(EMIT I PUE) + (EMIT P) + (RECIB E PUE)`
- `bucket=gastos``(RECIB I PUE) + (RECIB P) + (EMIT E PUE)`
- Aliases `causado`/`acreditable`
- 6 links en dashboard/impuestos migrados de `type+tipoComprobante+metodoPago`
a `bucket`.
- Nueva columna "Monto Pago" en la tabla drill-down — sortable, solo muestra
valor para tipo P. Excel export incluye la columna.
---
## 6. Derivado #3 — Cron watchdog SAT
Refactoricé la lógica del script CLI `scripts/sweep-stale-sat-jobs.ts` a
función exportable en **`services/sat/sweep-stale-jobs.service.ts:sweepStaleSatJobs`**.
El CLI ahora es un thin wrapper que reusa la función. El cron
`WATCHDOG_CRON_SCHEDULE = '0 */2 * * *'` en `sat-sync.job.ts` llama
`sweepStaleSatJobs({ apply: true })` cada 2 horas. Thresholds vía env:
`STALE_PENDING_HOURS` (default 12h) y `STALE_RUNNING_HOURS` (default 4h).
Gate del cron: usa el mismo `cronsEnabled` del `index.ts` (prod, o `ENABLE_CRONS_IN_DEV=1`).
---
## 7. Derivado #4 — Validación CSD↔RFC en uploadCsdContribuyente
Antes: el contador subía un CSD, Facturapi devolvía "Certificado no válido"
sin decir por qué. Podría ser RFC mismatch, FIEL subida en lugar de CSD, o
cert vencido.
Ahora `uploadCsdContribuyente` valida **antes** de subir a Facturapi usando
`@nodecfdi/credentials`:
1. Parseable + password correcta
2. `!credential.isFiel()` — debe ser CSD, no FIEL
3. `certificate.rfc() === contribuyente.rfc` (uppercase, strict)
4. `validToDateTime() > now` — no vencido
Cada fallo tiene un mensaje específico. Facturapi sigue validando también;
nuestra capa es defense-in-depth con diagnóstico mejor.
---
## 8. Derivado #5 — Investigación SAT rejections (logging mejorado)
La librería `@nodecfdi/sat-ws-descarga-masiva` expone `verifyResult.getCodeRequest()`
con los 5 códigos SAT específicos del estado de la solicitud:
- `5000 Accepted` — solicitud recibida con éxito
- `5002 Exhausted` — agotadas solicitudes de por vida (mismos parámetros)
- `5003 MaximumLimit` — tope máximo de CFDI/Metadata
- `5004 EmptyResult` — no hay información en el rango
- `5005 Duplicated` — solicitud duplicada (existe una vigente con mismos params)
Antes `verifySatRequest` solo loggeaba `getStatus().getCode()` que es el
HTTP-wrapper status (siempre 5000 "Aceptada" si la llamada HTTP funciona).
Ahora también captura `codeRequest.getValue()` + `getEntryId()` + `getMessage()`
en el debug log y en el error message cuando status es `rejected`/`failed`.
**Patrón observado en Manuel (sin datos del nuevo log):** 9 rejections de
emitidos en bloques 3-9 + 12-13, pero 10-11 funcionaron. NO es rate-limit
constante. **Hipótesis más probable:** `5005 Duplicated` — solicitudes previas
stale del SAT que interfieren con nuevas.
Acción pendiente: capturar un caso nuevo con el log mejorado para confirmar
y decidir estrategia (limpiar solicitudes previas, esperar 72h entre intentos,
reducir rangos, etc.).
---
## Archivos tocados
### Backend
- `apps/api/src/migrations/tenant/030_obligacion_periodos_declaracion_id.sql`**nuevo** (#6)
- `apps/api/src/migrations/tenant/031_declaraciones_contribuyente_id.sql`**nuevo** (fix 5.2)
- `apps/api/src/services/declaraciones.service.ts``completarObligacionesPorDeclaracion` con declaracionId + periodicidad filter + include/exclude keywords; `createDeclaracion` con `creadoPorUserId`; `listDeclaraciones` con contribuyenteId filter; `uploadComprobantePago` llama completar obligaciones
- `apps/api/src/services/obligaciones.service.ts``getObligacionesPorPeriodo` con LEFT JOIN declaraciones + tipo `DeclaracionLink`
- `apps/api/src/controllers/documentos.controller.ts``listarDeclaraciones` lee contribuyenteId; `crearDeclaracion`/`crearExtra` disparan notify; `subirComprobantePago` pasa uploadedByUserId
- `apps/api/src/services/email/templates/documento-subido.ts`**nuevo** (#9)
- `apps/api/src/services/email/email.service.ts``sendDocumentoSubido`
- `apps/api/src/utils/memberships.ts``getTenantOwnerEmails`, `getUserEmailById`
- `apps/api/src/services/notify-upload.service.ts`**nuevo** (#9 orquestador)
- `apps/api/src/middlewares/feature-gate.middleware.ts` — ruteo catálogo despacho/Horux 360 (fix 5.1)
- `packages/shared/src/constants/plans.ts``hasFeature` defensive
- `apps/api/src/controllers/cfdi.controller.ts` — nuevo param `bucket` en drillDown (fix 5.6)
- `apps/api/src/services/sat/sweep-stale-jobs.service.ts`**nuevo** (#3)
- `apps/api/src/jobs/sat-sync.job.ts` — cron watchdog cada 2h (#3)
- `apps/api/scripts/sweep-stale-sat-jobs.ts` — refactorizado a thin CLI wrapper (#3)
- `apps/api/src/services/contribuyente-facturapi.service.ts``uploadCsdContribuyente` con validaciones pre-Facturapi (#4)
- `apps/api/src/services/sat/sat-client.service.ts``verifySatRequest` expone `codeRequest` (#5)
### Frontend
- `apps/web/app/(dashboard)/pendientes/page.tsx` — tipo `DeclaracionLink` + link ↗ (#6)
- `apps/web/app/(dashboard)/calendario/page.tsx` — ícono AlertTriangle + leyenda (#7)
- `apps/web/app/(dashboard)/dashboard/page.tsx` — drill links usan `bucket` (fix 5.6)
- `apps/web/app/(dashboard)/impuestos/page.tsx` — drill links usan `bucket` (fix 5.6)
- `apps/web/app/(dashboard)/drill-down/page.tsx` — columna "Monto Pago" + sort (fix 5.6)
- `apps/web/app/(dashboard)/documentos/page.tsx` — DeclaracionesTab pasa `selectedContribuyenteId` (fix 5.2)
- `apps/web/lib/api/declaraciones.ts``listDeclaraciones` con contribuyenteId (fix 5.2)
- `apps/web/lib/hooks/use-declaraciones.ts` — hook con contribuyenteId (fix 5.2)
### Documentación
- `docs/plans/2026-04-19-pending-features.md` — 4 secciones marcadas ✅ (#6 #7 #9 #10)
- `docs/plans/2026-04-22-pendientes-y-addons.md` — derivados actualizados con #3 #4 #5
- `docs/plans/2026-04-23-features-fixes-y-derivados.md`**este doc**
### Data directa
- Zorro + Patito: `UPDATE obligacion_periodos` para revertir 3 filas completadas
por error (DIOT + 2 anuales de ISR) — sus alertas `ob-*` reabiertas.
- Declaraciones legacy con `contribuyente_id=NULL` se dejaron como están
(test data, no se eliminaron).
### Migrations aplicadas a tenants
- `030_obligacion_periodos_declaracion_id` (Zorro, Patito)
- `031_declaraciones_contribuyente_id` (Zorro, Patito)
---
## Pendientes al cierre
### Features pending (de 2026-04-19) — solo queda 1 abierto
- **#2** Convertir `/pendientes` → "Despacho" con métricas cross-contribuyente.
### Derivados abiertos — 10
| Prioridad | Derivado |
|---|---|
| Alta | Cloudflare Tunnel en prod para `MP_NOTIFICATION_URL` |
| Alta | Recrear org Facturapi de Carlos (TORC9611214CA) |
| Media | Prueba cross-contribuyente end-to-end |
| Media | Re-sync custom de rangos emitidos de Manuel (depende de capturar `codeRequest` nuevo) |
| Baja | Recomputar overage al cambiar de plan |
| Baja | `FRONTEND_URL` dev HTTPS permanente |
| Baja | Typecheck web cleanup (~12 errores) |
| Baja | Declaraciones legacy con contribuyente_id=NULL |
| Baja | Flag por despacho para activar/desactivar emails |
| Baja | Email para declaraciones sin contribuyenteId |
**Cerrados hoy:** #3 watchdog cron, #4 validación CSD, #5 logging SAT, 6 bugfixes.

View File

@@ -0,0 +1,861 @@
# Sesión 2026-04-24 — Fixes y features
Cambios hechos en `Horux_despacho` durante la sesión del 23-24 de abril 2026.
Cubre tanto lógica fiscal como tema específico del fork (multi-contribuyente,
Facturapi, filtro por RFC).
Los cambios **portables a Horux 360** están también en
`docs/Horux_despachos-vs-Horux360.md` (§12-§17 y §19 parcial). Este doc
consolida **todo** lo del día, incluyendo fork-específicos.
---
## Índice
1. [Storage de CfdiRelacionados (CFDI 4.0)](#1-storage-de-cfdirelacionados-cfdi-40)
2. [Saldo real en CxP/CxC](#2-saldo-real-en-cxpcxc)
3. [Tratamiento I/07 y E/07 en ingresos/gastos](#3-tratamiento-i07-y-e07-en-ingresosgastos)
4. [Drill-down consistente con KPIs](#4-drill-down-consistente-con-kpis)
5. [Cache de métricas: DELETE antes de calcular](#5-cache-de-métricas-delete-antes-de-calcular)
6. [Facturapi multi-contribuyente](#6-facturapi-multi-contribuyente-fork-específico)
7. [Filtro inclusivo por RFC en dashboard (primera iteración)](#7-filtro-inclusivo-por-rfc-fork-específico)
8. [Fix zona horaria en parser SAT](#8-fix-zona-horaria-en-parser-sat-portable)
9. [Refactor completo: RFC como fuente de verdad (fases 1-4)](#9-refactor-completo-rfc-como-fuente-de-verdad-fork-específico)
10. [Scripts nuevos](#10-scripts-nuevos)
11. [Validaciones hechas](#11-validaciones-hechas)
12. [Pendientes activos](#12-pendientes-activos)
13. [Cache invalidations del día](#13-cache-invalidations-del-día)
14. [Alerta: TipoRelacion sospechoso en notas de crédito](#14-alerta-tiporelacion-sospechoso-en-notas-de-crédito-portable)
15. [Facturapi save post-emit usando parseXml](#15-facturapi-save-post-emit-usando-parsexml-portable)
16. [Pivote a Método A en Grupo 1 ingresos](#16-pivote-a-método-a-en-grupo-1-ingresos-portable)
17. [Clamp defensivo del IVA en complementos P](#17-clamp-defensivo-del-iva-en-complementos-p-portable)
18. [Método A en gastos y adquisiciones](#18-método-a-en-gastos-y-adquisiciones-portable)
19. [Fix base gravable en histórico ISR (RESICO PM)](#19-fix-base-gravable-en-histórico-isr-resico-pm-portable)
---
## 1. Storage de CfdiRelacionados (CFDI 4.0)
**Portable** — detalle en vs doc §12.
Migración 032 agrega `cfdi_tipo_relacion VARCHAR(2)` y `cfdis_relacionados TEXT`
a la tabla `cfdis`. Parser extrae los nodos `<cfdi:CfdiRelacionados>` y los
guarda. Backfill idempotente desde `xml_original`.
- **Aplicado en fork**: 1,168 CFDIs actualizados (de 10,658 escaneados).
- **Archivos**: migración, `sat-parser.service.ts`, `sat.service.ts`,
`cfdi.service.ts`, `packages/shared/src/types/cfdi.ts`.
---
## 2. Saldo real en CxP/CxC
**Portable** — detalle en vs doc §13.
Problema: `saldo_pendiente_mxn` quedaba NULL para I PPD, así el reporte
CxP/CxC mostraba el `total_mxn` como "todo pendiente" aunque hubiera
pagos/NC/anticipos.
Solución: denormalizar el campo con fórmula compensada, hook al insertar,
backfill.
- **Utility central**: `apps/api/src/utils/saldo.ts` (`saldoComputadoExpr`,
`recomputarSaldoPendiente`, `uuidsAfectadosPorCfdi`).
- **Hooks**: `sat.service.ts:saveCfdis` (batch UPDATE al final del loop),
`cfdi.service.ts:createCfdi` (por CFDI).
- **Backfill**: 784 I PPD vigentes en fork. Delta global: -$11,764,854 de
"saldo pendiente" que ya estaba cubierto.
---
## 3. Tratamiento I/07 y E/07 en ingresos/gastos
**Portable** — detalle completo en vs doc §13b.
Evolución iterativa durante el día:
1. **Iter 1**: excluir E/07 (cancelación anticipo) de NC en Grupo 1 ingresos
+ gastos uniforme + adquisiciones G01. Decisión del user: las E/07 no
son devoluciones reales, no deben restar.
2. **Iter 2**: excluir I/07 (aplicación anticipo) de facturas en Grupo 1
ingresos + gastos uniforme. Decisión: también doble-cuentan.
3. **Iter 3**: reemplazar exclusión por **compensación con NETO_CUSTOM**:
```
contribución_I07 = (NETO_CUSTOM(I/07) EXCL_MONTO(I/07))
Σ (NETO_CUSTOM(rel) EXCL_MONTO(rel))
```
donde `NETO_CUSTOM = total traslados + retenciones`. Aplica a
ingresos G1 + gastos + adquisiciones.
4. **Iter 4**: `GREATEST(0, ...)` clamp — cuando el anticipo está en
periodo anterior a la I/07, el resultado era negativo (caso Husberto
julio 2025 con anticipos de mayo 2025 y marzo 2024). Clamp a 0
garantiza que nunca genere contribución negativa.
**Estado final por bucket/grupo**:
| Bucket / grupo | I/07 | E/07 |
|---|---|---|
| Ingresos G1 PF Empresarial | Compensación `NETO_CUSTOM` clampada a 0 | Exclusión |
| Ingresos G2 Sueldos (605) | N/A | N/A |
| Ingresos G3 PM y otros | Sumadas completas | Restadas completas |
| Gastos (uniforme) | Compensación `NETO_CUSTOM` clampada a 0 | Exclusión |
| Adquisiciones G01 (uniforme) | Compensación `NETO_CUSTOM` clampada a 0 | Exclusión |
| IVA causado/acreditable | N/A (solo E) | Exclusión |
| Flujo de efectivo | N/A (solo E) | Exclusión |
**Archivos**: `dashboard.service.ts` (helpers `NETO_CUSTOM` y
`EXCL_MONTO_ALIAS`, queries `g1Facturas`, `facturas` de egresos y
adquisiciones), `cfdi.controller.ts` (drill-down buckets).
---
## 4. Drill-down consistente con KPIs
**Portable** — detalle en vs doc §16.
Cambios:
- Drill-down ahora respeta los mismos filtros que el dashboard (régimen,
`TODOS_REGIMENES`, régimenes ignorados, E/07 donde aplica).
- Las I/07 se listan con `total_mxn` crudo (el dashboard aplica
compensación invisible para la fila → aceptable delta visual entre
header y suma de filas por decisión del user).
- Exportados los constants `GRUPO_PF_EMPRESARIAL`, `GRUPO_SUELDOS`,
`GRUPO_PM_OTROS` desde `dashboard.service.ts`.
---
## 5. Cache de métricas: DELETE antes de calcular
**Portable** — detalle en vs doc §17.
Bug descubierto: agregar `DELETE FROM metricas_mensuales WHERE (contrib,
año, mes)` **antes del upsert** no era suficiente, porque las queries
`calcular{Ingresos,Egresos}` leen del mismo cache via read-through. Si el
DELETE está DESPUÉS de `calcular*`, el recompute lee valores viejos y
los propaga.
Fix: DELETE al **inicio** de `computeMetricaMensual`, antes del
`Promise.all([calcular*])`.
**Impacto**: Husberto Feb 2025 gastos bajó de $525k (stale) a $463k (real).
---
## 6. Facturapi multi-contribuyente (FORK-ESPECÍFICO)
**No portable a Horux 360** tal cual — Horux 360 es single-tenant con una
sola org Facturapi. Pero varias lecciones generales sí aplican.
### Bug 1: `createOrg` no idempotente
`createOrgContribuyente` lanzaba 409 "El contribuyente ya tiene una
organización Facturapi" cuando la fila local en `facturapi_orgs` existía,
sin importar el estado real en Facturapi.
Escenario: fila local huérfana (org borrada manualmente en Facturapi, API
key cambiada) → UI muestra "no hay org" (porque `retrieve` falla y
`orgStatus` retorna `configured: false`) → user pulsa "Crear Organización"
→ backend 409 → user bloqueado.
**Fix** en `contribuyente-facturapi.service.ts:createOrgContribuyente()`:
- Si hay fila local + org viva en Facturapi → devolver la existente (`reused: true`).
- Si hay fila local + Facturapi 404 → crear nueva y actualizar el FK local (`recreated: true`).
- Si no hay fila local → crear fresh.
### Bug 2: `issuer` no es campo válido en invoice create
La primera versión del fix para multi-régimen intentaba pasar
`invoicePayload.issuer = { tax_system }`. Facturapi rechaza con
`"issuer" is not allowed` — el `tax_system` del emisor se toma
**exclusivamente** de `legal.tax_system` de la organización.
**Fix**: nuevo helper `ensureOrgLegalForEmit()` que se llama ANTES del
`invoices.create`:
1. Valida que el régimen elegido esté en `contribuyentes.regimen_fiscal` (CSV).
2. GET `/v2/organizations/{id}` para leer `legal` actual.
3. Si ya coincide `tax_system` + `legal_name` → no-op.
4. Si difiere → PUT `/v2/organizations/{id}/legal` con razón social,
`tax_system` elegido, y domicilio completo desde
`contribuyentes.domicilio` JSONB.
### Bug 3: frontend no enviaba el régimen
El form de emisión tenía un selector "Régimen del Emisor" con estado
`emisorRegimen`, pero no lo incluía en el payload al backend.
**Fix** (`apps/web/app/(dashboard)/facturacion/page.tsx`): agregar
`issuerTaxSystem: emisorRegimen` al `data` del emit.
### Errores SAT post-fix (no son código)
- **"RegimenFiscal no corresponde al tipo de persona"**: resolvido al
implementar sync legal (el `tax_system` refleja régimen válido).
- **"No se encontró el RFC en LCO"**: la LCO (Lista de Contribuyentes
Obligados) del SAT tarda 24-72h en propagar CSDs nuevos. No hay fix
de código — esperar. Documentar como UX banner pendiente.
### Archivos
- `apps/api/src/services/contribuyente-facturapi.service.ts` — 3 fixes
listados arriba.
- `apps/api/src/services/facturapi.service.ts:createInvoice()` —
comentario explicando que `issuer` no es válido.
- `apps/web/app/(dashboard)/facturacion/page.tsx` — propaga
`issuerTaxSystem`.
### Portabilidad parcial a Horux 360
El patrón de `ensureOrgLegalForEmit()` sí aplica: si Horux 360 permite
múltiples regímenes por tenant, se debe sincronizar el `legal.tax_system`
de la org antes del emit cuando el user elija uno distinto al default.
---
## 7. Filtro inclusivo por RFC — PRIMERA ITERACIÓN (FORK-ESPECÍFICO)
**Superado por §9**: esta iteración usó un filtro OR inclusivo que resolvía
el bug de "CFDI invisible" pero introducía el bug de "CFDI en lado
equivocado". Se mantiene aquí como contexto histórico — la solución final
es §9 (refactor completo a RFC).
**No portable a Horux 360** — el bug que resuelve solo ocurre en
multi-contribuyente dentro del mismo tenant.
### Problema
Cuando dos contribuyentes del mismo tenant tienen relación emisor-receptor
(ej. Carlos emite factura a Horux 360, ambos contribuyentes del mismo
despacho), el mismo UUID entra dos veces al sync SAT:
1. Sync del primero → INSERT con `contribuyente_id = A, type = X`.
2. Sync del segundo → UPSERT, el UPDATE **no toca** `contribuyente_id`
pero sí sobrescribe `type` y otros campos.
Resultado: CFDI queda con `contribuyente_id` del primer sync pero `type`
del segundo — inconsistente. El dashboard filtra por `contribuyente_id = X`
y excluye el CFDI.
### Solución — filtro por RFC
`utils/contribuyente-context.ts:resolveContribuyenteContext()` genera:
```sql
AND (
contribuyente_id = 'X'
OR UPPER(rfc_emisor) = 'X_RFC'
OR UPPER(rfc_receptor) = 'X_RFC'
)
```
El `type` del CFDI + el lado del query (EMITIDO/RECIBIDO) ya determina
si es ingreso o gasto del contribuyente — no se requiere
`contribuyente_id` para la atribución correcta.
Helper equivalente `getContribFilter(pool, id)` en
`dashboard.service.ts`. Las 6 ocurrencias de `cf = contribuyenteId ? ...`
migradas al helper async.
### Alcance
Fix en dashboard.service + impuestos.service (vía
`resolveContribuyenteContext`). Otros servicios (reportes, listado de
CFDIs) conservan su filtro original — si aparecen inconsistencias
similares, migrar con el mismo patrón.
### Archivos
- `apps/api/src/utils/contribuyente-context.ts` — filtro inclusivo.
- `apps/api/src/services/dashboard.service.ts` — helper local
`getContribFilter()`, 6 usos migrados.
---
## 8. Fix zona horaria en parser SAT (portable)
**Portable** — aplica también a Horux 360. Detalle en vs doc §19.
### Problema
`new Date(comprobante['@_Fecha'])` interpreta el string ISO sin TZ según
la zona horaria del proceso Node. En CDMX (UTC-6), `"2025-12-31T18:37:51"`
se convierte a UTC `"2026-01-01T00:37:51Z"`. Postgres guarda el UTC,
desalineando el mes/año del CFDI.
Alcance: cualquier CFDI emitido después de las 18:00 hora México queda
en el día siguiente UTC. Fin de mes o fin de año cae fuera del periodo
correcto.
### Solución
Helper `parseCfdiDate(str)` en `sat-parser.service.ts` fuerza 'Z' si el
string no trae TZ indicator. Todos los `new Date(...)` del XML + metadata
CSV migrados al helper.
**Backfill** (`scripts/backfill-fechas-tz.ts`): re-parsea `fecha_emision`
y `fecha_cert_sat` desde `xml_original` con regex sobre los atributos
`Fecha=""` y `FechaTimbrado=""`. En fork: 10,658 CFDIs actualizados
(todos estaban desfasados).
### Archivos
- `apps/api/src/services/sat/sat-parser.service.ts` — helper +
4 usos migrados (XML + CSV metadata).
- `apps/api/scripts/backfill-fechas-tz.ts` — **nuevo** script idempotente.
---
## 9. Refactor completo: RFC como fuente de verdad (fork-específico)
### Contexto
El fix §7 (filtro inclusivo `contribuyente_id OR rfc_emisor OR rfc_receptor`)
resolvió el bug de "CFDI que no aparecía para un contribuyente", pero
introdujo otro bug: si el CFDI tiene `contribuyente_id = A` (del primer
sync) y `type = 'EMITIDO'` (del segundo sync que sobrescribió), entonces
para el contribuyente A aparece como EMITIDO aunque él sea en realidad
receptor. El user reportó caso real: CFDI `a2f1f589` donde Horux 360
(receptor) lo veía como ingreso emitido.
### Diagnóstico raíz
El par `(type, contribuyente_id)` en BD es inconsistente cuando dos
contribuyentes del mismo tenant se facturan entre sí:
- Primer sync inserta con su perspectiva.
- Segundo sync UPSERT: actualiza `type` pero NO `contribuyente_id`.
- Resultado: `type` refleja perspectiva del último sync, pero
`contribuyente_id` refleja perspectiva del primero — desalineados.
### Solución: usar RFC directamente
**Dejar de confiar en `type` y `contribuyente_id`** en los filtros del
dashboard. El RFC del contribuyente comparado contra `rfc_emisor` /
`rfc_receptor` del CFDI es fuente de verdad inmutable:
- Si `rfc_emisor = X_RFC` → el contribuyente X emitió este CFDI.
- Si `rfc_receptor = X_RFC` → el contribuyente X recibió este CFDI.
`type` y `contribuyente_id` se conservan en BD (legacy), pero ya no se
usan como filtros en dashboard/impuestos/reportes/drill.
### Fase 1 — Helper central (`utils/contribuyente-context.ts`)
Extendido `resolveContribuyenteContext` para retornar:
- `esEmisor`: fragmento SQL `UPPER(rfc_emisor) = 'X_RFC'`.
- `esReceptor`: `UPPER(rfc_receptor) = 'X_RFC'`.
- Fallback (sin contribuyenteId, Horux 360 single-tenant): RFC del tenant.
Si no hay tenant tampoco, fallback a `type = 'EMITIDO/RECIBIDO'`.
El campo `contribFilter` (filtro inclusivo) se marcó como deprecated pero
se mantiene para queries legacy.
### Fase 2 — Dashboard (`dashboard.service.ts`)
- `calcularIngresosPorRegimen`: 3 grupos (G1 PF Empresarial, G2 Sueldos,
G3 PM) migrados. Filtro por `esEmisor` (ingresos) y `esReceptor` (G2 sueldos).
- `calcularEgresosPorRegimen`: 3 queries (facturas, pagos, NC) con `esReceptor`.
- `calcularAdquisicionesMercancias`: facturas y NC con `esReceptor`.
- `calcularIvaBalancePorRegimen`: 6 buckets (s1-s3, r1-r3) con `esEmisor`/`esReceptor`
según el lado.
- `getKpis`: conteos por lado derivados de `esEmisor`/`esReceptor` en vez de `type`.
- `getRegimenesDelPeriodo`: UNION de emisor/receptor usando los filtros.
- Helper local `getContribFilter` eliminado.
Firmas intactas — ninguna función cambió su contrato externo.
### Fase 3 — Impuestos (`impuestos.service.ts`)
Los `BUCKET_*` constantes (que eran strings con `type = 'EMITIDO'` hard-coded)
convertidos a **factories** que reciben `ctx`:
- `bucketCausadoPos(ctx)`, `bucketCausadoNeg(ctx)`, `bucketCausadoAny(ctx)`
- `bucketAcreditablePos(ctx)`, `bucketAcreditableNeg(ctx)`, `bucketAcreditableAny(ctx)`
- `signedCausadoTras/Ret(ctx)`, `signedAcreditableTras/Ret(ctx)` — SUM expressions signed.
- `regimenTenantExpr(ctx)`: CASE WHEN esEmisor THEN regimen_emisor ELSE regimen_receptor.
Funciones migradas: `getIvaMensual`, `getResumenIva`, `readResumenIvaFromCache`
(ahora recibe `ctx` completo en vez de `contribFilter`), `getResumenIsr`
(query de ISR retenido usa `(esEmisor OR esReceptor)`).
### Fase 4 — Reportes (`reportes.service.ts`)
Helper local `resolveEmisorReceptor(pool, contribuyenteId)` — versión ligera
del context resolver, no depende de tenantId. Migradas:
- `getFlujoEfectivo`: 6 queries (entradas/salidas × I/P/E).
- `calcularFlujoPorMes`: helper `q()` acepta `'EMITIDO'|'RECIBIDO'` semántico
en vez de literal type.
- `getConcentradoRfc`: clientes/proveedores vía RFC.
- `getCuentasXPagar`: filtro `esReceptor` en vez de `type='RECIBIDO' AND contrib_id=X`.
- `getCuentasXCobrar`: filtro `esEmisor`.
### Fase 4b — Drill-down (`cfdi.controller.ts:drillDown`)
Importa `resolveContribuyenteContext`. Los 4 buckets (ingresos G1/G2/G3,
gastos, causado, acreditable) migrados a `esEmisor`/`esReceptor`. El
filtro final `AND contribuyente_id = X` solo aplica cuando NO hay bucket
(drill crudo sin semantic de lado).
### Validación
Horux 360 (`b3761db6-…`) ingresos 2025:
- Pre-refactor: contaminado con CFDIs de Carlos/Husberto donde Horux era
receptor pero `type='EMITIDO'` en BD (del sync del emisor).
- Post-refactor: $305,904 solo régimen 626 (su RESICO). ✓ Coherente.
Husberto (`d745a915-…`) ingresos 2025:
- $9,507,265 solo régimen 612. ✓
CFDI `a2f1f589-…` (caso reportado): Husberto→Horux 360. Ahora aparece
en ingresos de Husberto (emisor) y gastos de Horux 360 (receptor),
nunca en ingresos de Horux 360.
### Alcance
Solo dashboard + impuestos + reportes + drill-down + conteos. **No tocado**:
- Listado `/cfdi` (usa filtro propio por `type`).
- Alertas, calendario, conciliación — si aparecen inconsistencias
similares, migrar con el mismo patrón.
### Archivos
- `apps/api/src/utils/contribuyente-context.ts` — `esEmisor`/`esReceptor`.
- `apps/api/src/services/dashboard.service.ts` — 8+ queries, helper local
eliminado.
- `apps/api/src/services/impuestos.service.ts` — factories + queries.
- `apps/api/src/services/reportes.service.ts` — helper local + queries.
- `apps/api/src/controllers/cfdi.controller.ts:drillDown` — 4 buckets.
- `apps/api/src/controllers/dashboard.controller.ts:getRegimenesDelPeriodo`
— propaga `tenantId` al service.
### Cache
Recomputed tras el refactor — 212 invalidaciones, 392 filas escritas, 0 errores.
### Nota de portabilidad a Horux 360
El refactor aplica conceptualmente a Horux 360 también (single-tenant), pero
el bug original (`type` y `contribuyente_id` desalineados) no existe allá
porque no hay multi-contribuyente. En Horux 360 single-tenant, el fallback
del helper usa el RFC del tenant y sigue funcionando. Si se decide portar,
el código funciona idéntico sin cambios.
---
## 10. Scripts nuevos
- `backfill-cfdis-relaciones.ts` (§1) — re-parsea CfdiRelacionados.
- `backfill-saldo-pendiente.ts` (§2) — pobla `saldo_pendiente_mxn`.
- `backfill-fechas-tz.ts` (§8) — re-parsea fechas desde XML.
- `invalidate-metricas-all.ts` — fuerza invalidación de cache.
- `process-metricas-now.ts` — dispara recompute inmediato.
- `inspect-cfdi.ts` / `inspect-cfdi-full.ts` — debug de UUID.
- `check-saldo.ts` — valida fórmula de saldo.
- `inspect-rfc.ts`, `find-contribuyente.ts`, `list-contribuyentes.ts`,
`check-carlos-lco.ts` — debug contribuyentes.
- `validate-gastos.ts` / `validate-ingresos.ts` — tests de paridad
dashboard vs drill.
- `breakdown-gastos.ts` — desglose por régimen.
- `deep-egresos.ts` — análisis componente a componente.
- `check-cache.ts` — inspecciona `metricas_mensuales`.
- `debug-i07.ts` — desglose de I/07 con NETO_CUSTOM y contribución.
---
## 11. Validaciones hechas
### Husberto Ignacio Torres (TOAH680201RA2, d745a915-...)
- **Feb 2025 gastos**: 6 iteraciones de validación hasta cuadrar
$438,056.13 post compensación.
- **Jul 2025 gastos**: descubrió contribuciones negativas de I/07 con
anticipos de meses anteriores → motivó el clamp `GREATEST(0, ...)`.
Post-clamp: $361,967.39.
- **Oct 2025 gastos**: $384,375.93 (cuadran dashboard + drill).
- **Ingresos 2025 post-refactor RFC**: $9,507,265 solo régimen 612.
### Carlos Husberto Torres (TORC9611214CA, 414b22a8-...)
- **Ingresos 2025 completo**: problema reportado — 3 meses (jun/sep/nov)
en $0 y total muy bajo ($335,905).
- Diagnóstico: una factura específica (43fd3e58) con `fecha_emision`
= 2026-01-01 00:37 UTC pero XML decía 2025-12-31 18:37 México → bug TZ.
- Post-fix (§8): total 2025 = $554,905.56 (+$219k). Los 3 meses ya
muestran valores.
### Horux 360 (HTS240708LJA, b3761db6-...)
- **Caso reportado `a2f1f589`**: aparecía en emitidos de Horux 360 aunque
Husberto era el emisor. Raíz: `type/contribuyente_id` desalineados
post-UPSERT. Fix: refactor §9 usa RFC directamente.
- **Mayo 2025 ingresos post-refactor**: $45,003 (régimen 626 RESICO).
Detectado que un CFDI P (`079ace7d-…`) tiene `iva_traslado_pago_mxn`
inflado por error del proveedor en el XML — decisión: dejar como está
(ver §11 cerrados).
---
## 12. Pendientes activos
### Funcionalidad
- **Propagar compensación `NETO_CUSTOM` a otros buckets**: hoy solo
aplica a ingresos G1 + gastos + adquisiciones. Pendiente evaluar:
IVA causado/acreditable, flujo de efectivo, ISR retenido.
- **Saldo en listado de CFDIs**: hoy solo CxP/CxC aprovechan
`saldo_pendiente_mxn`. Si se expone en `/cfdi`, hay que extender a
más tipos (no solo I PPD).
### Datos a investigar
- **Saldos negativos en backfill**: -$1.7M detectado en MO3NI6U8. Indica
P multi-docto over-counteado o anticipos referenciados múltiples veces.
No bloquea el reporte (filtro `saldo > 0.01`) pero vale auditar.
- **Carlos — LCO del SAT**: esperar 24-72h desde trámite del CSD.
### UX / Operativa
- **Banner "CSD recién tramitado"**: warning amigable cuando user intenta
emitir en las primeras 24h tras subir CSD. Evita tickets repetidos.
### Cerrados por decisión
- I/07 en Grupo 3 PM en ingresos — suma completa (no se toca).
- ISR retenido — queda como está (no distingue EMITIDO/RECIBIDO).
- Cache `flujo_*` vs `getFlujoEfectivo` — diseño aceptado (neto vs bruto).
- **CFDIs P con `iva_traslado_pago_mxn` inflado**: si el XML del proveedor
tiene inconsistencia entre `TotalTrasladosBaseIVA16` y `MontoTotalPagos`
(el proveedor reporta la base del IVA de la factura original en vez de
la base proporcional al pago parcial), el dashboard resta de más en el
neteo y el ingreso sale bajo. Ejemplo: CFDI `079ace7d-…` con
pago=$43,611 pero IVA trasladado=$30,076 → neto=$13,534. Decisión
del user (2026-04-24): dejar como está — el cálculo refleja el XML
timbrado, la corrección es pedirle al proveedor que reemita con los
totales correctos. Sin fix de código, sin clamp.
---
## 13. Cache invalidations del día
Secuencia de recomputes:
1. Post-§2 (saldo): no requiere (hook).
2. Post-§3 iter 1 (excluir E/07 G1): 212 invalidaciones → 392 filas.
3. Post-§3 iter 2 (excluir E/07 gastos): 212 → 392.
4. Post-§3 iter 3 (compensación NETO_CUSTOM G1): 212 → 392.
5. Post-§3 iter 4 (compensación NETO_CUSTOM gastos): 212 → 392.
6. Post-§3 iter 5 (clamp `GREATEST(0, ...)`): 212 → 392.
7. Post-§8 (fix TZ + backfill fechas): 212 → **401** (CFDIs reubicados a
mes correcto generaron nuevas combinaciones régimen×mes).
8. Post-§9 (refactor RFC): 212 → 392 filas (revirtió a 392 porque los
CFDIs atribuidos erróneamente a un contribuyente vía filtro inclusivo
viejo dejaron de poblar régimenes que no les correspondían).
---
## 14. Alerta: TipoRelacion sospechoso en notas de crédito (portable)
### Origen
Caso `9de39173-738d-48df-bf86-af3c6ed1d748` (Husberto Ago 2025): nota de
crédito recibida con `cfdi_tipo_relacion = '01'` cuya `cfdis_relacionados`
apuntaba a `b1390d12-93c9-449b-94c3-c760d980af01`. El user siguió la
cadena de referencia y descubrió que `b1390d12` era un anticipo — el
emisor debió haber puesto TipoRelacion `07` (aplicación de anticipo),
no `01` (NC por errores). El error inflaba gastos e IVA acreditable
porque nuestras compensaciones de anticipo (§3) solo activan cuando el
TipoRelacion es 07.
### Heurística
Para cada CFDI **X** con:
- `tipo_comprobante = 'E'`
- `cfdi_tipo_relacion IS NOT NULL AND cfdi_tipo_relacion <> '07'`
- `cfdis_relacionados` no vacío
- No está en `cfdi_descartados` bajo `tipo_alerta='tipo-relacion-sospechosa'`
…buscar si existe otro CFDI **Y** (`Y.id <> X.id`) con
`Y.cfdi_tipo_relacion = '07'` cuyos `cfdis_relacionados` compartan al
menos un UUID con los de X. Si sí → ese UUID referenciado ya fue
tratado como anticipo en otra parte → X probablemente debió emitirse
como `07` también.
SQL clave (en `alertas-auto.service.ts`, exportado como
`SOSPECHOSA_TIPO_RELACION_WHERE_EXPORT`):
```sql
AND EXISTS (
SELECT 1 FROM cfdis y
WHERE y.id <> c.id
AND y.cfdi_tipo_relacion = '07'
AND y.status NOT IN ('Cancelado', '0')
AND y.cfdis_relacionados IS NOT NULL
AND y.cfdis_relacionados <> ''
AND string_to_array(LOWER(y.cfdis_relacionados), '|')
&& string_to_array(LOWER(c.cfdis_relacionados), '|')
)
```
El operador `&&` de PostgreSQL hace overlap entre dos arrays — si
comparten al menos un UUID devuelve `true`. Más conciso que `INTERSECT`
o `unnest() + IN (...)`.
### Implementación
- `alertas-auto.service.ts`:
- `SOSPECHOSA_TIPO_RELACION_WHERE` (fragmento SQL, exportado).
- `alertaTipoRelacionSospechosa(pool, contribuyenteId)` → alerta
prioridad `alta`, id `tipo-relacion-sospechosa`, drill-down
`/alertas/tipo-relacion-sospechosa`.
- Enganchada en `generarAlertasAutomaticas` (11ª alerta).
- `alertas.controller.ts`:
- `getTipoRelacionSospechosa` — drill-down reutilizando el mismo
`WHERE` para coherencia.
- `alertas.routes.ts`:
- `GET /alertas/drilldown/tipo-relacion-sospechosa`.
- `apps/web/app/(dashboard)/alertas/tipo-relacion-sospechosa/page.tsx`:
- Patrón de `discrepancia-regimen` (toggle Activos/Descartados,
filtros fecha + TipoRelacion, export Excel, modal viewer).
- Columnas: Fecha, Dirección (EMITIDO/RECIBIDO), Emisor, Receptor,
TipoRel (en rojo), Referenciados (UUIDs cortos), Total MXN.
- `cfdi_descartados` con `tipo_alerta='tipo-relacion-sospechosa'` sirve
como whitelist — si el contador confirma que un match es falso
positivo, puede descartarlo y ya no reaparece.
### Alcance actual (decidido con user)
- Solo `tipo_comprobante='E'` (el user explícitamente dijo "solo E").
- Prioridad `alta`.
- Drill-down = lista simple de sospechosos (no enseñamos el CFDI 07
que "ya consumió" el anticipo — el user dijo "basta con ver los
CFDIs sospechosos").
### Falsos positivos conocidos
- Caso teórico: el mismo UUID referenciado legítimamente por una NC
01 real y por una 07 de otro emisor/contexto. Se resuelve via
`cfdi_descartados`.
### Portable a Horux 360
Sí, con un solo cambio: quitar el filtro `contribuyente_id` (Horux 360
es single-contribuyente por tenant, esa columna no existe). Todo el
resto del SQL es idéntico.
---
## 15. Facturapi save post-emit usando parseXml (portable)
### Problema detectado
Al inspeccionar las facturas Facturapi guardadas en BD del tenant (CFDIs con
`source='facturapi'`), todas tenían campos del emisor vacíos: `rfc_emisor=''`,
`nombre_emisor=''`, `regimen_fiscal_emisor=NULL`, `subtotal=0`, `iva_traslado=0`
y `xml_original=NULL`. El timbrado en Facturapi y el SAT estaba correcto — el
bug era solo en el guardado local.
### Causa raíz
En `apps/api/src/controllers/facturacion.controller.ts` el INSERT post-emit
leía `invoice.issuer?.tax_id`, `invoice.subtotal`, `invoice.taxes` del
response de `client.invoices.create`. El SDK de Facturapi NO incluye esos
campos top-level; el emisor vive en `invoice.issuer_info` y los impuestos
viven dentro de cada `items[*].product.taxes`. El receptor sí funcionaba
porque sigue siendo `invoice.customer.tax_id` (esa parte no cambió).
### Solución
Tras la emisión, descargar el XML real timbrado y reutilizar el mismo
parser que procesa CFDIs del SAT (`parseXml` de
`sat-parser.service.ts`). Los datos provienen de la fuente autoritativa
(el XML sellado), garantizando consistencia con CFDIs descargados via
sync SAT.
```ts
const xmlBuffer = contribuyenteId
? await downloadXmlContribuyente(pool, contribuyenteId, invoice.id)
: await facturapiService.downloadXml(tenantId, invoice.id);
const xmlString = xmlBuffer.toString('utf-8');
const parsed = parseXml(xmlString, 'emitidos');
// Upsert RFCs y INSERT cfdis con parsed.{rfcEmisor, subtotal, ivaTraslado, ...} y xml_original = xmlString
```
### Backfill
Script `scripts/backfill-facturapi-cfdis.ts` reaplica la misma lógica para
las filas insertadas con la versión vieja. Iteró sobre las 4 CFDIs
Facturapi del tenant DESPACHO_MO3NI6U8_B9VGG y las completó.
### Archivos
- `apps/api/src/controllers/facturacion.controller.ts` — refactor del INSERT
- `apps/api/scripts/backfill-facturapi-cfdis.ts` — one-shot
### Beneficios adicionales
- Ahora se almacena el `xml_original` de las CFDIs emitidas (antes vacío). El
drill-down y el visor de CFDIs pueden mostrarlo.
- Se populan `regimen_fiscal_emisor` y `regimen_fiscal_receptor` que antes
faltaban — esto destrabó el matching para los KPIs por régimen.
---
## 16. Pivote a Método A en Grupo 1 ingresos (portable)
### Motivación
El §3 de este doc evolucionó la lógica de I/07 a "compensación NETO_CUSTOM
con clamp `GREATEST(0, ...)`". El user descubrió que esa fórmula falla en
escenarios N:1 — un anticipo referenciado por múltiples I/07 produce
sub-cuenta porque cada I/07 resta el anticipo COMPLETO.
Ejemplo: anticipo $200 + 3 I/07 de $100 c/u (todas referenciando el mismo
anticipo) + 3 E/07 de $60/$100/$40:
- Real: anticipo $200 + 3 servicios $100 = $500 brutos $200 cancelados = **$300**
- Compensación con clamp: $200 + max(0, $100$200)×3 + 0 (E/07 excluidas) = **$200**
- Método A ingenuo: $200 + $300 $200 = **$300**
### Decisión
Migrar Grupo 1 (PF Empresarial: 606, 612, 621, 625, 626) a **Método A
ingenuo** — sumar todas las I PUE incluyendo I/07, restar todas las E PUE
incluyendo E/07. La cancelación algebraica `anticipo + I/07 E/07` es
correcta cuando los tres CFDIs están en el universo de la query.
### Cambios SQL en `dashboard.service.ts:calcularIngresosPorRegimen`
**Antes** (Grupo 1 facturas):
```sql
SUM(CASE
WHEN cfdi_tipo_relacion = '07' THEN
GREATEST(0, NETO_CUSTOM(cfdi) Σ NETO_CUSTOM(rel))
ELSE total_mxn IMP_TRAS EXCL_MONTO
END)
```
**Después** (Grupo 1 facturas):
```sql
SUM(total_mxn IMP_TRAS EXCL_MONTO)
```
**Antes** (Grupo 1 NC): incluía `AND cfdi_tipo_relacion <> '07'`.
**Después** (Grupo 1 NC): filtro removido — E/07 también restan.
### Drill-down
`apps/api/src/controllers/cfdi.controller.ts` bucket `ingresos` Grupo 1:
removido `AND ${E_NO_ANTICIPO}` para que el drill liste E/07 igual que el KPI.
### Trade-off documentado
Método A pierde la robustez ante E/07 ausentes (si el emisor olvida emitir
la E/07, la I/07 cuenta completa = doble-cuenta del anticipo). Para esto
existe la alerta de §14 (`tipo-relacion-sospechosa`) que detecta E/07s
faltantes y emisores que pusieron 01 cuando debió ser 07.
### Buckets que NO migran a Método A
- **Saldos CxP/CxC** (`utils/saldo.ts`): conserva la exclusión E/07 y la
resta del anticipo en I/07 — semántica per-factura distinta a la del
dashboard agregado.
- **IVA causado/acreditable**: queda con compensación `NETO_CUSTOM` por ahora
(revisable después).
---
## 17. Clamp defensivo del IVA en complementos P (portable)
### Caso real que motivó el fix
CFDI `079ace7d-…` (P emitido por Horux 360 en may-2025): cobró $43,611.20
de una I PPD de $218k. El proveedor reportó `iva_traslado_pago_mxn=$30,076`
(el IVA de la factura completa $218k) en vez del proporcional al pago
($43,611 × 16% / 1.16 ≈ $6,017). Esto inflaba la resta del IVA del aporte
del P y bajaba ingresos artificialmente $24k.
### Solución
Clampar el IVA reportado al máximo legal posible (`monto_pago_mxn × 0.16`).
PostgreSQL `LEAST` retorna el menor de dos valores. Dado que el SAT no
permite tasa de IVA mayor al 16%, el cap es matemáticamente defensible
incluso para casos legítimos (tasa 0%, 8% frontera) porque el IVA real
estará bajo el cap.
### Helpers actualizados
`dashboard.service.ts:11`:
```sql
IVA_TRAS_PAGO_CLAMPED = LEAST(COALESCE(iva_traslado_pago_mxn, 0),
COALESCE(monto_pago_mxn, 0) * 0.16)
IVA_RET_PAGO_CLAMPED = LEAST(COALESCE(iva_retencion_pago_mxn, 0),
COALESCE(monto_pago_mxn, 0) * 0.16)
IMP_TRAS_PAGO = IVA_TRAS_PAGO_CLAMPED + COALESCE(ieps_traslado_pago_mxn, 0)
IVA_NETO_PAGO = IVA_TRAS_PAGO_CLAMPED IVA_RET_PAGO_CLAMPED
```
`impuestos.service.ts`: `IVA_TRAS_EXPR`, `IVA_RET_EXPR` y las versiones
`_ALIAS(alias)` aplican el mismo clamp inline en la rama `WHEN tipo_comprobante='P'`.
### Lo que SÍ y NO se clampa
- **IVA**: se clampa
- **IEPS**: NO se clampa (rates SAT van hasta 53%)
- **ISR retención**: NO se ve afectado (no usa los campos `_pago_`)
### Resultado validación Horux 360 may-2025
Ingresos pre-clamp: $45,003.48 → post-clamp: $68,102.38 (+$23,098.90, exacto
con la corrección esperada del único P afectado).
### Recompute
212 filas en `metricas_mensuales` invalidadas y recomputadas con razón
`CLAMP_IVA_P_GLOBAL`.
---
## 18. Método A en gastos y adquisiciones (portable)
### Cambio
Aplicar el mismo Método A de §16 simétricamente en:
- `dashboard.service.ts:calcularEgresosPorRegimen` (facturas + nc)
- `dashboard.service.ts:calcularAdquisicionesMercancias` (facturas + nc, mismo SQL con `uso_cfdi='G01'`)
- `cfdi.controller.ts` bucket `gastos` (drill-down)
### SQL antes/después
Idéntico al §16 — quitar el `CASE WHEN cfdi_tipo_relacion='07' THEN ... ELSE ...`
en facturas, y quitar el filtro `AND cfdi_tipo_relacion <> '07'` en NC.
### Estado consolidado post-cambio
| Bucket | I/07 | E/07 |
|---|---|---|
| Ingresos G1 | suma completa | resta completa |
| Ingresos G2 (sueldos) | n/a | n/a |
| Ingresos G3 (PM y otros) | suma completa | resta completa |
| Gastos | suma completa | resta completa |
| Adquisiciones G01 | suma completa | resta completa |
| IVA causado/acreditable | compensada | excluida |
| Saldos CxP/CxC | resta anticipo | excluida |
Los 5 buckets económicos del dashboard ya son Método A uniforme. Los buckets
fiscales de IVA y de saldos mantienen su lógica especializada por las razones
que hablamos en §16.
### Recompute
212 filas invalidadas + 392 escritas tras `METODO_A_GASTOS_Y_ADQUISICIONES`.
---
## 19. Fix base gravable en histórico ISR (RESICO PM) (portable)
### Bug detectado
La tabla "Histórico ISR" en `/impuestos` mostraba **base gravable = ingresos**
para Horux 360 (régimen 626 PM), cuando debería ser
`max(0, ingresos deducciones)`.
### Causa raíz
Lógica duplicada y desincronizada entre dos funciones de
`apps/api/src/services/impuestos.service.ts`:
- `calcularResumenIsr` (KPI del periodo): tenía la lógica completa que
distingue PM/PF en régimen 626 vía `rfcLength`.
- `getIsrMensual` (histórico mensual): solo verificaba la constante
`REGIMENES_RESTA_DEDUCCIONES = ['606', '612']`. El 626 no estaba ahí
→ `formula = ingresos` siempre → `base = max(0, ing) = ing`.
### Solución — extracción a single source of truth
Helper exportado `determinarFormulaBaseGravable(clave, rfcLength)`:
```ts
export function determinarFormulaBaseGravable(
clave: string,
rfcLength: number,
): 'ingresos-deducciones' | 'ingresos' {
if (REGIMENES_RESTA_DEDUCCIONES.includes(clave)) return 'ingresos-deducciones';
if (clave === '626' && rfcLength === 12) return 'ingresos-deducciones';
return 'ingresos';
}
```
Reglas que codifica:
- 606 (Arrendamiento) y 612 (PF Empresarial): **siempre** restan deducciones.
- 626 (RESICO): PM (RFC 12 chars) resta deducciones; PF (RFC 13) usa tasa
plana sobre ingresos.
- Resto de regímenes PM (601, 603, 607...): no restan en base — sus
deducciones se modelan vía coeficiente de utilidad en el ISR causado
(Art. 14 LISR).
### Cambios
- `calcularResumenIsr`: reemplazado el `if/else if/else` inline con la
llamada al helper.
- `getIsrMensual`:
- Resuelve `rfcLength` vía `resolveContribuyenteContext` al inicio.
- Usa el helper en el branch "con régimen filtrado".
- El branch "sin régimen filtrado" ahora itera por régimen y aplica la
fórmula correcta a cada uno antes de sumar — antes hacía `ing ded`
global lo cual falla con multi-régimen mixto (ej. PM con 626+601 que
tienen fórmulas distintas).
### Sin recompute
La base gravable se deriva en memoria desde `calcular{Ingresos,Egresos}PorRegimen`
(que sí están cached). El fix toma efecto inmediato sin tocar `metricas_mensuales`.
### Archivos
- `apps/api/src/services/impuestos.service.ts` — helper + 2 usages

View File

@@ -0,0 +1,513 @@
# Sesión 2026-04-25 — Módulo Despacho, Tareas y Papelería
Sesión enfocada en **features nuevos** del fork `Horux_despacho`. Los fixes
fiscales del día anterior están en
`docs/plans/2026-04-24-session-fixes-and-features.md`.
---
## Índice
1. [Banner "CSD recién tramitado"](#1-banner-csd-recién-tramitado)
2. [Preferencias de notificación por contribuyente](#2-preferencias-de-notificación-por-contribuyente)
3. [Tareas operativas (recurrentes)](#3-tareas-operativas-recurrentes)
4. [Papelería de Trabajo](#4-papelería-de-trabajo)
5. [Módulo "Despacho" — 3 páginas](#5-módulo-despacho-3-páginas)
6. [Selector de periodo global](#6-selector-de-periodo-global)
7. [Asignación de supervisor desde /usuarios](#7-asignación-de-supervisor-desde-usuarios)
8. [Ajustes de UI](#8-ajustes-de-ui)
9. [Migraciones aplicadas](#9-migraciones-aplicadas)
10. [Pendientes](#10-pendientes)
---
## 1. Banner "CSD recién tramitado"
### Problema
El SAT tarda 24-72h en propagar un CSD nuevo a la Lista de Contribuyentes
Obligados (LCO). Durante esa ventana, intentos de emisión vía Facturapi
fallan con mensajes tipo "RFC no encontrado en LCO". El user pierde
tiempo intentando emitir y abre tickets innecesarios.
### Solución
Banner contextual en `/facturacion` que aparece SOLO si hubo un rechazo
del SAT con patrón LCO en las últimas 24h.
### Archivos
- **Migración 033**: `facturapi_orgs.last_lco_rejection_at timestamptz`.
- `apps/api/src/controllers/facturacion.controller.ts`:
- Helper `isLcoRejection(message)` con regex contra patrones SAT.
- Helper `markLcoRejection(pool, contribuyenteId)` upsertea timestamp.
- En el catch de `emitir()` se dispara la marca cuando aplica.
- Endpoint `getLcoStatus` retorna `{ hasRecentLcoRejection, rejectedAt }`.
- `apps/api/src/routes/facturacion.routes.ts`: `GET /facturacion/lco-status`.
- `apps/web/app/(dashboard)/facturacion/page.tsx`: banner amber al inicio
del form, polling cada 15min.
### Mensaje
> CSD aún en proceso de validación — espera 24 horas antes de emitir tu factura.
### Auto-expiración
La condición es `rejectedAt > NOW() - 24h`. Pasadas las 24h el banner
desaparece sin necesidad de cron.
---
## 2. Preferencias de notificación por contribuyente
### Problema
Los emails informativos se enviaban siempre. El user quería poder
desactivar tipos específicos por contribuyente sin afectar a otros.
### Solución
JSONB `email_preferences` en `contribuyentes`; los handlers de envío
respetan la preferencia antes de mandar.
### Archivos
- **Migración 034**: `contribuyentes.email_preferences jsonb DEFAULT '{}'`.
- `apps/api/src/services/notification-preferences.service.ts`:
- `EMAIL_TYPES = ['documento_subido', 'weekly_update', 'subscription_expiring', 'recordatorio_fiscal']`.
- `getContribuyenteEmailPreferences()` con default true.
- `setContribuyenteEmailPreferences()` merge sobre JSONB.
- `apps/api/src/services/notify-upload.service.ts`: check
`prefs.documento_subido` antes de enviar.
- `apps/api/src/controllers/notification-preferences.controller.ts`:
`listPreferences` + `updatePreferences`.
- `apps/api/src/routes/notification-preferences.routes.ts`:
`GET / PUT /api/notificaciones`.
- `apps/web/app/(dashboard)/configuracion/notificaciones/page.tsx`:
toggles por tipo de email por contribuyente, optimistic update.
### Tipos de email
| Tipo | Estado | Notas |
|---|---|---|
| `documento_subido` | ✅ activo | check en `notify-upload.service.ts` |
| `weekly_update` | ⏳ próximamente | el job es tenant-wide, requiere refactor |
| `subscription_expiring` | ⏳ próximamente | no es per-contribuyente hoy |
| `recordatorio_fiscal` | ⏳ placeholder | para futuras alertas fiscales |
Los toggles "Próximamente" se persisten pero no bloquean el envío
todavía. Cuando se implemente cada email per-contribuyente, basta con
agregar el check.
### Default
Todo activado. Si la columna está vacía o le falta una key, asumimos
`true` para preservar comportamiento previo.
---
## 3. Tareas operativas (recurrentes)
Sistema completo de tareas operativas del despacho por contribuyente,
distinto del flujo de obligaciones fiscales (que ya existía).
### Características
- **Recurrencias**: semanal, quincenal, mensual, bimestral, trimestral,
semestral, anual.
- **Día de vencimiento**: día de la semana (1-7) o día del mes (1-31)
según recurrencia.
- **Estados**: pendiente / completada (en BD); "atrasada" se calcula en
frontend.
- **Permisos**:
- Rol `cliente`: bloqueado en TODOS los endpoints (403).
- Flag `solo_supervisor_completa`: si está en true, solo
owner/cfo/supervisor pueden marcarla completa.
### Archivos
- **Migración 035**:
```sql
tareas_catalogo (id, contribuyente_id, nombre, descripcion, recurrencia,
dia_semana, dia_mes, solo_supervisor_completa, es_default,
active, orden, created_at)
tarea_periodos (id, tarea_id, periodo, fecha_limite, completada,
completada_at, completada_por, notas, created_at)
```
- `apps/api/src/services/tareas.service.ts`:
- CRUD del catálogo.
- **Materialización lazy**: cuando el frontend pide tareas con periodo
actual, el backend genera periodos faltantes solo del presente en
adelante (códigos `2025-W12`, `2025-01`, `2025-B1`, `2025-Q1`,
`2025-S1`, `2025`).
- `seedTareasDefault()` con 4 tareas: estados de cuenta (día 5),
conciliación (día 10), contabilización (día 14), revisión fiscal
preliminar (día 15, `solo_supervisor_completa=true`).
- `getEventosTareasParaCalendario()` retorna shape `EventoFiscal` con
`tipo: 'tarea'`.
- `contarTareasProximasVencer()` para alertas auto.
- `getAuxiliarUserIdDeContribuyente()` resuelve cartera → auxiliar.
- `apps/api/src/controllers/tareas.controller.ts`:
- Bloqueo rol cliente.
- `notifyAuxiliarTareaCompletada()`: dispara email cuando una tarea
con `solo_supervisor_completa=true` se marca completa por
supervisor/owner — el auxiliar recibe aviso.
- `apps/api/src/routes/tareas.routes.ts`:
```
GET /api/tareas — list con periodos materializados
POST /api/tareas — crear
PATCH /api/tareas/:id — update
DELETE /api/tareas/:id — soft delete (active=false)
POST /api/tareas/seed — seed defaults
POST /api/tareas/periodo/:id/completar
DELETE /api/tareas/periodo/:id/completar
```
- `apps/api/src/services/email/templates/tarea-completada.ts`: template
para notificar al auxiliar.
- `apps/api/src/services/alertas-auto.service.ts`:
`alertaTareasProximasVencer()` (≤3 días, prioridad media), enchufada
en `generarAlertasAutomaticas`.
- `apps/api/src/controllers/calendario.controller.ts`: incluye tareas en
el GET de eventos cuando hay contribuyente seleccionado y rol no es
cliente.
- `packages/shared/src/types/calendario.ts`: agregado `'tarea'` a
`TipoEvento`.
### Frontend
- `apps/web/components/obligaciones/tareas-tab.tsx`: pestaña "Tareas" en
`/configuracion/obligaciones` con tabla, modal crear/editar, check
pendiente/completada.
- `apps/web/app/(dashboard)/configuracion/obligaciones/page.tsx`: tabs
"Obligaciones" / "Tareas".
### Flujo email "tarea revisada"
1. Auxiliar trabaja en algo del contribuyente.
2. Supervisor/owner marca como completada la tarea
`solo_supervisor_completa=true` (ej. "Revisión fiscal preliminar").
3. Backend resuelve `auxiliar_user_id` desde la cartera del
contribuyente.
4. Email al auxiliar con el detalle (quién marcó, periodo, notas).
---
## 4. Papelería de Trabajo
Sección nueva en `/documentos` para subir archivos de trabajo del
despacho con flujo opcional de aprobación.
### Características
- **No accesible para usuarios rol cliente** (oculta + 403 en API).
- **Formatos permitidos**: PDF, Word (doc/docx), Excel (xls/xlsx).
- **Tamaño máximo**: 5 MB por archivo (validado backend + frontend).
- **Periodo**: mes + año (selector en el modal de subida).
- **Aprobación opcional**: checkbox al subir; si está activo, queda en
estado `pendiente` y solo owner/supervisor pueden aprobar/rechazar.
- **Comentario opcional al rechazar**.
### Archivos
- **Migración 036**:
```sql
papeleria_trabajo (id, contribuyente_id, nombre, descripcion,
archivo bytea, archivo_filename, archivo_mime,
archivo_size, anio, mes,
requiere_aprobacion, estado, aprobado_por,
aprobado_at, comentario_rechazo, subido_por,
created_at)
```
Estados válidos: `pendiente | aprobado | rechazado`. NULL si no
requiere aprobación.
- `apps/api/src/services/papeleria.service.ts`:
- Validación MIME + tamaño.
- CRUD: `uploadPapeleria`, `listPapeleria` (filtros año/mes/estado),
`downloadArchivo`, `aprobar`, `rechazar`, `eliminar`.
- `aprobar`/`rechazar` validan rol owner/cfo/supervisor.
- `apps/api/src/controllers/papeleria.controller.ts`:
- Bloqueo rol cliente con `rejectClienteRole()`.
- Schema Zod estricto.
- **Notificaciones**:
- Al subir con `requiere_aprobacion=true`: email a owners + supervisores.
- Al aprobar/rechazar: email al uploader (excepto si es el mismo).
- Excepción al uploader: si el aprobador es el mismo user que subió
(ej. owner que sube su propia papelería), no se auto-notifica.
- `apps/api/src/services/email/templates/papeleria.ts`: 2 templates
(`papeleriaAprobacionRequeridaEmail` + `papeleriaDecisionEmail`).
- `apps/api/src/routes/papeleria.routes.ts`:
```
GET /api/papeleria — list con filtros
POST /api/papeleria — upload (base64 en JSON body)
GET /api/papeleria/:id/download — descarga binaria
POST /api/papeleria/:id/aprobar
POST /api/papeleria/:id/rechazar — body: { comentario? }
DELETE /api/papeleria/:id
```
### Frontend
- `apps/web/components/documentos/papeleria-tab.tsx`: pestaña con
filtros (año, mes, estado), modal de upload con base64 conversion,
badges de estado, modal de rechazo con comentario.
- `apps/web/app/(dashboard)/documentos/page.tsx`: pestaña condicional
`if (user.role !== 'cliente')`.
---
## 5. Módulo "Despacho" — 3 páginas
Reemplazo del módulo `/pendientes` con sub-secciones específicas por
rol. **Sidebar** renombrado: "Pendientes" → "Despacho".
### Estructura
```
/despachos → redirect según rol
/despachos/contribuyentes → owner/cfo (métricas globales)
/despachos/mis-asignados → owner/cfo/supervisor/auxiliar
/despachos/equipo → owner/cfo/supervisor (jerárquico)
```
### Sub-nav común
- `apps/web/components/despachos/despacho-subnav.tsx`: tabs visibles
según rol del user (filtrado en frontend).
- `defaultDespachoPathForRole(role)`: helper para el redirect en
`/despachos`.
### 5a. `/despachos/contribuyentes` (owner-only)
7 cards de métricas globales del despacho, todas filtrables por periodo:
| Card | Fuente | Notas |
|---|---|---|
| Total contribuyentes | `entidades_gestionadas` activos | independiente del periodo |
| Última extracción SAT | `MAX(sat_sync_jobs.completed_at)` BD central | independiente del periodo |
| Progreso del mes (%) | `obligaciones+tareas completadas / total` | barra de color |
| Declaraciones presentadas | `declaraciones_provisionales` con `created_at` en periodo | |
| Declaraciones pagadas | subset con `pdf_pago IS NOT NULL` | |
| Declaraciones atrasadas | obligaciones de declaración pendientes con `periodo < seleccionado` | heurística por categoría |
| Tareas atrasadas | tareas pendientes con `fecha_limite < primer día` | |
**Heurística "es declaración"** (sin flag explícito en el catálogo):
```sql
LOWER(categoria) ~ 'mensual|anual|declarac' OR LOWER(nombre) LIKE '%declarac%'
```
Si crece la base se puede agregar columna `es_declaracion boolean` y
ajustar.
### 5b. `/despachos/mis-asignados`
Tabla con contribuyentes filtrada por cartera del user:
| Rol | Filtro |
|---|---|
| owner / cfo | TODOS los contribuyentes |
| supervisor | contribuyentes en sus carteras top-level + subcarteras |
| auxiliar | contribuyentes en carteras donde es `auxiliar_user_id` |
**Columnas**:
- Contribuyente (link → `/configuracion/obligaciones` con contribuyente
preseleccionado).
- Cartera.
- **Avance** (barra de color verde≥80% / ámbar 50-79% / rojo <50% +
porcentaje numérico).
- **Atrasos** — badge agregado de obligaciones+tareas atrasadas; "Al
día" si no tiene.
- Obl. periodo (completadas / pendientes).
- Tareas periodo (completadas / pendientes).
**Resaltado de filas** con atrasos: `bg-red-50/50` para llamar la
atención.
**Orden**: por suma de atrasos descendente (rezagados arriba).
### 5c. `/despachos/equipo`
Vista jerárquica supervisor → auxiliares. Click en un supervisor
**expande** debajo a sus auxiliares.
**Resolución de la relación supervisor → auxiliar** (UNION de 3
fuentes):
```sql
1. carteras top-level con auxiliar_user_id + supervisor_user_id directos
2. subcarteras con auxiliar_user_id; supervisor del cartera padre
3. tabla legacy auxiliar_supervisores (fallback / override desde /usuarios)
```
**Sección "Auxiliares sin supervisor asignado"**: si después del UNION
queda un auxiliar activo (rol `auxiliar` en `tenant_memberships`) sin
supervisor, aparece en una sección al final con icono de advertencia
ámbar — solo el owner la ve.
**Permisos**:
- owner/cfo: ve TODOS los supervisores con sus auxiliares + huérfanos.
- supervisor: ve solo a sí mismo con sus auxiliares (sin huérfanos).
**Métricas por miembro**:
- Contribuyentes asignados.
- Avance del periodo (barra de color, igual que mis-asignados).
- Atrasos (obligaciones + tareas).
### Backend
- `apps/api/src/services/despacho-stats.service.ts`:
- `getContribuyentesStats(pool, tenantId, año?, mes?)`.
- `getMisAsignados(pool, userId, userRole, año?, mes?)`.
- `getEquipoStats(pool, userId, userRole, tenantId, año?, mes?)`.
- `apps/api/src/controllers/despacho-stats.controller.ts`: 3 endpoints
con guards de rol.
- `apps/api/src/routes/despacho-stats.routes.ts`: mountea en
`/api/despachos`.
---
## 6. Selector de periodo global
### Problema
Antes solo el mes en curso era visible. El user quiere navegar a otros
periodos para revisar avance histórico.
### Solución
Wrapper `<PeriodoSelector />` alrededor del `<PeriodSelector />` de
`@horux/shared-ui` (mismo patrón visual que `/dashboard`, `/impuestos`,
`/reportes`). Persiste selección en `localStorage` via Zustand.
### Archivos
- `apps/web/stores/periodo-store.ts`:
- `fechaInicio` / `fechaFin` (strings ISO YYYY-MM-DD).
- Default: primer y último día del mes en curso.
- Helper `añoMesFromFechaInicio()` para queries que usan año/mes.
- `apps/web/components/periodo-selector.tsx`: wrapper que se pasa como
children al `<Header>` en cada página `/despachos/*`.
### Endpoints que aceptan el filtro
- `GET /api/despachos/contribuyentes-stats?año=YYYY&mes=MM`
- `GET /api/despachos/mis-asignados?año=YYYY&mes=MM`
- `GET /api/despachos/equipo-stats?año=YYYY&mes=MM`
Default: mes en curso si no vienen los params.
---
## 7. Asignación de supervisor desde /usuarios
### Problema
Al invitar un auxiliar se pedía supervisor, pero **no había forma de
cambiarlo después**. Al abrir el modal aparecía "none" porque el
endpoint solo leía de `auxiliar_supervisores` ignorando carteras.
### Solución
Endpoint que consulta TODAS las fuentes de la relación supervisor↔auxiliar
y un modal "Supervisor" en `/usuarios`.
### Archivos backend
- `apps/api/src/controllers/usuarios.controller.ts`:
- `getSupervisor`: UNION priorizada — `auxiliar_supervisores` (1) →
cartera directa (2) → cartera padre de subcartera (3). El primer
resultado gana.
- `updateSupervisor`: upsert en `auxiliar_supervisores` (override
explícito sobre la cartera). Pasar `supervisorUserId: null` borra
la asignación.
- `apps/api/src/routes/usuarios.routes.ts`:
```
GET /api/usuarios/:id/supervisor
PUT /api/usuarios/:id/supervisor
```
### Frontend
- Botón "Supervisor" (icono `UserCheck`) en cada fila de auxiliar,
visible solo en tenant tipo despacho y para admins.
- Modal con `<Select>` de supervisores + opción "Sin supervisor
asignado".
### Trade-off
La tabla `auxiliar_supervisores` actúa como **override** sobre la
cartera. Si después de asignar via cartera, el owner cambia el
supervisor desde `/usuarios`, queda guardado en `auxiliar_supervisores`
y eso prevalece. Si quieres revertir, borrar la fila desde
`/usuarios` (opción "Sin supervisor asignado").
---
## 8. Ajustes de UI
### 8a. Sidebar sin selector de contribuyente
El `<ContribuyenteSelector />` ya estaba en el header — duplicado en
sidebar agregaba ruido. Removido del sidebar (`apps/web/components/layouts/sidebar.tsx`).
### 8b. Selector contribuyente oculto en `/despachos/*`
Las 3 vistas de Despacho son cross-contribuyente (métricas agregadas,
mis asignados es la lista completa, equipo es jerarquía). Filtrar por
contribuyente individual rompe el propósito.
`apps/web/components/contribuyente-selector.tsx`: array `HIDDEN_PATHS`
con `/despachos`. Si el pathname matchea, retorna `null` antes de
renderizar.
### 8c. Header
Selector de periodo se pasa como **children del `<Header>`** (al lado
del título), igual que en `/dashboard` — no en el lado derecho.
### 8d. PageNotificaciones centrada
`<main className="... mx-auto">` agregado.
### 8e. Configuración Obligaciones Fiscales — Header
Faltaba el `<Header />` en la página `/configuracion/obligaciones` así
que perdía el selector global. Agregado.
---
## 9. Migraciones aplicadas
| # | Archivo | Tabla / cambio |
|---|---|---|
| 033 | `033_facturapi_orgs_lco_rejection.sql` | `facturapi_orgs.last_lco_rejection_at` |
| 034 | `034_contribuyentes_email_preferences.sql` | `contribuyentes.email_preferences jsonb` |
| 035 | `035_tareas.sql` | `tareas_catalogo` + `tarea_periodos` |
| 036 | `036_papeleria_trabajo.sql` | `papeleria_trabajo` |
Aplicadas vía `pnpm db:migrate-tenants` en ambos tenants
(`DESPACHO_MO7JE8BZ_VDOPR` y `DESPACHO_MO3NI6U8_B9VGG`).
---
## 10. Pendientes
### Tareas
- Soporte de tipo "one-time" (no recurrente). Hoy todas son recurrentes.
- Asignación explícita de tarea a un user específico (hoy se asume
cartera).
### Notificaciones
- Implementar el envío per-contribuyente para `weekly_update`,
`subscription_expiring`, `recordatorio_fiscal` (los 3 toggles
"Próximamente"). Requiere refactor del cron de weekly-update para que
itere por contribuyente en lugar de por tenant.
### Métricas Despacho
- "Es declaración" en obligaciones: agregar flag explícito al catálogo
(`obligaciones_contribuyente.es_declaracion boolean`) y migrar la
heurística actual de regex.
- Drill-down: click en cada card del módulo Contribuyentes lleva a una
vista detallada (ej. listado de declaraciones atrasadas con
contribuyente y periodo).
### Equipo
- Mostrar % avance histórico (gráfica) de cada miembro a lo largo de
varios periodos.
- Asignar manualmente contribuyentes a un user desde la vista de equipo
(hoy solo via carteras).
### Papelería
- Versionado: si el user sube un nuevo archivo del mismo "concepto",
guardar histórico de versiones (hoy crea fila independiente cada vez).
- Filtros por uploader.
### Bugs reportados (por revisar)
- **Visibilidad de auxiliares en carteras de supervisores**: en la
pantalla de carteras, el supervisor está viendo a auxiliares que NO
están asignados a él. Debe filtrar para que cada supervisor solo vea
los auxiliares en sus propias carteras (top-level + subcarteras
donde es supervisor).
- **Tareas completadas > pendientes en módulo Despacho**: las métricas
muestran más tareas completadas de las que existen pendientes —
aritméticamente raro. Posible causa: el conteo de "completadas"
incluye periodos históricos mientras que "pendientes" se limita al
periodo seleccionado. Validar que ambos lados del ratio usen el
mismo universo (mes filtrado) o documentar explícitamente la
asimetría si es intencional.
- **Drill-down de alerta TipoRelacion sospechoso incompleto**: no
están saliendo todos los CFDIs tipo E con posible TipoRelacion
errónea en el listado. Revisar la heurística en
`apps/api/src/services/alertas-auto.service.ts` (constante
`SOSPECHOSA_TIPO_RELACION_WHERE` y reuso en
`getCfdisTipoRelacionSospechosa` del controller). Posibles causas:
(a) operador `&&` de PostgreSQL sobre `string_to_array` no detecta
algunos casos por case-sensitivity o caracteres extraños en
`cfdis_relacionados`; (b) el filtro excluye E con
`cfdi_tipo_relacion=NULL` cuando algunas inconsistencias podrían
tener ese estado; (c) RFC del contribuyente no se aplica
uniformemente en el JOIN.

View File

@@ -0,0 +1,251 @@
# Setup admin global + gestión de clientes (2026-04-26 turno tardío)
Sesión continuación del día. Foco: habilitar al admin global del fork para
que pueda operar como plataforma — gestión de clientes, edición de catálogos
(planes, add-ons), auto-facturación de pagos, y experiencia de login dedicada.
> Relacionado: `2026-04-26-session.md` (índice del día), `2026-04-26-sprints-1-2-3.md`
> (sprints anteriores), y los docs específicos de IVA y notificaciones.
---
## 1. Bootstrap admin global del fork
El fork `Horux_despacho` no tenía tenant raíz ni `platform_admin`. Ejecutado:
```bash
HORUX_ADMIN_EMAIL=carlos@horuxfin.com \
HORUX_ADMIN_NOMBRE=Carlos \
HORUX_TI_EMAIL=ivan@horuxfin.com \
HORUX_TI_NOMBRE=Ivan \
pnpm bootstrap:admin-global
```
Crea:
- Tenant `Horux 360` (RFC `HTS240708LJA`, plan enterprise, dbMode MANAGED).
- `carlos@horuxfin.com` con rol `platform_admin` (admin global).
- `ivan@horuxfin.com` con rol `platform_ti` (TI superset).
Contraseña fijada manualmente a `Admin12345!` con bcrypt cost 12 +
`tokenVersion: increment` para invalidar JWTs viejos.
**Cuentas finales del fork** (todas con `Admin12345!`):
- `carlos@horuxfin.com` — platform_admin
- `ivan@horuxfin.com` — platform_ti
- `jd@demo.com` — owner Patito
- `jf@demo.com` — owner Zorro
- `supervisor@patito.com` / `auxiliar@patito.com` / `cliente@patito.com`
---
## 2. Login y home redirect
`apps/web/app/(auth)/login/page.tsx` ahora detecta el admin global tras el
login y lo manda directo a `/clientes` (gestión de tenants), no a `/dashboard`
(operativo despacho). `dashboard/page.tsx` también tiene `useEffect` defensivo
que redirige si llega ahí por URL directa.
```ts
const isGlobalAdmin = isGlobalAdminRfc(response.user?.tenantRfc, userRole, platformRoles);
if (isGlobalAdmin) router.push('/clientes');
```
---
## 3. Sidebar — banner "Configuración inicial" oculto
`components/layouts/sidebar.tsx`: el banner que linkea a `/onboarding` solo se
muestra cuando `(!contribuyentes || contribuyentes.length === 0) && role !== 'cliente' && !isGlobalAdmin`.
El admin global nunca tendrá contribuyentes en su tenant raíz Horux 360 — el
banner sería ruido permanente.
(Una versión inicial también limpiaba todos los items operativos del sidebar
para el admin global. Revertida — el admin global mantiene acceso completo
para impersonar tenants. Solo cambia el banner.)
---
## 4. `/admin/usuarios` — fix `roleLabels[role].icon` undefined
Bug latente desde que se introdujeron los roles del despacho. `roleLabels`
solo tenía `owner/contador/visor` (Horux 360 mainline); cuando aparecía un
user con rol `cfo`, `supervisor`, `auxiliar` o `cliente`, el lookup retornaba
`undefined` y `roleInfo.icon` lanzaba TypeError.
Fix: agregar los 7 roles + `defaultRoleInfo` como fallback defensivo:
```ts
const roleLabels: Record<string, { label: string; icon: typeof Shield; color: string }> = {
owner: { label: 'Dueño', icon: Shield, color: 'text-primary' },
cfo: { label: 'CFO', icon: Briefcase, color: 'text-indigo-600' },
contador: { label: 'Contador', icon: Calculator, color: 'text-green-600' },
supervisor: { label: 'Supervisor', icon: UserCheck, color: 'text-blue-600' },
auxiliar: { label: 'Auxiliar', icon: UserCog, color: 'text-cyan-600' },
cliente: { label: 'Cliente', icon: User, color: 'text-amber-600' },
visor: { label: 'Visor', icon: Eye, color: 'text-muted-foreground' },
};
const defaultRoleInfo = { label: 'Sin rol', icon: User, color: 'text-muted-foreground' };
```
---
## 5. `/configuracion/precios-suscripcion` — planes despacho
La página leía `plan_prices` (BD central, planes Horux 360 legacy:
starter/business/business_ia/enterprise). En el fork no aplican.
**Reescrita** para mostrar los 4 planes despacho desde `DESPACHO_PLAN_PRICES`:
| Plan | Mensual | Anual 1er año | Anual renovación | RFCs | Timbres/mes |
|---|---:|---:|---:|---:|---:|
| Mi Empresa | $580 | $5,800 | $5,800 | 1 | 50 |
| Mi Empresa + | $900 | $9,000 | $9,000 | 1 | 50 |
| Business Control | No aplica | $25,850 | $25,850 | 100 | 0 |
| Enterprise | No aplica | $43,000 | $43,000 | 100 | 0 |
**Modo read-only** porque los precios viven en `DESPACHO_PLAN_PRICES`
(catálogo estático en `@horux/shared`).
### Capacidad latente para edición
Migración aplicada `20260426230000_despacho_plan_prices`:
```sql
CREATE TABLE despacho_plan_prices (
plan TEXT PRIMARY KEY,
monthly DECIMAL(10,2),
first_year DECIMAL(10,2) NOT NULL,
renewal DECIMAL(10,2) NOT NULL,
permite_monthly BOOLEAN NOT NULL DEFAULT false,
updated_at TIMESTAMP NOT NULL
);
```
Seed inicial con los 4 planes y sus valores. Permite migrar a editable cuando
se quiera — service + endpoints + UI editable quedan como follow-up.
---
## 6. Gestión de clientes (`/clientes`)
Página enriquecida con KPIs y operaciones para el admin global.
### Backend
- **Service**: `apps/api/src/services/admin-clientes.service.ts`
- `getClientesStats({ from, to })` — devuelve:
- `suscripcionesPorPlan`: groupBy plan con count (status=authorized).
- `ingresos`: sum + count de `Payment.amount` con status=approved en rango.
- `noRenovaciones`: subs con `currentPeriodEnd` en rango y status terminal
(cancelled / trial_expired / paused) + datos del tenant.
- `usuariosPorCliente`: count de memberships activos por tenant.
- `getTenantUsuarios(tenantId)` — drill-down: lista users + rol + isOwner.
- **Controller**: `controllers/admin-clientes.controller.ts` con
`requireStaff(req)` (platform_admin/ti).
- **Routes**: `routes/admin-clientes.routes.ts` montadas en
`/api/admin/clientes` (`GET /stats`, `GET /:tenantId/usuarios`).
### UI
- Selector de rango de fechas (default mes en curso).
- 4 KPI cards: clientes registrados / suscripciones activas (con breakdown por
plan en pie) / ingresos del periodo / no renovaciones.
- Tabla "Clientes que no renovaron" expandible con detalle (cliente, RFC, plan,
vencimiento, status).
- En cada item de la lista de clientes, click sobre el contador de usuarios
abre un modal con la lista (email, nombre, rol, badge de owner).
---
## 7. Auto-facturación de pagos al cliente
CLAUDE.md ya documentaba `invoicing.service.ts:emitInvoiceIfApplicable`. Cada
webhook `payment.approved` lo dispara. Idempotente por `Payment.facturapiInvoiceId`,
fail-soft si Facturapi falla.
### Ajustes en este turno
- `PLAN_LABELS` extendido con los 4 planes despacho. Antes la factura mostraba
el codename (`mi_empresa`, `business_cloud`) en lugar de "Mi Empresa" /
"Enterprise".
- Texto "Horux 360" → "Horux Despachos" en la descripción del CFDI y en el
concepto de timbres.
### Prerequisito operativo (manual del admin)
El emisor de las facturas es el tenant Horux 360 (RFC `HTS240708LJA`).
Requiere:
1. **CSD válido** subido vía `/configuracion/csd` por Carlos (admin global).
2. `tenants.facturapi_org_id` poblado tras crear la organización en Facturapi.
Hoy la columna está en `null` para el tenant recién bootstrapeado. Mientras
falte, `emitInvoiceIfApplicable` registra error en consola y `Payment.facturapiInvoiceId`
queda null. **Pendiente operativo**: que Carlos suba el CSD.
---
## 8. Gestión de add-ons (`/configuracion/addons`)
Página nueva para que el admin global edite el catálogo de add-ons.
### Backend
- **Controller**: `controllers/admin-addons.controller.ts`
- `listCatalogo` — incluye count de suscripciones activas por add-on.
- `updateCatalogoItem(id, { nombre?, precio?, active? })` con audit log.
- **Routes**: `/api/admin/addons/catalogo` (`GET`, `PUT /:id`). `requireStaff`.
### UI
- Tabla con columnas: codename, nombre (editable), precio MXN (editable),
frecuencia, suscripciones activas (read-only), estado (badge), acciones.
- Edición inline con dos botones (guardar / cancelar) por fila.
- Toggle de activación con **confirmación condicional**: si el add-on tiene
≥1 suscripción activa, pide confirmar antes de desactivar.
- Card link agregada al `/configuracion` raíz solo visible para admin global.
### Add-ons del catálogo actual
- `lolita_ia_contribuyente` — Lolita IA (por contribuyente) — $250/mes
- `modulo_ia` — Módulo IA Fiscal — $390/mes
- `rfcs_extra_10` — +10 RFCs adicionales — $190/mes
- `rfcs_extra_50` — +50 RFCs adicionales — $690/mes
- `timbres_extra_500` — +500 timbres mensuales — $490/mes
- `contribuyente_extra_business_cloud` — Contribuyente adicional — $45/mes (overage automático Business Control / Enterprise)
---
## 9. Sync al repo OneDrive
Tras cada bloque de cambios, ejecutado:
```powershell
robocopy "C:\Users\chtr1\Downloads\Horux_despacho" "C:\Users\chtr1\OneDrive\Documentos\GitHub\Horux_despachos" /E `
/XD node_modules .next .turbo dist .pnpm data email-previews .git xmls `
/XF .env .env.local "*.log" tsconfig.tsbuildinfo /NFL /NDL /NJH
```
Excluye `node_modules`, builds, caches, secretos, datos de tenants, y el
`.git/` del destino para preservar el repo origen.
---
## 10. Pendientes derivados
- **Cargar CSD del Horux 360** vía `/configuracion/csd` para activar
auto-facturación real. Sin esto, `emitInvoiceIfApplicable` falla silencioso.
- **Hacer editable `DespachoPlanPrice` desde UI**: la migración + seed están
aplicados pero falta service + endpoint + UI editable. Mientras tanto la
página de precios queda read-only.
- **Notificación al desactivar add-on con suscripciones activas**: hoy se
bloquean las contrataciones nuevas pero las activas siguen vigentes. Quizá
enviar email al owner avisando que el add-on quedará "deprecated".
- **Visual cue del prerequisito faltante de Facturapi org**: agregar alerta
en `/clientes` o `/configuracion` cuando el tenant Horux 360 no tenga
`facturapi_org_id` para que Carlos lo configure.
---
## 11. Pendientes históricos (sin cambio)
- SMTP `.env` prod (cron de notificaciones loguea a consola sin SMTP).
- Cloudflare Tunnel + `FRONTEND_URL` HTTPS dev.
- Typecheck web cleanup (~18 errores preexistentes).
- Nómina (tipo N) y Carta Porte sin implementar.
- SAT rejections — esperando captura real.
- Re-notificación bug 2.4 visibilidad auxiliares (sin reproducción específica).

View File

@@ -0,0 +1,491 @@
# Sesión 2026-04-26 — Compensación I/07 PPD + Activos Fijos
Fix focalizado: cuando una I/07 PPD aplica un anticipo y en el **mismo
mes/año** existe una E (cualquier TipoRelacion) que referencia esa
I/07 PPD, la I/07 PPD aporta al bucket = base de la E. Antes el filtro
`metodo_pago = 'PUE'` excluía la I/07 PPD del bucket de facturas pero
la E sí entraba como NC, generando **gasto/ingreso negativo** en el
periodo.
---
## 1. Caso real que motivó el fix
Husberto Ignacio Torres (TOAH680201RA2), agosto-2025, gastos:
| CFDI | Total | IVA | Base | Notas |
|---|---:|---:|---:|---|
| Anticipo `729109FC…` | ? | ? | ? | no en BD del tenant |
| **I/07 PPD `5c874749`** | $454,000 | $62,621 | $391,379 | apunta al anticipo |
| **E/07 PUE `7163da3b`** | $148,000 | $20,414 | $127,586 | apunta a `5c874749` (mismo día 2025-08-08) |
| **E/01 PUE `7aac715b`** | $10,000 | $1,379 | $8,621 | también apunta a `5c874749` (mismo día) |
Patrón observado en BD:
- La I/07 PPD apunta al **anticipo** original.
- La E (07 o 01) apunta a la **I/07 PPD** (no al anticipo).
### Comportamiento previo (Método A puro)
```
I/07 PPD → NO entra al bucket (filtro metodo_pago='PUE')
E/07 PUE → $127,586 (NC normal en Método A)
E/01 PUE → $8,621
Net agosto-2025 = $136,207 ❌ (gasto negativo)
```
El anticipo aportó en su periodo (vía P/PUE original), pero al cancelar
con la E sin que la I/07 PPD haya entrado al universo del bucket, queda
una entrada negativa fantasma.
### Comportamiento nuevo (con compensación)
```
I/07 PPD compensada = +$127,586 + $8,621 = +$136,207
E/07 PUE = $127,586
E/01 PUE = $8,621
Net agosto-2025 = $0 ✓
```
El neto en agosto-2025 vuelve a 0 (el anticipo ya se contó antes y los
pagos P futuros materializarán el resto del servicio cuando lleguen).
---
## 2. Volumen del patrón en BD
Búsqueda con el query nuevo (`scripts/find-i07-ppd-cases.ts` filtro
RFC):
| Contribuyente | I/07 PPD ↔ E referencias directas | Mismo mes/año |
|---|---:|---:|
| Husberto (TOAH680201RA2) | 26 | **23** |
| (resto del tenant) | varios | varios |
23 casos cumplen exactamente la regla "mismo mes/año" en Husberto.
Implementarlo afecta de forma medible el dashboard.
---
## 3. Implementación
### Archivos modificados
`apps/api/src/services/dashboard.service.ts`:
#### `calcularEgresosPorRegimen` — bucket adicional `i07PpdComp`
```sql
SELECT i.regimen_fiscal_receptor AS regimen,
COALESCE(SUM((
SELECT COALESCE(SUM(
COALESCE(e.total_mxn, 0)
- COALESCE(e.iva_traslado_mxn, 0)
- COALESCE(e.ieps_traslado_mxn, 0)
- COALESCE(e.impuestos_locales_trasladado_mxn, 0)
), 0)
FROM cfdis e
WHERE e.tipo_comprobante = 'E'
AND e.status NOT IN ('Cancelado','0')
AND ${esReceptorE} -- alias 'e.'
AND LOWER(i.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
AND date_trunc('month', e.fecha_emision) = date_trunc('month', i.fecha_emision)
)), 0) AS monto
FROM cfdis i
WHERE ${esReceptorI} -- alias 'i.'
AND i.tipo_comprobante = 'I' AND i.metodo_pago = 'PPD'
AND i.cfdi_tipo_relacion = '07'
AND i.status NOT IN ('Cancelado', '0')
AND ${FR.replace('fecha_emision', 'i.fecha_emision')}
AND i.regimen_fiscal_receptor = ANY($3)
GROUP BY i.regimen_fiscal_receptor
```
Sumado al bucket de gastos:
```ts
const monto = montoF + montoP + montoI07Comp - montoNC;
```
#### `calcularIngresosPorRegimen` Grupo 1 — bucket simétrico `g1I07PpdComp`
Misma lógica pero del lado **emisor** (`esEmisor` en lugar de
`esReceptor`, `regimen_fiscal_emisor`, filtro a `GRUPO_PF_EMPRESARIAL`).
### Helpers SQL
Para usar `esEmisor`/`esReceptor` con alias en la query, se hace
`replace` inline:
```ts
const esReceptorE = esReceptor.replace(/\brfc_receptor\b/g, 'e.rfc_receptor');
```
`esReceptor` viene de `resolveContribuyenteContext()` como fragmento
`UPPER(rfc_receptor) = 'X_RFC'`. El replace lo prepara para usar con el
alias `e.`.
### Lo que NO se tocó
- **Adquisiciones G01** (`calcularAdquisicionesMercancias`): no se
agregó la compensación todavía. Si surge un caso, replicar el patrón
con `WHERE e.uso_cfdi = 'G01'` adicional.
- **IVA causado/acreditable** (`impuestos.service.ts`): mantiene
compensación NETO_CUSTOM con E/07 (no Método A). La regla de I/07 PPD
↔ E mismo mes podría aplicar también en simetría, pero requiere
análisis por separado y está fuera de este cambio.
---
## 4. Validación
### Typecheck
✅ 0 errores en API.
### Recompute
- 212 filas en `metricas_mensuales` invalidadas con razón
`I07_PPD_COMPENSACION_E_MISMO_MES`.
- 392 filas escritas tras `processAllTenantsInvalidations()`.
- 0 errores.
### Caso de validación
Husberto agosto-2025 gastos: el balance $136,207 generado por las E
sin compensación debe desaparecer y volver a 0 en ese periodo.
---
## 5. Trade-offs y decisiones documentadas
### Solo "mismo mes/año"
La regla del user es "máximo un periodo después". En BD real, ningún
caso de Husberto tiene E "1 mes después" — todos los 23 casos están en
el mismo mes que su I/07 PPD. La regla `date_trunc('month', e.fecha)
= date_trunc('month', i.fecha)` cubre los casos reales.
Si en el futuro aparecen E un mes después con monto significativo, se
puede ampliar a `date_trunc('month', e.fecha) BETWEEN
date_trunc('month', i.fecha) AND date_trunc('month', i.fecha + interval
'1 month')`.
### Cualquier TipoRelacion en la E
La regla original era E/07 (cancelación de anticipo). Pero los casos
reales muestran E/01 también compartiendo `cfdis_relacionados` con la
I/07 PPD (ej. `7aac715b`). Esto es congruente con el bug "TipoRelacion
sospechoso" que ya documentamos: el emisor a veces pone 01 cuando
debería ser 07. La compensación nueva captura ambos correctamente.
### Patrón de referencia: E → I/07 PPD (no E → anticipo)
El patrón observado en BD muestra que las E referencian a la I/07 PPD
directamente. Es el patrón SAT estándar (la E "ajusta" la factura, no
el anticipo). El JOIN se hace por:
```sql
LOWER(i.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
```
Si en el futuro aparecieran casos con E → anticipo (otro patrón), se
puede hacer un UNION con el join alternativo.
---
## 6. Pendientes derivados
- **Validar Husberto agosto-2025** post-deploy: ya no debe mostrar
gasto negativo. Si lo hace, revisar si hay otros patrones (E que
referencia el anticipo en lugar de la I/07 PPD).
- **Decidir si aplicar a Adquisiciones G01**.
- **Decidir si aplicar a IVA causado/acreditable** simétricamente.
- **Considerar ampliar tolerancia a 1 mes después** si aparece un caso
real con monto significativo.
---
## 7. Pestaña "Activos Fijos" en /impuestos
Vista informativa nueva para llevar seguimiento de la deducción mensual
proporcional de activos fijos. **No altera dashboard ni ISR** — el SAT
trata estos CFDIs como gasto del periodo, así que el sistema los sigue
contando igual. Esta vista permite al contador planear la deducción
manual en su declaración anual.
### Decisión clave del scope (con el user)
Inicialmente se evaluó excluir activos fijos del bucket de gastos y
del cálculo de ISR. Se descartó porque eso desalineaba el sistema con
el comportamiento del SAT (que sí considera el CFDI como gasto del
periodo) y generaría confusión "el sistema no funciona". Decisión:
sistema se mantiene como está, vista nueva sirve solo para
**seguimiento informativo** del MOI.
### Modelo de cálculo
```
MOI = total_mxn iva_traslado_mxn ieps_traslado_mxn impuestos_locales_trasladado_mxn
porcentajeMensual = porcentajeAnual / 12
mesesTranscurridos = (year(periodo) year(adq)) × 12 + (month(periodo) month(adq)) + 1
acumuladoHastaMes = MIN(MOI, MOI × pctMensual × mesesTranscurridos)
acumuladoHastaMesPrev = MIN(MOI, MOI × pctMensual × (mesesTranscurridos 1))
acreditableEsteMes = acumHasta acumPrev
saldoPendiente = MOI acumHasta
```
Si el activo se da de baja: `mesesAplicables = MIN(mesesTranscurridos,
mesesEntreAdqYBaja)`. A partir del mes posterior a la baja,
`acreditableEsteMes = 0`.
Dividir `% / 12` evita el problema del primer año (mes parcial) y
permite seguimiento natural por periodo.
### Tabla de % LISR Art. 34
| Clave | Concepto | % anual |
|---|---|---:|
| I01 | Construcciones | 5% |
| I02 | Mobiliario y equipo de oficina | 10% |
| I03 | Equipo de transporte | 25% |
| I04 | Equipo de cómputo y accesorios | 30% |
| I05 | Dados, troqueles, moldes, matrices | 35% |
| I06 | Comunicaciones telefónicas | 10% |
| I07 | Comunicaciones satelitales | 8% |
| I08 | Otra maquinaria y equipo | 10% |
### Filtros (qué CFDIs entran a esta vista)
- `tipo_comprobante = 'I'` y `status NOT IN ('Cancelado','0')`
- `uso_cfdi ∈ {I01..I08}`
- Receptor = contribuyente (`esReceptor`)
- `regimen_fiscal_receptor ∈ {601, 606, 611, 612, 625, 626}`
- **Para 626**: solo si `rfcLength === 12` (PM). RESICO PF (RFC 13)
paga tasa plana sin restar deducciones.
### Estados
- `activo`: aún acreditable, no dado de baja, saldo > 0.
- `agotado`: saldo = 0 (MOI ya se dedujo completo según meses
transcurridos).
- `baja_venta` / `baja_desecho` / `baja_otro`: el contador lo dio de
baja con motivo correspondiente.
### Schema (migración 037)
```sql
CREATE TABLE activos_fijos_baja (
id serial PRIMARY KEY,
cfdi_id int NOT NULL REFERENCES cfdis(id) ON DELETE CASCADE,
fecha_baja date NOT NULL,
motivo varchar(20) NOT NULL CHECK (motivo IN ('venta','desecho','otro')),
comentario text,
dado_de_baja_por uuid NOT NULL,
created_at timestamptz DEFAULT now(),
UNIQUE (cfdi_id)
);
```
### Endpoints
```
GET /api/impuestos/activos-fijos?año=YYYY&mes=MM&contribuyenteId=...&estado=...
POST /api/impuestos/activos-fijos/:cfdiId/baja
body: { fechaBaja, motivo: 'venta'|'desecho'|'otro', comentario? }
DELETE /api/impuestos/activos-fijos/:cfdiId/baja
```
### Archivos
- **Migración 037**: `037_activos_fijos_baja.sql`.
- `apps/api/src/services/activos-fijos.service.ts`: cálculo + manejo
de baja. Usa `resolveContribuyenteContext` para obtener `rfcLength`
y filtrar 626 PM.
- `apps/api/src/controllers/activos-fijos.controller.ts`: 3 handlers
con Zod.
- `apps/api/src/routes/impuestos.routes.ts`: 3 rutas montadas en
`/api/impuestos/activos-fijos`.
- `apps/web/components/impuestos/activos-fijos-tab.tsx`: componente
con disclaimer (recordatorio de que es informativa), 4 KPIs (MOI,
acumulado previo, este mes, saldo pendiente), filtro de estado,
tabla con badge + acción de baja/reversa, modal de baja con motivo +
fecha + comentario.
- `apps/web/app/(dashboard)/impuestos/page.tsx`: botón nuevo
"Activos Fijos" en el switch de tabs + render condicional.
### UX claves
- **Disclaimer ámbar** al inicio de la pestaña recordando que el
sistema considera los CFDIs como gasto del periodo (igual que SAT)
y esta vista es solo seguimiento, no afecta cálculos automáticos.
- **Estados visuales** con badge de color (verde/gris/ámbar/rojo).
- **Filtro de estado** (todos/activos/agotados/baja).
- **Acción reversible**: dar de baja siempre se puede revertir
(DELETE en `/baja`) — la fila vuelve a calcular meses normalmente.
### NO se tocó
- `calcularEgresosPorRegimen`, `calcularAdquisicionesMercancias`,
`calcularResumenIsr`, `getIsrMensual`: intactos.
- `metricas_mensuales` cache: no requiere recompute.
- IVA causado/acreditable: sigue incluyendo estos CFDIs como antes.
### Filtro de conceptos por contribuyente (migración 038)
I06 (Comunicaciones telefónicas) y I07 (Comunicaciones satelitales)
suelen usarse para **gastos regulares** (factura de teléfono, internet
satelital) que no son adquisiciones de activos fijos. Para no ensuciar
la vista, el contador puede excluir conceptos por contribuyente.
**Migración 038**:
```sql
ALTER TABLE contribuyentes
ADD COLUMN activos_fijos_usos_excluidos jsonb DEFAULT '[]'::jsonb;
```
**Endpoints**:
```
PUT /api/impuestos/activos-fijos/usos-excluidos
body: { contribuyenteId, usos: ['I06','I07'] }
```
El response del `GET /activos-fijos` incluye `usosExcluidos` (lista
actual) para que el UI muestre badge "N excluidos".
**UI**: botón "Conceptos" en la barra de filtros abre modal con 8
checkboxes (uno por uso I01-I08). Por default todos están marcados
(considerados). Desmarcar = excluir. Persiste en BD.
### Pendientes derivados
- Auto-detectar bajas que vienen de CFDIs tipo egreso emitidos por el
contribuyente que cancelan parcialmente un activo (ej. venta de
equipo). Hoy es manual.
- Vista anual con resumen por concepto y depreciación de cierre.
- Conectar con declaraciones anuales: cuando el contador suba la
declaración anual, mostrar checkbox para "este activo lo apliqué
como deducción este ejercicio" para llevar trazabilidad.
- Considerar nuevos usos CFDI introducidos por SAT en el futuro
(mantener mapa centralizado).
- Permitir excluir CFDIs específicos (no solo conceptos completos)
para casos mixtos (ej. el cliente compra un teléfono celular
ocasional que SÍ es activo, pero la factura mensual del servicio
telefónico también es I06 y NO es activo).
---
## 8. Extensión IVA — compensación I PPD/07 ↔ E (turno 2026-04-26)
### Asimetría que motivó el cambio
El flujo del SAT con anticipo causa el IVA en tres puntos:
- **Anticipo I PUE** — IVA causado/acreditado en su mes (PUE = se causa al emitir).
- **Aplicación I/07** — la factura final que aplica el anticipo. Si es **PUE** aporta su IVA completo; si es **PPD** aporta 0 hasta que llegue el P.
- **E que cancela** — NC formal o cancelación de operación.
En el caso **PUE** (aplicación I PUE/07), la cadena cierra algebraicamente
gracias al filtro `bucketCausadoNeg/Acreditable` que excluye `tipoRelación='07'`
y al SUM_REL_TRAS que compensa la I PUE/07 contra el anticipo. Sin
necesidad de tocar nada.
En el caso **PPD**, la I PPD/07 no aporta nada en su mes (espera al P).
Si en el **mismo mes** existe una E con tipoRelación **≠ 07** que la
referencia, la E entra al `bucketAcreditableNeg` (o `bucketCausadoNeg`)
y resta IVA — pero la I PPD/07 nunca aportó nada que la E pudiera
neutralizar. Resultado: se "pierde" el IVA equivalente a la E.
### Implementación (`apps/api/src/services/impuestos.service.ts`)
Nuevos predicados/helpers:
- `IS_I_PPD_07` — gemelo de `IS_I_PUE_07` para metodo_pago='PPD'.
- `SUM_E_REFERENCING_TRAS(esLadoE)` / `SUM_E_REFERENCING_RET(esLadoE)`
subqueries que suman el IVA de las E's que referencian la I PPD/07
actual, filtrando por **mismo lado** y **mismo mes/año**.
No filtran por `tipoRelación`: en PPD cualquier E que apunte a la
I PPD/07 cuenta (incluyendo las 07, fiscalmente correctas).
- `HAS_E_REFERENCING_MISMO_MES(esLadoE)` — EXISTS para incluir las
I PPD/07 en `bucketCausadoAny`/`bucketAcreditableAny`. Sin filtro
tipoRelación (consistente con `SUM_E_REFERENCING_*`).
- `E_REFERENCIA_I_PPD_07_MISMO_MES(esLadoIAlias)` — EXISTS desde la
fila E que verifica si esta E referencia una I PPD/07 del mismo
lado/mes. Permite distinguir dos clases de E/07:
- E/07 → anticipo I PUE puro (triángulo PUE clásico): EXISTS = false
→ la E/07 queda excluida del NEG (statu quo, la lógica
`SUM_REL_TRAS` de la I PUE/07 ya cierra el ciclo).
- E/07 → I PPD/07 (cancelación de operación PPD): EXISTS = true
→ la E/07 entra al NEG y resta IVA. La I PPD/07 hereda el mismo
IVA via `SUM_E_REFERENCING_*`, neteando dentro del mes.
`bucketCausadoNeg` y `bucketAcreditableNeg` extendidos con el
disyuntivo `OR E_REFERENCIA_I_PPD_07_MISMO_MES(...)` para que las
E/07 que apuntan a I PPD/07 no queden filtradas. Los aliases `e` y
`i` se derivan de `ctx.esEmisor`/`ctx.esReceptor` con el rewrite
`replace(/\brfc_(emisor|receptor)\b/g, 'e.rfc_$1' | 'i.rfc_$1')`.
Rama nueva en los 4 signed exprs (`signedCausadoTras/Ret`,
`signedAcreditableTras/Ret`):
```
WHEN ${esLado} AND ${IS_I_PPD_07} THEN ${SUM_E_REFERENCING_*(esLadoE)}
```
### Por qué la versión inicial filtraba `<> '07'` (descartado)
La primera implementación filtraba `tipoRelación <> '07'` en
`SUM_E_REFERENCING_*` y `HAS_E_REFERENCING_MISMO_MES`, asumiendo que
las E/07 estaban universalmente excluidas del NEG y que heredarlas
sobre-acreditaría. Eso era cierto solo para el triángulo PUE puro,
pero **ignoraba el caso fiscalmente correcto**: una E/07 que cancela
una I PPD/07 sí debe restar IVA, porque la I PPD nunca aportó nada
en su mes.
La corrección es discriminar **a qué apunta la E**, no qué tipoRelación
tiene. Si apunta a una I PPD/07 → afecta IVA simétricamente (E resta
en NEG, I PPD hereda en POS, netean a 0). Si apunta a un anticipo
I PUE puro → queda excluida (statu quo).
### Validación con caso real Husberto 2025-08
Receptor TOAH680201RA2 con 4 CFDIs en `cfdis_relacionados` enredados:
- Anticipo `729109fc` I PUE: $148K, IVA $20,413 → +$20,413 acreditable (POS, en su mes)
- Aplicación `5c874749` I PPD/07: $454K, IVA $62,621 → hereda IVA total de las E del mismo mes
- NC `7163da3b` E PUE/07: $148K, IVA $20,413 → ahora entra al NEG (apunta a I PPD/07)
- NC `7aac715b` E PUE/01: $10K, IVA $1,379 → entra al NEG (tipoRelación ≠ 07)
| Concepto | Aporte agosto 2025 |
|---|---:|
| Anticipo I PUE (POS) | + $20,413.79 |
| I PPD/07 hereda E/07 + E/01 (rama nueva) | + $21,793.10 |
| E/07 (NEG, ahora incluida porque apunta a I PPD/07) | $20,413.79 |
| E/01 (NEG, ya estaba) | $1,379.31 |
| **Total acreditable** | **$20,413.79** |
| Estado | Acreditable agosto 2025 |
|---|---:|
| Antes del cambio | $20,413.79 + 0 + 0 $1,379.31 = **$19,033.69** |
| Después (versión inicial con filtro `<> '07'`) | $20,413.79 + $1,379.31 + 0 $1,379.31 = **$20,413.79** |
| Después (versión refinada sin filtro) | Ver tabla ↑ = **$20,413.79** |
**Delta total vs antes: +$1,379.31 acreditable recuperado.** Las dos
versiones (con/sin filtro) dan el mismo resultado en el caso Husberto
porque la E/01 y la E/07 cubren montos distintos. La versión refinada
es necesaria para casos donde **solo existe la E/07** (lo correcto
fiscalmente): sin la condición nueva en `bucketAcreditableNeg`, la
E/07 quedaría excluida y la I PPD nunca sería incluida en el bucket
Any → la compensación no ocurriría.
### Cache `metricas_mensuales`
`computeMetricaMensual` en `metricas-compute.service.ts` llama a
`getResumenIva` que ya usa los signed exprs nuevos — futuros recomputes
escriben los valores correctos. Periodos cacheados con la lógica vieja
quedan stale hasta invalidarse. Pendiente: barrido de invalidación por
periodo donde existan I PPD/07 + E/(≠07) referenciándolas en mismo mes.
### Por qué no se aplicó al caso PUE
El caso anticipo I PUE + I PUE/07 + E/07 ya cierra con la lógica
existente (compensación SUM_REL_TRAS en I PUE/07, exclusión de E/07
del NEG). Algebraicamente equivalente al flujo "natural" donde la E/07
restaría — la diferencia es que el código actual es **robusto al caso
"no se emite E/07"** (común en aplicaciones íntegras), donde el flujo
natural sobrecausaría. Cambiar PUE rompería ese caso típico para
ganar nada en el atípico.

View File

@@ -0,0 +1,193 @@
# Refactor IVA — fórmula del owner (2026-04-26)
Cambio mayor en la fórmula del IVA causado/acreditable de `/impuestos`.
El owner pidió alinear el cálculo a un spec explícito que difiere en tres
puntos clave del código previo. Doc de las fórmulas, los cambios SQL
puntuales y la validación con Husberto.
> Relacionado: `docs/plans/2026-04-26-i07-ppd-compensacion.md` §8 (rama
> nueva I PPD/07 que se conserva en este refactor).
---
## 1. Fórmula del owner
### IVA Trasladado (lado emisor del contribuyente)
| Componente | Filtros | Campo IVA |
|---|---|---|
| (+) I PUE emisor | `tipo='I' AND metodo_pago='PUE'`, régimen emisor en lista, vigente | `iva_traslado_mxn iva_retencion_mxn` |
| (+) P emisor | `tipo='P'`, régimen emisor en lista, vigente | `iva_traslado_pago_mxn iva_retencion_pago_mxn` |
| (+) **I PPD/07 emisor — hereda** | `tipo='I' AND metodo_pago='PPD' AND tipoRel='07'`, régimen emisor en lista | suma de IVA neto de E que la referencien en mismo mes |
| () E PUE emisor | `tipo='E' AND metodo_pago='PUE'`, régimen emisor en lista | `iva_traslado_mxn iva_retencion_mxn` |
### IVA Acreditable (lado receptor)
Simétrico: cambia `rfc_emisor → rfc_receptor` y `regimen_fiscal_emisor →
regimen_fiscal_receptor`. El componente "I PPD/07 hereda" busca E del lado
**receptor** que la referencien.
### Reglas globales
- **Régimenes considerados**: `605, 606, 612, 621, 625, 626, 601, 603, 607,
608, 610, 611, 614, 615, 620, 622, 623, 624` (excluye 616 público en
general, 614 ingresos por intereses, etc. según lista del owner).
- **Filtro de régimen por lado**: el régimen del lado del contribuyente —
emisor cuando vende, receptor cuando compra.
- **Conceptos excluidos**: claves prod/serv `84121603` (seguros), `93161608`
(gobierno), `85101501` (salud), `85121800` (servicios médicos) se restan
del IVA del CFDI.
- **Tipo P**: usa `iva_traslado_pago_mxn` y `iva_retencion_pago_mxn`
directos, **sin clamp** (vs el código previo que aplicaba
`LEAST(iva, monto*0.16)` como defensa contra XMLs malformados).
- **E con tipoRel=07**: SÍ entran al NEG y restan IVA. El owner asume que
el contador emite la E/07 cuando se cancela el anticipo. Si no se
emite, el IVA del anticipo se sobrecausa (riesgo aceptado).
- **I PUE/07**: aporta IVA completo, **sin compensación** contra los
anticipos referenciados (el código previo restaba el IVA del anticipo
para evitar doble conteo cuando E/07 ausente).
---
## 2. Diferencias vs código previo
| Aspecto | Antes | Ahora |
|---|---|---|
| Clamp IVA en P | `LEAST(iva, monto×0.16)` | Campo directo |
| Compensación I PUE/07 | `GREATEST(0, IVA Σ IVA anticipos)` | Sin compensación, IVA completo |
| E con tipoRel=07 | Excluida del NEG (filtro `<> '07'`), excepto si apuntaba a I PPD/07 | Todas las E PUE entran al NEG |
| `bucketCausadoNeg`/`bucketAcreditableNeg` | Compleja con `OR E_REFERENCIA_I_PPD_07` | Simple: `E PUE del lado` |
| Predicado `E_REFERENCIA_I_PPD_07_MISMO_MES` | Existía | **Eliminado** (ya no necesario) |
| `IS_I_PUE_07`, `SUM_REL_TRAS`, `SUM_REL_RET` | Existían | **Eliminados** |
| `IS_I_PPD_07`, `SUM_E_REFERENCING_TRAS/RET`, `HAS_E_REFERENCING_MISMO_MES` | Existían | **Conservados** (rama nueva I PPD/07) |
| Presentación KPI | Trasladado / Acreditable / Retenido separados | **Igual**: separados, fórmula `T A R` |
---
## 3. Cambios concretos en `apps/api/src/services/impuestos.service.ts`
### Eliminados
- `IS_I_PUE_07`
- `SUM_REL_TRAS`, `SUM_REL_RET`
- `E_REFERENCIA_I_PPD_07_MISMO_MES`
### Modificados
- `IVA_TRAS_EXPR`, `IVA_RET_EXPR`: rama de tipo P sin `LEAST(...)`.
- `IVA_TRAS_EXPR_ALIAS`, `IVA_RET_EXPR_ALIAS`: idem para subqueries.
- `bucketCausadoNeg`, `bucketAcreditableNeg`: simplificados a `E PUE del
lado correcto` sin filtros tipoRel ni rama EXISTS.
- `signedCausadoTras/Ret`, `signedAcreditableTras/Ret`: removida la rama
`WHEN bucket POS AND IS_I_PUE_07 THEN GREATEST(0, IVA SUM_REL)`.
Quedan tres ramas: POS, I PPD/07 hereda, NEG.
### Conservados sin cambios
- `IS_I_PPD_07`
- `SUM_E_REFERENCING_TRAS`, `SUM_E_REFERENCING_RET`
- `HAS_E_REFERENCING_MISMO_MES`
- `bucketCausadoAny`, `bucketAcreditableAny` (solo usan
`HAS_E_REFERENCING_MISMO_MES`, no el predicado eliminado)
- Bloques de presentación KPI en `getResumenIva` y `getIvaMensual`
---
## 4. Validación con caso real
Husberto Ignacio Torres (RFC `TOAH680201RA2`), agosto 2025:
| KPI | Antes refactor | Después refactor |
|---|---:|---:|
| Trasladado | $119,093.08 | $111,781.45 |
| Acreditable | $147,023.59 | $182,683.84 |
| Retenido | $0.00 | $0.00 |
| Resultado IVA | $27,930.51 | **$70,902.39** |
Delta resultado: **$42,971.88** a favor del contribuyente. La diferencia
se origina en:
1. **Compensación I PUE/07 removida**: 11 I PUE/07 del mes con $48,197 IVA
bruto. Antes aportaban su remanente vs anticipos; ahora aportan completo
→ +acreditable.
2. **E/07 que cancelaba anticipos PUE ahora resta**: antes excluida del NEG;
ahora entra → más NC en el cálculo.
3. **Sin clamp P**: P recibidas con IVA reportado mayor al 16% del pago ya
no se truncan → +acreditable.
**Validación numérica** (breakdown bruto agosto 2025 lado receptor):
- I PUE recibidas: $186,714.60
- P recibidas: $43,659.91
- I PPD/07 hereda IVA de E: $21,793.10
- E PUE recibidas (resta): $69,483.77
- **Total Acreditable: $182,683.84** ✓
Lado emisor:
- I PUE emisor: $111,781.45
- P emisor: $0
- I PPD/07 emisor hereda: $0
- E PUE emisor (resta): $0
- **Total Trasladado: $111,781.45** ✓
---
## 5. Riesgos y trade-offs aceptados
### Sobrecausa cuando E/07 ausente
Sin compensación I PUE/07, el flujo `anticipo I PUE + I PUE/07 sin E/07`
sobrecausa por el monto del anticipo. En Husberto agosto 2025 hay **11 I
PUE/07 con 0 E/07 emitidas** → todo ese volumen actualmente sobrecausa.
El owner aceptó este trade-off bajo la premisa fiscal: "lo correcto es que
el contador emita la E/07 cuando aplica el anticipo". Si en producción se
detectan tenants donde sistemáticamente faltan las E/07, la decisión deberá
revisarse (revertir a compensación o introducir un toggle por tenant).
### Sin clamp en P
XMLs de proveedores que reportan el IVA de la factura completa en P
parciales causan un IVA acreditable inflado. El código previo defendía con
`LEAST(iva, monto×0.16)`. Ahora se confía en que el campo del XML sea
correcto.
### Divergencia con dashboard
`apps/api/src/services/dashboard.service.ts` mantiene su lógica de IVA
balance independiente. Después de este cambio, los KPIs del dashboard
podrían diferir de `/impuestos`. Pendiente: alinear (o documentar la
diferencia intencional).
---
## 6. Cache `metricas_mensuales`
El cambio invalida silenciosamente todas las filas pre-calculadas en
`metricas_mensuales` (cualquier periodo cerrado por contribuyente). Para
repoblar:
```sql
-- Borrar cache de un contribuyente específico:
DELETE FROM metricas_mensuales
WHERE contribuyente_id IN (SELECT entidad_id FROM contribuyentes WHERE rfc = 'XXX');
-- O global del tenant (si se rehace para todos):
DELETE FROM metricas_mensuales;
```
Después, las consultas a años cerrados caerán al path on-the-fly hasta
que el cron `computeMetricaMensual` repueble la tabla.
---
## 7. Pendientes
- **Recompute bulk** de `metricas_mensuales` para todos los tenants y años
pasados con la fórmula nueva (ahora mismo solo limpiamos la cache de
Husberto 2025).
- **Validar otros tenants**: el delta esperado depende del volumen de I
PUE/07 sin E/07 contraparte. Tenants que no usen el patrón de anticipo
no verán cambio significativo; los que sí lo usen verán acreditable
subir.
- **Alinear dashboard**: si los KPIs de `/dashboard` y `/impuestos`
divergen, decidir cuál fórmula es la canónica.
- **Documentar para usuarios finales**: el cambio en el resultado IVA es
notable (~$43K en Husberto agosto). Si se va a desplegar a producción,
preparar nota de release explicando por qué cambian los números.

View File

@@ -0,0 +1,244 @@
# Notificaciones email automáticas — alertas y recordatorios (2026-04-26)
Cron diario 8:30 AM (America/Mexico_City) que envía dos tipos de email a
los responsables del despacho:
1. **Alertas fiscales nuevas**: una vez por alerta detectada (no se repite).
2. **Recordatorios próximos a vencer**: en 3 ventanas (3 días, 1 día, mismo día).
Cierra el pendiente histórico "notificaciones email automáticas de
alertas/recordatorios" que estaba en CLAUDE.md "Problemas conocidos".
---
## 1. Modelo de notificación elegido (Option B)
Después de evaluar 3 opciones (digest diario / por evento / híbrido) el
owner eligió **Option B — por evento**: notificar cuando algo se activa
por primera vez. Para alertas significa una sola email por (alerta_id,
contribuyente_id) en toda la vida; para recordatorios significa hasta tres
emails (uno por ventana) por recordatorio.
### Por qué no digest diario
El owner prefiere relevancia temporal sobre consolidación. Una alerta
nueva debe gatillar email; no esperar al "siguiente lunes" como hace
`weekly-update.job.ts`.
### Por qué no real-time (vs daily 8:30 AM)
Real-time requiere hooks en `generarAlertasAutomaticas` que se ejecuta on
each `/alertas` page load. Costoso. El cron diario captura el mismo set
de alertas con UX equivalente (el usuario no esperaba inmediatez sub-hora
para una alerta fiscal). Los recordatorios siempre se evalúan en función
de `fecha_limite ± días`, así que un cron al día es suficiente.
---
## 2. Esquema de datos (BD tenant)
### Migración 039 — `alertas_notificadas`
```sql
CREATE TABLE alertas_notificadas (
id BIGSERIAL PRIMARY KEY,
alerta_id TEXT NOT NULL,
contribuyente_id UUID,
notified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
resuelta_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX uniq_alertas_notif
ON alertas_notificadas (alerta_id, COALESCE(contribuyente_id::text, ''));
```
- `alerta_id` es el `id` que retorna `generarAlertasAutomaticas` (e.g.,
`'lista-negra-propia'`, `'discrepancia-regimen'`).
- `contribuyente_id` puede ser NULL si la alerta es tenant-level. El
UNIQUE compuesto con `COALESCE(... ::text, '')` permite la combinación
porque NULL no participa en UNIQUE de columna sola.
- `resuelta_at` se setea cuando una alerta previamente notificada deja
de aparecer en la corrida actual del cron. Solo informativo — no
genera email.
### Migración 040 — columnas en `recordatorios`
```sql
ALTER TABLE recordatorios
ADD COLUMN email_3d_at TIMESTAMPTZ,
ADD COLUMN email_1d_at TIMESTAMPTZ,
ADD COLUMN email_0d_at TIMESTAMPTZ;
```
Cada columna se setea cuando el cron envía el email para esa ventana.
Si el usuario edita `fecha_limite` después de un envío, las columnas
previas siguen marcadas — no se re-notifica para ventanas ya enviadas.
Decisión MVP simple y predecible.
---
## 3. Flujo del cron
`apps/api/src/jobs/notifications.job.ts` corre `30 8 * * *` America/Mexico_City:
```
runNotifications()
├─ FOR each tenant active:
│ ├─ runNotificationsForTenant(tenantId)
│ │ ├─ Promise.all:
│ │ │ ├─ processNewAlertas(pool, tenantId, ctx)
│ │ │ └─ processProximosRecordatorios(pool, tenantId, ctx)
│ │ └─ try/catch por proceso (un error no bloquea el otro)
│ └─ try/catch por tenant
└─ Logea resumen final
```
### `processNewAlertas`
Para cada contribuyente activo:
1. `generarAlertasAutomaticas(pool, tenantId, contribuyenteId)` → lista
de alertas activas (no persistidas).
2. Por cada alerta: `INSERT INTO alertas_notificadas ... ON CONFLICT DO
NOTHING RETURNING id`. Si retorna fila → era nueva, agregar a batch.
3. Marcar `resuelta_at = NOW()` para alertas previamente notificadas
que NO están activas hoy (`alerta_id <> ALL($activos)`).
4. Si hay alertas nuevas → resolver destinatarios y enviar email
batched (1 email con todas las alertas nuevas del contribuyente).
### `processProximosRecordatorios`
Para cada ventana `['3d', '1d', '0d']`:
1. Buscar recordatorios donde `completado = false`,
`fecha_limite = CURRENT_DATE + diasVentana`, y `email_Xd_at IS NULL`.
2. Por cada recordatorio: resolver destinatarios, enviar email,
`UPDATE email_Xd_at = NOW()`.
---
## 4. Resolución de destinatarios
### Alertas (contribuyente-específicas)
Conjunto = unión de:
- `entidades_gestionadas.supervisor_user_id` del contribuyente.
- `carteras.auxiliar_user_id` de carteras donde aparece el contribuyente
(vía `cartera_entidades`).
- `cliente_accesos.user_id` para ese contribuyente.
El owner del tenant queda incluido **solo si es supervisor de ese
contribuyente** (no se agrega por ser owner). Dedupe natural vía `Set<userId>`.
### Recordatorios (tenant-level, no atados a contribuyente)
- **Privado**: solo el `creado_por`.
- **Público**:
- Clientes con cualquier acceso (`cliente_accesos.user_id`).
- Auxiliares de cualquier cartera (`carteras.auxiliar_user_id`).
- **Si no hay auxiliares en absoluto**: agregar supervisores
(`entidades_gestionadas.supervisor_user_id` `carteras.supervisor_user_id`).
- **Si owner es supervisor y no hay auxiliares**: owner queda incluido
vía la lista de supervisores (intersección).
### Dedupe por usuario, no por email
Si el mismo user aparece como supervisor + auxiliar + cliente, el `Set`
sobre userId garantiza un solo email. El email se resuelve después con
`prisma.user.findMany({ where: { id: { in: userIds }, active: true } })`.
---
## 5. Templates email
### `alertas-nuevas.ts`
Header con conteo total + breakdown por nivel (alta/media/baja con
badges de color SAT-style). Lista de items con borde izquierdo del color
del nivel. Botón "Ver alertas en el sistema" → `${FRONTEND_URL}/alertas`.
Footer aclaratorio: "Estas alertas ya fueron registradas — solo te
avisaremos cuando aparezcan nuevas, no se repetirá esta notificación si
la misma alerta sigue activa."
### `recordatorio-proximo.ts`
Subject incluye prefijo según ventana: `🗓 / ⚠️ / ⏰` + label "en 3 días"
/ "mañana" / "HOY". Body resalta `fecha_limite` con color del nivel de
urgencia (azul/amarillo/rojo). Link → `${FRONTEND_URL}/calendario`.
---
## 6. Archivos creados/modificados
```
apps/api/src/migrations/tenant/039_alertas_notificadas.sql [+]
apps/api/src/migrations/tenant/040_recordatorios_email_notif.sql [+]
apps/api/src/services/email/templates/alertas-nuevas.ts [+]
apps/api/src/services/email/templates/recordatorio-proximo.ts [+]
apps/api/src/services/email/email.service.ts [~] +sendAlertasNuevas, +sendRecordatorioProximo
apps/api/src/services/notifications.service.ts [+]
apps/api/src/jobs/notifications.job.ts [+]
apps/api/src/index.ts [~] +startNotificationsJob (prod-only)
```
Migraciones aplicadas a 3 tenants existentes:
`horux_despacho_mo3nhzvl_1xheu`, `horux_despacho_mo3ni6u8_b9vgg`
(Patito), `horux_despacho_mo7je8bz_vdopr` (Zorro).
---
## 7. Operación
### Activación
- **Producción**: el cron arranca automáticamente en `index.ts` cuando
`NODE_ENV === 'production'`. SMTP debe estar configurado en `.env`
(`SMTP_HOST/PORT/USER/PASS/FROM`).
- **Dev**: cron OMITIDO. Disparo manual:
```ts
import { runNotificationsForTenant } from './jobs/notifications.job.js';
await runNotificationsForTenant('<tenantId>');
```
Para probar sin SMTP real, los emails se loguean a consola (transport
detecta SMTP_USER vacío y entra en modo "log only").
### Disparo manual desde admin
`runNotifications()` y `runNotificationsForTenant(tenantId)` están
exportados — se pueden cablear a un endpoint admin futuro tipo
`POST /admin/notifications/run` para forzar un envío.
### Trazabilidad
- Tabla `alertas_notificadas` queda como log permanente (con
`notified_at` + `resuelta_at`). Útil para auditar "¿se envió email
cuando apareció esta alerta?".
- Recordatorios: las 3 columnas `email_Xd_at` documentan cuáles ventanas
se enviaron.
---
## 8. Pendientes / mejoras posibles
- **Notificación de resolución**: hoy `resuelta_at` se setea en silencio.
Si el owner quiere "buena noticia: la alerta de lista negra desapareció"
agregar template `alerta-resuelta.ts` y disparar email cuando
`rowCount > 0` en el UPDATE de resolved.
- **Re-notificación tras resolución**: hoy MVP "una sola vez en la vida".
Si una alerta se resuelve y vuelve a activarse, no re-notifica. Cambio
pequeño: `DELETE alertas_notificadas WHERE resuelta_at IS NOT NULL AND
resuelta_at < NOW() - INTERVAL '30 days'` antes del INSERT permitiría
re-notificación tras 30 días.
- **Preferencias por usuario**: hoy el destinatario no puede opt-out de
notificaciones específicas. Tabla `user_notification_preferences` con
flags por categoría sería útil cuando aparezca el primer "demasiados
emails" de un cliente.
- **Endpoint admin de disparo manual**: cablear `runNotifications()` a
`POST /admin/notifications/run` para QA/debug.
- **Histórico de emails enviados**: audit-log entry por cada email
enviado (cuántos a quién) para soporte cuando un usuario diga "no me
llegó nada".

View File

@@ -0,0 +1,82 @@
# Rebrand de planes despacho (2026-04-26)
Reestructura del catálogo de planes de Horux Despachos: nuevos precios, nuevos
límites, nuevos planes para empresas individuales y unificación de la regla de
overage por contribuyente extra.
## 1. Catálogo nuevo
| Plan (codename) | Display | Precio anual MXN | RFCs | CFDIs/contrib. | Timbres/mes | Servidor backup | Features extra |
|---|---|---|---|---|---|---|---|
| `mi_empresa` | Mi Empresa | $6,960 (= $580/mes × 12) | 1 | 1,000,000 | 50 | No | — |
| `mi_empresa_plus` | Mi Empresa + | $10,800 (= $900/mes × 12) | 1 | 1,000,000 | 50 | No | API + Lolita IA |
| `business_control` | Business Control | $25,850 IVA inc. | 100 | 1,000,000 | 0 | Sí | API |
| `business_cloud` | **Enterprise** (display) | $43,000 IVA inc. | 100 | 3,000,000 | 0 | Sí | API |
- **`business_cloud` mantiene el codename interno** por backward compat con
suscripciones vigentes; solo cambia el `name` display a "Enterprise".
- Todos los planes despacho se cobran **anual** vía MP preapproval. El monto
mensual ($580/$900) es solo descripción comercial — el cobro es uno por año.
## 2. Overage por contribuyente extra
- Antes: solo `business_cloud`, incluía 3 RFCs base, $45/mes a partir del 4°.
- Ahora: aplica a **`business_control` y `business_cloud`**, ambos incluyen
100 RFCs base, $45/mes a partir del 101°.
- Mi Empresa / Mi Empresa+ tienen **límite duro de 1 RFC** (no permiten overage).
Implementación:
- `addon.service.ts` — constante renombrada `BUSINESS_CLOUD_INCLUDED_RFCS = 3`
`DESPACHO_INCLUDED_RFCS = 100`. La función `adjustBusinessCloudOverage` se
renombró a `adjustDespachoOverage` y ahora valida con
`permiteOverage(plan)` (helper en `@horux/shared`) en vez de comparar
literal contra `'business_cloud'`.
- `contribuyente.controller.ts` — actualizado import y dos callsites (`create`
y `deactivate`).
- `seed.ts``nombre` del catálogo `contribuyente_extra_business_cloud` ahora
es genérico: "Contribuyente adicional (RFC extra)". El codename se mantiene
para no migrar `subscription_addons` existentes.
## 3. Validación de planes en backend
- `subscription.controller.ts``VALID_PLANS` extendido con `mi_empresa` y
`mi_empresa_plus`. `DESPACHO_ONLY_ANNUAL` también los incluye (catálogo solo
expone tarifa anual; pedir `monthly` regresa 400 antes de llegar al servicio).
- `subscription.service.ts` — type alias `Plan` extendido con los dos nuevos
literales para que `scheduleChange`, `subscribe`, `initiateUpgrade` y
`applyPendingChanges` los acepten como destinos válidos.
- `getPlanPrice(plan, frequency)` lee `DESPACHO_PLAN_PRICES[plan]` (catálogo
estático en `@horux/shared`) — mi_empresa y mi_empresa_plus ya estaban ahí
desde el cambio del catálogo (ver `packages/shared/src/constants/despacho-plans.ts`).
## 4. UI (estado previo a esta sesión, ya implementado)
- `/configuracion/planes-despacho` muestra 4 cards en grid lg:grid-cols-4.
- Business Control marcado como "Más popular".
- Cada Card usa `flex flex-col` + `mt-auto` en el botón para alinear botones
al margen inferior aunque las listas de features tengan distinto largo.
- Solo se listan features incluidas (eliminadas las filas "Sin..." para no
ensuciar la vista con negaciones).
- Botón único "Contratar" (sin la variante "(terminar prueba)").
## 5. Migraciones / deploy
No requiere migración de schema. El enum Prisma `Plan` ya incluyó
`mi_empresa` y `mi_empresa_plus` en migración `20260426073942_add_mi_empresa_plan`.
El catálogo de addon se actualiza vía `pnpm db:seed` (upsert por codename).
## 6. Pendientes (decisión del owner)
- **Mi Empresa monthly vs annual billing**: hoy se cobra anual. Si se quiere
preapproval mensual, hay que separar `mi_empresa` del set
`DESPACHO_ONLY_ANNUAL` y agregar precio mensual al catálogo.
- **Mi Empresa overage**: actualmente bloqueado en 1 RFC. Si se quiere permitir
+RFC con cobro automático, agregar `mi_empresa` a `permiteOverage()` y
definir un threshold/precio independiente.
- **Enterprise timbres**: el plan no incluye timbres. Si en algún momento se
quiere paquete fijo incluido, agregar `timbresIncluidosMes` > 0 en
`DESPACHO_PLANS.business_cloud`.
- **`getMyPlan` en `despacho.controller.ts`** sigue mapeando solo a
`business_control`/`business_cloud` por `dbMode`. Si se extiende a tenants
Mi Empresa, hay que revisar esa lógica (hoy reporta `business_cloud` para
cualquier tenant MANAGED no-trial).

View File

@@ -0,0 +1,374 @@
# Sesión 2026-04-26 — Resumen del día
Sesión consolidada con cuatro frentes: (1) limpieza de la columna "Tipo"
en CFDI y drill-downs, (2) rebrand de planes despacho con nuevos precios
y dos planes nuevos para empresas, (3) overage despacho a 100 RFCs y
generalización a Business Control + Enterprise, (4) compensación IVA
para el patrón I/07 PPD ↔ E mismo mes.
Los frentes fiscales y de planes tienen documentos dedicados; este doc
agrega una guía y captura lo que no entró ahí.
---
## Índice
1. [Limpieza columna "Tipo" en CFDI y drill-downs](#1-limpieza-columna-tipo-en-cfdi-y-drill-downs)
2. [Rebrand de planes despacho](#2-rebrand-de-planes-despacho)
3. [Overage despacho generalizado](#3-overage-despacho-generalizado)
4. [Compensación IVA I/07 PPD ↔ E mismo mes](#4-compensación-iva-i07-ppd--e-mismo-mes)
5. [Refactor IVA — fórmula del owner](#5-refactor-iva--fórmula-del-owner)
6. [Notificaciones email automáticas (alertas + recordatorios)](#6-notificaciones-email-automáticas-alertas--recordatorios)
7. [Pendientes](#7-pendientes)
Documentos relacionados creados/actualizados hoy:
- `docs/plans/2026-04-26-i07-ppd-compensacion.md` (creado en otro turno; §8
agregada hoy con la extensión IVA)
- `docs/plans/2026-04-26-rebrand-planes-despacho.md` (creado hoy)
- `docs/plans/2026-04-26-iva-refactor.md` (creado hoy — refactor que
reemplaza la compensación I PUE/07 y el clamp en P)
- `docs/plans/2026-04-26-notifications-email.md` (creado hoy — cron 8:30 AM
con emails por alerta nueva y recordatorio próximo a vencer)
- `docs/plans/2026-04-26-sprints-1-2-3.md` (creado hoy — pre-deploy IVA,
bugs latentes, decisiones del owner D1-D7, sprint 6 SAT)
- `docs/plans/2026-04-26-admin-global-setup.md` (creado hoy — bootstrap
admin global, gestión clientes, add-ons UI, auto-facturación, redirect
login → /clientes)
---
## 1. Limpieza columna "Tipo" en CFDI y drill-downs
### Problema
La columna "Tipo" (EMITIDO/RECIBIDO) era ruido: la información ya está
implícita en la posición del RFC emisor/receptor relativa al
contribuyente activo. Aparecía en `/cfdi`, en los drill-downs de
métricas del dashboard y en los drill-downs de alertas, además de
duplicarse en cada export Excel.
### Cambios
**Frontend `/cfdi`** (`apps/web/app/(dashboard)/cfdi/page.tsx`):
- Removida `<th>Tipo</th>` y la celda `<td>` con el badge.
- Removida `'Tipo': cfdi.type === 'EMITIDO' ? 'Emitido' : 'Recibido'`
de las dos funciones de export.
- Filtros "Todos / Emitidos / Recibidos" cambiaron de filtrar por la
columna `type` a filtrar por RFC del contribuyente. La razón: con
multi-contribuyente por tenant el `type` puede ser inconsistente
cuando dos contribuyentes del mismo tenant se facturan entre sí.
RFC en posición emisor/receptor es fuente de verdad.
**Backend** (`apps/api/src/services/cfdi.service.ts`):
```ts
if (filters.tipo === 'EMITIDO') {
whereClause += ` AND rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++})`;
params.push(filters.contribuyenteId);
} else if (filters.tipo === 'RECIBIDO') {
whereClause += ` AND rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++})`;
params.push(filters.contribuyenteId);
}
```
**Drill-downs actualizados** (mismo patrón en cada uno: removido
`{ header: 'Tipo', key: 'type', width: 10 }` de `EXCEL_COLUMNS`,
`<th>Tipo</th>` de thead, `<td>{cfdi.type}</td>` de tbody):
- `apps/web/app/(dashboard)/drill-down/page.tsx` — drill-down genérico
de métricas del dashboard.
- `apps/web/app/(dashboard)/alertas/cancelaciones/page.tsx`
- `apps/web/app/(dashboard)/alertas/cancelaciones-periodo-anterior/page.tsx`
- `apps/web/app/(dashboard)/alertas/efectivo/page.tsx`
- `apps/web/app/(dashboard)/alertas/tipo-relacion-sospechosa/page.tsx`
(también removida la columna "Dirección" redundante).
---
## 2. Rebrand de planes despacho
Detalle completo: `docs/plans/2026-04-26-rebrand-planes-despacho.md`.
### Resumen de cambios
| Plan (codename) | Display | Anual MXN | RFCs | CFDIs/contrib. | Timbres/mes | Backup | Features extra |
|---|---|---:|---:|---:|---:|---|---|
| `mi_empresa` | Mi Empresa | $6,960 | 1 | 1M | 50 | No | — |
| `mi_empresa_plus` | Mi Empresa + | $10,800 | 1 | 1M | 50 | No | API + Lolita IA |
| `business_control` | Business Control ★ | $25,850 | 100 | 1M | 0 | Sí | API |
| `business_cloud` | Enterprise (display) | $43,000 | 100 | 3M | 0 | Sí | API |
★ = "Más popular".
`business_cloud` mantiene su codename interno por compat con
suscripciones vigentes; solo cambia el `name` display.
### Archivos tocados hoy
- `apps/api/src/controllers/subscription.controller.ts``VALID_PLANS`
y `DESPACHO_ONLY_ANNUAL` extendidos con `mi_empresa` y
`mi_empresa_plus`.
- `apps/api/src/services/payment/subscription.service.ts` — type alias
`Plan` extendido con los dos literales nuevos para que `subscribe`,
`scheduleChange`, `initiateUpgrade` y `applyPendingChanges` los
acepten.
### Trabajo de fondo previo (no en esta sesión pero relacionado)
- `packages/shared/src/constants/despacho-plans.ts` — catálogo y
helpers (`isDespachoPaidPlan`, `permiteOverage`,
`despachoPlanTieneDualidad`).
- `apps/api/prisma/schema.prisma` — enum `Plan` con
`mi_empresa` y `mi_empresa_plus` (migración
`20260426073942_add_mi_empresa_plan`).
- `apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx`
UI con grid de 4 cards alineadas verticalmente
(`flex flex-col` + `mt-auto`), botón "Contratar".
---
## 3. Overage despacho generalizado
### Antes
`addon.service.ts` tenía `BUSINESS_CLOUD_INCLUDED_RFCS = 3` y la
función `adjustBusinessCloudOverage` filtraba con
`sub.plan !== 'business_cloud'`. Solo Enterprise generaba overage.
### Después
- Constante renombrada `BUSINESS_CLOUD_INCLUDED_RFCS = 3`
`DESPACHO_INCLUDED_RFCS = 100`.
- Función renombrada `adjustBusinessCloudOverage`
`adjustDespachoOverage`.
- Filtro de plan ahora usa `permiteOverage(sub.plan)` (helper en
`@horux/shared`) que retorna `true` para `business_control` y
`business_cloud`. Mi Empresa / Mi Empresa+ tienen límite duro
de 1 RFC y `permiteOverage` retorna false — no entran a overage.
- Codename del catálogo `contribuyente_extra_business_cloud` se
preserva por compat con `subscription_addons` existentes; solo
cambia el `nombre` display a "Contribuyente adicional (RFC extra)".
### Archivos tocados
- `apps/api/src/services/payment/addon.service.ts` — constante,
función, plan check, comentarios.
- `apps/api/src/controllers/contribuyente.controller.ts` — import +
dos callsites (`create` y `deactivate`) + comentarios actualizados.
- `apps/api/prisma/seed.ts` — nombre del addon catalogo a genérico.
---
## 4. Compensación IVA I/07 PPD ↔ E mismo mes
Detalle completo: `docs/plans/2026-04-26-i07-ppd-compensacion.md` §8.
### Asimetría que motivó el fix
Para `I PUE/07` la cadena anticipo + aplicación + E/07 cierra
algebraicamente con la lógica existente
(`SUM_REL_TRAS` + filtro `<> '07'` en NEG). Para `I PPD/07` la
aplicación no aporta IVA en su mes (espera al P), pero si en el
**mismo mes** existe una E con `tipoRelación ≠ 07` que la referencia,
la E sí resta IVA en NEG y la I PPD nunca aportó nada que la
neutralice. Resultado previo: IVA acreditable / causado de la E
"perdido".
### Solución
Mirror del `i07PpdComp` que ya aplicamos en gastos/ingresos G1: la
I PPD/07 hereda como aporte el IVA de la E que la cancela (mismo
lado, mismo mes/año, `tipoRelación ≠ 07`). Net I PPD + E = 0
dentro del mes.
### Archivos tocados
- `apps/api/src/services/impuestos.service.ts`:
- **Predicado nuevo** `IS_I_PPD_07`.
- **Helpers nuevos** `SUM_E_REFERENCING_TRAS(esLadoE)` /
`SUM_E_REFERENCING_RET(esLadoE)`. La I PPD/07 hereda IVA de
TODAS las E que la referencien (mismo lado/mes), sin filtrar
tipoRelación.
- **Helper EXISTS** `HAS_E_REFERENCING_MISMO_MES(esLadoE)` agregado
a `bucketCausadoAny` y `bucketAcreditableAny` para que las
I PPD/07 relevantes entren al `WHERE` de los queries que usan
estos buckets.
- **Predicado EXISTS** `E_REFERENCIA_I_PPD_07_MISMO_MES(esLadoIAlias)`
que se evalúa **desde la fila E**: detecta si una E referencia
una I PPD/07 del mismo lado/mes. Permite distinguir E/07 que
apuntan a anticipo I PUE puro (siguen excluidas del NEG, statu
quo) de E/07 que apuntan a I PPD/07 (entran al NEG en el caso
PPD).
- **`bucketCausadoNeg` y `bucketAcreditableNeg` extendidos** con
disyuntivo `OR E_REFERENCIA_I_PPD_07_MISMO_MES(...)` — sin esto,
la compensación no ocurriría cuando la operación tiene solo una
E/07 (lo fiscalmente correcto pero raro en práctica).
- **Rama nueva** en los 4 signed exprs (`signedCausadoTras`,
`signedCausadoRet`, `signedAcreditableTras`,
`signedAcreditableRet`):
```sql
WHEN ${esLado} AND ${IS_I_PPD_07} THEN ${SUM_E_REFERENCING_*(esLadoE)}
```
`esLadoE` = `ctx.esEmisor`/`ctx.esReceptor` con rewrite
`replace(/\brfc_(emisor|receptor)\b/g, 'e.rfc_$1')`. Análogamente
`esLadoIAlias` para alias `i` en `E_REFERENCIA_I_PPD_07_MISMO_MES`.
### Validación con caso real
Husberto Ignacio Torres (RFC `TOAH680201RA2`), agosto 2025:
- Anticipo `729109fc` I PUE: $148K, IVA $20,413.79.
- Aplicación `5c874749` I PPD/07: $454K, IVA $62,620.69.
- NC `7163da3b` E PUE/07: $148K, IVA $20,413.79 (cancela anticipo).
- NC `7aac715b` E PUE/01: $10K, IVA $1,379.31 (sustitución).
| Estado | Acreditable agosto 2025 |
|---|---:|
| Antes | $19,033.69 |
| Después | $20,413.00 |
**Delta: +$1,379.31** acreditable recuperado, exactamente la E/01 que
restaba sin contraparte. La E/07 sigue sin afectar IVA (correcto).
### Cache
`computeMetricaMensual` llama a `getResumenIva` que ya usa los signed
exprs nuevos. Periodos cacheados con la lógica vieja quedan stale
hasta que se recompute.
---
## 5. Refactor IVA — fórmula del owner
Detalle completo: `docs/plans/2026-04-26-iva-refactor.md`.
Cambio mayor en `/impuestos`. El owner pidió alinear el cálculo a un spec
explícito que difiere del código previo en tres puntos:
1. **Sin clamp del IVA en P**: campos `iva_traslado_pago_mxn` /
`iva_retencion_pago_mxn` se usan directos. Antes:
`LEAST(iva, monto × 0.16)`.
2. **Sin compensación I PUE/07**: las I PUE/07 aportan IVA completo. La
E/07 (si se emite) resta normalmente vía bucket NEG. Antes había
`GREATEST(0, IVA Σ IVA anticipos referenciados)`.
3. **E con tipoRel=07 entra al NEG**: ya no se filtran las E/07 del
bucket NEG. Antes el filtro `<> '07'` las excluía (excepto las que
apuntaban a I PPD/07 vía un disyuntivo EXISTS).
### Conservados
- Rama I PPD/07 con `SUM_E_REFERENCING_TRAS/RET` (hereda IVA de E del
mismo mes que la cancelan).
- Estructura de tres KPIs separados: Trasladado / Acreditable / Retenido
con fórmula `Resultado = T A R`.
- Exclusiones por clave_prod_serv (`84121603`, `93161608`, `85101501`,
`85121800`).
- Filtro de régimen por lado del contribuyente (emisor cuando vende,
receptor cuando compra).
### Eliminados del código
- `IS_I_PUE_07`
- `SUM_REL_TRAS`, `SUM_REL_RET`
- `E_REFERENCIA_I_PPD_07_MISMO_MES`
### Validación con Husberto agosto 2025
| KPI | Antes | Después | Delta |
|---|---:|---:|---:|
| Trasladado | $119,093.08 | $111,781.45 | $7,311.63 |
| Acreditable | $147,023.59 | $182,683.84 | +$35,660.25 |
| Resultado IVA | $27,930.51 | $70,902.39 | **$42,971.88** |
Delta favorable al contribuyente (más acreditable, menos a pagar).
### Riesgos aceptados
- Sin compensación I PUE/07 + E/07 ausente → sobrecausa el IVA del anticipo.
En Husberto agosto: 11 I PUE/07 con 0 E/07 emitidas. El owner aceptó
bajo la premisa "fiscalmente el contador debe emitir la E/07".
- Sin clamp P → vulnerables a XMLs de proveedores que reportan IVA total
en pagos parciales.
---
## 6. Notificaciones email automáticas (alertas + recordatorios)
Detalle completo: `docs/plans/2026-04-26-notifications-email.md`.
Cron diario **8:30 AM America/Mexico_City** que cierra el pendiente
histórico de "emails automáticos para alertas/recordatorios" (estaba en
CLAUDE.md "Problemas conocidos"). Modelo elegido por el owner: **Option B
— por evento** (una notificación cuando algo se activa, no digest diario).
### Comportamiento
- **Alertas**: por cada contribuyente activo, llama
`generarAlertasAutomaticas`. Las que aparecen por primera vez se
insertan en `alertas_notificadas` (BD tenant) y disparan email batched
al supervisor + auxiliares + clientes del contribuyente. Una alerta
solo se notifica **una vez en la vida** (MVP). Las que dejan de
aparecer se marcan `resuelta_at` (informativo, no email).
- **Recordatorios**: 3 ventanas (3 días antes, 1 día antes, mismo día).
Cada ventana se envía a lo más una vez vía columnas `email_3d_at /
email_1d_at / email_0d_at` en `recordatorios`. Recipientes: clientes +
auxiliares; si no hay auxiliares, también supervisores; si owner es
supervisor sin auxiliares, también owner.
### Archivos
- Migraciones tenant 039 (alertas_notificadas) y 040 (columnas en recordatorios)
- 2 templates email: `alertas-nuevas.ts`, `recordatorio-proximo.ts`
- `services/notifications.service.ts`: resolución destinatarios + procesamiento
- `jobs/notifications.job.ts`: cron `30 8 * * *` con disparo manual exportado
- `email.service.ts`: helpers `sendAlertasNuevas` + `sendRecordatorioProximo`
- `index.ts`: wire del cron solo si `NODE_ENV === 'production'`
### Operación
- En **dev** el cron NO arranca automáticamente (evita spam con datos de
prueba). Disparo manual: `runNotificationsForTenant(tenantId)`.
- Migraciones aplicadas a los 3 tenants existentes (Patito, Zorro,
mo3nhzvl).
- Sin SMTP configurado los emails se loguean a consola (transport detecta
`SMTP_USER` vacío). **Pendiente real**: configurar SMTP en `.env` para
prod.
---
## 7. Pendientes
**Resoluciones de hoy** (cerradas):
- ✓ Drill-down dashboard también limpiado de columna "Tipo".
- ✓ Compensación IVA aplicada solo al caso PPD (PUE no necesita —
evaluado y descartado, ver §8 del doc i07-ppd).
- ✓ Versión inicial filtraba `<> '07'`; refinada para distinguir por
destino de la E (apunta a I PPD/07 vs apunta a anticipo I PUE puro).
Ahora cubre el caso fiscalmente correcto donde solo existe E/07.
- ✓ Refactor IVA al spec explícito del owner: removida compensación I
PUE/07, removido clamp en P, todas las E PUE entran al NEG. Caso
Husberto agosto 2025 valida $111K trasladado / $182K acreditable.
- ✓ **Notificaciones email automáticas de alertas/recordatorios**
(CLAUDE.md "Problemas conocidos"): cron diario 8:30 AM con detección
de alertas nuevas + 3 ventanas de recordatorio (3d/1d/0d). Modelo
Option B (por evento). Detalle en `2026-04-26-notifications-email.md`.
- ✓ **#2 Convertir `/pendientes` → "Despacho"** (verificado, ya estaba
hecho de sesión previa: módulo `/despachos` con sub-nav).
- ✓ **Recrear org Facturapi de Carlos** (verificado, ya estaba hecho:
TORC9611214CA tiene `facturapi_org_id` y `csd_uploaded=true`).
- ✓ **Sprint 1 — pre-deploy IVA**: validados otros tenants (72-100% de
I PUE/07 con E contraparte), borrado bulk de `metricas_mensuales`
(353 filas en años < 2026), `dashboard.service.ts` alineado con la
fórmula nueva (s4/r4 nuevos, sin clamp P, sin compensación I PUE/07,
sin filtro `<> '07'`). Detalle en `2026-04-26-sprints-1-2-3.md`.
- ✓ **Sprint 2 — bugs latentes**: overage al cambiar de plan
(`reconcileOverageAfterPlanChange` en upgrade/scheduled/cancel),
`getMyPlan` lee `tenant.plan` directamente. "Completadas > Pendientes"
no es bug. Visibilidad auxiliares: investigado, sin reproducción.
- ✓ **Sprint 3 — decisiones owner D1-D7**: Mi Empresa(+) con billing
dual (mensual default + anual con 17% / 10 meses); re-notificación
alertas tras 30 días de resuelta. Resto confirmado o sin cambio.
- ✓ **Sprint 6 — investigación SAT**: logging `codeRequest` verificado
activo (`sat-client.service.ts:116-184`), listo para diagnosticar
rejections futuras. Manuel NO necesitaba re-sync (242 CFDIs
completos); el bug real era de Alexa con record stale del 2026-04-21
— reconciliado a `completed`. Detalle en `2026-04-26-sprints-1-2-3.md`.
- ✓ **Bootstrap admin global del fork**: ejecutado
`pnpm bootstrap:admin-global` con `HORUX_ADMIN_EMAIL=carlos@horuxfin.com`
+ `HORUX_TI_EMAIL=ivan@horuxfin.com`. Crea tenant `Horux 360`
(`HTS240708LJA`) y asigna `platform_admin`/`platform_ti`.
Contraseña fijada manualmente a `Admin12345!` (bcrypt cost 12).
**Pendientes derivados de hoy:**
- Configurar **SMTP en `.env`** de producción para que el cron de
notificaciones envíe correos reales (sin esto se loguean a consola).
- **Alerta automática "P con IVA > 16% del pago"** (follow-up D5) —
detectar XMLs malformados sin reintroducir clamp global.
- **Reportar bug 2.4 con detalle** si reaparece visibilidad de
auxiliares en carteras: capturar pantalla + rol + URL exacta.

View File

@@ -0,0 +1,320 @@
# Sprints 1, 2, 3 y 6 — cierre del día (2026-04-26)
Cuatro sprints encadenados después del refactor IVA y del wire de
notificaciones email:
- **Sprint 1**: pre-deploy del refactor IVA. Validar otros tenants,
recompute de `metricas_mensuales`, alinear `dashboard.service.ts`.
- **Sprint 2**: bugs latentes (overage al cambiar de plan, `getMyPlan`
para Mi Empresa, métricas de Despacho, visibilidad auxiliares).
- **Sprint 3**: decisiones del owner (D1-D7) — billing dual, re-notif
alertas, etc.
- **Sprint 6**: investigación SAT — verificar logging `codeRequest`,
reconciliar record stale de Alexa.
---
## Sprint 1 — Pre-deploy refactor IVA
### 1.1 Validar otros tenants
Inventario por contribuyente con I/07 PUE/PPD/E:
| Tenant | Contribuyente | Lado | I PUE/07 | con E (cualquier tipoRel) | I PPD/07 con E |
|---|---|---|---:|---:|---:|
| Patito | TOAH (Husberto) | Receptor | 356 | 257 (72%) | 21/21 |
| Patito | TOAH (Husberto) | Emisor | 6 | 6 (100%) | — |
| Patito | TORC (Carlos) | Receptor | 8 | 6 (75%) | 22/22 |
| Zorro | (sin volumen) | — | 0 | 0 | 0 |
**Hallazgo crítico ajustado**: la primera medida (filtro estricto a
E con `tipoRel=07` apuntando al anticipo) sugería sobrecausa masiva.
La medida correcta (cualquier E PUE que cancele en el mismo mes,
incluyendo E/01 sustitución) muestra que 72-100% sí tienen contraparte.
**Decisión del owner**: para los huérfanos, **fidelidad al XML > interpretación**:
> "Si no existe la tipo E, lo correcto es mostrar los datos tal cual viene
> la información, ya que si hacemos cualquier modificación, podemos llegar
> a una discrepancia."
Esto cierra D4: confirmar la fórmula nueva sin compensación I PUE/07
ni clamp en P.
### 1.2 Recompute bulk de `metricas_mensuales`
Borrado de cache de años cerrados (< 2026) en los 3 tenants:
```sql
DELETE FROM metricas_mensuales WHERE anio < 2026;
```
| Tenant | Filas borradas | Filas restantes (2026 actual) |
|---|---:|---:|
| Patito | 250 | 0 |
| Zorro | 103 | 6 |
| mo3nhzvl | 0 | 0 |
Estrategia **lazy repopulation**: la próxima query de un usuario sobre
un mes pasado dispara el path on-the-fly con la fórmula nueva, y el
cron `metricas-invalidations.job.ts` repuebla en background.
### 1.3 Alinear `dashboard.service.ts` con la fórmula nueva
`calcularIvaBalancePorRegimen` tenía la lógica vieja inline (clamp P,
compensación I PUE/07, filtro `<> '07'` en s3/r3). Cambios:
- `IVA_NETO_PAGO`: removido clamp `LEAST(...)`, usa campos directos.
- `s1`/`r1` (I PUE emisor/receptor): removida la rama de compensación
con `SUM_REL_TRAS`. Ahora aportan IVA neto completo.
- `s3`/`r3` (E PUE): removido filtro `<> '07'`. Todas las E PUE entran
al NEG.
- **Nuevos `s4`/`r4`**: I PPD/07 hereda IVA neto de E que la cancele en
mismo mes. Mirror del `SUM_E_REFERENCING_*` de `impuestos.service.ts`.
Suma final actualizada:
```ts
causado = s1 + s2 + s4 - r3
acreditable = r1 + r2 + r4 - s3
balance = causado - acreditable
```
**Validación**: dashboard coincide centavo a centavo con `/impuestos`
para Husberto agosto 2025: T=$111,781.45, A=$182,683.84, balance=$70,902.39.
---
## Sprint 2 — Bugs latentes
### 2.1 Recomputar overage al cambiar de plan
`adjustDespachoOverage` no se invocaba desde el flujo de cambios de plan.
Si un tenant pasaba de Enterprise → Business Control (o Mi Empresa→Business
Control), el addon de overage quedaba huérfano: cobros incorrectos en MP.
**Cambios**:
- `addon.service.ts`:
- **Nuevo helper** `countActiveContribuyentesForTenant(tenantId)`
abre pool tenant y cuenta CONTRIBUYENTEs activos. Reemplaza el
helper local `countActiveContribuyentes` que vivía en
`contribuyente.controller.ts`.
- **Nuevo helper** `cancelOverageAddonForTenant(tenantId)` — cancela
el preapproval MP + setea status='cancelled'. Idempotente. No
requiere que la subscripción esté activa (útil al cancelarla).
- `subscription.service.ts`:
- **Nuevo helper privado** `reconcileOverageAfterPlanChange(tenantId, fromPlan, toPlan)`
— fail-soft. Si el plan target permite overage, llama
`adjustDespachoOverage`. Si no, llama `cancelOverageAddonForTenant`.
- `applyApprovedUpgrade`: invoca el reconcile tras el `$transaction`.
- `applyPendingChanges`: invoca el reconcile dentro del loop.
- `cancelSubscription`: invoca `cancelOverageAddonForTenant` antes de
marcar status='cancelled' (porque el lookup necesita la sub activa).
### 2.2 `getMyPlan` para Mi Empresa
Mapping anterior usaba `dbMode` como proxy: `BYO → business_control`,
`MANAGED → business_cloud`. Para Mi Empresa y Mi Empresa+ (también
MANAGED) reportaba `business_cloud` por error.
**Fix**: leer `tenant.plan` directamente. Soporta los 4 planes despacho.
Trial sigue detectándose por `trialEndsAt`.
```ts
let currentPlan: string;
if (isTrialActive) {
currentPlan = 'trial';
} else {
currentPlan = String(tenant.plan);
}
```
### 2.3 "Completadas > Pendientes" — NO es bug
Verificación contra datos reales:
| Contribuyente | Obl. pendientes | Obl. completadas |
|---|---:|---:|
| Horux 360 | 1 | 2 |
| Husberto | 2 | 3 |
`progresoDelMes = completadas / (pendientes + completadas)` está bien.
"Completadas > Pendientes" es señal positiva (despacho al día), no
error de cálculo. Cerrado.
### 2.4 Visibilidad auxiliares en carteras — sin reproducción
Datos actuales en Patito:
- Cartera `Demo` (top-level, sin auxiliar)
- Subcartera `Demo Auxiliar` con `auxiliar_user_id` apuntando al auxiliar.
El query del controller (`WHERE c.auxiliar_user_id = $1`) trae la
subcartera correctamente. Sin reproducción específica del bug original
(quién, qué pantalla, qué se ve vs qué se espera), cerrado como
"investigado, pendiente reporte específico".
---
## Sprint 3 — Decisiones del owner (D1-D7)
### Resumen de decisiones
| # | Decisión | Estado |
|---|---|---|
| D1 | Mi Empresa y Mi Empresa+ con billing dual: mensual default, anual = 10 meses (descuento 17%) | ✓ Implementado |
| D2 | Mi Empresa(+) sin overage de RFCs | ✓ Ya estaba |
| D3 | Enterprise sin timbres incluidos | ✓ Ya estaba |
| D4 | Confirmar fidelidad al XML (sin compensación I PUE/07, sin clamp P) | ✓ Confirmado tras Sprint 1 |
| D5 | Mantener sin clamp en IVA de P | ✓ Ya estaba |
| D6 | Sin email de "alerta resuelta" | ✓ Ya estaba |
| D7 | Re-notificación tras 30 días de resuelta | ✓ Implementado |
### D1 — Billing dual Mi Empresa(+)
**Catálogo nuevo** en `packages/shared/src/constants/despacho-plans.ts`:
```ts
export const DESPACHO_PLAN_PRICES = {
mi_empresa: { monthly: 580, firstYear: 5_800, renewal: 5_800, permiteMonthly: true },
mi_empresa_plus: { monthly: 900, firstYear: 9_000, renewal: 9_000, permiteMonthly: true },
business_control: { monthly: null, firstYear: 25_850, renewal: 25_850, permiteMonthly: false },
business_cloud: { monthly: null, firstYear: 43_000, renewal: 43_000, permiteMonthly: false },
};
```
**Helpers nuevos**:
- `getPrecioDespacho(plan, frequency, phase)` — resuelve precio según
frequency. Throws si el plan no permite la frecuencia.
- `permiteFrecuenciaMensual(plan)` — flag.
**Backend**:
- `subscription.service.ts:getPlanPrice` usa `permiteFrecuenciaMensual`
+ `getPrecioDespacho`.
- `subscription.controller.ts`: `DESPACHO_ONLY_ANNUAL` ahora solo
contiene `business_control` y `business_cloud`.
**UI** (`/configuracion/planes-despacho`):
- Nuevo `FrequencyToggle` con dos pestañas (Mensual / Anual `17%`)
inline en cada Card de Mi Empresa y Mi Empresa+. Toggle per-plan,
default mensual.
- Precio dinámico:
- Mensual: $580 — "o $5,800/año (ahorras 17%)" como CTA al anual.
- Anual: $5,800 — "Pagas 10 meses en lugar de 12" en verde.
- `handleContratar` y `handleCambiar` usan `frequencyFor(plan)` para
derivar la frecuencia del toggle.
### D7 — Re-notificación tras 30 días
`notifications.service.ts:processAlertasContribuyente` — antes del INSERT
de detección de alertas nuevas, borra registros con `resuelta_at < NOW() - 30 days`:
```sql
DELETE FROM alertas_notificadas
WHERE contribuyente_id = $1::uuid
AND resuelta_at IS NOT NULL
AND resuelta_at < NOW() - INTERVAL '30 days'
```
Si una alerta vuelve a aparecer después de >30 días resuelta, el INSERT
posterior la detecta como "nueva" y vuelve a notificar al equipo.
---
## Archivos modificados
```
packages/shared/src/constants/despacho-plans.ts [~] D1 catálogo + helpers
apps/api/src/services/dashboard.service.ts [~] Sprint 1.3 IVA balance
apps/api/src/services/payment/addon.service.ts [~] Sprint 2.1 helpers
apps/api/src/services/payment/subscription.service.ts [~] Sprint 2.1 reconcile + D1 getPlanPrice
apps/api/src/controllers/subscription.controller.ts [~] D1 DESPACHO_ONLY_ANNUAL
apps/api/src/controllers/despacho.controller.ts [~] Sprint 2.2 getMyPlan
apps/api/src/services/notifications.service.ts [~] D7 DELETE 30d
apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx [~] D1 UI toggle
```
Migraciones: ninguna nueva (las tablas/columnas necesarias se crearon
en sesiones previas).
Cache borrada: `metricas_mensuales WHERE anio < 2026` en los 3 tenants
(353 filas en total).
---
---
## Sprint 6 — Investigación SAT
### 6.1 Verificar logging de `codeRequest`
`apps/api/src/services/sat/sat-client.service.ts` líneas 116-184: el código
expone `codeRequestValue/Entry/Message` en cada `verify()` vía
`getCodeRequest()` de la lib `@nodecfdi/sat-ws-descarga-masiva`.
Cuando el SAT rechaza una solicitud, los 3 valores se loguean en
`[SAT Verify Debug]` (consola) y se incluyen en `error.message` del
`VerifyResult` cuando `status` es `rejected` o `failed`. Formato:
```
SAT request=<entry>(<value>) codeRequest=<entry>(<value>) — <message>
wrapperCode=<status> wrapperMsg="<msg>"
```
Permite diagnosticar los 5 códigos SAT documentados:
- `5000 Accepted` (happy path)
- `5002 Exhausted`
- `5003 MaximumLimit`
- `5004 EmptyResult`
- `5005 Duplicated` (la hipótesis principal de Manuel pre-2026-04-23)
**Logging activo y listo.** Solo falta capturar el primer caso real en
producción para confirmar la hipótesis 5005 y decidir mitigación
(esperar 72h, reducir rangos, cambiar FIEL, etc.).
### 6.2 Re-sync de Manuel — reformulado
El plan original era re-sync custom de bloques 3-9 de Manuel. Verificación
sobre datos reales:
| Contribuyente | Tenant | Job initial | CFDIs en BD |
|---|---|---|---:|
| Manuel (GADM9107165I0) | Zorro | ✓ completed | 242, distribuidos consistentemente |
| Alexa (TORA0007099R6) | Zorro | **failed** (stale) | 415 descargados |
**Manuel NO necesita re-sync** — su initial está completed con 242 CFDIs.
Los "bloques 3-9" mencionados en la sesión 2026-04-21 corresponden a
sub-fallos internos del job que terminó completed en su totalidad.
**El bug real era de Alexa**: 415 CFDIs descargados pero record marcado
`failed` por el bug stale del 2026-04-21 (cleanup manual de la sesión MP).
**Reconciliado** con UPDATE directo:
```sql
UPDATE sat_sync_jobs
SET status='completed',
error_message='Reconciliado 2026-04-26: el initial completo pero el
status quedo stale por el bug del 2026-04-21.',
cfdis_inserted=415,
completed_at=COALESCE(completed_at, NOW())
WHERE id='830bac32-1bfb-44cb-ab47-333eac840f81' AND status='failed';
```
La pendiente original apuntaba al contribuyente equivocado. Reformularla
y cerrarla con fix de datos en lugar de re-sync.
---
## Pendientes derivados
- **Sprint 4**: Cloudflare Tunnel + `FRONTEND_URL` HTTPS dev + typecheck
web cleanup. Decisión del owner: dejarlos al final.
- **Sprint 5**: Nómina (tipo N) y Carta Porte. Priorizar según demanda real.
- **Sprint 6 abierto**:
- Capturar el primer `codeRequest` real en producción cuando ocurra
una rejection SAT (logging ya activo).
- Prueba cross-contribuyente end-to-end (manual del owner).
- Mejora futura: **alerta automática "P con IVA > 16% del pago"**
follow-up de D5 para detectar XMLs malformados sin reintroducir clamp
global.
- **Reportar bug 2.4 con detalle**: si reaparece visibilidad de auxiliares
en carteras, capturar pantalla + rol del usuario + URL exacta.

View File

@@ -0,0 +1,126 @@
# Sesión 2026-04-27 — Compensación I PPD/07 solo con E PUE + filtro CFDI por tipo
Dos cambios independientes pedidos en la sesión:
1. La compensación I PPD/07 ↔ E (introducida 2026-04-26) ahora solo aplica
cuando la **E referenciante es PUE**. Si la E es PPD, no se compensa.
2. Bug: el filtro de tipo de comprobante (I/E/P/T/N) en `/cfdi` no estaba
funcionando — el dropdown actualizaba el state pero la query SQL nunca
recibía el parámetro.
---
## 1. Compensación I PPD/07 — restricción a E PUE
### Motivación
La asimetría detectada: el bucket NEG (`bucketCausadoNeg`/`bucketAcreditableNeg`
en `impuestos.service.ts`, y `nc` en `dashboard.service.ts`) **solo cuenta E
PUE** — las E PPD no restan en su mes (esperan al P). Pero la compensación
I PPD/07 ↔ E disparaba con cualquier E (PUE o PPD) referenciante en mismo
mes, generando aporte sin contraparte cuando la E era PPD.
Caso concreto: si la I PPD/07 era cancelada por una **E PPD** en mismo mes,
el periodo recibía `+IVA/base` de la I PPD/07 sin que la E PPD restara nada
(porque las E PPD están fuera del NEG). Resultado: ingreso/gasto inflado en
el mes.
Regla nueva: solo E PUE dispara compensación, simétrico al NEG.
### Cambios — 7 puntos
Filtro `AND e.metodo_pago = 'PUE'` agregado en cada subquery que detecta
E referenciantes:
**`apps/api/src/services/impuestos.service.ts`** (3 puntos)
- L99 — `SUM_E_REFERENCING_TRAS`
- L110 — `SUM_E_REFERENCING_RET`
- L136 — `HAS_E_REFERENCING_MISMO_MES` (predicado EXISTS para el WHERE)
**`apps/api/src/services/dashboard.service.ts`** (4 puntos)
- L377 — `g1I07PpdComp` (ingresos PF Empresarial G1, base)
- L543 — `i07PpdComp` (egresos, base)
- L752 — `s4` (balance IVA por régimen, lado emisor)
- L775 — `r4` (balance IVA por régimen, lado receptor)
### Comportamiento esperado por escenario
| Escenario | Antes | Ahora |
|---|---|---|
| I PPD/07 + E PUE mismo mes | Compensación (suma a 0) | Compensación (suma a 0) — sin cambio |
| I PPD/07 + E PPD mismo mes | I PPD/07 hereda IVA/base (E PPD no resta → ingreso/gasto inflado) | I PPD/07 no compensa (E PPD se cobra vía P más tarde, ahí causa) |
| I PPD/07 + sin E mismo mes | I PPD/07 espera al P | Sin cambio |
### Cache `metricas_mensuales`
`metricas-compute.service.ts` invoca `calcular*PorRegimen` y `getResumenIva`,
así que la lógica nueva fluye automáticamente en el próximo recompute. Filas
ya cacheadas con la lógica vieja siguen sirviéndose hasta su recompute manual
(o vía `METRICAS_BYPASS_CACHE=1`).
---
## 2. Filtro de tipo de comprobante en `/cfdi` — fix
### Síntoma
Dropdown "Tipo de comprobante" en `/cfdi` (Select con I/E/P/T/N) actualizaba
`filters.tipoComprobante` correctamente, pero la lista no filtraba — todos
los tipos seguían apareciendo.
### Causa raíz — 2 puntos rotos
El service (`apps/api/src/services/cfdi.service.ts:93-95`) ya tenía la lógica:
```ts
if (filters.tipoComprobante) {
whereClause += ` AND tipo_comprobante = $${paramIndex++}`;
params.push(filters.tipoComprobante);
}
```
Pero **dos eslabones intermedios** no propagaban el filtro:
1. **Cliente HTTP** (`apps/web/lib/api/cfdi.ts`, función `getCfdis`):
serializaba `tipo`, `estado`, `fechaInicio`, etc. — pero **no**
`tipoComprobante`. El query string llegaba al backend sin el parámetro.
2. **Controller** (`apps/api/src/controllers/cfdi.controller.ts`,
`getCfdis`): construía `filters` desde `req.query` sin leer
`tipoComprobante`. Aunque el cliente lo hubiera enviado, el controller
lo descartaba.
### Fix
`apps/web/lib/api/cfdi.ts` — agregada serialización:
```ts
if (filters.tipoComprobante) params.set('tipoComprobante', filters.tipoComprobante);
```
`apps/api/src/controllers/cfdi.controller.ts` — agregado al spread de filters:
```ts
tipoComprobante: req.query.tipoComprobante as any,
```
### Lección
Patrón frecuente en este repo: agregar un filtro nuevo requiere **3 puntos
sincronizados** — tipo en `@horux/shared`, serialización en
`apps/web/lib/api/*.ts`, parsing en `apps/api/src/controllers/*.ts`. El tipo
existía (`CfdiFilters.tipoComprobante` en `packages/shared/src/types/cfdi.ts:115`),
y el service consumía. Lo intermedio quedó desconectado y falló silencioso.
---
## 3. Pendientes derivados
- **Recompute de `metricas_mensuales`** para meses con I PPD/07 + E PPD que
hayan cacheado valores inflados. Disparar via `metricas-compute.service.ts`
o setear `METRICAS_BYPASS_CACHE=1` temporalmente para auditar diferencias.
- **Smoke test** en `/cfdi` con cada tipo (I/E/P/T/N) tras refrescar.
- **Reverter aún pendiente** (de sesión 2026-04-26): owner pidió evaluar si
toda la rama I PPD/07 ↔ E debería desaparecer. Fix de hoy es paliativo —
cierra la asimetría más visible pero la regla de "I PPD hereda E" sigue
siendo opinión, no SAT canónico.

View File

@@ -0,0 +1,631 @@
# Sesión 2026-04-27 — Resumen del día
Sesión enfocada en el módulo de Impuestos y la gestión de planes: refactor
del cálculo de ISR acumulado al estilo formato 14 del SAT, dos toggles
nuevos para excluir activos fijos y NCs, extensión del filtro de activos
para cubrir las cadenas P → I y E → {I, P → I}, límite de 5 RFCs durante
el trial gratuito, fix de un bug crítico de scope SQL, y un plan "Custom"
gratis sin fecha fin asignable solo por Admin Global.
Ocho releases shippeadas en OneDrive durante la sesión: V.1.0.6, V.1.0.7,
V.1.0.8, V.1.0.9, V.1.0.11, V.1.0.12, V.1.0.14 (V.1.0.10 y V.1.0.13 fueron
solo docs).
---
## Índice
1. [V.1.0.6 — ISR base gravable acumulada y desglose del periodo](#1-v106--isr-base-gravable-acumulada-y-desglose-del-periodo)
2. [V.1.0.7 — Filtros "Considerar activos" y "Considerar NCs" (Fase 1)](#2-v107--filtros-considerar-activos-y-considerar-ncs-fase-1)
3. [V.1.0.8 — Defaults de los toggles a ON (cache-friendly)](#3-v108--defaults-de-los-toggles-a-on-cache-friendly)
4. [V.1.0.9 — Filtro de activos extendido a P y E relacionadas](#4-v109--filtro-de-activos-extendido-a-p-y-e-relacionadas)
5. [Spec en pipeline (no shipped) — Sort por nombre en drill-down](#5-spec-en-pipeline-no-shipped--sort-por-nombre-en-drill-down)
6. [V.1.0.11 — Límite de 5 RFCs durante trial gratuito](#6-v1011--límite-de-5-rfcs-durante-trial-gratuito)
7. [V.1.0.12 — Fix bug de scope SQL en filtro de activos](#7-v1012--fix-bug-de-scope-sql-en-filtro-de-activos)
8. [V.1.0.14 — Plan Custom (gratis, sin fecha fin, solo admin)](#8-v1014--plan-custom-gratis-sin-fecha-fin-solo-admin)
9. [Pendientes derivados](#9-pendientes-derivados)
Documentos relacionados creados hoy:
- `docs/superpowers/specs/2026-04-27-isr-base-gravable-acumulada-design.md`
- `docs/superpowers/plans/2026-04-27-isr-base-gravable-acumulada.md`
- `docs/superpowers/specs/2026-04-27-filtros-activos-ncs-impuestos-fase1-design.md`
- `docs/superpowers/plans/2026-04-27-filtros-activos-ncs-impuestos-fase1.md`
- `docs/superpowers/specs/2026-04-27-drill-down-sort-by-name-design.md` (no implementado)
- `docs/superpowers/specs/2026-04-27-trial-rfc-limit-design.md`
- `docs/superpowers/specs/2026-04-27-custom-plan-design.md`
---
## 1. V.1.0.6 — ISR base gravable acumulada y desglose del periodo
### Problema
La pestaña ISR de `/impuestos` calculaba la base gravable mes a mes con
`Math.max(0, ing ded)`. Esto perdía déficits acumulados: un mes con
pérdida no reducía el acumulado de meses siguientes. La lógica fiscal
correcta es acumular ingresos y deducciones desde enero, restar al final,
y solo aplicar `max(0, …)` al pasar a ISR causado.
### Cambios — Tabla "Histórico ISR"
Antes: 4 columnas (Mes, Ingresos, Deducciones, Base Gravable). Base
Gravable era el `max(0, ing_mes ded_mes)` mensual independiente.
Después: **6 columnas** — Mes, Ingresos, Ingresos Acum., Deducciones,
Deducciones Acum., Base Gravable Acum. La columna BG mensual desaparece.
La BG Acum. se calcula como `ingAcum dedAcum` **sin clamp**: si el
acumulado es negativo, se renderiza en rojo (`text-destructive`). Fila
"Total" eliminada (la última fila con datos ya es el YTD).
### Cambios — Sección "Cálculo de ISR Acumulado" → "Cálculo de ISR del Periodo"
Rename del título y reescritura del card al estilo del formato 14 SAT:
```
Ingresos del periodo (Mar 2026) $X
(+) Ingresos acumulados anteriores (Ene-Feb) $A
() Deducciones del periodo (Mar 2026) $Y
() Deducciones acumuladas anteriores $B
─────────────────────────────────────────────
(=) Base gravable acumulada $X+AYB ← rojo si negativa
ISR causado (acumulado) tarifa(max(0, BG))
() ISR retenido (acumulado) $R
─────────────────────────────────────────────
ISR a pagar max(0, causado retenido)
```
"Del periodo" = único el **mes final** del filtro (no el rango entero).
"Anteriores" = enero hasta el mes previo al mes final, mismo año.
Etiquetas de mes derivadas dinámicamente: `mesFinal=1` muestra "(sin meses
anteriores)", `mesFinal=2` muestra "(Ene)", etc.
### Backend — endpoint nuevo
`GET /api/impuestos/isr/resumen-desglosado?fechaFin=...&conciliacion=...&contribuyenteId=...`
Internamente llama 3 veces a `getResumenIsr` con rangos distintos:
- `delPeriodo`: solo el mes final (1 mes)
- `anteriores`: Ene-1 a (mesFinal-1)-último-día (vacío si mesFinal=1)
- `total`: Ene-1 a último-día-del-mes-final
`Promise.all` para los 2 que son independientes (`delPeriodo` + `total`).
Cuando `mesFinal === 1`, evita query inútil retornando `emptyResumenIsr()`
para anteriores.
### Archivos modificados (V.1.0.6)
```
packages/shared/src/types/impuestos.ts
apps/api/src/services/impuestos.service.ts
apps/api/src/controllers/impuestos.controller.ts
apps/api/src/routes/impuestos.routes.ts
apps/web/lib/api/impuestos.ts
apps/web/lib/hooks/use-impuestos.ts
apps/web/app/(dashboard)/impuestos/page.tsx
```
`IsrMensual` extendido con `ingresosAcum`, `deduccionesAcum`,
`baseGravableAcum` (running totals desde enero). `BaseGravableRegimen`
ganó `isrCausado` para alimentar la sección por régimen.
---
## 2. V.1.0.7 — Filtros "Considerar activos" y "Considerar NCs" (Fase 1)
### Problema
La pestaña Impuestos no permitía excluir compras de activos fijos
(que se deprecian, no se deducen mensualmente) ni notas de crédito
(NCs tipoRel=01 que ajustan facturas previas). El contador necesita
poder ver/ocultar estas categorías para análisis.
### UI
Dos toggles nuevos junto a "Conciliación":
```
[Régimen ▾] [☐ Conciliación] [☐ Considerar activos] [☐ Considerar NCs]
```
Mismo styling que Conciliación, tooltips descriptivos via `title`.
**En esta versión los defaults eran OFF** (excluir por default). El default
se invirtió a ON en V.1.0.8 — ver §3.
Toggle ON = considerar/incluir.
Toggle OFF = no considerar/excluir.
### Backend
Helper neutral en módulo nuevo `apps/api/src/services/_shared/cfdi-filters.ts`:
- `buildExtraFilters(considerarActivos, considerarNCs)` → fragmento WHERE
para queries con `FROM cfdis` directo.
- `buildExtraFiltersAlias(alias, considerarActivos, considerarNCs)`
versión alias-aware para subqueries (`FROM cfdis e`).
Cuando ambos flags son `true` retorna string vacío (no afecta el WHERE).
Se extendieron 7 funciones de servicio con 2 nuevos parámetros opcionales,
default `true` para preservar el comportamiento de los callers que no los
pasan (dashboard, reportes, alertas):
| Función | Archivo |
|---|---|
| `calcularIngresosPorRegimen` | `dashboard.service.ts` |
| `calcularEgresosPorRegimen` | `dashboard.service.ts` |
| `getResumenIva` | `impuestos.service.ts` |
| `getIvaMensual` | `impuestos.service.ts` |
| `getResumenIsr` | `impuestos.service.ts` |
| `getIsrMensual` | `impuestos.service.ts` |
| `getResumenIsrDesglosado` | `impuestos.service.ts` |
Templates de subqueries de la rama I PPD/07 (`SUM_E_REFERENCING_TRAS`,
`SUM_E_REFERENCING_RET`, `HAS_E_REFERENCING_MISMO_MES`) y sus helpers
intermedios (`bucketCausadoAny`, `bucketAcreditableAny`, `signed*` exprs,
`readResumenIvaFromCache`) propagaron los flags a través de 3 niveles.
Cache gate de IVA extendido: `metricas_mensuales` solo se consulta cuando
`!conciliacion && considerarActivos && considerarNCs`. Cualquier toggle
distinto del default backend → live query.
### Frontend
- API client: 5 funciones HTTP serializan los flags como query params,
incluyendo cuando son `false` (`if (flag !== undefined) params.set(..., String(flag))`).
- Hooks: 5 hooks incluyen los flags en `queryKey` para refetch al togglear.
- UI: state + 2 toggle buttons + propagación a las 5 llamadas.
### Decisión de diseño — Default backend `true`, default UI inicial `false`
El backend default `true` (= include todo) preserva dashboard, reportes,
etc. La UI inicialmente arrancó con default `false` (= excluir por
default) por lógica fiscal. La asimetría se resolvió en V.1.0.8.
### Pruebas
- `pnpm typecheck` shared + api: PASS.
- Web typecheck para los archivos del plan: clean (otros errores web son
pre-existentes, fuera de scope).
- Smoke deferido a verificación manual del owner.
### Fase 2 (futura)
Extender `metricas_mensuales` con columnas base + 2 deltas (`*_activos`,
`*_ncs_01`) por métrica IVA. Hace los toggles instantáneos vía
suma/resta sin live query.
---
## 3. V.1.0.8 — Defaults de los toggles a ON (cache-friendly)
### Motivación
Tras shippear V.1.0.7, el final code review levantó una observación
importante: con UI default OFF (ambos toggles excluyendo), el cache
`metricas_mensuales` queda **siempre bypass-eado** en `/impuestos`.
Cada carga inicial era live query (~1-3s). El cache solo servía cuando
el contador activaba manualmente ambos toggles.
### Decisión
Invertir defaults UI de `false` a `true`. Trade-off:
- **Antes (V.1.0.7)**: default OFF → carga inicial siempre lenta.
Default fiscalmente "más correcto" (excluir por automático).
- **Después (V.1.0.8)**: default ON → carga inicial rápida (cache hit,
comportamiento idéntico al de versiones previas). El contador activa
el filtro cuando lo necesita.
La consciencia del filtro queda como acción del contador, no como default
silencioso. Fase 2 elimina el dilema: con cache base+deltas, ambos
defaults serán igual de rápidos.
### Cambios
```ts
// Antes:
const [considerarActivos, setConsiderarActivos] = useState(false);
const [considerarNCs, setConsiderarNCs] = useState(false);
// Después:
const [considerarActivos, setConsiderarActivos] = useState(true);
const [considerarNCs, setConsiderarNCs] = useState(true);
```
Plus actualización del spec doc para reflejar la decisión.
### Archivos modificados (V.1.0.8)
```
apps/web/app/(dashboard)/impuestos/page.tsx
docs/superpowers/specs/2026-04-27-filtros-activos-ncs-impuestos-fase1-design.md
```
---
## 4. V.1.0.9 — Filtro de activos extendido a P y E relacionadas
### Problema
El filtro de activos en V.1.0.7 solo excluía facturas tipo I con uso
I01-I08. Pero en realidad un activo fijo se materializa en varias
facturas relacionadas:
1. La I de la compra (uso I01-I08).
2. La P (complemento de pago) que paga esa compra.
3. Eventualmente una E (NC) que cancela la compra o el pago.
Si el contador desactiva "Considerar activos", esperaría ver excluidas
**todas** las facturas asociadas a la operación de activo fijo, no solo
la I original. De lo contrario los pagos y NCs quedan visibles sin la
factura que los origina, generando inconsistencia.
### Solución
Extender el predicado del filtro de activos para cubrir 3 capas:
| Capa | Predicado SQL |
|---|---|
| **1. I directa** | `tipo_comprobante = 'I' AND uso_cfdi IN (I01-I08)` |
| **2. P → I-activo** | `tipo='P' AND EXISTS(SELECT 1 FROM cfdis i_act WHERE i_act.uuid=uuid_relacionado AND i_act.tipo='I' AND i_act.uso_cfdi IN (I01-I08))` |
| **3. E → {I-activo, P-de-activo}** | `tipo='E' AND EXISTS(SELECT 1 FROM cfdis r_act WHERE r_act.uuid IN cfdis_relacionados pipe-split AND (r_act es I-activo OR r_act es P-de-activo))` |
La capa 3 cubre los dos casos del owner:
- E tipoRel=01 que cancela una P que pagó un activo (caso 1 del prompt).
- E tipoRel=03 que devuelve directamente una I-activo (caso 3 del prompt).
- Cualquier otro tipoRel — el predicado es genérico, no filtra por
`cfdi_tipo_relacion` en la rama de activos.
### Independencia con el filtro de NCs
Los dos filtros operan en AND:
- "Considerar NCs" OFF: excluye todas las E tipoRel=01, sin importar a
qué se relacionen.
- "Considerar activos" OFF: excluye E (cualquier tipoRel) que se
relacione con activos.
Una E tipoRel=01 sobre I regular: solo el filtro de NCs la afecta.
Una E tipoRel=03 sobre I-activo: solo el filtro de activos la afecta.
Una E tipoRel=01 sobre I-activo: ambos filtros la excluirían.
### Comportamiento por tipo de CFDI
| CFDI | Excluido si activos OFF? |
|---|---|
| I uso I01-I08 | ✅ predicado 1 |
| I uso G03 (gasto regular) | ❌ |
| P pagando I-activo | ✅ predicado 2 |
| P pagando I regular | ❌ |
| E tipoRel=01 → I-activo | ✅ predicado 3 |
| E tipoRel=03 → I-activo | ✅ predicado 3 |
| E tipoRel=01 → P-de-activo | ✅ predicado 3 |
| E tipoRel=07 → I PPD/07 (anticipo) | ❌ no es activo |
| E tipoRel=01 → I regular | ❌ (lo cubre el filtro NCs si está OFF) |
### Implementación
Cambio único en `apps/api/src/services/_shared/cfdi-filters.ts` (~70
líneas netas, el archivo creció de ~50 a ~110 líneas). Helpers internos
`activosExclusionNoAlias()` y `activosExclusionAlias(alias)` encapsulan
los 3 predicados. `buildExtraFilters` y `buildExtraFiltersAlias` los
invocan cuando `!considerarActivos`.
Cero cambios downstream — todos los callsites del helper (16+ en
service/dashboard) heredan automáticamente el comportamiento extendido.
### Performance
- **Default UI ON (V.1.0.8)**: el helper retorna empty string. Cero
impacto, cache hit normal.
- **Filtro activos OFF**: cada query con `FROM cfdis` ejecuta los 3
predicados con EXISTS anidados. Sin índice en `uuid_relacionado` ni
`cfdis_relacionados`, las queries grandes pueden ser ~10-20% más
lentas. Aceptable para Fase 1.
- Si el perf hit se vuelve notorio, evaluar índice B-tree en
`uuid_relacionado` y GIN sobre array para `cfdis_relacionados` en
Fase 2 (parte del cache extension).
### Archivos modificados (V.1.0.9)
```
apps/api/src/services/_shared/cfdi-filters.ts
```
---
## 5. Spec en pipeline (no shipped) — Sort por nombre en drill-down
Se diseñó y especificó el cambio para agregar sort por nombre emisor /
receptor en la página `/drill-down` genérica (los KPIs del dashboard
abren ahí). Spec en
`docs/superpowers/specs/2026-04-27-drill-down-sort-by-name-design.md`,
shippeada en V.1.0.5.
**Estado**: spec aprobada, implementación pendiente. Cambio trivial
(~6 líneas en un archivo). Las páginas de alertas con tablas similares
quedaron fuera de scope para evitar plan grande — se evaluarán en otra
sesión.
---
## 6. V.1.0.11 — Límite de 5 RFCs durante trial gratuito
### Problema
Despachos en periodo de prueba (30 días) podían agregar RFCs sin
restricción. El owner pidió un límite duro de 5 RFCs para forzar al
contador a contratar un plan si necesita gestionar más.
### Reglas
| Estado | Límite RFCs |
|---|---|
| Trial activo (`tenant.trialEndsAt > now`) | **5 contribuyentes activos** (5 OK, 6 bloqueado) |
| Trial expirado | Aplica el límite del plan vigente; este spec no agrega nada nuevo |
| Plan pagado (sin trial activo) | Sin nuevo límite |
### Backend (`apps/api/src/controllers/contribuyente.controller.ts`)
Constante local `TRIAL_MAX_CONTRIBUYENTES = 5`. En el handler `create`,
antes del `createContribuyente`:
```ts
const tenant = await prisma.tenant.findUnique({
where: { id: req.user!.tenantId },
select: { trialEndsAt: true },
});
const isTrialActive = tenant?.trialEndsAt ? tenant.trialEndsAt > new Date() : false;
if (isTrialActive) {
const activeCount = await countActiveContribuyentes(req.tenantPool!);
if (activeCount >= TRIAL_MAX_CONTRIBUYENTES) {
return next(new AppError(
403,
`Durante el periodo de prueba puedes gestionar hasta ${TRIAL_MAX_CONTRIBUYENTES} contribuyentes. Contrata un plan para agregar más.`,
));
}
}
```
### Frontend (`apps/web/app/(dashboard)/contribuyentes/page.tsx`)
- `useQuery(['my-plan-info'], ...)` para fetch `/despachos/me/plan` (endpoint existente).
- Cómputo `trialAtLimit = isTrialActive && activeCount >= 5`.
- Los 2 botones "Agregar RFC" / "Agregar primer RFC" reciben
`disabled={trialAtLimit}` con `title` mostrando el tooltip exacto
literal del owner:
> "Límite de contribuyentes para la prueba gratuita, para continuar agregando contribuyentes, selecciona un plan."
### No-cambios
- No nueva tabla, no migration.
- Mi Empresa hard limit a 1 RFC sigue siendo billing-only (out of scope).
- `tenant.cfdiLimit`, `tenant.usersLimit` no se tocan.
### Archivos modificados (V.1.0.11)
```
apps/api/src/controllers/contribuyente.controller.ts
apps/web/app/(dashboard)/contribuyentes/page.tsx
docs/superpowers/specs/2026-04-27-trial-rfc-limit-design.md
```
---
## 7. V.1.0.12 — Fix bug de scope SQL en filtro de activos
### Problema reportado por owner
Tras shippear V.1.0.9 (filtro de activos extendido a P y E relacionadas),
el owner reportó que la factura `8ec2eaf3-7879-11f0-81a8-8daae9822b10`
(tipo P, monto $295,100) seguía apareciendo en cálculos cuando
desactivaba el toggle "Considerar activos". La P pagaba la I
`5C874749-748F-11F0-96B1-2B9310891836`, que tenía `uso_cfdi = I03`
(Equipo de transporte) — un activo fijo per la regla.
### Causa raíz — scope ambiguity SQL
En `activosExclusionNoAlias()` (helper en `_shared/cfdi-filters.ts`),
el subquery del predicado P referenciaba `LOWER(uuid_relacionado)` sin
qualifying. Como el subquery usa `FROM cfdis i_act` y `i_act` también
tiene la columna `uuid_relacionado`, PostgreSQL resolvía la referencia
no-qualificada al **scope interno** (`i_act.uuid_relacionado`) en vez
del outer (`cfdis.uuid_relacionado`).
Resultado: el predicado evaluaba "¿existe un i_act donde
`i_act.uuid = i_act.uuid_relacionado` AND uso I01-I08?" — eso es
prácticamente siempre `false` (un CFDI no se referencia a sí mismo).
El `AND NOT (FALSE) = AND TRUE`, así que la P **nunca se excluía**.
Mismo bug en el predicado E (`cfdis_relacionados` no qualificado dentro
del subquery con `r_act` que también tiene esa columna).
La versión `Alias` (`activosExclusionAlias`) NO tenía el bug porque ahí
escribí `${alias}.uuid_relacionado` qualificado explícitamente.
### Fix
Qualifying las 2 referencias outer dentro de los subqueries con `cfdis.`:
```sql
-- Antes (bug):
WHERE LOWER(i_act.uuid) = LOWER(uuid_relacionado)
-- Después (fix):
WHERE LOWER(i_act.uuid) = LOWER(cfdis.uuid_relacionado)
```
Y para el predicado E:
```sql
-- Antes (bug):
WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(cfdis_relacionados), '|'))
-- Después (fix):
WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(cfdis.cfdis_relacionados), '|'))
```
Comentario JSDoc agregado a la función explicando por qué necesita
qualifying explícito (evitar futuras regresiones).
### Validación
Test directo con SQL antes/después del fix:
```
BUGGY version (sin qualifying outer):
bug_excludes = false ← P nunca se excluía
FIXED version (cfdis.uuid_relacionado):
fixed_excludes = true ← P correctamente excluida
Referenced I: { uuid: '5c874749...', uso_cfdi: 'I03', tipo_comprobante: 'I' }
```
### Lecciones
- **Qualifying explícito es defensivo siempre que un subquery comparta
nombre de tabla con el outer**. Postgres prioriza scope interno sin
warning, así que el bug es silencioso.
- **El test del owner en producción es indispensable**: el typecheck y
los tests de tipo no detectan errores semánticos de SQL. El fix solo
apareció porque el owner reportó un caso específico que esperaba
excluir.
### Archivos modificados (V.1.0.12)
```
apps/api/src/services/_shared/cfdi-filters.ts
```
---
## 8. V.1.0.14 — Plan Custom (gratis, sin fecha fin, solo admin)
### Problema
El owner pidió un plan especial para casos de cortesía / beta tester /
caso especial: acceso al sistema sin cobro, sin fecha de finalización,
asignable solo por el Admin Global y oculto del catálogo user-facing.
### Decisión clave — Reusar enum `custom`
El Plan enum de Prisma ya tenía `custom` (legacy: "precio variable por
tenant") con **0 tenants** en dev. La lógica antigua en
`subscription.service.ts` ya rechazaba `custom` del flujo self-serve —
patrón que coincide con la nueva semántica. Reusar el enum evita migration
y mantiene compatibilidad con código existente.
### Comportamiento
- **Limits**: idéntico a Mi Empresa (1 RFC, 50 timbres/mes, MANAGED, sin
API ni Lolita).
- **Costo**: $0. NO se incluye en `DESPACHO_PLAN_PRICES` → no genera
Subscription, no usa MercadoPago.
- **Vigencia**: indefinida. `tenant.trialEndsAt = null`. Sin
`currentPeriodEnd`. Ningún cron lo expira (`expireTrials`,
`applyPendingChanges` no le afectan).
- **Visibilidad**: oculto del catálogo en `/configuracion/planes-despacho`
(página user). Solo aparece como opción en `/clientes` (admin global).
### Catálogo (`packages/shared/src/constants/despacho-plans.ts`)
Nueva entrada `custom` en `DESPACHO_PLANS` con limits idénticos a Mi Empresa
y mismo array de features. NO se agrega a `DESPACHO_PLAN_PRICES`
helpers `permiteOverage('custom')` y `isDespachoPaidPlan('custom')` ya
retornan `false` por exclusión.
### Admin `/clientes` — extensión del dropdown
La página admin tenía un dropdown limitado a `starter | business |
enterprise`. Cambios:
- `PlanType` extendido a `starter | business | business_ia | enterprise | custom`.
- Tipo `CreateTenantData.plan` y `UpdateTenantData.plan` en
`apps/web/lib/api/tenants.ts` extendidos al mismo union.
- Dropdown ahora lista los 4 legacy con etiqueta "(legacy)" + Custom con
descripción "Sin cobro, sin fecha fin (despacho)".
- Cuando se selecciona Custom, el input "Monto Mensual" se oculta y
aparece nota: "Plan Custom no genera cobro ni suscripción. Vigencia
indefinida."
- Lista de tenants ahora usa el `PLAN_LABELS` global (cubre todos los
planes incluyendo despacho) en vez del `planLabels` local que solo
cubría legacy. `planColors` extendido con entradas para todos los
planes despacho + custom.
**Out of scope**: asignar planes despacho pagables (`mi_empresa`,
`mi_empresa_plus`, `business_control`, `business_cloud`) desde
`/clientes`. Esos van por self-serve del owner para evitar el escenario
de un tenant en plan paid sin Subscription. Si se necesita en el futuro,
requiere manejar la creación/cancelación de preapproval MP en el
endpoint admin.
### User `/configuracion/planes-despacho` — banner Custom
- `Despachoplan` type extendido con `custom`.
- Si `currentPlan === 'custom'`: banner rosa al top con "Plan Custom —
sin cobro, vigencia indefinida" + descripción "Tu cuenta está bajo un
plan especial asignado por tu administrador. Contacta a soporte si
necesitas cambiar de plan." Las cards de planes pagables se ocultan
(no hay opción de auto-cambio).
- Otros planes: comportamiento idéntico al de antes.
### Backend — sin cambios
`PUT /api/tenants/:id` ya acepta cualquier valor del enum Prisma (no
hay Zod gate restrictivo en el endpoint admin). Solo el endpoint
self-serve `addMyTenant` tiene Zod limitado a legacy — no se toca, sigue
siendo correcto que self-serve no permita Custom.
### Limitaciones aceptadas
1. **Transición paid → custom**: si el admin cambia un tenant que tenía
suscripción MP activa a Custom, el preapproval MP **sigue cobrando**
hasta que se cancele manualmente. Mitigación: el admin debe cancelar
la suscripción primero desde `/configuracion/suscripcion` del tenant
impersonado.
2. **Hard limit 1 RFC en Custom**: igual que Mi Empresa, el límite de
1 RFC para Custom es solo billing-only hoy (no enforced en
`contribuyente.controller.ts:create`). Si se quiere enforce duro,
replicar el patrón del trial limit V.1.0.11.
### Archivos modificados (V.1.0.14)
```
packages/shared/src/constants/despacho-plans.ts
apps/web/lib/api/tenants.ts
apps/web/app/(dashboard)/clientes/page.tsx
apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx
docs/superpowers/specs/2026-04-27-custom-plan-design.md
```
---
## 9. Pendientes derivados
### Verificación manual del owner (smoke)
Aún no se ha ejecutado smoke en navegador para los cambios de la sesión.
Lista mínima:
1. **Dashboard regression**: KPIs (`Ingresos`, `Gastos`, `Utilidad`) deben
ser idénticos a los de V.1.0.5 (= sin filtros aplicados).
2. **Histórico ISR (V.1.0.6)**: 6 columnas, BG_acum negativa en rojo,
running totals correctos, sin fila Total.
3. **Cálculo de ISR del Periodo (V.1.0.6)**: 8 renglones, etiquetas
dinámicas según `mesFinal`, BG en rojo si negativa.
4. **Toggle "Considerar activos" OFF (V.1.0.7+V.1.0.9)**:
- Excluye I uso I01-I08.
- Excluye P pagando esos I.
- Excluye E (cualquier tipoRel) referenciando esos I o P.
5. **Toggle "Considerar NCs" OFF (V.1.0.7)**: excluye E tipoRel=01.
6. **Combinaciones de los 3 toggles** (Conciliación + Activos + NCs):
8 combinaciones, números deben ser consistentes.
7. **Activos Fijos tab**: la tabla sigue mostrando todos los I con uso
I01-I08, no afectada por los toggles de impuestos.
8. **Filtro de régimen**: sigue distribuyendo correctamente cuando se
selecciona un régimen y se togglea cualquier filtro.
### Performance follow-ups
- Si toggle activos OFF en `/impuestos` siente lento (>3-4s), considerar:
- Índice B-tree en `uuid_relacionado` (cheap).
- Migración para indexar `cfdis_relacionados` (caro: requiere GIN
sobre array, o normalizar a tabla M:N).
- Fase 2 del feature de filtros: extender `metricas_mensuales` con
columnas base + deltas. Toggles instantáneos sin importar el estado.
### Push manual
Las versiones están commiteadas en OneDrive pero **no pusheadas a
`origin/main`**. Owner las push cuando quiera.
```
72ffdca V.1.0.14 ← Custom plan (gratis, sin fecha fin, solo admin)
6686e70 V.1.0.13 ← session doc updated (V.1.0.11 + V.1.0.12)
59d71ae V.1.0.12 ← fix scope SQL helper (bug crítico V.1.0.9)
f4e0d6f V.1.0.11 ← trial RFC limit (5 max)
b8c9df1 V.1.0.10 ← session summary doc original
297ffdb V.1.0.9 ← filtro activos extendido a P y E (con bug que V.1.0.12 arregla)
4b7566e V.1.0.8 ← defaults flipped a ON
2970ccf V.1.0.7 ← filtros activos/NCs Fase 1
cc34c39 V.1.0.6 ← ISR base gravable acumulada
```
### Fuera de scope (a evaluar después)
- **Implementar drill-down sort por nombre** (spec ya aprobada).
- **Replicar los toggles de impuestos a `/dashboard`** (si se pide):
ya está habilitado por las signatures de `calcular*PorRegimen`
solo falta UI + propagación.
- **Persistencia de los toggles** (hoy son `useState`, se pierden al
recargar): considerar `localStorage` o `tenant-view-store`.
- **Hard limit Mi Empresa (1 RFC)**: hoy es solo billing-only. Replicar
el patrón del trial limit V.1.0.11 (Mi Empresa también haría check
duro al crear). Aplica también para Custom (1 RFC). Considerar al
implementar Fase 2 del feature de filtros.
- **Bug class similar al V.1.0.12 en otras subqueries**: revisar otros
helpers SQL del repo que tengan subqueries con tablas del mismo
nombre (cfdis, conciliaciones, etc.) y qualifying débil. Sería un
audit pasivo, no urgente.
- **Asignar planes despacho pagados desde `/clientes` admin**: hoy
Custom es el único plan despacho asignable desde admin. Si se quiere
agregar también `mi_empresa`, `business_control`, etc., requiere
manejar creación/cancelación de preapproval MP en el endpoint admin.
Out of scope para V.1.0.14, pendiente futuro.