# 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 { 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 `. **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!" ✅