Skip to content

PRD — Dominio 03: accounts

Cuentas, negocios, sedes, perfiles profesionales y membresías. Define quién es quién dentro de la plataforma y dónde trabaja. Es el productor principal de relaciones hacia authz.


1. Propósito

Modelar la estructura organizacional real del mundo de la belleza: una persona (Account) puede ser cliente, dueña de uno o varios negocios (Business) con múltiples sedes (Branch), profesional independiente o asociada, y/o colaboradora. Todas esas relaciones se materializan en membresías que a su vez generan tuples de autorización en authz.

accounts no decide permisos — los escribe. authz los consume.

2. Entidades

EntidadResponsabilidad
AccountCuenta única de una persona. 1:1 con AuthCredentials de auth.
BusinessNegocio. Tiene un único dueño (Account). Puede ser real o virtual (autogenerado para profesionales independientes).
BranchSede del Business. Ubicación, timezone, horarios base. Puede ser física o virtual.
ProfessionalPerfil profesional de una persona. 0..1 por Account. Portable entre sedes.
BranchMembershipNúcleo de la relación Account↔Branch. type ∈ {professional, collaborator}.
ProfessionalMembershipSatélite 1:1 de BranchMembership cuando type = professional. Carga datos específicos del profesional en esa sede.

Patrón: Class Table Inheritance (CTI)

BranchMembership guarda lo común (account, branch, estado, timestamps, relación de invitación). ProfessionalMembership guarda lo específico del profesional en esa sede (servicios habilitados, horarios por sede, política de comisión, etc.). Evitamos un God Table con columnas nullables_solo_para_profesional.

BranchMembership (común)
   └── ProfessionalMembership (si type=professional)

Un colaborador (Laura) solo tiene BranchMembership. Una profesional asociada (María en Glamour Studio) tiene BranchMembership + ProfessionalMembership vinculados.

Profesional independiente = Business + Branch virtuales

Cuando una persona se registra como profesional sin asociarse a un negocio existente, accounts genera automáticamente:

  • Business virtual (kind = virtual, dueño = su propio Account)
  • Branch virtual dentro de ese business (ubicación opcional, puede ser "a domicilio" o un punto en el mapa)
  • BranchMembership(type=professional) + ProfessionalMembership apuntando a esa branch
  • Los tuples equivalentes en authz (owner, professional_member)

Así, el profesional independiente hereda naturalmente todos los permisos de dueño sobre su sede virtual, sin un branch aparte de lógica condicional.

3. Historias de usuario

  • U-1. Como Sofía (cliente), mi Account se crea automáticamente al registrarme — no necesito configurar nada más para reservar.
  • U-2. Como Carlos (dueño), quiero crear mi Business "Glamour Studio" y añadirle dos sedes (Chapinero, Usaquén).
  • U-3. Como María (profesional), quiero completar mi perfil profesional una sola vez y que me siga entre sedes.
  • U-4. Como María independiente, al activar mi perfil profesional la plataforma me crea un Business/Branch virtual para poder recibir reservas sin tener que inventar un nombre de negocio.
  • U-5. Como Carlos, quiero invitar a María a trabajar en mi sede de Chapinero — ella recibe una invitación y decide aceptarla.
  • U-6. Como María, quiero aceptar o rechazar una invitación de asociación, y terminarla cuando quiera (sin permiso del dueño).
  • U-7. Como Carlos, quiero dar de alta a Laura como colaboradora en Usaquén, sin que ella preste servicios.
  • U-8. Como Carlos, quiero delegar en Laura permisos específicos (gestionar agenda, aprobar servicios) sin que ella sea profesional.
  • U-9. Como Carlos, quiero ver la lista de miembros activos de cada sede y terminar la asociación cuando necesite.
  • U-10. Como Diego (platform admin), quiero suspender un Business o Branch si viola políticas, sin borrar datos históricos.

4. Requerimientos funcionales

⚠️ = decisión de MVP que se desvía o acota lo planeado originalmente, con justificación inline. Todo lo no marcado refleja el comportamiento implementado en apps/api/internal/accounts/.

