Skip to content

PRD — Dominio 06: booking

Reservas: el corazón transaccional de la plataforma. Crea, confirma, completa, cancela y marca como no-show. Congela precio y duración al reservar. Coordina con scheduling para validar disponibilidad y con billing para cobrar/reembolsar.


1. Propósito

Convertir un slot disponible en un compromiso vinculante entre cliente, profesional y sede. Una reserva es el objeto que:

  • Bloquea el slot (deja de aparecer como disponible en scheduling).
  • Congela el precio y la duración del servicio en el momento en que se reserva (no se ve afectado si luego cambian).
  • Habilita cobro (billing), recordatorios (notifications) y reseña (reviews).
  • Resiste eventos externos (bloqueos de sede, ausencias, terminación de asociación) aplicando políticas explícitas.

booking es la única fuente de verdad sobre si un slot está tomado. scheduling consulta aquí para calcular disponibilidad.

2. Entidades

EntidadResponsabilidad
BookingReserva única. Agrupa uno o más BookingItem dentro del mismo slot continuo.
BookingItemServicio concreto reservado. Copia congelada de price, currency, duration_minutes y referencia al BranchService original.
BookingHoldPre-reserva temporal durante el checkout. TTL corto (5-10 min). Si expira, se libera el slot.
BookingCancellationRegistro del evento de cancelación: quién, cuándo, por qué, política aplicada.

Relaciones clave

Account (cliente) ──┐

                 Booking ──< BookingItem ──> BranchService (ref)

                    └──< BookingCancellation (0..1)

ProfessionalMembership ──< Booking (el profesional que atiende)
Branch ──< Booking
  • Booking.status ∈ {hold, confirmed, in_progress, completed, cancelled, no_show}.
  • Booking apunta a ProfessionalMembership.id, no a Account. Así, si María sale de Glamour y entra a Nails Studio con la misma cuenta, sus bookings históricos en Glamour quedan atados correctamente a esa membresía archivada.
  • BookingItem apunta a BranchService.id (referencia débil, para analytics), pero todos los campos de precio y duración están copiados — no se leen del BranchService para mostrar el booking.

Estructura temporal

  • Booking.start_at y Booking.end_at en TIMESTAMPTZ. end_at - start_at = sum(BookingItem.duration_minutes) + buffers internos si hay varios items (MVP: 1 item = 1 booking).
  • Booking.timezone_snapshot guarda la tz de la branch al momento de reservar (para renderizar consistentemente si la tz de la branch cambia después — raro pero posible).

3. Historias de usuario

  • U-1. Como Sofía, quiero reservar "Corte con secado" con María en Glamour Chapinero el sábado a las 15:00. Pago desde la app y recibo confirmación.
  • U-2. Como Sofía, quiero cancelar mi reserva hasta 24h antes sin penalidad.
  • U-3. Como Sofía, si cancelo con menos de 24h, entiendo que puede aplicar una retención según la política del negocio.
  • U-4. Como Sofía, dos personas no pueden haber reservado "mi" slot — si yo llegué primero y completé el pago, gano yo.
  • U-5. Como María, quiero ver mi agenda del día con los bookings confirmados (clientes, servicios, horas).
  • U-6. Como María, puedo marcar a una cliente como no_show si no se presentó pasados 15 min.
  • U-7. Como Carlos (dueño), quiero cancelar un booking si hubo un problema operativo (p. ej. profesional enferma) y que al cliente se le reembolse.
  • U-8. Como Carlos, si bloqueo la sede por remodelación del 15 al 17 de marzo, los bookings dentro del rango deben cancelarse automáticamente con reembolso total y notificación.
  • U-9. Como Carlos o María, cuando se termina una asociación, los bookings futuros del profesional en esa sede se cancelan automáticamente con política "cancellation_by_business".
  • U-10. Como Sofía, puedo ver todas mis reservas pasadas y futuras en mi app.
  • U-11. Como Diego (platform admin), puedo ver bookings para auditoría pero no modificarlos (solo el negocio puede).

4. Requerimientos funcionales

