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
| Entidad | Responsabilidad |
|---|---|
Account | Cuenta única de una persona. 1:1 con AuthCredentials de auth. |
Business | Negocio. Tiene un único dueño (Account). Puede ser real o virtual (autogenerado para profesionales independientes). |
Branch | Sede del Business. Ubicación, timezone, horarios base. Puede ser física o virtual. |
Professional | Perfil profesional de una persona. 0..1 por Account. Portable entre sedes. |
BranchMembership | Núcleo de la relación Account↔Branch. type ∈ {professional, collaborator}. |
ProfessionalMembership | Saté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:
Businessvirtual (kind = virtual, dueño = su propio Account)Branchvirtual dentro de ese business (ubicación opcional, puede ser "a domicilio" o un punto en el mapa)BranchMembership(type=professional)+ProfessionalMembershipapuntando 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
AuthCredentialsal registrarse (coordina conauth, 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— soloplatform_admin. Seteastatus=suspendedy llama aOpenFGA.Delete(account:X#active@account:X)síncronamente dentro de la misma TX (verauthzRN-7). Si OpenFGA no responde → rollback. - RF-3b Al verificar email exitosamente (
authdomain emite eventoaccount.email_verified):accountsescribeaccount:X#email_verified@account:Xen el outbox. - RF-4 Al crear Account,
accountsescribe enauthz_outbox(misma TX):account:X#self@account:X— autoreferenciaaccount:X#active@account:X— cuenta activa desde el inicio
Business
- RF-5
POST /businessescrea 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:Ybusiness:X#active@business:X— negocio activo desde el inicio
- RF-6a
POST /admin/businesses/{id}/suspend— soloplatform_admin. Seteastatus=suspendedy llama aOpenFGA.Delete(business:X#active@business:X)síncronamente (verauthzRN-7). Cascadea: también borrabranch:Z#active@account:*de todas las branches del negocio en el mismo llamado. - RF-7 Business
virtualsolo 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}/branchescrea una Branch. Campos:display_name,kind ∈ {physical, virtual},address?,geo_point(PostGISgeography(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}/suspendoPATCHconstatus=archived— escribeOpenFGA.Delete(branch:X#active@account:*)síncronamente (verauthzRN-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/professionalcrea 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,
accountscrea Business+Branch virtuales y la membresía correspondiente (ver §2). - RF-16
GET /professionals/{account_id}es público. Incluye agregados dereviews(score, conteo) cuando ese dominio esté listo — MVP expone solo datos deaccounts.
BranchMembership
- RF-17 Flujo de invitación: el dueño (o
associate_inviter) invita a un Account a una Branch.POST /branches/{id}/memberships/inviteconaccount_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 enauthz_outbox:branch:B#member@account:X- si
type=professional: tambiénbranch:B#professional_member@account:X
- Si
type=professionaly el Account aún no tieneProfessional, el accept falla con422indicando que debe crear perfil profesional primero.
- Al aceptar: estado →
- RF-19 Terminar membresía:
POST /memberships/{id}/end.- Puede ejecutarlo el propio miembro o alguien con
can_end_associationssobre 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).
- Puede ejecutarlo el propio miembro o alguien con
- RF-20 Listar miembros:
GET /branches/{id}/memberships?status=active. Requierecan_readsobre 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 laProfessionalMembership.id.
Delegación de permisos (relaciones no-propietarias)
- RF-23
PUT /branches/{id}/memberships/{mid}/permissionscon{ relations: ["booking_manager", "service_approver", ...] }.- Solo el dueño (o
can_updatesobre la branch). Valida contra whitelist de relaciones delegables definida enauthz(§3 schema). - Diffea contra el estado actual y genera writes/deletes idempotentes en el outbox.
- Solo el dueño (o
- RF-24
GET /branches/{id}/memberships/{mid}/permissionsdevuelve las relaciones delegadas (lee deauthzvíaListRelations).
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
ownervíaowner 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.typees 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
ProfessionalServiceni sus reviews (son portátiles). Sí desactiva losBranchServiceasociados a esa membresía (responsabilidad decatalogvía evento). - RN-8 Suspender un Business cascadea a sus Branches: se eliminan síncronamente los tuples
activedel business y de todas sus branches en OpenFGA. Lectura pública sigue funcionando (no hay gate de estado encan_read). Reservas nuevas y mutaciones quedan bloqueadas por el schema deauthz— 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
endedrevoca 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)
COMMITDelegació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
| Dominio | Tipo |
|---|---|
auth | Coordina creación de Account al registrar (§01 RF-4). |
authz | Consumidor principal: cada mutación relevante escribe al authz_outbox. |
notifications | Envía invitaciones, avisos de aceptación/terminación. |
catalog | Consume evento membership.ended → desactiva BranchService. |
scheduling | Consume evento membership.ended → archiva horarios por sede del profesional. |
booking | Consume 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
authztras 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.