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
schedulingpara validar disponibilidad y conbillingpara 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
| Entidad | Responsabilidad |
|---|---|
Booking | Reserva única. Agrupa uno o más BookingItem dentro del mismo slot continuo. |
BookingItem | Servicio concreto reservado. Copia congelada de price, currency, duration_minutes y referencia al BranchService original. |
BookingHold | Pre-reserva temporal durante el checkout. TTL corto (5-10 min). Si expira, se libera el slot. |
BookingCancellation | Registro 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 ──< BookingBooking.status ∈ {hold, confirmed, in_progress, completed, cancelled, no_show}.Bookingapunta aProfessionalMembership.id, no aAccount. 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.BookingItemapunta aBranchService.id(referencia débil, para analytics), pero todos los campos de precio y duración están copiados — no se leen delBranchServicepara mostrar el booking.
Estructura temporal
Booking.start_atyBooking.end_atenTIMESTAMPTZ.end_at - start_at = sum(BookingItem.duration_minutes)+ buffers internos si hay varios items (MVP: 1 item = 1 booking).Booking.timezone_snapshotguarda 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_showsi 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/holdscrea unBookingHold. 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 GISTen 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-Keyobligatorio — repetir con misma key devuelve el mismo hold (no crea otro). - Requiere auth (el caller es el cliente).
client_account_idsolo lo puede usar staff de la branch (flujo presencial — ver RF-13).
- Valida schedule: llama
- RF-2
POST /bookings/{holdId}/confirmpasa deholdaconfirmed.- Si
billingestá activado para la branch: exige que haya unpayment_intent_idcapturado o autorizado. Si no hay cobro configurado: transición directa. - Emite evento
booking.confirmed→notificationsmanda confirmación al cliente y al profesional.
- Si
- 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, aplicalead_timeymax_advance. - Devuelve
{ slots: [{ start_at, end_at, professional_membership_id }] }en UTC. - Ventana máxima: 60 días.
- Llama internamente a
- 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_bookingssobre 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=— requierecan_read_bookings. Incluye filtros porprofessional_membership_id,status. - RF-7
GET /memberships/{id}/bookings?from=&to=— requiere ser el profesional o tenercan_read_bookingssobre 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óncan_cancelcubre:- Cliente dueño (
reason_code = by_client). - Profesional del booking (
reason_code = by_professional). - Quien tenga
can_manage_bookingssobre la branch (reason_code = by_owneroby_staff). - Sistema (
reason_code ∈ {system_branch_blocked, system_time_off, system_membership_ended}).
- Cliente dueño (
- Aplica
CancellationPolicyde la branch (ver RN-4) y determina monto reembolsable. - Emite evento
booking.cancelled { booking_id, reason_code, refund_amount }→billingprocesa,notificationsavisa. - Estado →
cancelled.BookingCancellationpersiste auditoría.
- Autorizado si
- RF-9
POST /bookings/{id}/start— marcain_progress. Opcional en MVP (uso en Business App cuando la profesional "abre" la cita). Solo el profesional del booking obooking_manager. - RF-10
POST /bookings/{id}/complete— marcacompleted. Solo el profesional del booking obooking_manager, y solo sistart_atya pasó.- Emite
booking.completed→billingcaptura si estaba autorizado,reviewshabilita reseña del cliente,notificationsmanda pedido de reseña.
- Emite
- RF-11
POST /bookings/{id}/no-show— marcano_show. Mismo permiso quecomplete. Solo disponible sistart_at + 15min <= now()(configurable por branch, default 15).- Emite
booking.no_show→billingaplica política (puede retener total, parcial o liberar — ver RN-6).
- Emite
- 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_otherssobre la branch puede llamarPOST /bookings/holdspasandoclient_account_id(debe existir) oclient_guest = { name, phone? }para un cliente sin cuenta.- Si
client_guest: se crea un Account guest (status=guest, sin credenciales enauth). La reserva queda atada a ese account. Si luego el cliente se registra con el mismo phone/email,authdispara 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
confirmedconpayment_mode = in_person).
- Si
Eventos de entrada (consumidos)
- RF-14
branch_block.created(descheduling): selecciona bookingsconfirmedcon[start_at, end_at)que intersectan el rango del block → cancela conreason_code = system_branch_blocked, aplica política "full refund" (RN-5). - RF-15
professional_time_off.created(descheduling): análogo porprofessional_membership_id→ cancela conreason_code = system_time_off. Refund total. - RF-16
branch_services.bulk_archived(decatalog→ originado pormembership.endeddeaccounts): cancela bookingsconfirmedcuyosBookingItemreferencian esosbranch_service_id→reason_code = system_membership_ended. Refund total. - RF-17
branch_block.deleted(descheduling): 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. Losreason_codesistémicos (system_branch_blocked,system_time_off,system_membership_ended) incluyenoverride_quiet: trueen el payload para quenotificationsenví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:
sqlCREATE 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 estadocompleted,cancelledono_showquedan excluidos del predicado y no bloquean el slot — el profesional puede ser re-agendado en ese horario.RN-2 Precio congelado:
BookingItem.priceyBookingItem.duration_minutesse copian desdeBranchServiceal crear el hold. Cambios posteriores en el servicio nunca afectan un booking existente (ni siquiera el que está enhold).RN-3 Un cliente no puede tener dos bookings activos con
start_atsolapados, 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,
bookingconsulta su propia tabla filtrando poraccount_iddel profesional en todas sus membresías. Si hay solape →409 professional_unavailable. (Esta regla vivía enscheduling— se mueve aquí porque es un conflicto de booking, no de horario.)RN-4
CancellationPolicypor Branch (config simple en MVP):{ free_cancellation_hours_before: int, partial_refund_percent_after: int }. Defaults:{ 24, 50 }. Aplica solo a cancelacionesby_client. Cancelacionesby_professional,by_owner,by_staffysystem_*siempre generan refund total.RN-5 Cancelaciones sistémicas (
system_*) nunca penalizan al cliente — refund total, notificación con disculpa automatizada.RN-6
NoShowPolicypor Branch:{ charge_percent_on_no_show: int }. Default100(se cobra todo). MVP: único valor por branch.RN-7 Un booking solo puede marcarse
completedono_showdespués destart_at. Antes, no se expone el botón.RN-8 Un booking
in_progressno se puede cancelar. Hay que completarlo o marcarlono_show.RN-9 El
timezone_snapshotdel booking es inmutable. Si la branch cambia de tz, los bookings existentes mantienen su tz original para renderizado consistente al usuario.RN-10
BookingHoldexpirado no puede "reabrirse" — se crea un nuevo hold. Elidempotency_keyoriginal 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_show3 veces en 90 días queda marcadorisk_no_showen su Account (flag leído pordiscovery/sede para decidir si aceptar su próxima reserva). MVP: solo el flag; política de rechazo es fase 2.RN-13
platform_adminpuede 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
| Dominio | Tipo |
|---|---|
authz | Checks de permisos: can_read (booking), can_cancel (booking), can_confirm (booking), can_complete (booking), can_create_for_others (booking). |
scheduling | Llama 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. |
catalog | Lee BranchService al crear hold (para copiar precio/duración/validar status). |
accounts | Lee Account (cliente), ProfessionalMembership (profesional). Consume membership.ended indirecto vía catalog.bulk_archived. |
billing | Consume booking.confirmed (autoriza cobro), booking.completed (captura), booking.cancelled/booking.no_show (refund/retención según política). |
notifications | Consume confirmaciones, recordatorios, cancelaciones, pedidos de reseña. |
reviews | Consume booking.completed (habilita reseña) y booking.no_show (la bloquea). |
discovery | Consume 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
notificationscon 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 = completedybilling.status = captureden < 5 min (outbox → billing worker). - 0 bookings con
pricedistinto alBranchService.priceal 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).