Creación (checkout)

  • RF-1 POST /bookings/holds crea un BookingHold. Body: { branch_service_id, start_at, client_account_id?, idempotency_key }.
    • Valida schedule: llama scheduling.GetScheduleWindows(membershipID, start_at, start_at+duration) → el rango pedido debe caer dentro de una ventana retornada. Si no → 409 slot_outside_schedule.
    • Valida booking conflict: la constraint EXCLUDE USING GIST en DB rechaza automáticamente si el slot solapa con un booking activo del mismo profesional. Choque → 409 slot_taken.
    • Valida cross-sede (ver RN-3b): el profesional no debe tener otro booking activo en cualquier sede que solape el rango.
    • TTL del hold: 10 minutos (configurable por branch, default 10). Después expira: worker periódico lo marca expired.
    • Header Idempotency-Key obligatorio — repetir con misma key devuelve el mismo hold (no crea otro).
    • Requiere auth (el caller es el cliente). client_account_id solo lo puede usar staff de la branch (flujo presencial — ver RF-13).
  • RF-2 POST /bookings/{holdId}/confirm pasa de hold a confirmed.
    • Si billing está activado para la branch: exige que haya un payment_intent_id capturado o autorizado. Si no hay cobro configurado: transición directa.
    • Emite evento booking.confirmednotifications manda confirmación al cliente y al profesional.
  • RF-3 Si el hold expira sin confirmar, emite booking.hold_expired (uso interno para métricas; no notifica al cliente).

Disponibilidad (consumer-facing)

  • RF-4 GET /availability?branch_service_id=X&from=Y&to=Z — endpoint público (rate-limited). Devuelve slots reservables reales: ventanas de horario menos bookings existentes.
    • Llama internamente a scheduling.GetScheduleWindows(membershipID, from, to) → ventanas de horario puras.
    • Consulta su propia tabla: bookings activos del profesional en cualquier sede con rango solapado.
    • Resta las ventanas ocupadas, aplica buffer entre citas (RN-7 de scheduling), fragmenta en slots de duration_minutes, aplica lead_time y max_advance.
    • Devuelve { slots: [{ start_at, end_at, professional_membership_id }] } en UTC.
    • Ventana máxima: 60 días.
  • RF-4b GET /branches/{id}/availability-summary?date=YYYY-MM-DD — agrega por profesional cuántos slots tiene disponibles ese día. Uso: badges "disponible hoy" en UI. Cache 60s.

Consulta

  • RF-5 GET /bookings/{id} — lo ve:
    • El cliente dueño.
    • El profesional del booking.
    • Cualquiera con can_read_bookings sobre la branch (owner + booking_manager).
    • platform_admin.
  • RF-5 GET /accounts/me/bookings?from=&to=&status= — lista los bookings donde el caller es cliente.
  • RF-6 GET /branches/{id}/bookings?from=&to=&status= — requiere can_read_bookings. Incluye filtros por professional_membership_id, status.
  • RF-7 GET /memberships/{id}/bookings?from=&to= — requiere ser el profesional o tener can_read_bookings sobre la branch.

Mutaciones de estado

  • RF-8 POST /bookings/{id}/cancel — cancelación explícita. Body: { reason_code, reason_text? }.
    • Autorizado si authz.Check(caller, can_cancel, booking:id) → true. La acción can_cancel cubre:
      • Cliente dueño (reason_code = by_client).
      • Profesional del booking (reason_code = by_professional).
      • Quien tenga can_manage_bookings sobre la branch (reason_code = by_owner o by_staff).
      • Sistema (reason_code ∈ {system_branch_blocked, system_time_off, system_membership_ended}).
    • Aplica CancellationPolicy de la branch (ver RN-4) y determina monto reembolsable.
    • Emite evento booking.cancelled { booking_id, reason_code, refund_amount }billing procesa, notifications avisa.
    • Estado → cancelled. BookingCancellation persiste auditoría.
  • RF-9 POST /bookings/{id}/start — marca in_progress. Opcional en MVP (uso en Business App cuando la profesional "abre" la cita). Solo el profesional del booking o booking_manager.
  • RF-10 POST /bookings/{id}/complete — marca completed. Solo el profesional del booking o booking_manager, y solo si start_at ya pasó.
    • Emite booking.completedbilling captura si estaba autorizado, reviews habilita reseña del cliente, notifications manda pedido de reseña.
  • RF-11 POST /bookings/{id}/no-show — marca no_show. Mismo permiso que complete. Solo disponible si start_at + 15min <= now() (configurable por branch, default 15).
    • Emite booking.no_showbilling aplica política (puede retener total, parcial o liberar — ver RN-6).
  • RF-12 PATCH /bookings/{id} no existe en MVP — no se edita un booking. Se cancela y se crea uno nuevo. Simplifica auditoría y consistencia con billing.

