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:
- 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.
- 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
| Entidad | Responsabilidad |
|---|---|
ServiceTemplate | Plantilla global curada por la plataforma. Ej: "Corte de cabello femenino", "Manicure tradicional". No tiene precio. |
ProfessionalService | Servicio que un profesional ofrece. Precio y duración de referencia como independiente. Portable. |
BranchService | Publicació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)ProfessionalServicepuede (o no) derivarse de unServiceTemplate. Derivarlo ayuda a discovery y normaliza búsqueda; crearlo libre es válido para servicios de nicho.BranchServicesiempre apunta a unProfessionalService(el servicio base) y a unaProfessionalMembership(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 delProfessionalService; 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
ProfessionalServicebase 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
ServiceTemplatesolo paraplatform_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-templateses público. Soporta filtro por categoría y búsqueda por nombre. - RF-4 No se borran físicamente — se marcan
is_active=false.ProfessionalServicederivados 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/servicescrea un servicio base del caller. Requiere que el caller tenga perfilProfessional. - RF-6 Campos:
name,description?,category,template_id?,base_price,currency,base_duration_minutes,public: true. - RF-7 Si
template_idse provee, se copian por valorcategorye (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 losBranchServiceya publicados (desacoplados a propósito). - RF-9
DELETE /professional-services/{id}marcastatus=deleted. Si hayBranchServiceactivos asociados, falla con409hasta que se archiven primero. - RF-10
GET /professionals/{account_id}/serviceses público.
BranchService — propuesta y aprobación
- RF-11
POST /branches/{branchId}/servicescrea 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_servicessobre la branch o es el profesional dueño delProfessionalService(via tupleproposer). - Estado inicial:
- Si caller puede
can_approve_services→status = approved,approved_at = now(),approved_by = caller(auto-aprobación). - Si no →
status = pending_approval.
- Si caller puede
- Escribe en
authz_outbox:branch_service:X#branch@branch:Y,branch_service:X#proposer@account:Z.
- Valida:
- RF-12
POST /branch-services/{id}/approve— requierecan_approve_servicessobre 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 editarprice,duration_minutes,notes. Reglas:- Si caller es el proponente (profesional) y
status=approved→ cambio vuelve apending_approval(re-aprobación requerida). - Si caller es
service_approver/owner→ cambio mantienestatus=approved, se logueaupdated_by.
- Si caller es el proponente (profesional) y
- RF-15
POST /branch-services/{id}/pausey.../resume— toggle entreactive/paused. Cualquiera concan_manage_serviceso 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=truees público (para discovery). Otros estados requieren permisocan_readsobre la branch.
Eventos de entrada (consumidos)
- RF-18 Consume
membership.ended(emitido poraccounts):- Archiva todos los
BranchServicecon eseprofessional_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[] }quebookingconsume para política de cancelación.
- Archiva todos los
Eventos de salida (emitidos)
- RF-19
branch_service.approved— consumido pordiscovery(índice de búsqueda) ynotifications(avisa al proponente). - RF-20
branch_service.rejected—notifications. - RF-21
branch_services.bulk_archived—booking. - RF-22
branch_service.price_changed— reservas futuras ya creadas no cambian de precio (precio congelado enBookingItemal reservar); el evento es informativo para analytics.
5. Reglas de negocio
- RN-1 Un
BranchServicesiempre está vinculado a unaProfessionalMembershipactiva. Si la membresía no es activa al momento de crear, rechazo422. - RN-2 No se permiten dos
BranchServiceactivos del mismoProfessionalServiceen la misma branch para la mismaProfessionalMembership(UNIQUE dondestatus in ('pending_approval','approved','paused')). - RN-3 El
pricedelBranchServicees moneda local de la branch por defecto (validado contraBranch.country/policy futura). MVP: todo en COP. - RN-4
duration_minutesdebe ser múltiplo de 5 (alineación con slots descheduling). 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
BranchServicerechazado puede re-proponerse (nueva propuesta, nuevo id). No se "reabre" el rechazado. - RN-7 Editar
priceoduration_minutesjamás modifica bookings existentes (precio/duración congelados al momento de reservar, responsabilidad del dominiobooking). - RN-8 Desactivar un
ServiceTemplateno desactiva losProfessionalServicederivados (copia por valor). - RN-9
BranchServiceen 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 estadoapprovedsigue siendo el mismo por consistencia del modelo). - RN-10 Cambios de categoría en
ProfessionalServiceestán prohibidos tras la creación si hayBranchServiceasociados (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
→ 201Propuesta 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)
→ 200Auto-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
| Dominio | Tipo |
|---|---|
authz | Productor de tuples (branch_service#branch, #proposer). Consumidor para checks (can_manage_services, can_approve_services). |
accounts | Lee ProfessionalMembership para validar propuesta. Consume evento membership.ended. |
scheduling | Consume servicios aprobados+activos para calcular disponibilidad. |
booking | Consume al crear reserva (copia price y duration_minutes a BookingItem — precio congelado). Consume bulk_archived para política de cancelación. |
discovery | Consume branch_service.approved para indexar y mostrar en búsqueda/mapa. |
notifications | Avisa 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
ProfessionalServicedistinto 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
BranchServiceactivos conProfessionalMembershipno activa (consistencia trasmembership.ended). - 0 bookings con precio distinto al
BranchService.priceen el momento de reservar (precio congelado correctamente). - ≥ 60% de
ProfessionalServicederivan de unServiceTemplate(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).