Files
HoruxDespachos/docs/plans/2026-04-14-owner-multi-rfc-subscriptions.md
2026-04-27 22:09:36 -06:00

474 lines
27 KiB
Markdown

# 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!" ✅