Flujo presencial (Carlos agenda a alguien por teléfono)

  • RF-13 Staff con can_create_bookings_for_others sobre la branch puede llamar POST /bookings/holds pasando client_account_id (debe existir) o client_guest = { name, phone? } para un cliente sin cuenta.
    • Si client_guest: se crea un Account guest (status=guest, sin credenciales en auth). La reserva queda atada a ese account. Si luego el cliente se registra con el mismo phone/email, auth dispara un merge (fuera de scope MVP — MVP deja el guest independiente).
    • El hold confirmado por este flujo puede omitir pago online (se acepta pago en sede, el booking queda confirmed con payment_mode = in_person).

Eventos de entrada (consumidos)

  • RF-14 branch_block.created (de scheduling): selecciona bookings confirmed con [start_at, end_at) que intersectan el rango del block → cancela con reason_code = system_branch_blocked, aplica política "full refund" (RN-5).
  • RF-15 professional_time_off.created (de scheduling): análogo por professional_membership_id → cancela con reason_code = system_time_off. Refund total.
  • RF-16 branch_services.bulk_archived (de catalog → originado por membership.ended de accounts): cancela bookings confirmed cuyos BookingItem referencian esos branch_service_idreason_code = system_membership_ended. Refund total.
  • RF-17 branch_block.deleted (de scheduling): no reactiva bookings cancelados. Los usuarios deben re-reservar.
  • RF-18 Cambios de precio en BranchService (branch_service.price_changed): ignorados — precio ya congelado.

Eventos de salida (emitidos)

  • RF-19 booking.hold_created, booking.hold_expired — métricas internas.
  • RF-20 booking.confirmed { booking_id, client_id, professional_membership_id, branch_id, start_at, end_at, total_amount }notifications, billing, discovery (invalida cache de disponibilidad).
  • RF-21 booking.cancelled { booking_id, reason_code, refund_amount, cancelled_by, override_quiet? }billing, notifications, discovery. Los reason_code sistémicos (system_branch_blocked, system_time_off, system_membership_ended) incluyen override_quiet: true en el payload para que notifications envíe el aviso fuera de quiet hours.
  • RF-22 booking.completed { booking_id }billing (captura), reviews (habilita), notifications (pide reseña).
  • RF-23 booking.no_show { booking_id }billing, notifications, reviews (bloquea reseña).

