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
⚠️ = 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
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).
- RF-8a
GET /businesses/{id}tiene dos vistas según el caller:- Vista pública (cualquier Account autenticado):
id,display_name,kind,status. No exponeowner_account_idnitax_id. - Vista de gestión (caller con
can_managesobre el business oowner): todos los campos, incluyendoowner_account_idytax_id. - Business con
kind=virtualdevuelve 404 para cualquier caller que no sea elowner(ver RN-12).
- Vista pública (cualquier Account autenticado):
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 para branches conkind=physicalystatus=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 elowner(ver RN-12). - RF-13a
GET /businesses/{id}/brancheses público pero filtra: solo retorna branches constatus=activeykind=physical. El owner (concan_manage) recibe todas, incluyendo archivadas y virtuales.
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}/invitationsconemail+type ∈ {professional, collaborator}. No requiere que el invitado tenga yaProfessional— 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/invitationsdevuelve las invitacionespending(no vencidas) cuyoemailcoincide con el email del caller (resuelto víaauth.GetCredentialsByAccountID, mismo patrón cross-domain queRF-13de booking). El dashboard muestra esa lista con botones aceptar/rechazar. Se eligió así porquenotifications(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. Cuandonotificationsexista 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 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:
DELETE /memberships/{id}.- 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. La respuesta enriquece cada membership conaccount_display_name(join contraaccounts) — quien ya puede leer el equipo de la branch ve el nombre real de cada persona, no solo suaccount_idcrudo. No es una superficie nueva de la API (no exponedisplay_namede cuentas arbitrarias a callers sincan_readsobre la branch). - RF-25
GET /accounts/me/membershipsdevuelve lasBranchMembershipactivas del caller, enriquecidas conbranch_name,business_idybusiness_name(join contrabranches/businesses). Alimenta el selector de contexto del dashboard (ver RN-13) — sin este join el frontend solo teníabranch_idcrudo 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 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 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.
- Solo el dueño de la branch. Valida contra whitelist de relaciones delegables definida en
- RF-24
GET /branches/{id}/memberships/{mid}/permissionsdevuelve las relaciones delegadas (lee deauthzvíaListRelations). 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
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. - 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 concan_manageoownersobre la entidad. Las respuestas públicas usan un subconjunto explícito de campos (nuncaSELECT *). - 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 elowner. 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, cookiebh_biz_id). Contexto staff = sucursal ajena conBranchMembershipactiva (RF-17/RF-25, cookiebh_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 alownerreal (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,DelegatePermissionsyGetDelegatedPermissionssolo requerían un JWT válido — cualquier cuenta autenticada podía invitar, expulsar, listar equipo o delegar/leer permisos en cualquier branch ajena, sin chequearowner/can_invite/can_end_associations/can_read(relaciones ya documentadas en RF-17/19/20 pero nunca implementadas). Se agregaroncan_readycan_invite/can_end_associations(ambasowner or associate_inviter) al modelo OpenFGA (branchtype) 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'
COMMITTerminació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). También resuelve email↔account_id para RF-17a (visibilidad de invitaciones) y RF-13 (staff-for-others). |
authz | Consumidor 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. |
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.