feat(sat): factura global + fecha_efectiva, fallback tenant-contribuyente, fix anio_global typo
Factura Global & fecha_efectiva: - Migracion 045_factura_global.sql: periodicidad, meses_global, año_global, fecha_efectiva - sat-parser.service.ts: extrae InformacionGlobal del XML - sat.service.ts: calcFechaEfectiva con soporte bimestral (periodicidad 05) - metricas-compute, dashboard, impuestos, cfdi, export, conciliacion, alertas: reemplaza fecha_emision-1h por COALESCE(fecha_efectiva, fecha_emision-1h) - Script recalc-metricas.ts para recalculo manual Fallback datos fiscales tenant → contribuyente: - contribuyente.service.ts: fetchTenantFiscalData + mergeContribuyenteWithTenant rellena regimenFiscal, codigoPostal y domicilio cuando el contribuyente tiene el mismo RFC que el tenant y sus campos estan vacios - contribuyente.controller.ts y contribuyente-config.controller.ts: pasan req.user!.tenantId al servicio Fix critico SAT sync: - sat.service.ts: anio_global → año_global en INSERT/UPDATE de CFDIs (la migracion creo 'año_global' con tilde; el codigo usaba 'anio_global', causando fallo en 100% de inserciones de CFDI) - determineChunkMonths: salta sondeo si existe job previo con requestIds - MAX_POLL_ATTEMPTS: 45 → 500 (~8h) para syncs iniciales grandes Docs: - docs/sessions/2026-05-22-factura-global-contribuyente-fallback.md
This commit is contained in:
236
docs/sessions/2026-05-04-business-control-prueba-trial.md
Normal file
236
docs/sessions/2026-05-04-business-control-prueba-trial.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# Sesión: Trial Business Control Prueba (Invitación desde Admin Global)
|
||||
|
||||
**Fecha:** 2026-05-04
|
||||
**Feature:** Sistema de invitaciones de trial configurable para plan Business Control
|
||||
|
||||
---
|
||||
|
||||
## 1. Requerimiento
|
||||
|
||||
Crear un trial específico para el plan **Business Control**, llamado "Business Control Prueba", con las siguientes características:
|
||||
|
||||
1. **Registro simplificado**: Los nuevos usuarios se registran sin escoger plan. Todos empiezan con el trial genérico actual (30 días, 3 RFCs, 1 usuario, MANAGED).
|
||||
2. **Invitación desde admin global**: El administrador global puede enviar una invitación a un tenant para activar "Business Control Prueba".
|
||||
3. **Periodo gratuito configurable**: El admin define cuántos días dura la prueba (1-365 días).
|
||||
4. **Todas las features de Business Control**: Durante el trial, el tenant tiene 100 RFCs, usuarios ilimitados, API, SAT incremental, etc.
|
||||
5. **Al vencer**: El tenant debe pagar la suscripción de Business Control para continuar.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decisiones Arquitectónicas
|
||||
|
||||
### 2.1 No nuevo enum Plan
|
||||
No se agregó `business_control_trial` al enum `Plan`. En su lugar:
|
||||
- `tenant.plan` se actualiza a `business_control` al aceptar la invitación.
|
||||
- `subscription.plan` = `business_control`, `subscription.status` = `'trial'`.
|
||||
- `tenant.trialEndsAt` se recalcula según los días configurados.
|
||||
|
||||
**Ventaja**: El feature-gate (`requireFeature`) y los límites (`DESPACHO_PLANS['business_control']`) funcionan automáticamente.
|
||||
|
||||
### 2.2 dbMode siempre MANAGED (Opción 1)
|
||||
**Decisión arquitectónica clave**: Todos los tenants, sin importar el plan, siempre tienen `dbMode: 'MANAGED'`. El conector/BYO es una **feature de respaldo** que se activa por separado sin cambiar el modo de la base de datos.
|
||||
|
||||
**Razón**: El BYO es "como respaldo" para cuando fallen los servicios en la nube, pero muchos clientes no tendrán servidor físico desde el inicio. Business Control y Enterprise operan 100% en la nube; el conector local es un respaldo opcional.
|
||||
|
||||
**Cambios aplicados**:
|
||||
- `signupDespacho()`: siempre crea tenant con `dbMode: 'MANAGED'`
|
||||
- `provisionConnector()`: ya NO cambia `dbMode` a `'BYO'`; solo guarda `connectorTokenEnc` y `connectorTunnelHostname`
|
||||
- `DESPACHO_PLANS`: `business_control` y `business_cloud` ahora tienen `dbMode: 'MANAGED'`
|
||||
- Seed de `despacho_plan_prices`: actualizado a `MANAGED` para esos planes
|
||||
- BD: `UPDATE despacho_plan_prices SET db_mode = 'MANAGED' WHERE plan IN ('business_control', 'business_cloud')`
|
||||
|
||||
### 2.3 Registro simplificado
|
||||
Se eliminó la selección de plan y frecuencia del registro de despacho (`/register-despacho`). Todos los nuevos usuarios empiezan con trial genérico de 30 días.
|
||||
|
||||
---
|
||||
|
||||
## 3. Cambios Implementados
|
||||
|
||||
### 3.1 Backend
|
||||
|
||||
#### Migración Prisma — `TrialInvitation`
|
||||
Nueva tabla en `apps/api/prisma/schema.prisma`:
|
||||
```prisma
|
||||
model TrialInvitation {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @map("tenant_id")
|
||||
invitedBy String @map("invited_by")
|
||||
plan String @default("business_control")
|
||||
durationDays Int @map("duration_days")
|
||||
status String @default("pending")
|
||||
token String @unique
|
||||
emailSentTo String? @map("email_sent_to")
|
||||
sentAt DateTime @default(now()) @map("sent_at")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
acceptedAt DateTime? @map("accepted_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
}
|
||||
```
|
||||
|
||||
**Migración aplicada:** `20260507201624_add_trial_invitations`
|
||||
|
||||
#### Nuevo Service: `apps/api/src/services/trial-invitations.service.ts`
|
||||
- `createInvitation()` — Crea invitación, genera token, envía email al owner
|
||||
- `acceptInvitation()` — Valida token, actualiza tenant.plan, crea subscription trial
|
||||
- `getInvitations()` — Lista invitaciones con enrich de tenant data
|
||||
- `getPendingInvitationForTenant()` — Obtiene invitación pendiente para un tenant
|
||||
- `cancelInvitation()` — Cancela invitación pendiente
|
||||
|
||||
#### Nuevo Controller: `apps/api/src/controllers/trial-invitations.controller.ts`
|
||||
Endpoints:
|
||||
- `POST /api/invitations/trial` — Solo global admin. Body: `{ tenantId, plan?, durationDays }`
|
||||
- `GET /api/invitations/trial` — Solo global admin. Lista todas.
|
||||
- `GET /api/invitations/trial/pending` — Autenticado. Devuelve invitación pendiente para el tenant.
|
||||
- `POST /api/invitations/trial/:token/accept` — Autenticado. Owner del tenant.
|
||||
- `POST /api/invitations/trial/:id/cancel` — Solo global admin.
|
||||
- `GET /api/invitations/trial/token/:token` — Público (autenticado). Detalles de invitación.
|
||||
|
||||
#### Nuevas Routes: `apps/api/src/routes/trial-invitations.routes.ts`
|
||||
Registradas en `app.ts` bajo `/api/invitations/trial`.
|
||||
|
||||
#### Modificación: `apps/api/src/controllers/despacho.controller.ts`
|
||||
`getMyPlan()` ahora respeta `subscription.plan` cuando `status === 'trial'`:
|
||||
```ts
|
||||
if (subscription?.status === 'trial' && subscription.plan && subscription.plan !== 'trial') {
|
||||
currentPlan = subscription.plan; // 'business_control' si es Business Control Prueba
|
||||
} else if (isTrialActive) {
|
||||
currentPlan = 'trial';
|
||||
} else {
|
||||
currentPlan = String(tenant.plan);
|
||||
}
|
||||
```
|
||||
|
||||
Schema Zod de signup: `plan` y `frequency` ahora son puramente opcionales (sin defaults forzados).
|
||||
|
||||
#### Modificación: `apps/api/src/services/despacho.service.ts`
|
||||
- `signupDespacho()` ya no crea suscripción pagada durante el registro.
|
||||
- Todos los nuevos tenants empiezan con `plan: 'trial'`, `dbMode: 'MANAGED'`.
|
||||
- No se devuelve `paymentUrl` en el response.
|
||||
|
||||
#### Nuevo template de email: `apps/api/src/services/email/templates/trial-invitation.ts`
|
||||
Email de invitación con link de activación y detalles del plan.
|
||||
|
||||
#### Modificación: `apps/api/src/services/email/email.service.ts`
|
||||
Agregado método `sendTrialInvitation()`.
|
||||
|
||||
### 3.2 Frontend
|
||||
|
||||
#### Registro simplificado: `apps/web/app/(auth)/register-despacho/page.tsx`
|
||||
- Eliminado step 3 (selección de plan y frecuencia).
|
||||
- Solo 2 pasos: datos del despacho/owner → selección de vertical.
|
||||
- No se envía `plan` ni `frequency` en el POST.
|
||||
- Redirige a `/onboarding` tras registro exitoso.
|
||||
|
||||
#### Nueva página: `apps/web/app/invitacion/trial/[token]/page.tsx`
|
||||
- Muestra detalles de la invitación (plan, días, despacho).
|
||||
- Botón "Aceptar invitación" (requiere autenticación como owner).
|
||||
- Si no está logueado, redirige a login con redirect.
|
||||
- Estados: loading, inválida, expirada, aceptada, éxito.
|
||||
|
||||
#### Modificación: `apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx`
|
||||
- Detecta invitación pendiente vía `GET /api/invitations/trial/pending`.
|
||||
- Muestra banner destacado: "Invitación especial — Business Control Prueba" con botón "Activar ahora".
|
||||
- Al aceptar, recarga el plan info y muestra mensaje de éxito.
|
||||
|
||||
#### Nueva página de admin: `apps/web/app/(dashboard)/admin/invitaciones-trial/page.tsx`
|
||||
- Formulario para crear invitación: selector de despacho, plan, duración en días.
|
||||
- Tabla de historial con estados (Pendiente, Aceptada, Expirada, Cancelada).
|
||||
- Acción cancelar para invitaciones pendientes.
|
||||
|
||||
#### Modificación: `apps/web/components/layouts/sidebar.tsx`
|
||||
Agregado link "Invitaciones Trial" a la navegación de admin global.
|
||||
|
||||
#### Nuevo API client: `apps/web/lib/api/trial-invitations.ts`
|
||||
Helpers para consumir todos los endpoints de invitaciones.
|
||||
|
||||
---
|
||||
|
||||
## 4. Flujo de Uso
|
||||
|
||||
### 4.1 Admin envía invitación
|
||||
1. Admin global navega a **Invitaciones Trial** en el sidebar.
|
||||
2. Selecciona un despacho, elige plan (Business Control o Enterprise), define duración (ej. 60 días).
|
||||
3. Clic en "Enviar invitación".
|
||||
4. El backend crea el token y envía email al owner del despacho.
|
||||
|
||||
### 4.2 Owner recibe y acepta
|
||||
1. Owner recibe email con link `/invitacion/trial/{token}`.
|
||||
2. Abre el link (si no está logueado, va a login primero).
|
||||
3. Ve los detalles de la invitación y clic en "Aceptar invitación".
|
||||
4. El backend:
|
||||
- Actualiza `tenant.plan = 'business_control'`
|
||||
- Actualiza `tenant.trialEndsAt = now + 60 días`
|
||||
- Marca subscriptions trial anteriores como `trial_converted`
|
||||
- Crea nueva subscription con `plan: 'business_control', status: 'trial'`
|
||||
- Marca invitación como `accepted`
|
||||
5. Owner es redirigido a `/configuracion/planes-despacho`.
|
||||
|
||||
### 4.3 Durante el trial
|
||||
- El tenant tiene todas las features de Business Control (100 RFCs, usuarios ilimitados, API, etc.).
|
||||
- `getMyPlan()` retorna `plan: 'business_control'` en lugar de `'trial'`.
|
||||
- Feature-gate permite acceso a todas las funciones del plan.
|
||||
|
||||
### 4.4 Al vencer
|
||||
- `plan-limits.middleware` detecta `needsRenewal = true`.
|
||||
- Bloquea escrituras (POST/PUT/DELETE) con código `SUBSCRIPTION_INACTIVE`.
|
||||
- Muestra banner: "Tu prueba gratuita terminó. Renueva tu plan para continuar."
|
||||
- Owner puede contratar Business Control desde `/configuracion/planes-despacho`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Archivos Modificados/Creados
|
||||
|
||||
### Backend (nuevos)
|
||||
- `apps/api/src/services/trial-invitations.service.ts`
|
||||
- `apps/api/src/controllers/trial-invitations.controller.ts`
|
||||
- `apps/api/src/routes/trial-invitations.routes.ts`
|
||||
- `apps/api/src/services/email/templates/trial-invitation.ts`
|
||||
- `apps/api/prisma/migrations/20260507201624_add_trial_invitations/migration.sql`
|
||||
|
||||
### Backend (modificados)
|
||||
- `apps/api/prisma/schema.prisma`
|
||||
- `apps/api/prisma/seed.ts`
|
||||
- `apps/api/src/app.ts`
|
||||
- `apps/api/src/services/despacho.service.ts`
|
||||
- `apps/api/src/controllers/despacho.controller.ts`
|
||||
- `apps/api/src/services/email/email.service.ts`
|
||||
- `apps/api/src/services/connector.service.ts`
|
||||
- `apps/api/src/services/admin-dashboard.service.ts`
|
||||
- `packages/shared/src/constants/despacho-plans.ts`
|
||||
|
||||
### Frontend (nuevos)
|
||||
- `apps/web/app/invitacion/trial/[token]/page.tsx`
|
||||
- `apps/web/app/(dashboard)/admin/invitaciones-trial/page.tsx`
|
||||
- `apps/web/lib/api/trial-invitations.ts`
|
||||
|
||||
### Frontend (modificados)
|
||||
- `apps/web/app/(auth)/register-despacho/page.tsx`
|
||||
- `apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx`
|
||||
- `apps/web/components/layouts/sidebar.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 6. Deploy
|
||||
|
||||
```bash
|
||||
cd /root/HoruxDespachosNuevo
|
||||
# Backend: migración ya aplicada + build implícito en reload
|
||||
# Frontend:
|
||||
pnpm build --filter=@horux/web
|
||||
# PM2 reload:
|
||||
pm2 reload horux-api
|
||||
pm2 reload horux-web
|
||||
```
|
||||
|
||||
**Estado:** ✅ Exitoso. Build sin errores. Procesos reiniciados.
|
||||
|
||||
---
|
||||
|
||||
## 7. Notas para Futuras Sesiones
|
||||
|
||||
- Si se quiere agregar más planes a las invitaciones (ej. `mi_empresa_plus`), solo hay que agregarlos al Select del frontend de admin.
|
||||
- El email de invitación usa `FRONTEND_URL` del environment. Si no está seteado, fallback a `https://app.horux360.com`.
|
||||
- La invitación expira en 7 días desde el envío (configurable en `createInvitation()`).
|
||||
- Si un tenant en trial genérico acepta Business Control Prueba, su trial anterior se pierde (se sobreescribe `trialEndsAt`). Esto es intencional.
|
||||
- Considerar agregar un cron que marque invitaciones expiradas automáticamente (hoy solo se marcan al intentar aceptar).
|
||||
- **dbMode siempre MANAGED**: El conector/BYO es una feature de respaldo independiente. Nunca cambiar `dbMode` a `'BYO'` en ningún flujo. Si en el futuro se quiere usar la conexión BYO como fallback cuando la nube falla, implementarlo en el `tenant.middleware.ts` como lógica de retry/fallback, no como modo principal.
|
||||
267
docs/sessions/2026-05-04-cross-tenant-access-platform-staff.md
Normal file
267
docs/sessions/2026-05-04-cross-tenant-access-platform-staff.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# Sesión: Fix de Acceso Cross-Tenant para Platform Staff (`platform_ti`)
|
||||
|
||||
**Fecha:** 2026-05-04
|
||||
**Participante:** Ivan (`ivan@horuxfin.com`) — Tenant role: `contador`, Platform role: `platform_ti`
|
||||
**User ID:** `9fa5f15e-5f17-4501-acde-f761f08ed533`
|
||||
**Tenant:** HORUX 360 (`c52c2f5d-b1ae-45c6-8cc8-b11c9611618a`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Problema Reportado
|
||||
|
||||
Ivan, con rol de plataforma `platform_ti`, no podía ver otros tenants (despachos) desde la sección "Despachos". Al navegar a `/despachos/contribuyentes`, recibía errores 403 y no veía el selector de tenant ni los datos de otros despachos.
|
||||
|
||||
**Síntomas:**
|
||||
- Error 403 en `/api/tenants`
|
||||
- Error 403 en endpoints de stats de despachos (`/api/despacho-stats/*`)
|
||||
- No se mostraba el dropdown de selección de tenant en el frontend
|
||||
- Redirecciones incorrectas basadas únicamente en `role` (ignorando `platformRoles`)
|
||||
|
||||
---
|
||||
|
||||
## 2. Causas Raíz Identificadas
|
||||
|
||||
### 2.1 Backend — Autorización inconsistente
|
||||
|
||||
| Problema | Ubicación | Detalle |
|
||||
|----------|-----------|---------|
|
||||
| `isGlobalAdmin()` no aceptaba `userId` | `apps/api/src/utils/platform-admin.ts` | Solo verificaba `role === 'owner'` + checks legacy. No revisaba `platformRoles`. |
|
||||
| `X-View-Tenant` bloqueado para `platform_ti` | `apps/api/src/middlewares/tenant.middleware.ts` | Solo permitía `platform_admin`, no `platform_ti`. |
|
||||
| Stats de despachos requerían `owner` exclusivamente | `apps/api/src/controllers/despacho-stats.controller.ts` | `getContribuyentesStats`, `getMisAsignados`, `getEquipoStats` usaban `ROLES_OWNER.has(role)`. |
|
||||
| `tenants.controller.ts` no pasaba `userId` | `apps/api/src/controllers/tenants.controller.ts` | `requireGlobalAdmin()` llamaba `isGlobalAdmin(tenantId, role)` sin `userId`. |
|
||||
| `usuarios.controller.ts` no pasaba `userId` | `apps/api/src/controllers/usuarios.controller.ts` | Mismo patrón que tenants. |
|
||||
|
||||
### 2.2 Frontend — Guards incompletos
|
||||
|
||||
| Problema | Ubicación | Detalle |
|
||||
|----------|-----------|---------|
|
||||
| Redirección basada solo en `role` | `apps/web/app/(dashboard)/despachos/page.tsx` | No consideraba `platformRoles` para redirigir a `/despachos/contribuyentes`. |
|
||||
| `despacho-subnav.tsx` no incluía `platform_ti` | `apps/web/components/despachos/despacho-subnav.tsx` | `defaultDespachoPathForRole()` no manejaba platform roles. |
|
||||
| `contribuyentes/page.tsx` no permitía `platform_ti` | `apps/web/app/(dashboard)/despachos/contribuyentes/page.tsx` | `enabled` solo para `owner` / `cfo`. |
|
||||
| `mis-asignados/page.tsx` no permitía `platform_ti` | `apps/web/app/(dashboard)/despachos/mis-asignados/page.tsx` | Guard de roles excluía platform staff. |
|
||||
| `tenant-view-store.ts` no guardaba RFC | `apps/web/stores/tenant-view-store.ts` | `setViewingTenant` solo aceptaba `id` y `name`; faltaba `rfc` para headers. |
|
||||
| Llamadas sin guard a `/api/tenants` | `apps/web/app/(dashboard)/clientes/page.tsx` | `useTenants()` se ejecutaba sin verificar `isGlobalAdminRfc`. |
|
||||
| Llamadas sin guard en admin/usuarios | `apps/web/app/(dashboard)/admin/usuarios/page.tsx` | `useQuery` para `/api/tenants` sin condición de habilitación. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Cambios Implementados
|
||||
|
||||
### 3.1 Backend
|
||||
|
||||
#### `apps/api/src/utils/platform-admin.ts`
|
||||
```ts
|
||||
export async function isGlobalAdmin(tenantId: string, role: string, userId?: string): Promise<boolean> {
|
||||
// NUEVO: Bypass para platform staff
|
||||
if (userId && await hasAnyPlatformRole(userId, ...SUPERSET_ROLES)) {
|
||||
return true;
|
||||
}
|
||||
if (role !== 'owner') return false;
|
||||
// ... legacy checks
|
||||
}
|
||||
```
|
||||
|
||||
#### `apps/api/src/middlewares/tenant.middleware.ts`
|
||||
```ts
|
||||
// Admin impersonation via X-View-Tenant header
|
||||
const viewTenantHeader = req.headers['x-view-tenant'] as string;
|
||||
if (viewTenantHeader) {
|
||||
// FIX: Ahora acepta platform_admin y platform_ti
|
||||
const isPlatformStaff = await hasAnyPlatformRole(req.user.userId, 'platform_admin', 'platform_ti');
|
||||
const globalAdmin = !isPlatformStaff && await isGlobalAdmin(req.user.tenantId, req.user.role);
|
||||
if (!isPlatformStaff && !globalAdmin) {
|
||||
return res.status(403).json({ message: 'No autorizado para ver otros tenants' });
|
||||
}
|
||||
// ... resolve viewed tenant pool
|
||||
}
|
||||
```
|
||||
|
||||
#### `apps/api/src/controllers/despacho-stats.controller.ts`
|
||||
```ts
|
||||
function isPlatformStaff(user: any): boolean {
|
||||
return (user?.platformRoles || []).some((r: string) => ['platform_admin', 'platform_ti'].includes(r));
|
||||
}
|
||||
|
||||
// Aplicado a:
|
||||
// - getContribuyentesStats
|
||||
// - getMisAsignados
|
||||
// - getEquipoStats
|
||||
```
|
||||
|
||||
#### `apps/api/src/controllers/tenants.controller.ts`
|
||||
```ts
|
||||
async function requireGlobalAdmin(req: Request): Promise<void> {
|
||||
// FIX: Ahora pasa req.user!.userId
|
||||
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId))) {
|
||||
throw new AppError(403, 'Solo el administrador global puede gestionar clientes');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllTenants(req: Request, res: Response, next: NextFunction) {
|
||||
// FIX FINAL: Devuelve 200 [] en lugar de 403 para no generar ruido en consola
|
||||
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
|
||||
if (!isAdmin) {
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
return res.json([]);
|
||||
}
|
||||
// ... resto
|
||||
}
|
||||
|
||||
export async function getTenant(req: Request, res: Response, next: NextFunction) {
|
||||
// FIX FINAL: Devuelve 404 en lugar de 403
|
||||
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
|
||||
if (!isAdmin) {
|
||||
return res.status(404).json({ message: 'Cliente no encontrado' });
|
||||
}
|
||||
// ... resto
|
||||
}
|
||||
```
|
||||
|
||||
#### `apps/api/src/controllers/usuarios.controller.ts`
|
||||
```ts
|
||||
async function isGlobalAdmin(req: Request): Promise<boolean> {
|
||||
// FIX: Ahora pasa req.user!.userId
|
||||
return checkGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Frontend
|
||||
|
||||
#### `apps/web/components/despachos/despacho-subnav.tsx`
|
||||
```ts
|
||||
const ITEMS: NavItem[] = [
|
||||
{ href: '/despachos/contribuyentes', label: 'Contribuyentes', icon: Building2,
|
||||
roles: ['owner','cfo','contador','visor','supervisor','auxiliar'] },
|
||||
{ href: '/despachos/mis-asignados', label: 'Mis asignados', icon: UserCheck,
|
||||
roles: ['owner','cfo','supervisor','auxiliar','contador','visor'] },
|
||||
{ href: '/despachos/equipo', label: 'Equipo', icon: Users,
|
||||
roles: ['owner','cfo','supervisor'] },
|
||||
];
|
||||
|
||||
export function defaultDespachoPathForRole(role: string, platformRoles?: string[]): string {
|
||||
const isStaff = platformRoles?.some(r => ['platform_admin','platform_ti'].includes(r));
|
||||
if (isStaff || role === 'owner' || role === 'cfo') return '/despachos/contribuyentes';
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### `apps/web/app/(dashboard)/despachos/page.tsx`
|
||||
```ts
|
||||
// FIX: Redirige platform_ti a contribuyentes
|
||||
if (platformRoles?.some(r => ['platform_admin', 'platform_ti'].includes(r))) {
|
||||
redirect('/despachos/contribuyentes');
|
||||
}
|
||||
```
|
||||
|
||||
#### `apps/web/app/(dashboard)/despachos/contribuyentes/page.tsx`
|
||||
```ts
|
||||
const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']);
|
||||
const isPlatformStaff = platformRoles?.some(r => PLATFORM_SUPERSET.has(r)) ?? false;
|
||||
const enabled = role === 'owner' || role === 'cfo' || isPlatformStaff;
|
||||
|
||||
// Dropdown de selección de tenant (solo para platform staff)
|
||||
const { data: despachos } = useQuery({
|
||||
queryKey: ['admin-despachos'],
|
||||
queryFn: fetchAdminDespachos,
|
||||
enabled: isPlatformStaff
|
||||
});
|
||||
|
||||
// onValueChange llama a setViewingTenant(despachoId, despachoName, despachoRfc)
|
||||
```
|
||||
|
||||
#### `apps/web/app/(dashboard)/despachos/mis-asignados/page.tsx`
|
||||
```ts
|
||||
// FIX: Ahora permite contador, visor, y platform staff
|
||||
const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']);
|
||||
const isPlatformStaff = platformRoles?.some(r => PLATFORM_SUPERSET.has(r)) ?? false;
|
||||
const enabled = ['owner','cfo','supervisor','auxiliar','contador','visor'].includes(role || '') || isPlatformStaff;
|
||||
```
|
||||
|
||||
#### `apps/web/stores/tenant-view-store.ts`
|
||||
```ts
|
||||
interface TenantViewState {
|
||||
viewingTenantId: string | null;
|
||||
viewingTenantName: string | null;
|
||||
viewingTenantRfc: string | null; // NUEVO
|
||||
setViewingTenant: (id: string | null, name: string | null, rfc?: string | null) => void;
|
||||
clearViewingTenant: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
#### `apps/web/app/(dashboard)/clientes/page.tsx`
|
||||
```ts
|
||||
// FIX: Solo llama a useTenants() si es global admin
|
||||
const { data: tenants } = useTenants({ enabled: isGlobalAdminRfc });
|
||||
```
|
||||
|
||||
#### `apps/web/app/(dashboard)/admin/usuarios/page.tsx`
|
||||
```ts
|
||||
// FIX: Condicional en query para evitar 403
|
||||
const { data: tenants } = useQuery({
|
||||
queryKey: ['tenants'],
|
||||
queryFn: getTenants,
|
||||
enabled: isGlobalAdminRfc, // NUEVO
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Decisiones Arquitectónicas
|
||||
|
||||
### 4.1 Platform Roles como Superset
|
||||
Los roles `platform_admin` y `platform_ti` se tratan como un **superset** que bypassa casi todas las verificaciones de tenant-level authorization. Esto es intencional: el staff de plataforma necesita operar cross-tenant sin fricción.
|
||||
|
||||
### 4.2 Soft Fail para `GET /api/tenants`
|
||||
En lugar de devolver `403` a usuarios no autorizados en `GET /api/tenants`, se devuelve `200 []`. Esto elimina ruido en la consola del navegador cuando componentes React hacen polling o renderizan hooks incondicionalmente. Las operaciones de escritura (`POST`, `PUT`, `DELETE`) siguen devolviendo `403`.
|
||||
|
||||
### 4.3 `X-View-Tenant` Header
|
||||
El mecanismo de impersonación de tenant usa:
|
||||
1. Frontend: `tenant-view-store` guarda `viewingTenantId` en `localStorage` (key: `horux-tenant-view`)
|
||||
2. Frontend: `apiClient` lee el store e inyecta header `X-View-Tenant`
|
||||
3. Backend: `tenantMiddleware` resuelve la DB pool del tenant visto
|
||||
|
||||
### 4.4 Cache Headers
|
||||
Se agregaron headers `Cache-Control: no-store, no-cache, must-revalidate, proxy-revalidate` a las respuestas de tenants para evitar que proxies/CDN cacheen datos cross-tenant.
|
||||
|
||||
---
|
||||
|
||||
## 5. Deploy
|
||||
|
||||
```bash
|
||||
cd /root/HoruxDespachosNuevo
|
||||
pnpm build --filter=@horux/web
|
||||
pm2 reload horux-api
|
||||
pm2 reload horux-web
|
||||
```
|
||||
|
||||
**Estado:** ✅ Exitoso. Ivan confirmó que puede ver todos los tenants.
|
||||
|
||||
---
|
||||
|
||||
## 6. Archivos Modificados
|
||||
|
||||
### Backend
|
||||
- `apps/api/src/middlewares/tenant.middleware.ts`
|
||||
- `apps/api/src/utils/platform-admin.ts`
|
||||
- `apps/api/src/controllers/despacho-stats.controller.ts`
|
||||
- `apps/api/src/controllers/tenants.controller.ts`
|
||||
- `apps/api/src/controllers/usuarios.controller.ts`
|
||||
|
||||
### Frontend
|
||||
- `apps/web/components/despachos/despacho-subnav.tsx`
|
||||
- `apps/web/app/(dashboard)/despachos/page.tsx`
|
||||
- `apps/web/app/(dashboard)/despachos/contribuyentes/page.tsx`
|
||||
- `apps/web/app/(dashboard)/despachos/mis-asignados/page.tsx`
|
||||
- `apps/web/stores/tenant-view-store.ts`
|
||||
- `apps/web/app/(dashboard)/clientes/page.tsx`
|
||||
- `apps/web/app/(dashboard)/admin/usuarios/page.tsx`
|
||||
- `apps/web/hooks/use-tenants.ts`
|
||||
|
||||
---
|
||||
|
||||
## 7. Notas para Futuras Sesiones
|
||||
|
||||
- Si se agrega un nuevo endpoint cross-tenant, siempre verificar que `isGlobalAdmin()` reciba `userId` para que los platform roles funcionen.
|
||||
- Si se agrega una nueva página en `/despachos/*`, usar `isPlatformStaff` o incluir platform roles en los guards.
|
||||
- El helper `isGlobalAdminRfc` en `@horux/shared` ya maneja `platformRoles`, pero los controllers backend deben usar la versión con `userId`.
|
||||
- Considerar centralizar la lógica de `isPlatformStaff` en un helper compartido backend/frontend para evitar duplicación.
|
||||
@@ -0,0 +1,121 @@
|
||||
# Sesión de cambios: 2026-05-22
|
||||
|
||||
## Resumen
|
||||
|
||||
Tres líneas de trabajo: (1) implementación completa de facturas globales (`InformacionGlobal`) con `fecha_efectiva`, (2) fallback robusto de datos fiscales del tenant a contribuyentes con RFC coincidente, y (3) corrección crítica de typo `anio_global` → `año_global` en sincronización SAT.
|
||||
|
||||
---
|
||||
|
||||
## 1. Facturas Globales — `InformacionGlobal` y `fecha_efectiva`
|
||||
|
||||
### Contexto
|
||||
Las facturas globales del SAT usan el nodo `<cfdi:InformacionGlobal>` que indica la periodicidad, meses y año al que realmente corresponden los ingresos. Antes del cambio, todos los CFDIs se agrupaban por `fecha_emision`, lo que desplazaba facturas globales emitidas al cierre de un período (ej. 31 de marzo) al mes equivocado.
|
||||
|
||||
### Cambios
|
||||
|
||||
**Base de datos**
|
||||
- Nueva migración `apps/api/src/migrations/tenant/045_factura_global.sql`:
|
||||
- `periodicidad VARCHAR(2)`
|
||||
- `meses_global VARCHAR(10)`
|
||||
- `año_global VARCHAR(4)`
|
||||
- `fecha_efectiva DATE` + índice `idx_cfdis_fecha_efectiva`
|
||||
- Aplicada a todos los tenants activos vía script de migración.
|
||||
|
||||
**Parser SAT**
|
||||
- `apps/api/src/services/sat/sat-parser.service.ts`
|
||||
- `CfdiParsed` ahora incluye `periodicidad`, `mesesGlobal`, `añoGlobal`.
|
||||
- Extraídos del XML desde `comprobante.InformacionGlobal`.
|
||||
|
||||
**Cálculo de `fecha_efectiva`**
|
||||
- `apps/api/src/services/sat/sat.service.ts`
|
||||
- `calcFechaEfectiva(cfdi)`: devuelve `new Date(año, mes-1, 1)` para facturas globales.
|
||||
- Soporta periodicidad bimestral (`05`): códigos `13-18` → meses `2,4,6,8,10,12`.
|
||||
|
||||
**Queries de métricas/reportes**
|
||||
Reemplazado `fecha_emision - interval '1 hour'` por `COALESCE(fecha_efectiva, fecha_emision - interval '1 hour')` en:
|
||||
- `metricas-compute.service.ts` (counts, min_anio, monthly compute)
|
||||
- `reportes.service.ts` (flujo efectivo, comparativos)
|
||||
- `dashboard.service.ts` (KPIs, neteo PPD/07)
|
||||
- `impuestos.service.ts` (IVA mensual)
|
||||
- `alertas-auto.service.ts` (alertas RESICO y régimen desconocido)
|
||||
- `cfdi.service.ts` (list filters, `getResumenCfdis`)
|
||||
- `export.service.ts`
|
||||
- `conciliacion.service.ts`
|
||||
- `alertas.controller.ts`
|
||||
|
||||
**Backfill**
|
||||
- Ejecutado en todos los tenants activos:
|
||||
- `horux_hts240708lja`: 24 registros
|
||||
- `horux_roem691011ez4`: 2,238 registros
|
||||
- `horux_auza640701ti9`: 6 registros
|
||||
- `horux_momc8311199va`: 14 registros
|
||||
- Métricas recalculadas para TORC9611214CA (enero-marzo 2026).
|
||||
|
||||
---
|
||||
|
||||
## 2. Fallback de datos fiscales del tenant al contribuyente
|
||||
|
||||
### Problema
|
||||
El tenant HORUX 360 (`HTS240708LJA`) tiene su régimen fiscal y domicilio en la base central (`tenants`), pero al existir como contribuyente dentro de su propia BD (`contribuyentes`), esos campos estaban vacíos. El frontend mostraba "Sin régimen" y "Sin domicilio" al seleccionar ese contribuyente.
|
||||
|
||||
### Solución robusta
|
||||
Cuando un contribuyente tiene el mismo RFC que su tenant, el backend ahora mezcla automáticamente los datos faltantes desde la base central.
|
||||
|
||||
**Archivos**
|
||||
- `apps/api/src/services/contribuyente.service.ts`
|
||||
- `fetchTenantFiscalData(tenantId)`: consulta `tenants` + `tenant_regimenes_activos` para obtener régimen (CSV de claves), CP y domicilio JSON.
|
||||
- `mergeContribuyenteWithTenant()`: rellena `regimenFiscal`, `codigoPostal` y `domicilio` si están vacíos en el contribuyente.
|
||||
- `listContribuyentes()` y `getContribuyenteById()` aceptan `tenantId` opcional.
|
||||
- `apps/api/src/controllers/contribuyente.controller.ts`
|
||||
- Pasa `req.user!.tenantId` a `listContribuyentes` y `getContribuyenteById`.
|
||||
- `apps/api/src/controllers/contribuyente-config.controller.ts`
|
||||
- Pasa `req.user!.tenantId` a `getContribuyenteById` en `uploadFiel` y `createOrg`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Fix crítico: `anio_global` → `año_global`
|
||||
|
||||
### Problema
|
||||
La migración `045_factura_global.sql` creó la columna `año_global` (con tilde), pero `sat.service.ts` usaba `anio_global` (sin tilde) en las queries `INSERT`/`UPDATE` de `saveCfdis`. Esto causaba que **cada inserción de CFDI fallara** con:
|
||||
|
||||
```
|
||||
column "anio_global" of relation "cfdis" does not exist
|
||||
```
|
||||
|
||||
Esto explicaba por qué el sync inicial del tenant DESPACHO_MPG95QP7_XZVFF insertó solo **174 de 8,284 CFDIs** descargados.
|
||||
|
||||
### Fix
|
||||
- `apps/api/src/services/sat/sat.service.ts`
|
||||
- Líneas 297 y 347: `anio_global` → `año_global`.
|
||||
|
||||
### Optimización adicional en SAT sync
|
||||
- `determineChunkMonths()`: ahora detecta si existe un job previo completado con `satRequestIds` y salta el sondeo `metadata` lento, reutilizando directamente el tamaño de chunk (3 o 6 meses).
|
||||
- `MAX_POLL_ATTEMPTS`: aumentado de 45 a 500 (~8 horas) para syncs iniciales grandes donde el SAT tarda horas en preparar paquetes.
|
||||
|
||||
### Re-sync validado
|
||||
Re-lanzado el sync inicial para DESPACHO_MPG95QP7_XZVFF tras el fix:
|
||||
- **Found:** 8,284 | **Downloaded:** 7,781 | **Inserted:** 8,266
|
||||
- Duración: ~7 minutos (vs. ~3.5 horas del intento anterior con el bug).
|
||||
|
||||
---
|
||||
|
||||
## Archivos modificados
|
||||
|
||||
| Archivo | Cambio |
|
||||
|---|---|
|
||||
| `apps/api/src/migrations/tenant/045_factura_global.sql` | Nueva migración (untracked → added) |
|
||||
| `apps/api/src/services/sat/sat-parser.service.ts` | Extrae `periodicidad`, `mesesGlobal`, `añoGlobal` |
|
||||
| `apps/api/src/services/sat/sat.service.ts` | `calcFechaEfectiva`, fix `año_global`, `determineChunkMonths` optimizado, `MAX_POLL_ATTEMPTS` |
|
||||
| `apps/api/src/services/metricas-compute.service.ts` | Usa `COALESCE(fecha_efectiva, ...)` |
|
||||
| `apps/api/src/services/dashboard.service.ts` | Usa `fecha_efectiva` en KPIs y neteo |
|
||||
| `apps/api/src/services/impuestos.service.ts` | Usa `fecha_efectiva` en IVA mensual |
|
||||
| `apps/api/src/services/cfdi.service.ts` | Filtros y resumen por `fecha_efectiva` |
|
||||
| `apps/api/src/services/export.service.ts` | Usa `fecha_efectiva` |
|
||||
| `apps/api/src/services/conciliacion.service.ts` | Usa `fecha_efectiva` |
|
||||
| `apps/api/src/services/alertas-auto.service.ts` | Usa `fecha_efectiva` en alertas |
|
||||
| `apps/api/src/controllers/alertas.controller.ts` | Usa `fecha_efectiva` en queries de alertas |
|
||||
| `apps/api/src/services/contribuyente.service.ts` | Fallback de datos fiscales del tenant |
|
||||
| `apps/api/src/controllers/contribuyente.controller.ts` | Pasa `tenantId` al servicio |
|
||||
| `apps/api/src/controllers/contribuyente-config.controller.ts` | Pasa `tenantId` al servicio |
|
||||
| `apps/api/src/scripts/recalc-metricas.ts` | Script de recálculo manual (untracked → added) |
|
||||
| `apps/web/...` | Múltiples ajustes frontend relacionados (fechas, alertas, drill-down) |
|
||||
Reference in New Issue
Block a user