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

View File

@@ -0,0 +1,298 @@
# Implementación de Sincronización SAT
## Resumen
Sistema de sincronización automática de CFDIs con el SAT (Servicio de Administración Tributaria de México) para Horux360.
## Componentes Implementados
### 1. Backend (API)
#### Servicios
| Archivo | Descripción |
|---------|-------------|
| `src/services/fiel.service.ts` | Gestión de credenciales FIEL (e.firma) |
| `src/services/sat/sat-client.service.ts` | Cliente para el servicio web del SAT |
| `src/services/sat/sat.service.ts` | Lógica principal de sincronización |
| `src/services/sat/sat-crypto.service.ts` | Encriptación AES-256-GCM para credenciales |
| `src/services/sat/sat-parser.service.ts` | Parser de XMLs de CFDI |
#### Controladores
| Archivo | Descripción |
|---------|-------------|
| `src/controllers/fiel.controller.ts` | Endpoints para gestión de FIEL |
| `src/controllers/sat.controller.ts` | Endpoints para sincronización SAT |
#### Job Programado
| Archivo | Descripción |
|---------|-------------|
| `src/jobs/sat-sync.job.ts` | Cron job para sincronización diaria (3:00 AM) |
### 2. Frontend (Web)
#### Componentes
| Archivo | Descripción |
|---------|-------------|
| `components/sat/FielUploadModal.tsx` | Modal para subir certificado y llave FIEL |
| `components/sat/SyncStatus.tsx` | Estado de sincronización con selector de fechas |
| `components/sat/SyncHistory.tsx` | Historial de sincronizaciones |
#### Página
| Archivo | Descripción |
|---------|-------------|
| `app/(dashboard)/configuracion/sat/page.tsx` | Página de configuración SAT |
### 3. Base de Datos
#### Tabla Principal (schema public)
```sql
-- sat_sync_jobs: Almacena los trabajos de sincronización
CREATE TABLE sat_sync_jobs (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
type VARCHAR(20) NOT NULL, -- 'initial' | 'daily'
status VARCHAR(20) NOT NULL, -- 'pending' | 'running' | 'completed' | 'failed'
date_from TIMESTAMP NOT NULL,
date_to TIMESTAMP NOT NULL,
cfdi_type VARCHAR(20),
sat_request_id VARCHAR(100),
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
);
-- fiel_credentials: Almacena las credenciales FIEL encriptadas
CREATE TABLE fiel_credentials (
id UUID PRIMARY KEY,
tenant_id UUID UNIQUE NOT NULL,
rfc VARCHAR(13) NOT NULL,
cer_data BYTEA NOT NULL,
key_data BYTEA NOT NULL,
key_password_encrypted BYTEA NOT NULL,
encryption_iv BYTEA NOT NULL,
encryption_tag BYTEA NOT NULL,
serial_number VARCHAR(100),
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()
);
```
#### Columnas agregadas a tabla cfdis (por tenant)
```sql
ALTER TABLE tenant_xxx.cfdis ADD COLUMN xml_original TEXT;
ALTER TABLE tenant_xxx.cfdis ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE tenant_xxx.cfdis ADD COLUMN last_sat_sync TIMESTAMP;
ALTER TABLE tenant_xxx.cfdis ADD COLUMN sat_sync_job_id UUID;
ALTER TABLE tenant_xxx.cfdis ADD COLUMN source VARCHAR(20) DEFAULT 'manual';
```
## Dependencias
```json
{
"@nodecfdi/sat-ws-descarga-masiva": "^2.0.0",
"@nodecfdi/credentials": "^2.0.0",
"@nodecfdi/cfdi-core": "^1.0.1"
}
```
## Flujo de Sincronización
```
1. Usuario configura FIEL (certificado .cer + llave .key + contraseña)
2. Sistema valida y encripta credenciales (AES-256-GCM)
3. Usuario inicia sincronización (manual o automática 3:00 AM)
4. Sistema desencripta FIEL y crea cliente SAT
5. Por cada mes en el rango:
a. Solicitar CFDIs emitidos al SAT
b. Esperar respuesta (polling cada 30s)
c. Descargar paquetes ZIP
d. Extraer y parsear XMLs
e. Guardar en BD del tenant
f. Repetir para CFDIs recibidos
6. Marcar job como completado
```
## API Endpoints
### FIEL
| Método | Ruta | Descripción |
|--------|------|-------------|
| GET | `/api/fiel/status` | Estado de la FIEL configurada |
| POST | `/api/fiel/upload` | Subir nueva FIEL |
| DELETE | `/api/fiel` | Eliminar FIEL |
### Sincronización SAT
| Método | Ruta | Descripción |
|--------|------|-------------|
| POST | `/api/sat/sync` | Iniciar sincronización |
| GET | `/api/sat/sync/status` | Estado actual |
| GET | `/api/sat/sync/history` | Historial de syncs |
| GET | `/api/sat/sync/:id` | Detalle de un job |
| POST | `/api/sat/sync/:id/retry` | Reintentar job fallido |
### Parámetros de sincronización
```typescript
interface StartSyncRequest {
type?: 'initial' | 'daily'; // default: 'daily'
dateFrom?: string; // ISO date, ej: "2025-01-01T00:00:00"
dateTo?: string; // ISO date, ej: "2025-12-31T23:59:59"
}
```
## Configuración
### Variables de entorno
```env
# Clave para encriptar credenciales FIEL (32 bytes hex)
FIEL_ENCRYPTION_KEY=tu_clave_de_32_bytes_en_hexadecimal
# Zona horaria para el cron
TZ=America/Mexico_City
```
### Límites del SAT
- **Antigüedad máxima**: 6 años
- **Solicitudes por día**: Limitadas (se reinicia cada 24h)
- **Tamaño de paquete**: Variable
## Errores Comunes del SAT
| Código | Mensaje | Solución |
|--------|---------|----------|
| 5000 | Solicitud Aceptada | OK - esperar verificación |
| 5002 | Límite de solicitudes agotado | Esperar 24 horas |
| 5004 | No se encontraron CFDIs | Normal si no hay facturas en el rango |
| 5005 | Solicitud duplicada | Ya existe una solicitud pendiente |
| - | Información mayor a 6 años | Ajustar rango de fechas |
| - | No se permite descarga de cancelados | Facturas canceladas no disponibles |
## Seguridad
1. **Encriptación de credenciales**: AES-256-GCM con IV único
2. **Almacenamiento seguro**: Certificado, llave y contraseña encriptados
3. **Autenticación**: JWT con tenantId embebido
4. **Aislamiento**: Cada tenant tiene su propio schema en PostgreSQL
## Servicios Systemd
```bash
# API Backend
systemctl status horux-api
# Web Frontend
systemctl status horux-web
```
## Comandos Útiles
```bash
# Ver logs de sincronización SAT
journalctl -u horux-api -f | grep "\[SAT\]"
# Estado de jobs
psql -U postgres -d horux360 -c "SELECT * FROM sat_sync_jobs ORDER BY created_at DESC LIMIT 5;"
# CFDIs sincronizados por tenant
psql -U postgres -d horux360 -c "SELECT COUNT(*) FROM tenant_xxx.cfdis WHERE source = 'sat';"
```
## Changelog
### 2026-01-25
- Implementación inicial de sincronización SAT
- Integración con librería @nodecfdi/sat-ws-descarga-masiva
- Soporte para fechas personalizadas en sincronización
- Corrección de cast UUID en queries SQL
- Agregadas columnas faltantes a tabla cfdis
- UI para selección de periodo personalizado
- Cambio de servicio web a modo producción (next start)
## Estado Actual (2026-01-25)
### Completado
- [x] Servicio de encriptación de credenciales FIEL
- [x] Integración con @nodecfdi/sat-ws-descarga-masiva
- [x] Parser de XMLs de CFDI
- [x] UI para subir FIEL
- [x] UI para ver estado de sincronización
- [x] UI para seleccionar periodo personalizado
- [x] Cron job para sincronización diaria (3:00 AM)
- [x] Soporte para fechas personalizadas
- [x] Corrección de cast UUID en queries
- [x] Columnas adicionales en tabla cfdis de todos los tenants
### Pendiente por probar
El SAT bloqueó las solicitudes por exceso de pruebas. **Esperar 24 horas** y luego:
1. Ir a **Configuración > SAT**
2. Clic en **"Periodo personalizado"**
3. Seleccionar: **2025-01-01** a **2025-12-31**
4. Clic en **"Sincronizar periodo"**
### Tenant de prueba
- **RFC**: HTS240708LJA
- **Schema**: `tenant_cas2408138w2`
- **Nota**: Los CFDIs "recibidos" de este tenant están cancelados (SAT no permite descargarlos)
### Comandos para verificar después de 24h
```bash
# Ver estado del sync
PGPASSWORD=postgres psql -h localhost -U postgres -d horux360 -c \
"SELECT status, cfdis_found, cfdis_downloaded, cfdis_inserted FROM sat_sync_jobs ORDER BY created_at DESC LIMIT 1;"
# Ver logs en tiempo real
journalctl -u horux-api -f | grep "\[SAT\]"
# Contar CFDIs sincronizados
PGPASSWORD=postgres psql -h localhost -U postgres -d horux360 -c \
"SELECT COUNT(*) as total FROM tenant_cas2408138w2.cfdis WHERE source = 'sat';"
```
### Problemas conocidos
1. **"Se han agotado las solicitudes de por vida"**: Límite de SAT alcanzado, esperar 24h
2. **"No se permite la descarga de xml que se encuentren cancelados"**: Normal para facturas canceladas
3. **"Información mayor a 6 años"**: SAT solo permite descargar últimos 6 años
## Próximos Pasos
- [ ] Probar sincronización completa después de 24h
- [ ] Verificar que los CFDIs se guarden correctamente
- [ ] Implementar reintentos automáticos para errores temporales
- [ ] Notificaciones por email al completar sincronización
- [ ] Dashboard con estadísticas de CFDIs por periodo
- [ ] Soporte para filtros adicionales (RFC emisor/receptor, tipo de comprobante)

View File

@@ -0,0 +1,534 @@
# API Reference - Horux360
**Base URL:** `https://horuxfin.com/api`
**Última actualización:** 2026-04-11
---
## Autenticación
Todos los endpoints (excepto auth y webhooks) requieren header:
```
Authorization: Bearer <accessToken>
```
### Rate Limits (por IP)
| Endpoint | Límite | Ventana |
|----------|--------|---------|
| `POST /auth/login` | 10 requests | 15 minutos |
| `POST /auth/register` | 3 requests | 1 hora |
| `POST /auth/refresh` | 20 requests | 15 minutos |
| General `/api/*` | 30 requests/s | burst 50 |
---
## Auth (`/api/auth`)
### `POST /auth/register`
Registra nueva empresa y usuario admin. Provisiona base de datos dedicada.
**Body:**
```json
{
"empresa": { "nombre": "Mi Empresa", "rfc": "ABC123456789" },
"usuario": { "nombre": "Juan", "email": "juan@empresa.com", "password": "min8chars" }
}
```
**Response:** `{ accessToken, refreshToken, user: UserInfo }`
### `POST /auth/login`
```json
{ "email": "usuario@empresa.com", "password": "..." }
```
**Response:** `{ accessToken, refreshToken, user: UserInfo }`
### `POST /auth/refresh`
```json
{ "refreshToken": "..." }
```
**Response:** `{ accessToken, refreshToken }`
### `POST /auth/logout` *(requiere auth)*
```json
{ "refreshToken": "..." }
```
### `GET /auth/me` *(requiere auth)*
**Response:** `UserInfo`
---
## Dashboard (`/api/dashboard`)
### `GET /dashboard/kpis`
KPIs principales: ingresos, egresos, utilidad, margen, IVA balance, IVA a favor acumulado/histórico, conteo de CFDIs por régimen.
**Query:** `fechaInicio`, `fechaFin` (default: mes actual)
### `GET /dashboard/ingresos-egresos`
Datos mensuales de ingresos/egresos desglosados por régimen fiscal para gráfica anual.
**Query:** `año` (default: año actual)
### `GET /dashboard/regimenes-periodo`
Regímenes fiscales presentes en los CFDIs del rango de fechas.
**Query:** `fechaInicio`, `fechaFin`
### `GET /dashboard/alertas`
Alertas activas no resueltas, ordenadas por prioridad.
**Query:** `limit` (default: 5)
---
## CFDI (`/api/cfdi`)
### `GET /cfdi`
Lista paginada de CFDIs con filtros.
**Query:**
- `page` (default: 1), `limit` (default: 20)
- `tipo`: `EMITIDO` | `RECIBIDO`
- `tipoComprobante`: `I` | `E` | `T` | `P` | `N`
- `estado`: `Vigente` | `Cancelado` | `0` | `1`
- `fechaInicio`, `fechaFin`
- `rfc`, `emisor`, `receptor`, `search`
### `GET /cfdi/resumen`
Resumen de conteo por tipo y estado.
### `GET /cfdi/emisores`
Lista de emisores únicos (RFC + nombre). **Query:** `search`
### `GET /cfdi/receptores`
Lista de receptores únicos (RFC + nombre). **Query:** `search`
### `GET /cfdi/drill-down`
CFDIs filtrados para drill-down desde dashboard.
**Query:** `fechaInicio`, `fechaFin`, `type`, `tipoComprobante`, `page`, `limit`
### `GET /cfdi/:id`
Detalle de un CFDI.
### `GET /cfdi/:id/conceptos`
Conceptos (líneas de detalle) de un CFDI.
### `GET /cfdi/:id/xml`
XML original del CFDI (descarga como archivo).
### `POST /cfdi`
Crear un CFDI individual. Sujeto a límite de plan.
### `POST /cfdi/bulk`
Carga masiva de CFDIs. Body limit: 50MB. Sujeto a límite de plan.
### `DELETE /cfdi/:id`
Eliminar un CFDI.
---
## Impuestos (`/api/impuestos`)
### `GET /impuestos/iva/mensual`
Datos mensuales de IVA (trasladado, acreditable, retenido, resultado, acumulado).
**Query:** `año` (default: año actual)
### `GET /impuestos/iva/resumen`
Resumen de IVA para un rango de fechas con desglose por régimen.
**Query:** `fechaInicio`, `fechaFin`
### `GET /impuestos/isr/resumen`
Resumen de ISR para un rango de fechas con desglose por régimen.
**Query:** `fechaInicio`, `fechaFin`
### `GET /impuestos/isr/coeficiente`
Coeficiente de utilidad del tenant para un año.
**Query:** `anio`
### `PUT /impuestos/isr/coeficiente` *(admin)*
Establecer coeficiente de utilidad.
**Body:** `{ anio, coeficiente }`
---
## Alertas (`/api/alertas`)
### `GET /alertas`
Lista de alertas con filtros opcionales.
**Query:** `leida`, `resuelta`, `prioridad`
### `GET /alertas/:id`
Detalle de una alerta.
### `GET /alertas/automaticas`
Alertas auto-generadas de cumplimiento fiscal (lista negra, concentración, discrepancias, cancelaciones, efectivo).
### `GET /alertas/manuales`
Obligaciones manuales pendientes.
### `GET /alertas/stats`
Estadísticas de alertas (total, no leídas, por prioridad).
### Drill-down de alertas
| Endpoint | Descripción |
|----------|-------------|
| `GET /alertas/drilldown/lista-negra-clientes` | Clientes en lista negra SAT |
| `GET /alertas/drilldown/lista-negra-proveedores` | Proveedores en lista negra SAT |
| `GET /alertas/drilldown/concentracion-clientes` | Análisis de concentración (clientes) |
| `GET /alertas/drilldown/concentracion-proveedores` | Análisis de concentración (proveedores) |
| `GET /alertas/drilldown/discrepancia-regimen` | Discrepancias de régimen fiscal |
| `GET /alertas/drilldown/cancelaciones` | CFDIs cancelados |
| `GET /alertas/drilldown/efectivo` | Análisis de método de pago efectivo |
### `POST /alertas`
Crear alerta manual.
### `PATCH /alertas/:id`
Actualizar alerta (leída, resuelta).
### `PATCH /alertas/manuales/:id/resolver`
Resolver obligación manual.
### `DELETE /alertas/:id`
Eliminar alerta.
### `POST /alertas/mark-all-read`
Marcar todas las alertas como leídas.
---
## Conciliacion (`/api/conciliacion`) *(requiere feature 'conciliacion')*
### `GET /conciliacion`
Lista CFDIs con estado de conciliacion (pendiente o conciliado).
**Query:**
- `tipo`: `EMITIDO` | `RECIBIDO` (requerido)
- `fechaInicio`, `fechaFin`: rango de fecha de emision
- `regimen`: clave de regimen fiscal (opcional)
- `estado`: `conciliado` | `pendiente` (opcional, default: todos)
**Reglas de exclusion:**
- Recibidos: excluye PPD
- Emitidos: excluye PPD para todos los regimenes excepto 605 y 616
- Tipo P usa `monto_pago_mxn` en vez de `total_mxn`
### `POST /conciliacion` *(admin, contador)*
Conciliar CFDIs en batch. Auto-concilia PPD si la factura P lleva saldo a 0.
```json
{ "cfdiIds": [1, 2, 3], "fechaDePago": "2026-04-10", "idBanco": 1 }
```
### `DELETE /conciliacion/:id` *(admin, contador)*
Desconciliar un CFDI. Si es tipo P, tambien desconcilia la PPD auto-conciliada.
---
## Bancos (`/api/bancos`)
### `GET /bancos`
Listar bancos del tenant.
### `POST /bancos` *(admin)*
Crear banco.
```json
{ "banco": "BBVA", "terminacionCuenta": "1234" }
```
### `PUT /bancos/:id` *(admin)*
Editar banco.
### `DELETE /bancos/:id` *(admin)*
Eliminar banco. Falla si tiene conciliaciones asociadas.
---
## Calendario (`/api/calendario`)
### `GET /calendario/generados`
Eventos fiscales generados para un año, basados en el catálogo de eventos fiscales y días inhábiles.
**Query:** `año` (default: año actual)
---
## Reportes (`/api/reportes`) *(requiere feature 'reportes' en plan)*
### `GET /reportes/estado-resultados`
Estado de resultados (P&L).
**Query:** `fechaInicio`, `fechaFin`
### `GET /reportes/flujo-efectivo`
Flujo de efectivo.
**Query:** `fechaInicio`, `fechaFin`
### `GET /reportes/comparativo`
Comparativo año contra año.
**Query:** `año`
### `GET /reportes/cuentas-x-pagar`
Cuentas por pagar (facturas con saldo pendiente).
**Query:** `fechaInicio`, `fechaFin`, `regimen`
### `GET /reportes/cuentas-x-cobrar`
Cuentas por cobrar.
**Query:** `fechaInicio`, `fechaFin`, `regimen`
### `GET /reportes/concentrado-rfc`
Concentrado por RFC (top clientes/proveedores).
**Query:** `tipo` (`cliente` | `proveedor`), `fechaInicio`, `fechaFin`
---
## Export (`/api/export`)
### `GET /export/cfdis`
Exporta CFDIs a Excel.
**Query:** `tipo`, `estado`, `fechaInicio`, `fechaFin`
### `GET /export/reporte`
Exporta reporte a Excel.
**Query:** `tipo` (`estado-resultados` | `flujo-efectivo`), `fechaInicio`, `fechaFin`
---
## Regímenes (`/api/regimenes`)
### `GET /regimenes`
Catálogo completo de regímenes fiscales SAT.
### `GET /regimenes/activos`
Regímenes activos del tenant.
### `PUT /regimenes/activos` *(admin)*
Configurar regímenes activos.
**Body:** `{ regimenIds: number[] }`
### `GET /regimenes/ignorados`
Regímenes ignorados del tenant (excluidos de cálculos).
### `PUT /regimenes/ignorados` *(admin)*
Configurar regímenes ignorados.
**Body:** `{ regimenIds: number[] }`
---
## FIEL (`/api/fiel`)
### `POST /fiel/upload`
Subir y validar credenciales FIEL (e.firma).
```json
{
"cerFile": "<base64>",
"keyFile": "<base64>",
"password": "..."
}
```
- Archivos max 50KB cada uno
- Password max 256 caracteres
- Encriptación AES-256-GCM por componente
### `GET /fiel/status`
Estado actual de la FIEL: configurada, RFC, serial, validez, días hasta expiración.
### `DELETE /fiel`
Eliminar credenciales FIEL.
---
## SAT Sync (`/api/sat`)
### `POST /sat/sync`
Iniciar sincronización manual con el SAT.
```json
{ "type": "daily", "dateFrom": "2026-01-01", "dateTo": "2026-01-31" }
```
### `GET /sat/sync/status`
Estado actual de sincronización (job activo, último completado, total sincronizados).
### `GET /sat/sync/history`
Historial de sincronizaciones paginado.
**Query:** `page`, `limit`
### `GET /sat/sync/:id`
Detalle de un job de sincronización (progreso, paquetes, errores).
### `POST /sat/sync/:id/retry`
Reintentar un job fallido.
### `GET /sat/cron` *(admin global)*
Info del cron de sincronización automática (03:00 AM diario).
### `POST /sat/cron/run` *(admin global)*
Ejecutar sincronización global manualmente.
---
## Usuarios (`/api/usuarios`)
### `GET /usuarios`
Usuarios del tenant actual.
### `GET /usuarios/global/all` *(admin global)*
Todos los usuarios de todas las empresas.
### `POST /usuarios/invite`
Invitar usuario (genera password temporal con `crypto.randomBytes`).
```json
{ "email": "nuevo@empresa.com", "nombre": "María", "role": "contador" }
```
### `PATCH /usuarios/:id`
Actualizar usuario (nombre, role, active). Scope: tenant.
### `DELETE /usuarios/:id`
Eliminar usuario. Scope: tenant.
### `PATCH /usuarios/global/:id` *(admin global)*
Actualizar usuario de cualquier empresa. Puede cambiar tenant.
### `DELETE /usuarios/global/:id` *(admin global)*
Eliminar usuario de cualquier empresa.
---
## Tenants / Clientes (`/api/tenants`) *(admin global)*
### `GET /tenants`
Lista de todos los tenants/clientes.
### `GET /tenants/:id`
Detalle de un tenant.
### `POST /tenants`
Crear nuevo tenant. Provisiona base de datos dedicada. Envía email al admin.
```json
{
"nombre": "Empresa Nueva",
"rfc": "ENE123456789",
"plan": "business",
"cfdiLimit": 500,
"usersLimit": 3,
"adminNombre": "Pedro",
"adminEmail": "pedro@nueva.com",
"amount": 999
}
```
### `PUT /tenants/:id`
Actualizar tenant (nombre, rfc, plan, limits, active).
### `DELETE /tenants/:id`
Soft delete — renombra la base de datos a `{name}_deleted_{timestamp}`.
---
## Suscripciones (`/api/subscriptions`) *(admin global)*
### `GET /subscriptions/:tenantId`
Suscripción activa del tenant.
### `POST /subscriptions/:tenantId/generate-link`
Generar link de pago MercadoPago.
### `POST /subscriptions/:tenantId/mark-paid`
Marcar como pagado manualmente.
```json
{ "amount": 999 }
```
### `GET /subscriptions/:tenantId/payments`
Historial de pagos.
---
## Webhooks (`/api/webhooks`)
### `POST /webhooks/mercadopago`
Webhook de MercadoPago. Requiere headers:
- `x-signature`: Firma HMAC-SHA256
- `x-request-id`: ID del request
Maneja notificaciones de tipo `payment` y `preapproval`.
---
## Roles y Permisos
| Rol | Descripción | Acceso |
|-----|-------------|--------|
| `admin` | Administrador del tenant | Todo dentro de su tenant + invitar usuarios + configuración |
| `contador` | Contador | CFDI, impuestos, reportes, dashboard, alertas |
| `visor` | Solo lectura | Dashboard, CFDI (solo ver), reportes |
### Admin Global
El admin del tenant con RFC `HTS240708LJA` tiene acceso adicional:
- Gestión de todos los tenants (`/api/tenants`)
- Suscripciones (`/api/subscriptions`)
- SAT cron (`/api/sat/cron`)
- Impersonación via `X-View-Tenant` header (bypass de plan limits)
- Gestión global de usuarios (`/api/usuarios/global/*`)
---
## Middlewares
| Middleware | Descripción |
|------------|-------------|
| **auth** | Verifica JWT, extrae payload a `req.user`. Factory `authorize(...roles)` para roles. |
| **tenant** | Resuelve pool de BD del tenant. Cache 5min. Soporta `X-View-Tenant` para admin global. |
| **plan-limits** | Verifica suscripción activa. Read-only si inactiva. Limita creación de CFDIs. Cache 5min. |
| **feature-gate** | Gate por features del plan (`requireFeature('reportes')`). 403 si no incluido. |
| **error** | Handler centralizado. `AppError` para respuestas estructuradas; 500 para no manejados. |
---
## Tipos Compartidos (`@horux/shared`)
### UserInfo
```typescript
interface UserInfo {
id: string;
email: string;
nombre: string;
role: 'admin' | 'contador' | 'visor';
tenantId: string;
tenantName: string;
tenantRfc: string;
plan: string;
}
```
### JWTPayload
```typescript
interface JWTPayload {
userId: string;
email: string;
role: Role;
tenantId: string;
iat?: number;
exp?: number;
}
```

View File

@@ -0,0 +1,397 @@
// Horux360 - Database Schema Documentation
// PostgreSQL Multi-tenant: database-per-tenant
// Última actualización: 2026-04-11
//
// ARQUITECTURA:
// horux360 (central) ← Prisma: tenants, users, subscriptions, catálogos
// horux_<rfc> (por tenant) ← Raw SQL (pg Pool): cfdis, cfdi_conceptos, rfcs, alertas
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ============================================
// BASE DE DATOS CENTRAL (horux360)
// Gestionada por Prisma Client
// ============================================
model Tenant {
id String @id @default(uuid())
nombre String
rfc String @unique
plan Plan @default(starter)
databaseName String @unique @map("database_name")
cfdiLimit Int @default(100) @map("cfdi_limit")
usersLimit Int @default(1) @map("users_limit")
active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime? @map("expires_at")
users User[]
fielCredential FielCredential?
satSyncJobs SatSyncJob[]
subscriptions Subscription[]
payments Payment[]
regimenesIgnorados TenantRegimenIgnorado[]
regimenesActivos TenantRegimenActivo[]
coeficientes CoeficienteUtilidad[]
@@map("tenants")
}
model User {
id String @id @default(uuid())
tenantId String @map("tenant_id")
email String @unique
passwordHash String @map("password_hash")
nombre String
role Role @default(visor)
active Boolean @default(true)
lastLogin DateTime? @map("last_login")
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
@@map("users")
}
model RefreshToken {
id String @id @default(uuid())
userId String @map("user_id")
token String @unique
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
@@map("refresh_tokens")
}
enum Plan {
starter
business
professional
enterprise
}
enum Role {
admin
contador
visor
}
// ─── Catálogo de Regímenes Fiscales SAT ───
model Regimen {
id Int @id @default(autoincrement())
clave String @unique @db.VarChar(3) // Clave SAT: 601, 603, 605, 606, etc.
descripcion String
tipoPersona String @map("tipo_persona") @db.VarChar(20) // fisica, moral, ambos
activo Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
tenantIgnorados TenantRegimenIgnorado[]
tenantActivos TenantRegimenActivo[]
@@map("regimenes")
}
model TenantRegimenIgnorado {
id Int @id @default(autoincrement())
tenantId String @map("tenant_id")
regimenId Int @map("regimen_id")
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
regimen Regimen @relation(fields: [regimenId], references: [id], onDelete: Cascade)
@@unique([tenantId, regimenId])
@@map("tenant_regimenes_ignorados")
}
model TenantRegimenActivo {
id Int @id @default(autoincrement())
tenantId String @map("tenant_id")
regimenId Int @map("regimen_id")
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
regimen Regimen @relation(fields: [regimenId], references: [id], onDelete: Cascade)
@@unique([tenantId, regimenId])
@@map("tenant_regimenes_activos")
}
// ─── Catálogo de Eventos Fiscales ───
model EventoFiscalCatalogo {
id Int @id @default(autoincrement())
titulo String
descripcion String?
tipo String @db.VarChar(20) // declaracion, pago, obligacion, informativa
diaBase Int @map("dia_base")
mesRelativo Int @default(1) @map("mes_relativo")
mesFijo Int? @map("mes_fijo")
recurrencia String @default("mensual") @db.VarChar(20) // mensual, anual
usaExtensionRfc Boolean @default(false) @map("usa_extension_rfc")
regimenes String @default("todos") // 'todos' o CSV: '601,603,612'
condicion String? @db.VarChar(50) // null, 'tiene_nomina', 'ingresos_4m'
activo Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
@@map("eventos_fiscales_catalogo")
}
/// Lista negra SAT (Art. 69-B CFF)
model ListaNegra {
id Int @id @default(autoincrement())
rfc String @unique @db.VarChar(13)
nombre String
situacion String @db.VarChar(30) // Definitivo, Presunto, Desvirtuado, Sentencia Favorable
updatedAt DateTime @updatedAt @map("updated_at")
createdAt DateTime @default(now()) @map("created_at")
@@index([rfc])
@@map("lista_negra")
}
/// Días inhábiles fiscales (festivos oficiales de México)
model DiaInhabil {
id Int @id @default(autoincrement())
fecha DateTime @unique @db.Date
nombre String
@@map("dias_inhabiles")
}
// ─── ISR ───
/// Tasas RESICO (Art. 113-E)
model IsrResicoTasa {
id Int @id @default(autoincrement())
anio Int @map("anio")
montoMaximo Decimal @map("monto_maximo") @db.Decimal(18, 2)
porcentaje Decimal @db.Decimal(5, 2)
@@unique([anio, montoMaximo])
@@map("isr_resico_tasas")
}
/// Tarifa ISR progresiva (Art. 96) - mensual
model IsrTarifa {
id Int @id @default(autoincrement())
anio Int @map("anio")
limiteInferior Decimal @map("limite_inferior") @db.Decimal(18, 2)
limiteSuperior Decimal? @map("limite_superior") @db.Decimal(18, 2)
cuotaFija Decimal @map("cuota_fija") @db.Decimal(18, 2)
porcentajeExcedente Decimal @map("porcentaje_excedente") @db.Decimal(5, 2)
@@unique([anio, limiteInferior])
@@map("isr_tarifas")
}
/// Coeficiente de utilidad por tenant/año
model CoeficienteUtilidad {
id Int @id @default(autoincrement())
tenantId String @map("tenant_id")
anio Int @map("anio")
coeficiente Decimal @db.Decimal(10, 4)
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@unique([tenantId, anio])
@@map("coeficiente_utilidad")
}
// ─── SAT Sync ───
model FielCredential {
id String @id @default(uuid())
tenantId String @unique @map("tenant_id")
rfc String @db.VarChar(13)
cerData Bytes @map("cer_data")
keyData Bytes @map("key_data")
keyPasswordEncrypted Bytes @map("key_password_encrypted")
cerIv Bytes @map("cer_iv")
cerTag Bytes @map("cer_tag")
keyIv Bytes @map("key_iv")
keyTag Bytes @map("key_tag")
passwordIv Bytes @map("password_iv")
passwordTag Bytes @map("password_tag")
serialNumber String? @map("serial_number") @db.VarChar(50)
validFrom DateTime @map("valid_from")
validUntil DateTime @map("valid_until")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@map("fiel_credentials")
}
// ─── Pagos (MercadoPago) ───
model Subscription {
id String @id @default(uuid())
tenantId String @map("tenant_id")
plan Plan
mpPreapprovalId String? @map("mp_preapproval_id")
status String @default("pending")
amount Decimal @db.Decimal(10, 2)
frequency String @default("monthly")
currentPeriodStart DateTime? @map("current_period_start")
currentPeriodEnd DateTime? @map("current_period_end")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
payments Payment[]
@@index([tenantId])
@@index([status])
@@map("subscriptions")
}
model Payment {
id String @id @default(uuid())
tenantId String @map("tenant_id")
subscriptionId String? @map("subscription_id")
mpPaymentId String? @map("mp_payment_id")
amount Decimal @db.Decimal(10, 2)
status String @default("pending")
paymentMethod String? @map("payment_method")
paidAt DateTime? @map("paid_at")
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
@@index([tenantId])
@@index([subscriptionId])
@@map("payments")
}
model SatSyncJob {
id String @id @default(uuid())
tenantId String @map("tenant_id")
type SatSyncType
status SatSyncStatus @default(pending)
dateFrom DateTime @map("date_from") @db.Date
dateTo DateTime @map("date_to") @db.Date
cfdiType CfdiSyncType? @map("cfdi_type")
satRequestId String? @map("sat_request_id") @db.VarChar(50)
satPackageIds String[] @map("sat_package_ids")
cfdisFound Int @default(0) @map("cfdis_found")
cfdisDownloaded Int @default(0) @map("cfdis_downloaded")
cfdisInserted Int @default(0) @map("cfdis_inserted")
cfdisUpdated Int @default(0) @map("cfdis_updated")
progressPercent Int @default(0) @map("progress_percent")
errorMessage String? @map("error_message")
startedAt DateTime? @map("started_at")
completedAt DateTime? @map("completed_at")
createdAt DateTime @default(now()) @map("created_at")
retryCount Int @default(0) @map("retry_count")
nextRetryAt DateTime? @map("next_retry_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@index([status])
@@index([status, nextRetryAt])
@@map("sat_sync_jobs")
}
enum SatSyncType {
initial
daily
}
enum SatSyncStatus {
pending
running
completed
failed
}
enum CfdiSyncType {
emitidos
recibidos
}
// ============================================
// BASES DE DATOS POR TENANT (horux_<rfc>)
// Gestionadas por raw SQL via pg Pool
// DDL en: apps/api/src/config/database.ts → createTables()
// ============================================
//
// TABLA: rfcs
// id SERIAL PRIMARY KEY
// rfc VARCHAR(13) UNIQUE NOT NULL
// nombre VARCHAR(255)
// creado_en TIMESTAMP DEFAULT NOW()
// actualizado_en TIMESTAMP DEFAULT NOW()
//
// TABLA: cfdis
// id SERIAL PRIMARY KEY
// year VARCHAR(4), month VARCHAR(2)
// type VARCHAR(10) -- EMITIDO | RECIBIDO
// uuid VARCHAR(36) UNIQUE
// serie, folio VARCHAR(50)
// status VARCHAR(20) -- Vigente, Cancelado, 0, 1
// fecha_emision TIMESTAMP
// rfc_emisor, rfc_receptor VARCHAR(13)
// nombre_emisor, nombre_receptor VARCHAR(255)
// subtotal, total, descuento NUMERIC(18,4) (+ _mxn variants)
// moneda VARCHAR(3), tipo_cambio NUMERIC(18,6)
// tipo_comprobante VARCHAR(1) -- I, E, T, P, N
// metodo_pago VARCHAR(3) -- PUE, PPD
// forma_pago VARCHAR(2), uso_cfdi VARCHAR(3)
// pac VARCHAR(13), fecha_cert_sat, fecha_cancelacion
// uuid_relacionado TEXT
// -- Impuestos (cada uno con variante _mxn):
// isr_retencion, iva_traslado, iva_retencion, ieps_traslado, ieps_retencion
// impuestos_locales_trasladado, impuestos_locales_retenidos
// -- Complemento de pagos:
// monto_pago, fecha_pago_p, num_parcialidad, saldo_pendiente
// isr_retencion_pago, iva_traslado_pago, iva_retencion_pago, ieps_traslado_pago, ieps_retencion_pago
// -- Nómina:
// num_seguro_social, puesto, salario_base_cot_apor, salario_diario_integrado
// total_percepciones, total_deducciones, imp_retenidos_nomina, otras_deducciones_nomina, subsidio_causado
// -- Metadata:
// conciliado VARCHAR(50)
// regimen_fiscal_emisor, regimen_fiscal_receptor VARCHAR(3)
// rfc_emisor_id, rfc_receptor_id INTEGER REFERENCES rfcs(id)
// xml_url, pdf_url, xml_original TEXT
// last_sat_sync TIMESTAMP, sat_sync_job_id UUID
// source VARCHAR(20) DEFAULT 'manual'
// creado_en, actualizado_en TIMESTAMP
//
// TABLA: cfdi_conceptos
// id SERIAL PRIMARY KEY
// cfdi_id INTEGER REFERENCES cfdis(id) ON DELETE CASCADE
// clave_prod_serv, no_identificacion, descripcion, cantidad, clave_unidad, unidad
// valor_unitario, importe, descuento (+ _mxn)
// isr_retencion, iva_traslado, iva_retencion, ieps_traslado, ieps_retencion (+ _mxn)
// impuestos_locales_trasladado, impuestos_locales_retenidos (+ _mxn)
// total_percepciones, total_deducciones, imp_retenidos_nomina, otras_deducciones_nomina, subsidio_causado (+ _mxn)
// creado_en TIMESTAMP
//
// TABLA: alertas
// id UUID PRIMARY KEY DEFAULT gen_random_uuid()
// tipo VARCHAR(50), titulo VARCHAR(200), mensaje TEXT
// prioridad VARCHAR(20) DEFAULT 'media'
// fecha_vencimiento TIMESTAMP, leida BOOLEAN, resuelta BOOLEAN
// created_at TIMESTAMP
//
// ÍNDICES:
// idx_cfdis_fecha_emision (DESC), idx_cfdis_type, idx_cfdis_status
// idx_cfdis_rfc_emisor, idx_cfdis_rfc_receptor
// idx_cfdis_year_month, idx_cfdis_rfc_emisor_id, idx_cfdis_rfc_receptor_id
// idx_cfdis_nombre_emisor_trgm (GIN trigram), idx_cfdis_nombre_receptor_trgm (GIN trigram)
// idx_cfdi_conceptos_cfdi_id, idx_cfdi_conceptos_clave
// EXTENSION: pg_trgm (para búsquedas fuzzy por nombre)

View File

@@ -0,0 +1,250 @@
# Guía de Despliegue en Producción - Horux360
## Infraestructura
### Servidor
- **OS:** Ubuntu 24.04 LTS
- **RAM:** 22GB
- **CPU:** 8 cores
- **Dominio:** horuxfin.com (DNS en AWS Route 53)
- **SSL:** Let's Encrypt (certificado real via DNS challenge)
- **IP Interna:** 192.168.10.212
### Stack
| Componente | Tecnología | Puerto |
|-----------|-----------|--------|
| Reverse Proxy | Nginx 1.24 | 80/443 |
| API | Node.js + Express + tsx | 4000 |
| Frontend | Next.js 14 | 3000 |
| Base de datos | PostgreSQL 16 | 5432 |
| Process Manager | PM2 | — |
---
## Arquitectura de Red
```
Internet
Nginx (443/SSL)
├── /api/* → 127.0.0.1:4000 (horux-api)
├── /api/auth/* → 127.0.0.1:4000 (rate limit: 5r/s)
├── /api/webhooks/* → 127.0.0.1:4000 (rate limit: 10r/s)
├── /health → 127.0.0.1:4000
└── /* → 127.0.0.1:3000 (horux-web)
```
---
## PM2 - Gestión de Procesos
### Configuración (`ecosystem.config.js`)
```javascript
module.exports = {
apps: [
{
name: 'horux-api',
interpreter: 'node',
script: '/root/Horux/node_modules/.pnpm/tsx@4.21.0/node_modules/tsx/dist/cli.mjs',
args: 'src/index.ts',
cwd: '/root/Horux/apps/api',
instances: 1,
exec_mode: 'fork',
autorestart: true,
max_memory_restart: '1G',
kill_timeout: 5000,
listen_timeout: 10000,
env: { NODE_ENV: 'production', PORT: 4000 },
},
{
name: 'horux-web',
script: 'node_modules/next/dist/bin/next',
args: 'start',
cwd: '/root/Horux/apps/web',
instances: 1,
exec_mode: 'fork',
autorestart: true,
max_memory_restart: '512M',
kill_timeout: 5000,
env: { NODE_ENV: 'production', PORT: 3000 },
},
],
};
```
### Notas
- La API usa `tsx` en lugar de `tsc` compilado porque `@horux/shared` exporta TypeScript raw (ESM) que `dist/` no puede resolver.
- Next.js usa la ruta directa `node_modules/next/dist/bin/next` porque `node_modules/.bin/next` es un shell script que PM2 no puede ejecutar como script Node.js.
### Comandos Útiles
```bash
pm2 restart all # Reiniciar todo
pm2 logs horux-api # Ver logs del API
pm2 logs horux-web # Ver logs del frontend
pm2 monit # Monitor en tiempo real
pm2 save # Guardar estado actual
pm2 startup # Configurar inicio automático
```
---
## Nginx
### Archivo: `/etc/nginx/sites-available/horux360.conf`
#### Rate Limiting
| Zona | Límite | Burst | Uso |
|------|--------|-------|-----|
| `auth` | 5r/s | 10 | `/api/auth/*` |
| `webhook` | 10r/s | 20 | `/api/webhooks/*` |
| `api` | 30r/s | 50 | `/api/*` (general) |
#### Security Headers
- `Content-Security-Policy`: Restrictivo (`default-src 'self'`)
- `Strict-Transport-Security`: 1 año con includeSubDomains
- `X-Frame-Options`: SAMEORIGIN
- `X-Content-Type-Options`: nosniff
- `Permissions-Policy`: camera, microphone, geolocation deshabilitados
- `Referrer-Policy`: strict-origin-when-cross-origin
#### Body Limits
- Global: `50M` (Nginx)
- API default: `10mb` (Express)
- `/api/cfdi/bulk`: `50mb` (Express route-specific)
### Renovar SSL
```bash
certbot renew --dry-run # Verificar
certbot renew # Renovar
```
---
## PostgreSQL
### Configuración de Rendimiento (`postgresql.conf`)
| Parámetro | Valor | Descripción |
|-----------|-------|-------------|
| `max_connections` | 300 | Para multi-tenant con pools por tenant |
| `shared_buffers` | 4GB | ~18% de 22GB RAM |
| `work_mem` | 16MB | Memoria por operación de sort/hash |
| `effective_cache_size` | 16GB | ~72% de RAM |
| `maintenance_work_mem` | 512MB | Para VACUUM, CREATE INDEX |
| `wal_buffers` | 64MB | Write-ahead log buffers |
### Arquitectura Multi-Tenant
Cada cliente tiene su propia base de datos PostgreSQL:
```
horux360 ← Base central (tenants, users, subscriptions)
horux_cas2408138w2 ← Base del admin global
horux_<rfc> ← Base de cada cliente
```
### Backups
```bash
# Cron job: 0 1 * * * /root/Horux/scripts/backup.sh
# Ubicación: /var/horux/backups/
# Retención: 7 diarios + 4 semanales
```
---
## Variables de Entorno
### API (`apps/api/.env`)
```env
NODE_ENV=production
PORT=4000
DATABASE_URL="postgresql://postgres:<password>@localhost:5432/horux360?schema=public"
JWT_SECRET=<min 32 chars>
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
CORS_ORIGIN=https://horuxfin.com
FRONTEND_URL=https://horuxfin.com
FIEL_ENCRYPTION_KEY=<min 32 chars, REQUERIDO>
FIEL_STORAGE_PATH=/var/horux/fiel
# MercadoPago
MP_ACCESS_TOKEN=<token>
MP_WEBHOOK_SECRET=<secret, REQUERIDO para producción>
# SMTP (Google Workspace)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=ivan@horuxfin.com
SMTP_PASS=<app-password>
SMTP_FROM=Horux360 <ivan@horuxfin.com>
# Admin
ADMIN_EMAIL=carlos@horuxfin.com
```
### Web (`apps/web/.env.local`)
```env
NEXT_PUBLIC_API_URL=https://horuxfin.com/api
```
---
## Directorios Importantes
```
/root/Horux/ ← Código fuente
/var/horux/fiel/ ← Archivos FIEL encriptados (0700)
/var/horux/backups/ ← Backups de PostgreSQL
/etc/nginx/sites-available/ ← Config de Nginx
/etc/letsencrypt/live/ ← Certificados SSL
```
---
## Despliegue de Cambios
```bash
# 1. Pull cambios
cd /root/Horux
git pull origin main
# 2. Instalar dependencias
pnpm install
# 3. Build
pnpm build
# 4. Reiniciar servicios
pm2 restart all
# 5. Si hay cambios en nginx:
cp deploy/nginx/horux360.conf /etc/nginx/sites-available/horux360.conf
nginx -t && systemctl reload nginx
```
---
## Troubleshooting
### API no inicia
```bash
pm2 logs horux-api --lines 50 # Ver logs de error
pm2 restart horux-api # Reiniciar
```
### Puerto en uso
```bash
lsof -i :4000 # Ver quién usa el puerto
kill <PID> # Matar proceso
pm2 restart horux-api
```
### Certificado SSL expirado
```bash
certbot renew
systemctl reload nginx
```
### Base de datos lenta
```bash
sudo -u postgres psql -c "SELECT * FROM pg_stat_activity WHERE state = 'active';"
```

Binary file not shown.

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.

View File

@@ -0,0 +1,143 @@
# Auditoría de Seguridad y Remediación - Horux360
**Fecha:** 2026-03-18
**Auditor:** Carlos e Ivan (Horux360)
**Alcance:** Plataforma completa (API, Frontend, Infraestructura)
---
## Resumen Ejecutivo
Se realizó una auditoría de seguridad completa de la plataforma Horux360 antes de abrirla a clientes. Se identificaron **6 vulnerabilidades críticas, 9 altas, 10 medias y 7 bajas**. Se corrigieron **20 vulnerabilidades** (todas las críticas, altas y medias de código).
## Vulnerabilidades Corregidas
### CRÍTICAS (6)
#### C1. Impersonación de Tenant sin Restricción
- **Archivo:** `tenant.middleware.ts`, `plan-limits.middleware.ts`
- **Problema:** Cualquier usuario con `role === 'admin'` (incluidos los admins de clientes) podía usar el header `X-View-Tenant` para acceder a los datos de CUALQUIER otro tenant.
- **Fix:** Se creó `utils/global-admin.ts` con función `isGlobalAdmin()` que verifica que el tenant del usuario solicitante tenga el RFC del admin global (`HTS240708LJA`). Se aplicó en `tenant.middleware.ts` y `plan-limits.middleware.ts`.
- **Impacto:** Rompía completamente el aislamiento multi-tenant.
#### C2. Endpoints de Suscripción sin Autorización (IDOR)
- **Archivo:** `subscription.routes.ts`, `subscription.controller.ts`
- **Problema:** Cualquier usuario autenticado podía llamar `POST /api/subscriptions/:tenantId/mark-paid` para marcar cualquier tenant como pagado.
- **Fix:** Se agregó `authorize('admin')` en las rutas y verificación `isGlobalAdmin()` en cada método del controlador. Doble capa de protección.
- **Impacto:** Bypass total de pagos.
#### C3. Bypass de Verificación de Webhook de MercadoPago
- **Archivo:** `webhook.controller.ts`, `mercadopago.service.ts`
- **Problema:** (1) Si faltaba el header `x-signature`, la verificación se saltaba completamente. (2) Si `MP_WEBHOOK_SECRET` no estaba configurado, la función retornaba `true` siempre.
- **Fix:** Ahora es obligatorio que los headers `x-signature`, `x-request-id` y `data.id` estén presentes; de lo contrario se rechaza con 401. Si `MP_WEBHOOK_SECRET` no está configurado, se rechaza el webhook.
- **Impacto:** Un atacante podía forjar webhooks para activar suscripciones gratis.
#### C4. `databaseName` Expuesto en JWT
- **Archivo:** `auth.service.ts`, `packages/shared/src/types/auth.ts`, `tenant.middleware.ts`
- **Problema:** El nombre interno de la base de datos PostgreSQL se incluía en el JWT (base64, visible para cualquier usuario).
- **Fix:** Se eliminó `databaseName` del payload JWT y del tipo `JWTPayload`. El tenant middleware ahora resuelve el `databaseName` server-side usando `tenantId` con caché de 5 minutos.
- **Impacto:** Fuga de información de infraestructura interna.
#### C5. Body Size Limit de 1GB
- **Archivo:** `app.ts`, `cfdi.routes.ts`, `deploy/nginx/horux360.conf`
- **Problema:** Express y Nginx aceptaban payloads de hasta 1GB, permitiendo DoS por agotamiento de memoria.
- **Fix:** Límite global reducido a `10mb`. Ruta `/api/cfdi/bulk` tiene límite específico de `50mb`. Nginx actualizado a `50M`.
- **Impacto:** Un solo request malicioso podía crashear el servidor.
#### C6. Archivo `.env` con Permisos 644
- **Archivo:** `apps/api/.env`
- **Problema:** El archivo `.env` era legible por cualquier usuario del sistema.
- **Fix:** `chmod 600` — solo legible por el propietario (root).
### ALTAS (5)
#### H1. SAT Cron Endpoints sin Autorización
- **Archivo:** `sat.routes.ts`, `sat.controller.ts`
- **Problema:** Cualquier usuario autenticado podía ejecutar el cron global de sincronización SAT.
- **Fix:** Se agregó `authorize('admin')` en rutas y `isGlobalAdmin()` en el controlador.
#### H2. Sin Content Security Policy (CSP)
- **Archivo:** `deploy/nginx/horux360.conf`
- **Problema:** Sin CSP, no había protección del navegador contra XSS.
- **Fix:** Se agregó CSP header completo. Se removió `X-XSS-Protection` (deprecado). Se agregó `Permissions-Policy`.
#### H3. Tenant CRUD con Admin Genérico
- **Archivo:** `usuarios.controller.ts`
- **Problema:** El check `isGlobalAdmin()` estaba duplicado y no centralizado.
- **Fix:** Se centralizó en `utils/global-admin.ts` con caché para evitar queries repetidos.
#### H4. Sin Rate Limiting en Auth
- **Archivo:** `auth.routes.ts`
- **Problema:** Sin límite de intentos en login/register/refresh.
- **Fix:** `express-rate-limit` instalado con: login 10/15min, register 3/hora, refresh 20/15min por IP.
#### H5. Logout Público
- **Archivo:** `auth.routes.ts`
- **Problema:** El endpoint `/auth/logout` no requería autenticación.
- **Fix:** Se agregó `authenticate` middleware.
### MEDIAS (9)
| # | Problema | Fix |
|---|---------|-----|
| M1 | Contraseñas temporales con `Math.random()` | Cambiado a `crypto.randomBytes(4).toString('hex')` |
| M2 | Contraseñas temporales logueadas a console | Removido `console.log` |
| M3 | Credenciales de BD enviadas por email | Removida sección de conexión DB del template de email |
| M4 | HTML injection en templates de email | Agregado `escapeHtml()` en todos los valores interpolados |
| M5 | Sin validación de tamaño en upload de FIEL | Límite de 50KB por archivo, 256 chars para password |
| M6 | SMTP sin requerir TLS | Agregado `requireTLS: true` en config de Nodemailer |
| M7 | Email no normalizado en registro | `toLowerCase()` aplicado antes del check de duplicados |
| M8 | FIEL_ENCRYPTION_KEY con default hardcoded | Removido `.default()`, ahora es requerido |
| M9 | Plan limits bypass con X-View-Tenant | Mismo fix que C1, verificación `isGlobalAdmin()` |
## Vulnerabilidades Pendientes (Infraestructura)
Estas requieren cambios de infraestructura que no son código:
| # | Severidad | Problema | Recomendación |
|---|-----------|---------|---------------|
| P1 | ALTA | App corre como root | Crear usuario `horux` dedicado |
| P2 | MEDIA | PostgreSQL usa superuser | Crear usuario `horux_app` con permisos mínimos |
| P3 | MEDIA | Backups sin encriptar ni offsite | Agregar GPG + sync a S3 |
| P4 | MEDIA | Sin lockout de cuenta | Agregar contador de intentos fallidos (requiere migración DB) |
| P5 | BAJA | Tokens JWT en localStorage | Migrar a HttpOnly cookies (requiere cambios frontend + API) |
| P6 | BAJA | Mismo JWT secret para access y refresh | Agregar `JWT_REFRESH_SECRET` |
## Archivos Modificados
### Nuevos
- `apps/api/src/utils/global-admin.ts` — Utilidad centralizada para verificar admin global con caché
### Modificados (Seguridad)
- `apps/api/src/middlewares/tenant.middleware.ts` — Resolución de databaseName server-side + global admin check
- `apps/api/src/middlewares/plan-limits.middleware.ts` — Global admin check para bypass
- `apps/api/src/controllers/subscription.controller.ts` — Global admin authorization
- `apps/api/src/controllers/webhook.controller.ts` — Verificación de firma obligatoria
- `apps/api/src/controllers/sat.controller.ts` — Global admin check en cron endpoints
- `apps/api/src/controllers/usuarios.controller.ts` — Uso de utilidad centralizada
- `apps/api/src/controllers/fiel.controller.ts` — Validación de tamaño de archivos
- `apps/api/src/routes/auth.routes.ts` — Rate limiting + logout autenticado
- `apps/api/src/routes/subscription.routes.ts` — authorize('admin') middleware
- `apps/api/src/routes/sat.routes.ts` — authorize('admin') en cron endpoints
- `apps/api/src/routes/cfdi.routes.ts` — Límite de 50MB específico para bulk
- `apps/api/src/services/auth.service.ts` — databaseName removido de JWT, email normalizado
- `apps/api/src/services/usuarios.service.ts` — randomBytes + sin console.log
- `apps/api/src/services/email/email.service.ts` — requireTLS
- `apps/api/src/services/email/templates/new-client-admin.ts` — Sin DB credentials, con escapeHtml
- `apps/api/src/services/payment/mercadopago.service.ts` — Rechazar si no hay secret
- `apps/api/src/config/env.ts` — FIEL_ENCRYPTION_KEY requerido
- `apps/api/src/app.ts` — Body limit 10MB
- `packages/shared/src/types/auth.ts` — databaseName removido de JWTPayload
- `deploy/nginx/horux360.conf` — CSP, Permissions-Policy, body 50M
## Prácticas Positivas Encontradas
- bcrypt con 12 salt rounds
- HTTPS con HSTS, TLS 1.2/1.3
- Helmet.js activo
- SQL parameterizado en todas las queries raw (Prisma ORM)
- FIEL encriptado con AES-256-GCM
- Refresh token rotation implementada
- Base de datos por tenant (aislamiento a nivel DB)
- PostgreSQL solo escucha en localhost
- `.env` en `.gitignore` y nunca commiteado

116
docs/superpowers/INDEX.md Normal file
View File

@@ -0,0 +1,116 @@
# Horux Despachos — Índice del proyecto
Este documento es el punto de entrada para cualquier sesión de trabajo en el pivote "Horux Despachos". Empieza aquí.
**Producto:** SaaS para despachos contables mexicanos (MVP), extensible a otras verticales profesionales (jurídica, arquitectura).
**Base de código:** fork de Horux360 en esta carpeta.
**Estado:** diseño aprobado, Plan 1 listo para ejecución, Planes 2-8 pendientes de brainstorm.
**Fecha inicio:** 2026-04-16.
---
## Lo primero que debes leer
1. **[Spec de diseño](specs/2026-04-16-horux-despachos-design.md)** — 16 secciones, decisiones arquitectónicas, modelo de datos, flujos, riesgos, roadmap de fases. Fuente de verdad para qué se construye.
2. **[Plan 1 — Refactor preparatorio del monorepo](plans/2026-04-16-refactor-monorepo-packages.md)** — 20 tasks, ~100 steps. Se ejecuta primero porque desbloquea todas las fases siguientes.
---
## Roadmap de 8 planes
Cada plan se construye sobre los anteriores. Cada uno pasa por su propio ciclo brainstorm → spec → plan → ejecución con la skill apropiada.
| # | Plan | Estado | Estimado | Spec | Plan |
|---|------|--------|----------|------|------|
| **1** | **Refactor preparatorio del monorepo** | ✅ Plan listo | 1-2 sem | Ver spec §13 | [Plan 1](plans/2026-04-16-refactor-monorepo-packages.md) |
| 2 | Cimientos de Despachos | ⏳ Pendiente | 3-4 sem | Pendiente | Pendiente |
| 3 | Roles y carteras | ⏳ Pendiente | 2 sem | Pendiente | Pendiente |
| 4 | Pricing y pagos | ⏳ Pendiente | 2-3 sem | Pendiente | Pendiente |
| 5 | Connector BYO-DB | ⏳ Pendiente | 2 sem | Pendiente | Pendiente |
| 6 | Admin global + dashboard cross-despacho | ⏳ Pendiente | 2 sem | Pendiente | Pendiente |
| 7 | Métricas pre-calculadas | ⏳ Pendiente | 2 sem | Pendiente | Pendiente |
| 8 | Polish + launch privado | ⏳ Pendiente | 1-2 sem | Pendiente | Pendiente |
**Total estimado:** 15-19 semanas hasta launch privado.
Detalle de alcance de cada plan en el spec §15.
---
## Cómo arrancar la próxima sesión
### Si vas a ejecutar el Plan 1 (refactor):
1. Verifica estado del repo:
```bash
cd /c/Users/chtr1/Downloads/Horux_despacho
git status
git log --oneline -5
```
2. Asegura deps instaladas:
```bash
pnpm install
pnpm -r typecheck # baseline sin errores antes de empezar
```
3. Invoca la skill de ejecución:
- **Recomendada:** `superpowers:subagent-driven-development` — fresh subagent por task, review entre tasks, contexto limpio.
- **Alternativa:** `superpowers:executing-plans` — ejecución inline con checkpoints.
4. Apunta la skill al plan:
- Path: `docs/superpowers/plans/2026-04-16-refactor-monorepo-packages.md`
5. Commits locales frecuentes (uno por task); **NO hacer `git push`** (no hay remote todavía).
### Si vas a brainstormear el siguiente plan (Plan 2):
1. Leer el spec completo (ya aprobado — no re-brainstorm decisiones ya cerradas).
2. Invocar `superpowers:brainstorming` con contexto: "brainstorm Plan 2 — Cimientos de Despachos: BD Central (Prisma), BD Tenant (migrations core + vertical contable), auth con despachoId, signup → trial → agregar contribuyente → FIEL/CSD → primera emisión CFDI."
3. El brainstorm genera un spec propio en `specs/` y termina en writing-plans que escribe el Plan 2 en `plans/`.
---
## Decisiones cerradas (no re-brainstorm)
Estas decisiones están aprobadas en el spec y NO deben revisarse salvo nueva evidencia:
- **Hosting:** Opción A (SaaS central + BYO-DB / Managed).
- **Tiers BD:** dos tiers (BYO barato, Managed premium).
- **Roles despacho:** Owner / Supervisor / Auxiliar / Cliente.
- **Carteras:** jerárquicas con cascada; Owner = Supervisor implícito.
- **Facturapi:** cuenta maestra Horux broker; 1 org por contribuyente; pool único timbres por despacho.
- **Connector:** Cloudflare Tunnel + Docker `horux/connector`.
- **Pricing:** tiers fijos + add-ons + paquetes one-shot.
- **Monorepo:** unificado, refactor preparatorio a `packages/core`, `packages/vertical-contable`, `packages/shared-ui`.
- **Admins globales:** impersonación con motivo + audit + dashboard cross-despacho.
- **Clientes-visores:** password + magic link + multi-RFC + multi-despacho.
- **Multi-vertical:** arquitectura preparada desde MVP.
- **Métricas:** hot/cold con drill-down + formula_version + invalidación dirigida.
---
## Archivos del directorio
```
docs/superpowers/
├── INDEX.md ← este archivo
├── specs/
│ └── 2026-04-16-horux-despachos-design.md ← spec completo
└── plans/
└── 2026-04-16-refactor-monorepo-packages.md ← Plan 1
```
Los Planes 2-8 agregarán specs y plans a sus respectivas carpetas conforme se vayan brainstormeando.
---
## Convenciones del proyecto
- **Disciplina TS:** `pnpm typecheck` tras cada cambio relevante. CI/guardrail local.
- **Tests:** Plan 1 NO agrega framework de tests (eso es proyecto aparte); valida con typecheck + smoke test manual.
- **Git:** commits locales por task; sin push (no hay remote); no `--amend` sobre commits existentes; no `--no-verify`.
- **Working directory único:** todo en `Downloads/Horux_despacho`. El repo de Horux360 en OneDrive NO se toca para este pivote.
- **Convenciones Prisma:** modelos en PascalCase, campos camelCase; relaciones explícitas con `@relation`.
- **Convenciones SQL (BD tenant):** snake_case, FK NOT NULL donde aplica, índices sobre `contribuyente_id` en todas las tablas verticales.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,895 @@
# Conciliacion Module Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a reconciliation module that lets users match CFDIs to bank payments, with bank account management in settings.
**Architecture:** Two new tables in tenant DBs (`bancos`, `conciliaciones`) + new column `id_conciliacion` in `cfdis`. Backend: service/controller/routes for each entity. Frontend: new `/conciliacion` page with tabs + bancos section in configuracion.
**Tech Stack:** Express + pg Pool (backend), Next.js + React Query (frontend), existing shadcn/ui components.
**Spec:** `docs/superpowers/specs/2026-04-12-conciliacion-design.md`
---
### Task 1: Database Schema — DDL and Migration
**Files:**
- Modify: `apps/api/src/config/database.ts:359-371` (createTables, after alertas)
- Modify: `apps/api/src/config/database.ts:374-393` (createIndexes)
- [ ] **Step 1: Add `bancos` and `conciliaciones` tables to createTables()**
In `apps/api/src/config/database.ts`, inside `createTables()`, after the `alertas` table block (line 369) and before the closing backtick+`);` (line 371), add:
```sql
CREATE TABLE IF NOT EXISTS bancos (
id SERIAL PRIMARY KEY,
banco VARCHAR(100) NOT NULL,
terminacion_cuenta VARCHAR(4) NOT NULL,
creado_en TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS conciliaciones (
id SERIAL PRIMARY KEY,
anio VARCHAR(4) NOT NULL,
mes VARCHAR(2) NOT NULL,
id_cfdi INTEGER NOT NULL UNIQUE REFERENCES cfdis(id),
fecha_de_pago DATE NOT NULL,
id_banco INTEGER NOT NULL REFERENCES bancos(id),
creado_en TIMESTAMP DEFAULT NOW()
);
```
- [ ] **Step 2: Add `id_conciliacion` column to `cfdis` DDL**
In the same `createTables()`, in the `cfdis` CREATE TABLE block, after the `conciliado VARCHAR(50),` line (around line 304), add:
```sql
id_conciliacion INTEGER REFERENCES conciliaciones(id),
```
**Note:** `conciliaciones` table must be created BEFORE `cfdis` for the FK to work. Move the `bancos` and `conciliaciones` CREATE TABLE blocks to BEFORE the `cfdis` block (after `rfcs`, before `cfdis`).
- [ ] **Step 3: Add indexes for conciliaciones in createIndexes()**
In `createIndexes()`, after the cfdi_conceptos indexes, add:
```sql
CREATE INDEX IF NOT EXISTS idx_conciliaciones_anio_mes ON conciliaciones(anio, mes);
CREATE INDEX IF NOT EXISTS idx_conciliaciones_id_cfdi ON conciliaciones(id_cfdi);
CREATE INDEX IF NOT EXISTS idx_cfdis_id_conciliacion ON cfdis(id_conciliacion);
```
- [ ] **Step 4: Migrate existing tenant**
Run these SQL commands against `horux_ede123456ab1`:
```sql
CREATE TABLE IF NOT EXISTS bancos (
id SERIAL PRIMARY KEY,
banco VARCHAR(100) NOT NULL,
terminacion_cuenta VARCHAR(4) NOT NULL,
creado_en TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS conciliaciones (
id SERIAL PRIMARY KEY,
anio VARCHAR(4) NOT NULL,
mes VARCHAR(2) NOT NULL,
id_cfdi INTEGER NOT NULL UNIQUE REFERENCES cfdis(id),
fecha_de_pago DATE NOT NULL,
id_banco INTEGER NOT NULL REFERENCES bancos(id),
creado_en TIMESTAMP DEFAULT NOW()
);
ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS id_conciliacion INTEGER REFERENCES conciliaciones(id);
CREATE INDEX IF NOT EXISTS idx_conciliaciones_anio_mes ON conciliaciones(anio, mes);
CREATE INDEX IF NOT EXISTS idx_conciliaciones_id_cfdi ON conciliaciones(id_cfdi);
CREATE INDEX IF NOT EXISTS idx_cfdis_id_conciliacion ON cfdis(id_conciliacion);
```
- [ ] **Step 5: Verify**
```bash
psql "postgresql://postgres:Hesoy%40m11@localhost:5432/horux_ede123456ab1" -c "\dt"
```
Expected: `bancos` and `conciliaciones` in the table list, `cfdis` has `id_conciliacion` column.
---
### Task 2: Backend — Bancos Service, Controller, Routes
**Files:**
- Create: `apps/api/src/services/bancos.service.ts`
- Create: `apps/api/src/controllers/bancos.controller.ts`
- Create: `apps/api/src/routes/bancos.routes.ts`
- Modify: `apps/api/src/app.ts`
- [ ] **Step 1: Create bancos service**
Create `apps/api/src/services/bancos.service.ts`:
```typescript
import type { Pool } from 'pg';
export interface Banco {
id: number;
banco: string;
terminacionCuenta: string;
creadoEn: string;
}
export async function getBancos(pool: Pool): Promise<Banco[]> {
const { rows } = await pool.query(`
SELECT id, banco, terminacion_cuenta as "terminacionCuenta",
creado_en as "creadoEn"
FROM bancos ORDER BY banco
`);
return rows;
}
export async function createBanco(pool: Pool, data: { banco: string; terminacionCuenta: string }): Promise<Banco> {
const { rows } = await pool.query(`
INSERT INTO bancos (banco, terminacion_cuenta)
VALUES ($1, $2)
RETURNING id, banco, terminacion_cuenta as "terminacionCuenta", creado_en as "creadoEn"
`, [data.banco, data.terminacionCuenta]);
return rows[0];
}
export async function updateBanco(pool: Pool, id: number, data: { banco?: string; terminacionCuenta?: string }): Promise<Banco> {
const fields: string[] = [];
const params: any[] = [];
let idx = 1;
if (data.banco) { fields.push(`banco = $${idx++}`); params.push(data.banco); }
if (data.terminacionCuenta) { fields.push(`terminacion_cuenta = $${idx++}`); params.push(data.terminacionCuenta); }
if (fields.length === 0) throw new Error('Nada que actualizar');
params.push(id);
const { rows } = await pool.query(`
UPDATE bancos SET ${fields.join(', ')} WHERE id = $${idx}
RETURNING id, banco, terminacion_cuenta as "terminacionCuenta", creado_en as "creadoEn"
`, params);
if (rows.length === 0) throw new Error('Banco no encontrado');
return rows[0];
}
export async function deleteBanco(pool: Pool, id: number): Promise<void> {
const { rows } = await pool.query(
`SELECT COUNT(*)::int as count FROM conciliaciones WHERE id_banco = $1`, [id]
);
if (rows[0].count > 0) {
throw new Error('No se puede eliminar un banco con conciliaciones asociadas');
}
await pool.query(`DELETE FROM bancos WHERE id = $1`, [id]);
}
```
- [ ] **Step 2: Create bancos controller**
Create `apps/api/src/controllers/bancos.controller.ts`:
```typescript
import type { Request, Response, NextFunction } from 'express';
import * as bancosService from '../services/bancos.service.js';
export async function getBancos(req: Request, res: Response, next: NextFunction) {
try {
const bancos = await bancosService.getBancos(req.tenantPool!);
res.json(bancos);
} catch (error) { next(error); }
}
export async function createBanco(req: Request, res: Response, next: NextFunction) {
try {
if (req.user!.role !== 'admin') return res.status(403).json({ message: 'No autorizado' });
const { banco, terminacionCuenta } = req.body;
if (!banco || !terminacionCuenta) return res.status(400).json({ message: 'banco y terminacionCuenta son requeridos' });
if (terminacionCuenta.length > 4) return res.status(400).json({ message: 'terminacionCuenta max 4 digitos' });
const result = await bancosService.createBanco(req.tenantPool!, { banco, terminacionCuenta });
res.status(201).json(result);
} catch (error) { next(error); }
}
export async function updateBanco(req: Request, res: Response, next: NextFunction) {
try {
if (req.user!.role !== 'admin') return res.status(403).json({ message: 'No autorizado' });
const id = parseInt(req.params.id);
const result = await bancosService.updateBanco(req.tenantPool!, id, req.body);
res.json(result);
} catch (error) { next(error); }
}
export async function deleteBanco(req: Request, res: Response, next: NextFunction) {
try {
if (req.user!.role !== 'admin') return res.status(403).json({ message: 'No autorizado' });
const id = parseInt(req.params.id);
await bancosService.deleteBanco(req.tenantPool!, id);
res.json({ message: 'Banco eliminado' });
} catch (error) { next(error); }
}
```
- [ ] **Step 3: Create bancos routes**
Create `apps/api/src/routes/bancos.routes.ts`:
```typescript
import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as bancosController from '../controllers/bancos.controller.js';
const router: IRouter = Router();
router.use(authenticate);
router.use(tenantMiddleware);
router.get('/', bancosController.getBancos);
router.post('/', bancosController.createBanco);
router.put('/:id', bancosController.updateBanco);
router.delete('/:id', bancosController.deleteBanco);
export { router as bancosRoutes };
```
- [ ] **Step 4: Register bancos routes in app.ts**
In `apps/api/src/app.ts`, add import and route:
```typescript
import { bancosRoutes } from './routes/bancos.routes.js';
// ... after regimenRoutes line:
app.use('/api/bancos', bancosRoutes);
```
- [ ] **Step 5: Verify bancos API**
```bash
TOKEN=$(curl -s -X POST http://localhost:4000/api/auth/login -H "Content-Type: application/json" -d '{"email":"admin@demo.com","password":"demo123"}' | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>process.stdout.write(JSON.parse(d).accessToken))")
curl -s -X POST http://localhost:4000/api/bancos -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"banco":"BBVA","terminacionCuenta":"1234"}'
curl -s http://localhost:4000/api/bancos -H "Authorization: Bearer $TOKEN"
```
Expected: banco created and listed.
---
### Task 3: Backend — Conciliacion Service, Controller, Routes
**Files:**
- Create: `apps/api/src/services/conciliacion.service.ts`
- Create: `apps/api/src/controllers/conciliacion.controller.ts`
- Create: `apps/api/src/routes/conciliacion.routes.ts`
- Modify: `apps/api/src/app.ts`
- [ ] **Step 1: Create conciliacion service**
Create `apps/api/src/services/conciliacion.service.ts`:
```typescript
import type { Pool } from 'pg';
const VIGENTE = `status NOT IN ('Cancelado', '0')`;
export interface ConciliacionCfdi {
id: number;
uuid: string;
type: string;
fechaEmision: string;
rfcEmisor: string;
nombreEmisor: string;
rfcReceptor: string;
nombreReceptor: string;
total: number;
totalMxn: number;
metodoPago: string | null;
conciliado: string | null;
idConciliacion: number | null;
conciliacion: {
id: number;
fechaDePago: string;
banco: string;
terminacionCuenta: string;
} | null;
}
export async function getCfdisConConciliacion(
pool: Pool,
filters: {
tipo: string;
fechaInicio?: string;
fechaFin?: string;
regimen?: string;
estado?: string;
}
): Promise<ConciliacionCfdi[]> {
const params: any[] = [];
let idx = 1;
let where = `WHERE c.type = $${idx++} AND c.${VIGENTE}`;
params.push(filters.tipo);
if (filters.fechaInicio) {
where += ` AND c.fecha_emision >= $${idx++}::date`;
params.push(filters.fechaInicio);
}
if (filters.fechaFin) {
where += ` AND c.fecha_emision <= ($${idx++}::date + interval '1 day')`;
params.push(filters.fechaFin);
}
if (filters.regimen) {
const regimenCol = filters.tipo === 'EMITIDO' ? 'regimen_fiscal_emisor' : 'regimen_fiscal_receptor';
where += ` AND c.${regimenCol} = $${idx++}`;
params.push(filters.regimen);
}
if (filters.estado === 'conciliado') {
where += ` AND c.conciliado = 'true'`;
} else if (filters.estado === 'pendiente') {
where += ` AND (c.conciliado IS NULL OR c.conciliado != 'true')`;
}
const { rows } = await pool.query(`
SELECT
c.id, c.uuid, c.type,
c.fecha_emision as "fechaEmision",
c.rfc_emisor as "rfcEmisor", c.nombre_emisor as "nombreEmisor",
c.rfc_receptor as "rfcReceptor", c.nombre_receptor as "nombreReceptor",
c.total, c.total_mxn as "totalMxn",
c.metodo_pago as "metodoPago",
c.conciliado,
c.id_conciliacion as "idConciliacion",
con.id as "conId",
con.fecha_de_pago as "conFechaDePago",
b.banco as "conBanco",
b.terminacion_cuenta as "conTerminacionCuenta"
FROM cfdis c
LEFT JOIN conciliaciones con ON con.id_cfdi = c.id
LEFT JOIN bancos b ON b.id = con.id_banco
${where}
ORDER BY c.fecha_emision DESC
`, params);
return rows.map((r: any) => ({
id: r.id,
uuid: r.uuid,
type: r.type,
fechaEmision: r.fechaEmision,
rfcEmisor: r.rfcEmisor,
nombreEmisor: r.nombreEmisor,
rfcReceptor: r.rfcReceptor,
nombreReceptor: r.nombreReceptor,
total: Number(r.total),
totalMxn: Number(r.totalMxn),
metodoPago: r.metodoPago,
conciliado: r.conciliado,
idConciliacion: r.idConciliacion,
conciliacion: r.conId ? {
id: r.conId,
fechaDePago: r.conFechaDePago,
banco: r.conBanco,
terminacionCuenta: r.conTerminacionCuenta,
} : null,
}));
}
export async function conciliar(
pool: Pool,
data: { cfdiIds: number[]; fechaDePago: string; idBanco: number },
tenantCreatedYear: number,
): Promise<number> {
const fechaPago = new Date(data.fechaDePago + 'T12:00:00');
const anio = String(fechaPago.getFullYear());
const mes = String(fechaPago.getMonth() + 1).padStart(2, '0');
if (fechaPago.getFullYear() < tenantCreatedYear) {
throw new Error(`Solo se puede conciliar del año ${tenantCreatedYear} en adelante`);
}
// Validate banco exists
const { rows: bancoRows } = await pool.query(`SELECT id FROM bancos WHERE id = $1`, [data.idBanco]);
if (bancoRows.length === 0) throw new Error('Banco no encontrado');
// Validate CFDIs exist, are vigente, and not already conciliado
const { rows: cfdis } = await pool.query(`
SELECT id, conciliado FROM cfdis
WHERE id = ANY($1) AND ${VIGENTE}
`, [data.cfdiIds]);
if (cfdis.length !== data.cfdiIds.length) {
throw new Error('Algunos CFDIs no existen o estan cancelados');
}
const yaConc = cfdis.filter((c: any) => c.conciliado === 'true');
if (yaConc.length > 0) {
throw new Error(`${yaConc.length} CFDIs ya estan conciliados`);
}
let count = 0;
for (const cfdiId of data.cfdiIds) {
const { rows: inserted } = await pool.query(`
INSERT INTO conciliaciones (anio, mes, id_cfdi, fecha_de_pago, id_banco)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`, [anio, mes, cfdiId, data.fechaDePago, data.idBanco]);
await pool.query(`
UPDATE cfdis SET conciliado = 'true', id_conciliacion = $1 WHERE id = $2
`, [inserted[0].id, cfdiId]);
count++;
}
return count;
}
export async function desconciliar(pool: Pool, conciliacionId: number): Promise<void> {
const { rows } = await pool.query(`SELECT id_cfdi FROM conciliaciones WHERE id = $1`, [conciliacionId]);
if (rows.length === 0) throw new Error('Conciliacion no encontrada');
await pool.query(`UPDATE cfdis SET conciliado = NULL, id_conciliacion = NULL WHERE id_conciliacion = $1`, [conciliacionId]);
await pool.query(`DELETE FROM conciliaciones WHERE id = $1`, [conciliacionId]);
}
```
- [ ] **Step 2: Create conciliacion controller**
Create `apps/api/src/controllers/conciliacion.controller.ts`:
```typescript
import type { Request, Response, NextFunction } from 'express';
import * as conciliacionService from '../services/conciliacion.service.js';
import { prisma } from '../config/database.js';
export async function getCfdis(req: Request, res: Response, next: NextFunction) {
try {
const { tipo, fechaInicio, fechaFin, regimen, estado } = req.query;
if (!tipo) return res.status(400).json({ message: 'tipo es requerido (EMITIDO|RECIBIDO)' });
const data = await conciliacionService.getCfdisConConciliacion(req.tenantPool!, {
tipo: tipo as string,
fechaInicio: fechaInicio as string,
fechaFin: fechaFin as string,
regimen: regimen as string,
estado: estado as string,
});
res.json(data);
} catch (error) { next(error); }
}
export async function conciliar(req: Request, res: Response, next: NextFunction) {
try {
if (!['admin', 'contador'].includes(req.user!.role)) {
return res.status(403).json({ message: 'No autorizado' });
}
const { cfdiIds, fechaDePago, idBanco } = req.body;
if (!cfdiIds?.length || !fechaDePago || !idBanco) {
return res.status(400).json({ message: 'cfdiIds, fechaDePago e idBanco son requeridos' });
}
const tenant = await prisma.tenant.findUnique({
where: { id: req.user!.tenantId },
select: { createdAt: true },
});
const tenantCreatedYear = tenant ? tenant.createdAt.getFullYear() : new Date().getFullYear();
const count = await conciliacionService.conciliar(req.tenantPool!, { cfdiIds, fechaDePago, idBanco }, tenantCreatedYear);
res.json({ message: `${count} CFDIs conciliados`, count });
} catch (error) { next(error); }
}
export async function desconciliar(req: Request, res: Response, next: NextFunction) {
try {
if (!['admin', 'contador'].includes(req.user!.role)) {
return res.status(403).json({ message: 'No autorizado' });
}
const id = parseInt(req.params.id);
await conciliacionService.desconciliar(req.tenantPool!, id);
res.json({ message: 'CFDI desconciliado' });
} catch (error) { next(error); }
}
```
- [ ] **Step 3: Create conciliacion routes**
Create `apps/api/src/routes/conciliacion.routes.ts`:
```typescript
import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import { requireFeature } from '../middlewares/feature-gate.middleware.js';
import * as conciliacionController from '../controllers/conciliacion.controller.js';
const router: IRouter = Router();
router.use(authenticate);
router.use(tenantMiddleware);
router.use(requireFeature('conciliacion'));
router.get('/', conciliacionController.getCfdis);
router.post('/', conciliacionController.conciliar);
router.delete('/:id', conciliacionController.desconciliar);
export { router as conciliacionRoutes };
```
- [ ] **Step 4: Register in app.ts**
In `apps/api/src/app.ts`, add import and route:
```typescript
import { conciliacionRoutes } from './routes/conciliacion.routes.js';
// ... after bancosRoutes:
app.use('/api/conciliacion', conciliacionRoutes);
```
- [ ] **Step 5: Verify conciliacion API**
```bash
TOKEN=$(curl -s -X POST http://localhost:4000/api/auth/login -H "Content-Type: application/json" -d '{"email":"admin@demo.com","password":"demo123"}' | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>process.stdout.write(JSON.parse(d).accessToken))")
curl -s "http://localhost:4000/api/conciliacion?tipo=EMITIDO" -H "Authorization: Bearer $TOKEN"
```
Expected: array of CFDIs with `conciliacion: null` for all.
---
### Task 4: Frontend — API Clients and Hooks
**Files:**
- Create: `apps/web/lib/api/bancos.ts`
- Create: `apps/web/lib/api/conciliacion.ts`
- Create: `apps/web/lib/hooks/use-bancos.ts`
- Create: `apps/web/lib/hooks/use-conciliacion.ts`
- [ ] **Step 1: Create bancos API client**
Create `apps/web/lib/api/bancos.ts`:
```typescript
import { apiClient } from './client';
export interface Banco {
id: number;
banco: string;
terminacionCuenta: string;
}
export async function getBancos(): Promise<Banco[]> {
const res = await apiClient.get<Banco[]>('/bancos');
return res.data;
}
export async function createBanco(data: { banco: string; terminacionCuenta: string }): Promise<Banco> {
const res = await apiClient.post<Banco>('/bancos', data);
return res.data;
}
export async function updateBanco(id: number, data: { banco?: string; terminacionCuenta?: string }): Promise<Banco> {
const res = await apiClient.put<Banco>(`/bancos/${id}`, data);
return res.data;
}
export async function deleteBanco(id: number): Promise<void> {
await apiClient.delete(`/bancos/${id}`);
}
```
- [ ] **Step 2: Create conciliacion API client**
Create `apps/web/lib/api/conciliacion.ts`:
```typescript
import { apiClient } from './client';
export interface ConciliacionCfdi {
id: number;
uuid: string;
type: string;
fechaEmision: string;
rfcEmisor: string;
nombreEmisor: string;
rfcReceptor: string;
nombreReceptor: string;
total: number;
totalMxn: number;
metodoPago: string | null;
conciliado: string | null;
idConciliacion: number | null;
conciliacion: {
id: number;
fechaDePago: string;
banco: string;
terminacionCuenta: string;
} | null;
}
export async function getCfdisConConciliacion(params: {
tipo: string;
fechaInicio?: string;
fechaFin?: string;
regimen?: string;
estado?: string;
}): Promise<ConciliacionCfdi[]> {
const q = new URLSearchParams();
q.set('tipo', params.tipo);
if (params.fechaInicio) q.set('fechaInicio', params.fechaInicio);
if (params.fechaFin) q.set('fechaFin', params.fechaFin);
if (params.regimen) q.set('regimen', params.regimen);
if (params.estado) q.set('estado', params.estado);
const res = await apiClient.get<ConciliacionCfdi[]>(`/conciliacion?${q}`);
return res.data;
}
export async function conciliar(data: {
cfdiIds: number[];
fechaDePago: string;
idBanco: number;
}): Promise<{ count: number }> {
const res = await apiClient.post<{ count: number }>('/conciliacion', data);
return res.data;
}
export async function desconciliar(id: number): Promise<void> {
await apiClient.delete(`/conciliacion/${id}`);
}
```
- [ ] **Step 3: Create bancos hook**
Create `apps/web/lib/hooks/use-bancos.ts`:
```typescript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as bancosApi from '@/lib/api/bancos';
export function useBancos() {
return useQuery({
queryKey: ['bancos'],
queryFn: bancosApi.getBancos,
});
}
export function useCreateBanco() {
const qc = useQueryClient();
return useMutation({
mutationFn: bancosApi.createBanco,
onSuccess: () => qc.invalidateQueries({ queryKey: ['bancos'] }),
});
}
export function useDeleteBanco() {
const qc = useQueryClient();
return useMutation({
mutationFn: bancosApi.deleteBanco,
onSuccess: () => qc.invalidateQueries({ queryKey: ['bancos'] }),
});
}
```
- [ ] **Step 4: Create conciliacion hook**
Create `apps/web/lib/hooks/use-conciliacion.ts`:
```typescript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as conciliacionApi from '@/lib/api/conciliacion';
export function useCfdisConConciliacion(params: {
tipo: string;
fechaInicio?: string;
fechaFin?: string;
regimen?: string;
}) {
return useQuery({
queryKey: ['conciliacion', params],
queryFn: () => conciliacionApi.getCfdisConConciliacion(params),
enabled: !!params.tipo,
});
}
export function useConciliar() {
const qc = useQueryClient();
return useMutation({
mutationFn: conciliacionApi.conciliar,
onSuccess: () => qc.invalidateQueries({ queryKey: ['conciliacion'] }),
});
}
export function useDesconciliar() {
const qc = useQueryClient();
return useMutation({
mutationFn: conciliacionApi.desconciliar,
onSuccess: () => qc.invalidateQueries({ queryKey: ['conciliacion'] }),
});
}
```
---
### Task 5: Frontend — Sidebar Navigation
**Files:**
- Modify: `apps/web/components/layouts/sidebar.tsx`
- Modify: `apps/web/components/layouts/sidebar-compact.tsx`
- Modify: `apps/web/components/layouts/sidebar-floating.tsx`
- Modify: `apps/web/components/layouts/topnav.tsx`
- [ ] **Step 1: Add Conciliacion to all 4 sidebar variants**
In each of the 4 navigation layout files, add to the `navigation` array after the Reportes entry:
```typescript
{ name: 'Conciliacion', href: '/conciliacion', icon: Scale, feature: 'conciliacion' },
```
Import `Scale` from `lucide-react` in each file (already imported in sidebar.tsx, check the others).
---
### Task 6: Frontend — Bancos Section in Configuracion
**Files:**
- Modify: `apps/web/app/(dashboard)/configuracion/page.tsx`
- [ ] **Step 1: Add BancosSection component**
In `apps/web/app/(dashboard)/configuracion/page.tsx`, add a new component `BancosSection` and render it in the page (only for admin). Place it after the RegimenesActivosSection.
```tsx
function BancosSection() {
const { data: bancos, isLoading } = useBancos();
const createBanco = useCreateBanco();
const deleteBancoMut = useDeleteBanco();
const [nombre, setNombre] = useState('');
const [terminacion, setTerminacion] = useState('');
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!nombre || !terminacion) return;
try {
await createBanco.mutateAsync({ banco: nombre, terminacionCuenta: terminacion });
setNombre('');
setTerminacion('');
} catch (err: any) {
alert(err.response?.data?.message || 'Error al crear banco');
}
};
const handleDelete = async (id: number) => {
if (!confirm('Eliminar este banco?')) return;
try {
await deleteBancoMut.mutateAsync(id);
} catch (err: any) {
alert(err.response?.data?.message || 'Error al eliminar');
}
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building className="h-5 w-5" />
Bancos
</CardTitle>
<CardDescription>Cuentas bancarias para conciliacion</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : bancos && bancos.length > 0 ? (
<div className="divide-y">
{bancos.map((b) => (
<div key={b.id} className="flex items-center justify-between py-2">
<div>
<span className="font-medium">{b.banco}</span>
<span className="text-muted-foreground ml-2">****{b.terminacionCuenta}</span>
</div>
<Button variant="ghost" size="sm" onClick={() => handleDelete(b.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No hay bancos registrados</p>
)}
<form onSubmit={handleAdd} className="flex gap-2 items-end">
<div className="flex-1 space-y-1">
<Label htmlFor="banco-nombre">Banco</Label>
<Input id="banco-nombre" value={nombre} onChange={e => setNombre(e.target.value)} placeholder="BBVA" required />
</div>
<div className="w-32 space-y-1">
<Label htmlFor="banco-term">Terminacion</Label>
<Input id="banco-term" value={terminacion} onChange={e => setTerminacion(e.target.value.replace(/\D/g, '').slice(0, 4))} placeholder="1234" maxLength={4} required />
</div>
<Button type="submit" disabled={createBanco.isPending}>Agregar</Button>
</form>
</CardContent>
</Card>
);
}
```
Add required imports at the top of the file:
```typescript
import { useBancos, useCreateBanco, useDeleteBanco } from '@/lib/hooks/use-bancos';
import { Trash2 } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
```
Render `<BancosSection />` in the page JSX, after the regimenes section, wrapped in the admin check:
```tsx
{isAdmin && <BancosSection />}
```
---
### Task 7: Frontend — Conciliacion Page
**Files:**
- Create: `apps/web/app/(dashboard)/conciliacion/page.tsx`
- [ ] **Step 1: Create the conciliacion page**
Create `apps/web/app/(dashboard)/conciliacion/page.tsx` with:
- Period selector and regimen selector (reuse existing components)
- Tabs: Emitidas / Recibidas
- Two sections per tab: "Por conciliar" (with checkboxes) and "Conciliadas"
- Sticky action bar when checkboxes are selected (banco dropdown + fecha de pago + button)
- CfdiViewerModal for "Ver factura"
- Desconciliar button on conciliated rows
- Visor role sees no checkboxes or action buttons
This is the largest file. Full implementation code should be written by the executing agent following the spec layout description. Key patterns to follow:
- Use `useCfdisConConciliacion({ tipo: activeTab, fechaInicio, fechaFin, regimen })`
- Split data into `pendientes` (conciliado !== 'true') and `conciliadas` (conciliado === 'true')
- `useState<Set<number>>` for selected checkbox IDs
- `useBancos()` for the banco dropdown
- `useConciliar()` and `useDesconciliar()` mutations
- `useAuthStore()` to check `user.role` for visor read-only
- `formatCurrency` from `@/lib/utils`
- `CfdiViewerModal` from `@/components/cfdi/cfdi-viewer-modal`
- `PeriodSelector` from `@/components/period-selector`
- `RegimenSelector` from `@/components/regimen-selector` (needs `useRegimenesDelPeriodo`)
- Action bar appears only when `selected.size > 0`, contains: banco Select, date Input, "Conciliar N facturas" Button
- Export to Excel button using `exportToExcel` from `@/lib/export-excel`
---
### Task 8: Verification and Cleanup
- [ ] **Step 1: Restart dev server**
Kill and restart `pnpm dev` to pick up all backend changes.
- [ ] **Step 2: Test full flow**
1. Login as admin
2. Go to Configuracion → verify Bancos section, add a bank
3. Go to Conciliacion → verify tabs show CFDIs
4. Select CFDIs, pick banco and date, conciliar → verify they move to "Conciliadas"
5. Desconciliar one → verify it moves back
6. Login as visor → verify read-only (no checkboxes, no action buttons)
- [ ] **Step 3: Test API edge cases**
```bash
# Try conciliar already conciliado CFDI — should fail
# Try conciliar with non-existent banco — should fail
# Try delete banco with conciliaciones — should fail
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,680 @@
# Tenant Schema Migrations Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement a numbered SQL migration system for tenant databases so schema changes auto-apply to existing tenants via eager (deploy) and lazy (on-connect) strategies.
**Architecture:** SQL files in `apps/api/src/migrations/tenant/` numbered `NNN_description.sql`. A `schema_migrations` table in each tenant DB tracks applied versions. `TenantMigrationRunner` reads files, diffs against the table, applies pending ones. Integrated into `getPool()` (lazy) and a CLI script (eager).
**Tech Stack:** Node.js, pg Pool, filesystem (fs/path), Prisma (central DB query for eager), tsx (CLI runner)
---
### Task 1: Create migration SQL file from existing schema
**Files:**
- Create: `apps/api/src/migrations/tenant/001_initial_schema.sql`
This file contains the exact SQL currently in `createTables()` and `createIndexes()` from `apps/api/src/config/database.ts:212-439`, prefixed with the `schema_migrations` table creation.
- [ ] **Step 1: Create the migrations directory and 001 file**
Create `apps/api/src/migrations/tenant/001_initial_schema.sql` with this content:
```sql
-- 001_initial_schema.sql
-- Initial tenant database schema (migrated from createTables + createIndexes)
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- =============================================
-- Tables
-- =============================================
CREATE TABLE IF NOT EXISTS rfcs (
id SERIAL PRIMARY KEY,
rfc VARCHAR(14) UNIQUE NOT NULL,
razon_social VARCHAR(255),
regimen_fiscal VARCHAR(3),
codigo_postal VARCHAR(5)
);
CREATE TABLE IF NOT EXISTS bancos (
id SERIAL PRIMARY KEY,
banco VARCHAR(100) NOT NULL,
terminacion_cuenta VARCHAR(4) NOT NULL,
creado_en TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS cfdis (
id SERIAL PRIMARY KEY,
year VARCHAR(4),
month VARCHAR(2),
type VARCHAR(10),
uuid VARCHAR(36) UNIQUE,
serie VARCHAR(50),
folio VARCHAR(50),
status VARCHAR(20),
fecha_emision TIMESTAMP,
rfc_emisor_id INTEGER REFERENCES rfcs(id),
rfc_emisor VARCHAR(13),
nombre_emisor VARCHAR(255),
rfc_receptor_id INTEGER REFERENCES rfcs(id),
rfc_receptor VARCHAR(13),
nombre_receptor VARCHAR(255),
subtotal NUMERIC(18,4),
subtotal_mxn NUMERIC(18,4),
descuento NUMERIC(18,4),
descuento_mxn NUMERIC(18,4),
total NUMERIC(18,4),
total_mxn NUMERIC(18,4),
saldo_insoluto TEXT,
moneda VARCHAR(3),
tipo_cambio NUMERIC(18,6),
tipo_comprobante VARCHAR(1),
metodo_pago VARCHAR(3),
forma_pago VARCHAR(2),
uso_cfdi VARCHAR(5),
pac VARCHAR(13),
fecha_cert_sat TIMESTAMP,
fecha_cancelacion TIMESTAMP,
uuid_relacionado TEXT,
isr_retencion NUMERIC(18,4),
isr_retencion_mxn NUMERIC(18,4),
iva_traslado NUMERIC(18,4),
iva_traslado_mxn NUMERIC(18,4),
iva_retencion NUMERIC(18,4),
iva_retencion_mxn NUMERIC(18,4),
ieps_traslado NUMERIC(18,4),
ieps_traslado_mxn NUMERIC(18,4),
ieps_retencion NUMERIC(18,4),
ieps_retencion_mxn NUMERIC(18,4),
impuestos_locales_trasladado NUMERIC(18,4),
impuestos_locales_trasladado_mxn NUMERIC(18,4),
impuestos_locales_retenidos NUMERIC(18,4),
impuestos_locales_retenidos_mxn NUMERIC(18,4),
monto_pago NUMERIC(18,4),
monto_pago_mxn NUMERIC(18,4),
fecha_pago_p TIMESTAMP,
num_parcialidad TEXT,
isr_retencion_pago NUMERIC(18,4),
isr_retencion_pago_mxn NUMERIC(18,4),
iva_traslado_pago NUMERIC(18,4),
iva_traslado_pago_mxn NUMERIC(18,4),
iva_retencion_pago NUMERIC(18,4),
iva_retencion_pago_mxn NUMERIC(18,4),
ieps_traslado_pago NUMERIC(18,4),
ieps_traslado_pago_mxn NUMERIC(18,4),
ieps_retencion_pago NUMERIC(18,4),
ieps_retencion_pago_mxn NUMERIC(18,4),
saldo_pendiente NUMERIC(18,4),
saldo_pendiente_mxn NUMERIC(18,4),
fecha_liquidacion TIMESTAMP,
fecha_pago DATE,
fecha_inicial_pago DATE,
fecha_final_pago DATE,
num_dias_pagados NUMERIC(10,2),
num_seguro_social VARCHAR(50),
puesto VARCHAR(255),
salario_base_cot_apor NUMERIC(18,4),
salario_base_cot_apor_mxn NUMERIC(18,4),
salario_diario_integrado NUMERIC(18,4),
salario_diario_integrado_mxn NUMERIC(18,4),
total_percepciones NUMERIC(18,4),
total_percepciones_mxn NUMERIC(18,4),
total_deducciones NUMERIC(18,4),
total_deducciones_mxn NUMERIC(18,4),
imp_retenidos_nomina NUMERIC(18,4),
imp_retenidos_nomina_mxn NUMERIC(18,4),
otras_deducciones_nomina NUMERIC(18,4),
otras_deducciones_nomina_mxn NUMERIC(18,4),
subsidio_causado NUMERIC(18,4),
subsidio_causado_mxn NUMERIC(18,4),
conciliado VARCHAR(50),
id_conciliacion INTEGER,
xml_url TEXT,
pdf_url TEXT,
xml_original TEXT,
last_sat_sync TIMESTAMP,
sat_sync_job_id UUID,
source VARCHAR(20) DEFAULT 'manual',
facturapi_id VARCHAR(50),
regimen_fiscal_emisor VARCHAR(3),
regimen_fiscal_receptor VARCHAR(3),
creado_en TIMESTAMP DEFAULT NOW(),
actualizado_en TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS cfdi_conceptos (
id SERIAL PRIMARY KEY,
cfdi_id INTEGER REFERENCES cfdis(id) ON DELETE CASCADE,
clave_prod_serv VARCHAR(10),
no_identificacion VARCHAR(100),
descripcion TEXT,
cantidad NUMERIC(18,4),
clave_unidad VARCHAR(10),
unidad VARCHAR(100),
valor_unitario NUMERIC(18,4),
valor_unitario_mxn NUMERIC(18,4),
importe NUMERIC(18,4),
importe_mxn NUMERIC(18,4),
descuento NUMERIC(18,4),
descuento_mxn NUMERIC(18,4),
isr_retencion NUMERIC(18,4),
isr_retencion_mxn NUMERIC(18,4),
iva_traslado NUMERIC(18,4),
iva_traslado_mxn NUMERIC(18,4),
iva_retencion NUMERIC(18,4),
iva_retencion_mxn NUMERIC(18,4),
ieps_traslado NUMERIC(18,4),
ieps_traslado_mxn NUMERIC(18,4),
ieps_retencion NUMERIC(18,4),
ieps_retencion_mxn NUMERIC(18,4),
impuestos_locales_trasladado NUMERIC(18,4),
impuestos_locales_trasladado_mxn NUMERIC(18,4),
impuestos_locales_retenidos NUMERIC(18,4),
impuestos_locales_retenidos_mxn NUMERIC(18,4),
total_percepciones NUMERIC(18,4),
total_percepciones_mxn NUMERIC(18,4),
total_deducciones NUMERIC(18,4),
total_deducciones_mxn NUMERIC(18,4),
imp_retenidos_nomina NUMERIC(18,4),
imp_retenidos_nomina_mxn NUMERIC(18,4),
otras_deducciones_nomina NUMERIC(18,4),
otras_deducciones_nomina_mxn NUMERIC(18,4),
subsidio_causado NUMERIC(18,4),
subsidio_causado_mxn NUMERIC(18,4),
creado_en TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS conciliaciones (
id SERIAL PRIMARY KEY,
anio VARCHAR(4) NOT NULL,
mes VARCHAR(2) NOT NULL,
id_cfdi INTEGER NOT NULL UNIQUE REFERENCES cfdis(id),
fecha_de_pago DATE NOT NULL,
id_banco INTEGER NOT NULL REFERENCES bancos(id),
creado_en TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS alertas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tipo VARCHAR(50) NOT NULL,
titulo VARCHAR(200) NOT NULL,
mensaje TEXT,
prioridad VARCHAR(20) DEFAULT 'media',
fecha_vencimiento TIMESTAMP,
leida BOOLEAN DEFAULT FALSE,
resuelta BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS recordatorios (
id SERIAL PRIMARY KEY,
titulo VARCHAR(200) NOT NULL,
descripcion TEXT,
fecha_limite DATE NOT NULL,
notas TEXT,
completado BOOLEAN DEFAULT FALSE,
privado BOOLEAN DEFAULT FALSE,
creado_por UUID NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- =============================================
-- Indexes
-- =============================================
CREATE INDEX IF NOT EXISTS idx_cfdis_fecha_emision ON cfdis(fecha_emision DESC);
CREATE INDEX IF NOT EXISTS idx_cfdis_type ON cfdis(type);
CREATE INDEX IF NOT EXISTS idx_cfdis_rfc_emisor ON cfdis(rfc_emisor);
CREATE INDEX IF NOT EXISTS idx_cfdis_rfc_receptor ON cfdis(rfc_receptor);
CREATE INDEX IF NOT EXISTS idx_cfdis_status ON cfdis(status);
CREATE INDEX IF NOT EXISTS idx_cfdis_year_month ON cfdis(year, month);
CREATE INDEX IF NOT EXISTS idx_cfdis_nombre_emisor_trgm ON cfdis USING gin(nombre_emisor gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_cfdis_nombre_receptor_trgm ON cfdis USING gin(nombre_receptor gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_cfdis_rfc_emisor_id ON cfdis(rfc_emisor_id);
CREATE INDEX IF NOT EXISTS idx_cfdis_rfc_receptor_id ON cfdis(rfc_receptor_id);
CREATE INDEX IF NOT EXISTS idx_cfdi_conceptos_cfdi_id ON cfdi_conceptos(cfdi_id);
CREATE INDEX IF NOT EXISTS idx_cfdi_conceptos_clave ON cfdi_conceptos(clave_prod_serv);
CREATE INDEX IF NOT EXISTS idx_conciliaciones_anio_mes ON conciliaciones(anio, mes);
CREATE INDEX IF NOT EXISTS idx_conciliaciones_id_cfdi ON conciliaciones(id_cfdi);
CREATE INDEX IF NOT EXISTS idx_cfdis_id_conciliacion ON cfdis(id_conciliacion);
-- Deferred FK for id_conciliacion
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cfdis_id_conciliacion_fkey') THEN
ALTER TABLE cfdis ADD CONSTRAINT cfdis_id_conciliacion_fkey FOREIGN KEY (id_conciliacion) REFERENCES conciliaciones(id);
END IF;
END $$;
```
- [ ] **Step 2: Commit**
```bash
git add apps/api/src/migrations/tenant/001_initial_schema.sql
git commit -m "feat: add 001_initial_schema.sql tenant migration file"
```
---
### Task 2: Create TenantMigrationRunner
**Files:**
- Create: `apps/api/src/config/tenant-migrations.ts`
- [ ] **Step 1: Create tenant-migrations.ts**
Create `apps/api/src/config/tenant-migrations.ts`:
```typescript
import { Pool } from 'pg';
import { readdir, readFile } from 'fs/promises';
import { join } from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { prisma } from './database.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const MIGRATIONS_DIR = join(__dirname, '..', 'migrations', 'tenant');
interface MigrationFile {
version: number;
name: string;
sql: string;
}
/**
* Ensure the schema_migrations table exists in the tenant DB.
*/
async function ensureMigrationsTable(pool: Pool): Promise<void> {
await pool.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name VARCHAR(255) NOT NULL,
applied_at TIMESTAMP DEFAULT NOW()
);
`);
}
/**
* Read all .sql files from the migrations directory, sorted by version.
*/
export async function getMigrationFiles(): Promise<MigrationFile[]> {
let files: string[];
try {
files = await readdir(MIGRATIONS_DIR);
} catch {
console.warn('[Migrations] Migrations directory not found:', MIGRATIONS_DIR);
return [];
}
const sqlFiles = files
.filter(f => f.endsWith('.sql'))
.sort();
const migrations: MigrationFile[] = [];
for (const file of sqlFiles) {
const match = file.match(/^(\d{3})_(.+)\.sql$/);
if (!match) {
console.warn(`[Migrations] Skipping invalid filename: ${file}`);
continue;
}
const version = parseInt(match[1], 10);
const sql = await readFile(join(MIGRATIONS_DIR, file), 'utf-8');
migrations.push({ version, name: file, sql });
}
return migrations;
}
/**
* Get versions already applied in this tenant DB.
*/
async function getAppliedVersions(pool: Pool): Promise<Set<number>> {
const result = await pool.query('SELECT version FROM schema_migrations ORDER BY version');
return new Set(result.rows.map((r: { version: number }) => r.version));
}
/**
* Apply pending migrations to a single tenant database.
* Returns the number of migrations applied.
*/
export async function migrate(pool: Pool, label?: string): Promise<number> {
await ensureMigrationsTable(pool);
const allMigrations = await getMigrationFiles();
if (allMigrations.length === 0) return 0;
const applied = await getAppliedVersions(pool);
const pending = allMigrations.filter(m => !applied.has(m.version));
if (pending.length === 0) return 0;
const tag = label ? ` (${label})` : '';
console.log(`[Migrations]${tag} Applying ${pending.length} pending migration(s)...`);
let count = 0;
for (const migration of pending) {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(migration.sql);
await client.query(
'INSERT INTO schema_migrations (version, name) VALUES ($1, $2)',
[migration.version, migration.name]
);
await client.query('COMMIT');
console.log(`[Migrations]${tag} Applied: ${migration.name}`);
count++;
} catch (error) {
await client.query('ROLLBACK');
console.error(`[Migrations]${tag} FAILED: ${migration.name}`, error);
throw error;
} finally {
client.release();
}
}
return count;
}
/**
* Eager migration: apply pending migrations to ALL active tenant databases.
* Does not stop on individual tenant failure — logs and continues.
*/
export async function migrateAll(): Promise<{ success: number; failed: number; skipped: number }> {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
});
console.log(`[Migrations] Starting eager migration for ${tenants.length} tenant(s)...`);
let success = 0;
let failed = 0;
let skipped = 0;
for (const tenant of tenants) {
const pool = new Pool({
connectionString: process.env.DATABASE_URL?.replace(/\/[^/]+$/, `/${tenant.databaseName}`),
max: 1,
});
try {
const applied = await migrate(pool, tenant.rfc);
if (applied > 0) {
success++;
} else {
skipped++;
}
} catch (error) {
console.error(`[Migrations] Failed for tenant ${tenant.rfc} (${tenant.databaseName}):`, error);
failed++;
} finally {
await pool.end();
}
}
console.log(`[Migrations] Eager migration complete: ${success} migrated, ${skipped} up-to-date, ${failed} failed`);
return { success, failed, skipped };
}
```
- [ ] **Step 2: Commit**
```bash
git add apps/api/src/config/tenant-migrations.ts
git commit -m "feat: add TenantMigrationRunner with migrate() and migrateAll()"
```
---
### Task 3: Integrate lazy migration into TenantConnectionManager
**Files:**
- Modify: `apps/api/src/config/database.ts`
Changes:
1. Add `migratedPools: Set<string>` to the class
2. Import `migrate` from `tenant-migrations.ts`
3. Make `getPool()` async — run `migrate(pool)` on first access per tenant
4. Replace `createTables()` + `createIndexes()` in `provisionDatabase()` with `migrate(pool)`
5. Remove `createTables()` and `createIndexes()` methods
6. Clear `migratedPools` entry in `invalidatePool()`
- [ ] **Step 1: Update database.ts imports**
At the top of `apps/api/src/config/database.ts`, add the import:
```typescript
import { migrate } from './tenant-migrations.js';
```
- [ ] **Step 2: Add migratedPools Set to the class**
In the `TenantConnectionManager` class, after `private dbConfig`:
```typescript
private migratedPools: Set<string> = new Set();
```
- [ ] **Step 3: Make getPool() async with lazy migration**
Replace the current `getPool()` method (lines 53-79) with:
```typescript
/**
* Get or create a connection pool for a tenant's database.
* Runs pending migrations on first access per session.
*/
async getPool(tenantId: string, databaseName: string): Promise<Pool> {
const entry = this.pools.get(tenantId);
let pool: Pool;
if (entry) {
entry.lastAccess = new Date();
pool = entry.pool;
} else {
const poolConfig: PoolConfig = {
host: this.dbConfig.host,
port: this.dbConfig.port,
user: this.dbConfig.user,
password: this.dbConfig.password,
database: databaseName,
max: 3,
idleTimeoutMillis: 300_000,
connectionTimeoutMillis: 10_000,
};
pool = new Pool(poolConfig);
pool.on('error', (err) => {
console.error(`[TenantDB] Pool error for tenant ${tenantId} (${databaseName}):`, err.message);
});
this.pools.set(tenantId, { pool, lastAccess: new Date() });
}
// Lazy migration: run once per tenant per process lifetime
if (!this.migratedPools.has(tenantId)) {
try {
await migrate(pool, databaseName);
this.migratedPools.add(tenantId);
} catch (error) {
console.error(`[TenantDB] Migration failed for ${tenantId} (${databaseName}):`, error);
// Don't block access — tenant can still work with current schema
this.migratedPools.add(tenantId);
}
}
return pool;
}
```
- [ ] **Step 4: Update provisionDatabase() to use migrate()**
Replace the `try` block inside `provisionDatabase()` that calls `createTables` and `createIndexes` (the inner try/finally around line 111-116) with:
```typescript
try {
await migrate(tenantPool, databaseName);
} finally {
await tenantPool.end();
}
```
- [ ] **Step 5: Update invalidatePool() to clear migration cache**
Add `this.migratedPools.delete(tenantId);` to `invalidatePool()`:
```typescript
invalidatePool(tenantId: string): void {
const entry = this.pools.get(tenantId);
if (entry) {
entry.pool.end().catch(() => {});
this.pools.delete(tenantId);
}
this.migratedPools.delete(tenantId);
}
```
- [ ] **Step 6: Remove createTables() and createIndexes() methods**
Delete the `private async createTables(pool: Pool)` method (lines 212-406) and the `private async createIndexes(pool: Pool)` method (lines 408-439) entirely. Their content is now in `001_initial_schema.sql`.
- [ ] **Step 7: Update all callers of getPool() to use await**
Since `getPool()` is now async, every call site must `await` it. The callers are:
In `apps/api/src/middlewares/tenant.middleware.ts`, change lines 75 and 85:
```typescript
// Line 75 — impersonation path
req.tenantPool = await tenantDb.getPool(tenantId, viewedTenant.databaseName);
// Line 85 — normal path
req.tenantPool = await tenantDb.getPool(tenantId, databaseName);
```
- [ ] **Step 8: Commit**
```bash
git add apps/api/src/config/database.ts apps/api/src/middlewares/tenant.middleware.ts
git commit -m "feat: integrate lazy tenant migrations into getPool()"
```
---
### Task 4: Create eager migration CLI script
**Files:**
- Create: `apps/api/scripts/migrate-tenants.ts`
- Modify: `apps/api/package.json`
- Modify: `turbo.json`
- [ ] **Step 1: Create the CLI script**
Create `apps/api/scripts/migrate-tenants.ts`:
```typescript
/**
* Eager tenant migration script.
* Run: pnpm --filter @horux/api db:migrate-tenants
* Or: pnpm db:migrate-tenants (from monorepo root via Turborepo)
*
* Applies pending SQL migrations to all active tenant databases.
*/
import { migrateAll } from '../src/config/tenant-migrations.js';
async function main() {
console.log('=== Tenant Schema Migration (Eager) ===\n');
const start = Date.now();
const result = await migrateAll();
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(`\n=== Done in ${elapsed}s ===`);
console.log(` Migrated: ${result.success}`);
console.log(` Up-to-date: ${result.skipped}`);
console.log(` Failed: ${result.failed}`);
if (result.failed > 0) {
console.error('\n⚠ Some tenants failed migration. Check logs above.');
process.exit(1);
}
process.exit(0);
}
main().catch((err) => {
console.error('Fatal error:', err);
process.exit(1);
});
```
- [ ] **Step 2: Add script to apps/api/package.json**
Add to the `"scripts"` section of `apps/api/package.json`:
```json
"db:migrate-tenants": "tsx scripts/migrate-tenants.ts"
```
- [ ] **Step 3: Add task to turbo.json**
Add to the `"tasks"` section of `turbo.json`:
```json
"db:migrate-tenants": {
"cache": false
}
```
- [ ] **Step 4: Commit**
```bash
git add apps/api/scripts/migrate-tenants.ts apps/api/package.json turbo.json
git commit -m "feat: add eager tenant migration CLI script (pnpm db:migrate-tenants)"
```
---
### Task 5: Update CLAUDE.md and README.md
**Files:**
- Modify: `CLAUDE.md`
- Modify: `README.md`
- [ ] **Step 1: Update CLAUDE.md**
In the "Problemas conocidos / pendientes" section, replace item 1:
```markdown
1. ~~**Schema drift multi-tenant:**~~ Resuelto. Migraciones SQL numeradas en `apps/api/src/migrations/tenant/`. Se aplican eager (`pnpm db:migrate-tenants`) en deploy y lazy (auto en `getPool()`) como safety net. Para agregar un cambio de schema: crear `NNN_description.sql` en el directorio de migraciones.
```
- [ ] **Step 2: Update README.md deploy section**
In README.md, update the deploy instructions to include the new migration step. The deploy flow should reference:
```bash
git pull
pnpm install
pnpm build
pnpm db:migrate-tenants # Apply schema changes to all tenant DBs
pm2 restart all
```
- [ ] **Step 3: Commit**
```bash
git add CLAUDE.md README.md
git commit -m "docs: update CLAUDE.md and README.md with tenant migration system"
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,772 @@
# Plan 2A: Schema + Auth para Horux Despachos — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Evolucionar el schema de BD central y tenant para soportar el modelo de despachos (verticalProfile, dbMode, entidades gestionadas, contribuyentes, carteras), agregar roles de despacho (supervisor, cliente), refactorear el migration runner para tracking por scope, y crear un endpoint de signup básico para despachos.
**Architecture:** Se evoluciona el modelo `Tenant` existente (no se renombra — un despacho ES un tenant con campos adicionales). Se agregan 5 tenant migrations SQL (tracking table + entidades + carteras + contribuyentes + cliente_accesos). El migration runner se extiende para soportar tabla de tracking (`tenant_migrations`). Se agregan tipos nuevos a `@horux/shared`.
**Tech Stack:** Prisma 5.22, PostgreSQL 16, TypeScript 5, Express 4.21, pnpm workspaces.
**Validation:** `pnpm --filter @horux/api typecheck` (57 pre-existing errors baseline — verify no NEW errors). `pnpm --filter @horux/shared typecheck` (0 errors baseline).
**Git:** Commits locales, sin push. Un commit por task.
**Pre-existing codebase context (Plan 2A engineer MUST know):**
- Prisma schema: `apps/api/prisma/schema.prisma` — modelos Tenant, User, TenantMembership, Rol, FielCredential, Subscription, etc.
- Current roles table (Rol): id=1 owner, id=2 contador, id=3 visor, id=7 cfo, id=8 auxiliar.
- Tenant migrations: `apps/api/src/migrations/tenant/001-005.sql` — flat numbered, applied lazily by `TenantConnectionManager.getPool()` via `migrate()` in `config/tenant-migrations.ts`.
- Auth JWT payload: `{ userId, email, role, tenantId, platformRoles?, tokenVersion? }` from `@horux/shared`.
- Config env: `apps/api/src/config/env.ts` — Zod-validated, includes DATABASE_URL, JWT_SECRET, FIEL_ENCRYPTION_KEY.
- Imports in apps/api use `.js` extension (NodeNext module resolution).
---
## File Structure
**New files:**
- `apps/api/prisma/migrations/YYYYMMDD_despacho_fields/migration.sql` — Prisma migration (auto-generated)
- `apps/api/src/migrations/tenant/006_tenant_migrations_tracking.sql` — tracking table for scope-based migrations
- `apps/api/src/migrations/tenant/007_entidades_gestionadas.sql` — core: base entity table
- `apps/api/src/migrations/tenant/008_carteras.sql` — core: portfolios + assignments
- `apps/api/src/migrations/tenant/009_cliente_accesos.sql` — core: client-viewer access
- `apps/api/src/migrations/tenant/010_contribuyentes.sql` — vertical-contable: taxpayer subtype
- `packages/shared/src/types/despacho.ts` — DespachoRole, VerticalProfile, DbMode types
- `apps/api/src/controllers/despacho.controller.ts` — signup endpoint
- `apps/api/src/services/despacho.service.ts` — signup business logic
- `apps/api/src/routes/despacho.routes.ts` — route mounting
**Modified files:**
- `apps/api/prisma/schema.prisma` — add fields to Tenant, add enums
- `apps/api/prisma/seed.ts` — add 'supervisor' and 'cliente' roles
- `apps/api/src/config/tenant-migrations.ts` — support tracking table
- `apps/api/src/app.ts` — mount despacho routes
- `packages/shared/src/types/auth.ts` — add DespachoRole to exports
- `packages/shared/src/index.ts` — re-export despacho types
---
## Tasks
### Task 1: Prisma migration — add despacho fields to Tenant
**Files:**
- Modify: `apps/api/prisma/schema.prisma`
- Create: auto-generated migration via `prisma migrate dev`
- [ ] **Step 1: Add new enums and fields to Prisma schema**
Open `apps/api/prisma/schema.prisma` and add the following:
After the existing `enum Plan { ... }`:
```prisma
enum VerticalProfile {
CONTABLE
JURIDICO
ARQUITECTURA
}
enum DbMode {
BYO
MANAGED
}
```
In the `model Tenant { ... }`, add AFTER the `telefono` field (before the relations block):
```prisma
// === Despacho fields (Plan 2A) ===
verticalProfile VerticalProfile? @map("vertical_profile")
dbMode DbMode? @map("db_mode")
dbConnectionEnc String? @map("db_connection_enc")
dbConnectionIv String? @map("db_connection_iv")
dbSchemaVersion Int @default(0) @map("db_schema_version")
connectorTokenEnc String? @map("connector_token_enc")
connectorTunnelHostname String? @map("connector_tunnel_hostname")
connectorLastSeen DateTime? @map("connector_last_seen")
connectorVersion String? @map("connector_version") @db.VarChar(20)
```
- [ ] **Step 2: Generate and apply Prisma migration**
Run:
```bash
cd apps/api && npx prisma migrate dev --name despacho_fields
```
Expected: migration SQL generated in `prisma/migrations/YYYYMMDD_despacho_fields/`. Since all new fields are nullable or have defaults, this is safe for existing data.
If the command fails because there's no DB connection, create the migration without applying:
```bash
cd apps/api && npx prisma migrate dev --name despacho_fields --create-only
```
- [ ] **Step 3: Generate Prisma client**
Run:
```bash
cd apps/api && npx prisma generate
```
- [ ] **Step 4: Verify typecheck**
Run: `pnpm --filter @horux/api typecheck`
Expected: same 57 pre-existing errors, no new ones.
- [ ] **Step 5: Commit**
```bash
git add apps/api/prisma/
git commit -m "feat(schema): add despacho fields to Tenant model (verticalProfile, dbMode, connector)"
```
---
### Task 2: Seed new roles (supervisor, cliente)
**Files:**
- Modify: `apps/api/prisma/seed.ts`
- [ ] **Step 1: Read current seed.ts to understand the roles seeding pattern**
Open `apps/api/prisma/seed.ts` and find where roles are upserted. The current roles are:
```
id=1: owner, id=2: contador, id=3: visor, id=7: cfo, id=8: auxiliar
```
- [ ] **Step 2: Add supervisor and cliente roles to the seed**
Add to the roles upsert section:
```typescript
await prisma.rol.upsert({
where: { nombre: 'supervisor' },
update: {},
create: { id: 9, nombre: 'supervisor', descripcion: 'Supervisor de despacho — titular de RFCs, crea carteras' },
});
await prisma.rol.upsert({
where: { nombre: 'cliente' },
update: {},
create: { id: 10, nombre: 'cliente', descripcion: 'Cliente visor externo — acceso read-only a sus RFCs' },
});
```
- [ ] **Step 3: Run seed (if DB is available)**
Run:
```bash
cd apps/api && npx prisma db seed
```
If no DB connection, skip — the seed will run at next deploy. The migration and seed are idempotent.
- [ ] **Step 4: Commit**
```bash
git add apps/api/prisma/seed.ts
git commit -m "feat(seed): add supervisor and cliente roles for despachos"
```
---
### Task 3: Add despacho types to @horux/shared
**Files:**
- Create: `packages/shared/src/types/despacho.ts`
- Modify: `packages/shared/src/index.ts` (or wherever types are re-exported)
- [ ] **Step 1: Create despacho types file**
Create `packages/shared/src/types/despacho.ts`:
```typescript
export type DespachoRole = 'owner' | 'supervisor' | 'auxiliar' | 'cliente';
export type VerticalProfile = 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
export type DbMode = 'BYO' | 'MANAGED';
export interface DespachoInfo {
id: string;
nombre: string;
rfc: string;
verticalProfile: VerticalProfile;
dbMode: DbMode | null;
plan: string;
}
export interface DespachoSignupRequest {
despacho: {
nombre: string;
rfc: string;
regimenFiscal?: string;
codigoPostal?: string;
verticalProfile: VerticalProfile;
};
owner: {
nombre: string;
email: string;
password: string;
};
}
export interface ContribuyenteInfo {
id: string;
rfc: string;
razonSocial: string;
regimenFiscal: string;
codigoPostal?: string;
supervisorUserId?: string;
active: boolean;
}
```
- [ ] **Step 2: Find and update the barrel export**
Read `packages/shared/src/index.ts` to see how types are exported. Add:
```typescript
export * from './types/despacho';
```
If the barrel uses a different pattern (e.g., explicit re-exports), follow that pattern.
- [ ] **Step 3: Verify typecheck**
Run: `pnpm --filter @horux/shared typecheck`
Expected: 0 errors.
- [ ] **Step 4: Commit**
```bash
git add packages/shared/
git commit -m "feat(shared): add DespachoRole, VerticalProfile, DbMode types"
```
---
### Task 4: Tenant migration — tracking table + entidades_gestionadas
**Files:**
- Create: `apps/api/src/migrations/tenant/006_tenant_migrations_tracking.sql`
- Create: `apps/api/src/migrations/tenant/007_entidades_gestionadas.sql`
- [ ] **Step 1: Create migration 006 — tracking table**
Create `apps/api/src/migrations/tenant/006_tenant_migrations_tracking.sql`:
```sql
-- Tracking table for scope-based migrations.
-- Allows checking which migrations have been applied and which are pending.
-- For now, all existing migrations (001-005) are considered "legacy" scope
-- and are tracked by the existing file-based runner. This table tracks
-- only NEW migrations going forward (007+).
CREATE TABLE IF NOT EXISTS tenant_migrations (
scope varchar(50) NOT NULL,
version int NOT NULL,
name varchar(255),
applied_at timestamptz DEFAULT now(),
PRIMARY KEY (scope, version)
);
-- Mark 001-005 as already applied under "legacy" scope
-- so the runner doesn't try to re-apply them.
INSERT INTO tenant_migrations (scope, version, name)
VALUES
('legacy', 1, '001_initial_schema'),
('legacy', 2, '002_create_opiniones_cumplimiento'),
('legacy', 3, '003_create_declaraciones_provisionales'),
('legacy', 4, '004_declaraciones_liga_pago_pdf'),
('legacy', 5, '005_create_constancias_situacion_fiscal'),
('legacy', 6, '006_tenant_migrations_tracking')
ON CONFLICT (scope, version) DO NOTHING;
```
- [ ] **Step 2: Create migration 007 — entidades_gestionadas**
Create `apps/api/src/migrations/tenant/007_entidades_gestionadas.sql`:
```sql
-- Core table: base entity managed by the despacho.
-- Subtyped by vertical (e.g., contribuyentes for CONTABLE).
-- Carteras and client access operate on this table (vertical-agnostic).
CREATE TABLE IF NOT EXISTS entidades_gestionadas (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tipo varchar(20) NOT NULL,
nombre text NOT NULL,
identificador text,
supervisor_user_id uuid,
active boolean DEFAULT true,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_entidades_supervisor ON entidades_gestionadas(supervisor_user_id);
CREATE INDEX IF NOT EXISTS ix_entidades_tipo ON entidades_gestionadas(tipo, active);
CREATE INDEX IF NOT EXISTS ix_entidades_identificador ON entidades_gestionadas(identificador);
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('core', 7, '007_entidades_gestionadas')
ON CONFLICT (scope, version) DO NOTHING;
```
- [ ] **Step 3: Verify SQL syntax**
Read both files back to confirm no typos. The SQL should be idempotent (`IF NOT EXISTS`, `ON CONFLICT DO NOTHING`).
- [ ] **Step 4: Commit**
```bash
git add apps/api/src/migrations/tenant/006_tenant_migrations_tracking.sql apps/api/src/migrations/tenant/007_entidades_gestionadas.sql
git commit -m "feat(migrations): add tenant_migrations tracking + entidades_gestionadas table"
```
---
### Task 5: Tenant migrations — carteras + cliente_accesos + contribuyentes
**Files:**
- Create: `apps/api/src/migrations/tenant/008_carteras.sql`
- Create: `apps/api/src/migrations/tenant/009_cliente_accesos.sql`
- Create: `apps/api/src/migrations/tenant/010_contribuyentes.sql`
- [ ] **Step 1: Create migration 008 — carteras**
Create `apps/api/src/migrations/tenant/008_carteras.sql`:
```sql
-- Core: supervisor portfolios. A supervisor groups entities into carteras
-- and assigns auxiliares to them. Cascading: if supervisor loses an entity,
-- it's removed from all their carteras automatically (via JOIN, not trigger).
CREATE TABLE IF NOT EXISTS carteras (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
supervisor_user_id uuid NOT NULL,
nombre text NOT NULL,
descripcion text,
created_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_carteras_supervisor ON carteras(supervisor_user_id);
CREATE TABLE IF NOT EXISTS cartera_entidades (
cartera_id uuid NOT NULL REFERENCES carteras(id) ON DELETE CASCADE,
entidad_id uuid NOT NULL REFERENCES entidades_gestionadas(id) ON DELETE CASCADE,
added_at timestamptz DEFAULT now(),
PRIMARY KEY (cartera_id, entidad_id)
);
CREATE TABLE IF NOT EXISTS cartera_auxiliares (
cartera_id uuid NOT NULL REFERENCES carteras(id) ON DELETE CASCADE,
auxiliar_user_id uuid NOT NULL,
added_at timestamptz DEFAULT now(),
PRIMARY KEY (cartera_id, auxiliar_user_id)
);
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('core', 8, '008_carteras')
ON CONFLICT (scope, version) DO NOTHING;
```
- [ ] **Step 2: Create migration 009 — cliente_accesos**
Create `apps/api/src/migrations/tenant/009_cliente_accesos.sql`:
```sql
-- Core: direct access grants for external client-viewers.
-- A client user can see specific entities (not via carteras).
CREATE TABLE IF NOT EXISTS cliente_accesos (
user_id uuid NOT NULL,
entidad_id uuid NOT NULL REFERENCES entidades_gestionadas(id) ON DELETE CASCADE,
granted_at timestamptz DEFAULT now(),
PRIMARY KEY (user_id, entidad_id)
);
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('core', 9, '009_cliente_accesos')
ON CONFLICT (scope, version) DO NOTHING;
```
- [ ] **Step 3: Create migration 010 — contribuyentes (vertical contable)**
Create `apps/api/src/migrations/tenant/010_contribuyentes.sql`:
```sql
-- Vertical CONTABLE: taxpayer subtype of entidades_gestionadas.
-- Uses single-PK inheritance: contribuyentes.entidad_id = entidades_gestionadas.id.
-- CFDI and other fiscal tables will FK to this table (via entidad_id, not a separate id).
CREATE TABLE IF NOT EXISTS contribuyentes (
entidad_id uuid PRIMARY KEY REFERENCES entidades_gestionadas(id) ON DELETE CASCADE,
rfc varchar(13) NOT NULL UNIQUE,
regimen_fiscal varchar(3),
codigo_postal varchar(5),
domicilio jsonb
);
CREATE INDEX IF NOT EXISTS ix_contribuyentes_rfc ON contribuyentes(rfc);
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 10, '010_contribuyentes')
ON CONFLICT (scope, version) DO NOTHING;
```
- [ ] **Step 4: Commit**
```bash
git add apps/api/src/migrations/tenant/008_carteras.sql apps/api/src/migrations/tenant/009_cliente_accesos.sql apps/api/src/migrations/tenant/010_contribuyentes.sql
git commit -m "feat(migrations): add carteras, cliente_accesos, contribuyentes tables"
```
---
### Task 6: Despacho signup service + controller + route
**Files:**
- Create: `apps/api/src/services/despacho.service.ts`
- Create: `apps/api/src/controllers/despacho.controller.ts`
- Create: `apps/api/src/routes/despacho.routes.ts`
- Modify: `apps/api/src/app.ts` (mount route)
- [ ] **Step 1: Create despacho service**
Create `apps/api/src/services/despacho.service.ts`:
```typescript
import { prisma, tenantDb } from '../config/database.js';
import { hashPassword } from '../auth/passwords.js';
import { generateAccessToken, generateRefreshToken } from '../auth/tokens.js';
import type { DespachoSignupRequest, VerticalProfile } from '@horux/shared';
import type { JWTPayload, Role } from '@horux/shared';
export async function signupDespacho(data: DespachoSignupRequest) {
const { despacho, owner } = data;
// Validate uniqueness
const existingTenant = await prisma.tenant.findUnique({ where: { rfc: despacho.rfc } });
if (existingTenant) {
throw new Error('Ya existe una empresa registrada con este RFC');
}
const existingUser = await prisma.user.findUnique({ where: { email: owner.email } });
if (existingUser) {
throw new Error('Ya existe un usuario con este email');
}
const passwordHash = await hashPassword(owner.password);
// Create tenant + user + membership in transaction
const result = await prisma.$transaction(async (tx) => {
// 1. Create tenant as despacho
const databaseName = `horux_${despacho.rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
const tenant = await tx.tenant.create({
data: {
nombre: despacho.nombre,
rfc: despacho.rfc.toUpperCase(),
plan: 'starter',
databaseName,
cfdiLimit: 0,
usersLimit: 3,
verticalProfile: despacho.verticalProfile as any,
dbMode: 'MANAGED' as any,
dbSchemaVersion: 0,
trialEndsAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
codigoPostal: despacho.codigoPostal,
},
});
// 2. Create user
const user = await tx.user.create({
data: {
email: owner.email.toLowerCase(),
passwordHash,
nombre: owner.nombre,
lastTenantId: tenant.id,
},
});
// 3. Create membership as owner
const ownerRole = await tx.rol.findUnique({ where: { nombre: 'owner' } });
if (!ownerRole) throw new Error('Rol owner no encontrado en BD');
await tx.tenantMembership.create({
data: {
userId: user.id,
tenantId: tenant.id,
rolId: ownerRole.id,
isOwner: true,
},
});
return { tenant, user };
});
// 4. Provision tenant database (outside transaction — creates actual DB)
try {
await tenantDb.provisionDatabase(despacho.rfc);
} catch (err: any) {
// If DB provisioning fails, delete the tenant (rollback)
await prisma.tenant.delete({ where: { id: result.tenant.id } });
await prisma.user.delete({ where: { id: result.user.id } });
throw new Error(`Error al crear base de datos del despacho: ${err.message}`);
}
// 5. Generate JWT pair
const payload: Omit<JWTPayload, 'iat' | 'exp'> = {
userId: result.user.id,
email: result.user.email,
role: 'owner' as Role,
tenantId: result.tenant.id,
tokenVersion: 0,
};
const accessToken = generateAccessToken(payload);
const refreshToken = generateRefreshToken(payload);
// 6. Store refresh token
await prisma.refreshToken.create({
data: {
userId: result.user.id,
token: refreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
},
});
return {
accessToken,
refreshToken,
user: {
id: result.user.id,
email: result.user.email,
nombre: result.user.nombre,
role: 'owner' as Role,
tenantId: result.tenant.id,
tenantName: result.tenant.nombre,
tenantRfc: result.tenant.rfc,
plan: result.tenant.plan,
tenants: [{
id: result.tenant.id,
nombre: result.tenant.nombre,
rfc: result.tenant.rfc,
plan: result.tenant.plan,
role: 'owner' as Role,
isOwner: true,
}],
},
};
}
```
- [ ] **Step 2: Create despacho controller**
Create `apps/api/src/controllers/despacho.controller.ts`:
```typescript
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { signupDespacho } from '../services/despacho.service.js';
import { AppError } from '../middlewares/error.middleware.js';
const signupSchema = z.object({
despacho: z.object({
nombre: z.string().min(2, 'Nombre del despacho requerido'),
rfc: z.string().regex(/^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/i, 'RFC inválido'),
regimenFiscal: z.string().optional(),
codigoPostal: z.string().regex(/^\d{5}$/, 'Código postal inválido').optional(),
verticalProfile: z.enum(['CONTABLE', 'JURIDICO', 'ARQUITECTURA']),
}),
owner: z.object({
nombre: z.string().min(2, 'Nombre del owner requerido'),
email: z.string().email('Email inválido'),
password: z.string().min(10, 'La contraseña debe tener al menos 10 caracteres'),
}),
});
export async function signup(req: Request, res: Response, next: NextFunction) {
try {
const data = signupSchema.parse(req.body);
const result = await signupDespacho(data);
return res.status(201).json(result);
} catch (error: any) {
if (error instanceof z.ZodError) {
return next(new AppError(400, error.errors[0].message));
}
if (error.message?.includes('Ya existe')) {
return next(new AppError(409, error.message));
}
return next(error);
}
}
```
- [ ] **Step 3: Create despacho routes**
Create `apps/api/src/routes/despacho.routes.ts`:
```typescript
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import { signup } from '../controllers/despacho.controller.js';
const router = Router();
const signupLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5,
message: { message: 'Demasiados intentos de registro. Intenta en una hora.' },
});
router.post('/signup', signupLimiter, signup);
export default router;
```
- [ ] **Step 4: Mount route in app.ts**
Open `apps/api/src/app.ts`. Find the block where routes are mounted (look for `app.use('/api/auth'`). Add:
```typescript
import despachoRoutes from './routes/despacho.routes.js';
// ... in the routes section:
app.use('/api/despachos', despachoRoutes);
```
- [ ] **Step 5: Verify typecheck**
Run: `pnpm --filter @horux/api typecheck`
Expected: same pre-existing errors, no new ones. If there are new errors from the despacho files, fix them (likely import paths or type mismatches with Prisma generated types).
Common issue: Prisma might not know about `VerticalProfile` and `DbMode` enums yet if the migration wasn't applied. If typecheck fails on `verticalProfile: despacho.verticalProfile as any`, the `as any` cast handles it. If stricter typing is needed, verify Prisma client was regenerated (Step 3 of Task 1).
- [ ] **Step 6: Commit**
```bash
git add apps/api/src/services/despacho.service.ts apps/api/src/controllers/despacho.controller.ts apps/api/src/routes/despacho.routes.ts apps/api/src/app.ts
git commit -m "feat(api): add POST /api/despachos/signup endpoint"
```
---
### Task 7: Validation + smoke test
**Files:** None (verification only)
- [ ] **Step 1: Verify all packages typecheck**
Run:
```bash
pnpm --filter @horux/shared typecheck
pnpm --filter @horux/core typecheck
pnpm --filter @horux/shared-ui typecheck
pnpm --filter @horux/api typecheck
```
Expected: shared/core/shared-ui = 0 errors. api = same pre-existing errors only.
- [ ] **Step 2: Verify migration files exist and are numbered correctly**
Run:
```bash
ls -la apps/api/src/migrations/tenant/
```
Expected: files 001-010 in order. Verify 006-010 are our new ones.
- [ ] **Step 3: Verify Prisma schema has new fields**
Run:
```bash
grep -n "verticalProfile\|dbMode\|dbSchemaVersion\|connectorTokenEnc" apps/api/prisma/schema.prisma
```
Expected: all 4 fields present.
- [ ] **Step 4: Verify commit history**
Run:
```bash
git log --oneline -10
```
Expected: 6 new commits from this plan on top of the Plan 1 refactor commits.
- [ ] **Step 5: Start dev server and test signup endpoint (MANUAL)**
Run: `pnpm dev`
Test with curl (or user in browser):
```bash
curl -X POST http://localhost:4000/api/despachos/signup \
-H "Content-Type: application/json" \
-d '{
"despacho": {
"nombre": "Despacho Test",
"rfc": "DTE250101AAA",
"verticalProfile": "CONTABLE"
},
"owner": {
"nombre": "Test Owner",
"email": "test@despacho.com",
"password": "testpassword123"
}
}'
```
Expected: 201 with `{ accessToken, refreshToken, user: { ... } }`.
If no DB connection, this step is deferred. The typecheck validation is sufficient for the plan.
- [ ] **Step 6: Final commit if any fixes were needed**
```bash
git add -A && git status
# Only commit if there are changes
git commit -m "fix: Plan 2A validation fixes" || true
```
---
## Self-Review
### Spec coverage (vs spec §3-§5, §11, §15-Phase1)
| Spec requirement | Task | Status |
|------------------|------|--------|
| Tenant evolves to support despacho (verticalProfile, dbMode, connector fields) | Task 1 | ✅ |
| New roles: supervisor, cliente | Task 2 | ✅ |
| Shared types: DespachoRole, VerticalProfile, DbMode, DespachoSignupRequest, ContribuyenteInfo | Task 3 | ✅ |
| Tenant migration: tenant_migrations tracking table | Task 4 | ✅ |
| Tenant migration: entidades_gestionadas (core) | Task 4 | ✅ |
| Tenant migration: carteras + cartera_entidades + cartera_auxiliares (core) | Task 5 | ✅ |
| Tenant migration: cliente_accesos (core) | Task 5 | ✅ |
| Tenant migration: contribuyentes (vertical-contable, single-PK inheritance) | Task 5 | ✅ |
| Signup endpoint: POST /despachos/signup | Task 6 | ✅ |
| Trial 30 days | Task 6 (trialEndsAt) | ✅ |
| Managed DB provisioned at signup | Task 6 (provisionDatabase) | ✅ |
| JWT + refresh token on signup | Task 6 | ✅ |
**Deferred to Plan 2B:**
- CRUD contribuyentes endpoints (add/update/delete RFC within despacho)
- FIEL/CSD assignment to contribuyente (not tenant)
- CFDI emission with contribuyente_id FK
- Metrics tables (metricas_mensuales etc.)
- Magic link auth flow
**Deferred to Plan 2C:**
- Frontend signup page
- Dashboard adapted for despacho
- Contribuyente selector UI
- Onboarding wizard
### Placeholder scan
- No "TBD", "TODO", "implement later" found.
- All code blocks contain complete, copy-paste-ready code.
- Types referenced (DespachoSignupRequest, JWTPayload, Role, etc.) are all defined in tasks.
### Type consistency
- `DespachoRole` = `'owner' | 'supervisor' | 'auxiliar' | 'cliente'` — consistent with spec §5.
- `VerticalProfile` = `'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA'` — matches Prisma enum.
- `DbMode` = `'BYO' | 'MANAGED'` — matches Prisma enum.
- `signupDespacho()` accepts `DespachoSignupRequest` and returns `LoginResponse`-compatible shape.
- SQL migrations use `gen_random_uuid()`, `timestamptz`, `varchar` — consistent with existing migrations.
- `tenant_migrations` table uses `(scope, version)` PK — matches spec §12.

View File

@@ -0,0 +1,577 @@
# Plan 2B: CRUD Contribuyentes + FIEL/CSD per Contribuyente + CFDI con contribuyente_id
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Un owner de despacho puede agregar contribuyentes (RFCs), subir FIEL/CSD por contribuyente, y emitir CFDIs asociados a un contribuyente específico.
**Architecture:** Se agregan 3 tenant migrations (FIEL per contribuyente, Facturapi org per contribuyente, contribuyente_id en cfdis). Se crea un CRUD completo para contribuyentes. Se refactorean las funciones de FIEL y Facturapi para resolver por contribuyente_id (en BD tenant) en vez de por tenantId (en BD central). FIEL para despachos vive en BD tenant (soberanía de datos), no en BD central.
**Tech Stack:** PostgreSQL 16, Express 4.21, TypeScript 5, Prisma 5.22, pg Pool (raw SQL), Zod.
**Validation:** `pnpm --filter @horux/api typecheck` — no NEW errors vs baseline (~57 pre-existing).
**Git:** Commits locales, un commit por task.
**Prerequisite:** Plan 2A completado (tenant migrations 006-010 existen, signup endpoint funcional).
---
## File Structure
**New files:**
- `apps/api/src/migrations/tenant/011_fiel_per_contribuyente.sql`
- `apps/api/src/migrations/tenant/012_facturapi_per_contribuyente.sql`
- `apps/api/src/migrations/tenant/013_cfdi_contribuyente_id.sql`
- `apps/api/src/services/contribuyente.service.ts`
- `apps/api/src/controllers/contribuyente.controller.ts`
- `apps/api/src/routes/contribuyente.routes.ts`
**Modified files:**
- `apps/api/src/app.ts` (mount new routes)
- `apps/api/src/services/cfdi.service.ts` (add contribuyente_id to createCfdi + getCfdis filter)
- `apps/api/src/controllers/facturacion.controller.ts` (emitir accepts contribuyenteId)
---
## Tasks
### Task 1: Tenant migrations — FIEL, Facturapi orgs, CFDI contribuyente_id
**Files:**
- Create: `apps/api/src/migrations/tenant/011_fiel_per_contribuyente.sql`
- Create: `apps/api/src/migrations/tenant/012_facturapi_per_contribuyente.sql`
- Create: `apps/api/src/migrations/tenant/013_cfdi_contribuyente_id.sql`
- [ ] **Step 1: Create migration 011 — FIEL per contribuyente (in tenant BD)**
Create `apps/api/src/migrations/tenant/011_fiel_per_contribuyente.sql`:
```sql
-- FIEL credentials stored per contribuyente in the despacho's own database.
-- This keeps FIEL data sovereign (in the despacho's BD, not central).
-- The central FielCredential table continues to work for Horux360 classic tenants.
CREATE TABLE IF NOT EXISTS fiel_contribuyente (
contribuyente_id uuid PRIMARY KEY REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE,
rfc varchar(13) NOT NULL,
cer_data bytea NOT NULL,
key_data bytea NOT NULL,
key_password_enc bytea NOT NULL,
cer_iv bytea NOT NULL,
cer_tag bytea NOT NULL,
key_iv bytea NOT NULL,
key_tag bytea NOT NULL,
password_iv bytea NOT NULL,
password_tag bytea NOT NULL,
serial_number varchar(50),
valid_from timestamptz NOT NULL,
valid_until timestamptz NOT NULL,
is_active boolean DEFAULT true,
uploaded_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 11, '011_fiel_per_contribuyente')
ON CONFLICT (scope, version) DO NOTHING;
```
- [ ] **Step 2: Create migration 012 — Facturapi orgs per contribuyente**
Create `apps/api/src/migrations/tenant/012_facturapi_per_contribuyente.sql`:
```sql
-- Maps each contribuyente to a Facturapi organization within Horux's master account.
-- Each contribuyente gets its own org (with its own CSD, logo, series).
CREATE TABLE IF NOT EXISTS facturapi_orgs (
contribuyente_id uuid PRIMARY KEY REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE,
facturapi_org_id text NOT NULL UNIQUE,
csd_uploaded boolean DEFAULT false,
active boolean DEFAULT true,
created_at timestamptz DEFAULT now()
);
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 12, '012_facturapi_per_contribuyente')
ON CONFLICT (scope, version) DO NOTHING;
```
- [ ] **Step 3: Create migration 013 — add contribuyente_id to cfdis**
Create `apps/api/src/migrations/tenant/013_cfdi_contribuyente_id.sql`:
```sql
-- Add contribuyente_id to cfdis table.
-- Nullable for backward compat: existing CFDIs (Horux360 classic) don't have one.
-- New CFDIs from despachos will always have it set.
ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS contribuyente_id uuid REFERENCES contribuyentes(entidad_id);
CREATE INDEX IF NOT EXISTS ix_cfdi_contribuyente ON cfdis(contribuyente_id) WHERE contribuyente_id IS NOT NULL;
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 13, '013_cfdi_contribuyente_id')
ON CONFLICT (scope, version) DO NOTHING;
```
- [ ] **Step 4: Commit**
```bash
git add apps/api/src/migrations/tenant/011_fiel_per_contribuyente.sql apps/api/src/migrations/tenant/012_facturapi_per_contribuyente.sql apps/api/src/migrations/tenant/013_cfdi_contribuyente_id.sql
git commit -m "feat(migrations): add fiel_contribuyente, facturapi_orgs, cfdi contribuyente_id"
```
---
### Task 2: CRUD Contribuyentes — service + controller + routes
**Files:**
- Create: `apps/api/src/services/contribuyente.service.ts`
- Create: `apps/api/src/controllers/contribuyente.controller.ts`
- Create: `apps/api/src/routes/contribuyente.routes.ts`
- Modify: `apps/api/src/app.ts`
- [ ] **Step 1: Create contribuyente service**
Create `apps/api/src/services/contribuyente.service.ts`:
```typescript
import type { Pool } from 'pg';
export interface CreateContribuyenteData {
rfc: string;
razonSocial: string;
regimenFiscal?: string;
codigoPostal?: string;
domicilio?: Record<string, unknown>;
supervisorUserId?: string;
}
export interface ContribuyenteRow {
id: string;
tipo: string;
nombre: string;
identificador: string;
supervisorUserId: string | null;
active: boolean;
createdAt: string;
rfc: string;
regimenFiscal: string | null;
codigoPostal: string | null;
domicilio: Record<string, unknown> | null;
}
export async function listContribuyentes(pool: Pool): Promise<ContribuyenteRow[]> {
const { rows } = await pool.query(`
SELECT
e.id,
e.tipo,
e.nombre,
e.identificador,
e.supervisor_user_id AS "supervisorUserId",
e.active,
e.created_at AS "createdAt",
c.rfc,
c.regimen_fiscal AS "regimenFiscal",
c.codigo_postal AS "codigoPostal",
c.domicilio
FROM entidades_gestionadas e
JOIN contribuyentes c ON c.entidad_id = e.id
WHERE e.active = true
ORDER BY e.created_at DESC
`);
return rows;
}
export async function getContribuyenteById(pool: Pool, id: string): Promise<ContribuyenteRow | null> {
const { rows } = await pool.query(`
SELECT
e.id,
e.tipo,
e.nombre,
e.identificador,
e.supervisor_user_id AS "supervisorUserId",
e.active,
e.created_at AS "createdAt",
c.rfc,
c.regimen_fiscal AS "regimenFiscal",
c.codigo_postal AS "codigoPostal",
c.domicilio
FROM entidades_gestionadas e
JOIN contribuyentes c ON c.entidad_id = e.id
WHERE e.id = $1
`, [id]);
return rows[0] ?? null;
}
export async function createContribuyente(pool: Pool, data: CreateContribuyenteData): Promise<ContribuyenteRow> {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows: [entidad] } = await client.query(`
INSERT INTO entidades_gestionadas (tipo, nombre, identificador, supervisor_user_id)
VALUES ('CONTRIBUYENTE', $1, $2, $3)
RETURNING id
`, [data.razonSocial, data.rfc.toUpperCase(), data.supervisorUserId ?? null]);
await client.query(`
INSERT INTO contribuyentes (entidad_id, rfc, regimen_fiscal, codigo_postal, domicilio)
VALUES ($1, $2, $3, $4, $5)
`, [entidad.id, data.rfc.toUpperCase(), data.regimenFiscal ?? null, data.codigoPostal ?? null, data.domicilio ? JSON.stringify(data.domicilio) : null]);
await client.query('COMMIT');
return (await getContribuyenteById(pool, entidad.id))!;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
export async function updateContribuyente(pool: Pool, id: string, data: Partial<CreateContribuyenteData>): Promise<ContribuyenteRow | null> {
const existing = await getContribuyenteById(pool, id);
if (!existing) return null;
const client = await pool.connect();
try {
await client.query('BEGIN');
if (data.razonSocial || data.supervisorUserId !== undefined) {
const sets: string[] = [];
const vals: unknown[] = [];
let idx = 1;
if (data.razonSocial) {
sets.push(`nombre = $${idx}`, `identificador = $${idx}`);
vals.push(data.razonSocial);
idx++;
}
if (data.supervisorUserId !== undefined) {
sets.push(`supervisor_user_id = $${idx}`);
vals.push(data.supervisorUserId);
idx++;
}
sets.push('updated_at = now()');
vals.push(id);
await client.query(`UPDATE entidades_gestionadas SET ${sets.join(', ')} WHERE id = $${idx}`, vals);
}
if (data.regimenFiscal !== undefined || data.codigoPostal !== undefined || data.domicilio !== undefined) {
const sets: string[] = [];
const vals: unknown[] = [];
let idx = 1;
if (data.regimenFiscal !== undefined) { sets.push(`regimen_fiscal = $${idx}`); vals.push(data.regimenFiscal); idx++; }
if (data.codigoPostal !== undefined) { sets.push(`codigo_postal = $${idx}`); vals.push(data.codigoPostal); idx++; }
if (data.domicilio !== undefined) { sets.push(`domicilio = $${idx}`); vals.push(JSON.stringify(data.domicilio)); idx++; }
vals.push(id);
await client.query(`UPDATE contribuyentes SET ${sets.join(', ')} WHERE entidad_id = $${idx}`, vals);
}
await client.query('COMMIT');
return (await getContribuyenteById(pool, id))!;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
export async function deactivateContribuyente(pool: Pool, id: string): Promise<boolean> {
const { rowCount } = await pool.query(`
UPDATE entidades_gestionadas SET active = false, updated_at = now() WHERE id = $1
`, [id]);
return (rowCount ?? 0) > 0;
}
```
- [ ] **Step 2: Create contribuyente controller**
Create `apps/api/src/controllers/contribuyente.controller.ts`:
```typescript
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as contribuyenteService from '../services/contribuyente.service.js';
import { AppError } from '../middlewares/error.middleware.js';
const createSchema = z.object({
rfc: z.string().regex(/^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/i, 'RFC inválido'),
razonSocial: z.string().min(2, 'Razón social requerida'),
regimenFiscal: z.string().length(3).optional(),
codigoPostal: z.string().regex(/^\d{5}$/).optional(),
domicilio: z.record(z.unknown()).optional(),
supervisorUserId: z.string().uuid().optional(),
});
const updateSchema = createSchema.partial();
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const rows = await contribuyenteService.listContribuyentes(req.tenantPool!);
return res.json({ data: rows });
} catch (err) { return next(err); }
}
export async function getById(req: Request, res: Response, next: NextFunction) {
try {
const row = await contribuyenteService.getContribuyenteById(req.tenantPool!, String(req.params.id));
if (!row) return next(new AppError(404, 'Contribuyente no encontrado'));
return res.json(row);
} catch (err) { return next(err); }
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const data = createSchema.parse(req.body);
const row = await contribuyenteService.createContribuyente(req.tenantPool!, data);
return res.status(201).json(row);
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
if (err.code === '23505') return next(new AppError(409, 'Ya existe un contribuyente con este RFC'));
return next(err);
}
}
export async function update(req: Request, res: Response, next: NextFunction) {
try {
const data = updateSchema.parse(req.body);
const row = await contribuyenteService.updateContribuyente(req.tenantPool!, String(req.params.id), data);
if (!row) return next(new AppError(404, 'Contribuyente no encontrado'));
return res.json(row);
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
return next(err);
}
}
export async function deactivate(req: Request, res: Response, next: NextFunction) {
try {
const ok = await contribuyenteService.deactivateContribuyente(req.tenantPool!, String(req.params.id));
if (!ok) return next(new AppError(404, 'Contribuyente no encontrado'));
return res.json({ message: 'Contribuyente desactivado' });
} catch (err) { return next(err); }
}
```
- [ ] **Step 3: Create contribuyente routes**
Create `apps/api/src/routes/contribuyente.routes.ts`:
```typescript
import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import { authorize } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as ctrl from '../controllers/contribuyente.controller.js';
const router: IRouter = Router();
router.use(authenticate);
router.use(tenantMiddleware);
router.get('/', ctrl.list);
router.get('/:id', ctrl.getById);
router.post('/', authorize('owner', 'supervisor'), ctrl.create);
router.put('/:id', authorize('owner', 'supervisor'), ctrl.update);
router.delete('/:id', authorize('owner'), ctrl.deactivate);
export default router;
```
- [ ] **Step 4: Mount routes in app.ts**
Open `apps/api/src/app.ts`. Add import:
```typescript
import contribuyenteRoutes from './routes/contribuyente.routes.js';
```
Add route mount (before error middleware):
```typescript
app.use('/api/contribuyentes', contribuyenteRoutes);
```
- [ ] **Step 5: Verify typecheck**
Run: `pnpm --filter @horux/api typecheck`
Expected: no new errors.
- [ ] **Step 6: Commit**
```bash
git add apps/api/src/services/contribuyente.service.ts apps/api/src/controllers/contribuyente.controller.ts apps/api/src/routes/contribuyente.routes.ts apps/api/src/app.ts
git commit -m "feat(api): add CRUD endpoints for contribuyentes"
```
---
### Task 3: Add contribuyente_id to CFDI insert + list filter
**Files:**
- Modify: `apps/api/src/services/cfdi.service.ts`
- [ ] **Step 1: Add `contribuyente_id` to CFDI_SELECT constant**
Open `apps/api/src/services/cfdi.service.ts`. Find the `CFDI_SELECT` constant (starts around line 5). At the END of the select list (before the closing backtick), add:
```sql
contribuyente_id AS "contribuyenteId"
```
Make sure to add a comma after the previous field.
- [ ] **Step 2: Add `contribuyente_id` to the INSERT in `createCfdi()`**
Find the `createCfdi()` function. In the INSERT INTO cfdis statement, add `contribuyente_id` to the column list and a corresponding `$N` placeholder. Also add `contribuyenteId` to the `CreateCfdiData` interface if it exists, or pass it as parameter.
At the top of `createCfdi()`, the function receives `data: CreateCfdiData`. Check if `CreateCfdiData` is an interface in the same file. Add:
```typescript
contribuyenteId?: string;
```
In the INSERT query, add `contribuyente_id` column and `data.contribuyenteId ?? null` as value.
- [ ] **Step 3: Add optional `contribuyenteId` filter to `getCfdis()`**
In `getCfdis()`, the function builds a WHERE clause dynamically. Add a filter:
```typescript
// Add to the CfdiFilters interface (or wherever filters are defined):
contribuyenteId?: string;
// In the WHERE clause building section:
if (filters.contribuyenteId) {
conditions.push(`contribuyente_id = $${paramIndex}`);
params.push(filters.contribuyenteId);
paramIndex++;
}
```
- [ ] **Step 4: Verify typecheck**
Run: `pnpm --filter @horux/api typecheck`
- [ ] **Step 5: Commit**
```bash
git add apps/api/src/services/cfdi.service.ts
git commit -m "feat(cfdi): add contribuyente_id to CFDI insert and list filter"
```
---
### Task 4: Modify emitir endpoint to accept contribuyenteId
**Files:**
- Modify: `apps/api/src/controllers/facturacion.controller.ts`
- [ ] **Step 1: Update the `emitir()` function**
Open `apps/api/src/controllers/facturacion.controller.ts`. Find the `emitir()` function.
Add `contribuyenteId` extraction from request body at the beginning:
```typescript
const contribuyenteId = req.body.contribuyenteId as string | undefined;
```
After the CFDI is created in the DB (the INSERT INTO cfdis section), ensure `contribuyente_id` is included. Find the line that does the INSERT into cfdis and add `contribuyenteId` to the data passed to `createCfdi()` (or directly in the INSERT):
```typescript
// When calling createCfdi or building the insert data:
// Add: contribuyenteId: contribuyenteId ?? null
```
The exact modification depends on how `emitir()` builds the CFDI data. Read the function and add `contribuyenteId` to the object passed to the INSERT.
- [ ] **Step 2: Verify typecheck**
Run: `pnpm --filter @horux/api typecheck`
- [ ] **Step 3: Commit**
```bash
git add apps/api/src/controllers/facturacion.controller.ts
git commit -m "feat(facturacion): emitir endpoint accepts contribuyenteId"
```
---
### Task 5: Validation
**Files:** None (verification only)
- [ ] **Step 1: Verify all migrations exist**
```bash
ls -la apps/api/src/migrations/tenant/
```
Expected: 13 files (001-013).
- [ ] **Step 2: Typecheck**
```bash
pnpm --filter @horux/shared typecheck
pnpm --filter @horux/core typecheck
pnpm --filter @horux/api typecheck
```
- [ ] **Step 3: Verify commit history**
```bash
git log --oneline -8
```
Expected: 4 new commits from this plan.
- [ ] **Step 4: Test endpoints (MANUAL — requires DB)**
Start server: `pnpm dev`
Test CRUD contribuyentes:
```bash
# Login first to get token
TOKEN="..."
# Create contribuyente
curl -X POST http://localhost:4000/api/contribuyentes \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"rfc":"ABC010203XY1","razonSocial":"Test SA de CV","regimenFiscal":"601"}'
# List contribuyentes
curl http://localhost:4000/api/contribuyentes \
-H "Authorization: Bearer $TOKEN"
```
---
## Self-Review
### Spec coverage (vs spec §4.2, §7)
| Requirement | Task | Status |
|------------|------|--------|
| `fiel_contribuyente` table in tenant BD | Task 1 | ✅ |
| `facturapi_orgs` table in tenant BD | Task 1 | ✅ |
| `contribuyente_id` column in cfdis | Task 1 | ✅ |
| CRUD contribuyentes (POST/GET/PUT/DELETE) | Task 2 | ✅ |
| CFDI insert with contribuyente_id | Task 3 | ✅ |
| CFDI list filter by contribuyente_id | Task 3 | ✅ |
| Emitir endpoint accepts contribuyenteId | Task 4 | ✅ |
### Deferred to Plan 2B-2 (service refactoring)
These require deeper refactoring of existing services:
- **FIEL upload per contribuyente** — requires new `uploadFielContribuyente()` function that writes to tenant BD instead of central. Currently `fiel.service.ts` uses Prisma (central BD). The new function would use `pool.query()` (tenant BD).
- **Facturapi org creation per contribuyente** — `createOrganization()` currently writes `facturapiOrgId` to `Tenant`. Needs to write to `facturapi_orgs` in tenant BD.
- **getOrgClient() per contribuyente** — resolves org from `facturapi_orgs` table instead of `Tenant.facturapiOrgId`.
- **SAT sync per contribuyente** — resolves FIEL from `fiel_contribuyente` table.
### Type consistency
- `ContribuyenteRow` interface used consistently in service/controller.
- `CreateContribuyenteData` matches Zod schema in controller.
- `contribuyenteId` field name consistent across CFDI and facturacion changes.

View File

@@ -0,0 +1,29 @@
# Plan 2B-2: FIEL + Facturapi per Contribuyente — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
**Goal:** FIEL y Facturapi se resuelven por contribuyente (tabla tenant BD) en vez de por tenant (BD central). Los servicios existentes NO se modifican (siguen para Horux360 classic); se crean servicios NUEVOS paralelos para el flujo despachos.
**Architecture:** Nuevos servicios `contribuyente-fiel.service.ts` y `contribuyente-facturapi.service.ts` que operan sobre tablas `fiel_contribuyente` y `facturapi_orgs` en la BD tenant (via pool.query, no Prisma). Nuevos endpoints bajo `/api/contribuyentes/:id/fiel` y `/api/contribuyentes/:id/facturapi`. El endpoint `emitir()` se adapta para resolver org desde `facturapi_orgs` cuando se pasa `contribuyenteId`.
---
## Tasks
### Task 1: Contribuyente FIEL service
Create `apps/api/src/services/contribuyente-fiel.service.ts` — funciones que operan sobre tabla `fiel_contribuyente` en BD tenant.
### Task 2: Contribuyente Facturapi service
Create `apps/api/src/services/contribuyente-facturapi.service.ts` — funciones que operan sobre tabla `facturapi_orgs` en BD tenant.
### Task 3: Controller + routes
Create controller + routes para exponer FIEL upload/status y Facturapi org/CSD per contribuyente.
### Task 4: Wire emitir() para resolver org por contribuyente
Modify `emitir()` en facturacion.controller.ts para que si `contribuyenteId` está presente, resuelva la org Facturapi desde `facturapi_orgs` en vez de `Tenant.facturapiOrgId`.
### Task 5: Validation

View File

@@ -0,0 +1,789 @@
# Plan 2C: Frontend Despachos — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Un owner de despacho puede registrarse desde el frontend, gestionar contribuyentes (agregar/editar/desactivar RFCs), y seleccionar qué contribuyente está operando para filtrar CFDIs.
**Architecture:** Se crean API client functions + React Query hooks para el endpoint /api/contribuyentes. Se crea una nueva página de signup para despachos que llama a POST /api/despachos/signup. Se crea un selector de contribuyente (dropdown) persistido en Zustand store. La lista de CFDIs se filtra por el contribuyente seleccionado.
**Tech Stack:** Next.js 14 App Router, React 18, Zustand, React Query, Tailwind, @horux/shared-ui, Zod (client-side).
**Validation:** `pnpm --filter @horux/web typecheck` — no NEW errors vs baseline. Visual smoke test en browser.
---
## Tasks
### Task 1: API client + hooks para contribuyentes
**Files:**
- Create: `apps/web/lib/api/contribuyentes.ts`
- Create: `apps/web/lib/hooks/use-contribuyentes.ts`
- [ ] **Step 1: Create API client functions**
Create `apps/web/lib/api/contribuyentes.ts`:
```typescript
import apiClient from './client';
export interface Contribuyente {
id: string;
tipo: string;
nombre: string;
identificador: string;
supervisorUserId: string | null;
active: boolean;
createdAt: string;
rfc: string;
regimenFiscal: string | null;
codigoPostal: string | null;
domicilio: Record<string, unknown> | null;
}
export interface CreateContribuyenteData {
rfc: string;
razonSocial: string;
regimenFiscal?: string;
codigoPostal?: string;
}
export async function getContribuyentes(): Promise<{ data: Contribuyente[] }> {
const { data } = await apiClient.get('/contribuyentes');
return data;
}
export async function getContribuyente(id: string): Promise<Contribuyente> {
const { data } = await apiClient.get(`/contribuyentes/${id}`);
return data;
}
export async function createContribuyente(payload: CreateContribuyenteData): Promise<Contribuyente> {
const { data } = await apiClient.post('/contribuyentes', payload);
return data;
}
export async function updateContribuyente(id: string, payload: Partial<CreateContribuyenteData>): Promise<Contribuyente> {
const { data } = await apiClient.put(`/contribuyentes/${id}`, payload);
return data;
}
export async function deactivateContribuyente(id: string): Promise<void> {
await apiClient.delete(`/contribuyentes/${id}`);
}
```
- [ ] **Step 2: Create React Query hooks**
Create `apps/web/lib/hooks/use-contribuyentes.ts`:
```typescript
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '@/stores/auth-store';
import * as api from '@/lib/api/contribuyentes';
export function useContribuyentes() {
const user = useAuthStore((s) => s.user);
return useQuery({
queryKey: ['contribuyentes', user?.tenantId],
queryFn: () => api.getContribuyentes().then((r) => r.data),
enabled: !!user,
});
}
export function useContribuyente(id: string | null) {
const user = useAuthStore((s) => s.user);
return useQuery({
queryKey: ['contribuyente', id, user?.tenantId],
queryFn: () => api.getContribuyente(id!),
enabled: !!user && !!id,
});
}
export function useCreateContribuyente() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.createContribuyente,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contribuyentes'] });
},
});
}
export function useUpdateContribuyente() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<api.CreateContribuyenteData> }) =>
api.updateContribuyente(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contribuyentes'] });
},
});
}
export function useDeactivateContribuyente() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.deactivateContribuyente,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contribuyentes'] });
},
});
}
```
- [ ] **Step 3: Verify typecheck**
Run: `pnpm --filter @horux/web typecheck`
- [ ] **Step 4: Commit**
```bash
git add apps/web/lib/api/contribuyentes.ts apps/web/lib/hooks/use-contribuyentes.ts
git commit -m "feat(web): add API client + React Query hooks for contribuyentes"
```
---
### Task 2: Signup page para despachos
**Files:**
- Create: `apps/web/app/(auth)/register-despacho/page.tsx`
- [ ] **Step 1: Create the despacho signup page**
Create `apps/web/app/(auth)/register-despacho/page.tsx`:
```tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Button, Input, Label, Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import apiClient from '@/lib/api/client';
export default function RegisterDespachoPage() {
const router = useRouter();
const { setUser, setTokens } = useAuthStore();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState({
despachoNombre: '',
despachoRfc: '',
codigoPostal: '',
ownerNombre: '',
ownerEmail: '',
ownerPassword: '',
acceptedTerms: false,
});
const handleChange = (field: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
setForm((prev) => ({ ...prev, [field]: e.target.value }));
setError('');
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.acceptedTerms) {
setError('Debes aceptar los términos y condiciones');
return;
}
setLoading(true);
setError('');
try {
const { data } = await apiClient.post('/despachos/signup', {
despacho: {
nombre: form.despachoNombre,
rfc: form.despachoRfc,
codigoPostal: form.codigoPostal || undefined,
verticalProfile: 'CONTABLE',
},
owner: {
nombre: form.ownerNombre,
email: form.ownerEmail,
password: form.ownerPassword,
},
});
setTokens(data.accessToken, data.refreshToken);
setUser(data.user);
router.push('/dashboard');
} catch (err: any) {
const msg = err.response?.data?.message || 'Error al registrar el despacho';
setError(msg);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-purple-50 dark:from-gray-900 dark:to-gray-800 p-4">
<Card className="w-full max-w-lg">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">Registra tu Despacho</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
30 días de prueba gratis. Sin tarjeta de crédito.
</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Datos del despacho */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Datos del despacho
</h3>
<div>
<Label htmlFor="despachoNombre">Razón social</Label>
<Input
id="despachoNombre"
value={form.despachoNombre}
onChange={handleChange('despachoNombre')}
placeholder="Despacho Contable SA de CV"
required
/>
</div>
<div>
<Label htmlFor="despachoRfc">RFC del despacho</Label>
<Input
id="despachoRfc"
value={form.despachoRfc}
onChange={handleChange('despachoRfc')}
placeholder="DCO010203XY1"
maxLength={13}
required
/>
</div>
<div>
<Label htmlFor="codigoPostal">Código postal</Label>
<Input
id="codigoPostal"
value={form.codigoPostal}
onChange={handleChange('codigoPostal')}
placeholder="06600"
maxLength={5}
/>
</div>
</div>
{/* Datos del owner */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Tu cuenta (dueño)
</h3>
<div>
<Label htmlFor="ownerNombre">Nombre completo</Label>
<Input
id="ownerNombre"
value={form.ownerNombre}
onChange={handleChange('ownerNombre')}
placeholder="Juan Pérez"
required
/>
</div>
<div>
<Label htmlFor="ownerEmail">Email</Label>
<Input
id="ownerEmail"
type="email"
value={form.ownerEmail}
onChange={handleChange('ownerEmail')}
placeholder="juan@despacho.com"
required
/>
</div>
<div>
<Label htmlFor="ownerPassword">Contraseña</Label>
<Input
id="ownerPassword"
type="password"
value={form.ownerPassword}
onChange={handleChange('ownerPassword')}
placeholder="Mínimo 10 caracteres"
minLength={10}
required
/>
</div>
</div>
{/* Terms */}
<div className="flex items-start gap-2">
<input
type="checkbox"
id="terms"
checked={form.acceptedTerms}
onChange={(e) => setForm((prev) => ({ ...prev, acceptedTerms: e.target.checked }))}
className="mt-1"
/>
<label htmlFor="terms" className="text-sm text-muted-foreground">
Acepto los{' '}
<Link href="/terminos" target="_blank" className="underline text-primary">
términos y condiciones
</Link>
</label>
</div>
{error && (
<p className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">{error}</p>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Registrando...' : 'Crear despacho'}
</Button>
<p className="text-center text-sm text-muted-foreground">
¿Ya tienes cuenta?{' '}
<Link href="/login" className="text-primary underline">
Inicia sesión
</Link>
</p>
</form>
</CardContent>
</Card>
</div>
);
}
```
- [ ] **Step 2: Verify typecheck + visual check**
Run: `pnpm --filter @horux/web typecheck`
Then open `http://localhost:3000/register-despacho` in browser to verify the form renders.
- [ ] **Step 3: Commit**
```bash
git add apps/web/app/\(auth\)/register-despacho/page.tsx
git commit -m "feat(web): add despacho signup page at /register-despacho"
```
---
### Task 3: Contribuyente selector store + component
**Files:**
- Create: `apps/web/stores/contribuyente-store.ts`
- Create: `apps/web/components/contribuyente-selector.tsx`
- [ ] **Step 1: Create Zustand store for selected contribuyente**
Create `apps/web/stores/contribuyente-store.ts`:
```typescript
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface ContribuyenteState {
selectedContribuyenteId: string | null;
selectedContribuyenteRfc: string | null;
selectedContribuyenteNombre: string | null;
setSelectedContribuyente: (id: string, rfc: string, nombre: string) => void;
clearSelectedContribuyente: () => void;
}
export const useContribuyenteStore = create<ContribuyenteState>()(
persist(
(set) => ({
selectedContribuyenteId: null,
selectedContribuyenteRfc: null,
selectedContribuyenteNombre: null,
setSelectedContribuyente: (id, rfc, nombre) =>
set({ selectedContribuyenteId: id, selectedContribuyenteRfc: rfc, selectedContribuyenteNombre: nombre }),
clearSelectedContribuyente: () =>
set({ selectedContribuyenteId: null, selectedContribuyenteRfc: null, selectedContribuyenteNombre: null }),
}),
{ name: 'horux-contribuyente' }
)
);
```
- [ ] **Step 2: Create selector component**
Create `apps/web/components/contribuyente-selector.tsx`:
```tsx
'use client';
import { useContribuyentes } from '@/lib/hooks/use-contribuyentes';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { Button } from '@horux/shared-ui';
import { ChevronDown, Building2 } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
export function ContribuyenteSelector() {
const { data: contribuyentes, isLoading } = useContribuyentes();
const { selectedContribuyenteId, selectedContribuyenteRfc, setSelectedContribuyente, clearSelectedContribuyente } =
useContribuyenteStore();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
if (isLoading || !contribuyentes || contribuyentes.length === 0) return null;
const selected = contribuyentes.find((c) => c.id === selectedContribuyenteId);
return (
<div ref={ref} className="relative">
<Button
variant="outline"
size="sm"
onClick={() => setOpen(!open)}
className="flex items-center gap-2 max-w-[250px]"
>
<Building2 className="h-4 w-4 flex-shrink-0" />
<span className="truncate text-xs">
{selected ? `${selected.rfc}${selected.nombre}` : 'Todos los RFCs'}
</span>
<ChevronDown className="h-3 w-3 flex-shrink-0" />
</Button>
{open && (
<div className="absolute top-full left-0 mt-1 w-72 bg-popover border rounded-md shadow-lg z-50 py-1">
<button
onClick={() => {
clearSelectedContribuyente();
setOpen(false);
}}
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent ${
!selectedContribuyenteId ? 'bg-accent font-medium' : ''
}`}
>
Todos los RFCs
</button>
<div className="border-t my-1" />
{contribuyentes.map((c) => (
<button
key={c.id}
onClick={() => {
setSelectedContribuyente(c.id, c.rfc, c.nombre);
setOpen(false);
}}
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent ${
selectedContribuyenteId === c.id ? 'bg-accent font-medium' : ''
}`}
>
<span className="font-mono text-xs">{c.rfc}</span>
<span className="ml-2 text-muted-foreground">{c.nombre}</span>
</button>
))}
</div>
)}
</div>
);
}
```
- [ ] **Step 3: Verify typecheck**
Run: `pnpm --filter @horux/web typecheck`
- [ ] **Step 4: Commit**
```bash
git add apps/web/stores/contribuyente-store.ts apps/web/components/contribuyente-selector.tsx
git commit -m "feat(web): add contribuyente selector store + dropdown component"
```
---
### Task 4: Contribuyentes management page
**Files:**
- Create: `apps/web/app/(dashboard)/contribuyentes/page.tsx`
- [ ] **Step 1: Create the contribuyentes page**
Create `apps/web/app/(dashboard)/contribuyentes/page.tsx`:
```tsx
'use client';
import { useState } from 'react';
import {
Button, Input, Label, Card, CardContent, CardHeader, CardTitle,
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@horux/shared-ui';
import {
useContribuyentes,
useCreateContribuyente,
useUpdateContribuyente,
useDeactivateContribuyente,
} from '@/lib/hooks/use-contribuyentes';
import type { CreateContribuyenteData } from '@/lib/api/contribuyentes';
import { Plus, Pencil, Trash2, Building2 } from 'lucide-react';
export default function ContribuyentesPage() {
const { data: contribuyentes, isLoading } = useContribuyentes();
const createMutation = useCreateContribuyente();
const updateMutation = useUpdateContribuyente();
const deactivateMutation = useDeactivateContribuyente();
const [showCreate, setShowCreate] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<CreateContribuyenteData>({
rfc: '', razonSocial: '', regimenFiscal: '', codigoPostal: '',
});
const resetForm = () => {
setForm({ rfc: '', razonSocial: '', regimenFiscal: '', codigoPostal: '' });
setShowCreate(false);
setEditingId(null);
};
const handleCreate = async () => {
try {
await createMutation.mutateAsync(form);
resetForm();
} catch (err: any) {
alert(err.response?.data?.message || 'Error al crear contribuyente');
}
};
const handleUpdate = async () => {
if (!editingId) return;
try {
await updateMutation.mutateAsync({ id: editingId, data: form });
resetForm();
} catch (err: any) {
alert(err.response?.data?.message || 'Error al actualizar');
}
};
const handleDeactivate = async (id: string, rfc: string) => {
if (!confirm(`¿Desactivar contribuyente ${rfc}? Esta acción no se puede deshacer.`)) return;
try {
await deactivateMutation.mutateAsync(id);
} catch (err: any) {
alert(err.response?.data?.message || 'Error al desactivar');
}
};
const openEdit = (c: any) => {
setForm({ rfc: c.rfc, razonSocial: c.nombre, regimenFiscal: c.regimenFiscal || '', codigoPostal: c.codigoPostal || '' });
setEditingId(c.id);
};
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Contribuyentes</h1>
<p className="text-sm text-muted-foreground">RFCs que gestiona tu despacho</p>
</div>
<Button onClick={() => setShowCreate(true)} className="flex items-center gap-2">
<Plus className="h-4 w-4" /> Agregar RFC
</Button>
</div>
{isLoading ? (
<p className="text-muted-foreground">Cargando...</p>
) : !contribuyentes || contribuyentes.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">Sin contribuyentes</h3>
<p className="text-sm text-muted-foreground mt-1 mb-4">
Agrega el primer RFC para empezar a gestionar su contabilidad.
</p>
<Button onClick={() => setShowCreate(true)}>Agregar primer RFC</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-3">
{contribuyentes.map((c) => (
<Card key={c.id}>
<CardContent className="flex items-center justify-between py-4 px-6">
<div>
<p className="font-semibold">{c.nombre}</p>
<p className="text-sm text-muted-foreground font-mono">{c.rfc}</p>
{c.regimenFiscal && (
<p className="text-xs text-muted-foreground mt-1">Régimen: {c.regimenFiscal}</p>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => openEdit(c)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeactivate(c.id, c.rfc)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Create / Edit Dialog */}
<Dialog open={showCreate || !!editingId} onOpenChange={() => resetForm()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingId ? 'Editar contribuyente' : 'Agregar contribuyente'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label>RFC</Label>
<Input
value={form.rfc}
onChange={(e) => setForm((p) => ({ ...p, rfc: e.target.value }))}
placeholder="ABC010203XY1"
maxLength={13}
disabled={!!editingId}
/>
</div>
<div>
<Label>Razón social</Label>
<Input
value={form.razonSocial}
onChange={(e) => setForm((p) => ({ ...p, razonSocial: e.target.value }))}
placeholder="Empresa SA de CV"
/>
</div>
<div>
<Label>Régimen fiscal (clave)</Label>
<Input
value={form.regimenFiscal || ''}
onChange={(e) => setForm((p) => ({ ...p, regimenFiscal: e.target.value }))}
placeholder="601"
maxLength={3}
/>
</div>
<div>
<Label>Código postal</Label>
<Input
value={form.codigoPostal || ''}
onChange={(e) => setForm((p) => ({ ...p, codigoPostal: e.target.value }))}
placeholder="06600"
maxLength={5}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={resetForm}>Cancelar</Button>
<Button
onClick={editingId ? handleUpdate : handleCreate}
disabled={createMutation.isPending || updateMutation.isPending}
>
{editingId ? 'Guardar' : 'Agregar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
```
- [ ] **Step 2: Verify typecheck**
Run: `pnpm --filter @horux/web typecheck`
- [ ] **Step 3: Commit**
```bash
git add apps/web/app/\(dashboard\)/contribuyentes/page.tsx
git commit -m "feat(web): add contribuyentes management page at /contribuyentes"
```
---
### Task 5: Wire contribuyente selector to sidebar + CFDI filter
**Files:**
- Modify: `apps/web/components/layouts/sidebar.tsx` (add selector + menu item)
- Modify: `apps/web/app/(dashboard)/cfdi/page.tsx` (pass contribuyenteId filter)
- [ ] **Step 1: Add ContribuyenteSelector to sidebar**
Open `apps/web/components/layouts/sidebar.tsx`. Find where the navigation items are rendered. ABOVE the nav list (but below the logo/brand area), add the ContribuyenteSelector:
```tsx
import { ContribuyenteSelector } from '../contribuyente-selector';
// Inside the render, above the nav items list:
<div className="px-3 py-2">
<ContribuyenteSelector />
</div>
```
Also add "Contribuyentes" to the navigation items array (for owners):
```typescript
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
```
Import `Building2` from `lucide-react` if not already imported.
- [ ] **Step 2: Wire contribuyenteId to CFDI list**
Open `apps/web/app/(dashboard)/cfdi/page.tsx`. Find where the CFDI list hook is called (likely `useCfdis()` from `use-cfdi.ts`).
Add the contribuyente filter:
```tsx
import { useContribuyenteStore } from '@/stores/contribuyente-store';
// Inside the component:
const { selectedContribuyenteId } = useContribuyenteStore();
// In the useCfdis() call or the API params, add:
// contribuyenteId: selectedContribuyenteId || undefined
```
The exact modification depends on how `useCfdis()` passes params. Read the hook and the API function to see where to add the filter. If `useCfdis()` accepts a filters object, add `contribuyenteId` to it. If it's passed as query params, add `&contribuyenteId=X` to the URL.
Also add `selectedContribuyenteId` to the React Query key so data refetches when the selector changes.
- [ ] **Step 3: Verify typecheck**
Run: `pnpm --filter @horux/web typecheck`
- [ ] **Step 4: Commit**
```bash
git add apps/web/components/layouts/sidebar.tsx apps/web/app/\(dashboard\)/cfdi/page.tsx
git commit -m "feat(web): wire contribuyente selector to sidebar + CFDI filter"
```
---
### Task 6: Validation
- [ ] **Step 1: Typecheck all packages**
```bash
pnpm --filter @horux/shared typecheck && pnpm --filter @horux/shared-ui typecheck && pnpm --filter @horux/web typecheck
```
- [ ] **Step 2: Verify commit history**
```bash
git log --oneline -8
```
- [ ] **Step 3: Visual smoke test (MANUAL)**
Start: `pnpm dev`
Test:
1. Open `http://localhost:3000/register-despacho` — verify form renders, fields work
2. Login with existing account → navigate to `/contribuyentes` — verify empty state
3. (If DB connected) Create a contribuyente → verify it appears in list
4. Check sidebar — verify ContribuyenteSelector dropdown appears
5. Navigate to `/cfdi` — verify list loads (filter not visible until contribuyentes exist)

View File

@@ -0,0 +1,17 @@
# Plan 3: Roles y Carteras — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Supervisores crean carteras de contribuyentes y asignan auxiliares. La función `getEntidadesVisibles()` filtra qué contribuyentes puede ver cada rol. Clientes acceden solo a sus RFCs via `cliente_accesos`.
**Architecture:** Se extiende el type `Role` con 'supervisor' | 'cliente'. Se crea CRUD para carteras (tenant BD). Se crea helper `getEntidadesVisibles(pool, userId, role)` que retorna los IDs de entidades visibles según el rol. Los endpoints de contribuyentes filtran usando este helper.
---
## Tasks
### Task 1: Extend Role type
### Task 2: Cartera CRUD service + controller + routes
### Task 3: getEntidadesVisibles helper
### Task 4: Filter contribuyentes by entidades visibles
### Task 5: Validation

View File

@@ -0,0 +1,946 @@
# Filtros "Considerar activos" y "Considerar NCs" — Fase 1 — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Agregar 2 toggles en `/impuestos` ("Considerar activos" y "Considerar NCs") que cuando están OFF (default) excluyen del cálculo de IVA/ISR las facturas tipo I con uso I01-I08 y las facturas tipo E con cfdi_tipo_relacion=01 respectivamente.
**Architecture:** Frontend agrega 2 booleanos al state de la página de impuestos y los propaga como query params hasta el backend. Backend aplica un fragmento WHERE adicional (helper en módulo neutral `_shared/cfdi-filters.ts`) a todas las queries que escanean `cfdis` dentro del path de impuestos. Funciones compartidas con dashboard (`calcular*PorRegimen`) reciben los flags como params opcionales con default `true` (= include todo) para preservar el comportamiento del dashboard. Cache `metricas_mensuales` queda intacto pero su gate se extiende para fall-through cuando los toggles están OFF; el cache se actualizará en Fase 2 con un schema base+deltas.
**Tech Stack:** Express + TypeScript en API, Next.js 14 + React Query en web, types compartidos en `@horux/shared`. Verificación vía `pnpm typecheck` (no unit tests para esta área per el patrón del repo).
**Spec:** `docs/superpowers/specs/2026-04-27-filtros-activos-ncs-impuestos-fase1-design.md`
---
## File Structure
### Files to create
```
apps/api/src/services/_shared/cfdi-filters.ts
└── Helper buildExtraFilters + buildExtraFiltersAlias (módulo neutral)
```
### Files to modify
```
apps/api/src/services/dashboard.service.ts
└── calcularIngresosPorRegimen + calcularEgresosPorRegimen: agregar 2 params booleanos default true, aplicar buildExtraFilters al WHERE de TODAS las queries internas
apps/api/src/services/impuestos.service.ts
└── getResumenIva + getIvaMensual: nuevos params + aplicar filtro al WHERE
└── getResumenIsr + getIsrMensual + getResumenIsrDesglosado: nuevos params + propagar a calcular*PorRegimen
└── Cache gate de getResumenIva: extender condición para bypass cuando flags ≠ default backend
└── Subqueries con alias `e` (rama I PPD/07): aplicar buildExtraFiltersAlias
apps/api/src/controllers/impuestos.controller.ts
└── Helper parseFlag + 5 handlers parsean los 2 query params nuevos
apps/web/lib/api/impuestos.ts
└── 5 funciones HTTP extendidas con 2 params nuevos
apps/web/lib/hooks/use-impuestos.ts
└── 5 hooks extendidos con 2 params nuevos (incluir en queryKey)
apps/web/app/(dashboard)/impuestos/page.tsx
└── 2 useState nuevos + 2 toggle buttons + propagación a hooks
```
---
## Task 1: Crear módulo helper compartido
**Files:**
- Create: `apps/api/src/services/_shared/cfdi-filters.ts`
- [ ] **Step 1: Crear directorio si no existe**
```bash
mkdir -p "C:/Users/chtr1/Downloads/Horux_despacho/apps/api/src/services/_shared"
```
- [ ] **Step 2: Escribir el módulo**
Crear `apps/api/src/services/_shared/cfdi-filters.ts` con el contenido completo:
```ts
/**
* Helpers para construir fragmentos AND adicionales en WHERE clauses según
* los toggles "Considerar activos" y "Considerar NCs" de la UI de impuestos.
*
* - considerarActivos === false → excluir facturas tipo I con uso de CFDI I01-I08.
* - considerarNCs === false → excluir facturas tipo E con cfdi_tipo_relacion = '01'.
*
* Cuando ambos son true (default backend = "include todo"), retorna string
* vacío. Esto preserva el comportamiento histórico para callers que no pasan
* los flags (ej. dashboard, reportes).
*
* Las versiones `Alias` se usan en subqueries con alias de tabla
* (ej. `cfdis e` en SUM_E_REFERENCING_*). Para activos el filtro es no-op
* en esos subqueries (porque escanean type E), pero el filtro de NCs sí
* aplica.
*/
const ACTIVOS_USOS = "('I01','I02','I03','I04','I05','I06','I07','I08')";
export function buildExtraFilters(
considerarActivos: boolean,
considerarNCs: boolean,
): string {
const parts: string[] = [];
if (!considerarActivos) {
parts.push(`AND NOT (tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS_USOS})`);
}
if (!considerarNCs) {
parts.push(`AND NOT (tipo_comprobante = 'E' AND COALESCE(cfdi_tipo_relacion, '') = '01')`);
}
return parts.length > 0 ? ' ' + parts.join(' ') : '';
}
export function buildExtraFiltersAlias(
alias: string,
considerarActivos: boolean,
considerarNCs: boolean,
): string {
const parts: string[] = [];
if (!considerarActivos) {
parts.push(`AND NOT (${alias}.tipo_comprobante = 'I' AND ${alias}.uso_cfdi IN ${ACTIVOS_USOS})`);
}
if (!considerarNCs) {
parts.push(`AND NOT (${alias}.tipo_comprobante = 'E' AND COALESCE(${alias}.cfdi_tipo_relacion, '') = '01')`);
}
return parts.length > 0 ? ' ' + parts.join(' ') : '';
}
```
- [ ] **Step 3: Verificar typecheck del API**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
Expected: PASS sin errores.
- [ ] **Step 4: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/api/src/services/_shared/cfdi-filters.ts
git commit -m "feat(api): helper buildExtraFilters para toggles activos/NCs"
```
---
## Task 2: Extender `calcularIngresosPorRegimen` y `calcularEgresosPorRegimen` en dashboard.service.ts
**Files:**
- Modify: `apps/api/src/services/dashboard.service.ts`
**Heads up:** Dashboard también consume estas funciones. Default `true` en los nuevos params preserva su comportamiento.
- [ ] **Step 1: Agregar import del helper al inicio del archivo**
Encontrar la sección de imports al inicio de `dashboard.service.ts` y agregar:
```ts
import { buildExtraFilters } from './_shared/cfdi-filters.js';
```
(Las imports en este proyecto usan extensión `.js` aunque el archivo sea `.ts` — patrón ESM con tsx. Revisa imports existentes para confirmar el estilo.)
- [ ] **Step 2: Extender la signature de `calcularIngresosPorRegimen`**
Buscar la función exportada `calcularIngresosPorRegimen`. Agregar 2 parámetros opcionales con default `true` al final de la lista, antes del cierre de `)`:
Cambiar la signature para incluir:
```ts
export async function calcularIngresosPorRegimen(
pool: Pool,
tenantId: string,
fechaInicio: string,
fechaFin: string,
// ...parámetros existentes preservados...
conciliacion?: boolean,
contribuyenteId?: string | null,
considerarActivos: boolean = true, // nuevo
considerarNCs: boolean = true, // nuevo
): Promise<...>
```
(Mantener los nombres y orden de los parámetros existentes. Solo agregar los 2 nuevos al final.)
- [ ] **Step 3: Aplicar el filtro a TODAS las queries internas de calcularIngresosPorRegimen**
Dentro del cuerpo de la función, antes de las queries SQL, computar el fragmento:
```ts
const extra = buildExtraFilters(considerarActivos, considerarNCs);
```
Luego, en cada query SQL que escanee `cfdis`, agregar `${extra}` al final del WHERE clause. Buscar todos los `FROM cfdis` dentro del cuerpo de la función — deben ser ~3-5 queries — y a cada uno agregarle el fragmento.
Ejemplo de transformación:
```ts
// Antes:
const { rows } = await pool.query(`
SELECT ...
FROM cfdis
WHERE ${VIGENTE} AND ${FR}
AND ${ctx.esEmisor}
GROUP BY ...
`, [fechaInicio, fechaFin]);
// Después:
const { rows } = await pool.query(`
SELECT ...
FROM cfdis
WHERE ${VIGENTE} AND ${FR}${extra}
AND ${ctx.esEmisor}
GROUP BY ...
`, [fechaInicio, fechaFin]);
```
`extra` retorna con leading space cuando agrega contenido. Si ambos flags son `true` retorna string vacío y la query queda idéntica.
- [ ] **Step 4: Repetir para `calcularEgresosPorRegimen`**
Misma extensión de signature (2 params al final con default `true`), mismo helper `extra = buildExtraFilters(...)`, misma aplicación a todos los `FROM cfdis` del cuerpo.
- [ ] **Step 5: Verificar typecheck del API**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
Expected: PASS sin errores. Cualquier callsite existente de estas funciones que no pase los nuevos params usa los defaults `true`, comportamiento idéntico a antes.
- [ ] **Step 6: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/api/src/services/dashboard.service.ts
git commit -m "feat(api): calcular*PorRegimen aceptan flags considerarActivos/considerarNCs"
```
---
## Task 3: Extender `getResumenIva` y `getIvaMensual` en impuestos.service.ts
**Files:**
- Modify: `apps/api/src/services/impuestos.service.ts`
- [ ] **Step 1: Agregar import del helper al inicio del archivo**
Buscar la sección de imports del archivo. Agregar:
```ts
import { buildExtraFilters } from './_shared/cfdi-filters.js';
```
- [ ] **Step 2: Extender signature de `getResumenIva`**
Encontrar `export async function getResumenIva(...)`. Agregar 2 params al final con default `true`:
```ts
export async function getResumenIva(
pool: Pool,
fechaInicio: string,
fechaFin: string,
tenantId: string,
conciliacion?: boolean,
contribuyenteId?: string | null,
considerarActivos: boolean = true,
considerarNCs: boolean = true,
): Promise<ResumenIva>
```
- [ ] **Step 3: Computar `extra` y aplicar a todas las queries internas**
Dentro del body, después de `const FR = getFR(conciliacion);` agregar:
```ts
const extra = buildExtraFilters(considerarActivos, considerarNCs);
```
Y aplicar `${extra}` al final de cada WHERE en queries con `FROM cfdis` (las que NO usan alias `e` — esas son Task 5). Aplica el mismo patrón del Task 2 Step 3.
- [ ] **Step 4: Extender el cache gate de getResumenIva**
Buscar la condición que protege el path de cache (alrededor de línea 322 según la versión actual del archivo, puede haber cambiado por WIP). El patrón es:
```ts
if (
!conciliacion &&
contribuyenteId &&
...condiciones existentes...
) {
const cached = await readResumenIvaFromCache(...);
if (cached) return cached;
}
```
Extender:
```ts
if (
!conciliacion &&
considerarActivos && // nuevo: cache solo aplica con backend default (todo incluido)
considerarNCs && // nuevo
contribuyenteId &&
...condiciones existentes...
) {
const cached = await readResumenIvaFromCache(...);
if (cached) return cached;
}
```
Cuando UI tiene los toggles OFF (default), `considerarActivos===false || considerarNCs===false` → cache bypass → live query. Aceptado para Fase 1.
- [ ] **Step 5: Extender signature de `getIvaMensual`**
Misma extensión: agregar 2 params al final con default `true`. Agregar `const extra = buildExtraFilters(...)` y aplicar a todas las queries con `FROM cfdis` dentro del loop mensual.
- [ ] **Step 6: Verificar typecheck del API**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/api/src/services/impuestos.service.ts
git commit -m "feat(api): getResumenIva y getIvaMensual aceptan flags considerarActivos/considerarNCs + cache gate"
```
---
## Task 4: Extender `getResumenIsr`, `getIsrMensual`, `getResumenIsrDesglosado`
**Files:**
- Modify: `apps/api/src/services/impuestos.service.ts`
- [ ] **Step 1: Extender signature de `getResumenIsr`**
Agregar 2 params al final con default `true`:
```ts
export async function getResumenIsr(
pool: Pool,
fechaInicio: string,
fechaFin: string,
tenantId: string,
conciliacion?: boolean,
contribuyenteId?: string | null,
considerarActivos: boolean = true,
considerarNCs: boolean = true,
): Promise<ResumenIsr>
```
- [ ] **Step 2: Propagar a llamadas a `calcular*PorRegimen` y a queries internas**
Dentro de `getResumenIsr`:
- Agregar `const extra = buildExtraFilters(considerarActivos, considerarNCs);` al inicio del cuerpo (después del `getFR`).
- Aplicar `${extra}` a TODOS los `FROM cfdis` internos de la función (sin alias).
- En las llamadas existentes `calcularIngresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId)` agregar al final los 2 nuevos args:
```ts
const ingresosData = await calcularIngresosPorRegimen(
pool, tenantId, fechaInicio, fechaFin,
undefined, undefined, conciliacion, contribuyenteId,
considerarActivos, considerarNCs, // nuevos
);
```
Idem para `calcularEgresosPorRegimen`.
- [ ] **Step 3: Extender signature de `getIsrMensual`**
Agregar 2 params al final con default `true`:
```ts
export async function getIsrMensual(
pool: Pool,
año: number,
tenantId: string,
conciliacion?: boolean,
contribuyenteId?: string | null,
regimenClave?: string | null,
considerarActivos: boolean = true,
considerarNCs: boolean = true,
): Promise<IsrMensual[]>
```
- [ ] **Step 4: Propagar dentro de `getIsrMensual`**
Dentro del loop mensual de `getIsrMensual`, las llamadas existentes a `calcularIngresosPorRegimen` y `calcularEgresosPorRegimen` deben recibir los 2 nuevos args al final. Patrón:
```ts
const [ingresosData, egresosData] = await Promise.all([
calcularIngresosPorRegimen(
pool, tenantId, fi, ff,
undefined, undefined, conciliacion, contribuyenteId,
considerarActivos, considerarNCs, // nuevos
),
calcularEgresosPorRegimen(
pool, tenantId, fi, ff,
undefined, undefined, conciliacion, contribuyenteId,
considerarActivos, considerarNCs, // nuevos
),
]);
```
- [ ] **Step 5: Extender signature de `getResumenIsrDesglosado`**
Agregar 2 params al final con default `true`:
```ts
export async function getResumenIsrDesglosado(
pool: Pool,
fechaFin: string,
tenantId: string,
conciliacion?: boolean,
contribuyenteId?: string | null,
considerarActivos: boolean = true,
considerarNCs: boolean = true,
): Promise<import('@horux/shared').ResumenIsrDesglosado>
```
- [ ] **Step 6: Propagar dentro de `getResumenIsrDesglosado`**
Las 3 llamadas a `getResumenIsr` (una secuencial para `anteriores` cuando mesFinal !== 1, dos en `Promise.all` para `delPeriodo` y `total`) deben pasar los 2 nuevos args al final:
```ts
anteriores = await getResumenIsr(
pool, fiAnt, ffAnt, tenantId, conciliacion, contribuyenteId,
considerarActivos, considerarNCs, // nuevos
);
const [delPeriodo, total] = await Promise.all([
getResumenIsr(pool, fiPeriodo, ffPeriodo, tenantId, conciliacion, contribuyenteId, considerarActivos, considerarNCs),
getResumenIsr(pool, fiTotal, ffTotal, tenantId, conciliacion, contribuyenteId, considerarActivos, considerarNCs),
]);
```
- [ ] **Step 7: Verificar typecheck del API**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
Expected: PASS.
- [ ] **Step 8: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/api/src/services/impuestos.service.ts
git commit -m "feat(api): getResumenIsr/getIsrMensual/getResumenIsrDesglosado aceptan flags considerarActivos/considerarNCs"
```
---
## Task 5: Aplicar filtros a subqueries con alias `e` (rama I PPD/07)
**Files:**
- Modify: `apps/api/src/services/impuestos.service.ts`
**Context:** En la rama I PPD/07 hay subqueries que iteran sobre `cfdis e` (alias) para detectar E que referencian I PPD/07. Estos subqueries pueden ser constants templates (`SUM_E_REFERENCING_TRAS`, `SUM_E_REFERENCING_RET`, `HAS_E_REFERENCING_MISMO_MES`) o expresiones inline. Necesitan el filtro `buildExtraFiltersAlias('e', ...)`.
- [ ] **Step 1: Importar `buildExtraFiltersAlias`**
Verificar que el import al inicio del archivo incluya ambas:
```ts
import { buildExtraFilters, buildExtraFiltersAlias } from './_shared/cfdi-filters.js';
```
- [ ] **Step 2: Identificar y modificar las constantes/templates de subqueries con alias `e`**
Buscar `cfdis e` en `impuestos.service.ts`. Deberían aparecer en constantes como `SUM_E_REFERENCING_TRAS = (esLadoE: string) => \`...\`` y similares.
**Decisión arquitectónica**: estas constantes son templates funcionales. La forma más limpia es **convertirlas a funciones que reciben los flags** y los aplican.
Buscar las constantes existentes (típicamente templates string functions) y convertirlas. Ejemplo (la firma exacta existente puede variar; la idea es agregar los 2 params al final):
Si encuentras (formato actual aproximado):
```ts
const SUM_E_REFERENCING_TRAS = (esLadoE: string) => `COALESCE((
SELECT SUM(${IVA_TRAS_EXPR_ALIAS('e')})
FROM cfdis e
WHERE e.status NOT IN ('Cancelado', '0')
AND ${esLadoE}
AND ...resto del where...
), 0)`;
```
Cambiar a:
```ts
const SUM_E_REFERENCING_TRAS = (esLadoE: string, considerarActivos: boolean, considerarNCs: boolean) => `COALESCE((
SELECT SUM(${IVA_TRAS_EXPR_ALIAS('e')})
FROM cfdis e
WHERE e.status NOT IN ('Cancelado', '0')
AND ${esLadoE}
AND ...resto del where...${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
), 0)`;
```
Aplicar el mismo patrón a las demás subqueries con alias `e`:
- `SUM_E_REFERENCING_TRAS`
- `SUM_E_REFERENCING_RET`
- `HAS_E_REFERENCING_MISMO_MES`
- Cualquier otra que use `cfdis e`
- [ ] **Step 3: Actualizar callsites de las subqueries**
Buscar dónde se usan estas funciones (ej. dentro de `getResumenIva`, `getResumenIsr`, sus helpers `bucketCausadoNeg`, `bucketAcreditableNeg`, etc.) y agregar los nuevos params:
```ts
// Antes:
SUM_E_REFERENCING_TRAS(esLado)
// Después:
SUM_E_REFERENCING_TRAS(esLado, considerarActivos, considerarNCs)
```
Los callsites están dentro de funciones que ya recibieron los flags en Tasks 3 y 4. Solo es propagación local.
- [ ] **Step 4: Verificar typecheck del API**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/api/src/services/impuestos.service.ts
git commit -m "feat(api): subqueries con alias 'e' (I PPD/07) respetan flags considerarActivos/considerarNCs"
```
---
## Task 6: Controllers — `parseFlag` helper + propagación
**Files:**
- Modify: `apps/api/src/controllers/impuestos.controller.ts`
- [ ] **Step 1: Agregar helper `parseFlag` cerca del top del archivo**
Después del helper `parseConciliacion(req)` existente, agregar:
```ts
function parseFlag(req: Request, key: string, defaultValue = true): boolean {
const v = req.query[key];
if (v === undefined || v === null) return defaultValue;
return v === 'true' || v === '1';
}
```
- [ ] **Step 2: Extender los 5 handlers**
Para cada uno de los 5 handlers (`getIvaMensual`, `getIsrMensual`, `getResumenIva`, `getResumenIsr`, `getResumenIsrDesglosado`):
1. Agregar las 2 lecturas de query params después de las existentes:
```ts
const considerarActivos = parseFlag(req, 'considerarActivos', true);
const considerarNCs = parseFlag(req, 'considerarNCs', true);
```
2. Pasar al service como los 2 últimos args.
Ejemplo para `getResumenIsrDesglosado`:
```ts
export async function getResumenIsrDesglosado(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth() + 1;
const lastDay = new Date(y, m, 0).getDate();
const fechaFin = (req.query.fechaFin as string) || `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
const conciliacion = parseConciliacion(req);
const contribuyenteId = (req.query.contribuyenteId as string) || null;
const considerarActivos = parseFlag(req, 'considerarActivos', true); // nuevo
const considerarNCs = parseFlag(req, 'considerarNCs', true); // nuevo
const desglose = await impuestosService.getResumenIsrDesglosado(
req.tenantPool,
fechaFin,
effectiveTenantId(req),
conciliacion,
contribuyenteId,
considerarActivos, // nuevo
considerarNCs, // nuevo
);
res.json(desglose);
} catch (error) {
next(error);
}
}
```
Aplicar el mismo patrón a los otros 4 handlers (`getIvaMensual`, `getIsrMensual`, `getResumenIva`, `getResumenIsr`).
- [ ] **Step 3: Verificar typecheck**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/api/src/controllers/impuestos.controller.ts
git commit -m "feat(api): controllers parsean flags considerarActivos/considerarNCs y los propagan al service"
```
---
## Task 7: Frontend API client
**Files:**
- Modify: `apps/web/lib/api/impuestos.ts`
- [ ] **Step 1: Extender las 5 funciones HTTP**
Para cada función, agregar 2 params booleanos opcionales y serializarlos en `URLSearchParams`. Patrón:
```ts
export async function getResumenIsrDesglosado(
fechaFin: string,
conciliacion?: boolean,
considerarActivos?: boolean,
considerarNCs?: boolean,
contribuyenteId?: string | null,
): Promise<ResumenIsrDesglosado> {
const params = new URLSearchParams();
params.set('fechaFin', fechaFin);
if (conciliacion) params.set('conciliacion', 'true');
if (considerarActivos) params.set('considerarActivos', 'true');
if (considerarNCs) params.set('considerarNCs', 'true');
if (contribuyenteId) params.set('contribuyenteId', contribuyenteId);
const response = await apiClient.get<ResumenIsrDesglosado>(`/impuestos/isr/resumen-desglosado?${params}`);
return response.data;
}
```
Aplicar el mismo patrón a:
- `getIsrMensual(año, conciliacion, contribuyenteId, regimenClave, considerarActivos, considerarNCs)` — orden: insertar los 2 nuevos AL FINAL para no romper callers existentes que pasan posicionalmente.
- `getIvaMensual(año, conciliacion, contribuyenteId, considerarActivos, considerarNCs)`
- `getResumenIva(fechaInicio, fechaFin, conciliacion, contribuyenteId, considerarActivos, considerarNCs)`
- `getResumenIsr(fechaInicio, fechaFin, conciliacion, contribuyenteId, considerarActivos, considerarNCs)`
- `getResumenIsrDesglosado(fechaFin, conciliacion, considerarActivos, considerarNCs, contribuyenteId)` — la signature actual ya tiene `contribuyenteId` al final; mantenerlo allí.
**Importante**: solo set en URLSearchParams cuando el valor es `true`. Si el frontend pasa `undefined` o `false`, NO se manda el param (el backend default `true` aplica). Esto evita ambigüedad con la convención `'false'` string.
Espera — esta regla es la INVERSA de lo que queremos. Nuestro UI default es `false` (toggle OFF) y queremos QUE EL BACKEND EXCLUYA. Si el frontend NO manda el param cuando el toggle está OFF, el backend default `true` (include) aplica → no se excluye → COMPORTAMIENTO INCORRECTO.
Corrección: serializar el booleano explícitamente (siempre).
```ts
if (considerarActivos !== undefined) params.set('considerarActivos', String(considerarActivos));
if (considerarNCs !== undefined) params.set('considerarNCs', String(considerarNCs));
```
Y en el controller (ya implementado en Task 6) `parseFlag` retorna `false` cuando `req.query.considerarActivos === 'false'`.
Verificar que el `parseFlag` del Task 6 maneja el string `'false'`:
```ts
function parseFlag(req: Request, key: string, defaultValue = true): boolean {
const v = req.query[key];
if (v === undefined || v === null) return defaultValue;
return v === 'true' || v === '1'; // cualquier otra cosa (ej. 'false', '0') → false
}
```
`v === 'true' || v === '1'` retorna `false` cuando `v === 'false'`. Correcto.
Aplicar a los 5 funciones:
```ts
if (considerarActivos !== undefined) params.set('considerarActivos', String(considerarActivos));
if (considerarNCs !== undefined) params.set('considerarNCs', String(considerarNCs));
```
- [ ] **Step 2: Verificar typecheck del web**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/web exec tsc --noEmit 2>&1 | grep "lib/api/impuestos"`
Expected: NO output (clean — los errores pre-existentes en otros archivos del web no nos importan).
- [ ] **Step 3: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/web/lib/api/impuestos.ts
git commit -m "feat(web): API client funciones aceptan considerarActivos/considerarNCs"
```
---
## Task 8: Frontend hooks
**Files:**
- Modify: `apps/web/lib/hooks/use-impuestos.ts`
- [ ] **Step 1: Extender los 5 hooks con 2 params nuevos**
Para cada hook, agregar 2 params booleanos opcionales al final, incluirlos en `queryKey`, y pasarlos al API call. Patrón:
```ts
export function useResumenIsrDesglosado(
fechaFin: string,
conciliacion?: boolean,
considerarActivos?: boolean,
considerarNCs?: boolean,
) {
const tk = useTenantKey();
const { selectedContribuyenteId } = useContribuyenteStore();
return useQuery({
queryKey: ['isr-resumen-desglosado', tk, fechaFin, conciliacion, considerarActivos, considerarNCs, selectedContribuyenteId],
queryFn: () => impuestosApi.getResumenIsrDesglosado(fechaFin, conciliacion, considerarActivos, considerarNCs, selectedContribuyenteId),
enabled: !!fechaFin,
});
}
```
Aplicar a los 5 hooks: `useResumenIsrDesglosado`, `useResumenIsr`, `useResumenIva`, `useIsrMensual`, `useIvaMensual`.
Para `useIsrMensual` que ya tiene `regimenClave` opcional, mantener ese param y agregar los 2 nuevos al final:
```ts
export function useIsrMensual(
año?: number,
conciliacion?: boolean,
regimenClave?: string | null,
considerarActivos?: boolean,
considerarNCs?: boolean,
)
```
(Verificar el orden actual de params del hook — los nuevos van AL FINAL.)
- [ ] **Step 2: Verificar typecheck del web**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/web exec tsc --noEmit 2>&1 | grep "lib/hooks/use-impuestos"`
Expected: NO output.
- [ ] **Step 3: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/web/lib/hooks/use-impuestos.ts
git commit -m "feat(web): hooks de impuestos aceptan considerarActivos/considerarNCs en queryKey"
```
---
## Task 9: Frontend UI — toggles + propagación
**Files:**
- Modify: `apps/web/app/(dashboard)/impuestos/page.tsx`
- [ ] **Step 1: Agregar 2 useState al inicio del componente**
Buscar la sección de useState existente (cerca de líneas 30-40, donde está `useState(false)` para `conciliacion`). Agregar:
```ts
const [considerarActivos, setConsiderarActivos] = useState(false);
const [considerarNCs, setConsiderarNCs] = useState(false);
```
- [ ] **Step 2: Pasar los 2 nuevos states a TODOS los hooks de impuestos**
Buscar cada llamada a hook y agregar los 2 args al final. Patrón:
```ts
// Antes:
const { data: resumenIsr } = useResumenIsr(fechaInicio, fechaFin, conciliacion);
const { data: resumenIsrDesglose } = useResumenIsrDesglosado(fechaFin, conciliacion);
// Después:
const { data: resumenIsr } = useResumenIsr(fechaInicio, fechaFin, conciliacion, considerarActivos, considerarNCs);
const { data: resumenIsrDesglose } = useResumenIsrDesglosado(fechaFin, conciliacion, considerarActivos, considerarNCs);
```
Aplicar a:
- `useIvaMensual(año, conciliacion, considerarActivos, considerarNCs)`
- `useIsrMensual(año, conciliacion, regimenSeleccionado, considerarActivos, considerarNCs)`
- `useResumenIva(fechaInicio, fechaFin, conciliacion, considerarActivos, considerarNCs)`
- `useResumenIsr(fechaInicio, fechaFin, conciliacion, considerarActivos, considerarNCs)`
- `useResumenIsrDesglosado(fechaFin, conciliacion, considerarActivos, considerarNCs)`
- [ ] **Step 3: Agregar 2 toggle buttons al row de filtros**
Buscar el bloque del toggle de Conciliación (alrededor de líneas 92-103). Después del button de Conciliación y antes del cierre del `<div className="flex items-center gap-3">`, agregar:
```tsx
<button
onClick={() => setConsiderarActivos(!considerarActivos)}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
considerarActivos
? 'bg-primary/10 text-primary border border-primary/30'
: 'hover:bg-accent'
)}
title="Si está inactivo, no se consideran facturas tipo I con uso de CFDI I01-I08 (compras de activos fijos)."
>
<CheckSquare className="h-4 w-4" />
Considerar activos
</button>
<button
onClick={() => setConsiderarNCs(!considerarNCs)}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
considerarNCs
? 'bg-primary/10 text-primary border border-primary/30'
: 'hover:bg-accent'
)}
title="Si está inactivo, no se consideran facturas tipo E con tipo de relación 01 (notas de crédito)."
>
<CheckSquare className="h-4 w-4" />
Considerar NCs
</button>
```
`CheckSquare` y `cn` ya están importados al inicio del archivo. NO agregues imports nuevos.
- [ ] **Step 4: Verificar typecheck del web**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/web exec tsc --noEmit 2>&1 | grep "impuestos/page"`
Expected: NO output.
- [ ] **Step 5: Smoke (opcional, defer si dev no corre)**
Si dev corre (`curl -s -o /dev/null -w "%{http_code}" http://localhost:3000 2>/dev/null` retorna algo distinto de 000):
1. Abrir `/impuestos`, pestaña ISR. Confirmar que aparecen 3 toggles: Conciliación, Considerar activos, Considerar NCs (todos OFF inicialmente).
2. Tooltip al hover en cada toggle nuevo describe el filtro.
3. Click "Considerar activos" → cambia a estilo activo (azul).
4. Verificar que los números de la tabla y la sección "Cálculo de ISR del Periodo" recalculan al togglear.
5. Smoke completo cross-feature en Task 10.
Si dev NO corre, **NO lo inicies**. Skip.
- [ ] **Step 6: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add "apps/web/app/(dashboard)/impuestos/page.tsx"
git commit -m "feat(web): toggles 'Considerar activos' y 'Considerar NCs' en /impuestos"
```
---
## Task 10: Verificación final + sync OneDrive + commit V.1.0.7
**Files:**
- Verify: typecheck completo
- Smoke: cross-feature en browser
- Copy: 8 archivos a OneDrive (1 nuevo + 7 modificados)
- Commit: V.1.0.7
- [ ] **Step 1: Typecheck completo de shared + api**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
pnpm --filter @horux/shared typecheck
pnpm --filter @horux/api typecheck
```
Expected: ambos PASS sin errores. Si falla, **STOP y reporta**.
- [ ] **Step 2: Verificar archivos web del plan limpios**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
pnpm --filter @horux/web exec tsc --noEmit 2>&1 | grep -E "(lib/api/impuestos|lib/hooks/use-impuestos|impuestos/page)"
```
Expected: NO output (los 3 archivos web del plan están limpios; otros errores web son pre-existentes y fuera de scope).
- [ ] **Step 3: Smoke cross-feature**
Si dev corre y tienes acceso al browser:
1. **Default UI** (`/impuestos`, ambos toggles OFF):
- ISR/IVA cargan números menores que antes (excluyen activos + NCs).
- Tabla "Histórico ISR" usa los acumulados filtrados.
- Sección "Cálculo de ISR del Periodo" refleja los filtros consistentemente en `delPeriodo`, `anteriores`, `total`.
2. **Toggle "Considerar activos" ON**: ingresos/deducciones/base gravable suben con la suma de activos del periodo.
3. **Toggle "Considerar NCs" ON**: cambia el bucket — NCs aparecen restando.
4. **Combinaciones**: probar las 4 combinaciones de los 2 toggles + Conciliación on/off (8 total).
5. **Cross-check `/dashboard`**: KPIs (ingresos, gastos, utilidad) **NO cambian** vs antes del deploy. Esto valida que el default `true` en `calcular*PorRegimen` preserva el dashboard.
6. **Activos Fijos tab**: la tabla sigue mostrando todos los CFDIs I con uso I01-I08 (no afectada por el toggle "Considerar activos" en ISR/IVA).
7. **Cambiar contribuyente**: el state de los toggles persiste en sesión (no se resetea al cambiar contribuyente).
Si no puedes hacer smoke completo, reporta qué se verificó y qué quedó pendiente para el owner.
- [ ] **Step 4: Copiar archivos a OneDrive (8 archivos: 1 nuevo + 7 modificados)**
```bash
SRC="C:/Users/chtr1/Downloads/Horux_despacho"
DST="C:/Users/chtr1/OneDrive/Documentos/GitHub/Horux_despachos"
# Crear carpeta _shared si no existe en OneDrive
mkdir -p "$DST/apps/api/src/services/_shared"
cp -p "$SRC/apps/api/src/services/_shared/cfdi-filters.ts" "$DST/apps/api/src/services/_shared/cfdi-filters.ts"
cp -p "$SRC/apps/api/src/services/dashboard.service.ts" "$DST/apps/api/src/services/dashboard.service.ts"
cp -p "$SRC/apps/api/src/services/impuestos.service.ts" "$DST/apps/api/src/services/impuestos.service.ts"
cp -p "$SRC/apps/api/src/controllers/impuestos.controller.ts" "$DST/apps/api/src/controllers/impuestos.controller.ts"
cp -p "$SRC/apps/web/lib/api/impuestos.ts" "$DST/apps/web/lib/api/impuestos.ts"
cp -p "$SRC/apps/web/lib/hooks/use-impuestos.ts" "$DST/apps/web/lib/hooks/use-impuestos.ts"
cp -p "$SRC/apps/web/app/(dashboard)/impuestos/page.tsx" "$DST/apps/web/app/(dashboard)/impuestos/page.tsx"
cp -p "$SRC/docs/superpowers/specs/2026-04-27-filtros-activos-ncs-impuestos-fase1-design.md" "$DST/docs/superpowers/specs/2026-04-27-filtros-activos-ncs-impuestos-fase1-design.md"
cp -p "$SRC/docs/superpowers/plans/2026-04-27-filtros-activos-ncs-impuestos-fase1.md" "$DST/docs/superpowers/plans/2026-04-27-filtros-activos-ncs-impuestos-fase1.md"
```
- [ ] **Step 5: Verificar diff Downloads vs OneDrive**
```bash
diff -rq \
--exclude=node_modules --exclude=.git --exclude=.turbo --exclude=.next \
--exclude=dist --exclude=tsconfig.tsbuildinfo --exclude=email-previews \
--exclude=pnpm-lock.yaml --exclude=.env --exclude=.env.local \
"C:/Users/chtr1/Downloads/Horux_despacho" \
"C:/Users/chtr1/OneDrive/Documentos/GitHub/Horux_despachos"
```
Expected: única diferencia esperada es `Only in C:/Users/chtr1/Downloads/Horux_despacho/apps/api: data` (XMLs runtime). Si aparece otra cosa, **STOP y reporta**.
- [ ] **Step 6: Commit en OneDrive**
```bash
cd "C:/Users/chtr1/OneDrive/Documentos/GitHub/Horux_despachos"
git status --short
```
Confirma que aparezcan exactamente los archivos copiados como M (modified) o ?? (untracked). Si hay algo más, reporta.
```bash
git add \
apps/api/src/services/_shared/cfdi-filters.ts \
apps/api/src/services/dashboard.service.ts \
apps/api/src/services/impuestos.service.ts \
apps/api/src/controllers/impuestos.controller.ts \
apps/web/lib/api/impuestos.ts \
apps/web/lib/hooks/use-impuestos.ts \
"apps/web/app/(dashboard)/impuestos/page.tsx" \
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
git commit -m "V.1.0.7"
git status --short
git log -2 --oneline
```
Expected:
- Commit creado con hash nuevo, mensaje `V.1.0.7`.
- Working tree clean.
- `git log -2` muestra V.1.0.7 sobre V.1.0.6.
- [ ] **Step 7: NO push**
Push lo hace el owner manualmente. Confirmar explícitamente que NO se ejecutó `git push`.

View File

@@ -0,0 +1,894 @@
# ISR — Base gravable acumulada y desglose del periodo — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Mostrar la base gravable y los acumulados de ISR correctamente en la pestaña ISR de `/impuestos`. La tabla histórica gana 3 columnas acumuladas (Ingresos, Deducciones, Base Gravable Acum.) y pierde la BG mensual incorrecta. La sección "Cálculo de ISR del Periodo" muestra el desglose `del periodo + anteriores = total acumulado` como en el formato 14 del SAT.
**Architecture:** Cambio puramente de cómputo + UI. Backend agrega running totals a `getIsrMensual` y un nuevo endpoint `/impuestos/resumen-isr-desglosado` que llama 3 veces a `getResumenIsr` (mes final, anteriores, total) y los devuelve juntos. Frontend modifica la tabla y reescribe el card de cálculo. Sin migraciones, sin cambio en la BD.
**Tech Stack:** Express + TypeScript en el API, Next.js 14 + React Query en el web, types compartidos en `@horux/shared`. Verificación vía `pnpm typecheck` (este proyecto no tiene unit tests para esta área — la disciplina es typecheck + smoke manual, ver `feedback_horux360_tscheck.md`).
**Spec:** `docs/superpowers/specs/2026-04-27-isr-base-gravable-acumulada-design.md`
---
## File Structure
### Files to modify
```
packages/shared/src/types/impuestos.ts
└── Extender IsrMensual con ingresosAcum, deduccionesAcum, baseGravableAcum
└── Agregar interface ResumenIsrDesglosado
apps/api/src/services/impuestos.service.ts
└── Modificar getIsrMensual (líneas 409-486): pase de running totals
└── Agregar getResumenIsrDesglosado (función nueva exportada)
apps/api/src/controllers/impuestos.controller.ts
└── Agregar handler getResumenIsrDesglosado
apps/api/src/routes/impuestos.routes.ts
└── Agregar GET /isr/resumen-desglosado
apps/web/lib/api/impuestos.ts
└── Agregar función getResumenIsrDesglosado (cliente HTTP)
apps/web/lib/hooks/use-impuestos.ts
└── Agregar hook useResumenIsrDesglosado
apps/web/app/(dashboard)/impuestos/page.tsx
└── Tabla Histórico ISR: 6 columnas, BG mensual fuera, BG_acum en rojo si negativa
└── Sección "Cálculo de ISR del Periodo": rename + layout nuevo con desglose
```
### Files NOT touched
- BD: ningún cambio de schema.
- `metricas_mensuales` cache: sigue guardando mensuales puros.
- KPIs de la parte alta de `/impuestos`: siguen mostrando rango filtrado completo.
- IVA mensual: fuera de scope.
---
## Task 1: Extender shared types
**Files:**
- Modify: `packages/shared/src/types/impuestos.ts`
- [ ] **Step 1: Agregar campos acumulados a `IsrMensual`**
Editar el interface existente (líneas 16-28):
```ts
export interface IsrMensual {
id: number;
año: number;
mes: number;
ingresosAcumulados: number; // mensual — naming legacy, no se renombra en este spec
deducciones: number; // mensual
baseGravable: number; // mensual — sigue retornándose para no romper consumidores externos, pero ya no se muestra en la UI
// Nuevos: running totals desde enero hasta el mes de esta fila
ingresosAcum: number;
deduccionesAcum: number;
baseGravableAcum: number; // sin clamp; puede ser negativo
isrCausado: number;
isrRetenido: number;
isrAPagar: number;
estado: EstadoDeclaracion;
fechaDeclaracion: string | null;
}
```
- [ ] **Step 2: Agregar `ResumenIsrDesglosado` al final del archivo**
```ts
/**
* Desglose del cálculo provisional ISR del mes final del filtro:
* delPeriodo = solo el mes final del filtro (1 mes)
* anteriores = enero hasta el mes anterior al final (puede estar vacío)
* total = enero hasta el mes final inclusive
*
* Reglas:
* - delPeriodo + anteriores = total para campos aditivos (ingresos, deducciones, retenciones).
* - Para baseGravable e isrCausado el total se calcula sobre el rango entero
* (no es la suma algebraica de delPeriodo + anteriores).
* - baseGravable puede ser negativa en cualquiera de los tres rangos.
* - isrCausado se clampa a 0 cuando la baseGravable acumulada es negativa.
*/
export interface ResumenIsrDesglosado {
delPeriodo: ResumenIsr;
anteriores: ResumenIsr;
total: ResumenIsr;
/** Mes final del filtro (1-12) */
mesFinal: number;
/** Año fiscal del filtro */
anio: number;
}
```
- [ ] **Step 3: Verificar que el package compile**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/shared typecheck`
Expected: PASS sin errores.
- [ ] **Step 4: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add packages/shared/src/types/impuestos.ts
git commit -m "feat(shared): types para acumulados ISR mensual + desglose del periodo"
```
---
## Task 2: Backend — running totals en `getIsrMensual`
**Files:**
- Modify: `apps/api/src/services/impuestos.service.ts:409-486`
- [ ] **Step 1: Modificar el push del result en el loop interno**
Encontrar el `result.push({ ... })` actual (alrededor de líneas 470-482) y agregar campos placeholder. Cambiar:
```ts
result.push({
id: 0,
año,
mes: m,
ingresosAcumulados: ing,
deducciones: ded,
baseGravable: base,
isrCausado: 0,
isrRetenido: 0,
isrAPagar: 0,
estado: 'pendiente',
fechaDeclaracion: null,
});
```
A:
```ts
result.push({
id: 0,
año,
mes: m,
ingresosAcumulados: ing,
deducciones: ded,
baseGravable: base,
ingresosAcum: 0, // se llena en el segundo pase abajo
deduccionesAcum: 0,
baseGravableAcum: 0,
isrCausado: 0,
isrRetenido: 0,
isrAPagar: 0,
estado: 'pendiente',
fechaDeclaracion: null,
});
```
- [ ] **Step 2: Agregar segundo pase de running totals justo antes del `return result`**
Reemplazar `return result;` por:
```ts
// Running totals: para cada mes, acumular ingresos y deducciones desde enero
// hasta ese mes inclusive. baseGravableAcum NO se clampa — los déficits se
// muestran negativos en la UI y solo se clampan al pasar a ISR causado.
let ingAcum = 0;
let dedAcum = 0;
for (const row of result) {
ingAcum += row.ingresosAcumulados; // (campo mensual, naming heredado)
dedAcum += row.deducciones;
row.ingresosAcum = ingAcum;
row.deduccionesAcum = dedAcum;
row.baseGravableAcum = ingAcum - dedAcum;
}
return result;
}
```
- [ ] **Step 3: Verificar typecheck del API**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
Expected: PASS sin errores. Si falla por que `IsrMensual` requiere los campos nuevos, asegurar que Task 1 ya esté aplicada.
- [ ] **Step 4: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/api/src/services/impuestos.service.ts
git commit -m "feat(api): getIsrMensual computa running totals (ingresos/deducciones/base gravable acumulada)"
```
---
## Task 3: Backend — nueva función `getResumenIsrDesglosado`
**Files:**
- Modify: `apps/api/src/services/impuestos.service.ts` (agregar al final, después de `getResumenIsr`)
- [ ] **Step 1: Agregar la función exportada**
Buscar el final de `getResumenIsr` (alrededor de línea 887) y después del `}` agregar:
```ts
/**
* Desglose del cálculo provisional ISR para el mes final del filtro.
*
* Tres llamadas a getResumenIsr con rangos distintos:
* - delPeriodo: solo el mes final del filtro (1 mes calendario)
* - anteriores: enero hasta el mes anterior al final (vacío si mesFinal=1)
* - total: enero hasta el mes final inclusive
*
* Si mesFinal === 1, la rama "anteriores" no llama al backend — retorna ceros
* para evitar un query inútil.
*/
export async function getResumenIsrDesglosado(
pool: Pool,
fechaFin: string,
tenantId: string,
conciliacion?: boolean,
contribuyenteId?: string | null,
): Promise<import('@horux/shared').ResumenIsrDesglosado> {
const fechaFinDate = new Date(fechaFin + 'T00:00:00');
const anio = fechaFinDate.getFullYear();
const mesFinal = fechaFinDate.getMonth() + 1; // 1-12
// Helper para construir rango fin de mes
const mmFinal = String(mesFinal).padStart(2, '0');
const ultDiaFinal = new Date(anio, mesFinal, 0).getDate();
const ultDiaFinalStr = String(ultDiaFinal).padStart(2, '0');
// delPeriodo: 1er a último día del mes final
const fiPeriodo = `${anio}-${mmFinal}-01`;
const ffPeriodo = `${anio}-${mmFinal}-${ultDiaFinalStr}`;
// anteriores: enero 1 al último día del (mesFinal - 1). Vacío si mesFinal=1.
let anteriores: import('@horux/shared').ResumenIsr;
if (mesFinal === 1) {
anteriores = emptyResumenIsr();
} else {
const mesAntes = mesFinal - 1;
const mmAntes = String(mesAntes).padStart(2, '0');
const ultDiaAntes = new Date(anio, mesAntes, 0).getDate();
const ultDiaAntesStr = String(ultDiaAntes).padStart(2, '0');
const fiAnt = `${anio}-01-01`;
const ffAnt = `${anio}-${mmAntes}-${ultDiaAntesStr}`;
anteriores = await getResumenIsr(pool, fiAnt, ffAnt, tenantId, conciliacion, contribuyenteId);
}
// total: enero 1 al último día del mes final
const fiTotal = `${anio}-01-01`;
const ffTotal = `${anio}-${mmFinal}-${ultDiaFinalStr}`;
const [delPeriodo, total] = await Promise.all([
getResumenIsr(pool, fiPeriodo, ffPeriodo, tenantId, conciliacion, contribuyenteId),
getResumenIsr(pool, fiTotal, ffTotal, tenantId, conciliacion, contribuyenteId),
]);
return { delPeriodo, anteriores, total, mesFinal, anio };
}
function emptyResumenIsr(): import('@horux/shared').ResumenIsr {
return {
ingresosAcumulados: 0,
ingresosPorRegimen: [],
deducciones: 0,
deduccionesPorRegimen: [],
baseGravable: 0,
baseGravablePorRegimen: [],
isrCausado: 0,
isrRetenido: 0,
isrAPagar: 0,
};
}
```
- [ ] **Step 2: Sin import top-level necesario**
El archivo ya usa el patrón `import('@horux/shared').XYZ` inline (ver línea 793 con `BaseGravableRegimen`). El código del Step 1 sigue ese patrón para `ResumenIsr` y `ResumenIsrDesglosado`, así que no hace falta agregar un import top-level. Continuar al Step 3.
- [ ] **Step 3: Verificar typecheck del API**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/api/src/services/impuestos.service.ts
git commit -m "feat(api): getResumenIsrDesglosado retorna {delPeriodo, anteriores, total} para desglose ISR provisional"
```
---
## Task 4: Backend — controller handler
**Files:**
- Modify: `apps/api/src/controllers/impuestos.controller.ts`
- [ ] **Step 1: Agregar handler después de `getResumenIsr` (línea 88)**
Insertar entre `getResumenIsr` y `getCoeficiente`:
```ts
export async function getResumenIsrDesglosado(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
// fechaFin define mes_final + año. Default: último día del mes corriente.
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth() + 1;
const lastDay = new Date(y, m, 0).getDate();
const fechaFin = (req.query.fechaFin as string) || `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
const conciliacion = parseConciliacion(req);
const contribuyenteId = (req.query.contribuyenteId as string) || null;
const desglose = await impuestosService.getResumenIsrDesglosado(
req.tenantPool,
fechaFin,
effectiveTenantId(req),
conciliacion,
contribuyenteId,
);
res.json(desglose);
} catch (error) {
next(error);
}
}
```
- [ ] **Step 2: Verificar typecheck**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/api/src/controllers/impuestos.controller.ts
git commit -m "feat(api): controller handler para resumen-isr-desglosado"
```
---
## Task 5: Backend — wire up route
**Files:**
- Modify: `apps/api/src/routes/impuestos.routes.ts`
- [ ] **Step 1: Agregar la ruta**
Encontrar la línea 17 (`router.get('/isr/resumen', impuestosController.getResumenIsr);`) y agregar inmediatamente después:
```ts
router.get('/isr/resumen-desglosado', impuestosController.getResumenIsrDesglosado);
```
El bloque queda así:
```ts
router.get('/iva/mensual', impuestosController.getIvaMensual);
router.get('/iva/resumen', impuestosController.getResumenIva);
router.get('/isr/mensual', impuestosController.getIsrMensual);
router.get('/isr/resumen', impuestosController.getResumenIsr);
router.get('/isr/resumen-desglosado', impuestosController.getResumenIsrDesglosado);
router.get('/isr/coeficiente', impuestosController.getCoeficiente);
router.put('/isr/coeficiente', impuestosController.setCoeficiente);
```
- [ ] **Step 2: Verificar typecheck**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
Expected: PASS.
- [ ] **Step 3: Smoke test del endpoint con un tenant existente**
Necesitas el dev API corriendo. En otra terminal:
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
pnpm dev
```
Login con un usuario que tenga datos (p.ej. del tenant Patito) y obtener el JWT. Luego:
```bash
# Reemplazar TOKEN por el JWT real
curl -s "http://localhost:4000/api/impuestos/isr/resumen-desglosado?fechaFin=2026-03-31&conciliacion=false" \
-H "Authorization: Bearer $TOKEN" | jq '. | {mesFinal, anio, "delPeriodo.ingresos": .delPeriodo.ingresosAcumulados, "anteriores.ingresos": .anteriores.ingresosAcumulados, "total.ingresos": .total.ingresosAcumulados}'
```
Expected:
- `mesFinal: 3, anio: 2026`
- `total.ingresos === delPeriodo.ingresos + anteriores.ingresos` (suma debe cuadrar para ingresos/deducciones/retenciones)
- `total.baseGravable` puede diferir de la suma (BG no es aditiva si hay meses de pérdida).
Probar también `fechaFin=2026-01-31` y verificar `anteriores.ingresosAcumulados === 0`.
- [ ] **Step 4: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/api/src/routes/impuestos.routes.ts
git commit -m "feat(api): ruta GET /impuestos/isr/resumen-desglosado"
```
---
## Task 6: Frontend — API client
**Files:**
- Modify: `apps/web/lib/api/impuestos.ts`
- [ ] **Step 1: Actualizar import de types**
En la línea 2 cambiar:
```ts
import type { IvaMensual, IsrMensual, ResumenIva, ResumenIsr } from '@horux/shared';
```
A:
```ts
import type { IvaMensual, IsrMensual, ResumenIva, ResumenIsr, ResumenIsrDesglosado } from '@horux/shared';
```
- [ ] **Step 2: Agregar la función al final del archivo**
Después de `getResumenIsr` (línea 51), agregar:
```ts
export async function getResumenIsrDesglosado(
fechaFin: string,
conciliacion?: boolean,
contribuyenteId?: string | null,
): Promise<ResumenIsrDesglosado> {
const params = new URLSearchParams();
params.set('fechaFin', fechaFin);
if (conciliacion) params.set('conciliacion', 'true');
if (contribuyenteId) params.set('contribuyenteId', contribuyenteId);
const response = await apiClient.get<ResumenIsrDesglosado>(`/impuestos/isr/resumen-desglosado?${params}`);
return response.data;
}
```
- [ ] **Step 3: Verificar typecheck del web**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/web typecheck`
Expected: PASS. Si la app no tiene script `typecheck`, correr `pnpm --filter @horux/web exec tsc --noEmit`.
- [ ] **Step 4: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/web/lib/api/impuestos.ts
git commit -m "feat(web): cliente API getResumenIsrDesglosado"
```
---
## Task 7: Frontend — hook `useResumenIsrDesglosado`
**Files:**
- Modify: `apps/web/lib/hooks/use-impuestos.ts`
- [ ] **Step 1: Agregar hook al final del archivo**
Después de `useResumenIsr` (línea 55), agregar:
```ts
export function useResumenIsrDesglosado(fechaFin: string, conciliacion?: boolean) {
const tk = useTenantKey();
const { selectedContribuyenteId } = useContribuyenteStore();
return useQuery({
queryKey: ['isr-resumen-desglosado', tk, fechaFin, conciliacion, selectedContribuyenteId],
queryFn: () => impuestosApi.getResumenIsrDesglosado(fechaFin, conciliacion, selectedContribuyenteId),
enabled: !!fechaFin,
});
}
```
- [ ] **Step 2: Verificar typecheck**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/web exec tsc --noEmit`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/web/lib/hooks/use-impuestos.ts
git commit -m "feat(web): hook useResumenIsrDesglosado"
```
---
## Task 8: Frontend — Tabla "Histórico ISR" con columnas acumuladas
**Files:**
- Modify: `apps/web/app/(dashboard)/impuestos/page.tsx:502-568`
- [ ] **Step 1: Reemplazar el bloque del export Excel (líneas 506-524)**
Cambiar:
```tsx
{isrMensual && isrMensual.length > 0 && (
<Button variant="outline" size="sm" onClick={() => exportToExcel(
isrMensual.map(r => ({
Mes: meses[r.mes - 1],
Ingresos: r.ingresosAcumulados,
Deducciones: r.deducciones,
'Base Gravable': r.baseGravable,
})),
[
{ header: 'Mes', key: 'Mes', width: 12 },
{ header: 'Ingresos', key: 'Ingresos', width: 18 },
{ header: 'Deducciones', key: 'Deducciones', width: 18 },
{ header: 'Base Gravable', key: 'Base Gravable', width: 18 },
],
`isr-mensual-${año}`,
)}>
<Download className="h-4 w-4 mr-1" /> Excel
</Button>
)}
```
A:
```tsx
{isrMensual && isrMensual.length > 0 && (
<Button variant="outline" size="sm" onClick={() => exportToExcel(
isrMensual.map(r => ({
Mes: meses[r.mes - 1],
Ingresos: r.ingresosAcumulados,
'Ingresos Acumulados': r.ingresosAcum,
Deducciones: r.deducciones,
'Deducciones Acumuladas': r.deduccionesAcum,
'Base Gravable Acumulada': r.baseGravableAcum,
})),
[
{ header: 'Mes', key: 'Mes', width: 12 },
{ header: 'Ingresos', key: 'Ingresos', width: 18 },
{ header: 'Ingresos Acumulados', key: 'Ingresos Acumulados', width: 22 },
{ header: 'Deducciones', key: 'Deducciones', width: 18 },
{ header: 'Deducciones Acumuladas', key: 'Deducciones Acumuladas', width: 22 },
{ header: 'Base Gravable Acumulada', key: 'Base Gravable Acumulada', width: 22 },
],
`isr-mensual-${año}`,
)}>
<Download className="h-4 w-4 mr-1" /> Excel
</Button>
)}
```
- [ ] **Step 2: Reemplazar el `<thead>` (líneas 532-538)**
Cambiar:
```tsx
<thead>
<tr className="border-b text-left text-sm text-muted-foreground">
<th className="pb-3 font-medium">Mes</th>
<th className="pb-3 font-medium text-right">Ingresos</th>
<th className="pb-3 font-medium text-right">Deducciones</th>
<th className="pb-3 font-medium text-right">Base Gravable</th>
</tr>
</thead>
```
A:
```tsx
<thead>
<tr className="border-b text-left text-sm text-muted-foreground">
<th className="pb-3 font-medium">Mes</th>
<th className="pb-3 font-medium text-right">Ingresos</th>
<th className="pb-3 font-medium text-right">Ingresos Acum.</th>
<th className="pb-3 font-medium text-right">Deducciones</th>
<th className="pb-3 font-medium text-right">Deducciones Acum.</th>
<th className="pb-3 font-medium text-right">Base Gravable Acum.</th>
</tr>
</thead>
```
- [ ] **Step 3: Reemplazar el `<tbody>` filas y la fila Total (líneas 540-566)**
Cambiar el bloque entero de `<tbody>...</tbody>` por:
```tsx
<tbody className="text-sm">
{isrMensual?.map((row) => (
<tr key={row.mes} className="border-b hover:bg-muted/50">
<td className="py-3 font-medium">{meses[row.mes - 1]}</td>
<td className="py-3 text-right">{formatCurrency(row.ingresosAcumulados)}</td>
<td className="py-3 text-right">{formatCurrency(row.ingresosAcum)}</td>
<td className="py-3 text-right">{formatCurrency(row.deducciones)}</td>
<td className="py-3 text-right">{formatCurrency(row.deduccionesAcum)}</td>
<td className={cn(
'py-3 text-right font-medium',
row.baseGravableAcum < 0 ? 'text-destructive' : ''
)}>
{formatCurrency(row.baseGravableAcum)}
</td>
</tr>
))}
{(!isrMensual || isrMensual.length === 0) && (
<tr>
<td colSpan={6} className="py-8 text-center text-muted-foreground">
No hay registros de ISR para este año
</td>
</tr>
)}
</tbody>
```
Notas:
- Removida la fila Total. La última fila (con datos) ya es el YTD al cierre de ese mes.
- `colSpan={6}` actualizado de 4.
- [ ] **Step 4: Verificar typecheck del web**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/web exec tsc --noEmit`
Expected: PASS.
- [ ] **Step 5: Smoke manual de la tabla**
Si el dev no está corriendo: `pnpm dev`. Luego:
1. Abrir http://localhost:3000/impuestos en el navegador.
2. Cambiar a la pestaña ISR.
3. Verificar que aparezcan **6 columnas** en la tabla "Histórico ISR".
4. Verificar que las columnas Ingresos Acum., Deducciones Acum. y Base Gravable Acum. muestren running totals correctos (la fila de febrero debe tener acumulado = enero + febrero).
5. Si hay un mes con BG negativa, verificar que aparezca **en rojo** (`text-destructive`).
6. Hacer click en "Excel" y verificar que el archivo descargado tenga las 6 columnas alineadas con el orden de la UI.
- [ ] **Step 6: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/web/app/\(dashboard\)/impuestos/page.tsx
git commit -m "feat(web): tabla Histórico ISR con columnas acumuladas; BG mensual deja de mostrarse"
```
---
## Task 9: Frontend — Sección "Cálculo de ISR del Periodo"
**Files:**
- Modify: `apps/web/app/(dashboard)/impuestos/page.tsx:371-432`
- [ ] **Step 1: Importar el nuevo hook**
Buscar la línea 7:
```ts
import { useIvaMensual, useIsrMensual, useResumenIva, useResumenIsr, useCoeficiente } from '@/lib/hooks/use-impuestos';
```
Cambiar a:
```ts
import { useIvaMensual, useIsrMensual, useResumenIva, useResumenIsr, useResumenIsrDesglosado, useCoeficiente } from '@/lib/hooks/use-impuestos';
```
- [ ] **Step 2: Llamar al nuevo hook después de `useResumenIsr`**
Buscar la línea 46 (`const { data: resumenIsr } = useResumenIsr(fechaInicio, fechaFin, conciliacion);`) y agregar inmediatamente después:
```ts
const { data: resumenIsrDesglose } = useResumenIsrDesglosado(fechaFin, conciliacion);
```
- [ ] **Step 3: Reescribir la sección del Card "Cálculo de ISR Acumulado"**
Reemplazar el bloque desde `<CardTitle className="text-base">Calculo de ISR Acumulado</CardTitle>` hasta el cierre `</CardContent>` correspondiente (aproximadamente líneas 381-432) por:
```tsx
<CardTitle className="text-base">Cálculo de ISR del Periodo</CardTitle>
</CardHeader>
<CardContent>
{(() => {
// Etiquetas dinámicas a partir del mesFinal del filtro
const desglose = resumenIsrDesglose;
if (!desglose) {
return <div className="text-sm text-muted-foreground">Cargando</div>;
}
const { delPeriodo, anteriores, total, mesFinal, anio } = desglose;
const labelMesFinal = `${meses[mesFinal - 1]} ${anio}`;
const labelAnteriores =
mesFinal === 1
? '(sin meses anteriores)'
: mesFinal === 2
? `(${meses[0]})`
: `(${meses[0]}-${meses[mesFinal - 2]})`;
// Resolver per-régimen si hay régimen seleccionado, igual patrón que antes.
const ingPer = regimenSeleccionado
? delPeriodo.ingresosPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: delPeriodo.ingresosAcumulados || 0;
const ingAnt = regimenSeleccionado
? anteriores.ingresosPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: anteriores.ingresosAcumulados || 0;
const dedPer = regimenSeleccionado
? delPeriodo.deduccionesPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: delPeriodo.deducciones || 0;
const dedAnt = regimenSeleccionado
? anteriores.deduccionesPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: anteriores.deducciones || 0;
const bgTotal = regimenSeleccionado
? total.baseGravablePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.baseGravable || 0
: total.baseGravable || 0;
const causadoTotal = regimenSeleccionado
? total.baseGravablePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.isrCausado || 0
: total.isrCausado || 0;
const retenido = total.isrRetenido || 0;
const aPagar = Math.max(0, causadoTotal - (regimenSeleccionado ? 0 : retenido));
return (
<div className="space-y-1">
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">Ingresos del periodo ({labelMesFinal})</span>
<span className="font-medium">{formatCurrency(ingPer)}</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">(+) Ingresos acumulados anteriores {labelAnteriores}</span>
<span className="font-medium">{formatCurrency(ingAnt)}</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">() Deducciones del periodo ({labelMesFinal})</span>
<span className="font-medium">{formatCurrency(dedPer)}</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">() Deducciones acumuladas anteriores {labelAnteriores}</span>
<span className="font-medium">{formatCurrency(dedAnt)}</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="font-medium">(=) Base gravable acumulada</span>
<span className={cn('font-medium', bgTotal < 0 ? 'text-destructive' : '')}>
{formatCurrency(bgTotal)}
</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">ISR causado (acumulado)</span>
<span className="font-medium">{formatCurrency(causadoTotal)}</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">() ISR retenido (acumulado)</span>
<span className="font-medium">{formatCurrency(regimenSeleccionado ? 0 : retenido)}</span>
</div>
<div className="flex justify-between py-2 bg-muted/50 px-4 rounded-lg mt-2">
<span className="font-medium">ISR a pagar</span>
<span className="font-bold text-lg">{formatCurrency(aPagar)}</span>
</div>
</div>
);
})()}
</CardContent>
```
Nota: `cn` ya está importado al inicio del archivo (línea 12). Si por alguna razón no lo está, agregar `cn` al import de `@horux/shared-ui`.
- [ ] **Step 4: Verificar typecheck**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/web exec tsc --noEmit`
Expected: PASS.
- [ ] **Step 5: Smoke manual de la sección**
Con el dev corriendo y un tenant con datos:
1. Abrir `/impuestos` → pestaña ISR.
2. Filtro de periodo en el mes corriente: verificar que aparezcan los 4 renglones de descomposición + base gravable + ISR causado + ISR retenido + ISR a pagar.
3. Cambiar el filtro a **enero del año en curso**: verificar que las dos líneas "anteriores" muestren `$0` con la etiqueta `(sin meses anteriores)`.
4. Cambiar el filtro a **febrero**: la etiqueta de "anteriores" debe decir `(Ene)`.
5. Cambiar el filtro a **marzo**: etiqueta `(Ene-Feb)`.
6. Si hay un tenant con pérdidas YTD: verificar que la línea "Base gravable acumulada" aparezca **en rojo** y que ISR a pagar sea `$0`.
7. Aritmética cruzada: la suma `Ing del periodo + Ing anteriores Ded del periodo Ded anteriores` debe coincidir con la línea Base gravable acumulada.
8. Probar también con **régimen seleccionado** en el dropdown — los números deben filtrar correctamente.
- [ ] **Step 6: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/web/app/\(dashboard\)/impuestos/page.tsx
git commit -m "feat(web): sección 'Cálculo de ISR del Periodo' con desglose periodo+anteriores=total"
```
---
## Task 10: Verificación final + sync OneDrive + commit release
**Files:**
- Verify: typecheck completo del repo
- Copy: 6 archivos modificados/nuevos a OneDrive
- Commit: bump de versión en OneDrive (mantener pattern V.1.0.x)
- [ ] **Step 1: Typecheck completo**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
pnpm --filter @horux/shared typecheck
pnpm --filter @horux/api typecheck
pnpm --filter @horux/web exec tsc --noEmit
```
Expected: los tres en PASS sin errores. Si hay errores, regresar al task correspondiente.
- [ ] **Step 2: Smoke test cross-feature**
Con dev corriendo, en el browser:
1. Cambiar entre IVA y ISR — verificar que IVA siga funcionando igual (no afectado).
2. Toggle conciliación on/off — verificar que la sección de cálculo y la tabla actualicen.
3. Cambiar contribuyente activo — verificar que los queries refetchean con el contribuyente nuevo.
4. Validar que los KPIs de la parte alta (Ingresos, Base Gravable, etc.) sigan mostrando los valores del rango filtrado completo (estos NO deben cambiar — solo afectamos la tabla y la sección de cálculo).
- [ ] **Step 3: Copiar archivos a OneDrive**
```bash
SRC="C:/Users/chtr1/Downloads/Horux_despacho"
DST="C:/Users/chtr1/OneDrive/Documentos/GitHub/Horux_despachos"
cp -p "$SRC/packages/shared/src/types/impuestos.ts" "$DST/packages/shared/src/types/impuestos.ts"
cp -p "$SRC/apps/api/src/services/impuestos.service.ts" "$DST/apps/api/src/services/impuestos.service.ts"
cp -p "$SRC/apps/api/src/controllers/impuestos.controller.ts" "$DST/apps/api/src/controllers/impuestos.controller.ts"
cp -p "$SRC/apps/api/src/routes/impuestos.routes.ts" "$DST/apps/api/src/routes/impuestos.routes.ts"
cp -p "$SRC/apps/web/lib/api/impuestos.ts" "$DST/apps/web/lib/api/impuestos.ts"
cp -p "$SRC/apps/web/lib/hooks/use-impuestos.ts" "$DST/apps/web/lib/hooks/use-impuestos.ts"
cp -p "$SRC/apps/web/app/(dashboard)/impuestos/page.tsx" "$DST/apps/web/app/(dashboard)/impuestos/page.tsx"
cp -p "$SRC/docs/superpowers/specs/2026-04-27-isr-base-gravable-acumulada-design.md" "$DST/docs/superpowers/specs/2026-04-27-isr-base-gravable-acumulada-design.md"
cp -p "$SRC/docs/superpowers/plans/2026-04-27-isr-base-gravable-acumulada.md" "$DST/docs/superpowers/plans/2026-04-27-isr-base-gravable-acumulada.md"
```
- [ ] **Step 4: Verificar diff OneDrive vs Downloads**
```bash
diff -rq \
--exclude=node_modules --exclude=.git --exclude=.turbo --exclude=.next \
--exclude=dist --exclude=tsconfig.tsbuildinfo --exclude=email-previews \
--exclude=pnpm-lock.yaml --exclude=.env --exclude=.env.local \
"C:/Users/chtr1/Downloads/Horux_despacho" \
"C:/Users/chtr1/OneDrive/Documentos/GitHub/Horux_despachos"
```
Expected: única diferencia esperada es `Only in C:/Users/chtr1/Downloads/Horux_despacho/apps/api: data` (XMLs runtime). Si aparece otra diferencia inesperada, investigar.
- [ ] **Step 5: Commit en OneDrive**
```bash
cd "C:/Users/chtr1/OneDrive/Documentos/GitHub/Horux_despachos"
git add \
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" \
docs/superpowers/specs/2026-04-27-isr-base-gravable-acumulada-design.md \
docs/superpowers/plans/2026-04-27-isr-base-gravable-acumulada.md
git commit -m "V.1.0.6"
git status --short
git log -2 --oneline
```
Expected:
- Commit creado con hash nuevo, mensaje `V.1.0.6` (mantiene el pattern de OneDrive).
- `git status` clean.
- `git log -2` muestra V.1.0.6 sobre V.1.0.5.
- [ ] **Step 6: NO push automático**
Per workflow del owner: el push a `origin/main` lo dispara él manualmente cuando quiera. Confirmar que NO se ejecutó `git push`.

View File

@@ -0,0 +1,797 @@
# Horux360 SaaS Transformation — Design Spec
**Date:** 2026-03-15
**Status:** Approved
**Author:** Carlos Horux + Claude
## Overview
Transform Horux360 from an internal multi-tenant accounting tool into a production-ready SaaS platform. Client registration remains manual (sales-led). Each client gets a fully isolated PostgreSQL database. Payments via MercadoPago. Transactional emails via Gmail SMTP (@horuxfin.com). Production deployment on existing server (192.168.10.212).
**Target scale:** 10-50 clients within 6 months.
**Starting from scratch:** No data migration. Existing schemas/data will be archived. Fresh setup.
---
## Section 1: Database-Per-Tenant Architecture
### Rationale
Clients sign NDAs requiring complete data isolation. Schema-per-tenant (current approach) shares a single database. Database-per-tenant provides:
- Independent backup/restore per client
- No risk of cross-tenant data leakage
- Each DB can be moved to a different server if needed
### Structure
```
PostgreSQL Server (max_connections: 300)
├── horux360 ← Central DB (Prisma-managed)
├── horux_cas2408138w2 ← Client DB (raw SQL)
├── horux_roem691011ez4 ← Client DB
└── ...
```
### Central DB (`horux360`) — Prisma-managed tables
Existing tables (modified):
- `tenants` — add `database_name` column, remove `schema_name`
- `users` — no changes
- `refresh_tokens` — flush all existing tokens at migration cutover (invalidate all sessions)
- `fiel_credentials` — no changes
New tables:
- `subscriptions` — MercadoPago subscription tracking
- `payments` — payment history
### Prisma schema migration
The Prisma schema (`apps/api/prisma/schema.prisma`) must be updated:
- Replace `schema_name String @unique @map("schema_name")` with `database_name String @unique @map("database_name")` on the `Tenant` model
- Add `Subscription` and `Payment` models
- Run `prisma migrate dev` to generate and apply migration
- Update `Tenant` type in `packages/shared/src/types/tenant.ts`: replace `schemaName` with `databaseName`
### JWT payload migration
The current JWT payload embeds `schemaName`. This must change:
- Update `JWTPayload` in `packages/shared/src/types/auth.ts`: replace `schemaName` with `databaseName`
- Update token generation in `auth.service.ts`: read `tenant.databaseName` instead of `tenant.schemaName`
- Update `refreshTokens` function to embed `databaseName`
- At migration cutover: flush `refresh_tokens` table to invalidate all existing sessions (forces re-login)
### Client DB naming
Formula: `horux_<rfc_normalized>`
```
RFC "HTS240708LJA" → horux_cas2408138w2
RFC "TPR840604D98" → horux_tpr840604d98
```
### Client DB tables (created via raw SQL)
Each client database contains these tables (no schema prefix, direct `public` schema):
- `cfdis` — with indexes: fecha_emision DESC, tipo, rfc_emisor, rfc_receptor, pg_trgm on nombre_emisor/nombre_receptor, uuid_fiscal unique
- `iva_mensual`
- `isr_mensual`
- `alertas`
- `calendario_fiscal`
### TenantConnectionManager
```typescript
class TenantConnectionManager {
private pools: Map<string, { pool: pg.Pool; lastAccess: Date }>;
private cleanupInterval: NodeJS.Timer;
// Get or create a pool for a tenant
getPool(tenantId: string, databaseName: string): pg.Pool;
// Create a new tenant database with all tables and indexes
provisionDatabase(rfc: string): Promise<string>;
// Drop a tenant database (soft-delete: rename to horux_deleted_<rfc>_<timestamp>)
deprovisionDatabase(databaseName: string): Promise<void>;
// Cleanup idle pools (called every 60s, removes pools idle > 5min)
private cleanupIdlePools(): void;
}
```
Pool configuration per tenant:
- `max`: 3 connections (with 2 PM2 cluster instances, this means 6 connections/tenant max; at 50 tenants = 300, matching `max_connections`)
- `idleTimeoutMillis`: 300000 (5 min)
- `connectionTimeoutMillis`: 10000 (10 sec)
**Note on PM2 cluster mode:** Each PM2 worker is a separate Node.js process with its own `TenantConnectionManager` instance. With `instances: 2` and `max: 3` per pool, worst case is 50 tenants × 3 connections × 2 workers = 300 connections, which matches `max_connections = 300`. If scaling beyond 50 tenants, either increase `max_connections` or reduce pool `max` to 2.
### Tenant middleware change
Current: Sets `search_path` on a shared connection.
New: Returns a dedicated pool connected to the tenant's own database.
```typescript
// Before
req.tenantSchema = schema;
await pool.query(`SET search_path TO "${schema}", public`);
// After
req.tenantPool = tenantConnectionManager.getPool(tenant.id, tenant.databaseName);
```
All tenant service functions change from using a shared pool with schema prefix to using `req.tenantPool` with direct table names.
### Admin impersonation (X-View-Tenant)
The current `X-View-Tenant` header support for admin "view-as" functionality is preserved. The new middleware resolves the `databaseName` for the viewed tenant:
```typescript
// If admin is viewing another tenant
if (req.headers['x-view-tenant'] && req.user.role === 'admin') {
const viewedTenant = await getTenantByRfc(req.headers['x-view-tenant']);
req.tenantPool = tenantConnectionManager.getPool(viewedTenant.id, viewedTenant.databaseName);
} else {
req.tenantPool = tenantConnectionManager.getPool(tenant.id, tenant.databaseName);
}
```
### Provisioning flow (new client)
1. Admin creates tenant via UI → POST `/api/tenants/`
2. Insert record in `horux360.tenants` with `database_name`
3. Execute `CREATE DATABASE horux_<rfc>`
4. Connect to new DB, create all tables + indexes
5. Create admin user in `horux360.users` linked to tenant
6. Send welcome email with temporary credentials
7. Generate MercadoPago subscription link
**Rollback on partial failure:** If any step 3-7 fails:
- Drop the created database if it exists (`DROP DATABASE IF EXISTS horux_<rfc>`)
- Delete the `tenants` row
- Delete the `users` row if created
- Return error to admin with the specific step that failed
- The entire provisioning is wrapped in a try/catch with explicit cleanup
### PostgreSQL tuning
```
max_connections = 300
shared_buffers = 4GB
work_mem = 16MB
effective_cache_size = 16GB
maintenance_work_mem = 512MB
```
### Server disk
Expand from 29 GB to 100 GB to accommodate:
- 25-50 client databases (~2-3 GB total)
- Daily backups with 7-day retention (~15 GB)
- FIEL encrypted files (<100 MB)
- Logs, builds, OS (~10 GB)
---
## Section 2: SAT Credential Storage (FIEL)
### Dual storage strategy
When a client uploads their FIEL (.cer + .key + password):
**A. Filesystem (for manual linking):**
```
/var/horux/fiel/
├── HTS240708LJA/
│ ├── certificate.cer.enc ← AES-256-GCM encrypted
│ ├── private_key.key.enc ← AES-256-GCM encrypted
│ └── metadata.json.enc ← serial, validity dates, upload date (also encrypted)
└── ROEM691011EZ4/
├── certificate.cer.enc
├── private_key.key.enc
└── metadata.json.enc
```
**B. Central DB (`fiel_credentials` table):**
- Existing structure: `cer_data`, `key_data`, `key_password_encrypted`, `encryption_iv`, `encryption_tag`
- **Schema change required:** Add per-component IV/tag columns (`cer_iv`, `cer_tag`, `key_iv`, `key_tag`, `password_iv`, `password_tag`) to support independent encryption per component. Alternatively, use a single JSON column for all encryption metadata. The existing `encryption_iv` and `encryption_tag` columns can be dropped after migration.
### Encryption
- Algorithm: AES-256-GCM
- Key: `FIEL_ENCRYPTION_KEY` environment variable (separate from other secrets)
- **Code change required:** `sat-crypto.service.ts` currently derives the key from `JWT_SECRET` via `createHash('sha256').update(env.JWT_SECRET).digest()`. This must be changed to read `FIEL_ENCRYPTION_KEY` from the env schema. The `env.ts` Zod schema must be updated to declare `FIEL_ENCRYPTION_KEY` as required.
- Each component (certificate, private key, password) is encrypted separately with its own IV and auth tag. The `fiel_credentials` table stores separate `encryption_iv` and `encryption_tag` per row. The filesystem also stores each file independently encrypted.
- **Code change required:** The current `sat-crypto.service.ts` shares a single IV/tag across all three components. Refactor to encrypt each component independently with its own IV/tag. Store per-component IV/tags in the DB (add columns: `cer_iv`, `cer_tag`, `key_iv`, `key_tag`, `password_iv`, `password_tag` — or use a JSON column).
- Password is encrypted, never stored in plaintext
### Manual decryption CLI
```bash
node scripts/decrypt-fiel.js --rfc HTS240708LJA
```
- Decrypts files to `/tmp/horux-fiel-<rfc>/`
- Files auto-delete after 30 minutes (via setTimeout or tmpwatch)
- Requires SSH access to server
### Security
- `/var/horux/fiel/` permissions: `700` (root only)
- Encrypted files are useless without `FIEL_ENCRYPTION_KEY`
- `metadata.json` is also encrypted (contains serial number + RFC which could be used to query SAT's certificate validation service, violating NDA confidentiality requirements)
### Upload flow
1. Client navigates to `/configuracion/sat`
2. Uploads `.cer` + `.key` files + enters password
3. API validates the certificate (checks it's a valid FIEL, not expired)
4. Encrypts and stores in both filesystem and database
5. Sends notification email to admin team: "Cliente X subió su FIEL"
---
## Section 3: Payment System (MercadoPago)
### Integration approach
Using MercadoPago's **Preapproval (Subscription)** API for recurring payments.
### New tables in central DB
```sql
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
plan VARCHAR(20) NOT NULL,
mp_preapproval_id VARCHAR(100),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
-- status: pending | authorized | paused | cancelled
amount DECIMAL(10,2) NOT NULL,
frequency VARCHAR(10) NOT NULL DEFAULT 'monthly',
-- frequency: monthly | yearly
current_period_start TIMESTAMP,
current_period_end TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_subscriptions_tenant_id ON subscriptions(tenant_id);
CREATE INDEX idx_subscriptions_status ON subscriptions(status);
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
subscription_id UUID REFERENCES subscriptions(id),
mp_payment_id VARCHAR(100),
amount DECIMAL(10,2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
-- status: approved | pending | rejected | refunded
payment_method VARCHAR(50),
paid_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_payments_tenant_id ON payments(tenant_id);
CREATE INDEX idx_payments_subscription_id ON payments(subscription_id);
```
### Plans and pricing
Defined in `packages/shared/src/constants/plans.ts` (update existing):
| Plan | Monthly price (MXN) | CFDIs | Users | Features |
|------|---------------------|-------|-------|----------|
| starter | Configurable | 100 | 1 | dashboard, cfdi_basic, iva_isr |
| business | Configurable | 500 | 3 | + reportes, alertas, calendario |
| professional | Configurable | 2,000 | 10 | + xml_sat, conciliacion, forecasting |
| enterprise | Configurable | Unlimited | Unlimited | + api, multi_empresa |
Prices are configured from admin panel, not hardcoded.
### Subscription flow
1. Admin creates tenant and assigns plan
2. Admin clicks "Generate payment link" → API creates MercadoPago Preapproval
3. Link is sent to client via email
4. Client pays → MercadoPago sends webhook
5. System activates subscription, records payment
### Webhook endpoint
`POST /api/webhooks/mercadopago` (public, no auth)
Validates webhook signature using `x-signature` header and `x-request-id`.
Events handled:
- `payment` → query MercadoPago API for payment details → insert into `payments`, update subscription period
- `subscription_preapproval` → update subscription status (authorized, paused, cancelled)
On payment failure or subscription cancellation:
- Mark tenant `active = false`
- Client gets read-only access (can view data but not upload CFDIs, generate reports, etc.)
### Admin panel additions
- View subscription status per client (active, amount, next billing date)
- Generate payment link button
- "Mark as paid manually" button (for bank transfer payments)
- Payment history per client
### Client panel additions
- New section in `/configuracion`: "Mi suscripción"
- Shows: current plan, next billing date, payment history
- Client cannot change plan themselves (admin does it)
### Environment variables
```
MP_ACCESS_TOKEN=<mercadopago_access_token>
MP_WEBHOOK_SECRET=<webhook_signature_secret>
MP_NOTIFICATION_URL=https://horux360.horux360.com/api/webhooks/mercadopago
```
---
## Section 4: Transactional Emails
### Transport
Nodemailer with Gmail SMTP (Google Workspace).
```
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=<user>@horuxfin.com
SMTP_PASS=<google_app_password>
SMTP_FROM=Horux360 <noreply@horuxfin.com>
```
Requires generating an App Password in Google Workspace admin.
### Email types
| Event | Recipient | Subject |
|-------|-----------|---------|
| Client registered | Client | Bienvenido a Horux360 |
| FIEL uploaded | Admin team | [Cliente] subió su FIEL |
| Payment received | Client | Confirmación de pago - Horux360 |
| Payment failed | Client + Admin | Problema con tu pago - Horux360 |
| Subscription expiring | Client | Tu suscripción vence en 5 días |
| Subscription cancelled | Client + Admin | Suscripción cancelada - Horux360 |
### Template approach
HTML templates as TypeScript template literal functions. No external template engine.
```typescript
// services/email/templates/welcome.ts
export function welcomeEmail(data: { nombre: string; email: string; tempPassword: string; loginUrl: string }): string {
return `<!DOCTYPE html>...`;
}
```
Each template:
- Responsive HTML email (inline CSS)
- Horux360 branding (logo, colors)
- Plain text fallback
### Email service
```typescript
class EmailService {
sendWelcome(to: string, data: WelcomeData): Promise<void>;
sendFielNotification(data: FielNotificationData): Promise<void>;
sendPaymentConfirmation(to: string, data: PaymentData): Promise<void>;
sendPaymentFailed(to: string, data: PaymentData): Promise<void>;
sendSubscriptionExpiring(to: string, data: SubscriptionData): Promise<void>;
sendSubscriptionCancelled(to: string, data: SubscriptionData): Promise<void>;
}
```
### Limits
Gmail Workspace: 500 emails/day. Expected volume for 25 clients: ~50-100 emails/month. Well within limits.
---
## Section 5: Production Deployment
### Build pipeline
**API:**
```bash
cd apps/api && pnpm build # tsc → dist/
pnpm start # node dist/index.js
```
**Web:**
```bash
cd apps/web && pnpm build # next build → .next/
pnpm start # next start (optimized server)
```
### PM2 configuration
```javascript
// ecosystem.config.js
module.exports = {
apps: [
{
name: 'horux-api',
script: 'dist/index.js',
cwd: '/root/Horux/apps/api',
instances: 2,
exec_mode: 'cluster',
env: { NODE_ENV: 'production' }
},
{
name: 'horux-web',
script: 'node_modules/.bin/next',
args: 'start',
cwd: '/root/Horux/apps/web',
instances: 1,
exec_mode: 'fork',
env: { NODE_ENV: 'production' }
}
]
};
```
Auto-restart on crash. Log rotation via `pm2-logrotate`.
### Nginx reverse proxy
```nginx
# Rate limiting zone definitions (in http block of nginx.conf)
limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=webhooks:10m rate=30r/m;
server {
listen 80;
server_name horux360.horux360.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name horux360.horux360.com;
ssl_certificate /etc/letsencrypt/live/horux360.horux360.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/horux360.horux360.com/privkey.pem;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
# Gzip
gzip on;
gzip_types text/plain application/json application/javascript text/css;
# Health check (for monitoring)
location /api/health {
proxy_pass http://127.0.0.1:4000;
}
# Rate limiting for public endpoints
location /api/auth/ {
limit_req zone=auth burst=5 nodelay;
proxy_pass http://127.0.0.1:4000;
}
location /api/webhooks/ {
limit_req zone=webhooks burst=10 nodelay;
proxy_pass http://127.0.0.1:4000;
}
# API
location /api/ {
proxy_pass http://127.0.0.1:4000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 200M; # Bulk XML uploads (200MB is enough for ~50k XML files)
}
# Next.js
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### Health check endpoint
The existing `GET /health` endpoint returns `{ status: 'ok', timestamp }`. PM2 uses this for liveness checks. Nginx can optionally use it for upstream health monitoring.
### SSL
Let's Encrypt with certbot. Auto-renewal via cron.
```bash
certbot --nginx -d horux360.horux360.com
```
### Firewall
```bash
ufw allow 22/tcp # SSH
ufw allow 80/tcp # HTTP (redirect to HTTPS)
ufw allow 443/tcp # HTTPS
ufw enable
```
PostgreSQL only on localhost (no external access).
### Backups
Cron job at **1:00 AM** daily (runs before SAT cron at 3:00 AM, with enough gap to complete):
**Authentication:** Create a `.pgpass` file at `/root/.pgpass` with `localhost:5432:*:postgres:<password>` and `chmod 600`. This allows `pg_dump` to authenticate without inline passwords.
```bash
#!/bin/bash
# /var/horux/scripts/backup.sh
set -euo pipefail
BACKUP_DIR=/var/horux/backups
DATE=$(date +%Y-%m-%d)
DOW=$(date +%u) # Day of week: 1=Monday, 7=Sunday
DAILY_DIR=$BACKUP_DIR/daily
WEEKLY_DIR=$BACKUP_DIR/weekly
mkdir -p $DAILY_DIR $WEEKLY_DIR
# Backup central DB
pg_dump -h localhost -U postgres horux360 | gzip > $DAILY_DIR/horux360_$DATE.sql.gz
# Backup each tenant DB
for db in $(psql -h localhost -U postgres -t -c "SELECT database_name FROM tenants WHERE database_name IS NOT NULL" horux360); do
db_trimmed=$(echo $db | xargs) # trim whitespace
pg_dump -h localhost -U postgres "$db_trimmed" | gzip > $DAILY_DIR/${db_trimmed}_${DATE}.sql.gz
done
# On Sundays, copy to weekly directory
if [ "$DOW" -eq 7 ]; then
cp $DAILY_DIR/*_${DATE}.sql.gz $WEEKLY_DIR/
fi
# Remove daily backups older than 7 days
find $DAILY_DIR -name "*.sql.gz" -mtime +7 -delete
# Remove weekly backups older than 28 days
find $WEEKLY_DIR -name "*.sql.gz" -mtime +28 -delete
# Verify backup files are not empty (catch silent pg_dump failures)
for f in $DAILY_DIR/*_${DATE}.sql.gz; do
if [ ! -s "$f" ]; then
echo "WARNING: Empty backup file: $f" >&2
fi
done
```
**Schedule separation:** Backups run at 1:00 AM, SAT cron runs at 3:00 AM. With 50 clients, backup should complete in ~15-30 minutes, leaving ample gap before SAT sync starts.
### Environment variables (production)
```
NODE_ENV=production
PORT=4000
DATABASE_URL=postgresql://postgres:<strong_password>@localhost:5432/horux360?schema=public
JWT_SECRET=<cryptographically_secure_random_64_chars>
JWT_EXPIRES_IN=24h
JWT_REFRESH_EXPIRES_IN=30d
CORS_ORIGIN=https://horux360.horux360.com
FIEL_ENCRYPTION_KEY=<separate_32_byte_hex_key>
MP_ACCESS_TOKEN=<mercadopago_production_token>
MP_WEBHOOK_SECRET=<webhook_secret>
MP_NOTIFICATION_URL=https://horux360.horux360.com/api/webhooks/mercadopago
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=<user>@horuxfin.com
SMTP_PASS=<google_app_password>
SMTP_FROM=Horux360 <noreply@horuxfin.com>
ADMIN_EMAIL=admin@horuxfin.com
```
### SAT cron
Already implemented. Runs at 3:00 AM when `NODE_ENV=production`. Will activate automatically with the environment change.
---
## Section 6: Plan Enforcement & Feature Gating
### Enforcement middleware
```typescript
// middleware: checkPlanLimits
async function checkPlanLimits(req, res, next) {
const tenant = await getTenantWithCache(req.user.tenantId); // cached 5 min
const subscription = await getActiveSubscription(tenant.id);
// Admin-impersonated requests bypass subscription check
// (admin needs to complete client setup regardless of payment status)
if (req.headers['x-view-tenant'] && req.user.role === 'admin') {
return next();
}
// Allowed statuses: 'authorized' (paid) or 'pending' (grace period for new clients)
const allowedStatuses = ['authorized', 'pending'];
// Check subscription status
if (!subscription || !allowedStatuses.includes(subscription.status)) {
// Allow read-only access for cancelled/paused subscriptions
if (req.method !== 'GET') {
return res.status(403).json({
message: 'Suscripción inactiva. Contacta soporte para reactivar.'
});
}
}
next();
}
```
**Grace period:** New clients start with `status: 'pending'` and have full write access (can upload FIEL, upload CFDIs, etc.). Once the subscription moves to `'cancelled'` or `'paused'` (e.g., failed payment), write access is revoked. Admin can also manually set status to `'authorized'` for clients who pay by bank transfer.
### CFDI limit check
Applied on `POST /api/cfdi/` and `POST /api/cfdi/bulk`:
```typescript
async function checkCfdiLimit(req, res, next) {
const tenant = await getTenantWithCache(req.user.tenantId);
if (tenant.cfdiLimit === -1) return next(); // unlimited
const currentCount = await getCfdiCountWithCache(req.tenantPool); // cached 5 min
const newCount = Array.isArray(req.body) ? req.body.length : 1;
if (currentCount + newCount > tenant.cfdiLimit) {
return res.status(403).json({
message: `Límite de CFDIs alcanzado (${currentCount}/${tenant.cfdiLimit}). Contacta soporte para upgrade.`
});
}
next();
}
```
### User limit check
Applied on `POST /api/usuarios/invite` (already partially exists):
```typescript
const userCount = await getUserCountForTenant(tenantId);
if (userCount >= tenant.usersLimit && tenant.usersLimit !== -1) {
return res.status(403).json({
message: `Límite de usuarios alcanzado (${userCount}/${tenant.usersLimit}).`
});
}
```
### Feature gating
Applied per route using the existing `hasFeature()` function from shared:
```typescript
function requireFeature(feature: string) {
return async (req, res, next) => {
const tenant = await getTenantWithCache(req.user.tenantId);
if (!hasFeature(tenant.plan, feature)) {
return res.status(403).json({
message: 'Tu plan no incluye esta función. Contacta soporte para upgrade.'
});
}
next();
};
}
// Usage in routes:
router.get('/reportes', authenticate, requireFeature('reportes'), reportesController);
router.get('/alertas', authenticate, requireFeature('alertas'), alertasController);
```
### Feature matrix
| Feature key | Starter | Business | Professional | Enterprise |
|-------------|---------|----------|-------------|------------|
| dashboard | Yes | Yes | Yes | Yes |
| cfdi_basic | Yes | Yes | Yes | Yes |
| iva_isr | Yes | Yes | Yes | Yes |
| reportes | No | Yes | Yes | Yes |
| alertas | No | Yes | Yes | Yes |
| calendario | No | Yes | Yes | Yes |
| xml_sat | No | No | Yes | Yes |
| conciliacion | No | No | Yes | Yes |
| forecasting | No | No | Yes | Yes |
| multi_empresa | No | No | No | Yes |
| api_externa | No | No | No | Yes |
### Frontend feature gating
The sidebar/navigation hides menu items based on plan:
```typescript
const tenant = useTenantInfo(); // new hook
const menuItems = allMenuItems.filter(item =>
!item.requiredFeature || hasFeature(tenant.plan, item.requiredFeature)
);
```
Pages also show an "upgrade" message if accessed directly via URL without the required plan.
### Caching
Plan checks and CFDI counts are cached in-memory with 5-minute TTL to avoid database queries on every request.
**Cache invalidation across PM2 workers:** Since each PM2 cluster worker has its own in-memory cache, subscription status changes (via webhook) must invalidate the cache in all workers. The webhook handler writes the status to the DB, then sends a `process.send()` message to the PM2 master which broadcasts to all workers to invalidate the specific tenant's cache entry. This ensures all workers reflect subscription changes within seconds, not minutes.
---
## Architecture Diagram
```
┌─────────────────────┐
│ Nginx (443/80) │
│ SSL + Rate Limit │
└──────────┬──────────┘
┌──────────────┼──────────────┐
│ │ │
┌─────▼─────┐ ┌────▼────┐ ┌──────▼──────┐
│ Next.js │ │ Express │ │ Webhook │
│ :3000 │ │ API x2 │ │ Handler │
│ (fork) │ │ :4000 │ │ (no auth) │
└───────────┘ │ (cluster)│ └──────┬──────┘
└────┬────┘ │
│ │
┌─────────▼──────────┐ │
│ TenantConnection │ │
│ Manager │ │
│ (pool per tenant) │ │
└─────────┬──────────┘ │
│ │
┌──────────────────┼──────┐ │
│ │ │ │
┌─────▼─────┐ ┌───────▼┐ ┌──▼──┐ │
│ horux360 │ │horux_ │ │horux│ │
│ (central) │ │client1 │ │_... │ │
│ │ └────────┘ └─────┘ │
│ tenants │ │
│ users │◄────────────────────────┘
│ subs │ (webhook updates)
│ payments │
└───────────┘
┌───────────────┐ ┌─────────────┐
│ /var/horux/ │ │ Gmail SMTP │
│ fiel/<rfc>/ │ │ @horuxfin │
│ backups/ │ └─────────────┘
└───────────────┘
┌───────────────┐
│ MercadoPago │
│ Preapproval │
│ API │
└───────────────┘
```
---
## Out of Scope
- Landing page (already exists separately)
- Self-service registration (clients are registered manually by admin)
- Automatic SAT connector (manual FIEL linking for now)
- Plan change by client (admin handles upgrades/downgrades)
- Mobile app
- Multi-region deployment

View File

@@ -0,0 +1,219 @@
# Modulo de Conciliacion — Spec
**Fecha:** 2026-04-12
**Estado:** Aprobado
---
## Objetivo
Permitir al usuario conciliar CFDIs emitidos y recibidos mes a mes, registrando fecha de pago y banco. Solo se permite conciliar del ano actual en adelante.
---
## Modelo de datos (BD tenant — raw SQL)
### Tabla `bancos` (nueva)
```sql
CREATE TABLE IF NOT EXISTS bancos (
id SERIAL PRIMARY KEY,
banco VARCHAR(100) NOT NULL,
terminacion_cuenta VARCHAR(4) NOT NULL,
creado_en TIMESTAMP DEFAULT NOW()
);
```
### Tabla `conciliaciones` (nueva)
```sql
CREATE TABLE IF NOT EXISTS conciliaciones (
id SERIAL PRIMARY KEY,
anio VARCHAR(4) NOT NULL,
mes VARCHAR(2) NOT NULL,
id_cfdi INTEGER NOT NULL UNIQUE REFERENCES cfdis(id),
fecha_de_pago DATE NOT NULL,
id_banco INTEGER NOT NULL REFERENCES bancos(id),
creado_en TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_conciliaciones_anio_mes ON conciliaciones(anio, mes);
CREATE INDEX IF NOT EXISTS idx_conciliaciones_id_cfdi ON conciliaciones(id_cfdi);
```
### Columnas en `cfdis`
- `conciliado VARCHAR(50)` — ya existe. Se actualiza a `'true'` al conciliar, `NULL` al desconciliar.
- `id_conciliacion INTEGER REFERENCES conciliaciones(id)` — nueva. FK a la conciliacion asociada. NULL si no conciliado.
Al conciliar: se crean registros en `conciliaciones`, se actualiza `cfdis.conciliado = 'true'` y `cfdis.id_conciliacion = conciliaciones.id`.
Al desconciliar: se pone `cfdis.conciliado = NULL`, `cfdis.id_conciliacion = NULL`, y se elimina el registro de `conciliaciones`.
### DDL para tenants nuevos
Agregar `bancos`, `conciliaciones` en `database.ts` -> `createTables()` despues de `alertas`.
Agregar `id_conciliacion INTEGER REFERENCES conciliaciones(id)` en la tabla `cfdis`.
### Migracion para tenants existentes
`CREATE TABLE IF NOT EXISTS` para `bancos` y `conciliaciones`, luego `ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS id_conciliacion INTEGER REFERENCES conciliaciones(id)`.
---
## Reglas de negocio
1. Solo se concilian CFDIs del **ano de alta del tenant en adelante** (se obtiene del `createdAt` del tenant en la BD central). Esto permite que una empresa registrada en 2025 pueda conciliar 2025, 2026, etc.
2. `anio` y `mes` de `conciliaciones` se derivan automaticamente de `fecha_de_pago`.
3. Un CFDI solo puede tener una conciliacion (`id_cfdi` es UNIQUE en conciliaciones, `id_conciliacion` en cfdis).
4. Solo CFDIs vigentes (`status NOT IN ('Cancelado', '0')`).
5. Al conciliar: INSERT en `conciliaciones` + UPDATE `cfdis` SET `conciliado = 'true'`, `id_conciliacion = <id>`.
6. Al desconciliar: UPDATE `cfdis` SET `conciliado = NULL`, `id_conciliacion = NULL` + DELETE de `conciliaciones`.
7. No se puede eliminar un banco que tenga conciliaciones asociadas.
---
## API endpoints
### Conciliacion
| Metodo | Ruta | Descripcion | Auth |
|--------|------|-------------|------|
| GET | `/conciliacion` | Lista CFDIs con estado de conciliacion | JWT + Tenant |
| POST | `/conciliacion` | Conciliar CFDIs (batch) | JWT + Tenant + admin/contador |
| DELETE | `/conciliacion/:id` | Desconciliar un CFDI | JWT + Tenant + admin/contador |
#### `GET /conciliacion`
**Query params:**
- `tipo`: `EMITIDO` | `RECIBIDO` (requerido)
- `fechaInicio`, `fechaFin`: rango de fecha de emision
- `regimen`: clave de regimen fiscal (opcional)
- `estado`: `conciliado` | `pendiente` (opcional, default: todos)
**Response:** Array de CFDIs con campo adicional `conciliacion` (null si pendiente, objeto si conciliado):
```json
{
"id": 1,
"uuid": "...",
"rfcEmisor": "...",
"nombreEmisor": "...",
"total": 1000,
"totalMxn": 1000,
"fechaEmision": "...",
"conciliado": "true",
"idConciliacion": 5,
"conciliacion": {
"id": 5,
"fechaDePago": "2026-04-10",
"banco": "BBVA",
"terminacionCuenta": "1234"
}
}
```
#### `POST /conciliacion`
**Body:**
```json
{
"cfdiIds": [1, 2, 3],
"fechaDePago": "2026-04-10",
"idBanco": 1
}
```
**Logica:**
1. Validar que todos los CFDIs existen, estan vigentes, y no estan ya conciliados.
2. Validar que `fechaDePago` es del ano actual en adelante.
3. Derivar `anio` y `mes` de `fechaDePago`.
4. Para cada CFDI: INSERT en `conciliaciones`, UPDATE `cfdis` SET `conciliado = 'true'`, `id_conciliacion = <new id>`.
#### `DELETE /conciliacion/:id`
1. Buscar la conciliacion por id.
2. UPDATE `cfdis` SET `conciliado = NULL`, `id_conciliacion = NULL` WHERE `id_conciliacion = :id`.
3. DELETE FROM `conciliaciones` WHERE `id = :id`.
### Bancos
| Metodo | Ruta | Descripcion | Auth |
|--------|------|-------------|------|
| GET | `/bancos` | Listar bancos del tenant | JWT + Tenant |
| POST | `/bancos` | Crear banco | JWT + Tenant + admin |
| PUT | `/bancos/:id` | Editar banco | JWT + Tenant + admin |
| DELETE | `/bancos/:id` | Eliminar banco (si no tiene conciliaciones) | JWT + Tenant + admin |
---
## Frontend
### Pagina `/conciliacion`
**Acceso:** Feature-gated por `conciliacion` (Business, Enterprise). Roles: admin y contador (lectura+escritura), visor (solo lectura).
**Layout:**
```
[Header: "Conciliacion"]
[Filtros: PeriodSelector | RegimenSelector]
[Tabs: Emitidas | Recibidas]
[Seccion: "Por conciliar" — tabla con checkboxes]
[Barra de accion: Banco (dropdown) + Fecha de pago (date) + Boton "Conciliar"]
[Seccion: "Conciliadas" — tabla con info de conciliacion + boton desconciliar]
```
**Tabla "Por conciliar":**
- Checkbox (no visible para visor)
- UUID (corto), Fecha emision, RFC Emisor/Receptor, Nombre, Total MXN, Metodo Pago
- Boton "Ver factura" (CfdiViewerModal)
**Tabla "Conciliadas":**
- UUID, Fecha emision, RFC, Nombre, Total MXN
- Fecha de pago, Banco (nombre + terminacion)
- Boton "Desconciliar" (no visible para visor)
- Boton "Ver factura"
**Flujo de conciliacion:**
1. Usuario selecciona checkboxes en "Por conciliar"
2. Aparece barra de accion sticky en la parte inferior
3. Selecciona banco (dropdown de bancos del tenant) y fecha de pago
4. Click "Conciliar N facturas"
5. Confirmacion -> POST `/conciliacion` -> refresh datos
### Seccion de bancos en `/configuracion`
Solo visible para admin. Card con:
- Lista de bancos existentes: Nombre + terminacion + boton eliminar
- Formulario inline: Nombre banco + Terminacion (max 4 digitos) + boton agregar
### Navegacion
Agregar "Conciliacion" al sidebar con feature gate `conciliacion`, visible para admin, contador, visor. Ubicacion: despues de Reportes.
---
## Archivos a crear/modificar
### Backend (crear)
- `apps/api/src/services/conciliacion.service.ts`
- `apps/api/src/controllers/conciliacion.controller.ts`
- `apps/api/src/routes/conciliacion.routes.ts`
- `apps/api/src/services/bancos.service.ts`
- `apps/api/src/controllers/bancos.controller.ts`
- `apps/api/src/routes/bancos.routes.ts`
### Backend (modificar)
- `apps/api/src/app.ts` — registrar rutas de conciliacion y bancos
- `apps/api/src/config/database.ts` — agregar tablas `bancos` y `conciliaciones` en `createTables()`, agregar `id_conciliacion` en `cfdis`
### Frontend (crear)
- `apps/web/app/(dashboard)/conciliacion/page.tsx`
- `apps/web/lib/api/conciliacion.ts`
- `apps/web/lib/api/bancos.ts`
- `apps/web/lib/hooks/use-conciliacion.ts`
- `apps/web/lib/hooks/use-bancos.ts`
### Frontend (modificar)
- `apps/web/components/layouts/sidebar.tsx` (y variantes) — agregar nav item
- `apps/web/app/(dashboard)/configuracion/page.tsx` — agregar seccion de bancos
### Migracion
- Aplicar DDL a tenant existente (`horux_ede123456ab1`): crear tablas + agregar columna

View File

@@ -0,0 +1,35 @@
---
title: Segmentación inteligente de solicitudes SAT
status: implementado
created: 2026-04-12
---
# Segmentación Inteligente de Solicitudes SAT
## Problema actual
La sincronización segmenta mes por mes, generando 4 solicitudes SAT por mes (xml emitidos, xml recibidos, metadata emitidos, metadata recibidos). Para 6 años = 288 solicitudes, agotando la cuota diaria del SAT rápidamente.
## Lógica propuesta
1. **Primer paso: solicitud de metadata del rango completo** (una sola solicitud)
- Obtener el total de CFDIs reportados por el SAT
2. **Decidir tamaño de bloque según volumen:**
- `totalCfdis <= 15,000` → bloques de **6 meses**
- `totalCfdis > 15,000` → bloques de **2 meses**
3. **Por cada bloque:**
- Descargar XMLs vigentes (cfdi + DocumentStatus active)
- Descargar metadata de todos (vigentes + cancelados)
## Impacto en solicitudes
| Escenario | Actual (mes a mes) | Propuesto (6 meses) | Propuesto (2 meses) |
|-----------|-------------------|--------------------|--------------------|
| 6 años, pocos CFDIs | 288 solicitudes | 25 solicitudes | 73 solicitudes |
| 6 años, muchos CFDIs | 288 solicitudes | N/A | 73 solicitudes |
## Archivos a modificar
- `apps/api/src/services/sat/sat.service.ts``processInitialSync()`

View File

@@ -0,0 +1,186 @@
# Opinión de Cumplimiento — Integration Design
**Date:** 2026-04-13
**Status:** Approved
## Problem
Horux360 has no way to check or track a tenant's SAT compliance status (Opinión de Cumplimiento). This is a critical fiscal document that indicates whether a company is current on all tax obligations. Currently, users must manually download it from the SAT portal.
## Solution
Integrate the existing standalone Playwright-based prototype into Horux360 as a weekly automated process. Display results in a new "Documentos" page accessible to all roles (business+ plans). Store last 6 months in DB, show last 5 in UI. Alert if status is not Positiva.
## Source Prototype
Located at `C:\Users\chtr1\Downloads\sat-opinion-prototype`. Key files to adapt:
- `src/sat-login.ts` — Playwright navigation: public page → FIEL login → report
- `src/opinion-scraper.ts` — 4 strategies to extract PDF base64 from DOM
- `src/pdf-parser.ts` — Regex extraction of RFC, razón social, estatus, folio, cadena original
- `src/types.ts``OpinionCumplimiento`, `Obligacion` interfaces
## Architecture
### New Files
| File | Purpose |
|------|---------|
| `src/services/opinion-cumplimiento.service.ts` | Orchestration: decrypt FIEL → temp files → Playwright → parse → save to DB → cleanup |
| `src/services/sat/sat-opinion-login.ts` | Adapted sat-login.ts: works with temp file paths from decrypted FIEL Buffers |
| `src/services/sat/sat-opinion-scraper.ts` | Adapted opinion-scraper.ts: extracts PDF from SAT Angular SPA |
| `src/services/sat/sat-opinion-parser.ts` | Adapted pdf-parser.ts: regex extraction from PDF text |
| `src/controllers/documentos.controller.ts` | Endpoints: list opinions, download PDF, manual trigger |
| `src/routes/documentos.routes.ts` | Routes with tenantMiddleware + feature gate |
| `src/migrations/tenant/002_create_opiniones_cumplimiento.sql` | Tenant DB migration |
| `apps/web/app/(dashboard)/documentos/page.tsx` | Frontend: Documentos page with Opinión tab |
| `apps/web/lib/api/documentos.ts` | API client functions |
| `apps/web/lib/hooks/use-documentos.ts` | React Query hooks |
### Modified Files
| File | Change |
|------|--------|
| `src/jobs/sat-sync.job.ts` | Add weekly cron for opinion download |
| `src/services/alertas-auto.service.ts` | Add alert for non-Positiva status |
| `apps/web/components/layouts/sidebar.tsx` | Add Documentos nav item |
| `apps/api/package.json` | Add playwright, pdf-parse dependencies |
| `packages/shared/src/types/` | Add OpinionCumplimiento types |
## Database
### Table: `opiniones_cumplimiento` (per-tenant DB)
```sql
CREATE TABLE IF NOT EXISTS opiniones_cumplimiento (
id SERIAL PRIMARY KEY,
rfc VARCHAR(14) NOT NULL,
razon_social VARCHAR(255),
estatus VARCHAR(50) NOT NULL,
folio VARCHAR(50),
cadena_original TEXT,
fecha_consulta TIMESTAMP NOT NULL,
pdf BYTEA NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_opiniones_fecha ON opiniones_cumplimiento(fecha_consulta DESC);
```
Migration file: `002_create_opiniones_cumplimiento.sql`
**Retention:** Records older than 6 months are deleted during the weekly cron run.
**UI display:** Only the last 5 records are shown via `ORDER BY fecha_consulta DESC LIMIT 5`.
## FIEL Security
The FIEL is stored encrypted (AES-256-GCM) in the central DB. For Playwright, which requires file paths:
1. `getDecryptedFiel(tenantId)` returns Buffers in memory
2. Write .cer and .key to `os.tmpdir()` with permissions `0o600`
3. Pass paths to Playwright `page.setInputFiles()`
4. Delete temp files in `finally` block (guaranteed cleanup even on error)
5. Password is only passed via `page.fill()` — never written to disk
Additional:
- Playwright runs headless in production (no `slowMo`)
- 3-minute timeout per tenant to prevent hanging processes
- Temp file names use `crypto.randomUUID()` to avoid collisions
## Cron Schedule
```
'0 4 * * 0' — Sundays 4:00 AM (America/Mexico_City)
```
Runs after the daily SAT sync (3:00 AM) to avoid overlap. Processes tenants sequentially (Playwright is heavy — no parallelism).
### Cron Flow
For each active tenant with FIEL configured:
1. Decrypt FIEL → write temp files
2. Launch Playwright headless → login → navigate to report
3. Extract PDF base64 from DOM → parse text
4. INSERT into `opiniones_cumplimiento`
5. DELETE records older than 6 months
6. Cleanup temp files
7. Close browser
Error handling: if one tenant fails, log error and continue to next. Don't stop the batch.
## API Endpoints
| Method | Route | Auth | Description |
|--------|-------|------|-------------|
| GET | `/api/documentos/opiniones` | All roles | Last 5 opinions (metadata only, no PDF binary) |
| GET | `/api/documentos/opiniones/:id/pdf` | All roles | Download PDF as binary (Content-Type: application/pdf) |
| POST | `/api/documentos/opiniones/consultar` | Admin only | Trigger manual download for current tenant |
All routes use `tenantMiddleware` + `requireFeature('documentos')`.
### GET /api/documentos/opiniones response
```json
[
{
"id": 1,
"rfc": "HTS240708LJA",
"razonSocial": "HORUX 360 SA DE CV",
"estatus": "Positiva",
"folio": "26NC4144337",
"cadenaOriginal": "||HTS240708LJA|26NC4144337|...",
"fechaConsulta": "2026-04-13T20:59:00.000Z",
"createdAt": "2026-04-13T22:00:00.000Z"
}
]
```
## Auto-Alert
New alert in `alertas-auto.service.ts`:
```typescript
async function alertaOpinionCumplimiento(pool: Pool): Promise<AlertaAuto | null>
```
- Queries latest record from `opiniones_cumplimiento`
- If `estatus !== 'Positiva'` → returns alert with priority 'alta'
- Message: "Tu Opinión de Cumplimiento es {estatus}. Última consulta: {fecha}."
- No drill-down (Documentos page shows details)
- If no records exist → no alert
## Frontend
### Sidebar
```typescript
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' }
```
Between Facturación and Usuarios in the navigation array. Visible to all roles.
### Page: `/documentos`
- Tab structure (future-proof for other document types): first tab "Opinión de Cumplimiento"
- Card/row per opinion showing:
- Fecha de consulta (formatted)
- Estatus badge (green=Positiva, red=Negativa, yellow=others)
- Folio
- Button to download PDF
- "Consultar ahora" button (admin only) triggers POST
- Empty state: "No hay opiniones registradas. La consulta automática se ejecuta cada semana."
- Loading/error states with React Query
### Dependencies
Add to `apps/api/package.json`:
- `playwright` (for Chromium automation)
- `pdf-parse` v2 (for PDF text extraction)
Post-install: `npx playwright install chromium` (needed on deploy)
## Scope Exclusions
- Parser for "Negativa" opinion obligations list (refine when sample PDF available)
- Email notifications on status change (only auto-alert for now)
- Multiple document types in the Documentos page (only Opinión de Cumplimiento in v1)
- PDF viewer in browser (download only)

View File

@@ -0,0 +1,106 @@
# Tenant Schema Migrations System
**Date:** 2026-04-13
**Status:** Approved
## Problem
Horux360 uses a database-per-tenant architecture. When schema changes are made to `createTables()` or `createIndexes()` in `TenantConnectionManager`, only newly provisioned tenants get the updated schema. Existing tenants' databases drift from the expected structure, requiring manual ALTER scripts.
## Solution
A numbered SQL migration system for tenant databases, with both eager (deploy-time) and lazy (on-connect) execution.
## Architecture
### Migration Files
```
apps/api/src/migrations/tenant/
001_initial_schema.sql # Current createTables() + createIndexes()
002_example_future.sql # Template for future changes
```
- Naming: `NNN_description.sql` (zero-padded 3 digits)
- Each file must be idempotent (use `IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS`, etc.)
- Files are read from disk at runtime, sorted by version number
### Schema Migrations Table (per tenant DB)
```sql
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name VARCHAR(255) NOT NULL,
applied_at TIMESTAMP DEFAULT NOW()
);
```
Created automatically before running any migration.
### TenantMigrationRunner
New file: `apps/api/src/config/tenant-migrations.ts`
**Exported functions:**
- `getMigrationFiles()` — Reads and sorts SQL files from migrations directory
- `getPendingMigrations(pool)` — Compares files vs `schema_migrations` table, returns pending
- `migrate(pool, databaseName?)` — Applies pending migrations in order, each in its own transaction. Returns count of applied migrations.
- `migrateAll()` — Queries all active tenants from central DB, calls `migrate()` on each. Logs progress and errors per tenant. Does not stop on individual tenant failure.
### Integration Points
1. **`TenantConnectionManager.provisionDatabase()`** — Replace `createTables()` + `createIndexes()` calls with `migrate(pool)`. This applies all migrations (starting from 001) to new tenants.
2. **`TenantConnectionManager.getPool()`** — After creating or retrieving a pool, call `migrate(pool)` if not already verified this session. Uses `migratedPools: Set<string>` to cache which tenants have been checked. Cache clears on process restart.
3. **New Turborepo script `db:migrate-tenants`** — Runs `migrateAll()` for eager deployment. Added to `apps/api/package.json` and root `turbo.json`.
4. **`createTables()` and `createIndexes()`** — Removed from `TenantConnectionManager`. Their content moves to `001_initial_schema.sql`.
### Lazy Migration Cache
```typescript
// In TenantConnectionManager
private migratedPools: Set<string> = new Set();
```
- `getPool()` checks `migratedPools.has(tenantId)` before running migrations
- If not in set → run `migrate(pool)` → add to set
- Set clears on PM2 restart (new process = fresh set)
- `invalidatePool()` also removes from `migratedPools`
### Deploy Flow
```bash
git pull
pnpm install
pnpm build
pnpm db:migrate-tenants # Eager: apply to all tenants
pm2 restart all # Lazy: safety net on connect
```
### Adding Future Schema Changes
1. Create `NNN_description.sql` in `apps/api/src/migrations/tenant/`
2. Write idempotent SQL
3. Deploy — eager applies to all, lazy catches stragglers
## Scope Exclusions
- No rollback support
- No data migrations (DDL only; data scripts remain separate)
- No parallel execution (sequential per tenant)
- No distributed locking (single PM2 fork instance)
- No changes to Prisma/central DB migrations
## Files Changed
| File | Change |
|------|--------|
| `apps/api/src/config/tenant-migrations.ts` | NEW — TenantMigrationRunner |
| `apps/api/src/migrations/tenant/001_initial_schema.sql` | NEW — current createTables + createIndexes |
| `apps/api/src/config/database.ts` | MODIFY — remove createTables/createIndexes, add lazy migration in getPool, call migrate in provisionDatabase |
| `apps/api/src/scripts/migrate-tenants.ts` | NEW — eager migration CLI script |
| `apps/api/package.json` | MODIFY — add db:migrate-tenants script |
| `turbo.json` | MODIFY — add db:migrate-tenants task |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,166 @@
# Plan Custom — gratis, sin fecha fin, solo asignable por Admin Global
## Contexto
El owner pidió un plan "Custom" para casos donde quiere otorgar acceso al
sistema sin cobro y sin fecha de finalización (cortesía, beta tester, caso
especial). Solo el Admin Global puede asignarlo; los usuarios finales no
deben verlo en su catálogo de planes.
## Decisión clave — Reusar enum `custom`
El Plan enum de Prisma ya incluye `custom` (legacy: "precio variable por
tenant"). En dev hay **0 tenants** en ese plan, y la lógica antigua en
`subscription.service.ts` rechaza `custom` del flujo self-serve — patrón
que coincide con la nueva semántica. Reusar el enum evita migration y
mantiene compatibilidad.
## Reglas
- **Comportamiento**: idéntico a Mi Empresa (1 RFC, MANAGED, 50 timbres/mes,
features básicas, sin API ni Lolita).
- **Costo**: $0. No genera Subscription, no usa MercadoPago.
- **Vigencia**: indefinida. `tenant.trialEndsAt = null`. Sin
`currentPeriodEnd`. Ningún cron lo expira.
- **Visibilidad**: oculto del catálogo user-facing. Solo aparece como
opción en `/clientes` (admin global).
## Cambios — Catálogo
`packages/shared/src/constants/despacho-plans.ts`:
```ts
custom: {
name: 'Custom',
maxRfcs: 1,
maxUsers: 3,
maxCfdisPorContribuyente: 1_000_000,
timbresIncluidosMes: 50,
dbMode: 'MANAGED' as const,
permiteServidorBackup: false,
features: [
'dashboard', 'cfdi_basic', 'iva_isr', 'reportes', 'alertas',
'calendario', 'conciliacion', 'documentos', 'facturacion',
'forecasting', 'xml_sat',
],
},
```
NO se agrega a `DESPACHO_PLAN_PRICES` (gratis). Helpers existentes:
- `permiteOverage('custom')``false` ✓ (ya retorna false porque solo
cubre business_control y business_cloud)
- `isDespachoPaidPlan('custom')``false` ✓ (idem)
- `permiteFrecuenciaMensual('custom')``false` ✓ (no está en
DESPACHO_PLAN_PRICES)
## Cambios — Frontend types
`apps/web/lib/api/tenants.ts`:
Extender el tipo del campo `plan` en `CreateTenantData` y `UpdateTenantData`:
```ts
type AdminAssignablePlan =
| 'starter' | 'business' | 'business_ia' | 'enterprise' // legacy Horux 360
| 'custom'; // nuevo
```
(Despacho paid plans NO se incluyen — esos van por self-serve del owner,
fuera de scope per acuerdo con owner.)
## Cambios — Página `/clientes` (admin)
`apps/web/app/(dashboard)/clientes/page.tsx`:
1. Reemplazar `PlanType` local por `AdminAssignablePlan` importado.
2. Eliminar el `planLabels` local (líneas 174-178) y `planColors` local
(líneas 180-184). Usar el `PLAN_LABELS` global que ya existe arriba
del archivo (cubre todo). Para colores, expandir el map global o
inline en el render.
3. Extender el `<Select>` del form para incluir `custom`:
```tsx
<SelectContent>
<SelectItem value="starter">Starter (legacy) Sin CFDIs, 1 usuario</SelectItem>
<SelectItem value="business">Business (legacy) 50 CFDIs, 3 usuarios</SelectItem>
<SelectItem value="business_ia">Business + IA (legacy)</SelectItem>
<SelectItem value="enterprise">Enterprise (legacy) 100 CFDIs, ilimitado</SelectItem>
<SelectItem value="custom">Custom Sin cobro, sin fecha fin (despacho)</SelectItem>
</SelectContent>
```
4. Cuando el plan seleccionado es `custom`, ocultar el campo `amount`
(no aplica) o forzarlo a 0.
## Cambios — Página `/configuracion/planes-despacho` (user)
`apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx`:
- Las cards visibles son `mi_empresa`, `mi_empresa_plus`,
`business_control`, `business_cloud`. `custom` NO aparece (no está en
ese array).
- Si `planInfo?.plan === 'custom'`: mostrar un banner read-only
prominente:
> "Estás en el plan **Custom** asignado por tu administrador. Contacta
> a soporte si necesitas cambiar."
Y NO renderizar las cards (o renderizarlas atenuadas con botones
deshabilitados).
## No-cambios
- Schema BD / migration — el enum `custom` ya existe.
- Backend `PUT /api/tenants/:id` — ya acepta cualquier valor del enum
Prisma (sin Zod gate). Cero cambios.
- `subscription.service.ts` — su lógica anti-`custom` existente sigue
vigente y coincide con el nuevo comportamiento (rechaza self-serve).
- `getMyPlan` en `despacho.controller.ts` — ya lee `tenant.plan`
directamente. Custom se reportará al frontend correctamente.
- Cron `applyPendingChanges` y `expireTrials` — Custom no tiene
Subscription ni trialEndsAt, no le afectan.
- Trial RFC limit (V.1.0.11) — Custom tiene `trialEndsAt=null`, así
que el limit de 5 no aplica. Aplica el límite duro del catálogo (1).
## Riesgos / 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, luego asignar custom. Documentar en runbook.
2. **Transición custom → paid**: el admin NO puede asignar planes
despacho pagables desde `/clientes` (no incluidos en el dropdown).
El tenant debe pasar por self-serve normal en
`/configuracion/planes-despacho`. Esto evita el escenario de un
tenant en plan paid sin Subscription que sería inconsistente.
3. **Hard limit de 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. Out of scope.
## Plan de pruebas
1. `pnpm typecheck` shared + api + web targeted: PASS.
2. **Admin asigna custom**: desde `/clientes`, edit tenant, seleccionar
"Custom", guardar. Verificar `tenant.plan === 'custom'` en BD.
3. **Admin asigna custom a tenant en trial**: trialEndsAt debería
limpiarse (a través de la lógica del service). Si el service no lo
limpia, agregar.
4. **User en custom**: login como ese tenant, ir a
`/configuracion/planes-despacho` → ver banner "Estás en plan Custom".
5. **Admin asigna otro plan a tenant en custom**: dropdown muestra los
demás planes legacy. Asignación funciona.
6. **`getMyPlan` retorna custom**: `/api/despachos/me/plan` retorna
`{ plan: 'custom', isTrialActive: false, ... }`.
## Implementación
~30 líneas netas en 4 archivos:
- `despacho-plans.ts` — agregar entrada custom (~12 líneas).
- `tenants.ts` (api client) — extender tipos (~3 líneas).
- `clientes/page.tsx` — dropdown + cleanup (~10 líneas).
- `planes-despacho/page.tsx` — banner Custom (~10 líneas).
Cambio chico, hago directo sin subagents. Una commit en Downloads + V.1.0.14
en OneDrive.

View File

@@ -0,0 +1,108 @@
# Drill-down genérica — sort por nombre emisor/receptor
## Contexto
La tabla de la página `/drill-down` (apps/web/app/(dashboard)/drill-down/page.tsx)
actualmente permite ordenar por `Fecha`, `Total MXN`, `Monto Pago` e `IVA Trasl.`
mediante el hook `useTableSort` y el componente `SortableHeader` de
`@horux/shared-ui`. Las columnas `Nombre Emisor` y `Nombre Receptor` se
renderizan como `<th>` planos no ordenables.
## Objetivo
Permitir ordenar también por nombre del emisor y por nombre del receptor,
sin remover ninguna de las columnas ordenables existentes.
Alcance limitado a la drill-down genérica. Las 9 páginas de `/alertas/*` quedan
fuera de este cambio (decisión del owner — se evaluarán después).
## Cambios
Archivo único: `apps/web/app/(dashboard)/drill-down/page.tsx`.
1. Extender el segundo parámetro de tipo de `useTableSort` para incluir las
nuevas keys:
```ts
useTableSort<Cfdi, 'fecha' | 'total' | 'pago' | 'iva' | 'emisor' | 'receptor'>
```
2. Agregar dos accesores al objeto pasado al hook:
```ts
emisor: (c) => c.nombreEmisor || '',
receptor: (c) => c.nombreReceptor || '',
```
`useTableSort` ya soporta accesores de tipo `string` — usa
`String.prototype.localeCompare` cuando ambos valores son strings, lo cual
maneja la collation del español correctamente.
3. Reemplazar los dos `<th>` planos por `SortableHeader`:
```tsx
// antes
<th className="pb-3 font-medium">Nombre Emisor</th>
<th className="pb-3 font-medium">Nombre Receptor</th>
// después
<SortableHeader label="Nombre Emisor"
active={getSortIndicator('emisor')}
onClick={() => toggleSort('emisor')} />
<SortableHeader label="Nombre Receptor"
active={getSortIndicator('receptor')}
onClick={() => toggleSort('receptor')} />
```
4. Mantener el `initialKey = 'fecha'` y `initialDir = 'desc'` (default actual).
## No-cambios
- No se tocan: `useTableSort`, `SortableHeader`, ni cualquier otro archivo en
`@horux/shared-ui`.
- No se tocan controllers ni services del API. El sort es 100% client-side.
- No se tocan las columnas RFC Emisor, RFC Receptor, UUID, Comp., M. Pago,
Reg. E ni Reg. R — siguen siendo `<th>` planos no ordenables.
- No se modifica el export a Excel: ya consume `sortedData`, así que el orden
vigente del usuario se respeta automáticamente.
## Comportamiento esperado
- Click sobre "Nombre Emisor": ordena ascendente por nombre. Re-click:
descendente. Cambia el sort activo (un solo sort a la vez, ya es el
contrato del hook).
- Click sobre "Nombre Receptor": idéntico, reemplaza al sort previo.
- Filas con `nombreEmisor` o `nombreReceptor` null/undefined: el accesor
retorna string vacío `''`, así que en `asc` aparecen primero. Es el
comportamiento estándar de `localeCompare` y se considera aceptable
(un CFDI sin nombre emisor/receptor es raro y debería ser visible al
ordenar por nombre).
## Riesgo
Mínimo:
- Cambio puramente client-side, una sola página, ~6 líneas netas.
- No introduce dependencias nuevas.
- `pnpm typecheck` debería seguir limpio (las nuevas keys están dentro del
union genérico, los accesores cumplen el contrato `(row: T) => number | string`).
## Plan de pruebas (smoke)
1. `pnpm typecheck` debe seguir en 0 errores.
2. Abrir `/drill-down` desde cualquier KPI del dashboard.
3. Click en "Nombre Emisor" → verificar orden alfabético ascendente y flecha
en el header. Re-click → descendente.
4. Click en "Nombre Receptor" → mismo comportamiento.
5. Click en "Fecha" / "Total MXN" → confirmar que los sorts pre-existentes
siguen funcionando.
6. Exportar a Excel después de ordenar por "Nombre Emisor" → confirmar que
el archivo descargado mantiene el mismo orden.
## Pendientes derivados
- Replicar el patrón en las 9 páginas de `/alertas/*` (cancelaciones,
cancelaciones-periodo-anterior, efectivo, tipo-relacion-sospechosa,
concentracion-clientes, concentracion-proveedores, discrepancia-regimen,
lista-negra-clientes, lista-negra-proveedores). Decisión del owner cuándo
abordarlas. Para `lista-negra-*` además habrá que introducir
`useTableSort` desde cero (hoy no lo usan).

View File

@@ -0,0 +1,347 @@
# Filtros "Considerar activos" y "Considerar NCs" en /impuestos — Fase 1
## Contexto
La pestaña ISR e IVA de `/impuestos` actualmente solo tiene un toggle de
"Conciliación" que cambia la semántica de fechas. El owner pidió dos toggles
adicionales:
1. **Considerar activos** — cuando ACTIVADO, incluye facturas tipo I con
`uso_cfdi` ∈ {I01, I02, I03, I04, I05, I06, I07, I08} (compras de activos
fijos / inversiones). Cuando DESACTIVADO, excluye esas facturas.
2. **Considerar NCs** — cuando ACTIVADO, incluye facturas tipo E con
`cfdi_tipo_relacion = '01'` (notas de crédito). Cuando DESACTIVADO, las
excluye.
### Decisión de defaults
**Default ambos toggles ON (incluir)** — revertido del default original OFF
por concerns de performance: con default OFF, el cache `metricas_mensuales`
quedaría siempre bypass-eado en `/impuestos` hasta Fase 2. Con default ON,
las cargas iniciales aprovechan el cache (comportamiento idéntico al de
versiones previas), y el contador opt-in al view filtrado cuando lo necesita.
Trade-off aceptado: el contador debe **desactivar manualmente** los toggles
cuando quiere ver números sin activos / sin NCs. La lógica fiscal de
"depreciación de activos" requiere consciencia del contador, no se aplica
silenciosamente.
Los filtros aplican **solo** en la pestaña Impuestos (IVA + ISR). Dashboard,
reportes, drill-downs, alertas y demás permanecen intactos.
## Justificación fiscal
- Los activos fijos (uso I01-I08) deben depreciarse, no deducirse en su mes
de adquisición. Excluirlos del cálculo provisional mensual evita inflar las
deducciones. La pestaña dedicada "Activos Fijos" (en `/impuestos`) es donde
se muestra y gestiona esa información.
- Las NCs tipoRel=01 son ajustes a documentos previos. El owner quiere ver
los números **brutos sin ajustes** por default y opt-in con el toggle. Asume
el riesgo de over-reporting si el contador olvida activarlo.
## Fases
- **Fase 1 (este spec):** UI + backend con live query. Sin cambios al cache
`metricas_mensuales`. Cuando los toggles están en su default (OFF), el cache
queda bypass-eado y todo es live query.
- **Fase 2 (spec posterior):** extender `metricas_mensuales` con columnas
base + 2 deltas para hacer el toggle instantáneo (computado por suma/resta).
## Cambios — Frontend
### `apps/web/app/(dashboard)/impuestos/page.tsx`
State nuevo (defaults `true` = filter active = incluir, cache-friendly):
```ts
const [considerarActivos, setConsiderarActivos] = useState(true);
const [considerarNCs, setConsiderarNCs] = useState(true);
```
Con defaults `true`, las cargas iniciales aprovechan el cache de
`metricas_mensuales`. El gate `!conciliacion && considerarActivos && considerarNCs`
queda en `true` por default y permite cache hit. El contador opt-in al view
filtrado desactivando los toggles cuando lo necesita.
UI: 2 toggle buttons en la misma fila que "Conciliación", mismo styling.
Orden recomendado: `Régimen | Conciliación | Considerar activos | Considerar NCs`.
```tsx
<button
onClick={() => setConsiderarActivos(!considerarActivos)}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
considerarActivos
? 'bg-primary/10 text-primary border border-primary/30'
: 'hover:bg-accent'
)}
title="Si está inactivo, no se consideran facturas tipo I con uso de CFDI I01-I08 (compras de activos fijos)."
>
<CheckSquare className="h-4 w-4" />
Considerar activos
</button>
```
(Análogo para Considerar NCs con tooltip "...facturas tipo E con tipo de
relación 01 (notas de crédito).")
Pasar a todos los hooks consumidos en la pestaña ISR e IVA.
### `apps/web/lib/hooks/use-impuestos.ts`
Extender 5 hooks. Ejemplo:
```ts
export function useResumenIsrDesglosado(
fechaFin: string,
conciliacion?: boolean,
considerarActivos?: boolean,
considerarNCs?: boolean,
) {
const tk = useTenantKey();
const { selectedContribuyenteId } = useContribuyenteStore();
return useQuery({
queryKey: ['isr-resumen-desglosado', tk, fechaFin, conciliacion, considerarActivos, considerarNCs, selectedContribuyenteId],
queryFn: () => impuestosApi.getResumenIsrDesglosado(fechaFin, conciliacion, considerarActivos, considerarNCs, selectedContribuyenteId),
enabled: !!fechaFin,
});
}
```
Aplicar el mismo patrón a `useResumenIsr`, `useResumenIva`, `useIsrMensual`,
`useIvaMensual`.
### `apps/web/lib/api/impuestos.ts`
Extender funciones HTTP. Ejemplo:
```ts
export async function getResumenIsrDesglosado(
fechaFin: string,
conciliacion?: boolean,
considerarActivos?: boolean,
considerarNCs?: boolean,
contribuyenteId?: string | null,
): Promise<ResumenIsrDesglosado> {
const params = new URLSearchParams();
params.set('fechaFin', fechaFin);
if (conciliacion) params.set('conciliacion', 'true');
if (considerarActivos) params.set('considerarActivos', 'true');
if (considerarNCs) params.set('considerarNCs', 'true');
if (contribuyenteId) params.set('contribuyenteId', contribuyenteId);
const response = await apiClient.get<ResumenIsrDesglosado>(`/impuestos/isr/resumen-desglosado?${params}`);
return response.data;
}
```
(Análogo para `getResumenIsr`, `getResumenIva`, `getIsrMensual`, `getIvaMensual`.)
## Cambios — Backend
### Helper compartido en `apps/api/src/services/impuestos.service.ts`
```ts
/**
* Construye fragmentos AND adicionales para WHERE clauses según los toggles
* "Considerar activos" y "Considerar NCs" en la UI de impuestos.
*
* - considerarActivos === false → excluir facturas tipo I con uso de CFDI I01-I08.
* - considerarNCs === false → excluir facturas tipo E con cfdi_tipo_relacion = '01'.
*
* Cuando ambos son true (default backend), retorna string vacío. Esto preserva
* el comportamiento histórico para callers que no pasan los flags (ej. dashboard).
*/
function buildExtraFilters(considerarActivos: boolean, considerarNCs: boolean): string {
const parts: string[] = [];
if (!considerarActivos) {
parts.push(`AND NOT (tipo_comprobante = 'I' AND uso_cfdi IN ('I01','I02','I03','I04','I05','I06','I07','I08'))`);
}
if (!considerarNCs) {
parts.push(`AND NOT (tipo_comprobante = 'E' AND COALESCE(cfdi_tipo_relacion, '') = '01')`);
}
return parts.length > 0 ? ' ' + parts.join(' ') : '';
}
```
### Funciones modificadas
Agregar 2 parámetros booleanos opcionales con default `true` (= include
todo, comportamiento histórico). Forman el último par de la signature.
| Función | Archivo | Cambio |
|---|---|---|
| `calcularIngresosPorRegimen` | `dashboard.service.ts` | +`considerarActivos=true, considerarNCs=true`, concatenar `buildExtraFilters(...)` al WHERE |
| `calcularEgresosPorRegimen` | `dashboard.service.ts` | Idem |
| `getResumenIva` | `impuestos.service.ts` | Idem + propagar al cache gate (ver abajo) |
| `getIvaMensual` | `impuestos.service.ts` | Idem |
| `getResumenIsr` | `impuestos.service.ts` | Idem + propagar a `calcular*PorRegimen` |
| `getIsrMensual` | `impuestos.service.ts` | Idem + propagar a `calcular*PorRegimen` |
| `getResumenIsrDesglosado` | `impuestos.service.ts` | Idem + propagar a las 3 llamadas a `getResumenIsr` |
**Importante**: como `buildExtraFilters` está en `impuestos.service.ts` y
`calcular*PorRegimen` viven en `dashboard.service.ts`, hay que **mover el
helper a un módulo compartido** o duplicarlo. Recomendación: mover a un
nuevo `apps/api/src/services/_shared/cfdi-filters.ts` (módulo neutral
reutilizable). Ambos services lo importan.
### Aplicación del fragmento
Concatenar al WHERE de TODA query que escanee `cfdis` dentro de las funciones
afectadas. Buscar patrón `WHERE ${VIGENTE} AND ${FR}` y agregar
`${buildExtraFilters(...)}` al final del WHERE.
Ejemplo en una query existente:
```ts
const FR = getFR(conciliacion);
const extra = buildExtraFilters(considerarActivos, considerarNCs);
const { rows } = await pool.query(`
SELECT regimen_fiscal_emisor as regimen, ...
FROM cfdis
WHERE ${VIGENTE} AND ${FR}${extra}
AND ${ctx.esEmisor}
GROUP BY regimen_fiscal_emisor
`, [fechaInicio, fechaFin]);
```
`buildExtraFilters` ya retorna con leading space, así que se concatena directo.
**Subqueries con alias** (`SUM_E_REFERENCING_*`): el alias `e` para la tabla
externa requiere referenciar columnas como `e.tipo_comprobante`,
`e.uso_cfdi`, `e.cfdi_tipo_relacion`. Necesitamos una variante del helper que
acepte alias:
```ts
function buildExtraFiltersAlias(alias: string, considerarActivos: boolean, considerarNCs: boolean): string {
const parts: string[] = [];
if (!considerarActivos) {
parts.push(`AND NOT (${alias}.tipo_comprobante = 'I' AND ${alias}.uso_cfdi IN ('I01','I02','I03','I04','I05','I06','I07','I08'))`);
}
if (!considerarNCs) {
parts.push(`AND NOT (${alias}.tipo_comprobante = 'E' AND COALESCE(${alias}.cfdi_tipo_relacion, '') = '01')`);
}
return parts.length > 0 ? ' ' + parts.join(' ') : '';
}
```
Y se usa donde aparezcan subqueries con alias `e` (ej. `SUM_E_REFERENCING_*`,
`HAS_E_REFERENCING_MISMO_MES`, `E_REFERENCIA_I_PPD_07_MISMO_MES` si existe).
### Controllers — `apps/api/src/controllers/impuestos.controller.ts`
Helper para parsear (junto a `parseConciliacion`):
```ts
function parseFlag(req: Request, key: string, defaultValue = true): boolean {
const v = req.query[key];
if (v === undefined || v === null) return defaultValue;
return v === 'true' || v === '1';
}
```
Cada handler relevante (`getResumenIva`, `getIvaMensual`, `getResumenIsr`,
`getIsrMensual`, `getResumenIsrDesglosado`) parsea los 2 nuevos flags con
**default `true`** y los pasa al service.
```ts
const considerarActivos = parseFlag(req, 'considerarActivos', true);
const considerarNCs = parseFlag(req, 'considerarNCs', true);
```
**Razón del default `true` en el controller**: si por algún motivo el query
param no llega (cliente legacy, prueba manual, otro consumer), comportamiento
es como antes (todo incluido). El frontend siempre manda el flag explícito,
así que en la práctica el default solo aplica al testing externo.
### Cache gate en `getResumenIva` (línea ~322)
Extender la condición:
```ts
if (
!conciliacion &&
considerarActivos && // new — cache solo aplica con backend default
considerarNCs && // new
contribuyenteId &&
...
) {
const cached = await readResumenIvaFromCache(...);
if (cached) return cached;
}
```
Con UI default (ambos toggles ON), `considerarActivos=true && considerarNCs=true`
→ cache hit (comportamiento idéntico a versiones previas). Cuando el contador
desactiva alguno → cache bypass → live query (~1-3s). Aceptable porque el
desactivado es action consciente, no la carga inicial. Fase 2 hará los toggles
instantáneos vía cache base+deltas.
## No-cambios
- **Schema BD**: ninguno. SQL puro.
- **Cache `metricas_mensuales`**: estructura intacta. Solo se actualiza el gate.
- **Dashboard, reportes, drill-downs, alertas**: comportamiento idéntico
(gracias a defaults `true` en `calcular*PorRegimen`).
- **Activos Fijos tab**: usa su propio `activos-fijos.service.ts`, no pasa
por las funciones filtradas. Verificar en el smoke.
- **`getRegimenesDelPeriodo`** y otros que NO calculan ingresos/deducciones
no se modifican. Los regímenes disponibles en el dropdown siguen siendo
los mismos (basados en presencia de CFDIs, no filtrados por estos toggles).
## Riesgos
1. **Tocar funciones compartidas con dashboard**: `calcular*PorRegimen` viven
en `dashboard.service.ts`. Default `true` debería preservar el dashboard,
pero hay que verificar manualmente post-deploy.
2. **Performance Fase 1**: con UI default ON (cache-friendly), las cargas
iniciales son rápidas. Solo cuando el contador desactiva un toggle hay
live query. Fase 2 elimina ese delay también.
3. **Subqueries con alias**: hay 5+ subqueries con alias `e` en
`impuestos.service.ts` (rama I PPD/07). Cada una necesita el helper alias.
Riesgo de olvidar una → resultados inconsistentes.
4. **NCs default OFF puede sobre-reportar ingresos**: el contador puede no
notar que las NCs están excluidas si no lee el tooltip. Mitigación:
tooltip claro y label "Considerar NCs" (lectura obvia).
## Plan de pruebas (smoke)
1. **Typecheck**: `pnpm --filter @horux/shared typecheck`,
`pnpm --filter @horux/api typecheck`. Ambos PASS sin errores.
2. **Dashboard regression**: abrir `/dashboard` → KPIs (ingresos, gastos,
utilidad) deben tener los mismos valores que antes del deploy.
3. **Activos Fijos tab**: abrir `/impuestos` → pestaña "Activos Fijos" → la
tabla debe seguir mostrando todas las facturas I con uso I01-I08.
4. **UI default (ambos toggles OFF)**: cargar `/impuestos` ISR. Verificar
que ingresos del periodo y deducciones son menores que antes (excluyen
activos + NCs tipoRel=01).
5. **Toggle "Considerar activos" ON**: deducciones suben con la suma de los
activos del periodo.
6. **Toggle "Considerar NCs" ON**: comportamiento depende del lado:
- Como receptor (NC recibida que cancela una factura PUE): deducciones
bajan (la NC resta).
- Como emisor (NC emitida que cancela una factura PUE propia): ingresos
bajan.
7. **Combinaciones de los 3 toggles** (Conciliación + Activos + NCs): ocho
combinaciones, números deben ser consistentes.
8. **IVA tab**: mismas pruebas (toggle on/off, comparar números).
9. **Tabla "Histórico ISR"**: debe respetar los 2 nuevos toggles también
(cada fila refleja los acumulados con los filtros activos).
10. **Sección "Cálculo de ISR del Periodo"**: las 3 ramas (`delPeriodo`,
`anteriores`, `total`) deben respetar los toggles consistentemente.
## Pendientes derivados
- **Fase 2**: extender `metricas_mensuales` con columnas
`*_activos`, `*_ncs_01` (×3 métricas IVA = 6 columnas nuevas).
Migration + recompute del cache + actualizar lectura del cache para hacer
suma/resta según toggles. Fase 2 entrega toggles instantáneos.
- **Tooltip + iconos**: si el owner quiere distinguir visualmente los 3
toggles (Conciliación con un check, Activos con un asset icon, NCs con un
document icon), aplicar después.
- **Persistencia de los toggles**: hoy el state vive en `useState`, se pierde
al recargar. Si se quiere persistir, considerar `localStorage` o agregar a
`tenant-view-store`. Out-of-scope para Fase 1.
- **Dashboard parity**: si en el futuro el owner quiere los mismos toggles en
`/dashboard`, ya está habilitado por la signature de `calcular*PorRegimen`
— solo falta UI + propagación. Out-of-scope.

View File

@@ -0,0 +1,298 @@
# ISR — Base gravable acumulada y desglose del periodo
## Contexto
En `/impuestos` (pestaña ISR) hay dos lugares donde la base gravable se calcula
mes a mes en lugar de acumulado, lo cual es fiscalmente incorrecto para pagos
provisionales mensuales:
1. **Tabla "Histórico ISR"** (`apps/web/app/(dashboard)/impuestos/page.tsx`,
líneas ~503-568): cada fila aplica `Math.max(0, ing_mes ded_mes)` por mes
independiente. Resultado: un mes con pérdida no reduce el acumulado.
2. **Sección "Cálculo de ISR Acumulado"** (mismas líneas ~371-432): muestra los
totales del rango filtrado en `resumenIsr`, sin distinguir lo que ya estaba
acumulado de meses previos del mismo año vs. el periodo actual.
El bug raíz vive en `getIsrMensual` (`apps/api/src/services/impuestos.service.ts`,
líneas 409-486): el query corre de `${año}-${mm}-01` a fin de mes, así que el
campo nombrado `ingresosAcumulados` en `IsrMensual` realmente trae solo el mes
(deuda heredada del refactor previo, el nombre miente).
## Objetivo
Mostrar la base gravable y los montos acumulados correctamente:
1. En la tabla, agregar columnas **Ingresos Acum.**, **Deducciones Acum.** y
**Base Gravable Acum.** (estas tres son running totals desde enero hasta el
mes de cada fila). La **BG mensual desaparece** del display — solo queda la
acumulada, que es la única fiscalmente válida.
2. En la sección de cálculo, presentar el desglose como aparece en el formato
14 (declaración provisional mensual del SAT):
```
Ingresos del periodo + Ingresos anteriores
Deducciones del periodo Deducciones anteriores
= Base gravable acumulada
```
Donde **"del periodo" = mes final del filtro** y **"anteriores" = enero
hasta el mes anterior al final**.
## Reglas fiscales
- **No se aplica `max(0, ...)` al display** de base gravable. Los déficits son
reales y se muestran negativos (en rojo). Si filtras febrero y enero tuvo
utilidad pero febrero pérdida grande, `BG_acum_feb` puede ser negativa.
- **`max(0, ...)` se aplica únicamente al pasar a ISR causado**: si
`BG_acum < 0`, ISR causado = 0. SAT hace lo mismo en el formato 14.
- **El año fiscal se resetea en enero**. "Anteriores" jamás cruza a años previos.
## Cambios — Backend
### `apps/api/src/services/impuestos.service.ts`
**`getIsrMensual` (líneas 409-486):**
Después del loop que llena `result[]` con datos mensuales, agregar un segundo
pase que computa los running totals:
```ts
let ingAcum = 0, dedAcum = 0;
for (const row of result) {
ingAcum += row.ingresosAcumulados; // (mensual, a pesar del nombre)
dedAcum += row.deducciones;
row.ingresosAcum = ingAcum;
row.deduccionesAcum = dedAcum;
row.baseGravableAcum = ingAcum - dedAcum; // sin clamp
}
```
Nota sobre naming: el campo existente `ingresosAcumulados` en `IsrMensual` se
mantiene por compat (es el mensual). Los nuevos campos son `ingresosAcum`,
`deduccionesAcum`, `baseGravableAcum`. En el spec del rename total al final
puede ocurrir, pero no es scope de este cambio.
**Nueva función exportada** `getResumenIsrDesglosado`:
```ts
export async function getResumenIsrDesglosado(
pool: Pool,
fechaFin: string,
tenantId: string,
conciliacion?: boolean,
contribuyenteId?: string | null,
): Promise<{
delPeriodo: ResumenIsr;
anteriores: ResumenIsr;
total: ResumenIsr;
}>
```
Lógica:
1. Derivar `año = fechaFin.year`, `mesFinal = fechaFin.month`.
2. Tres rangos:
- **delPeriodo**: `${año}-${mesFinal}-01` a fin de `mesFinal` (solo mes final)
- **anteriores**: `${año}-01-01` a `${año}-${mesFinal-1}-${ultDia}` (Ene a mesFinal-1; vacío si mesFinal=1)
- **total**: `${año}-01-01` a fin de `mesFinal` (Ene a mesFinal)
3. Llamar `getResumenIsr` 3 veces con esos rangos, retornar el objeto.
Caso `mesFinal=1`: retornar `anteriores` con todos los campos en cero (no se hace
query inútil).
### `apps/api/src/controllers/impuestos.controller.ts`
Agregar handler `getResumenIsrDesglosado`:
```ts
// GET /api/impuestos/resumen-isr-desglosado?fechaFin=...&conciliacion=...&contribuyenteId=...
```
El filtro por régimen no se pasa al endpoint — el frontend hace el lookup
contra `resumenIsr.baseGravablePorRegimen[]` igual que hoy con `useResumenIsr`,
para que la lógica de filtrado siga centralizada en un solo lugar.
### `apps/api/src/routes/impuestos.routes.ts`
Agregar la ruta `/resumen-isr-desglosado` con los mismos middlewares que
`/resumen-isr` (auth + tenant + plan limits).
## Cambios — Shared types
### `packages/shared/src/types/reportes.ts` (o donde viva `IsrMensual`)
Agregar campos al type:
```ts
export interface IsrMensual {
// ...campos existentes
ingresosAcum: number;
deduccionesAcum: number;
baseGravableAcum: number; // sin clamp, puede ser negativo
}
export interface ResumenIsrDesglosado {
delPeriodo: ResumenIsr;
anteriores: ResumenIsr;
total: ResumenIsr;
}
```
## Cambios — Frontend
### `apps/web/lib/api/impuestos.ts`
Agregar función `getResumenIsrDesglosado` (cliente HTTP) y hook
`useResumenIsrDesglosado` en `apps/web/lib/hooks/use-impuestos.ts`.
### `apps/web/app/(dashboard)/impuestos/page.tsx`
**Tabla "Histórico ISR" (líneas ~502-568):**
Headers (6 columnas):
```
Mes | Ingresos | Ingresos Acum. | Deducciones | Deducciones Acum. | Base Gravable Acum.
```
Body por fila:
```tsx
<td>{meses[row.mes - 1]}</td>
<td className="text-right">{formatCurrency(row.ingresosAcumulados)}</td> // mensual
<td className="text-right">{formatCurrency(row.ingresosAcum)}</td>
<td className="text-right">{formatCurrency(row.deducciones)}</td>
<td className="text-right">{formatCurrency(row.deduccionesAcum)}</td>
<td className={cn(
"text-right font-medium",
row.baseGravableAcum < 0 ? "text-destructive" : ""
)}>{formatCurrency(row.baseGravableAcum)}</td>
```
Fila Total: eliminar. La última fila (diciembre) ya es el total YTD, no hace
falta sumar acumulados (sería incorrecto). Si se quiere conservar, mostrar
solo los mensuales sumados (= total año) y el último valor acumulado de la
columna BG Acum.
**Decisión por defecto en este spec:** eliminar fila Total. Si el usuario
prefiere conservarla, lo discutimos al implementar.
Export Excel: 6 columnas alineadas con UI:
```ts
[
{ header: 'Mes', key: 'Mes' },
{ header: 'Ingresos', key: 'Ingresos' },
{ header: 'Ingresos Acumulados', key: 'IngresosAcum' },
{ header: 'Deducciones', key: 'Deducciones' },
{ header: 'Deducciones Acumuladas', key: 'DeduccionesAcum' },
{ header: 'Base Gravable Acumulada', key: 'BaseGravableAcum' },
]
```
**Sección "Cálculo de ISR del Periodo" (líneas ~371-432):**
1. Renombrar `<CardTitle>` de "Cálculo de ISR Acumulado" a "Cálculo de ISR
del Periodo".
2. Reemplazar el query `useResumenIsr(fechaInicio, fechaFin, conciliacion)` por
`useResumenIsrDesglosado(fechaFin, conciliacion, contribuyenteId)`. El
filtro por régimen se aplica del lado frontend contra
`total.baseGravablePorRegimen[]` (mismo patrón que hoy).
3. Layout nuevo del card content:
```tsx
<div className="space-y-2">
<FilaDesglose label={`Ingresos del periodo (${labelMesFinal})`} value={delPeriodo.ingresos} />
<FilaDesglose label={`(+) Ingresos acumulados anteriores ${labelAnteriores}`} value={anteriores.ingresos} />
<FilaDesglose label={`() Deducciones del periodo`} value={delPeriodo.deducciones} negative />
<FilaDesglose label={`() Deducciones acumuladas anteriores`} value={anteriores.deducciones} negative />
<Divider />
<FilaDesglose
label="(=) Base gravable acumulada"
value={total.baseGravable}
bold
danger={total.baseGravable < 0}
/>
<FilaDesglose label="ISR causado (acumulado)" value={total.isrCausado} />
<FilaDesglose label="() ISR retenido (acumulado)" value={total.isrRetenido} negative />
<Divider />
<FilaDesglose label="ISR a pagar" value={total.isrAPagar} bold large />
</div>
```
Etiquetas dinámicas:
- `labelMesFinal` = `"Mar 2026"` (mes y año de `fechaFin`)
- `labelAnteriores` = `"(Ene-Feb)"` o `"(sin meses anteriores)"` cuando
`mesFinal === 1`.
Si `mesFinal === 1`: las dos filas "anteriores" muestran `$0` con texto
discretamente atenuado y el label dice "(sin meses anteriores)".
`FilaDesglose` puede ser un componente local del archivo o sustituirse por el
mismo `<div className="flex justify-between py-2 border-b">` que ya se usa.
Decisión por defecto: inline (no extraer componente nuevo, mantener el patrón
existente).
## No-cambios
- `getResumenIsr` se mantiene tal cual — sigue usándose en KPIs y otros lugares.
- Los KPIs en la parte alta de la pestaña ISR (Ingresos, Deducciones, Base
Gravable, etc.) **siguen mostrando los valores del rango filtrado completo**.
El cambio aplica solo a la tabla histórica y a la sección de cálculo.
- `metricas_mensuales` (cache) sigue guardando valores mensuales puros — el
acumulado se computa al consumir el cache. Sin invalidaciones.
- IVA mensual (`getIvaMensual`) no se toca.
## Riesgos
- **BG mensual deja de aparecer en la tabla**: si algún usuario hacía export y
reportaba la BG mensual a contadores, esa columna ya no existe. Mitigación:
comunicar el cambio en el changelog/release notes.
- **Año cruzado**: si el usuario filtra `fechaFin = 2026-03-31` pero
`fechaInicio` es de 2025, "anteriores" sigue siendo solo Ene-Feb 2026, no
baja a 2025. Esperable porque ISR se acumula por año fiscal.
- **Performance**: 3 queries `getResumenIsr` por refresh de la sección de
cálculo. Cada uno hace ~10 queries internos (por régimen, retenciones, etc.).
En un mes promedio del año, son ~30 queries. Aceptable para un endpoint
on-demand. Si se vuelve cuello de botella, optimizar con un solo query
agregado.
## Plan de pruebas (smoke)
1. `pnpm typecheck` debe seguir limpio en `@horux/api` y `@horux/shared`.
2. Backend — abrir REPL/curl:
- `GET /api/impuestos/resumen-isr-desglosado?fechaFin=2026-03-31&...`:
- `delPeriodo` = solo Mar 2026
- `anteriores` = Ene-Feb 2026
- `total.ingresos === delPeriodo.ingresos + anteriores.ingresos`
- `total.baseGravable === total.ingresos total.deducciones` (sin clamp,
puede ser negativo)
- Mismo endpoint con `fechaFin=2026-01-31`:
- `anteriores.ingresos === 0`, `anteriores.deducciones === 0`, etc.
3. Frontend tabla:
- Tenant con datos en varios meses (p.ej. Patito): verificar que cada fila
muestre el running total correcto.
- Tenant con un mes negativo (Husberto Feb si hay datos): la BG Acum debe
aparecer en rojo y reducir el acumulado del mes siguiente.
4. Frontend sección:
- Filtrar `mes=marzo`: ver que los 4 renglones cuadren con la fórmula y la
línea BG sea la suma algebraica.
- Filtrar `mes=enero`: ver que las dos líneas "anteriores" digan "$0" con
etiqueta "(sin meses anteriores)".
- Filtrar `mes=diciembre`: ver acumulado anual completo, "anteriores" =
Ene-Nov, "del periodo" = Dic.
5. Validación cruzada con declaración SAT real (si owner tiene una a la mano):
confirmar que los números del desglose coincidan con la declaración formato 14.
## Pendientes derivados
- Considerar agregar **un endpoint `getIsrMensualConAcumulados`** que retorne
los acumulados pre-computados, en vez de exponerlos como campos extra del
endpoint actual. Reduciría payload si solo se necesita una vista.
- Si el cache de `metricas_mensuales` empieza a usarse para ISR (hoy solo
es para IVA), repetir la fix del acumulado al consumir el cache.
- **Recompute opcional**: el bug actual ya no es visible (eliminamos la BG
mensual) pero la fila de cálculo del periodo SÍ depende de queries en vivo.
No hay cache que invalidar — el fix es inmediato al deploy.

View File

@@ -0,0 +1,129 @@
# Límite de 5 RFCs durante trial gratuito
## Contexto
Despachos en periodo de prueba (30 días) pueden agregar RFCs sin restricción.
El owner pidió un límite duro de 5 RFCs durante trial — 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** (boundary: 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 (los del plan ya existen y son out of scope) |
## Cambios — Backend
### `apps/api/src/controllers/contribuyente.controller.ts`
Constante local al archivo:
```ts
const 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.`,
));
}
}
```
Imports: agregar `prisma` desde `../config/database.js` (ya está disponible
en otros controllers).
## Cambios — Frontend
### `apps/web/app/(dashboard)/contribuyentes/page.tsx`
Fetch del plan info (sigue patrón existente en `planes-despacho/page.tsx`):
```ts
const { data: planInfo } = useQuery({
queryKey: ['my-plan-info'],
queryFn: () => apiClient.get<{ isTrialActive: boolean }>('/despachos/me/plan').then(r => r.data),
});
const isTrialActive = planInfo?.isTrialActive ?? false;
const activeCount = (contribuyentes ?? []).filter(c => c.active !== false).length;
const trialAtLimit = isTrialActive && activeCount >= 5;
```
Modificar los 2 botones "Agregar RFC" (línea 70 y 78) para reflejar el estado:
```tsx
<Button
onClick={() => { resetForm(); setShowDialog(true); }}
disabled={trialAtLimit}
title={trialAtLimit ? 'Límite de contribuyentes para la prueba gratuita, para continuar agregando contribuyentes, selecciona un plan.' : undefined}
className="flex items-center gap-2"
>
<Plus className="h-4 w-4" /> Agregar RFC
</Button>
```
(Mismo patrón en el botón "Agregar primer RFC" — aunque cuando `activeCount === 0`
el `trialAtLimit` es `false`, así que ese botón nunca se deshabilita. Aún así,
aplico el atributo `disabled={trialAtLimit}` por consistencia defensiva.)
Mensaje del tooltip (literal del owner):
"Límite de contribuyentes para la prueba gratuita, para continuar agregando
contribuyentes, selecciona un plan."
## No-cambios
- Schema BD.
- Cron de trial (`expireTrials`).
- Mi Empresa hard limit a 1 RFC (sigue siendo solo billing-only,
fuera de scope).
- `tenant.cfdiLimit`, `tenant.usersLimit` — no se tocan.
## Riesgos
- **Race condition**: si dos creaciones concurrentes ven `count=4` y ambas
pasan, podríamos terminar con 6. Improbable en flujo manual UI; no se
mitiga (costo > beneficio).
- **Trial → paid mid-creación**: si el contador paga mientras está en 5
RFCs, el `trialEndsAt` no se modifica (sigue en futuro), pero la
subscription ahora tiene status `authorized`. Per la lógica actual,
el trial sigue "activo" hasta que `trialEndsAt < now`. El usuario
pagado seguirá viendo el límite de 5 hasta que expire el trial. **Aceptable**:
el owner gana dinero adicional el día que el contador convierte, no antes.
Si se quiere lift inmediato, modificar la lógica de `isTrialActive`
para excluir trials pagados — out of scope para este spec.
## Plan de pruebas
1. `pnpm typecheck` shared + api + web targeted: PASS.
2. Tenant en trial con 4 contribuyentes activos:
- UI: botón "Agregar RFC" habilitado.
- API: `POST /api/contribuyentes` con datos válidos retorna 201.
3. Tenant en trial con 5 contribuyentes activos:
- UI: botón "Agregar RFC" deshabilitado, tooltip visible al hover.
- API: `POST /api/contribuyentes` retorna 403 con el mensaje del spec.
4. Tenant trial expirado con 5 contribuyentes:
- UI: botón habilitado.
- API: 201 (puede crear el 6º — sin límite trial).
5. Tenant pagado (Business Control) con 5 contribuyentes:
- UI: botón habilitado.
- API: 201.
## Implementación
~15 líneas backend + ~8 líneas frontend. Cambio chico, una commit en
Downloads + V.1.0.11 en OneDrive.