Skip to content

PRD — Dominio 04: catalog

Servicios ofrecidos en la plataforma. Tres capas: plantillas globales (curadas por la plataforma), servicios del profesional (portables entre sedes) y servicios de la sede (precio/duración por sede, con flujo de aprobación entre profesional y dueño).


1. Propósito

Definir qué se ofrece, dónde, a qué precio y en cuánto tiempo. El catálogo tiene que soportar dos realidades:

  1. El profesional es el dueño de su oferta base (lo que sabe hacer, su precio de referencia como independiente) — y esa oferta es portable entre sedes.
  2. El dueño de la sede controla qué se publica y a qué precio en su sede — por eso la publicación en una sede requiere aprobación del dueño.

catalog no agenda, no cobra, no reserva. Expone al resto de la plataforma qué servicios existen y sus parámetros (precio, duración, sede, profesional que lo ejecuta).

2. Entidades

EntidadResponsabilidad
ServiceTemplatePlantilla global curada por la plataforma. Ej: "Corte de cabello femenino", "Manicure tradicional". No tiene precio.
ProfessionalServiceServicio que un profesional ofrece. Precio y duración de referencia como independiente. Portable.
BranchServicePublicación de un ProfessionalService en una sede. Precio/duración propios de la sede. Requiere aprobación.

Relaciones clave

ServiceTemplate  ──────┐
     (opcional)        │

ProfessionalService  (owner = Account con Professional)

                       │ 1:N

BranchService  (branch_id + professional_membership_id + proposer + status)
  • ProfessionalService puede (o no) derivarse de un ServiceTemplate. Derivarlo ayuda a discovery y normaliza búsqueda; crearlo libre es válido para servicios de nicho.
  • BranchService siempre apunta a un ProfessionalService (el servicio base) y a una ProfessionalMembership (el vínculo profesional↔sede). Ese FK es lo que permite desactivarlos masivamente cuando termina una asociación.

Precios y duración

  • ProfessionalService: base_price (DECIMAL, currency ISO), base_duration_minutes.
  • BranchService: price (DECIMAL, currency ISO), duration_minutes. No heredan del ProfessionalService; se definen al proponer la publicación (con sugerencia precargada desde el base).

Separamos precio base y precio por sede porque la misma profesional puede cobrar diferente en Glamour (Chapinero, zona premium) que en su branch virtual (a domicilio).

3. Historias de usuario

  • U-1. Como Diego (platform admin), quiero mantener un catálogo curado de plantillas (ServiceTemplate) para que los profesionales arranquen rápido sin partir de cero.
  • U-2. Como María (profesional), quiero registrar mis servicios base ("Corte con secado", "Balayage") con precio de referencia — una sola vez, y que me sigan entre sedes.
  • U-3. Como María, quiero proponer uno de mis servicios en la sede Chapinero de Glamour con un precio específico.
  • U-4. Como Carlos (dueño), quiero aprobar o rechazar cada servicio que los profesionales publican en mi sede — yo controlo qué se ofrece.
  • U-5. Como Carlos, quiero delegar en Laura la aprobación de servicios en Usaquén (permiso service_approver).
  • U-6. Como Carlos, quiero proponer yo mismo servicios para asignar a un profesional de mi sede (p.ej. un servicio de temporada).
  • U-7. Como Sofía (cliente), quiero ver el catálogo de servicios activos de una sede con precios claros para elegir qué reservar.
  • U-8. Como María, cuando termino asociación con una sede, mis servicios ahí se desactivan automáticamente (no quiero hacerlo a mano) — pero mis ProfessionalService base se quedan intactos.
  • U-9. Como Carlos o María, quiero pausar temporalmente un servicio en la sede sin borrarlo (vacaciones, falta de insumo).

4. Requerimientos funcionales

ServiceTemplate

  • RF-1 CRUD de ServiceTemplate solo para platform_admin. Endpoints bajo /admin/service-templates.
  • RF-2 Campos: id, name, category (taxonomía: hair, nails, skin, barber, makeup, spa, other), description, suggested_duration_minutes?, icon_url?, is_active.
  • RF-3 GET /service-templates es público. Soporta filtro por categoría y búsqueda por nombre.
  • RF-4 No se borran físicamente — se marcan is_active=false. ProfessionalService derivados siguen funcionando si el template se desactiva (la derivación es copia por valor de los campos relevantes al crear).