5. Reglas de negocio

  • RN-1 Un profesional no puede tener dos bookings activos con rangos temporales solapados. Enforced a nivel de DB con constraint de exclusión por rango, no con UNIQUE:

    sql
    CREATE EXTENSION IF NOT EXISTS btree_gist;
    
    ALTER TABLE bookings
      ADD CONSTRAINT bookings_no_time_overlap
      EXCLUDE USING GIST (
        professional_membership_id WITH =,
        tstzrange(start_at, end_at, '[)') WITH &&
      )
      WHERE (status IN ('hold', 'confirmed', 'in_progress'));

    El intervalo [) es semi-abierto: un booking que termina a las 15:45 no solapa con uno que empieza a las 15:45. Los bookings en estado completed, cancelled o no_show quedan excluidos del predicado y no bloquean el slot — el profesional puede ser re-agendado en ese horario.

  • RN-2 Precio congelado: BookingItem.price y BookingItem.duration_minutes se copian desde BranchService al crear el hold. Cambios posteriores en el servicio nunca afectan un booking existente (ni siquiera el que está en hold).

  • RN-3 Un cliente no puede tener dos bookings activos con start_at solapados, aunque sean en sedes distintas. Enforced por constraint de aplicación (no DB — consulta al crear hold).

  • RN-3b Un profesional no puede tener bookings activos solapados en distintas sedes. Al crear un hold, booking consulta su propia tabla filtrando por account_id del profesional en todas sus membresías. Si hay solape → 409 professional_unavailable. (Esta regla vivía en scheduling — se mueve aquí porque es un conflicto de booking, no de horario.)

  • RN-4 CancellationPolicy por Branch (config simple en MVP): { free_cancellation_hours_before: int, partial_refund_percent_after: int }. Defaults: { 24, 50 }. Aplica solo a cancelaciones by_client. Cancelaciones by_professional, by_owner, by_staff y system_* siempre generan refund total.

  • RN-5 Cancelaciones sistémicas (system_*) nunca penalizan al cliente — refund total, notificación con disculpa automatizada.

  • RN-6 NoShowPolicy por Branch: { charge_percent_on_no_show: int }. Default 100 (se cobra todo). MVP: único valor por branch.

  • RN-7 Un booking solo puede marcarse completed o no_show después de start_at. Antes, no se expone el botón.

  • RN-8 Un booking in_progress no se puede cancelar. Hay que completarlo o marcarlo no_show.

  • RN-9 El timezone_snapshot del booking es inmutable. Si la branch cambia de tz, los bookings existentes mantienen su tz original para renderizado consistente al usuario.

  • RN-10 BookingHold expirado no puede "reabrirse" — se crea un nuevo hold. El idempotency_key original queda consumido.

  • RN-11 Bookings históricos de un profesional cuya membresía se archivó siguen visibles al cliente y al profesional (desde "mis reservas pasadas"). La membresía archivada es referenciable, no borrada.

  • RN-12 Un cliente que ha hecho no_show 3 veces en 90 días queda marcado risk_no_show en su Account (flag leído por discovery/sede para decidir si aceptar su próxima reserva). MVP: solo el flag; política de rechazo es fase 2.

  • RN-13 platform_admin puede leer bookings pero no mutar estados — el negocio es soberano sobre su agenda.

6. Flujos críticos

Reserva exitosa (Sofía → Corte con María):

POST /bookings/holds
  body: { branch_service_id: bs_corte, start_at: "2026-05-02T20:00Z", idempotency_key: "uuid" }
  Idempotency-Key: uuid

  scheduling.check_slot(bs_corte, 2026-05-02T20:00Z, duration=45) → OK
  BEGIN TX
  INSERT BookingHold (status=hold, ttl=now+10min)
  INSERT Booking (status=hold, start_at, end_at, timezone_snapshot)
  INSERT BookingItem (branch_service_id, price=45000, duration=45 — copiados)
  COMMIT
  → 201 { booking_id, status: hold, expires_at }

(usuario completa pago con billing → obtiene payment_intent_id)

POST /bookings/{id}/confirm
  body: { payment_intent_id: "pi_xxx" }

  Verifica payment_intent está autorizado
  UPDATE Booking SET status=confirmed, confirmed_at=now()
  Emite booking.confirmed
  → 200
  (notifications → email/push a Sofía + a María)

Cancelación por el cliente con política:

POST /bookings/{id}/cancel  (caller: Sofía)
  body: { reason_code: "by_client", reason_text: "imprevisto" }

  Carga booking + CancellationPolicy de la branch
  Calcula horas_antes = (booking.start_at - now()) / 1h
  Si horas_antes >= policy.free_cancellation_hours_before: refund = 100%
  Si horas_antes <  policy.free_cancellation_hours_before: refund = partial_refund_percent_after%
  UPDATE Booking SET status=cancelled
  INSERT BookingCancellation (by=account:sofia, reason=by_client, refund_percent, refund_amount)
  Emite booking.cancelled { refund_amount }
  → 200
  (billing procesa refund; notifications avisa a ambos)

Cancelación sistémica por bloqueo de sede:

(scheduling emite: branch_block.created { branch=chap, start_at, end_at })
  ↓ booking consume
  SELECT bookings WHERE branch_id=chap
    AND status IN ('hold','confirmed','in_progress')
    AND [start_at, end_at) INTERSECTA [block.start_at, block.end_at)
  FOR EACH:
    UPDATE status=cancelled
    INSERT BookingCancellation (reason=system_branch_blocked, refund_percent=100)
    Emite booking.cancelled (refund total)

Completar booking y habilitar reseña:

