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

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).

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 (cualquier Account puede leer información básica para discovery).

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}/memberships/invite con account_id + type ∈ {professional, collaborator}.
    • Estado inicial: pending.
    • Notificación al invitado vía dominio notifications.
  • RF-18 El invitado acepta (POST /memberships/{id}/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: POST /memberships/{id}/end.
    • 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.

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 (o can_update sobre 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).

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.

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/memberships/invite { account: maria, type: professional }
  authz.Check(carlos, can_invite_professionals, branch:chap) → true
  INSERT BranchMembership (status=pending)
  notifications.send(maria, "invitation-received")

María → POST /memberships/{id}/accept
  BEGIN TX
  UPDATE BranchMembership SET status='active'
  INSERT ProfessionalMembership
  INSERT authz_outbox:
    branch:chap#member@account:maria
    branch:chap#professional_member@account:maria
  COMMIT
  notifications.send(carlos, "invitation-accepted")

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).
authzConsumidor principal: cada mutación relevante escribe al authz_outbox.
notificationsEnvía invitaciones, avisos de aceptación/terminación.
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