ProfessionalService

  • RF-5 POST /accounts/me/professional/services crea un servicio base del caller. Requiere que el caller tenga perfil Professional.
  • RF-6 Campos: name, description?, category, template_id?, base_price, currency, base_duration_minutes, public: true.
  • RF-7 Si template_id se provee, se copian por valor category e (idealmente) name. El template no es una FK fuerte: sobrevive si el admin lo retira.
  • RF-8 PATCH /professional-services/{id} solo por el owner. Cambios en precio base no afectan los BranchService ya publicados (desacoplados a propósito).
  • RF-9 DELETE /professional-services/{id} marca status=deleted. Si hay BranchService activos asociados, falla con 409 hasta que se archiven primero.
  • RF-10 GET /professionals/{account_id}/services es público.

BranchService — propuesta y aprobación

  • RF-11 POST /branches/{branchId}/services crea una propuesta. Body: { professional_service_id, professional_membership_id, price, currency, duration_minutes, notes? }.
    • Valida: professional_membership_id.branch == branchId, .status == active, professional_service.owner == professional_membership.account.
    • Autoriza: caller tiene can_manage_services sobre la branch o es el profesional dueño del ProfessionalService (via tuple proposer).
    • Estado inicial:
      • Si caller puede can_approve_servicesstatus = approved, approved_at = now(), approved_by = caller (auto-aprobación).
      • Si no → status = pending_approval.
    • Escribe en authz_outbox: branch_service:X#branch@branch:Y, branch_service:X#proposer@account:Z.
  • RF-12 POST /branch-services/{id}/approve — requiere can_approve_services sobre la branch. status: pending_approval → approved.
  • RF-13 POST /branch-services/{id}/reject — mismo permiso. Body opcional { reason }. status → rejected. El proponente recibe notificación.
  • RF-14 PATCH /branch-services/{id} permite editar price, duration_minutes, notes. Reglas:
    • Si caller es el proponente (profesional) y status=approved → cambio vuelve a pending_approval (re-aprobación requerida).
    • Si caller es service_approver/owner → cambio mantiene status=approved, se loguea updated_by.
  • RF-15 POST /branch-services/{id}/pause y .../resume — toggle entre active/paused. Cualquiera con can_manage_services o el proponente. No afecta bookings ya confirmados.
  • RF-16 POST /branch-services/{id}/archive — estado terminal. No se puede reactivar. Bookings históricos mantienen referencia.
  • RF-17 GET /branches/{id}/services?status=approved&active=true es público (para discovery). Otros estados requieren permiso can_read sobre la branch.

Eventos de entrada (consumidos)

  • RF-18 Consume membership.ended (emitido por accounts):
    • Archiva todos los BranchService con ese professional_membership_id (status → archived, archived_reason = "membership_ended").
    • No toca ProfessionalService (son portables y siguen en otras sedes / uso individual).
    • Emite evento branch_services.bulk_archived { branch_service_ids[] } que booking consume para política de cancelación.

Eventos de salida (emitidos)

  • RF-19 branch_service.approved — consumido por discovery (índice de búsqueda) y notifications (avisa al proponente).
  • RF-20 branch_service.rejectednotifications.
  • RF-21 branch_services.bulk_archivedbooking.
  • RF-22 branch_service.price_changed — reservas futuras ya creadas no cambian de precio (precio congelado en BookingItem al reservar); el evento es informativo para analytics.

5. Reglas de negocio

  • RN-1 Un BranchService siempre está vinculado a una ProfessionalMembership activa. Si la membresía no es activa al momento de crear, rechazo 422.
  • RN-2 No se permiten dos BranchService activos del mismo ProfessionalService en la misma branch para la misma ProfessionalMembership (UNIQUE donde status in ('pending_approval','approved','paused')).
  • RN-3 El price del BranchService es moneda local de la branch por defecto (validado contra Branch.country/policy futura). MVP: todo en COP.
  • RN-4 duration_minutes debe ser múltiplo de 5 (alineación con slots de scheduling). Mínimo 5, máximo 480 (8h).
  • RN-5 Aprobación por el mismo profesional (auto-aprobación) solo aplica si tiene el permiso can_approve_services. Un profesional asociado sin ese permiso siempre pasa por aprobación.
  • RN-6 Un BranchService rechazado puede re-proponerse (nueva propuesta, nuevo id). No se "reabre" el rechazado.
  • RN-7 Editar price o duration_minutes jamás modifica bookings existentes (precio/duración congelados al momento de reservar, responsabilidad del dominio booking).
  • RN-8 Desactivar un ServiceTemplate no desactiva los ProfessionalService derivados (copia por valor).
  • RN-9 BranchService en sede virtual de un profesional independiente — como la sede y el profesional comparten dueño, la auto-aprobación siempre aplica (no hay flujo de aprobación real ahí, aunque el estado approved sigue siendo el mismo por consistencia del modelo).
  • RN-10 Cambios de categoría en ProfessionalService están prohibidos tras la creación si hay BranchService asociados (evita inconsistencia en discovery).