Account

  • RF-1 Account se crea en la misma transacción que AuthCredentials al registrarse (coordina con auth, sección 01 RF-4).
  • RF-2 Un Account tiene: id, display_name, avatar_url?, locale, timezone?, phone_e164?, created_at, status ∈ {active, suspended, deleted}.
  • RF-3 Actualización de perfil vía PATCH /accounts/me. Borrado lógico (status=deleted) preserva historial.
  • RF-3a POST /admin/accounts/{id}/suspend — solo platform_admin. Setea status=suspended y llama a OpenFGA.Delete(account:X#active@account:X) síncronamente dentro de la misma TX (ver authz RN-7). Si OpenFGA no responde → rollback.
  • RF-3b Al verificar email exitosamente (auth domain emite evento account.email_verified): accounts escribe account:X#email_verified@account:X en el outbox.
  • RF-4 Al crear Account, accounts escribe en authz_outbox (misma TX):
    • account:X#self@account:X — autoreferencia
    • account:X#active@account:X — cuenta activa desde el inicio

Business

  • RF-5 POST /businesses crea un Business. Campos: legal_name, display_name, kind ∈ {regular, virtual}, tax_id?, owner_account_id (=caller por defecto).
  • RF-6 Al crear Business, se escriben en el outbox (misma TX):
    • business:X#owner@account:Y
    • business:X#active@business:X — negocio activo desde el inicio
  • RF-6a POST /admin/businesses/{id}/suspend — solo platform_admin. Setea status=suspended y llama a OpenFGA.Delete(business:X#active@business:X) síncronamente (ver authz RN-7). Cascadea: también borra branch:Z#active@account:* de todas las branches del negocio en el mismo llamado.
  • RF-7 Business virtual solo lo crea el sistema (no hay endpoint público). Es inmutable en campos clave (legal_name, kind).
  • RF-8 Un Account puede poseer múltiples Businesses (Carlos podría abrir otro negocio distinto).
  • RF-8a GET /businesses/{id} tiene dos vistas según el caller:
    • Vista pública (cualquier Account autenticado): id, display_name, kind, status. No expone owner_account_id ni tax_id.
    • Vista de gestión (caller con can_manage sobre el business o owner): todos los campos, incluyendo owner_account_id y tax_id.
    • Business con kind=virtual devuelve 404 para cualquier caller que no sea el owner (ver RN-12).

Branch

  • RF-9 POST /businesses/{id}/branches crea una Branch. Campos: display_name, kind ∈ {physical, virtual}, address?, geo_point (PostGIS geography(Point,4326)), timezone (obligatorio), phone?. Al crear, escribe en el outbox (misma TX): branch:X#active@account:* — sede activa con wildcard.
  • RF-9a POST /admin/branches/{id}/suspend o PATCH con status=archived — escribe OpenFGA.Delete(branch:X#active@account:*) síncronamente (ver authz RN-7).
  • RF-10 Branch hereda dueño vía authz (owner from parent) — no se duplica el tuple.
  • RF-11 Branches no se borran físicamente; se archivan (status=archived). Bookings históricos mantienen referencia.
  • RF-12 GET /branches/{id} es público para branches con kind=physical y status=active. Campos públicos: id, display_name, address, geo_point, phone, timezone, status. Las branches virtuales (kind=virtual) devuelven 404 para cualquier caller que no sea el owner (ver RN-12).
  • RF-13a GET /businesses/{id}/branches es público pero filtra: solo retorna branches con status=active y kind=physical. El owner (con can_manage) recibe todas, incluyendo archivadas y virtuales.

Professional (perfil)

  • RF-13 POST /accounts/me/professional crea el perfil profesional del caller. Uno por Account.
  • RF-14 Campos: bio, headline, years_experience, languages[], specialties[], portfolio_urls[], public: true.
  • RF-15 Al crear el perfil, si la persona aún no tiene ninguna BranchMembership activa como profesional, accounts crea Business+Branch virtuales y la membresía correspondiente (ver §2).
  • RF-16 GET /professionals/{account_id} es público. Incluye agregados de reviews (score, conteo) cuando ese dominio esté listo — MVP expone solo datos de accounts.

BranchMembership

  • RF-17 Flujo de invitación: el dueño (o associate_inviter) invita a un Account a una Branch.
    • POST /branches/{id}/invitations con email + type ∈ {professional, collaborator}. No requiere que el invitado tenga ya Professional — eso se valida recién al aceptar (ver RF-18).
    • Estado inicial: pending.
  • RF-17a ⚠️ (decisión 2026-07) Visibilidad de la invitación — sin dominio notifications. El MVP no manda email ni push: GET /accounts/me/invitations devuelve las invitaciones pending (no vencidas) cuyo email coincide con el email del caller (resuelto vía auth.GetCredentialsByAccountID, mismo patrón cross-domain que RF-13 de booking). El dashboard muestra esa lista con botones aceptar/rechazar. Se eligió así porque notifications (dominio 09) todavía no existe — construirlo completo es otro proyecto — y porque el canal in-app resuelve el problema real (el invitado no tenía forma de enterarse) sin depender de un provider de email externo. Cuando notifications exista de verdad, este endpoint puede quedar como está (una vista, no un canal) o migrarse — no es una decisión final, es la más simple que resuelve el dolor de hoy.
  • RF-18 El invitado acepta (POST /invitations/{token}/accept) o rechaza (.../reject).
    • Al aceptar: estado → active, se escriben tuples en authz_outbox:
      • branch:B#member@account:X
      • si type=professional: también branch:B#professional_member@account:X
    • Si type=professional y el Account aún no tiene Professional, el accept falla con 422 indicando que debe crear perfil profesional primero.
  • RF-19 Terminar membresía: DELETE /memberships/{id}.
    • Puede ejecutarlo el propio miembro o alguien con can_end_associations sobre la branch.
    • Estado → ended, ended_at = now().
    • Se eliminan del outbox todos los tuples de esa membresía (member, professional_member, y cualquier permiso delegado — ver RF-22).
    • Bookings futuros (>now) se marcan para cancelación vía evento hacia booking (ese dominio decide la política).
  • RF-20 Listar miembros: GET /branches/{id}/memberships?status=active. Requiere can_read sobre la branch. La respuesta enriquece cada membership con account_display_name (join contra accounts) — quien ya puede leer el equipo de la branch ve el nombre real de cada persona, no solo su account_id crudo. No es una superficie nueva de la API (no expone display_name de cuentas arbitrarias a callers sin can_read sobre la branch).
  • RF-25 GET /accounts/me/memberships devuelve las BranchMembership activas del caller, enriquecidas con branch_name, business_id y business_name (join contra branches/businesses). Alimenta el selector de contexto del dashboard (ver RN-13) — sin este join el frontend solo tenía branch_id crudo y no podía mostrar en qué sucursal de qué negocio ajeno trabaja el caller.

ProfessionalMembership (satélite)

  • RF-21 Se crea/destruye automáticamente junto a su BranchMembership cuando type=professional. No tiene endpoints CRUD propios.
  • RF-22 Campos: commission_percent?, default_visible: true, onboarded_at?. Otros datos operativos (servicios habilitados, horarios) viven en sus dominios (catalog, scheduling) y apuntan por FK a la ProfessionalMembership.id.

Delegación de permisos (relaciones no-propietarias)

  • RF-23 PUT /branches/{id}/memberships/{mid}/permissions con { relations: ["booking_manager", "service_approver", ...] }.
    • Solo el dueño de la branch. Valida contra whitelist de relaciones delegables definida en authz (§3 schema).
    • Diffea contra el estado actual y genera writes/deletes idempotentes en el outbox.
  • RF-24 GET /branches/{id}/memberships/{mid}/permissions devuelve las relaciones delegadas (lee de authz vía ListRelations). Solo el dueño de la branch, o el propio account_id consultado (puede ver sus propios permisos delegados).

5. Reglas de negocio

  • RN-1 Un Account tiene exactamente un Professional (0 o 1). No hay "perfiles múltiples".
  • RN-2 El dueño de un Business no tiene BranchMembership en sus propias branches. Su autoridad viene del tuple owner vía owner from parent. Crear una membresía redundante está prohibido.
  • RN-3 Un Account puede ser profesional en varias branches de distintos businesses simultáneamente (María en Glamour + en su branch virtual).
  • RN-4 BranchMembership.type es inmutable. Cambiar de collaborator a professional requiere terminar y crear una nueva.
  • RN-5 Invitaciones tienen TTL de 7 días. Si no se aceptan, expiran (status=expired).
  • RN-6 Un Account no puede tener dos membresías activas en la misma branch (único UNIQUE(branch_id, account_id) WHERE status='active').
  • RN-7 Terminar la membresía de un profesional no borra su ProfessionalService ni sus reviews (son portátiles). Sí desactiva los BranchService asociados a esa membresía (responsabilidad de catalog vía evento).
  • RN-8 Suspender un Business cascadea a sus Branches: se eliminan síncronamente los tuples active del business y de todas sus branches en OpenFGA. Lectura pública sigue funcionando (no hay gate de estado en can_read). Reservas nuevas y mutaciones quedan bloqueadas por el schema de authz — sin lógica adicional en los handlers.
  • RN-9 Business/Branch virtuales son 1:1 con el Account dueño. No se pueden transferir.
  • RN-10 Delegación de permisos solo es válida sobre memberships activas. Cambiar a ended revoca automáticamente todos los permisos delegados.
  • RN-11 Ningún endpoint público expone PII de gestión. Los campos owner_account_id, tax_id, y cualquier dato que permita identificar personas detrás de un negocio son visibles únicamente a callers con can_manage o owner sobre la entidad. Las respuestas públicas usan un subconjunto explícito de campos (nunca SELECT *).
  • RN-12 Las entidades virtuales son privadas. Los Business y Branch con kind=virtual (generados automáticamente para profesionales independientes) son invisibles en todos los endpoints públicos y de listado. Devuelven 404 para cualquier caller que no sea el owner. Esto evita que la estructura técnica interna sea visible en el marketplace.
  • RN-13 El dashboard permite alternar entre contexto owner y contexto staff, sin cerrar sesión. Contexto owner = negocio propio (RF-5, cookie bh_biz_id). Contexto staff = sucursal ajena con BranchMembership activa (RF-17/RF-25, cookie bh_branch_id). Son mutuamente excluyentes: activar uno limpia el otro. El contexto staff solo habilita lo que esa membership permite en esa branch puntual (servicios/agenda propios) — nunca la gestión del negocio ajeno (Mi negocio, sucursales, suspender), reservada al owner real (RN-2).
  • RN-14 ⚠️ (fix de seguridad 2026-07-06) Los 5 endpoints de gestión de membership (RF-17/19/20/23/24) verifican autorización real, no solo autenticación. Hasta esta fecha, InviteMember, EndMembership, ListMemberships, DelegatePermissions y GetDelegatedPermissions solo requerían un JWT válido — cualquier cuenta autenticada podía invitar, expulsar, listar equipo o delegar/leer permisos en cualquier branch ajena, sin chequear owner/can_invite/can_end_associations/can_read (relaciones ya documentadas en RF-17/19/20 pero nunca implementadas). Se agregaron can_read y can_invite/can_end_associations (ambas owner or associate_inviter) al modelo OpenFGA (branch type) y el chequeo correspondiente en cada handler. Nunca hubo tuples que dependieran de este gap — el fix es aditivo, sin migración de datos.

6. Flujos críticos

Registro de profesional independiente (María):

POST /accounts/me/professional
  ↓ BEGIN TX
  INSERT Professional
  INSERT Business (kind=virtual, owner=maria)
  INSERT Branch (kind=virtual, business=...)
  INSERT BranchMembership (type=professional, status=active)
  INSERT ProfessionalMembership
  INSERT authz_outbox:
    business:maria_biz#owner@account:maria
    branch:maria_virtual#parent@business:maria_biz
    branch:maria_virtual#member@account:maria
    branch:maria_virtual#professional_member@account:maria
  ↓ COMMIT
  → 201 + {professional, business, branch, membership}

Invitación y aceptación de asociada (Carlos → María en Glamour Chapinero):

Carlos → POST /branches/chap/invitations { email: maria@..., type: professional }
  authz.Check(carlos, can_invite_professionals, branch:chap) → true
  INSERT Invitation (branch=chap, email=maria@..., type=professional, status=pending, token=...)

(RF-17a: sin envío activo — María la descubre en su próximo GET /accounts/me/invitations)

María → GET /accounts/me/invitations
  resuelve email de María vía auth.GetCredentialsByAccountID
  → [ { id, branch: "Glamour Chapinero", type: professional, token, expires_at } ]

María → POST /invitations/{token}/accept
  BEGIN TX
  Si type=professional y María no tiene Professional → 422 ErrNoProfessionalProfile (crea el perfil primero, ver RF-13)
  INSERT BranchMembership (status=active)
  INSERT ProfessionalMembership
  INSERT authz_outbox:
    branch:chap#member@account:maria
    branch:chap#professional_member@account:maria
  UPDATE Invitation SET status='accepted'
  COMMIT

Terminación de asociación (María sale de Glamour):

POST /memberships/{id}/end  (caller: maria)
  Verifica: caller == membership.account OR authz.Check(caller, can_end_associations, branch)
  BEGIN TX
  UPDATE BranchMembership SET status='ended', ended_at=now()
  DELETE ProfessionalMembership
  INSERT authz_outbox (delete):
    branch:chap#member@account:maria
    branch:chap#professional_member@account:maria
    + todos los tuples de permisos delegados a maria en esa branch
  Publica evento "membership.ended" (booking + catalog lo consumen)
  COMMIT

Delegación de permisos (Carlos delega a Laura en Usaquén):

PUT /branches/usa/memberships/{laura_mid}/permissions
  body: { relations: ["booking_manager", "schedule_manager"] }
  authz.Check(carlos, can_update, branch:usa) → true
  Lee estado actual (authz.ListRelations) → []
  Diff → writes: [booking_manager, schedule_manager]
  INSERT authz_outbox (writes)
  → 200 + { relations: [...] }

7. Dependencias

DominioTipo
authCoordina creación de Account al registrar (§01 RF-4). También resuelve email↔account_id para RF-17a (visibilidad de invitaciones) y RF-13 (staff-for-others).
authzConsumidor principal: cada mutación relevante escribe al authz_outbox.
notifications⚠️ Dominio no implementado (ver 09-notifications.md). Para MVP, la visibilidad de invitaciones se resuelve directo en accounts (RF-17a), sin pasar por este dominio.
catalogConsume evento membership.ended → desactiva BranchService.
schedulingConsume evento membership.ended → archiva horarios por sede del profesional.
bookingConsume evento membership.ended → política de cancelación de reservas futuras.

accounts no lee de catalog/scheduling/booking. Se comunica por eventos de salida.

8. Fuera de alcance (MVP)

  • Transferencia de Business entre Accounts — fase 2.
  • Multi-dueño de un Business (co-propiedad) — fase 2+.
  • Colaborador a distancia (sin asociarse a una branch física) — fuera de roadmap MVP.
  • Plantillas de permisos ("perfil recepcionista" = paquete de relaciones) — fase 2 con Premium.
  • Grupos/equipos dentro de una branch — fase 3.
  • Historial de asociaciones pasadas expuesto en UI pública del profesional — fase 3.
  • Perfiles profesionales verificados (badge oficial) — fase 4.
  • Onboarding asistido (wizard de alta de business) — UX fase 2.

9. Métricas

  • Registro de profesional independiente completo (Account + perfil + branch virtual operativa) < 60s end-to-end.
  • ≥ 90% de invitaciones se aceptan o rechazan en las primeras 48h (no caen en expiración).
  • 0 inconsistencias Account↔AuthCredentials (la tx coordinada nunca debe dejar huérfanos).
  • 0 BranchMemberships activas sin tuple equivalente en authz tras 1 min (lag del outbox).
  • p95 de POST /branches/{id}/memberships/invite < 300ms.
  • < 1% de reintentos en el outbox para tuples generados por este dominio.

Documentación interna — BeautyHub