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