(hora llega, Sofía se presenta, María le hace el corte)
POST /bookings/{id}/complete  (caller: María)
  Verifica start_at <= now()
  UPDATE Booking SET status=completed, completed_at=now()
  Emite booking.completed
  → 200
  (billing captura payment_intent si estaba authorized-only;
   reviews crea registro "review-pending" para que Sofía pueda reseñar;
   notifications manda a Sofía "¿cómo estuvo?" T+1h)

No-show por parte del profesional:

(Sofía no se presenta; hora de la cita +20min)
POST /bookings/{id}/no-show  (caller: María)
  Verifica start_at + 15min <= now() (ventana de gracia)
  UPDATE Booking SET status=no_show, no_show_at=now()
  Emite booking.no_show
  (billing: aplica NoShowPolicy → cobra charge_percent_on_no_show (default 100%);
   reviews: NO habilita reseña para este booking;
   account:sofia.no_show_count++ → si >=3 en 90d → flag risk_no_show)

7. Dependencias

DominioTipo
authzChecks de permisos: can_read (booking), can_cancel (booking), can_confirm (booking), can_complete (booking), can_create_for_others (booking).
schedulingLlama GetScheduleWindows() y IsOpenAt() al crear hold y al calcular disponibilidad. Una sola dirección — scheduling no lee datos de booking. Consume eventos de bloqueo/time-off para cancelar reservas.
catalogLee BranchService al crear hold (para copiar precio/duración/validar status).
accountsLee Account (cliente), ProfessionalMembership (profesional). Consume membership.ended indirecto vía catalog.bulk_archived.
billingConsume booking.confirmed (autoriza cobro), booking.completed (captura), booking.cancelled/booking.no_show (refund/retención según política).
notificationsConsume confirmaciones, recordatorios, cancelaciones, pedidos de reseña.
reviewsConsume booking.completed (habilita reseña) y booking.no_show (la bloquea).
discoveryConsume eventos de booking para invalidar cache de disponibilidad y actualizar "popularidad" en ranking.

booking lee de catalog y scheduling en el hot path de creación. Las demás interacciones son eventos asíncronos.

8. Fuera de alcance (MVP)

  • Bookings con múltiples items (pedir corte + manicure en una sola reserva secuencial) — fase 2. MVP: 1 item por booking.
  • Bookings con múltiples profesionales (p. ej. masaje a 4 manos) — fase 3.
  • Reprogramación (mover un booking confirmado a otra hora) — fase 2. MVP: cancelar + reservar de nuevo.
  • Waitlist (lista de espera si el slot está tomado) — fase 3.
  • Reserva grupal (5 amigas van juntas) — fase 4.
  • Pagos divididos (split entre amigas, o seña + saldo al llegar) — fase 3.
  • Merge de guest-account con cuenta real al registrarse con mismo phone/email — fase 2.
  • Cancelation policy por servicio (algunos servicios no-refundable) — fase 3.
  • No-show automático por inactividad del profesional — fase 2. MVP: marcado manual.
  • Recordatorios configurables por cliente (no quiero SMS, solo email) — fase 2 (hoy lo gobierna notifications con defaults).
  • Historial unificado de cliente (timeline con reviews, no-shows, ticket acumulado) — fase 3 (parcialmente visible desde Business App).
  • Reservas recurrentes (mismo corte cada 4 semanas) — fase 4.

9. Métricas

  • 0 slots con dos bookings activos simultáneamente (violación de RN-1 — contrato central).
  • p95 de POST /bookings/holds < 400ms (incluye llamada a scheduling).
  • p95 de POST /bookings/{id}/confirm < 300ms.
  • ≥ 95% de holds confirmados dentro del TTL (señal de UX de checkout fluida).
  • < 3% de bookings cancelados por el sistema (ideal: blocks/time-off se declaran con anticipación).
  • ≥ 98% de consistencia entre Booking.status = completed y billing.status = captured en < 5 min (outbox → billing worker).
  • 0 bookings con price distinto al BranchService.price al momento de crear el hold (prueba de congelamiento correcto).
  • < 1% de reclamos por "me cobraron de más" post-cambio de precio (prueba empírica de RN-2).
  • Tasa de no-show < 8% global (indicador de salud del marketplace; por encima → activar señales de riesgo o depósitos).

Documentación interna — BeautyHub