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

27 KiB

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
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:
// 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:

{
  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:

-- 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.prismaTenantMembership 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:
    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.comtenants: [{ 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-membershipMembershipSwitcher (cambia JWT real)
  • Admin globalTenantSelector (impersonación, los demás tenants no son suyos)

Para probar antes de Fase 4, se puede insertar una membership manual en 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:
    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/trial400 "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:

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. generatePaymentLinkpayerEmail 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.createTenantprisma.user.create solo email/passwordHash/nombre/lastTenantId. getAllTenants _count ahora cuenta memberships (where active) en vez de users.
  • usuarios.service.inviteUsuarioprisma.user.create sin tenantId/rolId.
  • platform-staff.controllersearchUsers 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!"