PRD — Dominio 04: catalog
Servicios ofrecidos en la plataforma. Modelo branch-first: la sede es dueña de su catálogo, los profesionales adoptan servicios de la sede (con overrides opcionales), y las plantillas globales actúan como taxonomía canónica para búsqueda y onboarding.
En el código este dominio vive en el paquete
services(apps/api/internal/services).
1. Propósito
Definir qué se ofrece, dónde, a qué precio y en cuánto tiempo. El catálogo soporta dos realidades:
- El dueño de la sede controla su catálogo: qué servicios existen en su sede, a qué precio y con qué duración. Un servicio existe una sola vez por sede, sin importar cuántos profesionales lo ejecuten.
- El profesional adopta servicios del catálogo de la sede donde tiene membresía, con overrides opcionales de precio/duración. Su reputación — no su catálogo — es lo portable entre sedes (ver
08-reviews).
catalog no agenda, no cobra, no reserva. Expone al resto de la plataforma qué servicios existen y sus parámetros.
Decisión de modelo (2026-07)
Se evaluaron dos modelos:
| Professional-first (PRD original) | Branch-first (elegido) | |
|---|---|---|
| Dueño del servicio | Profesional (portable) | Sede |
| Publicación en sede | Copia por sede con aprobación | El servicio ES de la sede; el profesional se adhiere |
| Duplicación en búsqueda | N resultados "Manicure" por sede (uno por profesional) | 1 resultado por sede; profesionales como variantes |
| Acoplamiento | catalog acoplado al ciclo de vida de memberships | Adopción como satélite; catálogo estable ante rotación de staff |
Razones: elimina la duplicación intra-sede por diseño, la entidad de mayor cardinalidad (adopción) es un satélite simple, y la propuesta de valor de "reputación portable" no se pierde — vive en reviews (las reseñas siguen al Professional vía memberships, independientemente de quién sea dueño del servicio). La "oferta portable" (clonar mis servicios a otra sede) se recupera como feature de conveniencia post-MVP sin invertir el modelo.
2. Entidades
| Entidad | Responsabilidad |
|---|---|
ServiceTemplate | Servicio canónico de la plataforma, curado por platform admin. Define nombre canónico + categoría + duración sugerida. Es la columna vertebral de la deduplicación en búsqueda y del onboarding. No tiene precio. |
BranchService | Servicio ofrecido por una sede. Nombre, precio, duración y estado propios. Opcionalmente derivado de un template. |
ProfessionalService | Adopción de un BranchService por una ProfessionalMembership. Overrides opcionales de precio/duración (nil = hereda). |
Relaciones
ServiceTemplate (canónico, plataforma)
│ 0..1 (weak FK, copia por valor)
▼
BranchService (dueño: la sede)
│ 1:N
▼
ProfessionalService (adopción; FK a ProfessionalMembership)BranchService.template_ides FK débil (ON DELETE SET NULL): al crear se copian por valorname(editable),categoryydefault_duration_minutes(sugerencia). Archivar el template no afecta los servicios derivados.ProfessionalServicetieneUNIQUE (branch_service_id, branch_membership_id): un profesional adopta un servicio de sede a lo sumo una vez.
Campos clave
ServiceTemplate: id, name (único case-insensitive), category, default_duration_minutes, status (active | archived).
BranchService: id, branch_id, template_id?, name, description?, category ⚠️, price_cents, currency, duration_minutes, capacity (ver RN-9), status (draft | active | paused ⚠️ | archived), approved_by_account_id?, approved_at?.
ProfessionalService: id, branch_service_id, branch_membership_id, price_cents_override?, duration_minutes_override?, status (active | archived).
⚠️ = definido en este PRD pero pendiente de implementación — ver §10.
Taxonomía de categorías
Enum cerrado, gestionado por código (no por admin): hair | nails | skin | barber | makeup | spa | tattoo | piercing | other.
category es obligatoria en ServiceTemplate y en BranchService. Es el eje principal de filtrado en discovery — un servicio sin categoría es invisible para el filtro más usado, por eso no puede ser opcional.
Precios y duración
BranchService:price_cents(INT, centavos),currency(ISO 4217). MVP: COP.ProfessionalService: overrides opcionales. Precio/duración efectivos = override si existe, si no el valor delBranchService.bookingcongela precio y duración efectivos en elBookingItemal reservar. Cambios posteriores jamás afectan reservas existentes.
3. Historias de usuario
- U-1. Como Diego (platform admin), quiero mantener un catálogo canónico de plantillas para que los negocios arranquen rápido y la búsqueda no se fragmente en mil variantes del mismo servicio.
- U-2. Como Carlos (dueño), quiero crear los servicios de mi sede eligiendo de un catálogo sugerido (typeahead) — y solo escribir desde cero si ofrezco algo de nicho.
- U-3. Como Carlos, quiero definir precio y duración por servicio una vez por sede, sin repetirlo por cada profesional.
- U-4. Como María (profesional asociada), quiero adherirme a los servicios de la sede que sé hacer, y ajustar mi precio/duración si el dueño me lo permite.
- U-5. Como María (independiente), quiero crear servicios en mi sede virtual sin fricción de aprobación (soy dueña y profesional a la vez).
- U-6. Como Laura (colaboradora con permiso), quiero proponer servicios nuevos como borrador para que Carlos los active.
- U-7. Como Sofía (cliente), quiero ver el catálogo de una sede con precios claros, sin ver el mismo servicio repetido por cada profesional que lo hace.
- U-8. Como María, cuando termino asociación con una sede, mis adopciones ahí se archivan solas — el catálogo de la sede queda intacto para el resto del equipo.
- U-9. Como Carlos, quiero pausar temporalmente un servicio (falta de insumo, vacaciones del único que lo hace) sin archivarlo.
4. Requerimientos funcionales
ServiceTemplate
- RF-1 CRUD de
ServiceTemplatesolo paraplatform_admin:POST /services/templates,PATCH /services/templates/{id},POST /services/templates/{id}/archive. - RF-2
GET /services/templateses público. Soporta filtro porcategoryy búsqueda por prefijo de nombre (alimenta el typeahead de creación). - RF-3 No se borran físicamente —
status=archived. LosBranchServicederivados no se ven afectados (copia por valor). - RF-4 ⚠️ La plataforma se lanza con un seed curado de templates (≥ 8 por categoría principal: hair, nails, barber, makeup, skin, spa). Sin seed, la métrica de adopción de templates (§9) y el onboarding no funcionan.
BranchService — ciclo de vida
draft ──► active ◄──► paused ⚠️
│ │ │
└────►────┴──► archived (terminal)- RF-5
POST /branches/{id}/servicescrea unBranchService. Body:{ template_id?, name, description?, category, price_cents, currency, duration_minutes }.- Si
template_idviene:namese precarga (editable),categoryse copia del template (no editable en ese caso),duration_minutesse sugiere. - Autoriza:
can_manage_servicessobre la branch. - Estado inicial:
draft. Si el caller además tienecan_approve_services→ activación inmediata (status=active,approved_by,approved_at) — cubre auto-aprobación del owner y del independiente en su sede virtual.
- Si
- RF-6
POST /branches/{id}/services/{sid}/approve— requierecan_approve_services.draft → active, registraapproved_by_account_id+approved_at. - RF-7 Rechazo de un borrador =
POST .../archivecon{ reason? }. No existe estadorejected: re-proponer es crear un borrador nuevo (mismo criterio que el PRD original RN-6, con un estado menos). - RF-8
PATCH /branches/{id}/services/{sid}editaname,description,price_cents,duration_minutes. Requierecan_manage_services.categoryes inmutable tras la creación si el servicio tiene adopciones o reservas (evita inconsistencia en discovery); corregirla = archivar y recrear. - RF-9 ⚠️
POST .../pausey.../resume— toggleactive ⇄ paused. No afecta reservas ya confirmadas; el servicio deja de ser reservable e indexable mientras esté pausado. - RF-10
POST .../archive— terminal. Reservas históricas mantienen la referencia (weak ref enBookingItem). - RF-11
GET /branches/{id}/services?status=activees público (consumido por discovery y la ficha de sede). Estadosdraft/archivedrequierencan_readsobre la branch.
ProfessionalService — adopción
- RF-12
POST /memberships/{mid}/services(opt-in). Body:{ branch_service_id, price_cents_override?, duration_minutes_override? }.- Valida: membership activa, membership pertenece a la branch del servicio, servicio en estado
active. - Autoriza: el dueño de la membership, o
can_manage_servicessobre la branch (el manager asigna servicios a su staff).
- Valida: membership activa, membership pertenece a la branch del servicio, servicio en estado
- RF-13
PATCH /memberships/{mid}/services/{sid}edita overrides. ⚠️ Política de overrides de precio por parte del profesional: MVP permite override libre; post-MVP el owner podrá restringirlo por branch policy (ver §8). - RF-14
DELETE /memberships/{mid}/services/{sid}(opt-out) archiva la adopción. Reservas futuras ya confirmadas no se tocan (responsabilidad debookingvía evento). - RF-15
GET /memberships/{mid}/serviceslista las adopciones de una membership con valores efectivos resueltos.
Eventos de entrada (consumidos)
- RF-16
membership.ended(deaccounts) → archiva las adopciones (ProfessionalService) de esa membership. No toca losBranchService— el catálogo es de la sede y sobrevive la rotación de staff. Emiteservices.professional_service.archived⚠️ por cada adopción (booking decide qué hacer con reservas futuras de ese profesional). - RF-17
branch.archived→ archiva en cascada losBranchServicede la branch y sus adopciones. - RF-18
business.suspended→ ídem RF-17 para todas las branches del business.
Eventos de salida (emitidos)
⚠️ Constantes declaradas en
services/domain/events.go; la emisión se cablea junto condiscovery(que es su consumidor principal). Nombres canónicos:
| Evento | Consumidores | Trigger |
|---|---|---|
services.branch_service.created | notifications (avisa a approvers si quedó en draft) | RF-5 |
services.branch_service.approved | discovery (indexa), notifications | RF-6 |
services.branch_service.updated ⚠️ | discovery (re-indexa precio/nombre) | RF-8 |
services.branch_service.paused / .resumed ⚠️ | discovery (des/re-indexa) | RF-9 |
services.branch_service.archived | discovery (des-indexa), booking | RF-10, RF-17 |
services.professional_service.adopted | discovery (agrega profesional al documento) | RF-12 |
services.professional_service.archived ⚠️ | discovery, booking | RF-14, RF-16 |
5. Reglas de negocio
- RN-1 (servicio canónico — anti-duplicación). Un servicio existe una sola vez por sede:
UNIQUE (branch_id, lower(unaccent(name)))sobre estados no archivados ⚠️. Intentar crear un duplicado devuelve409con el servicio existente en el body (la UI ofrece "adherite al existente" en vez de crear otro). - RN-2 (categoría obligatoria). Todo
BranchServicetienecategorydel enum cerrado. Sin categoría no hay filtro en discovery — no es negociable ⚠️. - RN-3 (template como canónico). La creación siempre ofrece typeahead contra
ServiceTemplate(matching normalizado: lower + unaccent). Crear libre es el camino de escape, no el principal. Meta: ≥ 60% de servicios derivados de template (§9). - RN-4 (adopción requiere estado sano). Opt-in solo con membership
active+ servicioactive. Violaciones →422. - RN-5 (reservabilidad). Un
BranchServicees reservable solo si tiene ≥ 1 adopción activa. Un servicio activo sin profesionales es visible para el owner (con warning en el dashboard) pero no aparece en discovery ni en booking. - RN-6 (precio efectivo congelado).
bookingcopia precio/duración efectivos (con override del profesional asignado) alBookingItem. Nada de lo que pase después en catalog modifica reservas existentes. - RN-7 (duración).
duration_minutes(y overrides): mínimo 5, máximo 480, múltiplo de 5 ⚠️ (alineación con slots descheduling; hoy la DB solo valida rango). - RN-8 (moneda). MVP: todo en COP ⚠️ (la migración 005 tiene
DEFAULT 'ARS'— corregir).currencyvalidada ISO 4217; multi-moneda real es fase 5+. - RN-9 (capacity — reservado, sin semántica). La columna
capacityexiste en DB pero ningún flujo la lee (booking y scheduling la ignoran). Hasta que se defina "reservas simultáneas por slot" (fase 3, junto con availability grupal), se fija en1y no se expone en UI ni API. Definir o eliminar antes de fase 3 — no dejar que acumule datos con semántica ambigua. - RN-10 (independiente = mismo modelo). La sede virtual del independiente usa exactamente el mismo flujo: owner crea → auto-activación (RF-5) → auto-adopción. La UI puede colapsarlo en un paso, el modelo no cambia.
6. Flujos críticos
Onboarding de Carlos (crea catálogo de su sede):
POST /branches/chap/services
body: { template_id: "tpl_manicure_tradicional", name: "Manicure tradicional",
category: "nails" (copiada), price_cents: 4500000, currency: "COP",
duration_minutes: 45 }
↓
authz: carlos tiene can_manage_services + can_approve_services (owner)
UNIQUE check (branch, nombre normalizado) → OK
INSERT status='active', approved_by=carlos
Emite services.branch_service.approved → discovery indexa
→ 201María se adhiere con override:
POST /memberships/m_maria_chap/services
body: { branch_service_id: "bs_manicure", price_cents_override: 5500000 }
↓
membership activa ✓, servicio active ✓
INSERT professional_services
Emite services.professional_service.adopted → discovery agrega a María al documento
→ 201Laura (colaboradora) propone, Carlos aprueba:
Laura → POST /branches/chap/services { name: "Peeling de temporada", category: "skin", ... }
can_manage_services ✓, can_approve_services ✗ → status='draft'
Emite services.branch_service.created → notifications avisa a approvers
Carlos → POST /branches/chap/services/X/approve
can_approve_services ✓ → status='active', approved_by=carlos
Emite services.branch_service.approved → discoveryIntento de duplicado (la solución al problema de búsqueda):
POST /branches/chap/services { name: "manicure tradicional ", ... }
↓ lower(unaccent(name)) colisiona con "Manicure tradicional" activo
→ 409 { error: "duplicate_service", existing: { id: "bs_manicure", ... } }
UI: "Este servicio ya existe en tu sede — ¿querés adherir un profesional?"Fin de asociación → catálogo intacto:
(accounts emite: membership.ended { membership_id })
↓ catalog consume
UPDATE professional_services SET status='archived' WHERE branch_membership_id=...
Emite services.professional_service.archived (por adopción)
BranchServices: intactos. Si alguno queda sin adopciones activas → RN-5: sale de discovery.7. Dependencias
| Dominio | Tipo |
|---|---|
authz | Checks can_manage_services, can_approve_services. Tuples de branch_service via outbox. |
accounts | Lee ProfessionalMembership para validar adopción. Consume membership.ended, branch.archived, business.suspended. |
scheduling | Consume servicios activos + adopciones para calcular disponibilidad por profesional. |
booking | Copia precio/duración efectivos al BookingItem. Consume *.archived para política de cancelación. |
discovery | Consume todos los eventos services.* para mantener el índice (ver 07-discovery). |
notifications | Avisa created (a approvers) / approved (al equipo). |
catalog no depende de scheduling/booking/discovery — solo expone datos y eventos.
8. Fuera de alcance (MVP)
- "Clonar mis servicios a otra sede" (conveniencia de portabilidad para María) — fase 2. No requiere cambiar el modelo: es un helper de creación masiva de drafts.
- Branch policy de overrides (owner restringe si el profesional puede subir/bajar precio y en qué rango) — fase 2.
- Paquetes/combos, variantes, precios dinámicos, descuentos — fases 3-7 (sin cambios vs PRD original).
- Historial de precios expuesto al cliente — fase 3.
- Multi-idioma de nombre/descripción — fase 2.
- Multi-moneda real — fase 5+.
- Capacity > 1 (reservas grupales/simultáneas) — fase 3, junto con su semántica en booking (RN-9).
9. Métricas
- ≥ 60% de
BranchServicederivados de unServiceTemplate(requiere RF-4 seed + RN-3 typeahead). - 0 duplicados normalizados por sede (garantizado por RN-1 una vez migrado).
- 100% de
BranchServiceconcategoryválida (garantizado por RN-2 una vez migrado). - ≥ 80% de drafts aprobados o archivados en < 48h.
- p95 de
GET /branches/{id}/services< 200ms. - 0 adopciones activas con membership no activa (consistencia post
membership.ended). - 0 bookings con precio distinto al efectivo del momento de reservar.
10. Gaps actuales vs este PRD (deuda a saldar)
Snapshot al 2026-07-02. Cada ítem es un cambio de código/DB pendiente; este PRD es el estado objetivo.
| # | Gap | Dónde |
|---|---|---|
| 1 | branch_services.category no existe (NOT NULL + enum + backfill desde template / other) | migración + services/domain |
| 2 | Falta UNIQUE (branch_id, lower(unaccent(name))) parcial (requiere extensión unaccent) | migración |
| 3 | Estado paused + endpoints pause/resume no implementados | domain + http |
| 4 | Eventos services.* declarados pero no emitidos; faltan constantes updated/paused/resumed/professional_service.archived | services/service |
| 5 | DEFAULT 'ARS' en migración 005 — el PRD dice COP | migración |
| 6 | Sin seed de service_templates | migración/seed |
| 7 | Form web de creación sin typeahead de template ni selector de categoría | apps/web .../servicios/nueva |
| 8 | RN-7 múltiplo de 5 no validado | domain + CHECK |
| 9 | Geo de branches partido: geography (mig 004) + lat/lng floats (mig 006) — consolidar en geography antes de discovery | migración + accounts store |
| 10 | No existe spec openspec del dominio services | openspec/specs/ |