6. Flujos críticos

Creación de servicio base por la profesional (María):

POST /accounts/me/professional/services
  body: { template_id: "tpl_corte_fem", name: "Corte con secado",
          base_price: 45000, currency: "COP", base_duration_minutes: 45 }

  Valida caller tiene Professional
  Copia category del template
  INSERT ProfessionalService
  → 201

Propuesta en sede asociada (María → Glamour Chapinero):

María → POST /branches/chap/services
  body: { professional_service_id, professional_membership_id,
          price: 60000, duration_minutes: 45 }

  authz: caller == proposer (profesional) pero NO tiene can_approve_services
  status = pending_approval
  INSERT BranchService
  INSERT authz_outbox:
    branch_service:X#branch@branch:chap
    branch_service:X#proposer@account:maria
  notifications.send(approvers, "service.proposed")
  → 201 + { status: pending_approval }

Carlos → POST /branch-services/X/approve
  authz.Check(carlos, can_approve_services, branch:chap) → true
  UPDATE BranchService SET status='approved', approved_at=now(), approved_by=carlos
  Emite evento branch_service.approved (discovery, notifications)
  → 200

Auto-aprobación en sede propia (María independiente en su branch virtual):

María → POST /branches/maria_virtual/services

  authz: caller es owner del business → tiene can_approve_services (vía owner from parent)
  status = approved (auto-aprobación)
  → 201 + { status: approved }

Terminación de asociación → archivado masivo:

(accounts emite: membership.ended { membership_id, branch_id, account_id })
  ↓ catalog consume
  UPDATE BranchService
    SET status='archived', archived_reason='membership_ended'
    WHERE professional_membership_id = ... AND status != 'archived'
  Emite branch_services.bulk_archived { ids[] }
  (booking consume y decide qué hacer con reservas futuras)

Edición de precio por el profesional (requiere re-aprobación):

PATCH /branch-services/X  { price: 70000 }
  caller == proposer, status=approved

  UPDATE SET price=70000, status='pending_approval', approved_at=null, approved_by=null
  notifications.send(approvers, "service.reproposed")

7. Dependencias

DominioTipo
authzProductor de tuples (branch_service#branch, #proposer). Consumidor para checks (can_manage_services, can_approve_services).
accountsLee ProfessionalMembership para validar propuesta. Consume evento membership.ended.
schedulingConsume servicios aprobados+activos para calcular disponibilidad.
bookingConsume al crear reserva (copia price y duration_minutes a BookingItem — precio congelado). Consume bulk_archived para política de cancelación.
discoveryConsume branch_service.approved para indexar y mostrar en búsqueda/mapa.
notificationsAvisa proposed/approved/rejected/repropose.

catalog no depende de scheduling/booking/discovery — solo expone datos y eventos.

8. Fuera de alcance (MVP)

  • Paquetes/combos (2+ servicios empaquetados con precio conjunto) — fase 3.
  • Precios dinámicos por día/hora/temporada — fase 4.
  • Descuentos y cupones — fase 7+ (marketing).
  • Variantes del mismo servicio (ej. "Corte" corto/medio/largo con precios distintos) — fase 3. Por ahora: un ProfessionalService distinto por variante.
  • Historial de cambios de precio expuesto al cliente — fase 3.
  • Insumos/costos detrás de un servicio — fuera de roadmap MVP (entra con módulo inventario, fase 7+).
  • Multi-idioma del nombre/descripción — fase 2 (la plataforma en MVP es solo ES).
  • Multi-moneda real (branches en países distintos con conversión) — fase 5+.
  • Plantillas de variantes en ServiceTemplate — fase 3.

9. Métricas

  • ≥ 80% de propuestas se aprueban o rechazan en < 48h.
  • p95 de GET /branches/{id}/services (listado público) < 200ms.
  • 0 BranchService activos con ProfessionalMembership no activa (consistencia tras membership.ended).
  • 0 bookings con precio distinto al BranchService.price en el momento de reservar (precio congelado correctamente).
  • ≥ 60% de ProfessionalService derivan de un ServiceTemplate (señal de que el catálogo curado cubre los casos comunes).
  • < 2% de servicios pasan por más de una re-aprobación en un mes (señal de estabilidad en precios acordados).

Documentación interna — BeautyHub