Skip to content

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:

  1. 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.
  2. 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 servicioProfesional (portable)Sede
Publicación en sedeCopia por sede con aprobaciónEl servicio ES de la sede; el profesional se adhiere
Duplicación en búsquedaN resultados "Manicure" por sede (uno por profesional)1 resultado por sede; profesionales como variantes
Acoplamientocatalog acoplado al ciclo de vida de membershipsAdopció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

EntidadResponsabilidad
ServiceTemplateServicio 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.
BranchServiceServicio ofrecido por una sede. Nombre, precio, duración y estado propios. Opcionalmente derivado de un template.
ProfessionalServiceAdopció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_id es FK débil (ON DELETE SET NULL): al crear se copian por valor name (editable), category y default_duration_minutes (sugerencia). Archivar el template no afecta los servicios derivados.
  • ProfessionalService tiene UNIQUE (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 del BranchService.
  • booking congela precio y duración efectivos en el BookingItem al 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 ServiceTemplate solo para platform_admin: POST /services/templates, PATCH /services/templates/{id}, POST /services/templates/{id}/archive.
  • RF-2 GET /services/templates es público. Soporta filtro por category y búsqueda por prefijo de nombre (alimenta el typeahead de creación).
  • RF-3 No se borran físicamente — status=archived. Los BranchService derivados 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}/services crea un BranchService. Body: { template_id?, name, description?, category, price_cents, currency, duration_minutes }.
    • Si template_id viene: name se precarga (editable), category se copia del template (no editable en ese caso), duration_minutes se sugiere.
    • Autoriza: can_manage_services sobre la branch.
    • Estado inicial: draft. Si el caller además tiene can_approve_services → activación inmediata (status=active, approved_by, approved_at) — cubre auto-aprobación del owner y del independiente en su sede virtual.
  • RF-6 POST /branches/{id}/services/{sid}/approve — requiere can_approve_services. draft → active, registra approved_by_account_id + approved_at.
  • RF-7 Rechazo de un borrador = POST .../archive con { reason? }. No existe estado rejected: 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} edita name, description, price_cents, duration_minutes. Requiere can_manage_services. category es inmutable tras la creación si el servicio tiene adopciones o reservas (evita inconsistencia en discovery); corregirla = archivar y recrear.
  • RF-9 ⚠️ POST .../pause y .../resume — toggle active ⇄ 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 en BookingItem).
  • RF-11 GET /branches/{id}/services?status=active es público (consumido por discovery y la ficha de sede). Estados draft/archived requieren can_read sobre 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_services sobre la branch (el manager asigna servicios a su staff).
  • 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 de booking vía evento).
  • RF-15 GET /memberships/{mid}/services lista las adopciones de una membership con valores efectivos resueltos.

Eventos de entrada (consumidos)

  • RF-16 membership.ended (de accounts) → archiva las adopciones (ProfessionalService) de esa membership. No toca los BranchService — el catálogo es de la sede y sobrevive la rotación de staff. Emite services.professional_service.archived ⚠️ por cada adopción (booking decide qué hacer con reservas futuras de ese profesional).
  • RF-17 branch.archived → archiva en cascada los BranchService de 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 con discovery (que es su consumidor principal). Nombres canónicos:

EventoConsumidoresTrigger
services.branch_service.creatednotifications (avisa a approvers si quedó en draft)RF-5
services.branch_service.approveddiscovery (indexa), notificationsRF-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.archiveddiscovery (des-indexa), bookingRF-10, RF-17
services.professional_service.adopteddiscovery (agrega profesional al documento)RF-12
services.professional_service.archived ⚠️discovery, bookingRF-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 devuelve 409 con el servicio existente en el body (la UI ofrece "adherite al existente" en vez de crear otro).
  • RN-2 (categoría obligatoria). Todo BranchService tiene category del 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 + servicio active. Violaciones → 422.
  • RN-5 (reservabilidad). Un BranchService es 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). booking copia precio/duración efectivos (con override del profesional asignado) al BookingItem. 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 de scheduling; hoy la DB solo valida rango).
  • RN-8 (moneda). MVP: todo en COP ⚠️ (la migración 005 tiene DEFAULT 'ARS' — corregir). currency validada ISO 4217; multi-moneda real es fase 5+.
  • RN-9 (capacity — reservado, sin semántica). La columna capacity existe 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 en 1 y 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
  → 201

Marí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
  → 201

Laura (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 → discovery

Intento 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

DominioTipo
authzChecks can_manage_services, can_approve_services. Tuples de branch_service via outbox.
accountsLee ProfessionalMembership para validar adopción. Consume membership.ended, branch.archived, business.suspended.
schedulingConsume servicios activos + adopciones para calcular disponibilidad por profesional.
bookingCopia precio/duración efectivos al BookingItem. Consume *.archived para política de cancelación.
discoveryConsume todos los eventos services.* para mantener el índice (ver 07-discovery).
notificationsAvisa 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 BranchService derivados de un ServiceTemplate (requiere RF-4 seed + RN-3 typeahead).
  • 0 duplicados normalizados por sede (garantizado por RN-1 una vez migrado).
  • 100% de BranchService con category vá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.

#GapDónde
1branch_services.category no existe (NOT NULL + enum + backfill desde template / other)migración + services/domain
2Falta UNIQUE (branch_id, lower(unaccent(name))) parcial (requiere extensión unaccent)migración
3Estado paused + endpoints pause/resume no implementadosdomain + http
4Eventos services.* declarados pero no emitidos; faltan constantes updated/paused/resumed/professional_service.archivedservices/service
5DEFAULT 'ARS' en migración 005 — el PRD dice COPmigración
6Sin seed de service_templatesmigración/seed
7Form web de creación sin typeahead de template ni selector de categoríaapps/web .../servicios/nueva
8RN-7 múltiplo de 5 no validadodomain + CHECK
9Geo de branches partido: geography (mig 004) + lat/lng floats (mig 006) — consolidar en geography antes de discoverymigración + accounts store
10No existe spec openspec del dominio servicesopenspec/specs/

Documentación interna — BeautyHub