{"openapi":"3.1.0","info":{"title":"Lukin API","description":"API de **Lukin**, plataforma SaaS B2B2C de reservas de servicios.\n\nTres audiencias comparten este contrato:\n\n- **Dashboard B2B** (`/api/v1/businesses/...`): gestión de agenda, servicios,\n  staff y cobros de cada negocio.\n- **Marketplace B2C** (`/api/v1/public/...`): descubrimiento y reserva por parte\n  del cliente final, con o sin cuenta.\n- **Agentes autónomos** (`/api/v1/agent/...`): búsqueda, disponibilidad y\n  pre-reserva desde un LLM.\n\n## Convención de errores\n\nTodas las respuestas de error comparten el mismo cuerpo\n(`ErrorResponse`): `code`, `message`, `details`, `hint` y `request_id`.\nRamifica siempre sobre `code`, nunca sobre `message`. Cada respuesta —también\nlas correctas— incluye la cabecera `X-Request-ID`; envíala tú para conservar la\ntrazabilidad de extremo a extremo, o léela para reportar una incidencia.\n\n## Flujo recomendado para un agente\n\n`search` → `availability` → `booking` → `checkout` → `status`. Se busca el\nlocal, se piden sus horas libres, se abre la pre-reserva con el consentimiento\ndel titular, se paga si el servicio lo exige y se consulta el estado. La guía\ncompleta, con `curl` paso a paso y las reglas del canal, está en\n`/api/v1/agent/guide`; el mismo flujo se expone como herramientas MCP en `/mcp`.\n\n## Límites\n\n- **Cupo**: la política del canal de agentes es de 60 peticiones por minuto\n  para `search`, `availability` y `bookings/status`, y de **5 por minuto** para\n  `bookings`, que es la única llamada que aparta hueco de verdad en la agenda de\n  un local. Pasado el cupo se responde `429 RATE_LIMITED` con `Retry-After`, y\n  entonces toca esperar ese número de segundos en lugar de reintentar en bucle.\n- **`X-Agent-Key`**: si te dieron una, mándala en cada llamada. No autentica ni\n  da acceso a nada: identifica tu cupo —lo multiplica y lo separa del de quien\n  comparta tu salida a internet—. Sin ella el contador va por IP. Una clave\n  desconocida no se rechaza: simplemente se ignora y cuentas por IP.\n- **Ventanas de fecha**: la disponibilidad acepta como mucho 31 días por\n  consulta (`422 DATE_RANGE_TOO_LARGE` si se pide más).\n- **Caducidad**: una pre-reserva nace `PENDING` y aparta el hueco solo hasta su\n  `expires_at` (10 minutos, o 20 si el servicio exige prepago). Vencida, el\n  hueco vuelve a la agenda.\n- **Comunas**: el catálogo es cerrado (346 comunas). Cualquier texto que no esté\n  en `GET /api/v1/public/comunas` responde `422 UNKNOWN_COMUNA`.","version":"0.1.0","x-lukin-agent-guide":{"guide_url":"/api/v1/agent/guide","mcp_url":"/mcp","flow":["search","availability","booking","checkout","status"]}},"servers":[{"url":"https://staging.lukin.cl","description":"Servidor de la API."}],"paths":{"/api/v1/health":{"get":{"tags":["health"],"summary":"Estado del servicio y de sus dependencias","description":"Comprueba que el proceso está vivo y que la base de datos responde: ejecuta un `SELECT 1` sobre la sesión de la petición. Devuelve 200 con `{\"status\": \"ok\", \"db\": \"ok\"}` cuando todo funciona y 503 con `{\"status\": \"degraded\", \"db\": \"error\"}` si la base no está disponible, para que balanceadores y sondas de despliegue retiren la instancia del pool.","operationId":"health_read_health","responses":{"200":{"description":"El servicio y la base de datos responden.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthOut"},"examples":{"todo_ok":{"summary":"El proceso está vivo y el `SELECT 1` sobre PostgreSQL responde","value":{"status":"ok","db":"ok"}}}}}},"503":{"description":"La base de datos no responde; el servicio está degradado.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthOut"},"examples":{"base_caida":{"summary":"La base no responde: el balanceador debe retirar esta instancia del pool","value":{"status":"degraded","db":"error"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/auth/b2b/request":{"post":{"tags":["auth"],"summary":"Pedir el acceso al panel (código y enlace mágico)","description":"Emite un código de un solo uso y envía **un solo correo** con el código y con un\nenlace mágico hacia `{PUBLIC_WEB_URL}/verify?token=...`.\n\nEl código tiene la longitud y la vida que fija la política OTP del proyecto\n(6 dígitos, 10 minutos) y el enlace caduca **en el mismo instante**: su `jti` es\nel id del reto, así que enlace y código son la misma credencial vista de dos\nmaneras y usar cualquiera de los dos inutiliza el otro.\n\nLa respuesta es siempre `202`, exista o no la cuenta: el cuerpo solo devuelve el\ndestino ofuscado y el tiempo que hay que esperar para pedir otro código. Pedir\nun segundo reto antes de ese plazo responde `429 OTP_COOLDOWN` con `Retry-After`.","operationId":"auth_request_access","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/B2BRequest"},"examples":{"vendedor":{"summary":"Pedir el acceso al panel","value":{"email":"ada@peluqueria-lukin.cl"}}}}},"required":true},"responses":{"202":{"description":"Reto emitido y correo enviado. Se responde igual exista o no la cuenta: el cuerpo no revela nada sobre ella.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/B2BRequestAccepted"},"examples":{"acuse":{"summary":"Mismo cuerpo exista o no la cuenta: el destino ofuscado y la espera","value":{"masked_destination":"a*a@peluqueria-lukin.cl","resend_after_seconds":60}}}}}},"429":{"description":"`OTP_COOLDOWN`: se pidió otro código antes de que expirase el cooldown de reenvío, o `RATE_LIMITED`: se agotó el cupo de la IP. En ambos casos `Retry-After` dice cuántos segundos esperar.","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1.0}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"cooldown":{"summary":"Se pidió otro código antes de que expirase el cooldown de reenvío","value":{"code":"OTP_COOLDOWN","message":"Ya te enviamos un código hace un momento.","details":{"retry_after":42},"hint":"Vuelve a intentarlo dentro de 42 segundos.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`INVALID_EMAIL`: la dirección no tiene forma de correo.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/auth/b2b/verify":{"post":{"tags":["auth"],"summary":"Canjear el código o el enlace por una sesión","description":"Canjea el reto por una sesión. Acepta **dos vías excluyentes**: `email` + `code`\n(lo que el vendedor teclea) o `token` (lo que trae el enlace del correo, con el\ncorreo en su claim `sub`).\n\nEn el **primer acceso** la cuenta no existe todavía: hace falta `accept_privacy:\ntrue` para crearla con rol `OWNER` y dejar registrados los consentimientos de\nTérminos y Política de Privacidad. Si falta, la respuesta es `422\nCONSENT_REQUIRED` y el reto **no se gasta**, de modo que basta reintentar con el\nmismo código marcando la casilla.\n\nSobre una cuenta que ya existe no se toca el rol: un `STAFF` invitado o un\n`CLIENT` del marketplace entran con el suyo.\n\n**403 `ACCOUNT_DISABLED` aquí; 401 `USER_INACTIVE` en las rutas protegidas.**\nUna cuenta deshabilitada responde `403 ACCOUNT_DISABLED`, y solo después de\nhaber presentado un reto válido: la credencial era correcta y lo que falla es la\ncuenta, de modo que reintentar el canje no lleva a ninguna parte. El `401\nUSER_INACTIVE` es el **otro** caso y nunca sale de aquí: lo emite\n`get_current_user` cuando se presenta en una ruta protegida un access token de\nuna cuenta ya desactivada, y ahí lo que se rechaza sí es la credencial. Está\nejemplificado en `GET /api/v1/auth/me`, que es la operación que lo devuelve.\nCada par `(code, status)` es único en toda la API (F2-T11): ramificar sobre\n`code` basta para distinguirlos.\n\nCon éxito devuelve el access token en el cuerpo y fija la cookie `lukin_refresh`.","operationId":"auth_verify_access","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/B2BVerify"},"examples":{"codigo":{"summary":"Canjear el código de 6 dígitos","value":{"email":"ada@peluqueria-lukin.cl","code":"000000"}},"primer_acceso":{"summary":"Primer acceso: hay que aceptar los textos legales","value":{"email":"ada@peluqueria-lukin.cl","code":"000000","accept_privacy":true}},"magic_link":{"summary":"Canjear el enlace del correo","value":{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…","accept_privacy":true}}}}},"required":true},"responses":{"200":{"description":"Sesión abierta. El access token viaja en el cuerpo y el refresh en la cookie `lukin_refresh` (HttpOnly, Secure, SameSite=Lax, Path=/api/v1/auth).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"},"examples":{"sesion_abierta":{"summary":"El access token en el cuerpo; el refresh, solo en la cookie","value":{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.********…","token_type":"bearer","expires_in":900,"user":{"id":"6f9619ff-8b86-d011-b42d-00c04fc964ff","email":"ada@peluqueria-lukin.cl","full_name":"Ada Lovelace","phone":"+56912345678","role":"OWNER"}}}}}}},"429":{"description":"`OTP_ATTEMPTS_EXCEEDED`: el reto agotó sus intentos y ya no admite más comprobaciones, o `RATE_LIMITED`: se agotó el cupo de la IP.","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1.0}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"`OTP_INVALID`: el código no coincide. `OTP_EXPIRED`: no hay reto vigente para ese correo (nunca existió, ya se usó o caducó). `MAGIC_LINK_INVALID`: el enlace está manipulado, caducado o ya se usó.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"codigo_incorrecto":{"summary":"El código no coincide con el reto vigente","value":{"code":"OTP_INVALID","message":"El código no es correcto.","details":null,"hint":"Revisa el correo o pide uno nuevo.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"sin_reto":{"summary":"No hay reto vigente: nunca existió, ya se usó o caducó","value":{"code":"OTP_EXPIRED","message":"El código ya no es válido.","details":null,"hint":"Pide un código nuevo.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`ACCOUNT_DISABLED`: la credencial era válida pero la cuenta está deshabilitada. Nunca `USER_INACTIVE`, que es el 401 de las rutas protegidas.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"cuenta_desactivada":{"summary":"El reto era válido pero la cuenta está deshabilitada: 403 `ACCOUNT_DISABLED`, nunca el 401 `USER_INACTIVE` de las rutas protegidas","value":{"code":"ACCOUNT_DISABLED","message":"La cuenta está desactivada.","details":null,"hint":"Escribe a soporte para reactivarla.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`CONSENT_REQUIRED`: la cuenta no existe todavía y hace falta aceptar los Términos y la Política de Privacidad (`accept_privacy: true`). El reto **no** se consume: sirve el mismo código para reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"falta_el_consentimiento":{"summary":"Primer acceso sin `accept_privacy`: el reto NO se consume","value":{"code":"CONSENT_REQUIRED","message":"Para crear tu cuenta hay que aceptar los Términos y la Política de Privacidad.","details":{"required_consents":["TERMS","PRIVACY_POLICY"],"terms_version":"2026-08-01","privacy_policy_version":"2026-08-01"},"hint":"Reenvía el mismo código con `accept_privacy: true`.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/auth/register":{"post":{"tags":["auth"],"summary":"Registrar una cuenta de cliente","description":"Da de alta un cliente final con correo y contraseña y **abre sesión de\ninmediato**: devuelve el `access_token` en el cuerpo y el refresh en la cookie\n`lukin_refresh` (`HttpOnly`), igual que `/auth/login`.\n\nEl correo se normaliza a minúsculas y el teléfono a E.164 (Chile por defecto)\nantes de tocar la base, así que `Ada@Lukin.cl` y `ada@lukin.cl` son la misma\ncuenta.\n\nSi ya existía un **invitado** (`GUEST`) con ese correo —alguien que reservó sin\nregistrarse, RF-03.02— no se crea una cuenta nueva: se promueve la suya a\n`CLIENT` conservando el mismo `id`, de modo que sus reservas anteriores siguen\nsiendo suyas. Si el correo pertenece a una cuenta ya registrada (`CLIENT`,\n`STAFF`, `OWNER`…) la respuesta es 409 `EMAIL_ALREADY_REGISTERED`.\n\nEl alta registra además los dos consentimientos de SEC-01 (términos y política\nde privacidad) con la versión vigente, la IP y el *user agent*, en la **misma**\ntransacción que el usuario. `accept_privacy` solo admite `true`.","operationId":"auth_register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterRequest"},"examples":{"alta":{"summary":"Alta completa, con teléfono de contacto","value":{"email":"ada@lukin.cl","password":"una-contrasena-segura","full_name":"Ada Lovelace","phone":"+56912345678","accept_privacy":true}},"sin_telefono":{"summary":"El teléfono es opcional: basta el correo","value":{"email":"ada@lukin.cl","password":"una-contrasena-segura","full_name":"Ada Lovelace","accept_privacy":true}}}}},"required":true},"responses":{"201":{"description":"Cuenta creada (o invitado promovido) y sesión abierta.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"},"example":{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.********…","token_type":"bearer","expires_in":900,"user":{"id":"6f9619ff-8b86-d011-b42d-00c04fc964ff","email":"ada@lukin.cl","full_name":"Ada Lovelace","phone":"+56912345678","role":"CLIENT"}}}}},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1.0}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"La cuenta existente con ese correo está desactivada (`ACCOUNT_DISABLED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"cuenta_desactivada":{"summary":"El invitado con ese correo existe pero está desactivado","value":{"code":"ACCOUNT_DISABLED","message":"Esta cuenta está desactivada.","details":null,"hint":"Escribe a soporte para reactivarla.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"409":{"description":"Ya hay una cuenta registrada con ese correo (`EMAIL_ALREADY_REGISTERED`) o el teléfono pertenece a otra (`PHONE_ALREADY_REGISTERED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"correo_ocupado":{"summary":"Ese correo ya tiene cuenta: no es un alta, es un login","value":{"code":"EMAIL_ALREADY_REGISTERED","message":"Ese correo electrónico ya está registrado.","details":null,"hint":"Inicia sesión o recupera tu contraseña.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/auth/login":{"post":{"tags":["auth"],"summary":"Iniciar sesión con contraseña","description":"Inicia sesión con correo y contraseña. Devuelve el `access_token` en el cuerpo y\nel refresh en la cookie `lukin_refresh` (`HttpOnly`, `Secure`, `SameSite=Lax`,\n`Path=/api/v1/auth`).\n\n**El 401 es siempre el mismo.** Un correo que no existe, una cuenta sin\ncontraseña (creada por OTP, magic link o SSO) y una contraseña equivocada\ncomparten cuerpo, código (`INVALID_CREDENTIALS`) y tiempo de respuesta: nada en\nla respuesta permite averiguar si un correo está registrado.\n\n**Dos límites, no uno.** Además del límite por IP (`AUTH_LIMIT`), cada identidad\ntiene su propio contador: 10 intentos fallidos en 15 minutos y el siguiente\nrecibe 429 con `details.policy = \"LOGIN_ACCOUNT_LIMIT\"` y la cabecera\n`Retry-After`, vengan de donde vengan. Un inicio de sesión correcto lo reinicia.\n\n**403 `ACCOUNT_DISABLED` aquí; 401 `USER_INACTIVE` en las rutas protegidas.**\nUna cuenta desactivada responde `403 ACCOUNT_DISABLED`, y solo después de haber\nacertado la contraseña: la credencial presentada era correcta y lo que falla es\nla cuenta, así que reintentar no sirve de nada y hay que escribir a soporte. El\n`401 USER_INACTIVE` es el **otro** caso y nunca sale de aquí: lo emite\n`get_current_user` cuando alguien presenta en una ruta protegida un access token\nde una cuenta que ya fue desactivada, y ahí lo que se rechaza sí es la\ncredencial. Puedes verlo ejemplificado en `GET /api/v1/auth/me`, que es la\noperación que lo devuelve. Cada par `(code, status)` es único en toda la API\n(F2-T11), de modo que ramificar sobre `code` basta para distinguirlos.","operationId":"auth_login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"},"examples":{"credenciales":{"summary":"Correo y contraseña de una cuenta ya registrada","value":{"email":"ada@lukin.cl","password":"una-contrasena-segura"}}}}},"required":true},"responses":{"200":{"description":"Sesión abierta.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"},"example":{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.********…","token_type":"bearer","expires_in":900,"user":{"id":"6f9619ff-8b86-d011-b42d-00c04fc964ff","email":"ada@lukin.cl","full_name":"Ada Lovelace","role":"CLIENT"}}}}},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1.0}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"cuenta_bloqueada":{"summary":"Diez intentos fallidos contra esta identidad en quince minutos","value":{"code":"RATE_LIMITED","message":"Has superado el límite de peticiones permitido.","details":{"retry_after":420,"limit":10,"policy":"LOGIN_ACCOUNT_LIMIT"},"hint":"Vuelve a intentarlo dentro de 420 segundos.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"401":{"description":"Credenciales incorrectas (`INVALID_CREDENTIALS`). El cuerpo es idéntico para un correo inexistente, una cuenta sin contraseña y una contraseña equivocada.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"credenciales_incorrectas":{"summary":"Correo inexistente, cuenta sin contraseña o contraseña equivocada: el mismo cuerpo para los tres","value":{"code":"INVALID_CREDENTIALS","message":"El correo o la contraseña no son correctos.","details":null,"hint":null,"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"La cuenta está desactivada (`ACCOUNT_DISABLED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"cuenta_desactivada":{"summary":"La contraseña era correcta pero la cuenta está desactivada: 403 `ACCOUNT_DISABLED`, nunca el 401 `USER_INACTIVE` de las rutas protegidas","value":{"code":"ACCOUNT_DISABLED","message":"Esta cuenta está desactivada.","details":null,"hint":"Escribe a soporte para reactivarla.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/auth/password/forgot":{"post":{"tags":["auth"],"summary":"Pedir un enlace para restablecer la contraseña","description":"Envía por correo un enlace para elegir una contraseña nueva.\n\n**Responde 202 siempre**, con el mismo cuerpo, exista o no la cuenta: un 404 o\nun mensaje distinto convertiría este endpoint en un enumerador de usuarios\nregistrados. El correo solo se envía de verdad si hay una cuenta activa **con\ncontraseña**; quien entra por OTP, magic link o SSO no tiene nada que\nrestablecer.\n\nEl enlace apunta a `{PUBLIC_WEB_URL}/recuperar?token=…`, caduca a los 60 minutos\ny sirve una sola vez.","operationId":"auth_password_forgot","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PasswordForgotRequest"},"examples":{"pedir_enlace":{"summary":"Solo el correo: la respuesta es la misma exista o no la cuenta","value":{"email":"ada@lukin.cl"}}}}},"required":true},"responses":{"202":{"description":"Solicitud aceptada. La respuesta no revela si la cuenta existe.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PasswordForgotAccepted"},"example":{"status":"PASSWORD_RESET_REQUESTED"}}}},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1.0}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"cupo_agotado":{"summary":"Se pidieron más de cinco enlaces por minuto desde la misma IP","value":{"code":"RATE_LIMITED","message":"Has superado el límite de peticiones permitido.","details":{"retry_after":38,"limit":5,"policy":"5/minute"},"hint":"Vuelve a intentarlo dentro de 38 segundos.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/auth/password/reset":{"post":{"tags":["auth"],"summary":"Fijar una contraseña nueva con el token del correo","description":"Canjea el token del correo por una contraseña nueva.\n\nEl token es de **un solo uso** sin necesidad de una tabla que lo recuerde: lleva\nla huella (`pwd_fp`) del hash de contraseña vigente cuando se emitió y aquí se\ncompara con el hash actual. Al cambiar la contraseña la huella deja de coincidir\ny todos los enlaces emitidos antes —el recién usado incluido— dejan de valer.\nUn token caducado, manipulado, de otro propósito o ya consumido devuelven el\nmismo 401 `RESET_TOKEN_INVALID`.\n\nCambiar la contraseña **cierra todas las sesiones abiertas** del usuario\n(se revocan sus familias de refresh) y reinicia su contador de intentos\nfallidos, para que quien acaba de recuperar su cuenta pueda entrar de inmediato\naunque el ataque la hubiera dejado bloqueada.","operationId":"auth_password_reset","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PasswordResetRequest"},"examples":{"canjear_enlace":{"summary":"El token del correo y la contraseña nueva","value":{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…","password":"una-contrasena-segura"}}}}},"required":true},"responses":{"200":{"description":"Contraseña actualizada y sesiones anteriores revocadas.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PasswordUpdated"},"example":{"status":"PASSWORD_UPDATED"}}}},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1.0}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"El enlace caducó, no es de este propósito o ya se usó (`RESET_TOKEN_INVALID`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"enlace_gastado":{"summary":"El enlace caducó, es de otro propósito o ya se usó: los tres son el mismo 401","value":{"code":"RESET_TOKEN_INVALID","message":"El enlace de recuperación no es válido o ya se usó.","details":null,"hint":"Pide un enlace nuevo desde «Olvidé mi contraseña».","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/auth/refresh":{"post":{"tags":["auth"],"summary":"Renovar el access token rotando la cookie de refresco","description":"Rota la cookie `lukin_refresh` y devuelve un `access_token` nuevo. **No** hace\nfalta enviar `Authorization`: la credencial de esta operación es la cookie, y lo\nnormal es llamarla justo cuando el access token acaba de caducar.\n\nCada refresco consume el token presentado y emite su sucesor dentro de la misma\nfamilia, así que la cookie de la respuesta es **siempre distinta** de la\nenviada. Un refresh solo vale una vez: reenviar uno ya rotado revoca la familia\nentera —también la sesión que estuviera usando el token vigente— y responde\n`401 REFRESH_REUSED`, porque no hay forma de distinguir al ladrón de la víctima.\n\nTodos los fallos limpian la cookie (`Max-Age=0`) para que el navegador deje de\nreenviar una credencial que ya no sirve.","operationId":"auth_refresh_session","responses":{"200":{"description":"Sesión renovada. El access token viaja en el cuerpo y el refresh rotado en la cookie `lukin_refresh`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccessTokenOut"},"example":{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.********…","token_type":"bearer","expires_in":900}}}},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1.0}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"`REFRESH_MISSING`: la petición no trae la cookie. `REFRESH_INVALID`: la cookie no corresponde a ninguna sesión viva o caducó. `REFRESH_REUSED`: el refresh ya se había usado y la familia entera queda revocada.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_cookie":{"summary":"La petición no trae `lukin_refresh`: no hay sesión que renovar","value":{"code":"REFRESH_MISSING","message":"Falta la cookie de sesión.","details":null,"hint":"Vuelve a iniciar sesión.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"refresh_reutilizado":{"summary":"El refresh ya se había usado: la familia entera queda revocada","value":{"code":"REFRESH_REUSED","message":"La sesión expiró o no es válida. Vuelve a iniciar sesión.","details":null,"hint":null,"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/auth/logout":{"post":{"tags":["auth"],"summary":"Cerrar la sesión de este navegador","description":"Cierra la sesión: revoca la familia del refresh que viaja en la cookie y la\ncaduca en el navegador (`Max-Age=0`, con el mismo `Path` con el que se fijó).\n\n**No exige un access token válido.** Cerrar sesión tiene que funcionar\nprecisamente cuando la credencial corta ya expiró; pedirla dejaría al usuario\ncon una sesión larga que no puede cerrar.\n\nEs idempotente: sin cookie, con una desconocida o con una ya revocada responde\nigualmente `204`. Solo revoca esa sesión; las demás del mismo titular (otro\nnavegador, el móvil) siguen abiertas, porque cada una tiene su propia familia.","operationId":"auth_logout","responses":{"204":{"description":"Sesión cerrada y cookie `lukin_refresh` caducada. Se responde lo mismo aunque no hubiera sesión que cerrar."},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"forma_del_error":{"summary":"Forma con la que llega cualquier error de la API (el cierre no rechaza nada)","value":{"code":"VALIDATION_ERROR","message":"Los datos enviados no son válidos.","details":null,"hint":"Ramifica siempre por `code`; `request_id` es lo que hay que citar al reportar.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/auth/me":{"get":{"tags":["auth"],"summary":"Perfil del usuario autenticado y sus negocios","description":"Perfil del usuario autenticado con todo lo que el panel necesita al arrancar:\nidentidad, cartera de negocios y estado del consentimiento.\n\n`businesses` reúne las dos vías de acceso, ordenadas por nombre: los negocios\ncuyo dueño es el titular (`role: \"OWNER\"`) y aquellos donde figura **activo** en\nla plantilla (`role: \"STAFF\"`). Ese `role` es la membresía en cada negocio, no\nel rol global de la cuenta, que viaja aparte en `role`. Los negocios con borrado\nlógico no aparecen, tampoco para su dueño.\n\n`consent_required` es `true` cuando faltan los Términos o la Política de\nPrivacidad en su versión vigente (SEC-01): el cliente debe volver a pedirlos.\n\nUna cuenta desactivada o anonimizada recibe `401 USER_INACTIVE`, nunca un 403.","operationId":"auth_read_me","responses":{"200":{"description":"Perfil del titular de la sesión.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserMe"},"example":{"id":"6f9619ff-8b86-d011-b42d-00c04fc964ff","email":"ada@peluqueria-lukin.cl","full_name":"Ada Lovelace","phone":"+56912345678","role":"OWNER","businesses":[{"id":"0d1f2e3a-4b5c-6d7e-8f90-1a2b3c4d5e6f","slug":"peluqueria-lukin","name":"Peluquería Lukin","role":"OWNER"},{"id":"9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d","slug":"barberia-nunoa","name":"Barbería Ñuñoa","role":"STAFF"}],"consent_required":false}}}},"401":{"description":"`TOKEN_MISSING`, `TOKEN_EXPIRED`, `TOKEN_INVALID` o `USER_INACTIVE` (cuenta desactivada o anonimizada).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"cuenta_desactivada":{"summary":"El token es válido pero la cuenta ya no: 401 `USER_INACTIVE`, nunca el 403 `ACCOUNT_DISABLED` de POST /api/v1/auth/login","value":{"code":"USER_INACTIVE","message":"La cuenta está desactivada o anonimizada.","details":null,"hint":null,"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"sin_cabecera":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Falta la cabecera `Authorization: Bearer <token>`.","details":null,"hint":null,"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"HTTPBearer":[]}]},"patch":{"tags":["auth"],"summary":"Actualizar el perfil del usuario autenticado","description":"Actualiza el nombre y el teléfono del titular. Semántica de `PATCH` estricta:\nun campo **ausente** no se toca, un campo enviado a `null` **se borra** y un\nvalor lo sustituye.\n\nEl teléfono se normaliza a E.164 asumiendo Chile cuando no trae prefijo, de modo\nque `9 1234 5678` se guarda como `+56912345678`. Si ese número ya pertenece a\notra cuenta la respuesta es `409 PHONE_ALREADY_REGISTERED`, y borrar el único\ndato de contacto que le queda a la cuenta es `422 CONTACT_REQUIRED`.\n\nEl correo no se cambia aquí: mover la dirección de una cuenta es un cambio de\nidentidad que exige verificar el destino, y ese es otro flujo.\n\nDevuelve el mismo `UserMe` que `GET /auth/me`, ya actualizado.","operationId":"auth_update_me","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserUpdate"},"examples":{"perfil":{"summary":"Cambiar nombre y teléfono","value":{"full_name":"Ada Lovelace","phone":"9 1234 5678"}},"borrar_telefono":{"summary":"Retirar el teléfono (`null` borra)","value":{}}}}},"required":true},"responses":{"200":{"description":"Perfil actualizado.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserMe"},"example":{"id":"6f9619ff-8b86-d011-b42d-00c04fc964ff","email":"ada@peluqueria-lukin.cl","full_name":"Ada Lovelace","phone":"+56912345678","role":"OWNER","businesses":[{"id":"0d1f2e3a-4b5c-6d7e-8f90-1a2b3c4d5e6f","slug":"peluqueria-lukin","name":"Peluquería Lukin","role":"OWNER"},{"id":"9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d","slug":"barberia-nunoa","name":"Barbería Ñuñoa","role":"STAFF"}],"consent_required":false}}}},"401":{"description":"`TOKEN_MISSING`, `TOKEN_EXPIRED`, `TOKEN_INVALID` o `USER_INACTIVE`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"cuenta_desactivada":{"summary":"El token es válido pero la cuenta ya no: 401 `USER_INACTIVE`, nunca el 403 `ACCOUNT_DISABLED` de POST /api/v1/auth/login","value":{"code":"USER_INACTIVE","message":"La cuenta está desactivada o anonimizada.","details":null,"hint":null,"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"sin_cabecera":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Falta la cabecera `Authorization: Bearer <token>`.","details":null,"hint":null,"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"409":{"description":"`PHONE_ALREADY_REGISTERED`: ese número pertenece a otra cuenta.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"telefono_ocupado":{"summary":"Ese número ya pertenece a otra cuenta","value":{"code":"PHONE_ALREADY_REGISTERED","message":"Ese número de teléfono ya está registrado.","details":null,"hint":"Comprueba el número o retíralo de la otra cuenta.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"HTTPBearer":[]}]}},"/api/v1/auth/sso/{provider}":{"post":{"tags":["auth"],"summary":"Iniciar sesión con Google o Apple","description":"Verifica el `id_token` que el SDK de Google o de Apple entregó al navegador (firma RS256 contra el JWKS del proveedor, `aud`, `iss`, `exp` e `iat`) y abre sesión con el mismo `AuthResponse` que el resto de los logins, fijando la cookie `lukin_refresh`.\n\nLa cuenta se enlaza por el **correo** del token, normalizado igual que en el registro. Si ese correo no existe todavía se crea un usuario `CLIENT` y para ello hace falta `accept_privacy: true`; un usuario `GUEST` (reserva sin cuenta) se promueve a `CLIENT` conservando su historial, y los roles `OWNER`, `STAFF` o `SUPERADMIN` inician sesión sin cambio alguno.\n\n`apple` solo está disponible con `SSO_APPLE_ENABLED=true`; mientras el flag esté apagado responde 404 `SSO_PROVIDER_DISABLED`.","operationId":"auth_sso_login","parameters":[{"name":"provider","in":"path","required":true,"schema":{"enum":["google","apple"],"type":"string","title":"Provider"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SsoLoginRequest"},"examples":{"primer_acceso":{"summary":"Correo nuevo: hay que aceptar los textos legales para crear la cuenta","value":{"id_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6IjEyMyJ9…","accept_privacy":true}},"cuenta_existente":{"summary":"El correo ya tiene cuenta: `accept_privacy` es irrelevante","value":{"id_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6IjEyMyJ9…"}}}}}},"responses":{"200":{"description":"Sesión abierta. El cuerpo trae el access token y el usuario; el refresh viaja solo en la cookie `lukin_refresh`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"},"examples":{"sesion_abierta":{"summary":"El access token en el cuerpo; el refresh, solo en la cookie","value":{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.********…","token_type":"bearer","expires_in":900,"user":{"id":"6f9619ff-8b86-d011-b42d-00c04fc964ff","email":"ada@lukin.cl","full_name":"Ada Lovelace","phone":null,"role":"CLIENT"}}}}}}},"401":{"description":"`SSO_TOKEN_INVALID` si el `id_token` no verifica; `SSO_EMAIL_NOT_VERIFIED` si el proveedor no da el correo por verificado.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"token_no_verifica":{"summary":"Firma, `aud`, `iss` o `exp` no cuadran: un solo código para los cuatro","value":{"code":"SSO_TOKEN_INVALID","message":"El token de inicio de sesión no es válido.","details":null,"hint":"Vuelve a intentarlo desde el botón del proveedor.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`ACCOUNT_DISABLED`: la cuenta está desactivada o anonimizada.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"cuenta_desactivada":{"summary":"El token era válido pero la cuenta está desactivada o anonimizada","value":{"code":"ACCOUNT_DISABLED","message":"La cuenta está desactivada.","details":null,"hint":"Escribe a soporte para reactivarla.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`SSO_PROVIDER_DISABLED`: ese proveedor no está habilitado en esta instalación.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"proveedor_apagado":{"summary":"`apple` sin `SSO_APPLE_ENABLED`: para quien llama, ese proveedor no existe","value":{"code":"SSO_PROVIDER_DISABLED","message":"El inicio de sesión con Apple no está disponible.","details":null,"hint":"Usa Google o el correo electrónico.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`CONSENT_REQUIRED`: el correo no tiene cuenta y falta `accept_privacy`; o los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"falta_el_consentimiento":{"summary":"El correo del token no tiene cuenta y no llegó `accept_privacy`","value":{"code":"CONSENT_REQUIRED","message":"Para crear tu cuenta hay que aceptar los Términos y la Política de Privacidad.","details":null,"hint":"Repite la petición con `accept_privacy: true`.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/admin/users":{"get":{"tags":["admin"],"summary":"Buscar cuentas de la plataforma","description":"Busca cuentas de toda la plataforma. `q` casa de forma **parcial y sin\ndistinguir mayúsculas** contra el correo, el nombre completo y el teléfono en\nE.164, de modo que un fragmento del correo o los últimos dígitos del móvil\nbastan para encontrar a alguien que llama a soporte.\n\n`role` e `is_active` acotan por rol global y por estado de acceso. El resultado\nva de la cuenta más reciente a la más antigua.\n\n`page_size` admite valores mayores que 100 sin dar error: se acotan\na 100 y la respuesta devuelve en `page_size` el valor realmente\naplicado.","operationId":"admin_list_users","security":[{"HTTPBearer":[]}],"parameters":[{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Fragmento del correo, del nombre o del teléfono.","examples":["ada","+56912"],"title":"Q"},"description":"Fragmento del correo, del nombre o del teléfono."},{"name":"role","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/UserRole"},{"type":"null"}],"description":"Rol global exacto.","title":"Role"},"description":"Rol global exacto."},{"name":"is_active","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"Estado de acceso.","title":"Is Active"},"description":"Estado de acceso."},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"Página que se pide, empezando en 1.","default":1,"title":"Page"},"description":"Página que se pide, empezando en 1."},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"Filas por página. Valores mayores que 100 no dan error: se acotan a 100 y la respuesta devuelve el valor aplicado.","default":20,"title":"Page Size"},"description":"Filas por página. Valores mayores que 100 no dan error: se acotan a 100 y la respuesta devuelve el valor aplicado."}],"responses":{"200":{"description":"Página de cuentas, de la más reciente a la más antigua.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Page_AdminUserOut_"},"examples":{"una_pagina":{"summary":"Primera página de resultados","value":{"items":[{"id":"8f14e45f-ceea-467a-9f47-1f2c3a6b9f11","email":"ada@peluqueria-lukin.cl","full_name":"Ada Lovelace","phone":"+56912345678","role":"OWNER","is_active":true,"created_at":"2026-01-15T10:30:00Z"}],"total":1,"page":1,"page_size":20}}}}}},"401":{"description":"`TOKEN_MISSING`, `TOKEN_EXPIRED` o `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Falta la cabecera `Authorization: Bearer <token>`.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`INSUFFICIENT_ROLE`: la cuenta no tiene el rol SUPERADMIN.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"rol_insuficiente":{"summary":"La sesión es válida pero la cuenta no es SUPERADMIN","value":{"code":"INSUFFICIENT_ROLE","message":"Tu rol no permite realizar esta operación.","details":null,"hint":null,"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/admin/users/{user_id}":{"patch":{"tags":["admin"],"summary":"Cambiar el rol o el acceso de una cuenta","description":"Cambia el rol global de una cuenta, su acceso, o ambos. Los campos ausentes no\nse tocan.\n\n**Desactivar cierra la sesión de verdad**: además de marcar la cuenta, revoca\ntodas sus familias de refresh tokens, así que ni el refresh rota ni el access\ntoken que tuviera sirve ya en ninguna ruta protegida (401 `USER_INACTIVE`).\n\nDos negativas deliberadas: un superadmin **no puede modificarse a sí mismo**\n(422 `CANNOT_MODIFY_SELF`; degradar a otro superadmin sí se puede), y una cuenta\n**anonimizada** por el derecho al olvido no admite cambios (409\n`USER_ANONYMIZED`).\n\nTodo cambio efectivo queda registrado en la bitácora de administración con el\nactor, el antes, el después y el `request_id` de esta petición.","operationId":"admin_update_user","security":[{"HTTPBearer":[]}],"parameters":[{"name":"user_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"User Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminUserUpdate"},"examples":{"cambiar_rol":{"summary":"Corregir el rol de una cuenta","value":{"role":"CLIENT"}},"desactivar":{"summary":"Cerrar el acceso y todas sus sesiones","value":{"is_active":false}},"reactivar":{"summary":"Devolver el acceso","value":{"is_active":true}}}}}},"responses":{"200":{"description":"Cuenta ya actualizada.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminUserOut"},"example":{"id":"8f14e45f-ceea-467a-9f47-1f2c3a6b9f11","email":"ada@peluqueria-lukin.cl","full_name":"Ada Lovelace","phone":"+56912345678","role":"OWNER","is_active":true,"created_at":"2026-01-15T10:30:00Z"}}}},"401":{"description":"`TOKEN_MISSING`, `TOKEN_EXPIRED` o `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Falta la cabecera `Authorization: Bearer <token>`.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`INSUFFICIENT_ROLE`: la cuenta no tiene el rol SUPERADMIN.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"rol_insuficiente":{"summary":"La sesión es válida pero la cuenta no es SUPERADMIN","value":{"code":"INSUFFICIENT_ROLE","message":"Tu rol no permite realizar esta operación.","details":null,"hint":null,"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`USER_NOT_FOUND`: no existe ninguna cuenta con ese id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"cuenta_inexistente":{"summary":"No hay ninguna cuenta con ese identificador","value":{"code":"USER_NOT_FOUND","message":"La cuenta solicitada no existe.","details":null,"hint":null,"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"409":{"description":"`USER_ANONYMIZED`: la cuenta ejerció el derecho al olvido (SEC-01) y ya no admite cambios.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"cuenta_anonimizada":{"summary":"La cuenta ejerció el derecho al olvido (SEC-01) y ya no representa a nadie","value":{"code":"USER_ANONYMIZED","message":"La cuenta está anonimizada y ya no admite cambios.","details":null,"hint":null,"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`CANNOT_MODIFY_SELF`: un superadmin no puede cambiarse el rol ni desactivarse a sí mismo. `VALIDATION_ERROR`: el cuerpo no trae ni `role` ni `is_active`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"no_te_modificas_a_ti_mismo":{"summary":"Un superadmin que se degradara o se desactivara dejaría la plataforma sin administración","value":{"code":"CANNOT_MODIFY_SELF","message":"Un superadmin no puede modificar su propia cuenta.","details":null,"hint":"Pide el cambio a otra cuenta con rol SUPERADMIN.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"cuerpo_vacio":{"summary":"El cuerpo no trae ni `role` ni `is_active`: no hay nada que cambiar","value":{"code":"VALIDATION_ERROR","message":"Los datos enviados no son válidos.","details":[{"loc":["body"],"msg":"Value error, Envía al menos `role` o `is_active`."}],"hint":null,"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/admin/businesses":{"get":{"tags":["admin"],"summary":"Buscar negocios de la plataforma","description":"Lista los negocios de la plataforma. `q` casa de forma parcial y sin distinguir\nmayúsculas contra el nombre y el slug; `is_published` separa los publicados de\nlos que no lo están.\n\nA diferencia de las rutas del marketplace, **incluye los negocios eliminados**\npor su dueño: el borrado es lógico y su `deleted_at` viaja en la respuesta, de\nmodo que una reclamación posterior a la baja sigue siendo investigable.","operationId":"admin_list_businesses","security":[{"HTTPBearer":[]}],"parameters":[{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Fragmento del nombre o del slug.","examples":["peluqueria"],"title":"Q"},"description":"Fragmento del nombre o del slug."},{"name":"is_published","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"Publicados en el marketplace o no.","title":"Is Published"},"description":"Publicados en el marketplace o no."},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"Página que se pide, empezando en 1.","default":1,"title":"Page"},"description":"Página que se pide, empezando en 1."},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"Filas por página. Valores mayores que 100 no dan error: se acotan a 100 y la respuesta devuelve el valor aplicado.","default":20,"title":"Page Size"},"description":"Filas por página. Valores mayores que 100 no dan error: se acotan a 100 y la respuesta devuelve el valor aplicado."}],"responses":{"200":{"description":"Página de negocios, incluidos los eliminados por su dueño.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Page_AdminBusinessOut_"},"examples":{"una_pagina":{"summary":"Primera página de resultados","value":{"items":[{"id":"1c5f9b1e-2b5a-4a4f-8f2b-6d0f6a1a1e10","slug":"peluqueria-lukin","name":"Peluquería Lukin","comuna":"Providencia","owner_email":"ada@peluqueria-lukin.cl","is_published":true,"created_at":"2026-01-15T10:30:00Z"}],"total":1,"page":1,"page_size":20}}}}}},"401":{"description":"`TOKEN_MISSING`, `TOKEN_EXPIRED` o `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Falta la cabecera `Authorization: Bearer <token>`.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`INSUFFICIENT_ROLE`: la cuenta no tiene el rol SUPERADMIN.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"rol_insuficiente":{"summary":"La sesión es válida pero la cuenta no es SUPERADMIN","value":{"code":"INSUFFICIENT_ROLE","message":"Tu rol no permite realizar esta operación.","details":null,"hint":null,"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/admin/businesses/{business_id}":{"patch":{"tags":["admin"],"summary":"Suspender o reactivar un negocio","description":"Suspende (`is_published: false`) o reactiva (`is_published: true`) la ficha de\nun negocio. Una ficha suspendida desaparece del marketplace público sin borrar\nnada: su agenda, su catálogo y su historial quedan intactos, y republicarla la\ndevuelve tal como estaba.\n\nLa acción queda registrada en la bitácora de administración como\n`BUSINESS_SUSPENDED` o `BUSINESS_REPUBLISHED`.","operationId":"admin_update_business","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminBusinessUpdate"},"examples":{"suspender":{"summary":"Suspender el negocio y sacarlo del marketplace","value":{"is_published":false}},"republicar":{"summary":"Volver a publicarlo","value":{"is_published":true}}}}}},"responses":{"200":{"description":"Ficha ya suspendida o republicada.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminBusinessOut"},"example":{"id":"1c5f9b1e-2b5a-4a4f-8f2b-6d0f6a1a1e10","slug":"peluqueria-lukin","name":"Peluquería Lukin","comuna":"Providencia","owner_email":"ada@peluqueria-lukin.cl","is_published":true,"created_at":"2026-01-15T10:30:00Z"}}}},"401":{"description":"`TOKEN_MISSING`, `TOKEN_EXPIRED` o `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Falta la cabecera `Authorization: Bearer <token>`.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`INSUFFICIENT_ROLE`: la cuenta no tiene el rol SUPERADMIN.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"rol_insuficiente":{"summary":"La sesión es válida pero la cuenta no es SUPERADMIN","value":{"code":"INSUFFICIENT_ROLE","message":"Tu rol no permite realizar esta operación.","details":null,"hint":null,"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: no existe ningún negocio con ese id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_inexistente":{"summary":"No hay ningún negocio con ese identificador","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":null,"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/admin/subscriptions":{"get":{"tags":["admin"],"summary":"Listar las suscripciones de los negocios","description":"Lista las suscripciones SaaS de los negocios, de la más reciente a la más\nantigua. `status` acota por estado exacto (`TRIAL`, `ACTIVE`, `PAST_DUE` o\n`CANCELLED`) y se omite para verlas todas.\n\nEs la vista de **soporte**: con el plan, el estado y las dos fechas que deciden\nla vigencia —`trial_ends_at` para una prueba, `current_period_end` más los días\nde gracia para un recibo impagado— se responde sin abrir una consola por qué la\nficha de un local dejó de aparecer en el marketplace.\n\nSolo de lectura. Una suscripción la mueven su titular (contratando o dándose de\nbaja) y Mercado Pago (cobrando); no hay ninguna ruta de administración que la\nescriba, porque hacerlo dejaría a Lukin creyendo que cobra a un negocio que\nnadie está cobrando.\n\nSe incluyen las `CANCELLED`: la consulta de soporte suele llegar justo después\nde una baja, y esconderla dejaría la lista sin la fila que la explica.","operationId":"admin_list_subscriptions","security":[{"HTTPBearer":[]}],"parameters":[{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/SubscriptionStatus"},{"type":"null"}],"description":"Estado exacto de la suscripción. Se omite para verlas todas.","examples":["PAST_DUE"],"title":"Status"},"description":"Estado exacto de la suscripción. Se omite para verlas todas."},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"Página que se pide, empezando en 1.","default":1,"title":"Page"},"description":"Página que se pide, empezando en 1."},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"Filas por página. Valores mayores que 100 no dan error: se acotan a 100 y la respuesta devuelve el valor aplicado.","default":20,"title":"Page Size"},"description":"Filas por página. Valores mayores que 100 no dan error: se acotan a 100 y la respuesta devuelve el valor aplicado."}],"responses":{"200":{"description":"Página de suscripciones, de la más reciente a la más antigua.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Page_AdminSubscriptionOut_"},"examples":{"una_pagina":{"summary":"Primera página de resultados","value":{"items":[{"business_id":"1c5f9b1e-2b5a-4a4f-8f2b-6d0f6a1a1e10","slug":"peluqueria-lukin","plan":"PREMIUM","status":"PAST_DUE","trial_ends_at":"2026-01-29T13:00:00Z","current_period_end":"2026-08-28T13:00:00Z"}],"total":1,"page":1,"page_size":20}}}}}},"401":{"description":"`TOKEN_MISSING`, `TOKEN_EXPIRED` o `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Falta la cabecera `Authorization: Bearer <token>`.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`INSUFFICIENT_ROLE`: la cuenta no tiene el rol SUPERADMIN.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"rol_insuficiente":{"summary":"La sesión es válida pero la cuenta no es SUPERADMIN","value":{"code":"INSUFFICIENT_ROLE","message":"Tu rol no permite realizar esta operación.","details":null,"hint":null,"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/businesses":{"post":{"tags":["businesses"],"summary":"Crear el perfil comercial de un negocio","description":"Da de alta el perfil comercial de un local y devuelve su ficha completa.\n\nTres cosas ocurren sin que el cliente las pida:\n\n- El **slug** se deriva del nombre (`Barbería Ñuñoa` → `barberia-nunoa`), con\n  sufijo correlativo si ya estaba ocupado, y **no vuelve a cambiar**: es la URL\n  pública del negocio y renombrarlo rompería los enlaces ya compartidos.\n- El **dueño entra en la plantilla** como `StaffMember`, para que un profesional\n  que trabaja solo sea reservable desde el primer minuto.\n- La **ubicación** se guarda como punto geográfico WGS 84; `lat` y `lng` se\n  devuelven derivadas de esa geometría, así que pueden diferir del valor enviado\n  en el último decimal.\n\nEl negocio nace **sin publicar** (`is_published: false`): no aparece en el\nmarketplace hasta que su dueño lo publica con un `PATCH`.","operationId":"businesses_create_business","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessCreate"},"examples":{"barberia":{"summary":"Barbería en Ñuñoa","value":{"name":"Barbería Ñuñoa","description":"Cortes clásicos y afeitado a navaja.","address":"Av. Irarrázaval 1234","comuna":"Ñuñoa","lat":-33.45,"lng":-70.66}}}}},"required":true},"responses":{"201":{"description":"Negocio creado, sin publicar y con su dueño ya dado de alta en la plantilla. El `slug` se deriva del nombre y no vuelve a cambiar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessOut"},"examples":{"sin_publicar":{"summary":"El negocio nace con su slug derivado del nombre y sin publicar","value":{"id":"3f2b9a10-0000-4000-8000-dddddddddddd","slug":"barberia-demo","name":"Barbería Demo","description":"Barbería de barrio con cortes clásicos y afeitado a navaja. Datos de demostración de Lukin.","logo_url":null,"gallery_urls":[],"address":"Avenida Rojas Magallanes 3900, La Florida","comuna":"La Florida","lat":-33.5455,"lng":-70.5545,"timezone":"America/Santiago","is_published":false,"rating_avg":0.0,"rating_count":0,"created_at":"2026-01-15T10:30:00Z","updated_at":"2026-01-15T10:30:00Z"}}}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`INSUFFICIENT_ROLE`: solo una cuenta con rol `OWNER` (o `SUPERADMIN`) puede dar de alta un negocio; un `CLIENT` o un `GUEST` no.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"rol_insuficiente":{"summary":"La sesión es válida pero la cuenta no tiene rol OWNER","value":{"code":"INSUFFICIENT_ROLE","message":"Tu rol no permite realizar esta operación.","details":null,"hint":"Da de alta el negocio desde una cuenta con rol OWNER.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`VALIDATION_ERROR`: algún campo incumple sus límites (`name` 3–120, `address` 3–200, `description` ≤ 1000, `lat` −90..90, `lng` −180..180). `details` nombra el campo exacto en su `loc`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"nombre_corto":{"summary":"`name` tiene menos de 3 caracteres","value":{"code":"VALIDATION_ERROR","message":"Los datos enviados no son válidos.","details":[{"loc":["body","name"],"msg":"String should have at least 3 characters"}],"hint":null,"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"409":{"description":"`SLUG_UNAVAILABLE`: se agotaron los sufijos de desempate para ese nombre. Es un caso de abuso, no de uso normal.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"slug_agotado":{"summary":"Se agotaron los sufijos de desempate para ese nombre","value":{"code":"SLUG_UNAVAILABLE","message":"No se pudo generar una URL pública para ese nombre.","details":null,"hint":"Prueba con un nombre distinto.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"HTTPBearer":[]}]}},"/api/v1/businesses/{business_id}":{"get":{"tags":["businesses"],"summary":"Ver la ficha del negocio","description":"Ficha completa del negocio para su propio equipo (propietario, plantilla activa\ny superadmin).\n\nUn negocio del que no eres miembro —o dado de baja, o inexistente— responde\n`404 BUSINESS_NOT_FOUND`, con el mismo cuerpo en los tres casos: la API no\ndistingue «no existe» de «no es tuyo».","operationId":"businesses_get_business","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessOut"},"examples":{"publicado":{"summary":"La ficha de `barberia-demo`, ya publicada en el marketplace","value":{"id":"3f2b9a10-0000-4000-8000-dddddddddddd","slug":"barberia-demo","name":"Barbería Demo","description":"Barbería de barrio con cortes clásicos y afeitado a navaja. Datos de demostración de Lukin.","logo_url":"/media/businesses/3f2b9a10-0000-4000-8000-dddddddddddd/logo-0c7b1f42.webp","gallery_urls":["/media/businesses/3f2b9a10-0000-4000-8000-dddddddddddd/gallery/2ad95e08.jpg"],"address":"Avenida Rojas Magallanes 3900, La Florida","comuna":"La Florida","lat":-33.5455,"lng":-70.5545,"timezone":"America/Santiago","is_published":true,"rating_avg":5.0,"rating_count":1,"created_at":"2026-01-15T10:30:00Z","updated_at":"2026-03-02T18:20:00Z"}}}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe, está dado de baja o quien pregunta no es miembro. Los tres casos responden **exactamente igual** para no revelar qué identificadores existen.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"patch":{"tags":["businesses"],"summary":"Editar el perfil comercial","description":"Edición parcial del perfil comercial: solo se modifica lo que venga en el\ncuerpo. Reservado al **propietario**; la plantilla recibe `403 OWNER_REQUIRED`.\n\n`lat` y `lng` se envían **juntas o ninguna**: media coordenada movería el local\na un punto arbitrario.\n\nEl `slug` **no** es editable y ni siquiera se declara en el cuerpo. Un `PATCH`\nque lo incluya —por ejemplo reenviando el `BusinessOut` que se acaba de leer—\nresponde `200` y deja el slug intacto, en vez de un `422` que obligaría a cada\ncliente a podar el objeto antes de enviarlo.\n\n## Publicar: `422 BUSINESS_INCOMPLETE`\n\n`is_published: true` solo pasa si el negocio está completo. Si no lo está, la\nrespuesta es **`422` con `code: BUSINESS_INCOMPLETE`**, `details.missing` con las\nclaves **requeridas** que fallan —las mismas de\n`GET /businesses/{business_id}/readiness`, nunca las informativas— y\n`hint: \"Completa horarios, servicios y staff antes de publicar\"`. El `PATCH`\nrechazado no aplica **ningún** otro campo del cuerpo.\n\nConsulta primero `GET /businesses/{business_id}/readiness` para saber qué falta.\n\n## Publicar: `402 SUBSCRIPTION_INACTIVE`\n\nEstar completo no basta: publicarse es lo que **vende**, y exige además la\nsuscripción SaaS al día. Un negocio completo pero sin plan vigente recibe\n**`402` con `code: SUBSCRIPTION_INACTIVE`** y un `hint` que lleva a la pantalla\nde suscripción. Es `402` y no `403` a propósito: quien lo pide es el dueño y\ntiene todo el derecho, lo que falta es el pago.\n\nEl orden entre las dos puertas es fijo —**completitud (`422`) y después\nsuscripción (`402`)**—, de modo que a un local al que aún le faltan horarios se\nle dice qué le falta antes de pedirle que pague.\n\nRetirar el negocio del marketplace (`is_published: false`) **no se bloquea\nnunca** —ni por completitud ni por suscripción—, y desactivar recursos después\nde publicar tampoco despublica solo: el\nnegocio sigue visible y la checklist vuelve a `is_ready: false` como aviso.","operationId":"businesses_update_business","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessUpdate"},"examples":{"publicar":{"summary":"Publicar el negocio en el marketplace","value":{"is_published":true}},"mudanza":{"summary":"Cambiar dirección y coordenadas (siempre juntas)","value":{"address":"Av. Irarrázaval 4321","lat":-33.4552,"lng":-70.6001}}}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessOut"},"examples":{"publicado":{"summary":"El negocio queda publicado; el `slug` sigue siendo el de siempre","value":{"id":"3f2b9a10-0000-4000-8000-dddddddddddd","slug":"barberia-demo","name":"Barbería Demo","description":"Barbería de barrio con cortes clásicos y afeitado a navaja. Datos de demostración de Lukin.","logo_url":"/media/businesses/3f2b9a10-0000-4000-8000-dddddddddddd/logo-0c7b1f42.webp","gallery_urls":["/media/businesses/3f2b9a10-0000-4000-8000-dddddddddddd/gallery/2ad95e08.jpg"],"address":"Avenida Rojas Magallanes 3900, La Florida","comuna":"La Florida","lat":-33.5455,"lng":-70.5545,"timezone":"America/Santiago","is_published":true,"rating_avg":5.0,"rating_count":1,"created_at":"2026-01-15T10:30:00Z","updated_at":"2026-03-02T18:20:00Z"}}}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe, está dado de baja o quien pregunta no es miembro. Los tres casos responden **exactamente igual** para no revelar qué identificadores existen.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`OWNER_REQUIRED`: eres miembro del negocio, pero esta operación es exclusiva de su propietario.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"solo_propietario":{"summary":"La sesión es de alguien de la plantilla y la operación es del dueño","value":{"code":"OWNER_REQUIRED","message":"Esta operación es exclusiva del propietario del negocio.","details":null,"hint":"Pide el cambio a la persona propietaria del negocio.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"402":{"description":"`SUBSCRIPTION_INACTIVE`: se pidió `is_published: true` y la suscripción SaaS del negocio no está vigente (F5-T09). No es un problema de permisos —quien lo pide es el dueño—, sino de pago: `hint` lleva a la pantalla de suscripción. Se comprueba **después** de la completitud, así que un negocio incompleto ve antes su `422`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_suscripcion":{"summary":"El negocio está completo, pero su plan SaaS no está vigente","value":{"code":"SUBSCRIPTION_INACTIVE","message":"La suscripción de este negocio no está vigente.","details":null,"hint":"Contrata o renueva el plan en /api/v1/businesses/{business_id}/subscription.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`VALIDATION_ERROR`: un campo incumple sus límites, o llegó `lat` sin `lng` (o al revés): media coordenada no describe ninguna ubicación.\n\n`BUSINESS_INCOMPLETE`: se pidió `is_published: true` y al negocio le faltan requisitos para venderse. `details.missing` enumera las claves **requeridas** que fallan —las mismas de `GET /businesses/{business_id}/readiness`, nunca las informativas— y `hint` dice qué completar. Despublicar (`is_published: false`) no se bloquea jamás.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"incompleto":{"summary":"Se pidió publicar y al negocio le faltan requisitos","value":{"code":"BUSINESS_INCOMPLETE","message":"El negocio no está listo para publicarse.","details":{"missing":["has_hours","has_staff_schedule"]},"hint":"Completa horarios, servicios y staff antes de publicar","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"media_coordenada":{"summary":"Llegó `lat` sin `lng`: media coordenada no describe ninguna ubicación","value":{"code":"VALIDATION_ERROR","message":"Los datos enviados no son válidos.","details":[{"loc":["body","lng"],"msg":"lat y lng se envían juntas o ninguna"}],"hint":null,"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["businesses"],"summary":"Dar de baja el negocio","description":"Da de baja el negocio. El borrado es **lógico**: la fila conserva su historial\n—reservas, pagos y reseñas siguen apuntando a ella— pero queda marcada con\n`deleted_at`, se despublica y desaparece de toda la API, incluida\n`GET /api/v1/me/businesses`. Un `GET` posterior responde `404`.\n\nReservado al propietario del negocio.","operationId":"businesses_delete_business","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"responses":{"204":{"description":"Negocio dado de baja. Sin cuerpo."},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe, está dado de baja o quien pregunta no es miembro. Los tres casos responden **exactamente igual** para no revelar qué identificadores existen.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`OWNER_REQUIRED`: eres miembro del negocio, pero esta operación es exclusiva de su propietario.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"solo_propietario":{"summary":"La sesión es de alguien de la plantilla y la operación es del dueño","value":{"code":"OWNER_REQUIRED","message":"Esta operación es exclusiva del propietario del negocio.","details":null,"hint":"Pide el cambio a la persona propietaria del negocio.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/businesses/{business_id}/readiness":{"get":{"tags":["businesses"],"summary":"Qué le falta al negocio para poder publicarse","description":"Checklist de completitud del negocio: qué le falta para poder publicarse y\nempezar a recibir reservas.\n\nCada entrada trae una `key` **estable** —el catálogo es cerrado y el panel la usa\npara enlazar con la pantalla que la resuelve—, si se cumple (`ok`) y si su\nincumplimiento **impide publicar** (`required`).\n\n- **Requeridas** (bloquean `is_published: true`): `has_hours`,\n  `has_active_service`, `has_active_staff`, `has_staff_schedule` y\n  `has_staff_service_assignment`. Las dos últimas son compuestas: un horario o\n  una asignación solo cuentan si son de alguien **activo** de la plantilla, y la\n  asignación además exige un servicio **activo**.\n- **Informativas** (`required: false`, nunca bloquean): `has_logo`,\n  `has_description` y `has_merchant_credential`.\n\n`has_merchant_credential` **solo aparece** cuando el catálogo tiene algún\nservicio activo con `requires_prepayment: true`. Es cuando la falta de\ncredencial de Mercado Pago tiene consecuencias: el cobro en línea se salta en\nsilencio y la reserva se confirma sin prepago.\n\n`is_ready` resume únicamente las verificaciones requeridas. Publicar **no**\ncongela el resultado: si después se desactiva el catálogo, el negocio sigue\npublicado y esta respuesta vuelve a `is_ready: false` como aviso.\n\nLo lee cualquier miembro del negocio (`STAFF` incluido), porque es la lista de\ntareas del panel y no una operación de propietario.","operationId":"businesses_get_business_readiness","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"responses":{"200":{"description":"Checklist vigente. `checks` trae entre siete y ocho entradas: la octava (`has_merchant_credential`) solo si hay prepago en el catálogo.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReadinessOut"},"example":{"is_ready":false,"checks":[{"key":"has_hours","ok":false,"required":true,"message":"Define el horario de atención de tu local"},{"key":"has_active_service","ok":true,"required":true,"message":"Publica al menos un servicio activo en tu catálogo"},{"key":"has_active_staff","ok":true,"required":true,"message":"Suma al menos una persona activa a tu equipo"},{"key":"has_staff_schedule","ok":true,"required":true,"message":"Asigna un horario semanal a alguien de tu equipo"},{"key":"has_staff_service_assignment","ok":true,"required":true,"message":"Indica qué servicios presta cada persona de tu equipo"},{"key":"has_logo","ok":false,"required":false,"message":"Sube el logo de tu negocio para que la ficha se vea completa"},{"key":"has_description","ok":false,"required":false,"message":"Escribe una descripción que cuente a qué se dedica tu negocio"},{"key":"has_merchant_credential","ok":false,"required":false,"message":"Conecta tu credencial de Mercado Pago para cobrar en línea"}]}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe, está dado de baja o quien pregunta no es miembro. Los tres casos responden **exactamente igual** para no revelar qué identificadores existen.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/me/businesses":{"get":{"tags":["businesses"],"summary":"Mis negocios y mi rol en cada uno","description":"Negocios en los que el usuario autenticado participa, con la relación que tiene\ncon cada uno: `OWNER` si es su propietario y `STAFF` si figura activo en la\nplantilla. Es la pantalla de «elige negocio» del panel, y lo único que se puede\npedir sin conocer todavía ningún `business_id`.\n\nLos negocios dados de baja no aparecen. Quien no participa en ninguno recibe una\nlista vacía, nunca un `403`.","operationId":"businesses_list_my_businesses","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/MyBusinessOut"},"type":"array","title":"Response Businesses List My Businesses"},"examples":{"dueno_y_trabajador":{"summary":"Dos negocios con los dos papeles posibles: dueño de uno, plantilla del otro","value":[{"id":"3f2b9a10-0000-4000-8000-dddddddddddd","slug":"barberia-demo","name":"Barbería Demo","description":"Barbería de barrio con cortes clásicos y afeitado a navaja. Datos de demostración de Lukin.","logo_url":"/media/businesses/3f2b9a10-0000-4000-8000-dddddddddddd/logo-0c7b1f42.webp","gallery_urls":["/media/businesses/3f2b9a10-0000-4000-8000-dddddddddddd/gallery/2ad95e08.jpg"],"address":"Avenida Rojas Magallanes 3900, La Florida","comuna":"La Florida","lat":-33.5455,"lng":-70.5545,"timezone":"America/Santiago","is_published":true,"rating_avg":5.0,"rating_count":1,"created_at":"2026-01-15T10:30:00Z","updated_at":"2026-03-02T18:20:00Z","membership_role":"OWNER"},{"id":"3f2b9a10-0000-4000-8000-eeeeeeeeeeee","slug":"spa-demo","name":"Spa Demo","description":"Centro de masajes y tratamientos faciales en pleno Providencia. Datos de demostración de Lukin.","logo_url":null,"gallery_urls":[],"address":"Avenida Providencia 2200, Providencia","comuna":"Providencia","lat":-33.4314,"lng":-70.6093,"timezone":"America/Santiago","is_published":true,"rating_avg":0.0,"rating_count":0,"created_at":"2026-01-15T10:31:00Z","updated_at":"2026-02-20T09:05:00Z","membership_role":"STAFF"}]},"sin_negocios":{"summary":"Quien no participa en ninguno recibe una lista vacía, nunca un 403","value":[]}}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"HTTPBearer":[]}]}},"/api/v1/businesses/{business_id}/logo":{"post":{"tags":["businesses"],"summary":"Subir o sustituir el logo del negocio","description":"Sustituye el logo del negocio por la imagen enviada en el campo `file` de un\ncuerpo `multipart/form-data`.\n\n- **Formatos admitidos: PNG, JPEG y WEBP**, decididos abriendo los bytes. Ni el\n  `Content-Type` que declara el cliente ni la extensión del nombre cuentan para\n  nada: un `foto.png` que por dentro es un SVG responde `415`.\n- **Tamaño máximo: 2 MB.** Un archivo mayor responde `413` y la API deja de\n  leerlo en cuanto sabe que no cabe.\n- La extensión de la URL resultante sale del formato **detectado**, así que un\n  WEBP subido como `foto.png` se publica como `.webp`.\n\nEl logo anterior **se borra del almacenamiento**: cada subida estrena URL, de\nmodo que ningún navegador ni CDN sigue sirviendo la imagen vieja desde su caché.","operationId":"businesses_upload_logo","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_businesses_upload_logo"}}}},"responses":{"200":{"description":"Logo actualizado. El anterior ya no existe.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LogoOut"},"examples":{"logo_nuevo":{"summary":"Cada subida estrena URL: ningún navegador sigue sirviendo la anterior","value":{"logo_url":"/media/businesses/3f2b9a10-0000-4000-8000-dddddddddddd/logo-0c7b1f42.webp"}}}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"example":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","hint":"Envía la cabecera `Authorization: Bearer <access_token>`.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"},"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"`OWNER_REQUIRED`: eres miembro del negocio, pero la identidad comercial (logo y galería) la gestiona su propietario.","content":{"application/json":{"example":{"code":"OWNER_REQUIRED","message":"Esta operación es exclusiva del propietario del negocio.","hint":"Pide a la persona propietaria que actualice las imágenes.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"},"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe, está dado de baja o quien pregunta no es miembro. Los tres casos responden **exactamente igual**.","content":{"application/json":{"example":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","hint":"Comprueba el `business_id` de la ruta.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"},"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"415":{"description":"`MEDIA_UNSUPPORTED_TYPE`: el contenido real del archivo no es PNG, JPEG ni WEBP. Se decide abriendo los bytes con Pillow, no leyendo el `Content-Type` ni la extensión del nombre: un SVG, un GIF, un PDF, un texto plano o una bomba de descompresión se rechazan aunque se llamen `foto.png`.","content":{"application/json":{"example":{"code":"MEDIA_UNSUPPORTED_TYPE","message":"El archivo no es una imagen PNG, JPEG o WEBP válida.","details":{"detected_format":"GIF","supported_formats":["JPEG","PNG","WEBP"]},"hint":"Convierte la imagen a PNG, JPEG o WEBP antes de subirla.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"},"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"413":{"description":"`MEDIA_TOO_LARGE`: el archivo supera los 2 MB. La API corta la lectura en cuanto aparece el primer byte sobrante, así que el cuerpo no llega a subirse entero.","content":{"application/json":{"example":{"code":"MEDIA_TOO_LARGE","message":"El archivo supera el máximo de 2 MB.","details":{"max_bytes":2097152},"hint":"Comprime la imagen o súbela con menos resolución.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"},"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/businesses/{business_id}/gallery":{"post":{"tags":["businesses"],"summary":"Añadir una foto a la galería del negocio","description":"Añade una foto a la galería del negocio (campo `file` de un cuerpo\n`multipart/form-data`) y devuelve la galería completa ya actualizada.\n\n- **Formatos admitidos: PNG, JPEG y WEBP**, verificados abriendo los bytes.\n- **Tamaño máximo: 5 MB** por foto → `413` si se supera.\n- **Máximo 12 fotos** por negocio. La foto número\n  13 responde `422 GALLERY_FULL` y **no se sube**: el\n  límite se comprueba antes de leer el archivo.\n\nEl orden de `gallery_urls` es el de subida, y es el que consume la ficha\npública del negocio.","operationId":"businesses_add_gallery_item","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_businesses_add_gallery_item"}}}},"responses":{"201":{"description":"Foto añadida a la galería.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GalleryItemOut"},"examples":{"segunda_foto":{"summary":"La foto subida y la galería completa; la última posición es la de esta respuesta","value":{"url":"/media/businesses/3f2b9a10-0000-4000-8000-dddddddddddd/gallery/7c31ba04.webp","gallery_urls":["/media/businesses/3f2b9a10-0000-4000-8000-dddddddddddd/gallery/2ad95e08.jpg","/media/businesses/3f2b9a10-0000-4000-8000-dddddddddddd/gallery/7c31ba04.webp"]}}}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"example":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","hint":"Envía la cabecera `Authorization: Bearer <access_token>`.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"},"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"`OWNER_REQUIRED`: eres miembro del negocio, pero la identidad comercial (logo y galería) la gestiona su propietario.","content":{"application/json":{"example":{"code":"OWNER_REQUIRED","message":"Esta operación es exclusiva del propietario del negocio.","hint":"Pide a la persona propietaria que actualice las imágenes.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"},"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe, está dado de baja o quien pregunta no es miembro. Los tres casos responden **exactamente igual**.","content":{"application/json":{"example":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","hint":"Comprueba el `business_id` de la ruta.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"},"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"415":{"description":"`MEDIA_UNSUPPORTED_TYPE`: el contenido real del archivo no es PNG, JPEG ni WEBP. Se decide abriendo los bytes con Pillow, no leyendo el `Content-Type` ni la extensión del nombre: un SVG, un GIF, un PDF, un texto plano o una bomba de descompresión se rechazan aunque se llamen `foto.png`.","content":{"application/json":{"example":{"code":"MEDIA_UNSUPPORTED_TYPE","message":"El archivo no es una imagen PNG, JPEG o WEBP válida.","details":{"detected_format":"GIF","supported_formats":["JPEG","PNG","WEBP"]},"hint":"Convierte la imagen a PNG, JPEG o WEBP antes de subirla.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"},"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"413":{"description":"`MEDIA_TOO_LARGE`: el archivo supera los 5 MB. La API corta la lectura en cuanto aparece el primer byte sobrante, así que el cuerpo no llega a subirse entero.","content":{"application/json":{"example":{"code":"MEDIA_TOO_LARGE","message":"El archivo supera el máximo de 5 MB.","details":{"max_bytes":5242880},"hint":"Comprime la imagen o súbela con menos resolución.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"},"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"`GALLERY_FULL`: la galería ya tiene 12 fotos. La comprobación es lo primero que hace la ruta, así que la foto rechazada no llega a subirse al almacenamiento.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["businesses"],"summary":"Eliminar una foto de la galería del negocio","description":"Quita una foto de la galería. La URL se envía en el parámetro de consulta `url`\ny tiene que ser **una de las que devuelve `gallery_urls`**: cualquier otra\n—inventada, ya borrada o perteneciente a otro negocio— responde\n`404 GALLERY_ITEM_NOT_FOUND`, sin revelar si existe en algún sitio.\n\nSe elimina tanto de la lista como del almacenamiento, y la respuesta no lleva\ncuerpo.","operationId":"businesses_delete_gallery_item","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}},{"name":"url","in":"query","required":true,"schema":{"type":"string","minLength":1,"maxLength":500,"description":"URL exacta de la foto, tal y como aparece en `gallery_urls`.","title":"Url"},"description":"URL exacta de la foto, tal y como aparece en `gallery_urls`."}],"responses":{"204":{"description":"Foto eliminada. Sin cuerpo."},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"example":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","hint":"Envía la cabecera `Authorization: Bearer <access_token>`.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"},"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"`OWNER_REQUIRED`: eres miembro del negocio, pero la identidad comercial (logo y galería) la gestiona su propietario.","content":{"application/json":{"example":{"code":"OWNER_REQUIRED","message":"Esta operación es exclusiva del propietario del negocio.","hint":"Pide a la persona propietaria que actualice las imágenes.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"},"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"`GALLERY_ITEM_NOT_FOUND`: esa URL no está en la galería **de este** negocio. También es la respuesta a la URL de la galería de otro local: nadie borra fotos del vecino ni averigua cuáles tiene.","content":{"application/json":{"example":{"code":"GALLERY_ITEM_NOT_FOUND","message":"Esa imagen no pertenece a la galería del negocio.","hint":"Envía una de las URLs que devuelve `gallery_urls`.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"},"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/businesses/{business_id}/hours":{"put":{"tags":["businesses"],"summary":"Reemplazar la matriz de horas operativas del local","description":"Reemplaza **toda** la matriz horaria del local y devuelve la que queda vigente,\nordenada por `(weekday, opens_at)`.\n\nLa matriz es una **lista de tramos**, no una celda por día: el mismo `weekday`\nadmite varios (jornada partida de 09–13 y 14–18). `weekday` va de **0 = lunes**\na 6 = domingo, igual que `datetime.date.weekday()`, y las horas son **locales\ndel negocio** (`timezone` de su ficha), no UTC.\n\nEl reemplazo es total y atómico: lo que no viaje en el cuerpo deja de existir, y\nuna **lista vacía cierra el local toda la semana**. Si el cuerpo no valida no se\ntoca nada y la matriz anterior sigue intacta.\n\nReglas que se validan antes de guardar:\n\n- `opens_at < closes_at` en cada tramo → si no, `422 HOURS_INVALID_RANGE`.\n  Ningún tramo cruza la medianoche: una jornada nocturna se parte en dos días.\n- Ningún par de tramos del mismo día se pisa ni se repite → si no,\n  `422 HOURS_OVERLAP`, con el día y los tramos culpables en `details`. Dos\n  tramos que se **tocan** (13:00 y 13:00) son una jornada partida válida.\n\n```json\n{\n  \"code\": \"HOURS_OVERLAP\",\n  \"message\": \"Hay tramos que se solapan en el mismo día.\",\n  \"details\": {\n    \"weekday\": 0,\n    \"conflicts\": [[\"09:00:00\", \"13:00:00\"], [\"12:30:00\", \"15:00:00\"]]\n  },\n  \"hint\": \"Junta los tramos que se pisan o corrige sus horas.\",\n  \"request_id\": \"b0f1…\"\n}\n```\n\nCambiar el horario no modifica los horarios ya guardados del equipo: la\ndisponibilidad interseca ambas matrices, así que nunca se ofrecen citas fuera\ndel local.","operationId":"businesses_replace_business_hours","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HoursMatrix"},"examples":{"jornada_partida":{"summary":"Lunes con jornada partida y sábado corrido","description":"El lunes abre de 09:00 a 13:00 y de 14:00 a 18:00; el sábado, de 10:00 a 14:00. El resto de los días el local está cerrado.","value":[{"weekday":0,"opens_at":"09:00:00","closes_at":"13:00:00"},{"weekday":0,"opens_at":"14:00:00","closes_at":"18:00:00"},{"weekday":5,"opens_at":"10:00:00","closes_at":"14:00:00"}]},"cerrado":{"summary":"Cerrado toda la semana","description":"La lista vacía borra la matriz: el local no abre ningún día.","value":[]}}}}},"responses":{"200":{"description":"Matriz vigente, ordenada por `(weekday, opens_at)`.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/HoursEntry"},"title":"Response Businesses Replace Business Hours"},"examples":{"jornada_partida":{"summary":"Lunes con jornada partida y sábado corrido","description":"El lunes abre de 09:00 a 13:00 y de 14:00 a 18:00; el sábado, de 10:00 a 14:00. El resto de los días el local está cerrado.","value":[{"weekday":0,"opens_at":"09:00:00","closes_at":"13:00:00"},{"weekday":0,"opens_at":"14:00:00","closes_at":"18:00:00"},{"weekday":5,"opens_at":"10:00:00","closes_at":"14:00:00"}]},"cerrado":{"summary":"Cerrado toda la semana","description":"La lista vacía borra la matriz: el local no abre ningún día.","value":[]}}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe, está dado de baja o quien pregunta no es miembro. Los tres casos responden igual.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`OWNER_REQUIRED`: el horario del local lo fija su propietario.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"solo_propietario":{"summary":"La sesión es de alguien de la plantilla y la operación es del dueño","value":{"code":"OWNER_REQUIRED","message":"Esta operación es exclusiva del propietario del negocio.","details":null,"hint":"Pide el cambio a la persona propietaria del negocio.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`HOURS_INVALID_RANGE` (un tramo abre después de cerrar), `HOURS_OVERLAP` (dos tramos del mismo día se pisan) o `VALIDATION_ERROR` (un `weekday` fuera de 0–6 o una hora imposible).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"solape":{"summary":"Dos tramos del mismo día se pisan","value":{"code":"HOURS_OVERLAP","message":"Hay tramos que se solapan en el mismo día.","details":{"weekday":0,"conflicts":[["09:00:00","13:00:00"],["12:30:00","15:00:00"]]},"hint":"Junta los tramos que se pisan o corrige sus horas.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"rango_imposible":{"summary":"Un tramo abre después de cerrar","value":{"code":"HOURS_INVALID_RANGE","message":"El tramo debe abrir antes de cerrar.","details":{"weekday":2,"opens_at":"18:00:00","closes_at":"09:00:00"},"hint":"Ningún tramo cruza la medianoche: parte la jornada nocturna en dos días.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"get":{"tags":["businesses"],"summary":"Ver la matriz de horas operativas del local","description":"Matriz horaria vigente del local, ordenada por `(weekday, opens_at)`.\n\nLa matriz es una **lista de tramos**, no una celda por día: el mismo `weekday`\nadmite varios (jornada partida de 09–13 y 14–18). `weekday` va de **0 = lunes**\na 6 = domingo, igual que `datetime.date.weekday()`, y las horas son **locales\ndel negocio** (`timezone` de su ficha), no UTC.\n\nUn día sin tramos es un día **cerrado**, y una lista vacía es un local cerrado\ntoda la semana. La lee cualquier miembro del negocio: el trabajador necesita\nsaber cuándo abre el local para entender los límites de su propio turno.","operationId":"businesses_get_business_hours","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"responses":{"200":{"description":"Matriz vigente, ordenada por `(weekday, opens_at)`.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/HoursEntry"},"title":"Response Businesses Get Business Hours"},"examples":{"jornada_partida":{"summary":"Lunes con jornada partida y sábado corrido","description":"El lunes abre de 09:00 a 13:00 y de 14:00 a 18:00; el sábado, de 10:00 a 14:00. El resto de los días el local está cerrado.","value":[{"weekday":0,"opens_at":"09:00:00","closes_at":"13:00:00"},{"weekday":0,"opens_at":"14:00:00","closes_at":"18:00:00"},{"weekday":5,"opens_at":"10:00:00","closes_at":"14:00:00"}]},"cerrado":{"summary":"Cerrado toda la semana","description":"La lista vacía borra la matriz: el local no abre ningún día.","value":[]}}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe, está dado de baja o quien pregunta no es miembro. Los tres casos responden igual.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/businesses/{business_id}/availability":{"get":{"tags":["businesses"],"summary":"Consultar la disponibilidad interna del negocio","description":"Huecos **agendables desde el panel** para un servicio en una ventana de días.\nEs la fuente de disponibilidad del dashboard B2B (UI-01.01, F6-T17) y la única\nque este debe usar: la ruta pública `GET\n/api/v1/public/businesses/{slug}/availability` responde `404` mientras el local\nno esté publicado y esconde los huecos inmediatos.\n\nTres diferencias con la ruta pública, y ninguna otra:\n\n* **No exige negocio publicado.** Basta con ser miembro: un local recién creado\n  (`is_published=false`, sin suscripción activa) responde `200` igual que uno\n  publicado.\n* **No aplica la antelación mínima del negocio** (`min_advance_minutes`), de\n  modo que la agenda ofrece exactamente los mismos horizontes que acepta `POST\n  /api/v1/businesses/{business_id}/bookings`, incluidos los que empiezan dentro\n  de un rato.\n* **No se cachea**: la respuesta se sirve con\n  `Cache-Control: private, no-store` porque depende de quién pregunta.\n\nEl cuerpo es **el mismo contrato** que la ruta pública. Cada `starts_at` y cada\n`ends_at` viajan en **hora local del negocio con su offset**\n(`2026-03-10T09:00:00-03:00`) y `timezone` dice cuál es esa zona; el offset\ncambia con el horario de verano y no debe deducirse de una respuesta anterior.\nLa misma hora puede aparecer varias veces con distinto `staff_member_id`: son\ndos personas libres a esa hora. Una lista de `slots` vacía es una respuesta\nlegítima —ese día no queda nada, el local no abre o nadie presta el servicio— y\nno un error.\n\n**Alcance por rol.** Con membresía `STAFF` la respuesta se acota siempre a la\npropia plaza, se envíe `staff_id` o no; pedir la de otra persona del equipo\nresponde `403 STAFF_SCOPE`. El propietario y el superadministrador filtran por\ncualquier plaza u omiten el parámetro para ver a toda la plantilla.\n\nLa ventana admite como mucho **31 días**, contando `date_from` y\n`date_to`. Sin `date_to`, se calcula únicamente `date_from`.","operationId":"businesses_get_business_availability","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}},{"name":"service_id","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"Servicio que se quiere agendar. Su duración define el largo de cada hueco. Sale de `GET /api/v1/businesses/{business_id}/services`.","title":"Service Id"},"description":"Servicio que se quiere agendar. Su duración define el largo de cada hueco. Sale de `GET /api/v1/businesses/{business_id}/services`.","examples":{"catalogo":{"summary":"Un servicio activo del catálogo","description":"Un servicio retirado (`is_active=false`) o de otro local responde `404 SERVICE_NOT_FOUND`.","value":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb"}}},{"name":"date_from","in":"query","required":true,"schema":{"type":"string","format":"date","description":"Primer día de la ventana (`YYYY-MM-DD`), en hora local del negocio.","title":"Date From"},"description":"Primer día de la ventana (`YYYY-MM-DD`), en hora local del negocio.","examples":{"un_dia":{"summary":"Un solo día","description":"Sin `date_to`, se calcula únicamente este día.","value":"2026-03-10"}}},{"name":"date_to","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Último día de la ventana, incluido. Por defecto, `date_from`. Como mucho 31 días contando ambos extremos.","title":"Date To"},"description":"Último día de la ventana, incluido. Por defecto, `date_from`. Como mucho 31 días contando ambos extremos.","examples":{"una_semana":{"summary":"Una semana","description":"Siete días contando `date_from`.","value":"2026-03-16"}}},{"name":"staff_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"description":"Acota la respuesta a una sola plaza del equipo. **Quien tiene membresía `STAFF` solo puede consultar la suya**: se le fuerza aunque omita el parámetro, y pedir la de otra persona responde `403 STAFF_SCOPE`. El propietario filtra por quien quiera.","title":"Staff Id"},"description":"Acota la respuesta a una sola plaza del equipo. **Quien tiene membresía `STAFF` solo puede consultar la suya**: se le fuerza aunque omita el parámetro, y pedir la de otra persona responde `403 STAFF_SCOPE`. El propietario filtra por quien quiera.","examples":{"todo_el_equipo":{"summary":"Toda la plantilla","description":"Omitiendo el parámetro se devuelven los huecos de todas las personas que prestan el servicio (solo para el propietario)."},"una_persona":{"summary":"Una plaza concreta","description":"Sale de `GET /api/v1/businesses/{business_id}/services/{service_id}/staff`.","value":"3f2b9a10-0000-4000-8000-cccccccccccc"}}}],"responses":{"200":{"description":"Huecos agendables en la ventana pedida, ordenados por instante y, a igualdad de hora, por plaza. El negocio no necesita estar publicado.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AvailabilityResponse"},"example":{"timezone":"America/Santiago","service_id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","date_from":"2026-03-10","date_to":"2026-03-10","slots":[{"starts_at":"2026-03-10T09:00:00-03:00","ends_at":"2026-03-10T09:30:00-03:00","staff_member_id":"3f2b9a10-0000-4000-8000-cccccccccccc","staff_name":"Ana"},{"starts_at":"2026-03-10T09:00:00-03:00","ends_at":"2026-03-10T09:30:00-03:00","staff_member_id":"3f2b9a10-0000-4000-8000-dddddddddddd","staff_name":"Benjamín"},{"starts_at":"2026-03-10T09:15:00-03:00","ends_at":"2026-03-10T09:45:00-03:00","staff_member_id":"3f2b9a10-0000-4000-8000-cccccccccccc","staff_name":"Ana"}]}}},"headers":{"Cache-Control":{"description":"Respuesta privada: ninguna caché puede guardarla.","schema":{"type":"string","example":"private, no-store"}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`STAFF_SCOPE`: eres miembro del negocio con membresía `STAFF` y pediste la disponibilidad de otra persona del equipo. La tuya sí puedes consultarla.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"agenda_ajena":{"summary":"La membresía es `STAFF` y se pidió la disponibilidad de otra plaza","value":{"code":"STAFF_SCOPE","message":"Solo puedes consultar tu propia agenda.","details":null,"hint":"Omite `staff_id`: se te acota automáticamente a tu plaza.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe, está dado de baja o quien pregunta no es miembro. Los tres casos responden igual (ADR-014); estar sin publicar **no** es uno de ellos.\n\n`SERVICE_NOT_FOUND`: el servicio no existe, está retirado del catálogo o pertenece a otro local.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`DATE_RANGE_INVALID`: `date_to` es anterior a `date_from`.\n\n`DATE_RANGE_TOO_LARGE`: la ventana supera los 31 días; `details` trae `max_days` y `requested_days`.\n\n`VALIDATION_ERROR`: algún parámetro no tiene el formato esperado.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"ventana_larga":{"summary":"La ventana pedida supera los 31 días","value":{"code":"DATE_RANGE_TOO_LARGE","message":"El rango de fechas solicitado es demasiado amplio.","details":{"max_days":31,"requested_days":60},"hint":"Pide como mucho 31 días por consulta.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/businesses/{business_id}/metrics":{"get":{"tags":["businesses"],"summary":"Consultar las métricas del negocio","description":"El resumen del local en una ventana de días: cuántas citas, cuánto se ha\ncobrado, cuánto se ha comprometido, cuánta agenda está ocupada, cuánta gente no\naparece, qué se vende y quién trabaja. Es la portada del dashboard B2B\n(UI-01.01).\n\n**Es del propietario.** Un miembro con membresía `STAFF` recibe\n`403 OWNER_REQUIRED`: el informe incluye los ingresos del local y la ocupación\nde cada persona del equipo.\n\n**Los días son días del negocio.** La ventana `[from, to]` se cuenta en el\ncalendario local (`range.timezone` dice cuál es), así que una cita de las 23:00\nen Santiago pertenece a su día chileno y no al día UTC siguiente. Sin `from` ni\n`to`, el informe cubre los últimos **30 días** contando hoy;\nlos dos extremos son independientes, así que enviar solo uno completa el otro.\nLa ventana admite como mucho **366 días**, un año bisiesto.\n\n**Dos cifras de dinero, y no son la misma.** `revenue_clp` es **caja**: los\npagos `APPROVED` cuyo `paid_at` cae en la ventana. `booked_value_clp` es\n**compromiso**: lo pactado en las citas `CONFIRMED` y `PAID` del periodo, se\nhaya cobrado o no. Un local que no usa prepago tiene un `revenue_clp` de cero y\nun `booked_value_clp` que es su facturación real.\n\n**Las dos tasas van en `[0, 1]`**, nunca en porcentaje. `occupancy_rate` son\nminutos vendidos entre minutos que el equipo podía atender (horario del local ∩\nturnos, menos bloqueos y colaciones); `no_show_rate` son las ausencias entre las\ncitas **que ya han empezado**, porque de las futuras todavía no se sabe nada.\n\n**Nada de esto tiene agujeros.** `bookings_by_status` trae siempre las cinco\nclaves, `bookings_per_day` un elemento por cada día del periodo —con `0` donde\nno hubo citas— y `occupancy_by_staff` una fila por cada persona activa de la\nplantilla, aunque no tuviera ni una cita. `top_services` trae como mucho\n5 elementos, ordenados de más a menos citas.","operationId":"businesses_get_business_metrics","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}},{"name":"from","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Primer día del informe (`YYYY-MM-DD`), en hora local del negocio. Omitido, 30 días antes de `to` contando ambos extremos.","title":"From"},"description":"Primer día del informe (`YYYY-MM-DD`), en hora local del negocio. Omitido, 30 días antes de `to` contando ambos extremos.","examples":{"un_mes":{"summary":"Un mes natural","description":"Con `to` en el último día del mes.","value":"2026-03-01"}}},{"name":"to","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Último día del informe, **incluido**. Omitido, hoy en hora local del negocio. Como mucho 366 días contando ambos extremos.","title":"To"},"description":"Último día del informe, **incluido**. Omitido, hoy en hora local del negocio. Como mucho 366 días contando ambos extremos.","examples":{"un_mes":{"summary":"Un mes natural","description":"El último día del mes que se está mirando.","value":"2026-03-31"}}}],"responses":{"200":{"description":"El resumen del periodo. Un negocio sin una sola cita responde `200` con ceros y listas vacías: «no hubo nada» es un dato, no un error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MetricsOut"},"example":{"range":{"date_from":"2026-03-09","date_to":"2026-03-15","timezone":"America/Santiago"},"bookings_total":24,"bookings_by_status":{"PENDING":2,"CONFIRMED":12,"PAID":7,"CANCELLED":2,"NO_SHOW":1},"revenue_clp":105000,"booked_value_clp":285000,"occupancy_rate":0.42,"no_show_rate":0.05,"top_services":[{"service_id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","name":"Corte de pelo","bookings":11,"revenue_clp":165000},{"service_id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbc","name":"Color","bookings":8,"revenue_clp":120000}],"bookings_per_day":[{"date":"2026-03-09","count":5},{"date":"2026-03-10","count":4},{"date":"2026-03-11","count":6},{"date":"2026-03-12","count":3},{"date":"2026-03-13","count":6},{"date":"2026-03-14","count":0},{"date":"2026-03-15","count":0}],"occupancy_by_staff":[{"staff_member_id":"3f2b9a10-0000-4000-8000-cccccccccccc","display_name":"Ana","booked_minutes":1200,"available_minutes":2400,"rate":0.5},{"staff_member_id":"3f2b9a10-0000-4000-8000-dddddddddddd","display_name":"Benjamín","booked_minutes":816,"available_minutes":2400,"rate":0.34}]}}},"headers":{"Cache-Control":{"description":"Respuesta privada: ninguna caché puede guardarla.","schema":{"type":"string","example":"private, no-store"}}}},"401":{"description":"`TOKEN_MISSING`, `TOKEN_EXPIRED`, `TOKEN_INVALID` o `USER_INACTIVE`: no hay sesión utilizable. Solo `TOKEN_EXPIRED` justifica renovar y reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"402":{"description":"`PLAN_UPGRADE_REQUIRED`: el tramo contratado no incluye el informe de métricas —hoy, solo `PREMIUM` lo trae—. `details.required_plan` dice el tramo más barato que sí lo incluye y `hint` lleva a Ajustes → Suscripción. Es una petición, no un aviso que se degrada: se corta aquí y no se calcula nada.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_premium":{"summary":"El tramo BASIC no incluye el informe de métricas","value":{"code":"PLAN_UPGRADE_REQUIRED","message":"Tu plan actual no incluye esta función.","details":{"capability":"METRICS","current_plan":"BASIC","required_plan":"PREMIUM"},"hint":"Sube de plan en Ajustes → Suscripción para desbloquear esta función.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`OWNER_REQUIRED`: eres miembro del negocio con membresía `STAFF`. El informe es del propietario: lleva ingresos y la ocupación de cada persona del equipo.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"solo_propietario":{"summary":"La sesión es de alguien de la plantilla y la operación es del dueño","value":{"code":"OWNER_REQUIRED","message":"Esta operación es exclusiva del propietario del negocio.","details":null,"hint":"Pide el cambio a la persona propietaria del negocio.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe, está dado de baja o quien pregunta no es miembro. Los tres casos responden igual (ADR-014).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`DATE_RANGE_INVALID`: `to` es anterior a `from`.\n\n`DATE_RANGE_TOO_LARGE`: la ventana supera los 366 días; `details` trae `max_days` y `requested_days`.\n\n`VALIDATION_ERROR`: alguna fecha no tiene el formato `YYYY-MM-DD`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"ventana_larga":{"summary":"La ventana supera los 366 días","value":{"code":"DATE_RANGE_TOO_LARGE","message":"El rango de fechas solicitado es demasiado amplio.","details":{"max_days":366,"requested_days":400},"hint":"Pide como mucho 366 días por informe.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"rango_invertido":{"summary":"`to` es anterior a `from`","value":{"code":"DATE_RANGE_INVALID","message":"El rango de fechas no es válido.","details":null,"hint":"`from` tiene que ser anterior o igual a `to`.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/businesses/{business_id}/bookings/upcoming":{"get":{"tags":["businesses"],"summary":"Listar las próximas reservas del negocio","description":"Las siguientes citas del local, de la más cercana en adelante. Es la tarjeta\n«lo que viene» de la portada del panel (UI-01.01) y el complemento de\n`GET /api/v1/businesses/{business_id}/bookings?from&to` (F4-T08): aquella pide\nuna ventana concreta para pintar un calendario, esta responde a «¿qué toca\nahora?» sin que nadie tenga que calcular fechas.\n\nSolo entran las citas **vivas** —`PENDING`, `CONFIRMED` y `PAID`— que empiezan\nde ahora en adelante. Las `CANCELLED` y las `NO_SHOW` no se atienden, y una cita\nque ya empezó pertenece al historial del día, no a lo que viene.\n\n**Alcance por rol.** Con membresía `STAFF` la lista se acota **siempre** a las\ncitas propias: es la agenda personal de quien abre el panel, y no hay parámetro\ncon el que pedir la de otra persona. El propietario y el superadministrador ven\nlas de todo el equipo.\n\n`limit` acota cuántas se devuelven, entre 1 y 50; por defecto\n10.\n\nEl cuerpo es el mismo `CalendarBooking` de la agenda: los instantes viajan en\n**hora local del negocio con su offset** (`2026-03-10T09:00:00-03:00`) y el\ncontacto del cliente va **ofuscado**, porque esta lista la ve todo el equipo y\nbasta para reconocer a quien ya vino otras veces (SEC-01).","operationId":"businesses_list_upcoming_bookings","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":50,"minimum":1,"description":"Cuántas citas devolver, de la más próxima en adelante. Entre 1 y 50; por defecto 10.","default":10,"title":"Limit"},"description":"Cuántas citas devolver, de la más próxima en adelante. Entre 1 y 50; por defecto 10.","examples":{"portada":{"summary":"Lo que cabe en la tarjeta","description":"El valor por defecto.","value":10},"jornada_completa":{"summary":"El día entero","description":"El máximo admitido.","value":50}}}],"responses":{"200":{"description":"Las próximas citas, de la más cercana en adelante. Lista vacía si no queda ninguna por atender.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CalendarBooking"},"title":"Response Businesses List Upcoming Bookings"},"example":[{"id":"3f2b9a10-0000-4000-8000-dddddddddddd","starts_at":"2026-03-10T09:00:00-03:00","ends_at":"2026-03-10T09:30:00-03:00","staff_member_id":"3f2b9a10-0000-4000-8000-cccccccccccc","staff_name":"Ana","service":{"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","name":"Corte de pelo","duration_minutes":30,"price_clp":15000,"modality":"IN_PERSON","requires_prepayment":false},"customer_name":"Javiera Rojas","customer_email_masked":"j***@example.com","customer_phone_masked":"+56 9 ****5678","status":"CONFIRMED","source":"DASHBOARD","price_clp":15000}]}},"headers":{"Cache-Control":{"description":"Respuesta privada: ninguna caché puede guardarla.","schema":{"type":"string","example":"private, no-store"}}}},"401":{"description":"`TOKEN_MISSING`, `TOKEN_EXPIRED`, `TOKEN_INVALID` o `USER_INACTIVE`: no hay sesión utilizable. Solo `TOKEN_EXPIRED` justifica renovar y reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe, está dado de baja o quien pregunta no es miembro. Los tres casos responden igual (ADR-014).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`VALIDATION_ERROR`: `limit` está fuera de `[1, 50]`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"limite_fuera_de_rango":{"summary":"`limit` se salió de `[1, 50]`","value":{"code":"VALIDATION_ERROR","message":"Los datos enviados no son válidos.","details":[{"loc":["query","limit"],"msg":"Input should be less than or equal to 50"}],"hint":null,"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/businesses/{business_id}/bookings":{"get":{"tags":["businesses"],"summary":"Listar la agenda del negocio en un rango","description":"Las citas del negocio en una ventana de tiempo, ordenadas por hora de inicio.\nEs la fuente del calendario del dashboard B2B (UI-01.01, F6-T17), y la\ncontrapartida de `GET /api/v1/businesses/{business_id}/availability` (F4-T14):\naquella dice qué huecos quedan, esta qué hay en ellos.\n\nLa ventana se pide con `from` y `to`, **los dos obligatorios y con offset**. Se\ndevuelve toda cita que **solape** el intervalo, no solo las que empiezan dentro:\nuna cita larga que arranca antes de `from` sigue ocupando la primera franja que\nel panel está pintando, y esconderla dejaría un hueco donde no lo hay.\n\nLa ventana admite como mucho **62 días**, contando el día\nde `from` y el de `to`. Pasado ese límite la respuesta es\n`422 DATE_RANGE_TOO_LARGE`, con `max_days` y `requested_days` en `details`\npara que la interfaz pueda explicarlo sin llevar ningún número copiado a mano.\n\n**Alcance por rol.** Con membresía `STAFF` la respuesta se acota siempre a la\npropia plaza, se envíe `staff_id` o no; pedir la de otra persona responde\n`403 STAFF_SCOPE`. El propietario y el superadministrador filtran por\ncualquier plaza u omiten el parámetro para ver el calendario completo.\n\nLos instantes salen en **hora local del negocio con su offset**\n(`2026-03-10T09:00:00-03:00`), listos para pintar. El contacto del cliente viaja\n**ofuscado**: la agenda la ve todo el equipo y basta para reconocer a quien ya\nvino otras veces.","operationId":"businesses_list_business_bookings","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}},{"name":"from","in":"query","required":true,"schema":{"type":"string","format":"date-time","description":"Inicio de la ventana que se está pintando, ISO 8601 **con offset**. Se exige la zona horaria por la misma razón que al reservar: un instante ingenuo no delimita nada, y el panel de un local de Santiago no puede pedir «el lunes» sin decir de dónde.","examples":["2026-03-09T00:00:00-03:00"],"title":"From"},"description":"Inicio de la ventana que se está pintando, ISO 8601 **con offset**. Se exige la zona horaria por la misma razón que al reservar: un instante ingenuo no delimita nada, y el panel de un local de Santiago no puede pedir «el lunes» sin decir de dónde."},{"name":"to","in":"query","required":true,"schema":{"type":"string","format":"date-time","description":"Fin de la ventana, **exclusivo**: para pedir un día entero se envía la medianoche del día siguiente. Así dos semanas consecutivas no repiten la cita de la frontera.","examples":["2026-03-16T00:00:00-03:00"],"title":"To"},"description":"Fin de la ventana, **exclusivo**: para pedir un día entero se envía la medianoche del día siguiente. Así dos semanas consecutivas no repiten la cita de la frontera."},{"name":"staff_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"description":"Acota la agenda a una sola plaza del equipo. **Quien tiene membresía `STAFF` solo puede consultar la suya**: se le fuerza aunque omita el parámetro, y pedir la de otra persona responde `403 STAFF_SCOPE`. El propietario filtra por quien quiera.","title":"Staff Id"},"description":"Acota la agenda a una sola plaza del equipo. **Quien tiene membresía `STAFF` solo puede consultar la suya**: se le fuerza aunque omita el parámetro, y pedir la de otra persona responde `403 STAFF_SCOPE`. El propietario filtra por quien quiera.","examples":{"todo_el_equipo":{"summary":"Toda la plantilla","description":"Omitido, se devuelven las citas de todo el equipo."},"una_persona":{"summary":"Una plaza concreta","description":"Sale de `GET /api/v1/businesses/{business_id}/staff`.","value":"3f2b9a10-0000-4000-8000-cccccccccccc"}}},{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"$ref":"#/components/schemas/BookingStatus"}},{"type":"null"}],"description":"Filtra por estado. Se puede repetir (`?status=CONFIRMED&status=PAID`) y los valores se combinan con **O**. Omitido **no filtra nada**, así que la respuesta incluye también las `CANCELLED` y las `NO_SHOW`: el calendario decide si las pinta en gris o las esconde, y el historial del día las necesita.","title":"Status"},"description":"Filtra por estado. Se puede repetir (`?status=CONFIRMED&status=PAID`) y los valores se combinan con **O**. Omitido **no filtra nada**, así que la respuesta incluye también las `CANCELLED` y las `NO_SHOW`: el calendario decide si las pinta en gris o las esconde, y el historial del día las necesita."}],"responses":{"200":{"description":"Citas del rango, ordenadas por hora de inicio. Una lista vacía es una respuesta legítima —ese día no hay nada apuntado—, no un error.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CalendarBooking"},"title":"Response Businesses List Business Bookings"},"example":[{"id":"3f2b9a10-0000-4000-8000-dddddddddddd","starts_at":"2026-03-10T09:00:00-03:00","ends_at":"2026-03-10T09:30:00-03:00","staff_member_id":"3f2b9a10-0000-4000-8000-cccccccccccc","staff_name":"Ana","service":{"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","name":"Corte de pelo","duration_minutes":30,"price_clp":15000,"modality":"IN_PERSON","requires_prepayment":false},"customer_name":"Javiera Rojas","customer_email_masked":"j***@example.com","customer_phone_masked":"+56 9 ****5678","status":"CONFIRMED","source":"DASHBOARD","price_clp":15000}]}},"headers":{"Cache-Control":{"description":"Respuesta privada: ninguna caché puede guardarla.","schema":{"type":"string","example":"private, no-store"}}}},"401":{"description":"`TOKEN_MISSING`, `TOKEN_EXPIRED`, `TOKEN_INVALID` o `USER_INACTIVE`: no hay sesión utilizable. Solo `TOKEN_EXPIRED` justifica renovar y reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`STAFF_SCOPE`: tu membresía es `STAFF` y la operación apunta a la agenda de otra persona del equipo. La tuya sí puedes gestionarla.\n\n`OWNER_REQUIRED` no aparece en esta superficie: la agenda es de todo el equipo, acotada por plaza.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"agenda_ajena":{"summary":"La membresía es `STAFF` y la operación apunta a la agenda de otra persona","value":{"code":"STAFF_SCOPE","message":"Solo puedes gestionar tu propia agenda.","details":null,"hint":"Omite `staff_id` o envía el de tu propia plaza.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe, está dado de baja o quien pregunta no es miembro. Los tres casos responden igual (ADR-014).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`DATE_RANGE_INVALID`: `to` es anterior a `from`.\n\n`DATE_RANGE_TOO_LARGE`: la ventana supera los 62 días; `details` trae `max_days` y `requested_days`.\n\n`VALIDATION_ERROR`: falta `from` o `to`, o alguno llegó sin offset.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"ventana_larga":{"summary":"La ventana supera los 62 días","value":{"code":"DATE_RANGE_TOO_LARGE","message":"El rango de fechas solicitado es demasiado amplio.","details":{"max_days":62,"requested_days":120},"hint":"Pide como mucho 62 días por consulta.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"rango_invertido":{"summary":"`to` es anterior a `from`","value":{"code":"DATE_RANGE_INVALID","message":"El rango de fechas no es válido.","details":null,"hint":"`from` tiene que ser anterior o igual a `to`.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["businesses"],"summary":"Apuntar una cita desde el panel","description":"Apunta una cita desde el mostrador y la deja **confirmada** en el mismo acto.\nEs lo que hace el negocio cuando alguien entra por la puerta o llama por\nteléfono, y por eso no se parece a las dos altas de cara al cliente:\n\n* **No hay código que canjear.** Quien afirma que el cliente viene es el propio\n  local, así que la reserva nace `CONFIRMED` y no `PENDING`; tampoco caduca.\n* **No se aplica la antelación mínima** del negocio ni el tope de reservas sin\n  confirmar: una cita para dentro de diez minutos es exactamente el caso normal\n  del mostrador.\n* **La hora no tiene que estar en la rejilla publicada.** El local encaja donde\n  le quepa —a las 10:07 si hace falta—, siempre que la persona esté trabajando y\n  el hueco esté libre de bloqueos y de otras citas con su buffer.\n* **`source` queda como `DASHBOARD`**, de modo que las métricas distinguen lo\n  que trajo el marketplace de lo que ya traía el local.\n\n`staff_id` es obligatorio: la agenda se apunta por columnas y el panel ya sabe\nen cuál está haciendo clic. Con membresía `STAFF` solo se admite la propia\n(`403 STAFF_SCOPE`).\n\nDel cliente basta el nombre y **un** dato de contacto —correo o teléfono—. Con\nél se resuelve el Shadow User (F4-T03): si esa persona ya reservó antes, se\nreutiliza su ficha y se le completa el contacto que faltara; si no, se crea una\ncuenta `GUEST` sin contraseña, que no otorga sesión ninguna. Sin ningún contacto\nla respuesta es `422 CUSTOMER_CONTACT_REQUIRED`: sin él no hay forma de avisarle\nsi la cita cambia.\n\nEl cuerpo **rechaza los campos que no existan** (`422`), en vez de aceptarlos y\nperderlos: la reserva no guarda notas, y un `notes` aceptado en silencio sería\nun texto escrito por el local que nunca vuelve a aparecer.","operationId":"businesses_create_business_booking","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DashboardBookingCreate"},"examples":{"con_ambos_contactos":{"summary":"Cliente con correo y teléfono","description":"Lo habitual cuando el cliente ya reservó antes.","value":{"service_id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","staff_id":"3f2b9a10-0000-4000-8000-cccccccccccc","starts_at":"2026-03-10T09:00:00-03:00","customer":{"full_name":"Javiera Rojas","email":"javiera@example.com","phone":"+56912345678"}}},"solo_telefono":{"summary":"Cliente que solo dejó su teléfono","description":"Basta con **uno** de los dos contactos; sin ninguno, `422 CUSTOMER_CONTACT_REQUIRED`.","value":{"service_id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","staff_id":"3f2b9a10-0000-4000-8000-cccccccccccc","starts_at":"2026-03-10T09:00:00-03:00","customer":{"full_name":"Javiera Rojas","phone":"+56912345678"}}}}}}},"responses":{"201":{"description":"Cita apuntada y **confirmada** en el mismo acto: ocupa agenda desde ya y no caduca. `source` vale `DASHBOARD`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CalendarBooking"},"example":{"id":"3f2b9a10-0000-4000-8000-dddddddddddd","starts_at":"2026-03-10T09:00:00-03:00","ends_at":"2026-03-10T09:30:00-03:00","staff_member_id":"3f2b9a10-0000-4000-8000-cccccccccccc","staff_name":"Ana","service":{"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","name":"Corte de pelo","duration_minutes":30,"price_clp":15000,"modality":"IN_PERSON","requires_prepayment":false},"customer_name":"Javiera Rojas","customer_email_masked":"j***@example.com","customer_phone_masked":"+56 9 ****5678","status":"CONFIRMED","source":"DASHBOARD","price_clp":15000}}}},"401":{"description":"`TOKEN_MISSING`, `TOKEN_EXPIRED`, `TOKEN_INVALID` o `USER_INACTIVE`: no hay sesión utilizable. Solo `TOKEN_EXPIRED` justifica renovar y reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`STAFF_SCOPE`: tu membresía es `STAFF` y la operación apunta a la agenda de otra persona del equipo. La tuya sí puedes gestionarla.\n\n`OWNER_REQUIRED` no aparece en esta superficie: la agenda es de todo el equipo, acotada por plaza.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"agenda_ajena":{"summary":"La membresía es `STAFF` y la operación apunta a la agenda de otra persona","value":{"code":"STAFF_SCOPE","message":"Solo puedes gestionar tu propia agenda.","details":null,"hint":"Omite `staff_id` o envía el de tu propia plaza.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe o no es tuyo.\n\n`SERVICE_NOT_FOUND`: el servicio no existe, está retirado del catálogo o es de otro local.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"servicio_ajeno":{"summary":"El identificador no corresponde a ningún recurso de **este** negocio","value":{"code":"SERVICE_NOT_FOUND","message":"El servicio solicitado no existe.","details":null,"hint":"Comprueba el `service_id` con GET /api/v1/businesses/{business_id}/services.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"409":{"description":"`SLOT_TAKEN`: alguien ocupó ese hueco entre el cálculo y el alta.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"hueco_ocupado":{"summary":"Alguien ganó ese hueco entre el cálculo de disponibilidad y el alta","value":{"code":"SLOT_TAKEN","message":"Ese horario acaba de ocuparse.","details":null,"hint":"Vuelve a pedir la disponibilidad y elige otro hueco.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`CUSTOMER_CONTACT_REQUIRED`: el cliente llegó sin correo y sin teléfono; hace falta al menos uno para avisarle.\n\n`DATETIME_NAIVE`: `starts_at` llegó sin offset.\n\n`SLOT_UNAVAILABLE`: esa persona no presta el servicio, no trabaja a esa hora o el hueco ya no está libre.\n\n`VALIDATION_ERROR`: el cuerpo trae un campo que la reserva no guarda (`extra: forbid`), un correo ilegible o un teléfono imposible.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_contacto":{"summary":"El cliente llegó sin correo y sin teléfono, y hay que poder avisarle","value":{"code":"CUSTOMER_CONTACT_REQUIRED","message":"Hace falta al menos un correo o un teléfono de contacto.","details":null,"hint":"Envía `customer.email` o `customer.phone`.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"hueco_no_disponible":{"summary":"Esa persona no presta el servicio, no trabaja a esa hora o el hueco no está libre","value":{"code":"SLOT_UNAVAILABLE","message":"Ese horario no está disponible.","details":null,"hint":"Elige uno de los que devuelve GET .../availability.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/businesses/{business_id}/bookings/{booking_id}":{"patch":{"tags":["businesses"],"summary":"Mover una cita de hora o de persona","description":"Mueve la cita de hora, de persona, o de las dos cosas, **conservando el `id`**:\nes la misma reserva, no una anulación seguida de un alta, de modo que el pago y\nel historial siguen apuntando a ella. El estado tampoco cambia: una `PAID` sigue\n`PAID`.\n\nLos dos campos son opcionales por separado y obligatorios en conjunto: un cuerpo\nvacío es `422`. Enviar solo `staff_id` pasa la cita a otra columna conservando\nla hora; enviar solo `starts_at`, al revés. `ends_at` se vuelve a derivar de la\nduración del servicio y **no** se envía.\n\nComo actor es el negocio, no hay ventana de cancelación ni antelación mínima que\nrespetar. Lo que sigue en pie es la agenda física: la plaza destino tiene que\nprestar el servicio (`422 SERVICE_NOT_ASSIGNED`) y el hueco tiene que estar\nlibre (`422 SLOT_UNAVAILABLE`, o `409 SLOT_TAKEN` si otra transacción lo gana\npor medio segundo).\n\nCon membresía `STAFF` solo se mueven las citas propias, y **no** se pueden pasar\na otra persona: las dos cosas son `403 STAFF_SCOPE`. Reasignar la agenda del\nequipo es del propietario.","operationId":"businesses_update_business_booking","security":[{"HTTPBearer":[]}],"parameters":[{"name":"booking_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"Identificador de la reserva, tal y como lo devuelve el listado.","examples":["3f2b9a10-0000-4000-8000-dddddddddddd"],"title":"Booking Id"},"description":"Identificador de la reserva, tal y como lo devuelve el listado."},{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DashboardBookingUpdate"},"examples":{"hora_y_persona":{"summary":"Mover de hora y de columna","description":"Los dos campos a la vez son un solo movimiento.","value":{"starts_at":"2026-03-10T11:00:00-03:00","staff_id":"3f2b9a10-0000-4000-8000-cccccccccccc"}},"solo_hora":{"summary":"Solo cambiar la hora","description":"La atiende quien ya la tenía.","value":{"starts_at":"2026-03-10T11:00:00-03:00"}},"solo_persona":{"summary":"Solo cambiar de persona","description":"Conserva la hora y pasa a otra columna.","value":{"staff_id":"3f2b9a10-0000-4000-8000-cccccccccccc"}}}}}},"responses":{"200":{"description":"Cita movida. Conserva el **mismo `id`** y el mismo estado: solo cambian `starts_at`, `ends_at` y, si se pidió, la plaza.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CalendarBooking"},"example":{"id":"3f2b9a10-0000-4000-8000-dddddddddddd","starts_at":"2026-03-10T09:00:00-03:00","ends_at":"2026-03-10T09:30:00-03:00","staff_member_id":"3f2b9a10-0000-4000-8000-cccccccccccc","staff_name":"Ana","service":{"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","name":"Corte de pelo","duration_minutes":30,"price_clp":15000,"modality":"IN_PERSON","requires_prepayment":false},"customer_name":"Javiera Rojas","customer_email_masked":"j***@example.com","customer_phone_masked":"+56 9 ****5678","status":"CONFIRMED","source":"DASHBOARD","price_clp":15000}}}},"401":{"description":"`TOKEN_MISSING`, `TOKEN_EXPIRED`, `TOKEN_INVALID` o `USER_INACTIVE`: no hay sesión utilizable. Solo `TOKEN_EXPIRED` justifica renovar y reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`STAFF_SCOPE`: tu membresía es `STAFF` y la operación apunta a la agenda de otra persona del equipo. La tuya sí puedes gestionarla.\n\n`OWNER_REQUIRED` no aparece en esta superficie: la agenda es de todo el equipo, acotada por plaza.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"agenda_ajena":{"summary":"La membresía es `STAFF` y la operación apunta a la agenda de otra persona","value":{"code":"STAFF_SCOPE","message":"Solo puedes gestionar tu propia agenda.","details":null,"hint":"Omite `staff_id` o envía el de tu propia plaza.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe o no es tuyo.\n\n`BOOKING_NOT_FOUND`: no hay ninguna reserva **de este negocio** con ese identificador. Una reserva del local vecino responde exactamente igual: probar identificadores no debe revelar qué citas existen fuera.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"cita_ajena":{"summary":"El identificador no corresponde a ningún recurso de **este** negocio","value":{"code":"BOOKING_NOT_FOUND","message":"La reserva solicitada no existe.","details":null,"hint":"Comprueba el `booking_id` con GET /api/v1/businesses/{business_id}/bookings.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"409":{"description":"`SLOT_TAKEN`: otra transacción ganó ese hueco.\n\n`INVALID_TRANSITION`: la cita está cerrada (`CANCELLED` o `NO_SHOW`) y ya no se mueve.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"cita_cerrada":{"summary":"La cita está `CANCELLED` o `NO_SHOW` y ya no se mueve","value":{"code":"INVALID_TRANSITION","message":"La reserva no admite ese cambio de estado.","details":null,"hint":"Una cita cerrada no vuelve atrás: apunta una nueva.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`SERVICE_NOT_ASSIGNED`: la plaza destino no presta ese servicio, o causó baja.\n\n`SLOT_UNAVAILABLE`: la hora nueva no está libre para esa persona.\n\n`DATETIME_NAIVE`: `starts_at` llegó sin offset.\n\n`VALIDATION_ERROR`: el cuerpo llegó vacío o con un campo desconocido.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"plaza_sin_el_servicio":{"summary":"La plaza destino no presta ese servicio, o causó baja","value":{"code":"SERVICE_NOT_ASSIGNED","message":"Esa persona no presta el servicio de la reserva.","details":null,"hint":"Asígnale el servicio en PUT .../staff/{staff_id}/services, o elige otra plaza.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/businesses/{business_id}/bookings/{booking_id}/confirm":{"post":{"tags":["businesses"],"summary":"Confirmar a mano una cita pendiente","description":"Confirma a mano una reserva `PENDING`: la que dejó a medias un cliente de la\nvitrina y que el local prefiere dar por buena —porque le llamó, o porque la vio\nllegar— antes de que caduque.\n\nEs la única transición que hace esta ruta: `PENDING → CONFIRMED`. Sobre\ncualquier otro estado responde `409 INVALID_TRANSITION`, y no un `200` sin\nefecto: un estado terminal es una respuesta, no una operación vacía.\n\nLas citas apuntadas desde el panel no pasan por aquí: nacen ya confirmadas.","operationId":"businesses_confirm_business_booking","security":[{"HTTPBearer":[]}],"parameters":[{"name":"booking_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"Identificador de la reserva, tal y como lo devuelve el listado.","examples":["3f2b9a10-0000-4000-8000-dddddddddddd"],"title":"Booking Id"},"description":"Identificador de la reserva, tal y como lo devuelve el listado."},{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"responses":{"200":{"description":"Cita confirmada: ya no puede caducar y ocupa agenda en firme.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CalendarBooking"},"example":{"id":"3f2b9a10-0000-4000-8000-dddddddddddd","starts_at":"2026-03-10T09:00:00-03:00","ends_at":"2026-03-10T09:30:00-03:00","staff_member_id":"3f2b9a10-0000-4000-8000-cccccccccccc","staff_name":"Ana","service":{"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","name":"Corte de pelo","duration_minutes":30,"price_clp":15000,"modality":"IN_PERSON","requires_prepayment":false},"customer_name":"Javiera Rojas","customer_email_masked":"j***@example.com","customer_phone_masked":"+56 9 ****5678","status":"CONFIRMED","source":"DASHBOARD","price_clp":15000}}}},"401":{"description":"`TOKEN_MISSING`, `TOKEN_EXPIRED`, `TOKEN_INVALID` o `USER_INACTIVE`: no hay sesión utilizable. Solo `TOKEN_EXPIRED` justifica renovar y reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`STAFF_SCOPE`: tu membresía es `STAFF` y la operación apunta a la agenda de otra persona del equipo. La tuya sí puedes gestionarla.\n\n`OWNER_REQUIRED` no aparece en esta superficie: la agenda es de todo el equipo, acotada por plaza.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"agenda_ajena":{"summary":"La membresía es `STAFF` y la operación apunta a la agenda de otra persona","value":{"code":"STAFF_SCOPE","message":"Solo puedes gestionar tu propia agenda.","details":null,"hint":"Omite `staff_id` o envía el de tu propia plaza.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe o no es tuyo.\n\n`BOOKING_NOT_FOUND`: no hay ninguna reserva **de este negocio** con ese identificador. Una reserva del local vecino responde exactamente igual: probar identificadores no debe revelar qué citas existen fuera.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"cita_ajena":{"summary":"El identificador no corresponde a ningún recurso de **este** negocio","value":{"code":"BOOKING_NOT_FOUND","message":"La reserva solicitada no existe.","details":null,"hint":"Comprueba el `booking_id` con GET /api/v1/businesses/{business_id}/bookings.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"409":{"description":"`INVALID_TRANSITION`: la cita no está `PENDING`. Una reserva ya confirmada, pagada, anulada o marcada como ausencia no vuelve a confirmarse.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"no_esta_pendiente":{"summary":"La cita no está `PENDING`: ya se confirmó, se pagó, se anuló o fue ausencia","value":{"code":"INVALID_TRANSITION","message":"La reserva no admite ese cambio de estado.","details":null,"hint":"Solo una reserva `PENDING` se confirma a mano.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/businesses/{business_id}/bookings/{booking_id}/cancel":{"post":{"tags":["businesses"],"summary":"Anular una cita de la agenda","description":"Anula la cita y libera el hueco, que vuelve a la agenda en cuanto la operación\nconfirma. La fila **no se borra**: sigue en el historial con su\n`cancelled_reason`, porque es el registro contable del negocio.\n\n**El local anula siempre**, sin ventana de cancelación: la que limita al cliente\n(`cancellation_window_hours`) es una política que el negocio fija *para sus\nclientes*, no para sí mismo, y cinco minutos antes de la cita el mostrador tiene\nque poder cerrar el día si se le rompió el equipo.\n\nAnular una cita ya cerrada es `409 INVALID_TRANSITION`.","operationId":"businesses_cancel_business_booking","security":[{"HTTPBearer":[]}],"parameters":[{"name":"booking_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"Identificador de la reserva, tal y como lo devuelve el listado.","examples":["3f2b9a10-0000-4000-8000-dddddddddddd"],"title":"Booking Id"},"description":"Identificador de la reserva, tal y como lo devuelve el listado."},{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingCancel"},"examples":{"con_motivo":{"summary":"Con motivo","description":"El texto se guarda en `cancelled_reason`.","value":{"reason":"Me surgió un imprevisto"}},"sin_motivo":{"summary":"Sin motivo","description":"El motivo es opcional: un cuerpo vacío basta.","value":{}}}}}},"responses":{"200":{"description":"Cita anulada. El hueco vuelve a la agenda en el acto y la fila sigue en el historial con su `cancelled_reason`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CalendarBooking"},"example":{"id":"3f2b9a10-0000-4000-8000-dddddddddddd","starts_at":"2026-03-10T09:00:00-03:00","ends_at":"2026-03-10T09:30:00-03:00","staff_member_id":"3f2b9a10-0000-4000-8000-cccccccccccc","staff_name":"Ana","service":{"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","name":"Corte de pelo","duration_minutes":30,"price_clp":15000,"modality":"IN_PERSON","requires_prepayment":false},"customer_name":"Javiera Rojas","customer_email_masked":"j***@example.com","customer_phone_masked":"+56 9 ****5678","status":"CONFIRMED","source":"DASHBOARD","price_clp":15000}}}},"401":{"description":"`TOKEN_MISSING`, `TOKEN_EXPIRED`, `TOKEN_INVALID` o `USER_INACTIVE`: no hay sesión utilizable. Solo `TOKEN_EXPIRED` justifica renovar y reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`STAFF_SCOPE`: tu membresía es `STAFF` y la operación apunta a la agenda de otra persona del equipo. La tuya sí puedes gestionarla.\n\n`OWNER_REQUIRED` no aparece en esta superficie: la agenda es de todo el equipo, acotada por plaza.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"agenda_ajena":{"summary":"La membresía es `STAFF` y la operación apunta a la agenda de otra persona","value":{"code":"STAFF_SCOPE","message":"Solo puedes gestionar tu propia agenda.","details":null,"hint":"Omite `staff_id` o envía el de tu propia plaza.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe o no es tuyo.\n\n`BOOKING_NOT_FOUND`: no hay ninguna reserva **de este negocio** con ese identificador. Una reserva del local vecino responde exactamente igual: probar identificadores no debe revelar qué citas existen fuera.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"cita_ajena":{"summary":"El identificador no corresponde a ningún recurso de **este** negocio","value":{"code":"BOOKING_NOT_FOUND","message":"La reserva solicitada no existe.","details":null,"hint":"Comprueba el `booking_id` con GET /api/v1/businesses/{business_id}/bookings.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"409":{"description":"`INVALID_TRANSITION`: la cita ya está `CANCELLED` o `NO_SHOW`, y ninguna de las dos vuelve atrás.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"ya_cerrada":{"summary":"La cita ya está `CANCELLED` o `NO_SHOW`, y ninguna de las dos vuelve atrás","value":{"code":"INVALID_TRANSITION","message":"La reserva no admite ese cambio de estado.","details":null,"hint":"Comprueba el `status` de la cita antes de anularla.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/businesses/{business_id}/bookings/{booking_id}/no-show":{"post":{"tags":["businesses"],"summary":"Marcar que el cliente no se presentó","description":"Deja constancia de que el cliente no se presentó. Solo desde `CONFIRMED` o\n`PAID`, y solo **después** de la hora de la cita: una ausencia es un hecho\nobservado, así que marcarla antes de que empiece responde\n`422 BOOKING_NOT_STARTED`.\n\nUna `PENDING` nunca es una ausencia aunque la hora haya pasado —nadie llegó a\nconfirmarla, y para eso está el vencimiento—, así que ese caso es\n`409 INVALID_TRANSITION`.\n\n`NO_SHOW` es terminal: no libera el hueco hacia atrás ni vuelve a `CONFIRMED`.","operationId":"businesses_mark_business_booking_no_show","security":[{"HTTPBearer":[]}],"parameters":[{"name":"booking_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"Identificador de la reserva, tal y como lo devuelve el listado.","examples":["3f2b9a10-0000-4000-8000-dddddddddddd"],"title":"Booking Id"},"description":"Identificador de la reserva, tal y como lo devuelve el listado."},{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"responses":{"200":{"description":"Ausencia registrada. El estado `NO_SHOW` es terminal.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CalendarBooking"},"example":{"id":"3f2b9a10-0000-4000-8000-dddddddddddd","starts_at":"2026-03-10T09:00:00-03:00","ends_at":"2026-03-10T09:30:00-03:00","staff_member_id":"3f2b9a10-0000-4000-8000-cccccccccccc","staff_name":"Ana","service":{"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","name":"Corte de pelo","duration_minutes":30,"price_clp":15000,"modality":"IN_PERSON","requires_prepayment":false},"customer_name":"Javiera Rojas","customer_email_masked":"j***@example.com","customer_phone_masked":"+56 9 ****5678","status":"CONFIRMED","source":"DASHBOARD","price_clp":15000}}}},"401":{"description":"`TOKEN_MISSING`, `TOKEN_EXPIRED`, `TOKEN_INVALID` o `USER_INACTIVE`: no hay sesión utilizable. Solo `TOKEN_EXPIRED` justifica renovar y reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`STAFF_SCOPE`: tu membresía es `STAFF` y la operación apunta a la agenda de otra persona del equipo. La tuya sí puedes gestionarla.\n\n`OWNER_REQUIRED` no aparece en esta superficie: la agenda es de todo el equipo, acotada por plaza.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"agenda_ajena":{"summary":"La membresía es `STAFF` y la operación apunta a la agenda de otra persona","value":{"code":"STAFF_SCOPE","message":"Solo puedes gestionar tu propia agenda.","details":null,"hint":"Omite `staff_id` o envía el de tu propia plaza.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe o no es tuyo.\n\n`BOOKING_NOT_FOUND`: no hay ninguna reserva **de este negocio** con ese identificador. Una reserva del local vecino responde exactamente igual: probar identificadores no debe revelar qué citas existen fuera.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"cita_ajena":{"summary":"El identificador no corresponde a ningún recurso de **este** negocio","value":{"code":"BOOKING_NOT_FOUND","message":"La reserva solicitada no existe.","details":null,"hint":"Comprueba el `booking_id` con GET /api/v1/businesses/{business_id}/bookings.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"409":{"description":"`INVALID_TRANSITION`: la cita no está `CONFIRMED` ni `PAID`. Una `PENDING` nunca es una ausencia: nadie llegó a confirmarla, y para eso está el vencimiento.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"no_estaba_en_pie":{"summary":"La cita no está `CONFIRMED` ni `PAID`: una `PENDING` nunca es una ausencia","value":{"code":"INVALID_TRANSITION","message":"La reserva no admite ese cambio de estado.","details":null,"hint":"Confírmala primero, o deja que venza sola.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`BOOKING_NOT_STARTED`: la cita todavía no ha empezado. Una ausencia es un hecho observado, y no se observa lo que aún no ha pasado.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"aun_no_empieza":{"summary":"La cita todavía no ha empezado, y una ausencia es un hecho observado","value":{"code":"BOOKING_NOT_STARTED","message":"La reserva todavía no ha comenzado.","details":null,"hint":"Espera a la hora de inicio para registrar la ausencia.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/businesses/{business_id}/settings":{"get":{"tags":["businesses"],"summary":"Ver las políticas de reserva del negocio","description":"Políticas con las que este negocio acepta reservas. Las lee cualquier miembro\n—la plantilla necesita saber con qué rejilla se pinta su agenda—, y son las\nmismas que aplica el cálculo de disponibilidad:\n\n- **`timezone`** — zona horaria IANA en la que se interpretan horarios, bloqueos\n  y recurrencias.\n- **`slot_interval_minutes`** — granularidad de la agenda: cada cuánto empieza\n  un hueco reservable.\n- **`min_advance_minutes`** — antelación mínima con la que se puede reservar.\n- **`cancellation_window_hours`** — margen antes de la cita en el que el cliente\n  todavía puede anular por su cuenta.\n- **`buffer_minutes`** — margen que se aparta antes y después de cada cita.\n\nUn negocio recién creado estrena los valores por defecto de la plataforma\n(`America/Santiago`, 15, 60, 24, 0).","operationId":"businesses_get_business_settings","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessSettings"},"examples":{"por_defecto":{"summary":"Un negocio recién creado estrena los valores por defecto de la plataforma","value":{"timezone":"America/Santiago","slot_interval_minutes":15,"min_advance_minutes":60,"cancellation_window_hours":24,"buffer_minutes":0}},"ajustado":{"summary":"El mismo local tras pedir citas cada media hora con 10 minutos de margen","value":{"timezone":"America/Santiago","slot_interval_minutes":30,"min_advance_minutes":60,"cancellation_window_hours":24,"buffer_minutes":10}}}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe, está dado de baja o quien pregunta no es miembro. Los tres casos responden igual.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"patch":{"tags":["businesses"],"summary":"Ajustar las políticas de reserva","description":"Ajusta las políticas de reserva. Es una edición **parcial**: lo que no viaja en\nel cuerpo no se toca, y la respuesta devuelve siempre las cinco políticas\nvigentes tras el cambio. Reservado al **propietario**; la plantilla recibe\n`403 OWNER_REQUIRED`.\n\nUn cuerpo vacío responde `422`: `PATCH {}` no es «déjalo como está», es una\nllamada que el cliente no quería hacer.\n\nCambiar `timezone` **no mueve ninguna reserva ya creada**: las citas se guardan\ncomo instante absoluto (`timestamptz`). La zona nueva solo cambia cómo se\ninterpretan de ahí en adelante los horarios de apertura, los bloqueos y las\nrecurrencias, así que conviene revisar la matriz horaria después de mudarse de\nzona.\n\n`slot_interval_minutes` y `buffer_minutes` se mueven en la rejilla de 5 minutos\n(5–120): un intervalo de 7 minutos produciría horas de inicio impredecibles para\nel cliente.","operationId":"businesses_update_business_settings","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessSettingsUpdate"},"examples":{"rejilla":{"summary":"Citas cada media hora y 10 minutos de margen entre ellas","value":{"slot_interval_minutes":30,"buffer_minutes":10}},"anticipacion":{"summary":"Exigir 2 horas de antelación y cerrar cancelaciones a 48 h","value":{"min_advance_minutes":120,"cancellation_window_hours":48}},"zona":{"summary":"Mudar el local a Isla de Pascua","value":{"timezone":"Pacific/Easter"}}}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessSettings"},"examples":{"ajustado":{"summary":"La respuesta trae siempre las cinco políticas, no solo las que cambiaron","value":{"timezone":"America/Santiago","slot_interval_minutes":30,"min_advance_minutes":60,"cancellation_window_hours":24,"buffer_minutes":10}}}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe, está dado de baja o quien pregunta no es miembro. Los tres casos responden igual.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`OWNER_REQUIRED`: la plantilla puede leer estas políticas, pero solo el propietario las cambia.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"solo_propietario":{"summary":"La sesión es de alguien de la plantilla y la operación es del dueño","value":{"code":"OWNER_REQUIRED","message":"Esta operación es exclusiva del propietario del negocio.","details":null,"hint":"Pide el cambio a la persona propietaria del negocio.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`INVALID_TIMEZONE`: `timezone` no es una zona IANA conocida. `VALIDATION_ERROR`: un valor se sale de su rango o de la rejilla de 5 minutos, o el cuerpo llegó vacío.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"zona_desconocida":{"summary":"`timezone` no es una zona IANA conocida","value":{"code":"INVALID_TIMEZONE","message":"La zona horaria indicada no existe.","details":{"timezone":"America/Santiagos"},"hint":"Usa un identificador IANA, por ejemplo `America/Santiago`.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/businesses/{business_id}/services":{"post":{"tags":["businesses"],"summary":"Crear un servicio del catálogo","description":"Añade una prestación al catálogo del negocio. Reservado al **propietario**; la\nplantilla recibe `403 OWNER_REQUIRED`.\n\nDos campos merecen atención:\n\n- **`price_clp`** es un número **entero en pesos chilenos**, sin decimales ni\n  separadores (`15000` son $15.000). El cero es válido y describe un servicio de\n  cortesía. El importe se copia como foto en cada reserva, así que subirlo más\n  adelante no reescribe ninguna cita ya hecha.\n- **`requires_prepayment: true`** activa el **checkout previo del cliente**: la\n  reserva no se confirma hasta que el pago se aprueba en Mercado Pago.\n\n`duration_minutes` va de 5 a 480 y siempre en pasos de 5, porque la agenda\navanza en esa misma rejilla. El servicio nace sin nadie asignado: quién lo\npresta se decide en `.../services/{service_id}/staff`.","operationId":"businesses_create_service","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceCreate"},"examples":{"corte":{"summary":"Corte de pelo presencial de 30 minutos","value":{"name":"Corte de pelo","description":"Corte clásico con máquina y tijera, incluye lavado.","price_clp":15000,"duration_minutes":30,"modality":"IN_PERSON","requires_prepayment":false}},"asesoria_online":{"summary":"Asesoría online con prepago obligatorio","value":{"name":"Asesoría de imagen online","description":"Videollamada de una hora con informe posterior.","price_clp":45000,"duration_minutes":60,"modality":"ONLINE","requires_prepayment":true}}}}}},"responses":{"201":{"description":"Servicio dado de alta y listo para asignarse al equipo.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceOut"},"examples":{"recien_creado":{"summary":"El servicio nace activo, sin reservas y sin nadie asignado","value":{"id":"3f2b9a10-0000-4000-8000-aaaaaaaaaaaa","business_id":"3f2b9a10-0000-4000-8000-dddddddddddd","name":"Corte y barba","description":"Corte completo con perfilado.","price_clp":18000,"duration_minutes":45,"modality":"IN_PERSON","requires_prepayment":false,"is_active":true,"has_bookings":false,"created_at":"2026-01-15T10:30:00Z","updated_at":"2026-01-15T10:30:00Z"}}}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe, está dado de baja o quien pregunta no es miembro. Los tres casos responden **exactamente igual**.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`OWNER_REQUIRED`: eres miembro del negocio, pero editar el catálogo es exclusivo de su propietario.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"solo_propietario":{"summary":"La sesión es de alguien de la plantilla y la operación es del dueño","value":{"code":"OWNER_REQUIRED","message":"Esta operación es exclusiva del propietario del negocio.","details":null,"hint":"Pide el cambio a la persona propietaria del negocio.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`VALIDATION_ERROR`: algún campo incumple sus límites (`name` 2–120, `description` ≤ 1000, `price_clp` entero ≥ 0, `duration_minutes` 5–480 y múltiplo de 5). `details` nombra el campo exacto en su `loc`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"duracion_fuera_de_rejilla":{"summary":"`duration_minutes` no es múltiplo de 5, y la agenda avanza en esa rejilla","value":{"code":"VALIDATION_ERROR","message":"Los datos enviados no son válidos.","details":[{"loc":["body","duration_minutes"],"msg":"Input should be a multiple of 5"}],"hint":null,"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"get":{"tags":["businesses"],"summary":"Listar el catálogo del negocio","description":"Catálogo completo del negocio para su equipo, **incluidos los servicios\ninactivos**: el panel tiene que poder ver lo retirado para reactivarlo.\n\nEl orden es fijo —primero los activos y, dentro de cada grupo, por nombre\nascendente— para que la lista no baile entre recargas.\n\nCada elemento trae `has_bookings`, que anticipa qué hará un `DELETE`: con\nreservas el servicio solo se desactiva; sin ellas, desaparece.","operationId":"businesses_list_services","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ServiceOut"},"title":"Response Businesses List Services"},"examples":{"catalogo_demo":{"summary":"El catálogo de `barberia-demo`: activos primero y por nombre dentro de cada grupo","value":[{"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","business_id":"3f2b9a10-0000-4000-8000-dddddddddddd","name":"Corte clásico","description":"Corte a tijera y máquina.","price_clp":12000,"duration_minutes":30,"modality":"IN_PERSON","requires_prepayment":false,"is_active":true,"has_bookings":true,"created_at":"2026-01-15T10:30:00Z","updated_at":"2026-01-15T10:30:00Z"},{"id":"3f2b9a10-0000-4000-8000-aaaaaaaaaaaa","business_id":"3f2b9a10-0000-4000-8000-dddddddddddd","name":"Corte y barba","description":"Corte completo con perfilado.","price_clp":18000,"duration_minutes":45,"modality":"IN_PERSON","requires_prepayment":false,"is_active":true,"has_bookings":false,"created_at":"2026-01-15T10:30:00Z","updated_at":"2026-01-15T10:30:00Z"}]}}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe, está dado de baja o quien pregunta no es miembro. Los tres casos responden **exactamente igual**.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/businesses/{business_id}/services/{service_id}":{"get":{"tags":["businesses"],"summary":"Ver la ficha de un servicio","description":"Ficha de una prestación concreta del catálogo, visible para cualquier miembro\ndel negocio.\n\nUn `service_id` de otro local responde `404 SERVICE_NOT_FOUND`, igual que uno\ninventado: la consulta está acotada al tenant, así que el identificador ajeno\nsencillamente no existe desde aquí.","operationId":"businesses_get_service","security":[{"HTTPBearer":[]}],"parameters":[{"name":"service_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Service Id"}},{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceOut"},"examples":{"corte_clasico":{"summary":"«Corte clásico»: 30 minutos, $12.000 y con reservas ya asociadas","value":{"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","business_id":"3f2b9a10-0000-4000-8000-dddddddddddd","name":"Corte clásico","description":"Corte a tijera y máquina.","price_clp":12000,"duration_minutes":30,"modality":"IN_PERSON","requires_prepayment":false,"is_active":true,"has_bookings":true,"created_at":"2026-01-15T10:30:00Z","updated_at":"2026-01-15T10:30:00Z"}}}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`SERVICE_NOT_FOUND`: no hay ningún servicio con ese identificador **en este negocio**. Un `service_id` de otro local responde igual que uno inventado: el catálogo no revela qué identificadores existen fuera de tu tenant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"servicio_ajeno":{"summary":"El identificador no corresponde a ningún recurso de **este** negocio","value":{"code":"SERVICE_NOT_FOUND","message":"El servicio solicitado no existe.","details":null,"hint":"Comprueba el `service_id` con GET /api/v1/businesses/{business_id}/services.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"patch":{"tags":["businesses"],"summary":"Editar un servicio del catálogo","description":"Edición parcial del servicio: solo se modifica lo que venga en el cuerpo, con\nlos mismos límites que el alta. Reservado al **propietario**.\n\nCambiar `price_clp` o `duration_minutes` afecta únicamente a las reservas\nfuturas. Las ya existentes conservan el precio y la hora de término que se\npactaron: son una foto del momento de reservar, no una referencia al catálogo.\n\nPoner `is_active: false` equivale a retirar el servicio sin borrarlo.","operationId":"businesses_update_service","security":[{"HTTPBearer":[]}],"parameters":[{"name":"service_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Service Id"}},{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUpdate"},"examples":{"subida_de_precio":{"summary":"Actualizar la tarifa sin tocar las reservas ya hechas","value":{"price_clp":20000}},"retirar":{"summary":"Retirar el servicio del catálogo sin borrarlo","value":{"is_active":false}}}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceOut"},"examples":{"tarifa_nueva":{"summary":"La tarifa sube a $20.000; las reservas ya hechas conservan la suya","value":{"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","business_id":"3f2b9a10-0000-4000-8000-dddddddddddd","name":"Corte clásico","description":"Corte a tijera y máquina.","price_clp":20000,"duration_minutes":30,"modality":"IN_PERSON","requires_prepayment":false,"is_active":true,"has_bookings":true,"created_at":"2026-01-15T10:30:00Z","updated_at":"2026-03-02T18:20:00Z"}}}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`SERVICE_NOT_FOUND`: no hay ningún servicio con ese identificador **en este negocio**. Un `service_id` de otro local responde igual que uno inventado: el catálogo no revela qué identificadores existen fuera de tu tenant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"servicio_ajeno":{"summary":"El identificador no corresponde a ningún recurso de **este** negocio","value":{"code":"SERVICE_NOT_FOUND","message":"El servicio solicitado no existe.","details":null,"hint":"Comprueba el `service_id` con GET /api/v1/businesses/{business_id}/services.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`OWNER_REQUIRED`: eres miembro del negocio, pero editar el catálogo es exclusivo de su propietario.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"solo_propietario":{"summary":"La sesión es de alguien de la plantilla y la operación es del dueño","value":{"code":"OWNER_REQUIRED","message":"Esta operación es exclusiva del propietario del negocio.","details":null,"hint":"Pide el cambio a la persona propietaria del negocio.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`VALIDATION_ERROR`: algún campo incumple sus límites (`name` 2–120, `description` ≤ 1000, `price_clp` entero ≥ 0, `duration_minutes` 5–480 y múltiplo de 5). `details` nombra el campo exacto en su `loc`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"duracion_fuera_de_rejilla":{"summary":"`duration_minutes` no es múltiplo de 5, y la agenda avanza en esa rejilla","value":{"code":"VALIDATION_ERROR","message":"Los datos enviados no son válidos.","details":[{"loc":["body","duration_minutes"],"msg":"Input should be a multiple of 5"}],"hint":null,"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["businesses"],"summary":"Retirar un servicio del catálogo","description":"Retira el servicio del catálogo. Reservado al **propietario**.\n\nEl efecto depende del historial, y por eso `has_bookings` viaja en la ficha:\n\n- **Con reservas asociadas** la baja es lógica: el servicio queda con\n  `is_active: false`, sigue apareciendo en el listado del panel y las citas\n  históricas continúan apuntando a él.\n- **Sin reservas** la fila se elimina de verdad, junto con las asignaciones al\n  equipo que colgaban de ella.\n\nAmbos casos responden `204` sin cuerpo: desde fuera, el servicio ya no se\nofrece.","operationId":"businesses_delete_service","security":[{"HTTPBearer":[]}],"parameters":[{"name":"service_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Service Id"}},{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"responses":{"204":{"description":"Servicio retirado del catálogo. Sin cuerpo, y con el mismo código tanto si la fila se borró como si solo se desactivó."},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`SERVICE_NOT_FOUND`: no hay ningún servicio con ese identificador **en este negocio**. Un `service_id` de otro local responde igual que uno inventado: el catálogo no revela qué identificadores existen fuera de tu tenant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"servicio_ajeno":{"summary":"El identificador no corresponde a ningún recurso de **este** negocio","value":{"code":"SERVICE_NOT_FOUND","message":"El servicio solicitado no existe.","details":null,"hint":"Comprueba el `service_id` con GET /api/v1/businesses/{business_id}/services.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`OWNER_REQUIRED`: eres miembro del negocio, pero editar el catálogo es exclusivo de su propietario.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"solo_propietario":{"summary":"La sesión es de alguien de la plantilla y la operación es del dueño","value":{"code":"OWNER_REQUIRED","message":"Esta operación es exclusiva del propietario del negocio.","details":null,"hint":"Pide el cambio a la persona propietaria del negocio.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/businesses/{business_id}/staff":{"post":{"tags":["businesses"],"summary":"Invitar a una persona al equipo","description":"Incorpora a una persona al equipo del negocio y le envía por correo un enlace de\nacceso al panel, válido **72 horas** y de un solo uso.\n\nQué ocurre bajo el capó:\n\n- Si el correo **ya tiene cuenta** en Lukin, se reutiliza y solo se le **eleva**\n  el rol a `STAFF` cuando venía por debajo (`GUEST` o `CLIENT`). A un `OWNER` o a\n  un `SUPERADMIN` no se le degrada por trabajar en el local de otra persona.\n- Si **no la tiene**, se crea sin contraseña: entrará con el enlace del correo.\n- El enlace es el **mismo magic link** del acceso al panel\n  (`{PUBLIC_WEB_URL}/verify?token=…`) y se canjea en\n  `POST /api/v1/auth/b2b/verify`. Como todo reto de acceso, **invalida el código\n  de acceso vigente de ese correo** si la persona acababa de pedir uno: el enlace\n  de la invitación queda como el único válido. El correo de invitación **no**\n  incluye el código de 6 dígitos.\n\nUna misma cuenta puede estar en la plantilla de varios negocios; lo que no puede\nes repetirse dentro del mismo (`409 STAFF_ALREADY_MEMBER`).","operationId":"businesses_invite_staff","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StaffInvite"},"examples":{"peluquera":{"summary":"Invitar a una profesional al equipo","value":{"email":"ana@peluqueria-lukin.cl","display_name":"Ana"}}}}}},"responses":{"201":{"description":"Persona incorporada al equipo y correo de invitación enviado con su enlace de acceso.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StaffOut"},"examples":{"plaza_nueva":{"summary":"La plaza nace activa y la persona recibe su enlace de acceso por correo","value":{"id":"3f2b9a10-0000-4000-8000-cccccccccccc","user_id":"3f2b9a10-0000-4000-8000-555555555555","email":"staff1.barberia@lukin.dev","display_name":"Camila","is_active":true,"is_owner":false,"created_at":"2026-01-15T10:32:00Z"}}}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"402":{"description":"`PLAN_LIMIT_REACHED`: el tramo contratado ya tiene todas sus plazas ocupadas —el propietario cuenta como una— y esta invitación las dejaría en `details.requested`. Ojo con leer ese campo como el equipo actual: son las plazas de **después** de aceptar, y las de ahora van aparte en `details.used`, que es el número con el que se dice «tienes N profesionales». `details.required_plan` dice el tramo más barato que sí las admite; `hint` lleva a Ajustes → Suscripción. `details.limit_kind` vale siempre `STAFF` aquí, y hay que mirarlo igual: el mismo `code` cubre también el tope mensual de reservas del marketplace (`MONTHLY_BOOKINGS`), cuyo `details` no trae `requested` ni significa lo mismo.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_plazas":{"summary":"El plan INDIVIDUAL solo tiene una plaza y ya la ocupa el propietario","value":{"code":"PLAN_LIMIT_REACHED","message":"Tu plan no admite tantos profesionales.","details":{"limit_kind":"STAFF","limit":1,"used":1,"requested":2,"current_plan":"INDIVIDUAL","required_plan":"BASIC"},"hint":"Sube de plan en Ajustes → Suscripción para sumar más profesionales al equipo.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`OWNER_REQUIRED`: eres miembro del negocio, pero administrar el equipo es exclusivo de su propietario.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"solo_propietario":{"summary":"La sesión es de alguien de la plantilla y la operación es del dueño","value":{"code":"OWNER_REQUIRED","message":"Esta operación es exclusiva del propietario del negocio.","details":null,"hint":"Pide el cambio a la persona propietaria del negocio.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe, está dado de baja o quien pregunta no es miembro. Los tres casos responden igual.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"409":{"description":"`STAFF_ALREADY_MEMBER`: ese correo ya tiene plaza en este negocio, **incluso si está dada de baja**: la fila no se borra nunca porque las reservas la referencian. Se reincorpora con `PATCH {is_active: true}`, no volviendo a invitar. El propietario también la recibe si se invita a sí mismo: entró en la plantilla al crear el negocio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"ya_tiene_plaza":{"summary":"Ese correo ya tiene plaza aquí, incluso si está dada de baja","value":{"code":"STAFF_ALREADY_MEMBER","message":"Esa persona ya forma parte de la plantilla.","details":null,"hint":"Reincorpórala con PATCH .../staff/{staff_id} y `is_active: true`.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`VALIDATION_ERROR`: el correo no tiene forma de correo o `display_name` incumple sus límites (2–80 caracteres).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"correo_ilegible":{"summary":"El correo no tiene forma de correo","value":{"code":"VALIDATION_ERROR","message":"Los datos enviados no son válidos.","details":[{"loc":["body","email"],"msg":"value is not a valid email address"}],"hint":null,"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"429":{"description":"`STAFF_INVITE_COOLDOWN`: se emitió otro reto para ese **correo** hace menos de 60 s (el cooldown del contrato OTP es por destino, no por negocio). `details.retry_after` y la cabecera `Retry-After` dicen cuántos segundos faltan. No se crea ni la cuenta, ni la plaza, ni el reto: basta reintentar pasado ese plazo.","headers":{"Retry-After":{"description":"Segundos que faltan para poder volver a invitar.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"cooldown":{"summary":"Se emitió otro reto para ese correo hace menos de 60 s","value":{"code":"STAFF_INVITE_COOLDOWN","message":"Hay que esperar antes de volver a invitar a esa persona.","details":{"retry_after":42},"hint":"Reintenta cuando pasen los segundos de `Retry-After`.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"get":{"tags":["businesses"],"summary":"Listar el equipo del negocio","description":"Equipo del negocio, con los **activos primero** y, dentro de cada grupo, por\nnombre. Incluye a quien está dado de baja (`is_active: false`), porque su plaza\nsigue existiendo y se puede reincorporar.\n\nLa lee cualquier miembro del negocio, no solo el propietario: la agenda del día\nnecesita saber con quién se cuenta.","operationId":"businesses_list_staff","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/StaffOut"},"title":"Response Businesses List Staff"},"examples":{"plantilla_demo":{"summary":"La plantilla de `barberia-demo`: su dueño y la persona que invitó","value":[{"id":"3f2b9a10-0000-4000-8000-333333333333","user_id":"3f2b9a10-0000-4000-8000-111111111111","email":"owner.barberia@lukin.dev","display_name":"Rodrigo","is_active":true,"is_owner":true,"created_at":"2026-01-15T10:30:00Z"},{"id":"3f2b9a10-0000-4000-8000-cccccccccccc","user_id":"3f2b9a10-0000-4000-8000-555555555555","email":"staff1.barberia@lukin.dev","display_name":"Camila","is_active":true,"is_owner":false,"created_at":"2026-01-15T10:32:00Z"}]}}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe, está dado de baja o quien pregunta no es miembro. Los tres casos responden igual.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/businesses/{business_id}/staff/{staff_id}":{"get":{"tags":["businesses"],"summary":"Ver una persona de la plantilla","description":"Ficha de una persona de la plantilla. Un `staff_id` que no pertenece a **este**\nnegocio responde `404 STAFF_NOT_FOUND`, igual que uno inexistente: la API no\nrevela qué identificadores existen en los negocios de terceros.","operationId":"businesses_get_staff_member","security":[{"HTTPBearer":[]}],"parameters":[{"name":"staff_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Staff Id"}},{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StaffOut"},"examples":{"una_plaza":{"summary":"Camila, activa y sin ser la propietaria del local","value":{"id":"3f2b9a10-0000-4000-8000-cccccccccccc","user_id":"3f2b9a10-0000-4000-8000-555555555555","email":"staff1.barberia@lukin.dev","display_name":"Camila","is_active":true,"is_owner":false,"created_at":"2026-01-15T10:32:00Z"}}}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`STAFF_NOT_FOUND`: no hay ninguna persona con ese id en la plantilla de **este** negocio. Un `staff_id` de otro tenant responde lo mismo. `BUSINESS_NOT_FOUND` si quien pregunta no es miembro del negocio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"plaza_ajena":{"summary":"El identificador no corresponde a ningún recurso de **este** negocio","value":{"code":"STAFF_NOT_FOUND","message":"Esa persona no pertenece a la plantilla del negocio.","details":null,"hint":"Comprueba el `staff_id` con GET /api/v1/businesses/{business_id}/staff.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"patch":{"tags":["businesses"],"summary":"Editar una plaza del equipo","description":"Edición parcial de una plaza: su nombre en la vitrina y su presencia en la\nagenda. Reservado al propietario.\n\n`is_active: true` es la **única** manera de reincorporar a quien se dio de baja;\nvolver a invitar su correo responde `409 STAFF_ALREADY_MEMBER`, porque la fila\nnunca llegó a borrarse.\n\nY por eso **reincorporar consume plaza igual que invitar**: si el equipo activo\nque quedaría no cabe en el tramo contratado, la respuesta es `402\nPLAN_LIMIT_REACHED` y no se toca nada. Reactivar a quien ya está activo no\nsuma plaza y nunca recibe ese error, ni lo reciben el renombrado ni la baja.","operationId":"businesses_update_staff_member","security":[{"HTTPBearer":[]}],"parameters":[{"name":"staff_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Staff Id"}},{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StaffUpdate"},"examples":{"renombrar":{"summary":"Cambiar el nombre que ve el cliente","value":{"display_name":"Ana Pérez"}},"reincorporar":{"summary":"Reincorporar a quien estaba dada de baja","value":{"is_active":true}}}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StaffOut"},"examples":{"renombrada":{"summary":"El nombre visible cambia; la plaza y su historial siguen siendo los mismos","value":{"id":"3f2b9a10-0000-4000-8000-cccccccccccc","user_id":"3f2b9a10-0000-4000-8000-555555555555","email":"staff1.barberia@lukin.dev","display_name":"Camila Rojas","is_active":true,"is_owner":false,"created_at":"2026-01-15T10:32:00Z"}}}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`STAFF_NOT_FOUND`: no hay ninguna persona con ese id en la plantilla de **este** negocio. Un `staff_id` de otro tenant responde lo mismo. `BUSINESS_NOT_FOUND` si quien pregunta no es miembro del negocio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"plaza_ajena":{"summary":"El identificador no corresponde a ningún recurso de **este** negocio","value":{"code":"STAFF_NOT_FOUND","message":"Esa persona no pertenece a la plantilla del negocio.","details":null,"hint":"Comprueba el `staff_id` con GET /api/v1/businesses/{business_id}/staff.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"402":{"description":"`PLAN_LIMIT_REACHED`: reincorporar a esta persona —`is_active: true` sobre una plaza dada de baja— dejaría más profesionales activos de los que admite el tramo contratado. Es el **mismo** tope que corta la invitación (`POST .../staff`), porque reactivar y invitar suman una plaza igual. `details.used` son las plazas ocupadas ahora, `details.requested` las que quedarían, `details.limit` el tope del tramo vigente y `details.required_plan` el más barato que sí las admite. `details.limit_kind` vale `STAFF` y dice de cuál de los dos topes del plan se está hablando: el otro es `MONTHLY_BOOKINGS`, el de reservas del marketplace al mes, que comparte `code` y no comparte `details`. Reactivar a quien **ya** está activo no consume plaza y no puede recibir este error; renombrar y dar de baja, tampoco.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_plazas_para_reincorporar":{"summary":"Reactivar a Camila dejaría dos plazas activas y el plan INDIVIDUAL admite una","value":{"code":"PLAN_LIMIT_REACHED","message":"Tu plan no admite tantos profesionales.","details":{"limit_kind":"STAFF","limit":1,"used":1,"requested":2,"current_plan":"INDIVIDUAL","required_plan":"BASIC"},"hint":"Sube de plan en Ajustes → Suscripción para sumar más profesionales al equipo.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`OWNER_REQUIRED`: eres miembro del negocio, pero administrar el equipo es exclusivo de su propietario.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"solo_propietario":{"summary":"La sesión es de alguien de la plantilla y la operación es del dueño","value":{"code":"OWNER_REQUIRED","message":"Esta operación es exclusiva del propietario del negocio.","details":null,"hint":"Pide el cambio a la persona propietaria del negocio.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`VALIDATION_ERROR`: `display_name` incumple sus límites (2–80).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"nombre_corto":{"summary":"`display_name` se sale de sus límites (2–80 caracteres)","value":{"code":"VALIDATION_ERROR","message":"Los datos enviados no son válidos.","details":[{"loc":["body","display_name"],"msg":"String should have at least 2 characters"}],"hint":null,"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["businesses"],"summary":"Retirar a una persona de la agenda","description":"Retira a la persona de la agenda. El borrado es **lógico**: la fila persiste con\n`is_active: false` —las reservas históricas apuntan a ella— y sigue apareciendo\nen el listado, marcada como inactiva. Se reincorpora con\n`PATCH {is_active: true}`.\n\nReservado al propietario del negocio.","operationId":"businesses_deactivate_staff_member","security":[{"HTTPBearer":[]}],"parameters":[{"name":"staff_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Staff Id"}},{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"responses":{"204":{"description":"Persona retirada de la agenda. Sin cuerpo."},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`STAFF_NOT_FOUND`: no hay ninguna persona con ese id en la plantilla de **este** negocio. Un `staff_id` de otro tenant responde lo mismo. `BUSINESS_NOT_FOUND` si quien pregunta no es miembro del negocio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"plaza_ajena":{"summary":"El identificador no corresponde a ningún recurso de **este** negocio","value":{"code":"STAFF_NOT_FOUND","message":"Esa persona no pertenece a la plantilla del negocio.","details":null,"hint":"Comprueba el `staff_id` con GET /api/v1/businesses/{business_id}/staff.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`OWNER_REQUIRED`: eres miembro del negocio, pero administrar el equipo es exclusivo de su propietario.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"solo_propietario":{"summary":"La sesión es de alguien de la plantilla y la operación es del dueño","value":{"code":"OWNER_REQUIRED","message":"Esta operación es exclusiva del propietario del negocio.","details":null,"hint":"Pide el cambio a la persona propietaria del negocio.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/businesses/{business_id}/staff/{staff_id}/services":{"put":{"tags":["businesses"],"summary":"Fijar los servicios que presta una persona","description":"Fija **el conjunto completo** de servicios que presta esta persona. No es un\ndelta: lo que llega en `service_ids` sustituye a lo que hubiera, así que enviar\ndos veces el mismo cuerpo deja exactamente las mismas asignaciones y\n`service_ids: []` la deja sin ninguna. Reservado al **propietario**.\n\nTodo ocurre en una sola transacción y la validación va primero: si algún\nidentificador no está en el catálogo de este negocio —porque es de otro local o\nporque no existe— la respuesta es `422 SERVICE_NOT_IN_BUSINESS` con\n`details.invalid_ids`, y **la asignación anterior no se toca**.\n\nUn servicio **inactivo** también puede asignarse: el panel edita el catálogo\ncompleto para poder reactivar una prestación sin volver a repartirla entre el\nequipo. Lo que decide si aparece como reservable es el servicio, no la\nasignación.\n\nDar de baja a la persona (`PATCH .../staff/{staff_id}` con `is_active: false`)\n**conserva** estas filas, de modo que reincorporarla no obliga a reconfigurar\nnada.","operationId":"businesses_set_staff_services","security":[{"HTTPBearer":[]}],"parameters":[{"name":"staff_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Staff Id"}},{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StaffServicesUpdate"},"examples":{"dos_servicios":{"summary":"La persona pasa a prestar exactamente estos dos servicios","value":{"service_ids":["3f2b9a10-0000-4000-8000-aaaaaaaaaaaa","3f2b9a10-0000-4000-8000-bbbbbbbbbbbb"]}},"ninguno":{"summary":"Retirarle todos los servicios sin darla de baja","value":{"service_ids":[]}}}}}},"responses":{"200":{"description":"Conjunto reemplazado. Devuelve los servicios asignados.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ServiceOut"},"title":"Response Businesses Set Staff Services"},"examples":{"dos_servicios":{"summary":"Camila presta los dos cortes del catálogo","value":[{"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","business_id":"3f2b9a10-0000-4000-8000-dddddddddddd","name":"Corte clásico","description":"Corte a tijera y máquina.","price_clp":12000,"duration_minutes":30,"modality":"IN_PERSON","requires_prepayment":false,"is_active":true,"has_bookings":true,"created_at":"2026-01-15T10:30:00Z","updated_at":"2026-01-15T10:30:00Z"},{"id":"3f2b9a10-0000-4000-8000-aaaaaaaaaaaa","business_id":"3f2b9a10-0000-4000-8000-dddddddddddd","name":"Corte y barba","description":"Corte completo con perfilado.","price_clp":18000,"duration_minutes":45,"modality":"IN_PERSON","requires_prepayment":false,"is_active":true,"has_bookings":false,"created_at":"2026-01-15T10:30:00Z","updated_at":"2026-01-15T10:30:00Z"}]},"sin_asignaciones":{"summary":"`service_ids: []` la deja sin ninguno: sigue en la plantilla y deja de ofrecer horas","value":[]}}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`OWNER_REQUIRED`: eres miembro del negocio, pero administrar el equipo es exclusivo de su propietario.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"solo_propietario":{"summary":"La sesión es de alguien de la plantilla y la operación es del dueño","value":{"code":"OWNER_REQUIRED","message":"Esta operación es exclusiva del propietario del negocio.","details":null,"hint":"Pide el cambio a la persona propietaria del negocio.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`STAFF_NOT_FOUND`: no hay ninguna persona con ese id en la plantilla de **este** negocio. Un `staff_id` de otro tenant responde lo mismo. `BUSINESS_NOT_FOUND` si quien pregunta no es miembro del negocio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"plaza_ajena":{"summary":"El identificador no corresponde a ningún recurso de **este** negocio","value":{"code":"STAFF_NOT_FOUND","message":"Esa persona no pertenece a la plantilla del negocio.","details":null,"hint":"Comprueba el `staff_id` con GET /api/v1/businesses/{business_id}/staff.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`SERVICE_NOT_IN_BUSINESS`: alguno de los `service_ids` no está en el catálogo de este negocio (es de otro local o no existe). `details.invalid_ids` los lista en el orden en que llegaron y **no se guarda nada**: la asignación anterior queda intacta. `VALIDATION_ERROR` cubre el otro 422 de esta ruta: identificadores repetidos o mal formados en el cuerpo.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"servicio_de_otro_local":{"summary":"Algún `service_id` no está en el catálogo de este negocio; no se guarda nada","value":{"code":"SERVICE_NOT_IN_BUSINESS","message":"Alguno de los servicios no pertenece al catálogo del negocio.","details":{"invalid_ids":["3f2b9a10-0000-4000-8000-aaaaaaaaaaaa"]},"hint":"Envía identificadores de GET /api/v1/businesses/{business_id}/services.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"get":{"tags":["businesses"],"summary":"Listar los servicios que presta una persona","description":"Servicios que presta esta persona ahora mismo, ordenados por nombre e\n**incluidos los inactivos**, igual que el catálogo del panel.\n\nLa lee cualquier miembro del negocio: la agenda necesita saber quién puede\natender qué. El `PUT` sobre esta misma ruta, en cambio, es del propietario.","operationId":"businesses_list_staff_services","security":[{"HTTPBearer":[]}],"parameters":[{"name":"staff_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Staff Id"}},{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ServiceOut"},"title":"Response Businesses List Staff Services"},"examples":{"dos_servicios":{"summary":"Camila presta los dos cortes del catálogo","value":[{"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","business_id":"3f2b9a10-0000-4000-8000-dddddddddddd","name":"Corte clásico","description":"Corte a tijera y máquina.","price_clp":12000,"duration_minutes":30,"modality":"IN_PERSON","requires_prepayment":false,"is_active":true,"has_bookings":true,"created_at":"2026-01-15T10:30:00Z","updated_at":"2026-01-15T10:30:00Z"},{"id":"3f2b9a10-0000-4000-8000-aaaaaaaaaaaa","business_id":"3f2b9a10-0000-4000-8000-dddddddddddd","name":"Corte y barba","description":"Corte completo con perfilado.","price_clp":18000,"duration_minutes":45,"modality":"IN_PERSON","requires_prepayment":false,"is_active":true,"has_bookings":false,"created_at":"2026-01-15T10:30:00Z","updated_at":"2026-01-15T10:30:00Z"}]},"sin_asignaciones":{"summary":"`service_ids: []` la deja sin ninguno: sigue en la plantilla y deja de ofrecer horas","value":[]}}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`STAFF_NOT_FOUND`: no hay ninguna persona con ese id en la plantilla de **este** negocio. Un `staff_id` de otro tenant responde lo mismo. `BUSINESS_NOT_FOUND` si quien pregunta no es miembro del negocio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"plaza_ajena":{"summary":"El identificador no corresponde a ningún recurso de **este** negocio","value":{"code":"STAFF_NOT_FOUND","message":"Esa persona no pertenece a la plantilla del negocio.","details":null,"hint":"Comprueba el `staff_id` con GET /api/v1/businesses/{business_id}/staff.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/businesses/{business_id}/services/{service_id}/staff":{"get":{"tags":["businesses"],"summary":"Listar quién presta un servicio","description":"Quién puede prestar este servicio: la **vista inversa** de la asignación,\nordenada por `display_name`.\n\nSolo devuelve personas **activas**. Alguien dado de baja conserva sus\nasignaciones —para reincorporarlo sin reconfigurar nada— pero desaparece de esta\nlista, porque no puede recibir citas nuevas.\n\nEs la ruta que debe consumir el **panel del negocio** para poblar el selector de\nprofesional al crear una reserva. Las rutas `/api/v1/public/*` no valen para eso:\nsolo sirven negocios publicados, y el panel tiene que funcionar también con el\nlocal en borrador.\n\nUn `service_id` que no pertenece a este negocio responde `404 SERVICE_NOT_FOUND`,\nigual que uno inventado.","operationId":"businesses_list_service_staff","security":[{"HTTPBearer":[]}],"parameters":[{"name":"service_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Service Id"}},{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/StaffOut"},"title":"Response Businesses List Service Staff"},"examples":{"quien_lo_presta":{"summary":"Las dos personas que hoy prestan «Corte clásico» en `barberia-demo`","value":[{"id":"3f2b9a10-0000-4000-8000-333333333333","user_id":"3f2b9a10-0000-4000-8000-111111111111","email":"owner.barberia@lukin.dev","display_name":"Rodrigo","is_active":true,"is_owner":true,"created_at":"2026-01-15T10:30:00Z"},{"id":"3f2b9a10-0000-4000-8000-cccccccccccc","user_id":"3f2b9a10-0000-4000-8000-555555555555","email":"staff1.barberia@lukin.dev","display_name":"Camila","is_active":true,"is_owner":false,"created_at":"2026-01-15T10:32:00Z"}]}}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`SERVICE_NOT_FOUND`: no hay ningún servicio con ese identificador **en este negocio**. Un `service_id` de otro local responde igual que uno inventado.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"servicio_ajeno":{"summary":"El identificador no corresponde a ningún recurso de **este** negocio","value":{"code":"SERVICE_NOT_FOUND","message":"El servicio solicitado no existe.","details":null,"hint":"Comprueba el `service_id` con GET /api/v1/businesses/{business_id}/services.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/businesses/{business_id}/staff/{staff_id}/schedule":{"put":{"tags":["businesses"],"summary":"Reemplazar el horario semanal de una persona","description":"Reemplaza **todo** el horario semanal de una persona de la plantilla y devuelve\nel que queda vigente, ordenado por `(weekday, starts_at)`.\n\nEl horario es una **lista de tramos**, no una celda por día: el mismo `weekday`\nadmite varios, que es como se parte la jornada. `weekday` va de **0 = lunes** a\n6 = domingo, igual que `datetime.date.weekday()`, y las horas son **locales del\nnegocio** (`timezone` de su ficha), no UTC.\n\nLa **colación no se resta aquí**: es un bloqueo de tipo `BREAK`\n(`/businesses/{business_id}/blocks`), que sí sabe de excepciones y de fechas\nconcretas. Partir el turno en dos tramos también vale, pero una pausa que se\nrepite cada semana pertenece a los bloqueos.\n\nEl reemplazo es total y atómico: lo que no viaje en el cuerpo deja de existir, y\nuna **lista vacía la deja sin horario** sin darla de baja. Si algo no valida no\nse toca nada y el horario anterior sigue intacto.\n\nReglas que se validan antes de guardar:\n\n- `starts_at < ends_at` en cada tramo → si no, `422 SCHEDULE_INVALID_RANGE`.\n  Ningún tramo cruza la medianoche: un turno nocturno se parte en dos días.\n- Ningún par de tramos del mismo día se pisa ni se repite → si no,\n  `422 SCHEDULE_OVERLAP`, con el día y los tramos culpables en `details`. Dos\n  tramos que se **tocan** (13:00 y 13:00) son una jornada partida válida.\n- Cada tramo cabe dentro de las horas operativas del local **de ese mismo día**\n  → si no, `422 SCHEDULE_OUTSIDE_BUSINESS_HOURS`, con `details.business_ranges`\n  diciendo entre qué horas sí se puede fichar. Un día en el que el local no abre\n  rechaza cualquier tramo, con `business_ranges` vacío.\n\n```json\n{\n  \"code\": \"SCHEDULE_OUTSIDE_BUSINESS_HOURS\",\n  \"message\": \"El horario del trabajador debe caber dentro de las horas del local.\",\n  \"details\": {\n    \"weekday\": 0,\n    \"starts_at\": \"08:00:00\",\n    \"ends_at\": \"12:00:00\",\n    \"business_ranges\": [[\"09:00:00\", \"18:00:00\"]]\n  },\n  \"hint\": \"Ajústalo a las horas de apertura de ese día, o amplía el horario del local.\",\n  \"request_id\": \"b0f1…\"\n}\n```\n\n**Quién puede escribir.** El propietario edita el horario de cualquiera; una\npersona de la plantilla, solo el suyo (`403 STAFF_SELF_ONLY` en otro caso).\nUn `staff_id` de otro negocio responde `404 STAFF_NOT_FOUND` y un negocio del\nque no eres miembro, `404 BUSINESS_NOT_FOUND`: la API no revela qué\nidentificadores existen fuera.","operationId":"businesses_replace_staff_schedule","security":[{"HTTPBearer":[]}],"parameters":[{"name":"staff_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Staff Id"}},{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScheduleMatrix"},"examples":{"semana_tipo":{"summary":"Lunes corrido y miércoles con jornada partida","description":"El lunes trabaja de 10:00 a 14:00; el miércoles, de 10:00 a 13:00 y de 15:00 a 18:00. El resto de los días libra. Todos los tramos caben dentro de las horas del local.","value":[{"weekday":0,"starts_at":"10:00:00","ends_at":"14:00:00"},{"weekday":2,"starts_at":"10:00:00","ends_at":"13:00:00"},{"weekday":2,"starts_at":"15:00:00","ends_at":"18:00:00"}]},"sin_horario":{"summary":"Sin horario","description":"La lista vacía borra la agenda de esa persona sin darla de baja: deja de ofrecer horas y conserva su plaza y sus servicios.","value":[]}}}}},"responses":{"200":{"description":"Horario vigente del trabajador, ordenado por `(weekday, starts_at)`.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ScheduleEntry"},"title":"Response Businesses Replace Staff Schedule"},"examples":{"semana_tipo":{"summary":"Lunes corrido y miércoles con jornada partida","description":"El lunes trabaja de 10:00 a 14:00; el miércoles, de 10:00 a 13:00 y de 15:00 a 18:00. El resto de los días libra. Todos los tramos caben dentro de las horas del local.","value":[{"weekday":0,"starts_at":"10:00:00","ends_at":"14:00:00"},{"weekday":2,"starts_at":"10:00:00","ends_at":"13:00:00"},{"weekday":2,"starts_at":"15:00:00","ends_at":"18:00:00"}]},"sin_horario":{"summary":"Sin horario","description":"La lista vacía borra la agenda de esa persona sin darla de baja: deja de ofrecer horas y conserva su plaza y sus servicios.","value":[]}}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`STAFF_NOT_FOUND`: no hay ninguna persona con ese id en la plantilla de **este** negocio. Un `staff_id` de otro tenant responde lo mismo. `BUSINESS_NOT_FOUND` si quien pregunta no es miembro del negocio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"plaza_ajena":{"summary":"El identificador no corresponde a ningún recurso de **este** negocio","value":{"code":"STAFF_NOT_FOUND","message":"Esa persona no pertenece a la plantilla del negocio.","details":null,"hint":"Comprueba el `staff_id` con GET /api/v1/businesses/{business_id}/staff.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`STAFF_SELF_ONLY`: eres miembro del negocio, pero solo el propietario edita el horario de otras personas. El tuyo sí lo editas tú.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"horario_ajeno":{"summary":"La membresía es `STAFF` y el horario es de otra persona del equipo","value":{"code":"STAFF_SELF_ONLY","message":"Solo puedes gestionar tu propia agenda.","details":null,"hint":"El tuyo sí lo editas tú; el de otra persona lo cambia el propietario.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`SCHEDULE_INVALID_RANGE` (un tramo empieza después de terminar), `SCHEDULE_OVERLAP` (dos tramos del mismo día se pisan), `SCHEDULE_OUTSIDE_BUSINESS_HOURS` (un tramo se sale de las horas del local) o `VALIDATION_ERROR` (un `weekday` fuera de 0–6 o una hora imposible).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"fuera_del_local":{"summary":"Un tramo se sale de las horas de apertura de ese día","value":{"code":"SCHEDULE_OUTSIDE_BUSINESS_HOURS","message":"El horario del trabajador debe caber dentro de las horas del local.","details":{"weekday":0,"starts_at":"08:00:00","ends_at":"12:00:00","business_ranges":[["09:00:00","18:00:00"]]},"hint":"Ajústalo a las horas de apertura de ese día, o amplía el horario del local.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"solape":{"summary":"Dos tramos del mismo día se pisan","value":{"code":"SCHEDULE_OVERLAP","message":"Hay tramos que se solapan en el mismo día.","details":{"weekday":2,"conflicts":[["10:00:00","13:00:00"]]},"hint":"Junta los tramos que se pisan o corrige sus horas.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"get":{"tags":["businesses"],"summary":"Ver el horario semanal de una persona","description":"Horario semanal vigente de esa persona, ordenado por `(weekday, starts_at)`.\n\nEl horario es una **lista de tramos**, no una celda por día: el mismo `weekday`\nadmite varios, que es como se parte la jornada. `weekday` va de **0 = lunes** a\n6 = domingo, igual que `datetime.date.weekday()`, y las horas son **locales del\nnegocio** (`timezone` de su ficha), no UTC.\n\nLa **colación no se resta aquí**: es un bloqueo de tipo `BREAK`\n(`/businesses/{business_id}/blocks`), que sí sabe de excepciones y de fechas\nconcretas. Partir el turno en dos tramos también vale, pero una pausa que se\nrepite cada semana pertenece a los bloqueos.\n\nUn día sin tramos es un día **libre**, y una lista vacía es alguien sin horario.\nLo lee cualquier miembro del negocio, no solo el propietario ni la persona misma:\nla agenda del día es información de equipo.","operationId":"businesses_get_staff_schedule","security":[{"HTTPBearer":[]}],"parameters":[{"name":"staff_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Staff Id"}},{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"responses":{"200":{"description":"Horario vigente del trabajador, ordenado por `(weekday, starts_at)`.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ScheduleEntry"},"title":"Response Businesses Get Staff Schedule"},"examples":{"semana_tipo":{"summary":"Lunes corrido y miércoles con jornada partida","description":"El lunes trabaja de 10:00 a 14:00; el miércoles, de 10:00 a 13:00 y de 15:00 a 18:00. El resto de los días libra. Todos los tramos caben dentro de las horas del local.","value":[{"weekday":0,"starts_at":"10:00:00","ends_at":"14:00:00"},{"weekday":2,"starts_at":"10:00:00","ends_at":"13:00:00"},{"weekday":2,"starts_at":"15:00:00","ends_at":"18:00:00"}]},"sin_horario":{"summary":"Sin horario","description":"La lista vacía borra la agenda de esa persona sin darla de baja: deja de ofrecer horas y conserva su plaza y sus servicios.","value":[]}}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`STAFF_NOT_FOUND`: no hay ninguna persona con ese id en la plantilla de **este** negocio. Un `staff_id` de otro tenant responde lo mismo. `BUSINESS_NOT_FOUND` si quien pregunta no es miembro del negocio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"plaza_ajena":{"summary":"El identificador no corresponde a ningún recurso de **este** negocio","value":{"code":"STAFF_NOT_FOUND","message":"Esa persona no pertenece a la plantilla del negocio.","details":null,"hint":"Comprueba el `staff_id` con GET /api/v1/businesses/{business_id}/staff.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/businesses/{business_id}/blocks":{"post":{"tags":["businesses"],"summary":"Crear un bloqueo de agenda","description":"Declara un tramo en el que no se puede reservar: una colación (`BREAK`), un\ncierre (`BLOCK`) o una alteración puntual del horario habitual (`EXCEPTION`).\n\n`starts_at` y `ends_at` son instantes con **offset obligatorio**\n(`2026-04-06T13:00:00-04:00`) y se guardan en UTC. Un datetime sin zona se\nrechaza con `422`: no identifica ningún instante concreto.\n\nCon `staff_id: null` el bloqueo es **del local completo** y se le resta a toda la\nplantilla; con valor, solo a esa persona.\n\nLa recurrencia semanal se repite **en la zona horaria del negocio**\n(`timezone` de su ficha), no cada 168 horas: una colación de 13:00 a 14:00 sigue\nsiendo de 13:00 a 14:00 después de los cambios de hora de abril y de septiembre\nen Chile, aunque su instante UTC se mueva. No tiene fecha de fin —se termina\nborrando el bloqueo— y debe durar **menos de siete días**, o se pisaría a sí\nmisma en la repetición siguiente.\n\n**Quién puede escribir.** El propietario declara cualquier bloqueo, incluidos\nlos del local. Una persona de la plantilla, solo los de su propia plaza:\n`staff_id` nulo o ajeno responde `403 STAFF_SELF_ONLY`. Un `staff_id` de otro\nnegocio es `422 STAFF_NOT_IN_BUSINESS`, igual que uno inventado: la API no\nrevela qué identificadores existen fuera.","operationId":"businesses_create_block","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BlockCreate"},"examples":{"colacion_semanal":{"summary":"Colación de todo el local, cada semana","description":"`staff_id: null` se lo resta a toda la plantilla y `weekly_recurrence: true` lo repite cada lunes a las **13:00 hora local**, cambios de hora incluidos. No lleva fecha de fin: para terminarla se borra el bloqueo.","value":{"kind":"BREAK","starts_at":"2026-04-06T13:00:00-04:00","ends_at":"2026-04-06T14:00:00-04:00","weekly_recurrence":true,"reason":"Colación"}},"excepcion_puntual":{"summary":"Excepción de un día para una persona","description":"Un bloqueo puntual sobre una plaza concreta: ese día no atiende, y el resto de la plantilla sigue con su horario.","value":{"staff_id":"3f2b9a10-0000-4000-8000-aaaaaaaaaaaa","kind":"EXCEPTION","starts_at":"2026-09-18T09:00:00-03:00","ends_at":"2026-09-18T18:00:00-03:00","weekly_recurrence":false,"reason":"Fiestas Patrias"}}}}}},"responses":{"201":{"description":"Bloqueo creado. Devuelve la fila guardada.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BlockOut"},"examples":{"colacion_semanal":{"summary":"La regla guardada: `starts_at` es el instante del que arranca la repetición","value":{"id":"3f2b9a10-0000-4000-8000-222222222222","staff_member_id":null,"kind":"BREAK","starts_at":"2026-04-06T17:00:00Z","ends_at":"2026-04-06T18:00:00Z","weekly_recurrence":true,"reason":"Colación","created_at":"2026-03-30T12:00:00Z"}}}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`STAFF_SELF_ONLY`: eres miembro del negocio, pero solo el propietario bloquea el local entero (`staff_id: null`) o la agenda de otra persona. La tuya sí la bloqueas tú.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"agenda_ajena":{"summary":"La membresía es `STAFF` y el bloqueo es del local o de otra persona","value":{"code":"STAFF_SELF_ONLY","message":"Solo puedes gestionar tu propia agenda.","details":null,"hint":"Envía tu propio `staff_id`, o pide el bloqueo del local a su propietario.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe, está dado de baja o quien pregunta no es miembro. Los tres casos responden igual.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`BLOCK_INVALID_RANGE` (`ends_at` no es posterior a `starts_at`), `BLOCK_RECURRENCE_TOO_LONG` (un bloqueo semanal que dura siete días o más), `STAFF_NOT_IN_BUSINESS` (el `staff_id` no es una plaza de este negocio) o `VALIDATION_ERROR` (un `starts_at` **sin offset**, un `kind` desconocido o un `reason` de más de 200 caracteres).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"rango_imposible":{"summary":"`ends_at` no es posterior a `starts_at`","value":{"code":"BLOCK_INVALID_RANGE","message":"El bloqueo debe terminar después de empezar.","details":null,"hint":"Un tramo nocturno se parte en dos bloqueos, uno por día.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"plaza_de_otro_local":{"summary":"El `staff_id` no es una plaza de este negocio","value":{"code":"STAFF_NOT_IN_BUSINESS","message":"Esa persona no pertenece a la plantilla del negocio.","details":null,"hint":"Elige un `staff_id` de GET /api/v1/businesses/{business_id}/staff.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"get":{"tags":["businesses"],"summary":"Listar los bloqueos de un rango de fechas","description":"Ocurrencias bloqueadas dentro de la ventana `[from 00:00, to 24:00)` **en hora\nlocal del negocio**, ordenadas por `starts_at`.\n\nLo que se devuelve son ocurrencias, no reglas: un bloqueo semanal aparece una vez\npor cada semana que cae dentro del rango, y todas comparten el mismo `block_id`\n—que es con el que se edita o se borra el bloqueo entero—.\n\nLa recurrencia semanal se repite **en la zona horaria del negocio**\n(`timezone` de su ficha), no cada 168 horas: una colación de 13:00 a 14:00 sigue\nsiendo de 13:00 a 14:00 después de los cambios de hora de abril y de septiembre\nen Chile, aunque su instante UTC se mueva. No tiene fecha de fin —se termina\nborrando el bloqueo— y debe durar **menos de siete días**, o se pisaría a sí\nmisma en la repetición siguiente.\n\nVentana por defecto: `from` es **hoy** y `to`, `from + 6`\ndías, es decir la semana que empieza hoy. El rango no puede superar\n**92 días** ni terminar antes de empezar\n(`422 DATE_RANGE_TOO_LARGE`): expandir recurrencias cuesta en proporción al\nrango, y la agenda del panel nunca pide un año de una vez.\n\nCon `staff_id` se devuelven los bloqueos de esa persona **más los del local**\n(`staff_member_id: null`), porque un cierre del local también le quita horas a\ncada trabajador. Sin `staff_id`, todos los del negocio.\n\nLa lee cualquier miembro del negocio, no solo el propietario.","operationId":"businesses_list_blocks","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}},{"name":"from","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Primer día de la ventana (`YYYY-MM-DD`), en hora local del negocio. Por defecto, hoy.","examples":["2026-04-06"],"title":"From"},"description":"Primer día de la ventana (`YYYY-MM-DD`), en hora local del negocio. Por defecto, hoy."},{"name":"to","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Último día de la ventana, incluido. Por defecto, `from + 6` días. Como mucho 92 días después de `from`.","examples":["2026-05-03"],"title":"To"},"description":"Último día de la ventana, incluido. Por defecto, `from + 6` días. Como mucho 92 días después de `from`."},{"name":"staff_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"description":"Filtra por plaza: devuelve sus bloqueos **más los del local**. Sin él, todos los del negocio.","title":"Staff Id"},"description":"Filtra por plaza: devuelve sus bloqueos **más los del local**. Sin él, todos los del negocio."}],"responses":{"200":{"description":"Ocurrencias dentro de la ventana, ordenadas por `starts_at`.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/BlockOccurrence"},"title":"Response Businesses List Blocks"},"examples":{"dos_semanas":{"summary":"La colación semanal expandida: una ocurrencia por semana, mismo `block_id`","value":[{"block_id":"3f2b9a10-0000-4000-8000-222222222222","staff_member_id":null,"kind":"BREAK","starts_at":"2026-04-06T17:00:00Z","ends_at":"2026-04-06T18:00:00Z","weekly_recurrence":true,"reason":"Colación"},{"block_id":"3f2b9a10-0000-4000-8000-222222222222","staff_member_id":null,"kind":"BREAK","starts_at":"2026-04-13T17:00:00Z","ends_at":"2026-04-13T18:00:00Z","weekly_recurrence":true,"reason":"Colación"}]}}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe, está dado de baja o quien pregunta no es miembro. Los tres casos responden igual.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`DATE_RANGE_TOO_LARGE`: `to` es anterior a `from` o los separan más de 92 días. `STAFF_NOT_IN_BUSINESS` si el `staff_id` del filtro no es una plaza de este negocio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"ventana_larga":{"summary":"La ventana supera los 92 días, o `to` es anterior a `from`","value":{"code":"DATE_RANGE_TOO_LARGE","message":"El rango de fechas solicitado es demasiado amplio.","details":{"max_days":92,"requested_days":180},"hint":"Pide como mucho 92 días por consulta.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/businesses/{business_id}/blocks/{block_id}":{"patch":{"tags":["businesses"],"summary":"Editar un bloqueo de agenda","description":"Edición **parcial** del bloqueo: solo se toca lo que venga en el cuerpo.\n\nEl rango se revalida sobre el **resultado**, no sobre el cuerpo: enviar solo\n`ends_at` lo compara con el `starts_at` que ya estaba guardado. Si algo no valida\nno se escribe nada y el bloqueo queda exactamente como estaba.\n\nActúa sobre el bloqueo entero, no sobre una ocurrencia: mover una colación\nsemanal la mueve **todas** las semanas. Para saltarse un día concreto se borra el\nbloqueo y se declaran los tramos que sí valen.\n\nUna persona de la plantilla no puede enviar `staff_id`: reasignar un bloqueo es\ngestionar la agenda de otra persona (`403 STAFF_SELF_ONLY`).","operationId":"businesses_update_block","security":[{"HTTPBearer":[]}],"parameters":[{"name":"block_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Block Id"}},{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BlockUpdate"},"examples":{"renombrar":{"summary":"Cambiar solo la nota interna","value":{"reason":"Almuerzo"}},"mover":{"summary":"Correr media hora el bloqueo","value":{"starts_at":"2026-04-06T13:30:00-04:00","ends_at":"2026-04-06T14:30:00-04:00"}}}}}},"responses":{"200":{"description":"Bloqueo actualizado.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BlockOut"},"examples":{"renombrado":{"summary":"Solo cambió la nota interna; la recurrencia sigue siendo la misma","value":{"id":"3f2b9a10-0000-4000-8000-222222222222","staff_member_id":null,"kind":"BREAK","starts_at":"2026-04-06T17:00:00Z","ends_at":"2026-04-06T18:00:00Z","weekly_recurrence":true,"reason":"Almuerzo","created_at":"2026-03-30T12:00:00Z"}}}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`STAFF_SELF_ONLY`: eres miembro del negocio, pero solo el propietario bloquea el local entero (`staff_id: null`) o la agenda de otra persona. La tuya sí la bloqueas tú.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"agenda_ajena":{"summary":"La membresía es `STAFF` y el bloqueo es del local o de otra persona","value":{"code":"STAFF_SELF_ONLY","message":"Solo puedes gestionar tu propia agenda.","details":null,"hint":"Envía tu propio `staff_id`, o pide el bloqueo del local a su propietario.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BLOCK_NOT_FOUND`: no hay ningún bloqueo con ese id **en este negocio**. Uno de otro local responde igual que uno inventado. `BUSINESS_NOT_FOUND` si quien pregunta no es miembro del negocio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"bloqueo_ajeno":{"summary":"El identificador no corresponde a ningún recurso de **este** negocio","value":{"code":"BLOCK_NOT_FOUND","message":"El bloqueo solicitado no existe.","details":null,"hint":"Comprueba el `block_id` con GET /api/v1/businesses/{business_id}/blocks.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`BLOCK_INVALID_RANGE` (`ends_at` no es posterior a `starts_at`), `BLOCK_RECURRENCE_TOO_LONG` (un bloqueo semanal que dura siete días o más), `STAFF_NOT_IN_BUSINESS` (el `staff_id` no es una plaza de este negocio) o `VALIDATION_ERROR` (un `starts_at` **sin offset**, un `kind` desconocido o un `reason` de más de 200 caracteres).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"rango_imposible":{"summary":"`ends_at` no es posterior a `starts_at`","value":{"code":"BLOCK_INVALID_RANGE","message":"El bloqueo debe terminar después de empezar.","details":null,"hint":"Un tramo nocturno se parte en dos bloqueos, uno por día.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"plaza_de_otro_local":{"summary":"El `staff_id` no es una plaza de este negocio","value":{"code":"STAFF_NOT_IN_BUSINESS","message":"Esa persona no pertenece a la plantilla del negocio.","details":null,"hint":"Elige un `staff_id` de GET /api/v1/businesses/{business_id}/staff.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["businesses"],"summary":"Eliminar un bloqueo de agenda","description":"Elimina el bloqueo. El borrado es **duro**: la fila desaparece y sus ocurrencias\ndejan de restar horas de inmediato. Nada la referencia —las reservas ya\nconfirmadas no dependen de ella—, así que no hay historial que conservar.\n\nEs también la forma de **terminar una recurrencia semanal**, que no tiene fecha\nde fin.\n\nUn `block_id` de otro negocio responde `404 BLOCK_NOT_FOUND`, igual que uno\ninventado.","operationId":"businesses_delete_block","security":[{"HTTPBearer":[]}],"parameters":[{"name":"block_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Block Id"}},{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"responses":{"204":{"description":"Bloqueo eliminado. Sin cuerpo."},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"Credenciales ausentes o no válidas.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`STAFF_SELF_ONLY`: eres miembro del negocio, pero solo el propietario bloquea el local entero (`staff_id: null`) o la agenda de otra persona. La tuya sí la bloqueas tú.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"agenda_ajena":{"summary":"La membresía es `STAFF` y el bloqueo es del local o de otra persona","value":{"code":"STAFF_SELF_ONLY","message":"Solo puedes gestionar tu propia agenda.","details":null,"hint":"Envía tu propio `staff_id`, o pide el bloqueo del local a su propietario.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BLOCK_NOT_FOUND`: no hay ningún bloqueo con ese id **en este negocio**. Uno de otro local responde igual que uno inventado. `BUSINESS_NOT_FOUND` si quien pregunta no es miembro del negocio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o quien pregunta no es miembro","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` de la ruta con GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"bloqueo_ajeno":{"summary":"El identificador no corresponde a ningún recurso de **este** negocio","value":{"code":"BLOCK_NOT_FOUND","message":"El bloqueo solicitado no existe.","details":null,"hint":"Comprueba el `block_id` con GET /api/v1/businesses/{business_id}/blocks.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/public/legal/privacy":{"get":{"tags":["compliance"],"summary":"Política de privacidad vigente","description":"Versión vigente de la política de privacidad, con el enlace al texto completo y la fecha desde la\nque rige. **Público**: no exige sesión, porque el pie del marketplace y el\nformulario de alta lo necesitan antes de que exista ninguna cuenta.\n\nEl campo `version` es exactamente el que hay que enviar en `policy_version` al\nregistrar el consentimiento (`POST /api/v1/consents`): publicar una versión\nnueva aquí es lo que hace que la anterior deje de valer y que\n`GET /api/v1/auth/me` responda `consent_required: true`.\n\nLa respuesta se sirve con `Cache-Control: public, max-age=3600`. Por defecto\n`url` apunta a `{PUBLIC_WEB_URL}/privacidad`.","operationId":"compliance_read_privacy_policy","responses":{"200":{"description":"Documento vigente. Cacheable una hora.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegalDocumentOut"},"example":{"version":"2026-08-01","url":"https://lukin.cl/privacidad","effective_at":"2026-08-01","title":"Política de Privacidad"}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"forma_del_error":{"summary":"Forma con la que llega cualquier error de la API (el texto legal no rechaza nada)","value":{"code":"VALIDATION_ERROR","message":"Los datos enviados no son válidos.","details":null,"hint":"Ramifica siempre por `code`; `request_id` es lo que hay que citar al reportar.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/public/legal/terms":{"get":{"tags":["compliance"],"summary":"Términos y condiciones vigentes","description":"Versión vigente de los términos y condiciones, con el enlace al texto completo y la fecha desde la\nque rige. **Público**: no exige sesión, porque el pie del marketplace y el\nformulario de alta lo necesitan antes de que exista ninguna cuenta.\n\nEl campo `version` es exactamente el que hay que enviar en `policy_version` al\nregistrar el consentimiento (`POST /api/v1/consents`): publicar una versión\nnueva aquí es lo que hace que la anterior deje de valer y que\n`GET /api/v1/auth/me` responda `consent_required: true`.\n\nLa respuesta se sirve con `Cache-Control: public, max-age=3600`. Por defecto\n`url` apunta a `{PUBLIC_WEB_URL}/terminos`.","operationId":"compliance_read_terms","responses":{"200":{"description":"Documento vigente. Cacheable una hora.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegalDocumentOut"},"example":{"version":"2026-08-01","url":"https://lukin.cl/terminos","effective_at":"2026-08-01","title":"Términos y Condiciones"}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"forma_del_error":{"summary":"Forma con la que llega cualquier error de la API (el texto legal no rechaza nada)","value":{"code":"VALIDATION_ERROR","message":"Los datos enviados no son válidos.","details":null,"hint":"Ramifica siempre por `code`; `request_id` es lo que hay que citar al reportar.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/consents":{"post":{"tags":["compliance"],"summary":"Registrar un consentimiento del titular","description":"Registra que el titular de la sesión acepta un texto legal, en la versión que\ndeclara `policy_version`. Cada llamada **inserta** una prueba nueva: el\nhistórico no se reescribe nunca, así que aceptar dos veces deja dos filas y\nninguna se pierde.\n\n`policy_version` debe coincidir con la versión vigente que publica\n`GET /api/v1/public/legal/{privacy,terms}`; si no, la respuesta es\n`422 POLICY_VERSION_OUTDATED` con la versión buena en el `hint` y no se guarda\nnada. `MARKETING` no tiene texto propio —las comunicaciones comerciales se\ndescriben en la política de privacidad— y por eso se versiona con ella.\n\nLa respuesta incluye la `ip` y el `user_agent` con los que se otorgó: son lo que\nconvierte la fila en una prueba oponible y no en una mera afirmación. Aceptar\nlos documentos obligatorios en su versión vigente deja `consent_required` de\n`GET /api/v1/auth/me` en `false`.","operationId":"compliance_grant_consent","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsentCreate"},"examples":{"privacidad":{"summary":"Aceptar la política de privacidad vigente","value":{"consent_type":"PRIVACY_POLICY","policy_version":"2026-08-01"}},"marketing":{"summary":"Aceptar comunicaciones comerciales (opcional y revocable)","value":{"consent_type":"MARKETING","policy_version":"2026-08-01"}}}}},"required":true},"responses":{"201":{"description":"Consentimiento registrado, con su trazabilidad.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsentOut"},"example":{"id":"6f9619ff-8b86-d011-b42d-00c04fc964ff","consent_type":"PRIVACY_POLICY","policy_version":"2026-08-01","granted_at":"2026-08-15T14:32:07.512Z","ip":"198.51.100.9","user_agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID` / `USER_INACTIVE`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"No hay una sesión válida para esta petición.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`POLICY_VERSION_OUTDATED`: la versión enviada no es la vigente. El `hint` indica cuál lo es; vuelve a mostrar el texto antes de reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"version_caducada":{"summary":"Se aceptó un texto que ya no rige: no se guarda nada","value":{"code":"POLICY_VERSION_OUTDATED","message":"La versión que aceptaste ya no es la vigente.","details":{"consent_type":"PRIVACY_POLICY","sent_version":"2026-01-01","current_version":"2026-08-01"},"hint":"La versión vigente es 2026-08-01. Vuelve a mostrar el texto (https://lukin.cl/privacidad) y registra el consentimiento con ella.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"HTTPBearer":[]}]}},"/api/v1/consents/{consent_type}":{"delete":{"tags":["compliance"],"summary":"Revocar un consentimiento revocable","description":"Retira un consentimiento: sella `revoked_at` sobre la prueba que lo otorgó y la\ndevuelve ya marcada. **No borra nada** —el histórico sigue entero en\n`GET /api/v1/me/consents`—, porque la Ley 19.628 exige poder acreditar tanto que\nel titular consintió como desde cuándo dejó de hacerlo.\n\nSolo `MARKETING` es revocable. `TERMS` y `PRIVACY_POLICY` son indispensables\npara prestar el servicio y responden `409 CONSENT_REQUIRED_FOR_SERVICE`: quien\nno quiera seguir amparando el tratamiento de sus datos ejerce el derecho al\nolvido en `POST /api/v1/me/erasure`, que es una baja, no una revocación.\n\nSin un registro vigente que retirar —ya revocado o nunca otorgado— la respuesta\nes `409 CONSENT_ALREADY_REVOKED`.","operationId":"compliance_revoke_consent","security":[{"HTTPBearer":[]}],"parameters":[{"name":"consent_type","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ConsentType","description":"Consentimiento a retirar. Solo `MARKETING` es revocable."},"description":"Consentimiento a retirar. Solo `MARKETING` es revocable."}],"responses":{"200":{"description":"Consentimiento revocado, con la fecha en que dejó de valer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsentOut"},"example":{"id":"0d1f2e3a-4b5c-6d7e-8f90-1a2b3c4d5e6f","consent_type":"MARKETING","policy_version":"2026-08-01","granted_at":"2026-08-15T14:32:07.512Z","revoked_at":"2026-09-02T09:15:44.108Z","ip":"198.51.100.9","user_agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID` / `USER_INACTIVE`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"No hay una sesión válida para esta petición.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"409":{"description":"`CONSENT_ALREADY_REVOKED`: no hay nada vigente que retirar. `CONSENT_REQUIRED_FOR_SERVICE`: ese consentimiento sostiene el servicio; el `hint` lleva al derecho al olvido.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sostiene_el_servicio":{"summary":"`TERMS` y `PRIVACY_POLICY` no se revocan: la salida es el derecho al olvido","value":{"code":"CONSENT_REQUIRED_FOR_SERVICE","message":"Este consentimiento es indispensable para prestar el servicio.","details":null,"hint":"Si no quieres seguir en Lukin, ejerce POST /api/v1/me/erasure.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"nada_que_retirar":{"summary":"No hay un consentimiento vigente de ese tipo: ya se revocó o nunca se otorgó","value":{"code":"CONSENT_ALREADY_REVOKED","message":"No hay un consentimiento vigente que revocar.","details":null,"hint":"Consulta GET /api/v1/me/consents para ver cuáles siguen en pie.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/me/consents":{"get":{"tags":["compliance"],"summary":"Histórico de consentimientos del titular","description":"Histórico completo de consentimientos del titular de la sesión, del más reciente\nal más antiguo. Incluye los revocados —con su `revoked_at`— porque el derecho de\nacceso alcanza también a lo que se consintió y ya no se consiente.\n\nCada fila trae la versión del texto aceptado y la `ip` y el `user_agent` con los\nque se otorgó. Solo se ven los consentimientos propios: no hay forma de pedir\nlos de otra cuenta.","operationId":"compliance_read_my_consents","responses":{"200":{"description":"Histórico del titular, por `granted_at` descendente.","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/ConsentOut"},"type":"array","title":"Response Compliance Read My Consents"},"example":[{"id":"0d1f2e3a-4b5c-6d7e-8f90-1a2b3c4d5e6f","consent_type":"MARKETING","policy_version":"2026-08-01","granted_at":"2026-08-15T14:32:07.512Z","revoked_at":"2026-09-02T09:15:44.108Z","ip":"198.51.100.9","user_agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"},{"id":"6f9619ff-8b86-d011-b42d-00c04fc964ff","consent_type":"PRIVACY_POLICY","policy_version":"2026-08-01","granted_at":"2026-08-15T14:32:07.512Z","ip":"198.51.100.9","user_agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"}]}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID` / `USER_INACTIVE`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"No hay una sesión válida para esta petición.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"HTTPBearer":[]}]}},"/api/v1/me/data-export":{"get":{"tags":["compliance"],"summary":"Descargar una copia de mis datos personales","description":"Entrega una copia completa de los datos personales del titular de la sesión, en\nel formato portable `UserExport`: su ficha, el histórico de consentimientos\n(revocados incluidos), todas sus reservas con el local, el servicio y el precio\npactado, los cobros de esas reservas, las reseñas que escribió y los negocios\nvivos en los que es propietario o trabajador activo —un local dado de baja deja\nde figurar en `memberships`, aunque sus reservas sigan en `bookings`—.\n\nLa respuesta viaja con `Content-Disposition: attachment` y el nombre\n`lukin-export-{YYYYMMDD}.json`, de modo que el navegador la guarde como fichero\nen lugar de pintarla.\n\n**Solo salen datos del titular.** De las personas que aparecen en sus reservas\nse publica únicamente el nombre con el que el local las presenta en la vitrina\n(`staff_name`); ni correos, ni teléfonos, ni identificadores de terceros. Es el\nmismo documento que devuelve `POST /api/v1/public/privacy/confirm` a quien\nejerce el derecho sin tener cuenta.","operationId":"compliance_export_my_data","responses":{"200":{"description":"Copia de los datos del titular, como descarga (`Content-Disposition: attachment`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserExport"},"example":{"format_version":"1","exported_at":"2026-08-30T14:32:07.512Z","user":{"id":"6f9619ff-8b86-d011-b42d-00c04fc964ff","email":"javiera@example.cl","full_name":"Javiera Soto","phone":"+56912345678","role":"CLIENT","created_at":"2026-03-01T12:00:00Z"},"consents":[{"id":"0d1f2e3a-4b5c-6d7e-8f90-1a2b3c4d5e6f","consent_type":"PRIVACY_POLICY","policy_version":"2026-08-01","granted_at":"2026-03-01T12:00:00Z","ip":"198.51.100.9","user_agent":"Mozilla/5.0"}],"bookings":[{"id":"3f6c1f0e-2b1a-4c3d-9e8f-7a6b5c4d3e2f","business":{"slug":"barberia-nunoa","name":"Barbería Ñuñoa"},"service":{"name":"Corte de pelo"},"staff_name":"Ana","starts_at":"2026-03-02T13:00:00Z","ends_at":"2026-03-02T13:30:00Z","status":"PAID","price_clp":15000,"source":"WEB","created_at":"2026-03-01T12:05:00Z"}],"payments":[{"id":"9a8b7c6d-5e4f-4a3b-8c9d-0e1f2a3b4c5d","booking_id":"3f6c1f0e-2b1a-4c3d-9e8f-7a6b5c4d3e2f","amount_clp":15000,"status":"APPROVED","provider_payment_id":"1234567890","paid_at":"2026-03-01T12:06:00Z"}],"reviews":[{"id":"1b2c3d4e-5f60-4718-9a2b-3c4d5e6f7a8b","booking_id":"3f6c1f0e-2b1a-4c3d-9e8f-7a6b5c4d3e2f","rating":5,"comment":"Impecable.","published_at":"2026-03-02T14:00:00Z"}],"memberships":[{"business_slug":"barberia-nunoa","role":"STAFF"}]}}}},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1.0}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID` / `USER_INACTIVE`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"No hay una sesión válida para esta petición.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"HTTPBearer":[]}]}},"/api/v1/me/erasure":{"post":{"tags":["compliance"],"summary":"Eliminar mi cuenta (derecho al olvido)","description":"Elimina la cuenta del titular de la sesión. **Es irreversible**: no hay\npapelera, ni plazo de gracia, ni forma de recuperar el acceso después.\n\nQué desaparece: el correo (pasa a `deleted+{id}@anon.lukin`), el teléfono, el\nnombre, la contraseña y todas las sesiones abiertas —el `access_token` que\nacompañó a esta petición deja de valer y la cookie de refresco se caduca en la\nmisma respuesta—. Se borran además los códigos de verificación enviados a esos\ncontactos y se retiran los datos personales que quedaban en los cuerpos\narchivados de la pasarela de pago.\n\nQué **no** desaparece, y por qué: las reservas y los cobros siguen enteros\n—`price_clp`, `amount_clp`, `status` y `paid_at` incluidos— porque el negocio\ntiene obligaciones contables sobre lo que cobró; las reseñas conservan su nota y\nsu texto pero pasan a firmarse como «Usuario eliminado»; y los consentimientos\nse conservan como prueba de que hubo base legal para tratar los datos.\n\nAntes de borrar hay dos puertas. Un dueño con **negocios activos** recibe `409\nOWNER_HAS_BUSINESSES`: un local sin dueño no tiene quien responda de sus\nreservas, así que primero hay que darlos de baja con `DELETE\n/api/v1/businesses/{business_id}` —Lukin no traspasa la propiedad de un\nnegocio— y volver a pedir la eliminación. Y una cuenta de superadministración\nrecibe `403`: es la llave del panel de operación, no la de un titular de datos.\n\nEl cuerpo lleva `confirm: 'ELIMINAR'`, escrito exactamente así.\nCualquier otra cosa responde `422 CONFIRMATION_MISMATCH` y no borra nada.","operationId":"compliance_erase_my_account","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErasureRequest"},"examples":{"confirmar":{"summary":"Confirmar la eliminación (irreversible)","value":{"confirm":"ELIMINAR"}}}}},"required":true},"responses":{"202":{"description":"Cuenta anonimizada y sesiones revocadas. El 202 dice que el borrado ya está hecho y que no queda nada que consultar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErasureAccepted"},"example":{"message":"Tu cuenta se eliminó. Conservamos tus reservas y cobros sin ningún dato que te identifique.","anonymized_at":"2026-08-31T14:32:07.512Z"}}}},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1.0}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID` / `USER_INACTIVE`: no hay sesión válida. Tras el borrado, **toda** petición con el token anterior cae aquí con `USER_INACTIVE`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"La petición llegó sin cabecera `Authorization`","value":{"code":"TOKEN_MISSING","message":"No hay una sesión válida para esta petición.","details":null,"hint":"Envía `Authorization: Bearer <access_token>` de POST /api/v1/auth/login.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`SUPERADMIN_CANNOT_BE_ERASED`: una cuenta de superadministración no se elimina por esta vía.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"`OWNER_HAS_BUSINESSES`: el titular sigue siendo dueño de un negocio activo. `details.businesses` los nombra y el `hint` lleva a darlos de baja uno a uno.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"dueno_con_negocios":{"summary":"El titular sigue siendo dueño de un negocio activo","value":{"code":"OWNER_HAS_BUSINESSES","message":"Todavía eres dueño de un negocio activo.","details":{"businesses":[{"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","name":"Barbería Demo"}]},"hint":"Da de baja cada negocio con DELETE /api/v1/businesses/{business_id} y vuelve a pedir la eliminación.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"HTTPBearer":[]}]}},"/api/v1/public/privacy/request":{"post":{"tags":["compliance"],"summary":"Solicitar el ejercicio de un derecho sobre mis datos","description":"Inicia una solicitud sobre datos personales para quien **no tiene sesión**:\nquien reservó como invitado, quien perdió el acceso a su cuenta o quien\nsimplemente prefiere ejercer su derecho desde el formulario público.\n\nSe identifica con su correo **o** con su teléfono —al menos uno; sin ninguno de\nlos dos la respuesta es `422 CONTACT_REQUIRED`—. El teléfono no es un adorno: un\ncliente dado de alta desde la agenda del negocio puede no tener correo, y sin\nesta vía se quedaría sin forma de ejercer nada.\n\nSi ese contacto corresponde a una cuenta viva, sale un código de verificación a\nese mismo contacto: por correo cuando se envió el correo y por SMS cuando solo\nse envió el teléfono, con la plantilla `otp_privacy`. Su longitud y su vida las\nfija `OTP_POLICY[PRIVACY]` (F2-T03), no este endpoint.\n\n**Responde siempre `202`, con el mismo cuerpo**, exista o no la cuenta y se haya\nenviado o no un código: un mensaje distinto por caso convertiría la ruta en un\nenumerador de clientes de Lukin. Pedir un código otra vez antes del cooldown\ntampoco cambia la respuesta; el código que ya salió sigue siendo válido.\n\nEl siguiente paso es `POST /api/v1/public/privacy/confirm` con ese código.","operationId":"compliance_request_privacy_action","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PrivacyRequest"},"examples":{"por-correo":{"summary":"Titular con correo: el código sale por email","value":{"email":"javiera@example.cl","action":"EXPORT"}},"por-telefono":{"summary":"Invitado del que el negocio solo tiene el móvil: el código sale por SMS","value":{"phone":"+56912345678","action":"EXPORT"}}}}},"required":true},"responses":{"202":{"description":"Solicitud aceptada. La respuesta no revela si el contacto existe.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PrivacyRequestAccepted"},"example":{"message":"Si ese contacto corresponde a una cuenta de Lukin, te enviamos un código para verificar la solicitud."}}}},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1.0}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"`OTP_CHANNEL_UNAVAILABLE`: se pidió el código por SMS y el canal está deshabilitado (`SMS_PROVIDER=disabled`). Es una avería de configuración del servidor y no dice nada de la cuenta: se comprueba antes de mirar si el contacto existe.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_contacto":{"summary":"No llegó ni el correo ni el teléfono: no hay a quién mandarle el código","value":{"code":"CONTACT_REQUIRED","message":"Hace falta un correo electrónico o un número de teléfono.","details":null,"hint":"Envía el correo o el teléfono con el que figuras en Lukin: el código de verificación sale a ese mismo contacto.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/public/privacy/confirm":{"post":{"tags":["compliance"],"summary":"Verificar el código y ejercer el derecho solicitado","description":"Canjea el código recibido por el resultado de la solicitud. El contacto debe ser\nel **mismo** con el que se pidió (el correo o el teléfono), porque el reto se\nidentifica por ese destino.\n\nCon `action: 'EXPORT'` la respuesta es `200` con el mismo `UserExport` que\ndevuelve `GET /api/v1/me/data-export`: es el mismo derecho ejercido con otra\ncredencial.\n\nCon `action: 'ERASURE'` la respuesta es `202` y la cuenta queda anonimizada: es\nel mismo borrado irreversible que ejerce `POST /api/v1/me/erasure` con sesión, y\nexige igual que `confirm` valga exactamente `ELIMINAR`. Esa palabra\nse comprueba **antes** de tocar el reto, así que una confirmación ausente o mal\nescrita responde `422 CONFIRMATION_MISMATCH` **sin gastar el código**: el\ntitular reintenta con el mismo. Un dueño con negocios vivos recibe `409\nOWNER_HAS_BUSINESSES` y una cuenta de superadministración, `403`.\n\nUn código incorrecto y una solicitud que nunca existió (o que ya caducó, o que\nya se canjeó) responden **igual**, `401 OTP_INVALID`: separarlos diría a quien\nprueba contactos ajenos cuáles corresponden a una cuenta real. Agotar los\nintentos que fija `OTP_POLICY` responde `429 OTP_LOCKED`, que sí es accionable:\nhay que pedir un código nuevo.","operationId":"compliance_confirm_privacy_action","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PrivacyConfirm"},"examples":{"exportar-por-correo":{"summary":"Canjear el código recibido por correo y descargar los datos","value":{"email":"javiera@example.cl","code":"0000","action":"EXPORT"}},"exportar-por-telefono":{"summary":"Canjear el código recibido por SMS","value":{"phone":"+56912345678","code":"0000","action":"EXPORT"}},"eliminar-la-cuenta":{"summary":"Ejercer el derecho al olvido sin sesión (irreversible)","value":{"email":"javiera@example.cl","code":"0000","action":"ERASURE","confirm":"ELIMINAR"}}}}},"required":true},"responses":{"200":{"description":"Copia de los datos del titular, idéntica a la de `GET /me/data-export`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserExport"},"example":{"format_version":"1","exported_at":"2026-08-30T14:32:07.512Z","user":{"id":"6f9619ff-8b86-d011-b42d-00c04fc964ff","email":"javiera@example.cl","full_name":"Javiera Soto","phone":"+56912345678","role":"CLIENT","created_at":"2026-03-01T12:00:00Z"},"consents":[{"id":"0d1f2e3a-4b5c-6d7e-8f90-1a2b3c4d5e6f","consent_type":"PRIVACY_POLICY","policy_version":"2026-08-01","granted_at":"2026-03-01T12:00:00Z","ip":"198.51.100.9","user_agent":"Mozilla/5.0"}],"bookings":[{"id":"3f6c1f0e-2b1a-4c3d-9e8f-7a6b5c4d3e2f","business":{"slug":"barberia-nunoa","name":"Barbería Ñuñoa"},"service":{"name":"Corte de pelo"},"staff_name":"Ana","starts_at":"2026-03-02T13:00:00Z","ends_at":"2026-03-02T13:30:00Z","status":"PAID","price_clp":15000,"source":"WEB","created_at":"2026-03-01T12:05:00Z"}],"payments":[{"id":"9a8b7c6d-5e4f-4a3b-8c9d-0e1f2a3b4c5d","booking_id":"3f6c1f0e-2b1a-4c3d-9e8f-7a6b5c4d3e2f","amount_clp":15000,"status":"APPROVED","provider_payment_id":"1234567890","paid_at":"2026-03-01T12:06:00Z"}],"reviews":[{"id":"1b2c3d4e-5f60-4718-9a2b-3c4d5e6f7a8b","booking_id":"3f6c1f0e-2b1a-4c3d-9e8f-7a6b5c4d3e2f","rating":5,"comment":"Impecable.","published_at":"2026-03-02T14:00:00Z"}],"memberships":[{"business_slug":"barberia-nunoa","role":"STAFF"}]}}}},"429":{"description":"`OTP_LOCKED`: el código agotó sus intentos y ya no admite más comprobaciones; pide uno nuevo. `RATE_LIMITED`: se superó el cupo de verificaciones de la ruta.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"202":{"description":"Cuenta anonimizada (`action: 'ERASURE'`). El borrado ya está hecho: el 202 dice que no queda nada que consultar, no que haya una tarea pendiente.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErasureAccepted"},"example":{"message":"Tu cuenta se eliminó. Conservamos tus reservas y cobros sin ningún dato que te identifique.","anonymized_at":"2026-08-31T14:32:07.512Z"}}}},"401":{"description":"`OTP_INVALID`: el código no coincide, caducó, ya se usó o nunca hubo una solicitud para ese contacto. Los cuatro casos comparten respuesta a propósito.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"codigo_o_solicitud_no_validos":{"summary":"El código no coincide, caducó, ya se usó o nunca hubo solicitud para ese contacto","value":{"code":"OTP_INVALID","message":"El código no es correcto o la solicitud ya no está vigente.","details":null,"hint":"Revisa el código del mensaje o vuelve a pedir uno en /privacy/request.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`SUPERADMIN_CANNOT_BE_ERASED`: una cuenta de superadministración no se anonimiza por esta vía.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"`OWNER_HAS_BUSINESSES`: el titular todavía es dueño de un negocio activo; el `hint` lleva a darlos de baja.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"confirmacion_mal_escrita":{"summary":"`ERASURE` sin `confirm: 'ELIMINAR'`: no se gasta el código","value":{"code":"CONFIRMATION_MISMATCH","message":"Escribe la confirmación exactamente como se pide.","details":{"expected":"ELIMINAR"},"hint":"El código sigue vivo: reintenta con la palabra bien escrita.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/businesses/{business_id}/merchant-credential":{"put":{"tags":["payments"],"summary":"Conectar la cuenta de Mercado Pago del negocio","description":"Conecta —o reemplaza— la cuenta de Mercado Pago con la que este negocio cobra sus\nreservas. Los fondos van **directos a la cuenta del vendedor**: Lukin no es\nintermediario del dinero, solo crea el checkout con esta credencial.\n\nAntes de guardar nada, el backend valida el `access_token` contra Mercado Pago\n(`/users/me`). Si el proveedor lo rechaza, la respuesta es `422\nMP_CREDENTIAL_INVALID` y **no se crea ninguna fila**: un negocio nunca queda con\nuna credencial que no cobra.\n\nEl token y el `webhook_secret` se guardan cifrados con AES-256-GCM, atados al\nidentificador del negocio, y **no se devuelven jamás**: de vuelta solo llegan los\ncuatro últimos caracteres del token (`last4`), el id de vendedor y la clave\npública. Para saber cuál está conectada se usa esa pista, no el secreto.\n\nEl `webhook_secret` es opcional y es el del **propio vendedor**, el que firma sus\nnotificaciones (`x-signature`); no es el de la plataforma. Como ambos secretos\nviajan en el mismo criptograma, reconectar sin enviarlo lo borra: manda siempre\nlos dos valores que quieras conservar.","operationId":"payments_connect_merchant_credential","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MerchantCredentialIn"},"examples":{"sandbox":{"summary":"Credenciales de prueba, con secreto de firma propio","value":{"access_token":"TEST-************************","public_key":"TEST-00000000-0000-0000-0000-000000000000","webhook_secret":"********"}},"produccion":{"summary":"Credenciales de producción, sin secreto de firma","value":{"access_token":"APP_USR-************************","public_key":"APP_USR-00000000-0000-0000-0000-000000000000"}}}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MerchantCredentialOut"},"examples":{"conectada":{"summary":"Cuenta validada contra Mercado Pago y guardada cifrada","value":{"status":"CONNECTED","last4":"6789","mp_user_id":"44444444","public_key":"APP_USR-00000000-0000-0000-0000-000000000000","has_webhook_secret":true,"validated_at":"2026-08-31T14:32:07.512Z"}}}}}},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"`OWNER_REQUIRED`: la bóveda es exclusiva del propietario; la plantilla no la lee ni la escribe.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"no_eres_el_dueno":{"summary":"Quien llama es del equipo del negocio, pero no su propietario","value":{"code":"OWNER_REQUIRED","message":"Esta operación es exclusiva del propietario del negocio.","details":null,"hint":"Pídele al dueño que conecte la cuenta de cobro desde su sesión.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe, está dado de baja o quien pregunta no es su propietario. Los tres casos responden igual.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"`MP_CREDENTIAL_INVALID`: Mercado Pago rechazó el access token. `VALIDATION_ERROR`: algún campo se sale de su longitud.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"token_rechazado":{"summary":"Mercado Pago no reconoce el access token: no se guarda nada","value":{"code":"MP_CREDENTIAL_INVALID","message":"Mercado Pago rechazó estas credenciales.","details":null,"hint":"Copia el access token de producción desde «Tus integraciones → Credenciales» en el panel de Mercado Pago.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"502":{"description":"`MP_UNAVAILABLE`: Mercado Pago no respondió; el token puede ser válido, reinténtalo en unos minutos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"get":{"tags":["payments"],"summary":"Ver qué cuenta de Mercado Pago está conectada","description":"Estado de la bóveda de este negocio. Sirve para que el panel de cobros muestre\nqué cuenta está conectada sin poder leer el secreto: devuelve los cuatro últimos\ncaracteres del access token, el identificador de vendedor de Mercado Pago, la\nclave pública, si hay además un secreto de firma guardado y cuándo se validó por\núltima vez.\n\nUn negocio que todavía no conectó nada responde `404\nMERCHANT_CREDENTIAL_NOT_FOUND` —no un cuerpo vacío—, de modo que el dashboard\ndistingue «sin configurar» de «configurado» con el status y no inspeccionando\ncampos nulos.","operationId":"payments_read_merchant_credential","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MerchantCredentialOut"},"examples":{"conectada":{"summary":"Cuenta validada contra Mercado Pago y guardada cifrada","value":{"status":"CONNECTED","last4":"6789","mp_user_id":"44444444","public_key":"APP_USR-00000000-0000-0000-0000-000000000000","has_webhook_secret":true,"validated_at":"2026-08-31T14:32:07.512Z"}}}}}},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"`OWNER_REQUIRED`: la bóveda es exclusiva del propietario; la plantilla no la lee ni la escribe.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"no_eres_el_dueno":{"summary":"Quien llama es del equipo del negocio, pero no su propietario","value":{"code":"OWNER_REQUIRED","message":"Esta operación es exclusiva del propietario del negocio.","details":null,"hint":"Pídele al dueño que conecte la cuenta de cobro desde su sesión.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe o no es tuyo. `MERCHANT_CREDENTIAL_NOT_FOUND`: el negocio existe pero todavía no conectó ninguna credencial.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"boveda_vacia":{"summary":"El negocio existe pero todavía no conectó ninguna cuenta","value":{"code":"MERCHANT_CREDENTIAL_NOT_FOUND","message":"Este negocio todavía no conectó una cuenta de Mercado Pago.","details":null,"hint":"Conéctala con PUT /businesses/{business_id}/merchant-credential antes de cobrar por internet.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o no es tuyo","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` en GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["payments"],"summary":"Desconectar la cuenta de Mercado Pago del negocio","description":"Desconecta la cuenta de Mercado Pago del negocio y **borra** el criptograma: el\nsecreto retirado deja de existir, no se archiva. A partir de aquí el negocio no\npuede cobrar reservas por adelantado hasta que conecte otra credencial.\n\nEs idempotente: desconectar una bóveda que ya estaba vacía responde igualmente\n`204`. Los pagos ya cobrados no se tocan —viven en su propia tabla y conservan su\nhistorial—, y las reservas que exigían prepago dejarán de poder crear checkout.","operationId":"payments_disconnect_merchant_credential","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"responses":{"204":{"description":"Successful Response"},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"`OWNER_REQUIRED`: la bóveda es exclusiva del propietario; la plantilla no la lee ni la escribe.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"no_eres_el_dueno":{"summary":"Quien llama es del equipo del negocio, pero no su propietario","value":{"code":"OWNER_REQUIRED","message":"Esta operación es exclusiva del propietario del negocio.","details":null,"hint":"Pídele al dueño que conecte la cuenta de cobro desde su sesión.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe, está dado de baja o quien pregunta no es su propietario. Los tres casos responden igual.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/plans":{"get":{"tags":["payments"],"summary":"Ver los planes de Lukin y sus precios","description":"Catálogo comercial de Lukin: los planes que puede contratar un negocio, con su\nprecio mensual vigente en pesos chilenos y lo que incluye cada uno.\n\nEs **pública y sin sesión**: la consume la página de precios, que se ve antes de\nregistrarse. No depende de ningún negocio ni de quién pregunte, así que dos\nllamadas cualesquiera devuelven lo mismo.\n\nLos tres tramos de pago —`INDIVIDUAL`, `BASIC` y `PREMIUM`— van de menor a mayor\ny cada uno incluye las prestaciones del anterior: `BASIC` no añade ninguna sobre\n`INDIVIDUAL` (ahí el salto es de plazas de profesional y de cupo de mensajes,\nno de funciones) y `PREMIUM` sí suma las suyas encima. Las listas vienen en el\norden en que se muestran. El precio sale de la configuración del despliegue,\nde modo que un cambio de tarifa no exige una versión nueva de la API: quien\npinte la página no debe cachear el importe indefinidamente.\n\n**Esta lista es lo que Lukin anuncia, no todo lo que se puede contratar.** El\ntramo gratuito `FREE` existe —se contrata por `POST` de este mismo recurso, la\nprueba caduca a él y el panel de quien lo tiene lo lee en su suscripción— y hoy\n**no** sale aquí: anunciarlo es una decisión comercial y va detrás de un ajuste\ndel despliegue. Un cliente que reciba `FREE` en `GET .../subscription` o en un\n`details.required_plan` no debe sorprenderse de no encontrarlo en esta lista, y\nno debe deducir de esta lista qué códigos acepta el alta.\n\nCuando el tramo gratuito sí se anuncia, va el primero y es el único con\n`marketplace_bookings_monthly` distinto de `null`: ese es su límite de reservas\ndel marketplace al mes, y la misma cifra viene redactada dentro de `features`.","operationId":"payments_list_plans","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/PlanOut"},"type":"array","title":"Response Payments List Plans"},"examples":{"catalogo":{"summary":"Lo que se anuncia hoy: los tres tramos de pago, de menor a mayor","value":[{"code":"INDIVIDUAL","monthly_price_clp":11130,"annual_price_clp":111300,"annual_available":false,"max_staff":1,"marketplace_bookings_monthly":null,"whatsapp_messages_monthly":50,"capabilities":[],"features":["agenda","reservas ilimitadas","ficha en el marketplace","cobro online con tu cuenta","recordatorios por email","horarios y bloqueos por profesional"]},{"code":"BASIC","monthly_price_clp":24430,"annual_price_clp":244300,"annual_available":false,"max_staff":5,"marketplace_bookings_monthly":null,"whatsapp_messages_monthly":100,"capabilities":[],"features":["agenda","reservas ilimitadas","ficha en el marketplace","cobro online con tu cuenta","recordatorios por email","horarios y bloqueos por profesional"]},{"code":"PREMIUM","monthly_price_clp":38430,"annual_price_clp":384300,"annual_available":false,"max_staff":null,"marketplace_bookings_monthly":null,"whatsapp_messages_monthly":200,"capabilities":["METRICS"],"features":["agenda","reservas ilimitadas","ficha en el marketplace","cobro online con tu cuenta","recordatorios por email","horarios y bloqueos por profesional","métricas del panel"]}]},"catalogo_con_el_gratuito":{"summary":"Con el tramo gratuito anunciado: va el primero y es el único con tope","value":[{"code":"FREE","monthly_price_clp":0,"annual_price_clp":0,"annual_available":false,"max_staff":1,"marketplace_bookings_monthly":30,"whatsapp_messages_monthly":0,"capabilities":[],"features":["agenda","hasta 30 reservas del marketplace al mes","ficha en el marketplace","cobro online con tu cuenta","recordatorios por email","horarios y bloqueos por profesional"]},{"code":"INDIVIDUAL","monthly_price_clp":11130,"annual_price_clp":111300,"annual_available":false,"max_staff":1,"marketplace_bookings_monthly":null,"whatsapp_messages_monthly":50,"capabilities":[],"features":["agenda","reservas ilimitadas","ficha en el marketplace","cobro online con tu cuenta","recordatorios por email","horarios y bloqueos por profesional"]},{"code":"BASIC","monthly_price_clp":24430,"annual_price_clp":244300,"annual_available":false,"max_staff":5,"marketplace_bookings_monthly":null,"whatsapp_messages_monthly":100,"capabilities":[],"features":["agenda","reservas ilimitadas","ficha en el marketplace","cobro online con tu cuenta","recordatorios por email","horarios y bloqueos por profesional"]},{"code":"PREMIUM","monthly_price_clp":38430,"annual_price_clp":384300,"annual_available":false,"max_staff":null,"marketplace_bookings_monthly":null,"whatsapp_messages_monthly":200,"capabilities":["METRICS"],"features":["agenda","reservas ilimitadas","ficha en el marketplace","cobro online con tu cuenta","recordatorios por email","horarios y bloqueos por profesional","métricas del panel"]}]}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"forma_del_error":{"summary":"Forma con la que llega cualquier error de la API (el catálogo no rechaza nada)","value":{"code":"VALIDATION_ERROR","message":"Los datos enviados no son válidos.","details":null,"hint":"Ramifica siempre por `code`; `request_id` es lo que hay que citar al reportar.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/businesses/{business_id}/subscription":{"post":{"tags":["payments"],"summary":"Contratar un plan para el negocio","description":"Contrata un plan para este negocio y devuelve la URL de Mercado Pago en la que\nel titular autoriza el cobro mensual.\n\nLa domiciliación se crea con la cuenta de **Lukin**, no con la del local: esta es\nla suscripción a la plataforma, y va por la vía contraria al checkout de una\nreserva, que se cobra con la cuenta del vendedor para que los fondos vayan\ndirectos a ella. Los datos de la tarjeta no pasan por esta API.\n\nLa suscripción **no** queda activa al responder: al volver de Mercado Pago sigue\ncomo estaba —en prueba, o pendiente— hasta que la pasarela notifica la\nautorización por webhook, que es cuando pasa a `ACTIVE` con su periodo pagado.\nConsulta `GET` de este mismo recurso para saber en qué punto está.\n\nUn negocio que ya tiene la suscripción activa **y de pago** responde `409\nSUBSCRIPTION_ALREADY_ACTIVE`: cambiar de plan exige dar de baja el vigente\nprimero, para que no queden dos cargos mensuales sobre la misma cuenta. Un\nnegocio en el tramo gratuito no lo recibe —no hay ningún cobro que duplicar—, y\nsube al tramo de pago por esta misma ruta.\n\n**Un tramo que no cuesta nada no pasa por Mercado Pago**: `checkout_url` llega\n`null`, el alta queda cerrada al responder y el negocio ya está en su tramo\nnuevo. Ahí no hay pantalla de vuelta ni webhook que esperar, así que lo que toca\ndespués es repreguntar `GET` de este recurso y pintar el plan, nunca redirigir.\nLo contrario **no** se puede deducir: un `null` no siempre es un alta cerrada\n—hay un caso anómalo sobre un tramo de pago— y la descripción de `checkout_url`\ndice cómo comprobarlo. La conducta al recibirlo es la misma en los dos.\nEse tramo tampoco exige correo del titular y **acepta cualquier\n`billing_cycle`**, que se ignora: sin cargo, «cada mes» y «cada año» son lo\nmismo. No se rechaza porque `ANNUAL_BILLING_UNAVAILABLE` habla de un tope por\ntransacción de Mercado Pago, y este tramo no llega a Mercado Pago. Los tramos de\npago sí lo rechazan con `422 ANNUAL_BILLING_UNAVAILABLE` mientras\n`annual_available` sea `false`.\n\nSubir del gratuito a un tramo de pago conserva el tramo gratuito hasta que\nMercado Pago confirme la autorización: durante esa ventana `GET` de este recurso\nresponde el plan de antes con `upgrade_pending: true`, que es la única señal de\nque el alta está a medias.","operationId":"payments_create_subscription","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubscriptionCreate"},"examples":{"premium":{"summary":"Contratar el plan PREMIUM","description":"Sin `billing_cycle` la domiciliación se crea **mensual**, que es el valor por defecto del contrato.","value":{"plan":"PREMIUM"}},"basic":{"summary":"Contratar el plan de entrada","value":{"plan":"BASIC"}},"premium_anual":{"summary":"Contratar el plan PREMIUM por un año","description":"El ciclo anual cobra **diez** mensualidades de una sola vez —dos meses gratis— y hay que pedirlo explícitamente: el cuerpo sin `billing_cycle` contrata el mensual. El importe que se domicilia es el `annual_price_clp` que publica `GET /plans` para ese tramo, y no se prorratea si se cambia de plan a mitad de período.","value":{"plan":"PREMIUM","billing_cycle":"ANNUAL"}}}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubscriptionCheckoutOut"},"examples":{"autorizacion_pendiente":{"summary":"A dónde mandar al titular para que autorice el cargo mensual","value":{"checkout_url":"https://www.mercadopago.cl/subscriptions/checkout?preapproval_id=2c9380849"}},"sin_nada_que_autorizar":{"summary":"Un tramo que no cobra: el alta queda cerrada aquí y no hay a dónde ir","value":{"checkout_url":null}}}}}},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"`OWNER_REQUIRED`: la suscripción es exclusiva del propietario; la plantilla no la lee ni la cambia.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"no_eres_el_dueno":{"summary":"Quien llama pertenece al equipo del negocio, pero no es su propietario","value":{"code":"OWNER_REQUIRED","message":"Esta operación es exclusiva del propietario del negocio.","details":null,"hint":"El plan lo contrata y lo da de baja el dueño desde su sesión.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe, está dado de baja o quien pregunta no es su propietario. Los tres casos responden igual.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"`PLAN_LIMIT_REACHED`: el equipo activo del negocio —el propietario cuenta como una plaza— no cabe en el plan que se pide contratar. `details.limit_kind` vale `STAFF`: el mismo `code` cubre **dos** topes distintos del plan —las plazas de profesional y `MONTHLY_BOOKINGS`, las reservas del marketplace al mes— y este campo es lo único que dice de cuál se está hablando, porque el resto de `details` no coincide entre los dos. Ramifica por él, no por el texto del mensaje. `details.used` son las plazas ocupadas ahora mismo, `details.limit` el tope del plan pedido y `details.required_plan` el tramo más barato que sí las admite. Aquí `details.requested` **coincide** con `used`, porque contratar no suma a nadie: se pregunta si el equipo que ya hay cabe en el tramo pedido. En la invitación (`POST .../staff`) los dos campos difieren en uno, así que la frase «tienes N profesionales» se compone siempre con `used`. `details.current_plan` repite aquí el plan **pedido**, no el vigente: es el mismo campo que rellena la invitación, donde `plan` sí es el vigente. Se comprueba antes de hablar con Mercado Pago: no se crea ninguna domiciliación, y también corta el alta del tramo gratuito, que admite una sola plaza.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"equipo_no_cabe_en_individual":{"summary":"Un negocio con tres plazas activas pide bajar a INDIVIDUAL, que solo admite una","value":{"code":"PLAN_LIMIT_REACHED","message":"Tu plan no admite tantos profesionales.","details":{"limit_kind":"STAFF","limit":1,"used":3,"requested":3,"current_plan":"INDIVIDUAL","required_plan":"BASIC"},"hint":"Sube de plan en Ajustes → Suscripción para sumar más profesionales al equipo.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"409":{"description":"`SUBSCRIPTION_ALREADY_ACTIVE`: el negocio ya tiene una suscripción activa; dála de baja antes de contratar otra.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"ya_tiene_plan":{"summary":"El negocio ya paga un plan: primero hay que darlo de baja","value":{"code":"SUBSCRIPTION_ALREADY_ACTIVE","message":"Este negocio ya tiene una suscripción activa.","details":null,"hint":"Da de baja la vigente con DELETE /businesses/{business_id}/subscription y vuelve a contratar.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`VALIDATION_ERROR`: `plan` no es ninguno de los cuatro códigos del catálogo. `CONTACT_REQUIRED`: el titular del negocio no tiene correo con el que domiciliar el cobro —no aplica a un tramo que no cobra: sin domiciliación no hay pagador—. `ANNUAL_BILLING_UNAVAILABLE`: se pidió el ciclo anual sobre un tramo que sí cobra y hoy solo se cobra mensualidad. Un tramo de precio 0 **no** lo recibe: ahí el ciclo no describe ningún cargo, así que se ignora en vez de rechazarse.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"anual_apagado":{"summary":"Se pide el ciclo anual de un tramo de pago mientras Lukin solo cobra mensualidades","value":{"code":"ANNUAL_BILLING_UNAVAILABLE","message":"El pago anual no está disponible por ahora. Contrata el plan con ciclo mensual.","details":null,"hint":"Reintenta con `billing_cycle: MONTHLY`; `annual_available` de GET /api/v1/plans dice qué tramos admiten el año.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"502":{"description":"`MP_UNAVAILABLE`: Mercado Pago no respondió al crear la domiciliación; reintentar es seguro, no se creó ninguna. `MP_CREDENTIAL_INVALID`: la credencial de **la plataforma** dejó de servir; no es nada que el negocio pueda arreglar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"get":{"tags":["payments"],"summary":"Ver la suscripción del negocio","description":"Estado de la suscripción SaaS de este negocio: plan, situación de cobro, hasta\ncuándo está pagado el periodo en curso, cuándo caduca la prueba, el importe que\nse está domiciliando y el identificador de la domiciliación en Mercado Pago.\n\n`amount_clp` es lo que este negocio **paga**, no lo que su tramo cuesta hoy:\nuna domiciliación en curso conserva su importe aunque suba la lista de precios,\nasí que para decir cuánto se cobra hay que leer este campo y no el de\n`GET /plans`. Llega `null` mientras no haya nada contratado —una suscripción\nsolo en prueba— o si se firmó antes de que el campo existiera.\n\nUn negocio recién creado responde `TRIAL` con su `trial_ends_at`: la prueba se\nabre sola al dar de alta el local y no hay que contratar nada para empezar.\n\n`PAST_DUE` significa que un cobro falló y que el negocio **sigue funcionando**\ndurante los días de gracia; pasados esos días deja de poder publicarse en el\nmarketplace y de cobrar reservas por internet, aunque conserva su agenda y sus\ncitas ya hechas. Un negocio sin ninguna suscripción vigente responde `404\nSUBSCRIPTION_NOT_FOUND`, no un cuerpo vacío.\n\n**`upgrade_pending` es lo único que delata un alta a medias.** Al contratar un\ntramo de pago desde el gratuito, la suscripción conserva su tramo hasta que\nMercado Pago confirme la autorización, así que durante esa ventana `plan` sigue\ndiciendo el de antes y `status` sigue diciendo `ACTIVE`: leídos solos, los dos\ndescriben una cuenta perfectamente normal. Sin este campo, la pantalla de vuelta\ndel pago canta un éxito que todavía no ha ocurrido. No dice **a qué** tramo se\nsube: eso vive en la domiciliación hasta que se cobra.\n\nEl tramo gratuito se lee aquí como cualquier otro, esté o no anunciado en\n`GET /plans`: `ACTIVE` sin `current_period_end`, `amount_clp` a `0` y sin\n`provider_preapproval_id` —salvo, justamente, mientras hay una subida pendiente—.","operationId":"payments_read_subscription","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubscriptionOut"},"examples":{"al_dia":{"summary":"Suscripción activa, con el periodo pagado en curso","value":{"plan":"PREMIUM","status":"ACTIVE","current_period_end":"2026-09-30T14:00:00Z","trial_ends_at":"2026-08-15T14:00:00Z","grace_ends_at":null,"provider_preapproval_id":"2c93808493d7f4e60193e0a1b2c30001","billing_cycle":"MONTHLY","amount_clp":38430,"upgrade_pending":false,"marketplace_quota":null}},"en_prueba":{"summary":"Negocio recién creado: la prueba se abre sola al dar de alta el local","value":{"plan":"PREMIUM","status":"TRIAL","current_period_end":null,"trial_ends_at":"2026-08-15T14:00:00Z","grace_ends_at":null,"provider_preapproval_id":null,"billing_cycle":"MONTHLY","amount_clp":null,"upgrade_pending":false,"marketplace_quota":null}},"gratuito_con_su_cupo":{"summary":"Tramo gratuito: lo único que se publica del tope está aquí","value":{"plan":"FREE","status":"ACTIVE","current_period_end":null,"trial_ends_at":null,"grace_ends_at":null,"provider_preapproval_id":null,"billing_cycle":"MONTHLY","amount_clp":0,"upgrade_pending":false,"marketplace_quota":{"limit":30,"used":22,"remaining":8}}},"gratuito_subiendo_de_tramo":{"summary":"Tramo gratuito con una subida contratada y todavía sin cobrar","value":{"plan":"FREE","status":"ACTIVE","current_period_end":null,"trial_ends_at":null,"grace_ends_at":null,"provider_preapproval_id":"2c93808493d7f4e60193e0a1b2c30002","billing_cycle":"MONTHLY","amount_clp":0,"upgrade_pending":true,"marketplace_quota":{"limit":30,"used":22,"remaining":8}}}}}}},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"`OWNER_REQUIRED`: la suscripción es exclusiva del propietario; la plantilla no la lee ni la cambia.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"no_eres_el_dueno":{"summary":"Quien llama pertenece al equipo del negocio, pero no es su propietario","value":{"code":"OWNER_REQUIRED","message":"Esta operación es exclusiva del propietario del negocio.","details":null,"hint":"El plan lo contrata y lo da de baja el dueño desde su sesión.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe o no es tuyo. `SUBSCRIPTION_NOT_FOUND`: el negocio existe pero no tiene ninguna suscripción vigente.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_suscripcion":{"summary":"El negocio existe pero no tiene ninguna suscripción vigente","value":{"code":"SUBSCRIPTION_NOT_FOUND","message":"Este negocio no tiene ninguna suscripción vigente.","details":null,"hint":"Contrata un plan con POST /businesses/{business_id}/subscription.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o no es tuyo","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` en GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["payments"],"summary":"Dar de baja la suscripción del negocio","description":"Da de baja la suscripción del negocio y devuelve la ficha resultante, ya en\nestado `CANCELLED`.\n\nPrimero se cancela la domiciliación en Mercado Pago y solo después se marca la\nfila: al revés, un fallo del proveedor dejaría a Lukin creyendo que no cobra\nmientras el cargo mensual sigue vivo en la cuenta del titular. Una\ndomiciliación que Mercado Pago ya no conoce no bloquea la baja.\n\nLa fila **no se borra**: el historial de altas y bajas es auditoría del cobro, y\nsolo hay una suscripción vigente por negocio en cada momento. A partir de aquí\nel local no puede publicarse en el marketplace ni cobrar reservas por internet,\npero conserva su agenda, su equipo y sus citas.","operationId":"payments_cancel_subscription","security":[{"HTTPBearer":[]}],"parameters":[{"name":"business_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Business Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubscriptionOut"},"examples":{"dada_de_baja":{"summary":"La domiciliación queda cancelada y la fila, `CANCELLED`","value":{"plan":"PREMIUM","status":"CANCELLED","current_period_end":"2026-09-30T14:00:00Z","trial_ends_at":"2026-08-15T14:00:00Z","grace_ends_at":null,"provider_preapproval_id":"2c93808493d7f4e60193e0a1b2c30001","billing_cycle":"MONTHLY","amount_clp":38430,"upgrade_pending":false,"marketplace_quota":null}}}}}},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"`OWNER_REQUIRED`: la suscripción es exclusiva del propietario; la plantilla no la lee ni la cambia.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"no_eres_el_dueno":{"summary":"Quien llama pertenece al equipo del negocio, pero no es su propietario","value":{"code":"OWNER_REQUIRED","message":"Esta operación es exclusiva del propietario del negocio.","details":null,"hint":"El plan lo contrata y lo da de baja el dueño desde su sesión.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: el negocio no existe o no es tuyo. `SUBSCRIPTION_NOT_FOUND`: el negocio existe pero no tiene ninguna suscripción vigente.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_suscripcion":{"summary":"El negocio existe pero no tiene ninguna suscripción vigente","value":{"code":"SUBSCRIPTION_NOT_FOUND","message":"Este negocio no tiene ninguna suscripción vigente.","details":null,"hint":"Contrata un plan con POST /businesses/{business_id}/subscription.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"negocio_ajeno":{"summary":"El negocio no existe, está dado de baja o no es tuyo","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Comprueba el `business_id` en GET /api/v1/me/businesses.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"502":{"description":"`MP_UNAVAILABLE`: no se pudo dar de baja la domiciliación en Mercado Pago. La suscripción **no** se marca como cancelada: hacerlo dejaría un cargo mensual vivo contra una suscripción que Lukin da por muerta.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/bookings/{booking_id}/checkout":{"post":{"tags":["payments"],"summary":"Iniciar el pago de una reserva mía","description":"Prepara el pago de **tu** reserva y devuelve la URL de Checkout Pro a la que hay\nque redirigir al comprador, junto con el importe y el instante en que ese enlace\ncaduca.\n\nEl cobro se crea con la cuenta de Mercado Pago **del local**, no con la de la\nplataforma: el dinero va directo al vendedor y Lukin no lo intermedia. Los datos\nde la tarjeta no pasan en ningún momento por esta API.\n\nEs idempotente mientras el enlace siga vivo: pedirlo dos veces devuelve el mismo\n`payment_id` y la misma `checkout_url`, sin crear una segunda preferencia ni una\nsegunda fila de pago. Cuando el plazo vence, la siguiente llamada crea un\ncheckout nuevo.\n\nUna reserva que ya está pagada responde `409 BOOKING_ALREADY_PAID`; una anulada,\n`409 BOOKING_NOT_PAYABLE`; un negocio sin cuenta conectada, `409\nBUSINESS_NOT_PAYMENT_ENABLED`; y un servicio de cortesía —importe cero—, `422\nZERO_AMOUNT`. La reserva de otro cliente responde `404`, igual que una que no\nexiste.","operationId":"payments_create_booking_checkout","security":[{"HTTPBearer":[]}],"parameters":[{"name":"booking_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"Identificador de la reserva que se va a pagar.","examples":["3f2b9a10-0000-4000-8000-dddddddddddd"],"title":"Booking Id"},"description":"Identificador de la reserva que se va a pagar."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckoutOut"},"examples":{"enlace_de_pago":{"summary":"A dónde mandar al comprador y hasta cuándo vale el enlace","value":{"payment_id":"8c1f0e64-0000-4000-8000-aaaaaaaaaaaa","checkout_url":"https://www.mercadopago.cl/checkout/v1/redirect?pref_id=1234567890-abcd","expires_at":"2026-03-09T18:30:00Z","amount_clp":15000}}}}}},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"`SUBSCRIPTION_INACTIVE`: la suscripción SaaS **del local** no está vigente, así que no puede cobrar por internet (F5-T09). No es un problema de quien reserva ni de su enlace: lo que falta es que el negocio contrate o reactive su plan. La reserva sigue en pie y se puede pagar en el local.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"el_local_no_puede_cobrar":{"summary":"La suscripción SaaS del negocio no está vigente","value":{"code":"SUBSCRIPTION_INACTIVE","message":"Este negocio no puede cobrar por internet en este momento.","details":null,"hint":"La reserva sigue en pie: se paga en el local.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"409":{"description":"`BOOKING_ALREADY_PAID`: la reserva ya está cobrada. `BOOKING_NOT_PAYABLE`: está anulada o cerrada y no admite pagos. `BUSINESS_NOT_PAYMENT_ENABLED`: el negocio todavía no conectó su cuenta de Mercado Pago.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"ya_pagada":{"summary":"La reserva ya está cobrada","value":{"code":"BOOKING_ALREADY_PAID","message":"Esta reserva ya está pagada.","details":null,"hint":"Consulta GET /api/v1/bookings/{booking_id}/payment antes de volver a cobrar.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`ZERO_AMOUNT`: la reserva no tiene importe que cobrar. `VALIDATION_ERROR`: el identificador del camino no es un UUID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"`MP_UNAVAILABLE`: Mercado Pago no respondió al crear la preferencia. El enlace de pago no existe todavía; reintentar es seguro.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"`BOOKING_NOT_FOUND`: no hay ninguna reserva tuya con ese identificador. Una reserva ajena responde igual que una inexistente.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"reserva_ajena_o_inexistente":{"summary":"No hay ninguna reserva tuya con ese identificador","value":{"code":"BOOKING_NOT_FOUND","message":"La reserva solicitada no existe.","details":null,"hint":"Comprueba el `booking_id` en GET /api/v1/bookings.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/public/bookings/{booking_id}/checkout":{"post":{"tags":["payments"],"summary":"Iniciar el pago de una reserva hecha sin cuenta","description":"Prepara el pago de una reserva hecha **sin cuenta**, autorizado por el\n`manage_token` que se entregó al confirmarla. Devuelve la misma `CheckoutOut` que\nla ruta con sesión: a dónde va el comprador, cuánto paga y hasta cuándo vale ese\nenlace.\n\nExiste porque quien reserva como invitado no tiene sesión que presentar: su\ncredencial es el enlace del correo. El token va en la query string, está atado a\n**esa** reserva y no abre sesión; cualquier fallo suyo responde `401\nTOKEN_INVALID` sin decir cuál de ellos fue.\n\nAl volver de Mercado Pago, el comprador aterriza en\n`{PUBLIC_WEB_URL}/reserva/{booking_id}/pago/{exito|fallo|pendiente}?token=…` con\nese mismo token, de modo que la página de retorno puede consultar el estado del\npago sin registrarse.\n\nEs idempotente mientras el enlace siga vivo, y comparte con la ruta autenticada\nlos `409` de reserva pagada, reserva cerrada y negocio sin cuenta conectada.","operationId":"payments_create_guest_booking_checkout","parameters":[{"name":"booking_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"Identificador de la reserva, el mismo que va en el enlace del correo.","examples":["3f2b9a10-0000-4000-8000-dddddddddddd"],"title":"Booking Id"},"description":"Identificador de la reserva, el mismo que va en el enlace del correo."},{"name":"token","in":"query","required":true,"schema":{"type":"string","description":"`manage_token` entregado al confirmar la reserva (`POST /api/v1/public/bookings/{booking_id}/confirm`). Es un JWT de propósito `booking_manage` atado a **esta** reserva y válido hasta una semana después de la cita: no abre sesión y `Authorization: Bearer` lo rechaza. Cualquier fallo —firma, plazo, propósito o reserva— responde `401 TOKEN_INVALID`.","examples":["eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."],"title":"Token"},"description":"`manage_token` entregado al confirmar la reserva (`POST /api/v1/public/bookings/{booking_id}/confirm`). Es un JWT de propósito `booking_manage` atado a **esta** reserva y válido hasta una semana después de la cita: no abre sesión y `Authorization: Bearer` lo rechaza. Cualquier fallo —firma, plazo, propósito o reserva— responde `401 TOKEN_INVALID`."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckoutOut"},"examples":{"enlace_de_pago":{"summary":"A dónde mandar al titular y hasta cuándo vale el enlace","value":{"payment_id":"8c1f0e64-0000-4000-8000-aaaaaaaaaaaa","checkout_url":"https://www.mercadopago.cl/checkout/v1/redirect?pref_id=1234567890-abcd","expires_at":"2026-03-09T18:30:00Z","amount_clp":15000}}}}}},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"`SUBSCRIPTION_INACTIVE`: la suscripción SaaS **del local** no está vigente, así que no puede cobrar por internet (F5-T09). No es un problema de quien reserva ni de su enlace: lo que falta es que el negocio contrate o reactive su plan. La reserva sigue en pie y se puede pagar en el local.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"`BOOKING_ALREADY_PAID`: la reserva ya está cobrada. `BOOKING_NOT_PAYABLE`: está anulada o cerrada y no admite pagos. `BUSINESS_NOT_PAYMENT_ENABLED`: el negocio todavía no conectó su cuenta de Mercado Pago.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"ya_pagada":{"summary":"La reserva ya está cobrada","value":{"code":"BOOKING_ALREADY_PAID","message":"Esta reserva ya está pagada.","details":null,"hint":"Consulta GET /api/v1/public/bookings/{booking_id}/payment antes de volver a cobrar.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`ZERO_AMOUNT`: la reserva no tiene importe que cobrar. `VALIDATION_ERROR`: el identificador del camino no es un UUID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"`MP_UNAVAILABLE`: Mercado Pago no respondió al crear la preferencia. El enlace de pago no existe todavía; reintentar es seguro.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"`TOKEN_INVALID`: el `token` de la URL no sirve. Comparten esta respuesta la firma manipulada, el enlace caducado, el token de otra reserva y la reserva inexistente: distinguirlos diría a quien prueba enlaces ajenos cuál llegó a existir.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"token_no_valido":{"summary":"El `token` de la URL no sirve","value":{"code":"TOKEN_INVALID","message":"El enlace no es válido o ha caducado.","details":null,"hint":"Vuelve a abrir el enlace del correo de la reserva.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-lukin-agent-step":"checkout"}},"/api/v1/bookings/{booking_id}/payment":{"get":{"tags":["payments"],"summary":"Consultar el estado del pago de una reserva mía","description":"Devuelve en qué estado está el cobro de **tu** reserva y en qué estado está la\nreserva misma.\n\nEs lo que consulta la pantalla a la que vuelve el comprador desde Mercado Pago:\nmientras el webhook no haya llegado, la respuesta sigue diciendo `PENDING`, y en\ncuanto llega pasa a `APPROVED` con su `paid_at` y la reserva a `PAID`.\n\nUna reserva que nunca inició un checkout responde `200` con `status: \"NONE\"` y\nlos campos del pago a `null`; no es un error, es que todavía no hay nada que\ncobrar. Si hubo varios intentos —un rechazo y su reintento— se devuelve el que\nya está resuelto, no el que sigue esperando.\n\nLa reserva de otro cliente responde `404`, igual que una que no existe.","operationId":"payments_get_booking_payment_status","security":[{"HTTPBearer":[]}],"parameters":[{"name":"booking_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"Identificador de la reserva que se va a pagar.","examples":["3f2b9a10-0000-4000-8000-dddddddddddd"],"title":"Booking Id"},"description":"Identificador de la reserva que se va a pagar."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentStatusOut"},"examples":{"cobro_aprobado":{"summary":"El webhook ya llegó: el cobro está `APPROVED` y la reserva `PAID`","value":{"status":"APPROVED","payment_id":"8c1f0e64-0000-4000-8000-aaaaaaaaaaaa","amount_clp":15000,"paid_at":"2026-03-09T18:12:44Z","booking_status":"PAID"}},"sin_cobro":{"summary":"La reserva nunca inició un checkout","value":{"status":"NONE","payment_id":null,"amount_clp":null,"paid_at":null,"booking_status":"CONFIRMED"}}}}}},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"`TOKEN_MISSING` / `TOKEN_EXPIRED` / `TOKEN_INVALID`: no hay sesión válida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"`BOOKING_NOT_FOUND`: no hay ninguna reserva tuya con ese identificador.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"reserva_ajena_o_inexistente":{"summary":"No hay ninguna reserva tuya con ese identificador","value":{"code":"BOOKING_NOT_FOUND","message":"La reserva solicitada no existe.","details":null,"hint":"Comprueba el `booking_id` en GET /api/v1/bookings.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`VALIDATION_ERROR`: el identificador del camino no es un UUID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/public/bookings/{booking_id}/payment":{"get":{"tags":["payments"],"summary":"Consultar el estado del pago de una reserva hecha sin cuenta","description":"Devuelve el estado del cobro de una reserva hecha **sin cuenta**, autorizado por\nel `manage_token` del enlace. Es la misma `PaymentStatusOut` que la ruta con\nsesión.\n\nExiste porque quien reserva como invitado vuelve de Mercado Pago sin sesión: el\ntoken que trae en la query es exactamente el que Lukin colgó de las `back_urls`\nal crear el checkout, así que la pantalla de retorno puede preguntar «¿se\npagó?» sin registrarse.\n\nUna reserva sin ningún intento de cobro responde `200` con `status: \"NONE\"`.\nCualquier fallo del token —firma, plazo, propósito o reserva— responde `401\nTOKEN_INVALID` sin decir cuál de ellos fue.","operationId":"payments_get_guest_booking_payment_status","parameters":[{"name":"booking_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"Identificador de la reserva, el mismo que va en el enlace del correo.","examples":["3f2b9a10-0000-4000-8000-dddddddddddd"],"title":"Booking Id"},"description":"Identificador de la reserva, el mismo que va en el enlace del correo."},{"name":"token","in":"query","required":true,"schema":{"type":"string","description":"`manage_token` entregado al confirmar la reserva (`POST /api/v1/public/bookings/{booking_id}/confirm`). Es un JWT de propósito `booking_manage` atado a **esta** reserva y válido hasta una semana después de la cita: no abre sesión y `Authorization: Bearer` lo rechaza. Cualquier fallo —firma, plazo, propósito o reserva— responde `401 TOKEN_INVALID`.","examples":["eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."],"title":"Token"},"description":"`manage_token` entregado al confirmar la reserva (`POST /api/v1/public/bookings/{booking_id}/confirm`). Es un JWT de propósito `booking_manage` atado a **esta** reserva y válido hasta una semana después de la cita: no abre sesión y `Authorization: Bearer` lo rechaza. Cualquier fallo —firma, plazo, propósito o reserva— responde `401 TOKEN_INVALID`."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentStatusOut"},"examples":{"cobro_aprobado":{"summary":"El webhook ya llegó: el cobro está `APPROVED` y la reserva `PAID`","value":{"status":"APPROVED","payment_id":"8c1f0e64-0000-4000-8000-aaaaaaaaaaaa","amount_clp":15000,"paid_at":"2026-03-09T18:12:44Z","booking_status":"PAID"}},"sin_cobro":{"summary":"La reserva nunca inició un checkout","value":{"status":"NONE","payment_id":null,"amount_clp":null,"paid_at":null,"booking_status":"CONFIRMED"}}}}}},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"`TOKEN_INVALID`: el `token` de la URL no sirve. Comparten esta respuesta la firma manipulada, el enlace caducado, el token de otra reserva y la reserva inexistente.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"token_no_valido":{"summary":"El `token` de la URL no sirve","value":{"code":"TOKEN_INVALID","message":"El enlace no es válido o ha caducado.","details":null,"hint":"Vuelve a abrir el enlace del correo de la reserva.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`VALIDATION_ERROR`: el identificador del camino no es un UUID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"peticion_mal_formada":{"summary":"Falta el `token` de la URL o el `booking_id` no es un UUID","value":{"code":"VALIDATION_ERROR","message":"Los datos enviados no son válidos.","details":[{"loc":["query","token"],"msg":"Field required"}],"hint":"Usa el enlace tal y como se envió al titular, sin recortarlo.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-lukin-agent-step":"status"}},"/api/v1/public/businesses/{slug}":{"get":{"tags":["public"],"summary":"Ver la ficha pública de un local","description":"Ficha completa del local publicado: portada, dirección y coordenadas, zona\nhoraria, **matriz horaria**, **catálogo activo** y **equipo activo**, todo en una\nsola respuesta para que la página del marketplace se renderice con una única\nllamada.\n\nSolo se sirven negocios **publicados y vivos**. Un slug inexistente, un negocio\nque sigue en borrador y uno dado de baja devuelven el mismo\n`404 BUSINESS_NOT_FOUND`, de modo que probar slugs no permite averiguar cuáles\nexisten.\n\nLos servicios retirados (`is_active=false`) y las personas dadas de baja no\naparecen: lo que está en la ficha es lo que se puede reservar hoy. Las horas de\n`hours` son **locales del negocio** (campo `timezone`), con `weekday` de\n0 = lunes a 6 = domingo.\n\nLa respuesta se sirve con `Cache-Control: public, max-age=60`: es contenido\npúblico que no depende de quién pregunte, así que cualquier caché intermedia\npuede guardarlo un minuto.\n\nNinguna respuesta de esta superficie incluye `email`, `phone`, `phone_e164` ni\n`user_id` en ningún nivel: de cada persona del equipo solo viajan el\nidentificador de su plaza y el nombre con el que el local la presenta (SEC-01).","operationId":"public_get_public_business","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","description":"Identificador del local en la URL del marketplace, en minúsculas y con guiones.","examples":["barberia-nunoa"],"title":"Slug"},"description":"Identificador del local en la URL del marketplace, en minúsculas y con guiones."}],"responses":{"200":{"description":"Ficha del local publicado, con horario, catálogo y equipo.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessPublic"},"example":{"id":"3f2b9a10-0000-4000-8000-aaaaaaaaaaaa","slug":"barberia-nunoa","name":"Barbería Ñuñoa","description":"Barbería clásica en pleno Ñuñoa, con tres sillones y café de cortesía.","logo_url":"/media/businesses/3f2b9a10-0000-4000-8000-aaaaaaaaaaaa/logo-1a2b.webp","gallery_urls":["/media/businesses/3f2b9a10-0000-4000-8000-aaaaaaaaaaaa/gallery/3c4d.webp"],"address":"Av. Irarrázaval 1234, local 5","comuna":"Ñuñoa","lat":-33.4569,"lng":-70.5975,"timezone":"America/Santiago","hours":[{"weekday":0,"opens_at":"09:00:00","closes_at":"13:00:00"},{"weekday":0,"opens_at":"14:00:00","closes_at":"18:00:00"},{"weekday":5,"opens_at":"10:00:00","closes_at":"14:00:00"}],"services":[{"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","name":"Corte de pelo","description":"Corte clásico con máquina y tijera, incluye lavado.","price_clp":15000,"duration_minutes":30,"modality":"IN_PERSON","requires_prepayment":false}],"staff":[{"id":"3f2b9a10-0000-4000-8000-cccccccccccc","display_name":"Ana"}],"rating_avg":4.7,"rating_count":128}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: no hay ningún negocio **publicado** con ese slug. El que no existe, el que sigue en borrador y el que fue dado de baja responden igual: la vitrina no revela qué slugs están ocupados.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"local_no_publicado":{"summary":"El slug no corresponde a ningún local publicado","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Busca el local en GET /api/v1/public/search y usa el `slug` que devuelve.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/public/businesses/{slug}/services":{"get":{"tags":["public"],"summary":"Listar los servicios reservables de un local","description":"Catálogo **activo** del local publicado, ordenado por nombre. Es exactamente la\nlista que viaja dentro del campo `services` de la ficha completa; existe como\nruta propia para que el cliente pueda refrescar solo el catálogo sin volver a\ntraerse el horario y el equipo.\n\nUn servicio retirado del catálogo no aparece aquí aunque siga visible en el\npanel del negocio. `requires_prepayment` indica si la reserva exigirá pasar por\nel checkout de pago antes de confirmarse.\n\nLa respuesta se sirve con `Cache-Control: public, max-age=60`: es contenido\npúblico que no depende de quién pregunte, así que cualquier caché intermedia\npuede guardarlo un minuto.","operationId":"public_list_public_services","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","description":"Identificador del local en la URL del marketplace, en minúsculas y con guiones.","examples":["barberia-nunoa"],"title":"Slug"},"description":"Identificador del local en la URL del marketplace, en minúsculas y con guiones."}],"responses":{"200":{"description":"Catálogo activo del local, ordenado por nombre.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ServicePublic"},"title":"Response Public List Public Services"},"example":[{"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","name":"Corte de pelo","description":"Corte clásico con máquina y tijera, incluye lavado.","price_clp":15000,"duration_minutes":30,"modality":"IN_PERSON","requires_prepayment":false}]}}},"404":{"description":"`BUSINESS_NOT_FOUND`: no hay ningún negocio **publicado** con ese slug. El que no existe, el que sigue en borrador y el que fue dado de baja responden igual: la vitrina no revela qué slugs están ocupados.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"local_no_publicado":{"summary":"El slug no corresponde a ningún local publicado","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Busca el local en GET /api/v1/public/search y usa el `slug` que devuelve.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/public/businesses/{slug}/staff":{"get":{"tags":["public"],"summary":"Listar el equipo visible de un local","description":"Personas **activas** de la plantilla del local publicado, ordenadas por su\nnombre visible. Es la misma lista que el campo `staff` de la ficha completa.\n\nDe cada persona se publica únicamente el identificador de su plaza —el que se\nenvía al pedir disponibilidad o al crear la reserva— y su `display_name`.\n\nLa respuesta se sirve con `Cache-Control: public, max-age=60`: es contenido\npúblico que no depende de quién pregunte, así que cualquier caché intermedia\npuede guardarlo un minuto.\n\nNinguna respuesta de esta superficie incluye `email`, `phone`, `phone_e164` ni\n`user_id` en ningún nivel: de cada persona del equipo solo viajan el\nidentificador de su plaza y el nombre con el que el local la presenta (SEC-01).","operationId":"public_list_public_staff","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","description":"Identificador del local en la URL del marketplace, en minúsculas y con guiones.","examples":["barberia-nunoa"],"title":"Slug"},"description":"Identificador del local en la URL del marketplace, en minúsculas y con guiones."}],"responses":{"200":{"description":"Plantilla activa del local, ordenada por nombre visible.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/StaffPublic"},"title":"Response Public List Public Staff"},"example":[{"id":"3f2b9a10-0000-4000-8000-cccccccccccc","display_name":"Ana"}]}}},"404":{"description":"`BUSINESS_NOT_FOUND`: no hay ningún negocio **publicado** con ese slug. El que no existe, el que sigue en borrador y el que fue dado de baja responden igual: la vitrina no revela qué slugs están ocupados.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"local_no_publicado":{"summary":"El slug no corresponde a ningún local publicado","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Busca el local en GET /api/v1/public/search y usa el `slug` que devuelve.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/public/businesses/{slug}/services/{service_id}/staff":{"get":{"tags":["public"],"summary":"Listar quién presta un servicio del local","description":"Quién puede prestar **este** servicio: la vista inversa de la asignación\nservicio↔trabajador, restringida a personas activas y ordenada por su nombre\nvisible. Es el paso previo a pedir disponibilidad, porque la agenda se calcula\npor trabajador.\n\nUn `service_id` retirado del catálogo, inventado o perteneciente a otro local\nresponde `404 SERVICE_NOT_FOUND`; un slug no publicado, `404 BUSINESS_NOT_FOUND`.\n\nEl **dashboard B2B no debe usar esta ruta**: su equivalente es\n`GET /api/v1/businesses/{business_id}/services/{service_id}/staff`, que\nfunciona también con el local en borrador.\n\nLa respuesta se sirve con `Cache-Control: public, max-age=60`: es contenido\npúblico que no depende de quién pregunte, así que cualquier caché intermedia\npuede guardarlo un minuto.\n\nNinguna respuesta de esta superficie incluye `email`, `phone`, `phone_e164` ni\n`user_id` en ningún nivel: de cada persona del equipo solo viajan el\nidentificador de su plaza y el nombre con el que el local la presenta (SEC-01).","operationId":"public_list_public_staff_for_service","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","description":"Identificador del local en la URL del marketplace, en minúsculas y con guiones.","examples":["barberia-nunoa"],"title":"Slug"},"description":"Identificador del local en la URL del marketplace, en minúsculas y con guiones."},{"name":"service_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"Identificador del servicio dentro del catálogo de este local.","title":"Service Id"},"description":"Identificador del servicio dentro del catálogo de este local."}],"responses":{"200":{"description":"Personas activas que prestan este servicio.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/StaffPublic"},"title":"Response Public List Public Staff For Service"},"example":[{"id":"3f2b9a10-0000-4000-8000-cccccccccccc","display_name":"Ana"}]}}},"404":{"description":"`BUSINESS_NOT_FOUND`: no hay ningún negocio **publicado** con ese slug. El que no existe, el que sigue en borrador y el que fue dado de baja responden igual: la vitrina no revela qué slugs están ocupados.\n\n`SERVICE_NOT_FOUND`: el servicio no existe, está retirado del catálogo o pertenece a otro local. Los tres casos comparten respuesta.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"local_no_publicado":{"summary":"El slug no corresponde a ningún local publicado","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Busca el local en GET /api/v1/public/search y usa el `slug` que devuelve.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"servicio_no_visible":{"summary":"El servicio no existe, está retirado o es de otro local","value":{"code":"SERVICE_NOT_FOUND","message":"El servicio solicitado no existe.","details":null,"hint":"Toma el `service_id` de GET /api/v1/public/businesses/{slug}/services.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/public/comunas":{"get":{"tags":["public"],"summary":"Buscar comunas del catálogo de Chile","description":"Catálogo **cerrado** de las 346 comunas de Chile, con su región y el centroide\ncon el que centrar el mapa. Es la lista de valores que acepta el campo `comuna`\ndel perfil comercial: cualquier otro texto responde `422 UNKNOWN_COMUNA`.\n\nLa búsqueda combina **prefijo y similitud trigram**, en ese orden de\npreferencia: `?q=prov` encuentra `Providencia` mientras se teclea, y `?q=flor`\n—que no es prefijo de nada— encuentra igualmente `La Florida`. El resultado sale\nordenado con las coincidencias de prefijo primero, luego las más parecidas y, a\nigualdad, por orden alfabético.\n\nSin `q` devuelve el principio del catálogo por orden alfabético, que es lo que\nel desplegable enseña antes de que se escriba nada.\n\nLa respuesta se sirve con `Cache-Control: public, max-age=3600`: el catálogo no\ndepende de quién pregunta y cambia cada varios años, así que cualquier caché\nintermedia puede guardarlo una hora.","operationId":"public_list_comunas","parameters":[{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string","maxLength":80},{"type":"null"}],"description":"Texto a buscar. Se compara sin tildes, sin distinguir mayúsculas y con los espacios colapsados, así que `ñuñoa`, `NUNOA` y ` Ñuñoa ` encuentran lo mismo. Omitirlo devuelve el principio del catálogo por orden alfabético.","examples":["flor"],"title":"Q"},"description":"Texto a buscar. Se compara sin tildes, sin distinguir mayúsculas y con los espacios colapsados, así que `ñuñoa`, `NUNOA` y ` Ñuñoa ` encuentran lo mismo. Omitirlo devuelve el principio del catálogo por orden alfabético."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":20,"minimum":1,"description":"Cuántas comunas devolver como máximo. Por defecto 10 y nunca más de 20: un valor mayor responde `422`.","default":10,"title":"Limit"},"description":"Cuántas comunas devolver como máximo. Por defecto 10 y nunca más de 20: un valor mayor responde `422`."}],"responses":{"200":{"description":"Comunas que coinciden con la búsqueda, como mucho `limit`. Una búsqueda sin coincidencias devuelve una lista vacía, no un `404`.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ComunaOut"},"title":"Response Public List Comunas"},"example":[{"name":"La Florida","region":"Metropolitana de Santiago","lat":-33.527298,"lng":-70.541309},{"name":"La Reina","region":"Metropolitana de Santiago","lat":-33.446076,"lng":-70.538545}]}}},"422":{"description":"`VALIDATION_ERROR`: `limit` fuera de 1..20 o `q` de más de 80 caracteres.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"limite_fuera_de_rango":{"summary":"`limit` por encima del techo","value":{"code":"VALIDATION_ERROR","message":"Los datos enviados no son válidos.","details":[{"loc":["query","limit"],"msg":"Input should be less than or equal to 20","type":"less_than_equal"}],"hint":"Pide como mucho 20 comunas; el catálogo entero está en el recurso MCP `lukin://comunas`.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/public/search":{"get":{"tags":["public"],"summary":"Buscar locales por texto, modalidad y cercanía","description":"Buscador del marketplace: cruza **texto**, **modalidad** y **radio geográfico**\nen una sola consulta, que es la pregunta real de RF-04.01 («barbería en La\nFlorida a 5 km que atienda online»).\n\nSolo aparecen locales **publicados** y con al menos un servicio activo; si se\nfiltra por modalidad, ese servicio activo tiene que ser de la modalidad pedida.\n\n**Punto de referencia.** `lat` y `lng` si vienen las dos; si no, el centroide de\n`comuna`. Con punto de referencia se filtra por `radius_km` y cada resultado\ntrae su `distance_km`; sin él, `distance_km` es `null` y no se descarta nada por\ndistancia. `modality=ONLINE` **no** aplica el radio —una videollamada no está a\nninguna distancia— pero sigue informando la distancia si hay punto.\n\n**Orden.** Con punto de referencia, por distancia ascendente y la valoración\ncomo desempate. Sin punto, por relevancia del texto, luego valoración y luego\nnúmero de valoraciones. En ambos casos el orden es estable entre páginas.\n\n**`matched_services`** explica por qué salió cada local: hasta\n5 servicios que coinciden con `q` o, si coincidió por su\nnombre, por su descripción o por su equipo, una muestra de su catálogo activo.\n\n**`date` filtra por agenda.** Sin él no se consulta ninguna disponibilidad y\n`next_available_slot` viaja a `null`: mirar la agenda de cada resultado es caro\ncomo para hacerlo sin que nadie lo pida. Con él solo aparecen los locales que\n**tienen cupo** ese día para alguno de sus `matched_services`, y cada uno trae su\nprimera hora libre en `next_available_slot`, en hora local del local y con su\noffset. Ese hueco es siempre el de **`matched_services[0]`** —los servicios\ncandidatos que se evaluaron antes que él y no tenían cupo ese día no se listan—,\nde modo que es literalmente la misma cadena que devuelve `GET\n/api/v1/public/businesses/{slug}/availability` para ese servicio y ese día, que\nes donde se reserva. La fecha admitida va de hoy a 60 días\nmás adelante; fuera de ahí, `422 DATE_OUT_OF_RANGE`.\n\n**Con `date`, dos páginas consecutivas pueden compartir un local.** Los\ncandidatos se toman desde el offset de la página *antes* de mirar la agenda —lo\ncontrario se saltaría resultados—, así que cada descarte corre la página\nsiguiente hacia atrás. Un listado infinito debe deduplicar por `business_id`.\n\n**`total` es el recuento previo al filtro de disponibilidad**, también cuando se\nenvía `date`: cuenta los locales que cumplen el texto, la modalidad y el radio\nantes de mirar ninguna agenda, así que puede ser mayor que el número de\nresultados con hora. Contar los que sí tienen cupo obligaría a evaluar la agenda\nde **todos** los resultados de la búsqueda y no la de una ventana, que no es una\nconsulta que se pueda servir a un anónimo.\n\nRespuesta cacheable durante 30 segundos (`Cache-Control: public, max-age=30`)\ny limitada por IP a `60/minute` (60 peticiones por minuto). La fecha forma\nparte de la URL, así que cada `date` tiene su propia entrada de caché.","operationId":"public_search","parameters":[{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string","maxLength":100},{"type":"null"}],"description":"Texto libre de la búsqueda. Cruza tres fuentes: el nombre y la descripción del local (índice de texto completo en castellano, así que «masajes» encuentra «masaje»), el nombre de sus servicios activos (por parecido y por subcadena, de modo que «corte» encuentra «Corte de pelo») y el nombre con el que presenta a su equipo. Máximo 100 caracteres.","examples":["corte"],"title":"Q"},"description":"Texto libre de la búsqueda. Cruza tres fuentes: el nombre y la descripción del local (índice de texto completo en castellano, así que «masajes» encuentra «masaje»), el nombre de sus servicios activos (por parecido y por subcadena, de modo que «corte» encuentra «Corte de pelo») y el nombre con el que presenta a su equipo. Máximo 100 caracteres."},{"name":"modality","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/ServiceModality"},{"type":"null"}],"description":"Filtra los locales que ofrecen al menos un servicio activo de esa modalidad. `ONLINE` **desactiva además el filtro por radio** —una videollamada no depende de la distancia—, aunque `distance_km` se sigue informando si hay punto de referencia. `IN_PERSON` mantiene el radio.","title":"Modality"},"description":"Filtra los locales que ofrecen al menos un servicio activo de esa modalidad. `ONLINE` **desactiva además el filtro por radio** —una videollamada no depende de la distancia—, aunque `distance_km` se sigue informando si hay punto de referencia. `IN_PERSON` mantiene el radio."},{"name":"comuna","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Comuna del catálogo de Chile (`GET /api/v1/public/comunas`). Se compara sin tildes ni mayúsculas, y una comuna que no exista responde `422 UNKNOWN_COMUNA` con sugerencias. Su **centroide** es el punto de referencia de la búsqueda cuando no se envían `lat` y `lng`.","examples":["La Florida"],"title":"Comuna"},"description":"Comuna del catálogo de Chile (`GET /api/v1/public/comunas`). Se compara sin tildes ni mayúsculas, y una comuna que no exista responde `422 UNKNOWN_COMUNA` con sugerencias. Su **centroide** es el punto de referencia de la búsqueda cuando no se envían `lat` y `lng`."},{"name":"date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Día para el que se quiere hora (`YYYY-MM-DD`), en hora local de cada local. Con este parámetro solo aparecen los negocios que **tienen cupo** ese día para alguno de sus `matched_services`, y cada resultado trae su primera hora libre en `next_available_slot`, que es siempre la del **primero** de sus `matched_services`: los que se evaluaron antes y no tenían hueco ese día no se listan. Sin él no se consulta ninguna agenda, `next_available_slot` viaja a `null` y `matched_services` llega completo.\n\n**`total` no cambia con este filtro**: sigue siendo el número de locales que cumplen el resto de la búsqueda —texto, modalidad y radio— antes de mirar la agenda, así que puede ser mayor que la suma de los resultados con hora. Se calcula sobre la misma consulta que sin `date` para que el recuento no dependa de cuántas agendas quepa consultar.\n\n**Las páginas pueden solaparse.** Los candidatos se toman desde el offset de la página *antes* de mirar la agenda —es lo que evita saltarse resultados—, así que cada local descartado por no tener cupo corre la página siguiente hacia atrás y uno que salió al final de la página 1 puede volver a salir al principio de la 2. Un listado infinito debe deduplicar por `business_id`.\n\nAdmite desde hoy hasta 60 días más adelante; fuera de ese rango la respuesta es `422 DATE_OUT_OF_RANGE`.","examples":["2026-09-15"],"title":"Date"},"description":"Día para el que se quiere hora (`YYYY-MM-DD`), en hora local de cada local. Con este parámetro solo aparecen los negocios que **tienen cupo** ese día para alguno de sus `matched_services`, y cada resultado trae su primera hora libre en `next_available_slot`, que es siempre la del **primero** de sus `matched_services`: los que se evaluaron antes y no tenían hueco ese día no se listan. Sin él no se consulta ninguna agenda, `next_available_slot` viaja a `null` y `matched_services` llega completo.\n\n**`total` no cambia con este filtro**: sigue siendo el número de locales que cumplen el resto de la búsqueda —texto, modalidad y radio— antes de mirar la agenda, así que puede ser mayor que la suma de los resultados con hora. Se calcula sobre la misma consulta que sin `date` para que el recuento no dependa de cuántas agendas quepa consultar.\n\n**Las páginas pueden solaparse.** Los candidatos se toman desde el offset de la página *antes* de mirar la agenda —es lo que evita saltarse resultados—, así que cada local descartado por no tener cupo corre la página siguiente hacia atrás y uno que salió al final de la página 1 puede volver a salir al principio de la 2. Un listado infinito debe deduplicar por `business_id`.\n\nAdmite desde hoy hasta 60 días más adelante; fuera de ese rango la respuesta es `422 DATE_OUT_OF_RANGE`."},{"name":"lat","in":"query","required":false,"schema":{"anyOf":[{"type":"number","maximum":90,"minimum":-90},{"type":"null"}],"description":"Latitud del punto de referencia (WGS 84), normalmente la posición real de quien busca. Viaja **siempre junto a `lng`**: enviar una sola de las dos es `422`. Tiene preferencia sobre el centroide de `comuna`.","title":"Lat"},"description":"Latitud del punto de referencia (WGS 84), normalmente la posición real de quien busca. Viaja **siempre junto a `lng`**: enviar una sola de las dos es `422`. Tiene preferencia sobre el centroide de `comuna`."},{"name":"lng","in":"query","required":false,"schema":{"anyOf":[{"type":"number","maximum":180,"minimum":-180},{"type":"null"}],"description":"Longitud del punto de referencia (WGS 84). Viaja siempre junto a `lat`.","title":"Lng"},"description":"Longitud del punto de referencia (WGS 84). Viaja siempre junto a `lat`."},{"name":"radius_km","in":"query","required":false,"schema":{"type":"number","maximum":50.0,"minimum":0.5,"description":"Radio de búsqueda en kilómetros alrededor del punto de referencia, entre 0.5 y 50. Solo se aplica si hay punto de referencia (`lat`+`lng` o `comuna`) y la modalidad no es `ONLINE`.","default":5.0,"title":"Radius Km"},"description":"Radio de búsqueda en kilómetros alrededor del punto de referencia, entre 0.5 y 50. Solo se aplica si hay punto de referencia (`lat`+`lng` o `comuna`) y la modalidad no es `ONLINE`."},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"Página que se pide, empezando en 1.","default":1,"title":"Page"},"description":"Página que se pide, empezando en 1."},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":20,"minimum":1,"description":"Resultados por página, entre 1 y 20. Un valor mayor responde `422`: este listado se pinta en una pantalla.","default":20,"title":"Page Size"},"description":"Resultados por página, entre 1 y 20. Un valor mayor responde `422`: este listado se pinta en una pantalla."}],"responses":{"200":{"description":"Página de resultados. Una búsqueda sin coincidencias devuelve `items: []` y `total: 0`, no un `404`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchPage"},"example":{"items":[{"business_id":"3f2b9a10-0000-4000-8000-aaaaaaaaaaaa","slug":"barberia-nunoa","name":"Barbería Ñuñoa","comuna":"Ñuñoa","distance_km":1.284,"rating_avg":4.7,"rating_count":128,"logo_url":"/media/businesses/3f2b9a10-0000-4000-8000-aaaaaaaaaaaa/logo-1a2b.webp","matched_services":[{"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","name":"Corte de pelo","price_clp":15000,"duration_minutes":30,"modality":"IN_PERSON"}]}],"total":1,"page":1,"page_size":20}}}},"422":{"description":"`UNKNOWN_COMUNA` si `comuna` no está en el catálogo (el cuerpo trae `details.suggestions`), o `VALIDATION_ERROR` si `page_size` pasa de 20, `radius_km` sale de 0.5..50, `q` pasa de 100 caracteres o llega una sola de las dos coordenadas.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"comuna_desconocida":{"summary":"La comuna no está en el catálogo","value":{"code":"UNKNOWN_COMUNA","message":"La comuna «Nunoa Alto» no existe.","details":{"suggestions":["Ñuñoa","Ñiquén"]},"hint":"Elige una de GET /api/v1/public/comunas.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-lukin-agent-step":"search"}},"/api/v1/public/sitemap-entries":{"get":{"tags":["public"],"summary":"Listar los locales publicados para el sitemap","description":"Catálogo **completo** de los locales publicados, en el orden estable que\nnecesita un sitemap: `updated_at` descendente con el identificador como\ndesempate. Es la fuente canónica del `sitemap.xml` del marketplace, que recorre\nlas páginas hasta agotar `total` y usa `updated_at` como `lastmod`.\n\n**No uses `/api/v1/public/search` para esto.** Su página está acotada a\n20 resultados —un valor mayor responde `422`— y su orden\ndepende de la búsqueda, de modo que un recorrido paginado sobre él no sería ni\nexhaustivo ni estable. Esta ruta no ordena por relevancia, no filtra y admite\npáginas de hasta 500 entradas.\n\nUn local despublicado o eliminado desaparece de aquí, que es justo lo que hace\nque el sitemap deje de anunciar una URL que ya responde `404`.\n\nRespuesta cacheable durante una hora (`Cache-Control: public, max-age=3600`)\ny limitada por IP a `60/minute` (60 peticiones por minuto).","operationId":"public_sitemap_entries","parameters":[{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"Página que se pide, empezando en 1.","default":1,"title":"Page"},"description":"Página que se pide, empezando en 1."},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":500,"minimum":1,"description":"Entradas por página, entre 1 y 500. Un valor mayor responde `422`.","default":500,"title":"Page Size"},"description":"Entradas por página, entre 1 y 500. Un valor mayor responde `422`."}],"responses":{"200":{"description":"Página del catálogo. Sin locales publicados devuelve `items: []` y `total: 0`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SitemapPage"},"example":{"items":[{"slug":"barberia-nunoa","updated_at":"2026-08-30T14:05:11.482913Z"},{"slug":"spa-la-florida","updated_at":"2026-08-29T09:12:00.000000Z"}],"total":2,"page":1,"page_size":500}}}},"422":{"description":"`VALIDATION_ERROR`: `page_size` fuera de 1..500 o `page` menor que 1.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"pagina_demasiado_grande":{"summary":"`page_size` fuera de rango","value":{"code":"VALIDATION_ERROR","message":"Los datos enviados no son válidos.","details":[{"loc":["query","page_size"],"msg":"Input should be less than or equal to 500","type":"less_than_equal"}],"hint":"Pide como mucho 500 entradas por página.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/public/businesses/{slug}/availability":{"get":{"tags":["public"],"summary":"Consultar las horas disponibles de un servicio","description":"Huecos **reservables** de un servicio en una ventana de días, calculados\ncruzando el horario del local, el turno de cada persona que presta el servicio,\nsus bloqueos y colaciones, y las reservas que ya tiene, con el margen entre\ncitas que el negocio haya configurado. Solo sobreviven los huecos en los que la\ncita cabe entera y que respetan la antelación mínima del local.\n\nCada `starts_at` y cada `ends_at` viajan en **hora local del negocio con su\noffset** (`2026-03-10T09:00:00-03:00`), y `timezone` dice cuál es esa zona. El\noffset cambia con el horario de verano: no se debe deducir de una respuesta\nanterior.\n\nLa misma hora puede aparecer **varias veces** con distinto `staff_member_id`:\nson dos personas libres a esa hora, y el cliente elige. Con `staff_id` la\nrespuesta se acota a una sola.\n\nUna lista de `slots` vacía es una respuesta legítima —ese día no queda nada, o\nel local no abre— y no un error.\n\nSolo se sirven negocios **publicados y vivos**: un slug inexistente, uno en\nborrador y uno dado de baja devuelven el mismo `404 BUSINESS_NOT_FOUND`.\n\nLa ventana admite como mucho **31 días**, contando `date_from` y\n`date_to`. Sin `date_to`, se calcula únicamente `date_from`.\n\nY `date_from` tiene que caer dentro del **horizonte de reserva**: entre hoy y\n90 días más adelante, que es hasta donde llega la agenda que\nse puede reservar de verdad. Fuera de ahí la respuesta es\n`422 DATE_OUT_OF_RANGE`, con `min_date` y `max_date` en `details`.\n\nEl **dashboard B2B no debe usar esta ruta**: su equivalente business-scoped es\n`GET /api/v1/businesses/{business_id}/availability`, que funciona también con\nel local en borrador.\n\nLa respuesta correcta se sirve con `Cache-Control: public, max-age=30`: es\ncontenido público que cualquier caché intermedia puede guardar medio minuto.","operationId":"public_get_public_availability","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","description":"Identificador del local en la URL del marketplace, en minúsculas y con guiones.","examples":["barberia-nunoa"],"title":"Slug"},"description":"Identificador del local en la URL del marketplace, en minúsculas y con guiones."},{"name":"service_id","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"Servicio que se quiere reservar. Su duración es la que define el largo de cada hueco.","title":"Service Id"},"description":"Servicio que se quiere reservar. Su duración es la que define el largo de cada hueco.","examples":{"catalogo":{"summary":"Un servicio del catálogo público","description":"Sale de `GET /api/v1/public/businesses/{slug}/services`. Un servicio retirado o de otro local responde `404`.","value":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb"}}},{"name":"date_from","in":"query","required":true,"schema":{"type":"string","format":"date","description":"Primer día de la ventana (`YYYY-MM-DD`), en hora local del negocio.","title":"Date From"},"description":"Primer día de la ventana (`YYYY-MM-DD`), en hora local del negocio.","examples":{"un_dia":{"summary":"Un solo día","description":"Sin `date_to`, se calcula únicamente este día.","value":"2026-03-10"}}},{"name":"date_to","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Último día de la ventana, incluido. Por defecto, `date_from`. Como mucho 31 días contando ambos extremos.","title":"Date To"},"description":"Último día de la ventana, incluido. Por defecto, `date_from`. Como mucho 31 días contando ambos extremos.","examples":{"una_semana":{"summary":"Una semana","description":"Siete días contando `date_from`.","value":"2026-03-16"}}},{"name":"staff_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"description":"Acota la respuesta a una sola persona del equipo. Debe prestar el servicio; si no, la respuesta es `404 STAFF_NOT_FOUND`.","title":"Staff Id"},"description":"Acota la respuesta a una sola persona del equipo. Debe prestar el servicio; si no, la respuesta es `404 STAFF_NOT_FOUND`.","examples":{"cualquiera":{"summary":"Cualquier persona del equipo","description":"Omitiendo el parámetro se ofrecen los huecos de toda la plantilla."},"una_persona":{"summary":"Una persona concreta","description":"Sale de `GET /api/v1/public/businesses/{slug}/services/{service_id}/staff`.","value":"3f2b9a10-0000-4000-8000-cccccccccccc"}}},{"name":"reschedule_booking_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"description":"Reserva que se está **moviendo de hora**. Con ella, la respuesta se calcula como para quien ya tiene una cita en este local: reprogramar no consume el cupo mensual del plan del negocio, así que un local sin cupo sigue ofreciendo horas a quien solo quiere cambiar la suya. La reserva tiene que ser de este local, de este servicio y seguir viva; cualquier otro valor se ignora y la respuesta es la de siempre. **No autoriza a crear nada**: el alta sigue rechazándose con `409 BUSINESS_UNAVAILABLE`.","title":"Reschedule Booking Id"},"description":"Reserva que se está **moviendo de hora**. Con ella, la respuesta se calcula como para quien ya tiene una cita en este local: reprogramar no consume el cupo mensual del plan del negocio, así que un local sin cupo sigue ofreciendo horas a quien solo quiere cambiar la suya. La reserva tiene que ser de este local, de este servicio y seguir viva; cualquier otro valor se ignora y la respuesta es la de siempre. **No autoriza a crear nada**: el alta sigue rechazándose con `409 BUSINESS_UNAVAILABLE`.","examples":{"reserva_nueva":{"summary":"Quiero una hora nueva","description":"Sin el parámetro, que es el caso normal de la vitrina."},"moviendo_la_mia":{"summary":"Estoy moviendo mi cita","description":"El `booking_id` que ya tiene quien abrió su enlace de gestión o su listado de «Mis reservas».","value":"3f2b9a10-0000-4000-8000-aaaaaaaaaaaa"}}}],"responses":{"200":{"description":"Huecos disponibles en la ventana pedida, ordenados por instante y, a igualdad de hora, por plaza.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AvailabilityResponse"},"example":{"timezone":"America/Santiago","service_id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","date_from":"2026-03-10","date_to":"2026-03-10","slots":[{"starts_at":"2026-03-10T09:00:00-03:00","ends_at":"2026-03-10T09:30:00-03:00","staff_member_id":"3f2b9a10-0000-4000-8000-cccccccccccc","staff_name":"Ana"},{"starts_at":"2026-03-10T09:00:00-03:00","ends_at":"2026-03-10T09:30:00-03:00","staff_member_id":"3f2b9a10-0000-4000-8000-dddddddddddd","staff_name":"Benjamín"},{"starts_at":"2026-03-10T09:15:00-03:00","ends_at":"2026-03-10T09:45:00-03:00","staff_member_id":"3f2b9a10-0000-4000-8000-cccccccccccc","staff_name":"Ana"}]}}},"headers":{"Cache-Control":{"description":"Caché pública de 30 segundos.","schema":{"type":"string","example":"public, max-age=30"}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: no hay ningún negocio **publicado** con ese slug —el que no existe, el que sigue en borrador y el dado de baja responden igual—.\n\n`SERVICE_NOT_FOUND`: el servicio no existe, está retirado del catálogo o pertenece a otro local.\n\n`STAFF_NOT_FOUND`: el `staff_id` enviado no es una persona activa que preste este servicio.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"local_no_publicado":{"summary":"El slug no corresponde a ningún local publicado","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Busca el local en GET /api/v1/public/search y usa el `slug` que devuelve.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`DATE_RANGE_INVALID`: `date_to` es anterior a `date_from`.\n\n`DATE_RANGE_TOO_LARGE`: la ventana supera los 31 días; `details` trae `max_days` y `requested_days`.\n\n`DATE_OUT_OF_RANGE`: `date_from` cae fuera del horizonte de reserva —ayer, o más de 90 días por delante—; `details` trae `min_date` y `max_date`.\n\n`VALIDATION_ERROR`: algún parámetro no tiene el formato esperado.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"ventana_demasiado_ancha":{"summary":"Se pidieron más de 31 días de una vez","value":{"code":"DATE_RANGE_TOO_LARGE","message":"La ventana no puede superar los 31 días.","details":{"max_days":31,"requested_days":45},"hint":"Parte la consulta en tramos y encadena las respuestas.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"fuera_del_horizonte":{"summary":"Se pidió un día a más de 90 días vista","value":{"code":"DATE_OUT_OF_RANGE","message":"La fecha pedida está fuera del rango consultable.","details":{"date":"2999-01-04","min_date":"2026-03-10","max_date":"2026-06-08","max_days":90},"hint":"Pide un día entre hoy y 2026-06-08.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-lukin-agent-step":"availability"}},"/api/v1/public/bookings":{"post":{"tags":["public"],"summary":"Reservar sin cuenta y recibir el código de confirmación","description":"Crea la reserva de quien **no tiene cuenta** y le envía un código de 4 dígitos\npara confirmarla.\n\nEn una sola transacción: resuelve el local publicado y el servicio activo,\nidentifica al cliente por su correo o su teléfono —reutilizando su fila si ya\nhabía reservado o si tiene cuenta, **sin cambiarle el rol**—, deja registrados\nlos consentimientos de Términos y Política de Privacidad con la versión vigente,\nla IP y el user agent de esta petición, y aparta el hueco en estado `PENDING`\nhasta `expires_at` (15 minutos por defecto).\n\nEl código sale después, por el canal que fija el despliegue\n(`BOOKING_OTP_CHANNEL`), y la respuesta solo dice a dónde fue **ofuscado**\n(`j***@example.com`, `+56 9 ****5678`): basta para comprobar que el contacto se\nescribió bien y no sirve para leérselo a nadie.\n\nSi ese envío no sale (`503 OTP_CHANNEL_UNAVAILABLE`), la reserva **no se\nqueda**: se cancela antes de responder y el hueco vuelve a la rejilla en el\nacto. El error no lleva `booking_id`, así que una reserva superviviente sería\nuna hora muerta que nadie podría confirmar ni anular.\n\nLa respuesta **no abre sesión**: no trae `access_token` ni fija la cookie\n`lukin_refresh`. Identificar a quien reserva no es autenticarlo.","operationId":"public_create_guest_booking","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GuestBookingCreate"},"examples":{"con_persona":{"summary":"Reservar con una persona concreta","description":"`staff_id` sale de `GET /api/v1/public/businesses/{slug}/services/{service_id}/staff`.","value":{"business_slug":"barberia-nunoa","service_id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","staff_id":"3f2b9a10-0000-4000-8000-cccccccccccc","starts_at":"2026-03-10T09:00:00-03:00","customer":{"full_name":"Javiera Rojas","email":"javiera@example.com","phone":"+56912345678"},"accept_privacy":true}},"sin_preferencia":{"summary":"Reservar sin elegir persona","description":"Sin `staff_id`, el motor asigna a quien esté libre y preste el servicio.","value":{"business_slug":"barberia-nunoa","service_id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","starts_at":"2026-03-10T09:00:00-03:00","customer":{"full_name":"Javiera Rojas","email":"javiera@example.com","phone":"+56912345678"},"accept_privacy":true}}}}},"required":true},"responses":{"201":{"description":"Reserva creada en estado `PENDING` y código enviado al contacto del cliente. El hueco queda apartado hasta `expires_at`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GuestBookingOut"},"examples":{"reserva_apartada":{"summary":"Hueco apartado y código enviado al titular","value":{"booking_id":"3f2b9a10-0000-4000-8000-dddddddddddd","status":"PENDING","otp_channel":"EMAIL","otp_destination_masked":"j***@example.com","starts_at":"2026-03-10T12:00:00Z","ends_at":"2026-03-10T12:30:00Z","expires_at":"2026-03-09T18:15:00Z"}}}}}},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1.0}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: no hay ningún negocio **publicado** con ese slug. `SERVICE_NOT_FOUND`: el servicio no existe, está retirado del catálogo o es de otro local.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"`SLOT_TAKEN`: alguien ocupó ese hueco entre el cálculo y el alta. `TOO_MANY_PENDING`: el cliente ya acumula el máximo de reservas sin confirmar. `BUSINESS_UNAVAILABLE`: el local está publicado pero ahora mismo no acepta reservas por internet; la disponibilidad de ese local ya venía vacía y esto solo alcanza a quien llegó en la misma carrera.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"hueco_ocupado":{"summary":"Alguien ganó ese hueco entre el cálculo y el alta","value":{"code":"SLOT_TAKEN","message":"Ese horario ya no está disponible.","details":null,"hint":"Vuelve a pedir GET /api/v1/public/businesses/{slug}/availability y elige otro hueco.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`VALIDATION_ERROR` con `accept_privacy: false` o una hora sin zona; `INVALID_PHONE` si el teléfono no es posible; `MIN_ADVANCE` si la cita es demasiado inmediata; `SLOT_UNAVAILABLE` si esa hora no está en la rejilla publicada.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"`OTP_CHANNEL_UNAVAILABLE`: el canal configurado en `BOOKING_OTP_CHANNEL` no tiene proveedor activo (`SMS_PROVIDER=disabled`). Es un fallo de configuración del servicio, no de la petición: reintentar no ayuda.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-lukin-agent-step":"booking"}},"/api/v1/public/bookings/{booking_id}/confirm":{"post":{"tags":["public"],"summary":"Confirmar la reserva con el código de 4 dígitos","description":"Canjea el código de 4 dígitos y deja la reserva en `CONFIRMED`.\n\nEl código se comprueba contra el mismo contacto al que se envió y se **consume**:\nun segundo canje del mismo código responde `422 OTP_EXPIRED`. Cinco intentos\nfallidos bloquean el reto y a partir de ahí la respuesta es `429 OTP_LOCKED`\n—incluso enviando el código correcto—: hay que pedir uno nuevo con\n`POST /resend-otp`.\n\nCon éxito devuelve el `manage_token`, un JWT de propósito `booking_manage` que\nvive hasta una semana después de la cita y con el que el invitado consulta,\nreprograma o cancela **esa** reserva sin registrarse. No es una sesión: como\ntoken de propósito, no vale como `Authorization: Bearer`.\n\nSi el servicio exige prepago y el negocio tiene su cuenta de Mercado Pago\nconectada, la respuesta trae además `requires_payment: true` con el\n`payment_checkout_url` y el `payment_id` del cobro, creado **en esta misma\npetición**: el invitado pasa de teclear su código a pagar sin un viaje de más.\nSi exige prepago pero el negocio todavía no puede cobrar, la reserva se confirma\nigualmente y `requires_payment` vale `false`.\n\nSobre una reserva abierta por un agente (`source=AGENT`) cuyo único\nconsentimiento archivado sea el que **declaró** el agente, esta operación exige\nademás `accept_privacy: true`: sin él responde `422 CONSENT_REQUIRED`, la\nreserva sigue `PENDING` y el código **no** se gasta. Con él, la aceptación queda\narchivada con la IP y el user agent de esta petición antes de confirmar. Es la\nmisma prueba que registra\n`POST /api/v1/public/bookings/{booking_id}/consent`, para quien la haya dado ya\ndesde el enlace del correo.","operationId":"public_confirm_guest_booking","parameters":[{"name":"booking_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"Identificador de la reserva devuelto al crearla.","examples":["3f2b9a10-0000-4000-8000-dddddddddddd"],"title":"Booking Id"},"description":"Identificador de la reserva devuelto al crearla."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GuestBookingConfirm"},"examples":{"codigo":{"summary":"Canjear el código de 4 dígitos","value":{"code":"0000"}},"reserva_de_agente":{"summary":"Canjear el código de una reserva abierta por un agente","description":"Cuando la reserva tiene `source=AGENT` y el único consentimiento que consta es el que declaró el agente, hay que enviar además `accept_privacy: true`; sin él la respuesta es `422 CONSENT_REQUIRED` y la reserva sigue `PENDING`.","value":{"code":"0000","accept_privacy":true}}}}}},"responses":{"200":{"description":"Reserva confirmada. La respuesta trae el `manage_token` y el enlace con el que el invitado volverá a ella.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GuestBookingConfirmed"},"examples":{"reserva_confirmada":{"summary":"Código canjeado, con enlace de autogestión y de pago","value":{"status":"CONFIRMED","manage_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...","manage_url":"http://localhost:3000/reserva/3f2b9a10-0000-4000-8000-dddddddddddd?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...","requires_payment":true,"payment_checkout_url":"https://www.mercadopago.cl/checkout/v1/redirect?pref_id=1234567890-abcd","payment_id":"8c1f0e64-0000-4000-8000-aaaaaaaaaaaa"}}}}}},"429":{"description":"`OTP_LOCKED`: el código agotó sus intentos y ya no admite más comprobaciones, ni siquiera con el código correcto; hay que pedir otro. `RATE_LIMITED`: se agotó el cupo de peticiones.","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"`BOOKING_NOT_FOUND`: no hay ninguna reserva con ese identificador. Una reserva de otro cliente responde igual: el identificador es la única referencia y probar UUID no debe revelar qué reservas existen.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"`BOOKING_EXPIRED`: se agotó el plazo para confirmar y el hueco se liberó. `INVALID_TRANSITION`: la reserva ya no está `PENDING` (por ejemplo, un segundo intento de confirmar la misma).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"`OTP_INVALID`: el código no coincide y queda un intento menos. `OTP_EXPIRED`: no hay reto vigente para ese contacto (caducó, ya se usó o nunca existió). `CONSENT_REQUIRED`: la reserva la abrió un agente y falta el `accept_privacy` del titular; el código no se gasta.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"codigo_incorrecto":{"summary":"El código no coincide y queda un intento menos","value":{"code":"OTP_INVALID","message":"El código no es correcto.","details":{"max_attempts":5},"hint":"Revisa el código del mensaje o pide uno nuevo.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"503":{"description":"`OTP_CHANNEL_UNAVAILABLE`: el canal configurado en `BOOKING_OTP_CHANNEL` no tiene proveedor activo (`SMS_PROVIDER=disabled`). Es un fallo de configuración del servicio, no de la petición: reintentar no ayuda.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-lukin-agent-step":"booking"}},"/api/v1/public/bookings/{booking_id}/resend-otp":{"post":{"tags":["public"],"summary":"Reenviar el código de confirmación de la reserva","description":"Emite y envía un código nuevo para una reserva que sigue esperando confirmación.\n\nInvalida el código anterior: en todo momento solo vale el último enviado. Entre\ndos envíos tienen que pasar 60 segundos; antes de ese plazo la respuesta es\n`429 OTP_COOLDOWN` con la cabecera `Retry-After`, y el código ya emitido sigue\nsiendo válido mientras tanto.\n\nSolo se reenvía sobre una reserva `PENDING` dentro de su plazo: una ya\nconfirmada, cancelada o vencida responde `409`.","operationId":"public_resend_guest_booking_otp","parameters":[{"name":"booking_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"Identificador de la reserva devuelto al crearla.","examples":["3f2b9a10-0000-4000-8000-dddddddddddd"],"title":"Booking Id"},"description":"Identificador de la reserva devuelto al crearla."}],"responses":{"202":{"description":"Código nuevo emitido y enviado. El anterior queda invalidado: solo vale el último.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GuestOtpResent"},"examples":{"codigo_reenviado":{"summary":"Código nuevo emitido; el anterior queda invalidado","value":{"otp_channel":"EMAIL","resend_available_at":"2026-03-09T18:01:00Z"}}}}}},"429":{"description":"`OTP_COOLDOWN`: se pidió otro código antes de que pasara el minuto de espera; `Retry-After` dice cuántos segundos faltan. `RATE_LIMITED`: se agotó el cupo de peticiones.","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"espera_entre_envios":{"summary":"Se pidió otro código antes de que pasara el minuto","value":{"code":"OTP_COOLDOWN","message":"Espera un momento antes de pedir otro código.","details":{"retry_after":60},"hint":"La cabecera `Retry-After` dice cuántos segundos faltan.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"`BOOKING_NOT_FOUND`: no hay ninguna reserva con ese identificador. Una reserva de otro cliente responde igual: el identificador es la única referencia y probar UUID no debe revelar qué reservas existen.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"reserva_inexistente":{"summary":"El identificador no corresponde a ninguna reserva","value":{"code":"BOOKING_NOT_FOUND","message":"La reserva solicitada no existe.","details":null,"hint":"Comprueba el `booking_id` que devolvió el alta de la reserva.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"409":{"description":"`BOOKING_EXPIRED`: la reserva ya no espera confirmación. `INVALID_TRANSITION`: la reserva no está `PENDING`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"plazo_agotado":{"summary":"El plazo para confirmar se agotó y el hueco se liberó","value":{"code":"BOOKING_EXPIRED","message":"El plazo para confirmar esta reserva ya venció.","details":null,"hint":"Vuelve a pedir hora: esta reserva ya no admite un código nuevo.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}},"ya_no_esta_pendiente":{"summary":"La reserva ya se confirmó o se canceló: no hay código que reenviar","value":{"code":"INVALID_TRANSITION","message":"La reserva no está pendiente de confirmación.","details":null,"hint":"Consulta su estado con GET /api/v1/public/bookings/{booking_id}.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"503":{"description":"`OTP_CHANNEL_UNAVAILABLE`: el canal configurado en `BOOKING_OTP_CHANNEL` no tiene proveedor activo (`SMS_PROVIDER=disabled`). Es un fallo de configuración del servicio, no de la petición: reintentar no ayuda.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-lukin-agent-step":"booking"}},"/api/v1/public/bookings/{booking_id}":{"get":{"tags":["public"],"summary":"Consultar la reserva con el enlace de autogestión","description":"Devuelve la reserva de quien reservó **sin cuenta**, con el local, el servicio y\nla persona ya resueltos y las horas en la zona del negocio.\n\nLa credencial es el `token` de la query: el `manage_token` que entregó\n`POST /api/v1/public/bookings/{booking_id}/confirm` y que viaja dentro del enlace\ndel correo. Está atado a *esta* reserva, así que el de otra responde `401`\n—igual que uno caducado, uno manipulado o un access token—, y vive hasta una\nsemana después de la cita para que el comprobante siga a mano al día siguiente.\n\n`can_cancel` y `can_reschedule` dicen de antemano si las otras dos operaciones\nvan a funcionar, así que la pantalla no necesita ofrecer un botón que responde\n`422` al pulsarlo.","operationId":"public_get_guest_booking","parameters":[{"name":"booking_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"Identificador de la reserva, el mismo que va en el enlace del correo.","examples":["3f2b9a10-0000-4000-8000-dddddddddddd"],"title":"Booking Id"},"description":"Identificador de la reserva, el mismo que va en el enlace del correo."},{"name":"token","in":"query","required":true,"schema":{"type":"string","description":"`manage_token` entregado al confirmar la reserva (`POST /api/v1/public/bookings/{booking_id}/confirm`). Es un JWT de propósito `booking_manage` atado a **esta** reserva y válido hasta una semana después de la cita: no abre sesión y `Authorization: Bearer` lo rechaza. Cualquier fallo —firma, plazo, propósito o reserva— responde `401 TOKEN_INVALID`.","examples":["eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."],"title":"Token"},"description":"`manage_token` entregado al confirmar la reserva (`POST /api/v1/public/bookings/{booking_id}/confirm`). Es un JWT de propósito `booking_manage` atado a **esta** reserva y válido hasta una semana después de la cita: no abre sesión y `Authorization: Bearer` lo rechaza. Cualquier fallo —firma, plazo, propósito o reserva— responde `401 TOKEN_INVALID`."}],"responses":{"200":{"description":"La reserva, con el local, el servicio y la persona resueltos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GuestBookingDetail"},"example":{"id":"3f2b9a10-0000-4000-8000-dddddddddddd","status":"CONFIRMED","source":"WEB","starts_at":"2026-03-10T09:00:00-03:00","ends_at":"2026-03-10T09:30:00-03:00","price_clp":15000,"business":{"id":"3f2b9a10-0000-4000-8000-aaaaaaaaaaaa","slug":"barberia-nunoa","name":"Barbería Ñuñoa","address":"Av. Irarrázaval 1234","comuna":"Ñuñoa","timezone":"America/Santiago"},"service":{"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","name":"Corte de pelo","duration_minutes":30,"price_clp":15000,"modality":"IN_PERSON","requires_prepayment":false},"staff":{"id":"3f2b9a10-0000-4000-8000-cccccccccccc","display_name":"Ana"},"can_cancel":true,"can_reschedule":true,"has_review":false,"created_at":"2026-03-05T18:42:11-03:00"}}}},"401":{"description":"`TOKEN_INVALID`: el `token` de la URL no sirve. Comparten exactamente esta respuesta la firma manipulada, el plazo agotado, un access token (`type='access'`), un token de otro propósito, el de **otra** reserva y el de una reserva que ya no existe: distinguirlos permitiría averiguar qué reservas hay probando identificadores. Lleva la cabecera `WWW-Authenticate: Bearer realm=\"booking_manage\"`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"token_no_valido":{"summary":"El `token` de la URL no sirve","value":{"code":"TOKEN_INVALID","message":"El enlace no es válido o ha caducado.","details":null,"hint":"Vuelve a abrir el enlace del correo; si caducó, pide uno nuevo al local.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"peticion_mal_formada":{"summary":"Falta el `token` de la URL o el `booking_id` no es un UUID","value":{"code":"VALIDATION_ERROR","message":"Los datos enviados no son válidos.","details":[{"loc":["query","token"],"msg":"Field required"}],"hint":"Usa el enlace tal y como se envió al titular, sin recortarlo.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-lukin-agent-step":"status"}},"/api/v1/public/bookings/{booking_id}/cancel":{"post":{"tags":["public"],"summary":"Anular la reserva desde el enlace de autogestión","description":"Anula la reserva del enlace y libera el hueco, que vuelve a la rejilla en cuanto\nla operación confirma. La fila **no se borra**: sigue en el historial del negocio\ncon su `cancelled_reason`.\n\nEl enlace no da más poder que una cuenta: quien lo usa es el cliente, así que\npuede anular hasta `cancellation_window_hours` antes del inicio de la cita y a\npartir de ahí la respuesta es `422 CANCELLATION_WINDOW_CLOSED` y solo el local\npuede hacerlo. Es el mismo límite que anticipa `can_cancel`.\n\nAnular una reserva ya cerrada es `409 INVALID_TRANSITION`, no una operación sin\nefecto: el estado terminal es una respuesta, no un no-op.","operationId":"public_cancel_guest_booking","parameters":[{"name":"booking_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"Identificador de la reserva, el mismo que va en el enlace del correo.","examples":["3f2b9a10-0000-4000-8000-dddddddddddd"],"title":"Booking Id"},"description":"Identificador de la reserva, el mismo que va en el enlace del correo."},{"name":"token","in":"query","required":true,"schema":{"type":"string","description":"`manage_token` entregado al confirmar la reserva (`POST /api/v1/public/bookings/{booking_id}/confirm`). Es un JWT de propósito `booking_manage` atado a **esta** reserva y válido hasta una semana después de la cita: no abre sesión y `Authorization: Bearer` lo rechaza. Cualquier fallo —firma, plazo, propósito o reserva— responde `401 TOKEN_INVALID`.","examples":["eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."],"title":"Token"},"description":"`manage_token` entregado al confirmar la reserva (`POST /api/v1/public/bookings/{booking_id}/confirm`). Es un JWT de propósito `booking_manage` atado a **esta** reserva y válido hasta una semana después de la cita: no abre sesión y `Authorization: Bearer` lo rechaza. Cualquier fallo —firma, plazo, propósito o reserva— responde `401 TOKEN_INVALID`."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingCancel"},"examples":{"con_motivo":{"summary":"Con motivo","description":"El texto se guarda en `cancelled_reason`.","value":{"reason":"Me surgió un imprevisto"}},"sin_motivo":{"summary":"Sin motivo","description":"El motivo es opcional: un cuerpo vacío basta.","value":{}}}}}},"responses":{"200":{"description":"Reserva anulada. El hueco vuelve a la rejilla en el acto y `can_cancel` pasa a `false`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GuestBookingDetail"},"example":{"id":"3f2b9a10-0000-4000-8000-dddddddddddd","status":"CONFIRMED","source":"WEB","starts_at":"2026-03-10T09:00:00-03:00","ends_at":"2026-03-10T09:30:00-03:00","price_clp":15000,"business":{"id":"3f2b9a10-0000-4000-8000-aaaaaaaaaaaa","slug":"barberia-nunoa","name":"Barbería Ñuñoa","address":"Av. Irarrázaval 1234","comuna":"Ñuñoa","timezone":"America/Santiago"},"service":{"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","name":"Corte de pelo","duration_minutes":30,"price_clp":15000,"modality":"IN_PERSON","requires_prepayment":false},"staff":{"id":"3f2b9a10-0000-4000-8000-cccccccccccc","display_name":"Ana"},"can_cancel":true,"can_reschedule":true,"has_review":false,"created_at":"2026-03-05T18:42:11-03:00"}}}},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"`TOKEN_INVALID`: el `token` de la URL no sirve. Comparten exactamente esta respuesta la firma manipulada, el plazo agotado, un access token (`type='access'`), un token de otro propósito, el de **otra** reserva y el de una reserva que ya no existe: distinguirlos permitiría averiguar qué reservas hay probando identificadores. Lleva la cabecera `WWW-Authenticate: Bearer realm=\"booking_manage\"`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"token_no_valido":{"summary":"El `token` de la URL no sirve","value":{"code":"TOKEN_INVALID","message":"El enlace no es válido o ha caducado.","details":null,"hint":"Vuelve a abrir el enlace del correo; si caducó, pide uno nuevo al local.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"409":{"description":"`INVALID_TRANSITION`: la reserva ya está `CANCELLED` o `NO_SHOW`, y ninguna de las dos vuelve atrás.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"`CANCELLATION_WINDOW_CLOSED`: entró la ventana de cancelación del local y a estas alturas solo el negocio puede anularla. `details` trae `window_hours` y `deadline`. Es exactamente lo que anticipa `can_cancel: false`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"ventana_cerrada":{"summary":"Entró la ventana de cancelación del local","value":{"code":"CANCELLATION_WINDOW_CLOSED","message":"Ya no se puede anular esta reserva por internet.","details":{"window_hours":24,"deadline":"2026-03-09T09:00:00-03:00"},"hint":"Llama al local: dentro de la ventana solo el negocio puede anularla.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/public/bookings/{booking_id}/reschedule":{"post":{"tags":["public"],"summary":"Mover de hora la reserva desde el enlace de autogestión","description":"Mueve la cita de hora —y opcionalmente de persona— **conservando el `id`**: es la\nmisma reserva, no una anulación seguida de un alta, de modo que el pago y el\nhistorial siguen apuntando a ella. El estado tampoco cambia.\n\n`ends_at` se vuelve a derivar de la duración del servicio y no se envía. El hueco\nnuevo se defiende igual que en el alta, así que una hora que otra persona gane\npor medio segundo responde `409 SLOT_TAKEN`. La ventana de cancelación se mide\nsobre el `starts_at` **vigente** y la antelación mínima sobre el **nuevo**.\n\n**El enlace de la URL sigue siendo válido después de mover la cita.** Su plazo se\ncalculó sobre la cita original (`ends_at + 7 días`), así que solo se queda corto\ncuando la cita **acaba más tarde** de lo que acababa antes; únicamente entonces\nla respuesta trae un `manage_token` renovado en lugar de `null`. Cuando venga con\nvalor, guárdalo y descarta el anterior; adelantar la cita no cambia nada.","operationId":"public_reschedule_guest_booking","parameters":[{"name":"booking_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"Identificador de la reserva, el mismo que va en el enlace del correo.","examples":["3f2b9a10-0000-4000-8000-dddddddddddd"],"title":"Booking Id"},"description":"Identificador de la reserva, el mismo que va en el enlace del correo."},{"name":"token","in":"query","required":true,"schema":{"type":"string","description":"`manage_token` entregado al confirmar la reserva (`POST /api/v1/public/bookings/{booking_id}/confirm`). Es un JWT de propósito `booking_manage` atado a **esta** reserva y válido hasta una semana después de la cita: no abre sesión y `Authorization: Bearer` lo rechaza. Cualquier fallo —firma, plazo, propósito o reserva— responde `401 TOKEN_INVALID`.","examples":["eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."],"title":"Token"},"description":"`manage_token` entregado al confirmar la reserva (`POST /api/v1/public/bookings/{booking_id}/confirm`). Es un JWT de propósito `booking_manage` atado a **esta** reserva y válido hasta una semana después de la cita: no abre sesión y `Authorization: Bearer` lo rechaza. Cualquier fallo —firma, plazo, propósito o reserva— responde `401 TOKEN_INVALID`."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingReschedule"},"examples":{"nueva_hora":{"summary":"Mover de hora","description":"Con `staff_id`, además cambia de persona.","value":{"starts_at":"2026-03-11T11:00:00-03:00","staff_id":"3f2b9a10-0000-4000-8000-cccccccccccc"}},"misma_persona":{"summary":"Mover sin cambiar de persona","description":"Sin `staff_id`, la atiende quien ya la tenía.","value":{"starts_at":"2026-03-11T11:00:00-03:00"}}}}}},"responses":{"200":{"description":"Reserva movida. Conserva el **mismo `id`** y el mismo estado: solo cambian `starts_at`, `ends_at` y, si se pidió, la persona. `manage_token` trae un enlace nuevo si el original se quedaba corto.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GuestBookingDetail"},"example":{"id":"3f2b9a10-0000-4000-8000-dddddddddddd","status":"CONFIRMED","source":"WEB","starts_at":"2026-03-10T09:00:00-03:00","ends_at":"2026-03-10T09:30:00-03:00","price_clp":15000,"business":{"id":"3f2b9a10-0000-4000-8000-aaaaaaaaaaaa","slug":"barberia-nunoa","name":"Barbería Ñuñoa","address":"Av. Irarrázaval 1234","comuna":"Ñuñoa","timezone":"America/Santiago"},"service":{"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","name":"Corte de pelo","duration_minutes":30,"price_clp":15000,"modality":"IN_PERSON","requires_prepayment":false},"staff":{"id":"3f2b9a10-0000-4000-8000-cccccccccccc","display_name":"Ana"},"can_cancel":true,"can_reschedule":true,"has_review":false,"created_at":"2026-03-05T18:42:11-03:00"}}}},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"`TOKEN_INVALID`: el `token` de la URL no sirve. Comparten exactamente esta respuesta la firma manipulada, el plazo agotado, un access token (`type='access'`), un token de otro propósito, el de **otra** reserva y el de una reserva que ya no existe: distinguirlos permitiría averiguar qué reservas hay probando identificadores. Lleva la cabecera `WWW-Authenticate: Bearer realm=\"booking_manage\"`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"token_no_valido":{"summary":"El `token` de la URL no sirve","value":{"code":"TOKEN_INVALID","message":"El enlace no es válido o ha caducado.","details":null,"hint":"Vuelve a abrir el enlace del correo; si caducó, pide uno nuevo al local.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"409":{"description":"`SLOT_TAKEN`: otra transacción ganó ese hueco entre la comprobación y el `UPDATE`. `INVALID_TRANSITION`: la reserva está cerrada (`CANCELLED` o `NO_SHOW`) y ya no se mueve.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"hueco_ocupado":{"summary":"Otra transacción ganó el hueco nuevo","value":{"code":"SLOT_TAKEN","message":"Ese horario ya no está disponible.","details":null,"hint":"Vuelve a pedir GET /api/v1/public/businesses/{slug}/availability y elige otro hueco.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`CANCELLATION_WINDOW_CLOSED`: entró la ventana del local, medida sobre el `starts_at` vigente. `MIN_ADVANCE`: la hora nueva es demasiado inmediata. `SERVICE_NOT_ASSIGNED`: el `staff_id` pedido no presta ese servicio. `SLOT_UNAVAILABLE`: la hora nueva no está libre. `DATETIME_NAIVE`: `starts_at` llegó sin offset.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/public/bookings/{booking_id}/agent-confirm":{"get":{"tags":["public"],"summary":"Revisar la pre-reserva que un agente abrió en tu nombre","description":"Devuelve la pre-reserva que un agente conversacional abrió en nombre del titular, para que este la revise antes de confirmarla (RF-08.04).\n\nEl hueco está **apartado, no confirmado**: `status` vale `PENDING` y `expires_at` dice hasta cuándo. Pasado ese plazo el barrido de expiración lo suelta y esta misma ruta responde `CANCELLED` con `cancelled_reason: \"EXPIRED\"`, que es justo lo que el titular necesita leer si abre el correo tarde: el enlace sigue sirviendo 24 horas más para poder explicárselo.\n\n`consent_required` vale `true` mientras el único consentimiento que consta sea el **declarado por el agente**: la pantalla tiene que pedir la aceptación del titular antes de dejarle confirmar. `otp_verified` dice si ya canjeó el código de 4 dígitos que salió por correo.\n\nLa respuesta **nunca** trae el `manage_token`: la credencial con la que se mueve o se anula una reserva sin cuenta se entrega al confirmar, no al mirar. El contacto del titular viaja enmascarado por el mismo motivo.","operationId":"public_get_agent_booking","parameters":[{"name":"booking_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"Identificador de la reserva devuelto al crearla.","examples":["3f2b9a10-0000-4000-8000-dddddddddddd"],"title":"Booking Id"},"description":"Identificador de la reserva devuelto al crearla."},{"name":"token","in":"query","required":true,"schema":{"type":"string","description":"Token del enlace de confirmación que Lukin envió al titular al abrirse la pre-reserva. Es un JWT de propósito `agent_confirm` atado a **esta** reserva y vigente hasta 24 horas después de que caduque el hueco: no abre sesión, no gestiona la reserva y `Authorization: Bearer` lo rechaza. Cualquier fallo —firma, plazo, propósito o reserva— responde `401 TOKEN_INVALID`.","examples":["eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."],"title":"Token"},"description":"Token del enlace de confirmación que Lukin envió al titular al abrirse la pre-reserva. Es un JWT de propósito `agent_confirm` atado a **esta** reserva y vigente hasta 24 horas después de que caduque el hueco: no abre sesión, no gestiona la reserva y `Authorization: Bearer` lo rechaza. Cualquier fallo —firma, plazo, propósito o reserva— responde `401 TOKEN_INVALID`."}],"responses":{"200":{"description":"La pre-reserva, con el estado del hueco, el consentimiento pendiente y —si el servicio exige prepago y el negocio puede cobrar— el enlace de pago.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentBookingView"},"example":{"booking_id":"3f2b9a10-0000-4000-8000-dddddddddddd","status":"PENDING","starts_at":"2026-03-10T12:00:00Z","ends_at":"2026-03-10T12:30:00Z","timezone":"America/Santiago","business":{"name":"Barbería Ñuñoa","slug":"barberia-nunoa","address":"Av. Irarrázaval 1234","comuna":"Ñuñoa","logo_url":"https://cdn.lukin.cl/negocios/barberia-nunoa/logo.png"},"service":{"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","name":"Corte de pelo","duration_minutes":30,"price_clp":15000,"modality":"IN_PERSON","requires_prepayment":false},"staff_name":"Camila Soto","customer":{"full_name":"Javiera Rojas","email_masked":"j***@gmail.com","phone_masked":"+56 9 ****5678"},"otp_channel":"EMAIL","otp_verified":false,"consent_required":true,"expires_at":"2026-03-09T18:20:00Z","requires_payment":true,"payment_status":"PENDING","payment_checkout_url":"https://www.mercadopago.cl/checkout/v1/redirect?pref_id=1234567890-abcd"}}}},"401":{"description":"`TOKEN_INVALID`: el token falta, está manipulado, caducó, es de otro propósito o es de otra reserva. Una reserva inexistente responde igual: el token es la única referencia y probar UUID no debe revelar cuáles llegaron a existir.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"token_no_valido":{"summary":"El `token` de la URL no sirve","value":{"code":"TOKEN_INVALID","message":"El enlace no es válido o ha caducado.","details":null,"hint":"Vuelve a abrir el enlace del correo; si caducó, pide uno nuevo al local.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"peticion_mal_formada":{"summary":"Falta el `token` de la URL o el `booking_id` no es un UUID","value":{"code":"VALIDATION_ERROR","message":"Los datos enviados no son válidos.","details":[{"loc":["query","token"],"msg":"Field required"}],"hint":"Usa el enlace tal y como se envió al titular, sin recortarlo.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/public/bookings/{booking_id}/consent":{"post":{"tags":["public"],"summary":"Registrar tu aceptación de los textos legales","description":"Archiva el consentimiento **del titular** sobre una reserva que abrió un agente\nen su nombre (SEC-01, Ley 19.628).\n\nEs el segundo acto de un trámite que empieza fuera de Lukin. Cuando un asistente\nconversacional abre la pre-reserva, lo único que puede aportar es su\n**declaración** de haber mostrado los textos, y así queda archivada: con el\n`user_agent` prefijado `agent:`. Eso acredita lo que dijo el agente, no la\nvoluntad de la persona. Esta operación registra la voluntad de la persona, con\nla IP y el user agent **reales** de quien la ejerce, y sella los dos documentos\nobligatorios —Términos y Política de Privacidad— en su versión vigente.\n\nResponde `204` sin cuerpo y es **idempotente**: llamarla otra vez no añade una\nsegunda fila mientras el consentimiento vigente ya sea el del titular. Sí vuelve\na escribir cuando lo único que consta es la declaración del agente o cuando el\ntexto cambió de versión, que son los dos casos en los que hace falta una\naceptación nueva.\n\nUn `false` en cualquiera de los dos campos responde `422 CONSENT_REQUIRED` y no\nregistra nada: la ley pide una manifestación de voluntad expresa, y no aceptar\nes una respuesta legítima —solo que sin ella no se puede seguir—. El estado del\nconsentimiento se consulta en el `consent_required` de\n`GET /api/v1/public/bookings/{booking_id}/agent-confirm`.","operationId":"public_record_booking_consent","parameters":[{"name":"booking_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"Identificador de la reserva devuelto al crearla.","examples":["3f2b9a10-0000-4000-8000-dddddddddddd"],"title":"Booking Id"},"description":"Identificador de la reserva devuelto al crearla."},{"name":"token","in":"query","required":true,"schema":{"type":"string","description":"Token del enlace que Lukin envió al titular: el de propósito `agent_confirm` de la pre-reserva o el `booking_manage` que entregó la confirmación. Cualquiera de los dos vale, siempre que sea de **esta** reserva; en cualquier otro caso la respuesta es `401 TOKEN_INVALID`.","examples":["eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."],"title":"Token"},"description":"Token del enlace que Lukin envió al titular: el de propósito `agent_confirm` de la pre-reserva o el `booking_manage` que entregó la confirmación. Cualquiera de los dos vale, siempre que sea de **esta** reserva; en cualquier otro caso la respuesta es `401 TOKEN_INVALID`."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GuestBookingConsent"},"examples":{"acepta":{"summary":"El titular acepta los dos documentos","description":"El caso normal: la pantalla de confirmación muestra los dos textos con su versión y el titular los acepta antes de seguir.","value":{"accept_privacy":true,"accept_terms":true}},"rechaza":{"summary":"El titular no acepta la política de privacidad","description":"Responde `422 CONSENT_REQUIRED` y no deja ningún registro.","value":{"accept_privacy":false,"accept_terms":true}}}}}},"responses":{"204":{"description":"Consentimiento archivado con la IP y el user agent del titular. Sin cuerpo: no hay nada que devolver que el cliente no supiera ya. Una segunda llamada responde igual sin duplicar el registro."},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"`TOKEN_INVALID`: el token falta, está manipulado, caducó, es de otro propósito o es de otra reserva. Una reserva inexistente responde igual, por el mismo motivo que en el enlace de confirmación.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"`CONSENT_REQUIRED`: `accept_privacy` o `accept_terms` no valen `true`. No se registra nada y el consentimiento sigue pendiente.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"no_acepta":{"summary":"El titular no marcó los dos documentos","value":{"code":"CONSENT_REQUIRED","message":"Falta la aceptación de los términos y de la política de privacidad.","details":{"accept_privacy":false,"accept_terms":true},"hint":"Sin la aceptación expresa del titular la reserva no puede confirmarse.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/bookings":{"post":{"tags":["bookings"],"summary":"Reservar con la cuenta iniciada","description":"Crea una cita a nombre de **quien llama** y la deja confirmada en el mismo acto.\n\nNo hay código de confirmación: la sesión ya prueba quién reserva, así que la\nreserva nace `CONFIRMED` y `source: WEB`. Ese es el único cambio respecto a\n`POST /api/v1/public/bookings`, que es la puerta de quien no tiene cuenta.\n\nEl local tiene que estar **publicado** y el servicio **activo**; la hora tiene\nque estar en la rejilla que publica\n`GET /api/v1/public/businesses/{slug}/availability` y respetar la antelación\nmínima del negocio. Sin `staff_id`, el motor reparte entre quienes prestan el\nservicio y estén libres, eligiendo a la persona con menos citas ese día.\n\n`starts_at` se envía **con offset** (`2026-03-10T09:00:00-03:00`): una hora sin\nzona es ambigua y responde `422`. La respuesta devuelve los instantes ya en hora\nlocal del negocio, listos para pintar.","operationId":"bookings_create_my_booking","security":[{"HTTPBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientBookingCreate"},"examples":{"con_persona":{"summary":"Reservar con una persona concreta","description":"`staff_id` sale de `GET /api/v1/public/businesses/{slug}/services/{service_id}/staff`.","value":{"business_slug":"barberia-nunoa","service_id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","staff_id":"3f2b9a10-0000-4000-8000-cccccccccccc","starts_at":"2026-03-10T09:00:00-03:00"}},"sin_preferencia":{"summary":"Reservar sin elegir persona","description":"Sin `staff_id`, el motor asigna a quien esté libre.","value":{"business_slug":"barberia-nunoa","service_id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","starts_at":"2026-03-10T09:00:00-03:00"}}}}}},"responses":{"201":{"description":"Reserva creada y **confirmada** en el mismo acto. Ocupa agenda desde ya: no hay código que canjear.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingOut"},"example":{"id":"3f2b9a10-0000-4000-8000-dddddddddddd","status":"CONFIRMED","source":"WEB","starts_at":"2026-03-10T09:00:00-03:00","ends_at":"2026-03-10T09:30:00-03:00","price_clp":15000,"business":{"id":"3f2b9a10-0000-4000-8000-aaaaaaaaaaaa","slug":"barberia-nunoa","name":"Barbería Ñuñoa","address":"Av. Irarrázaval 1234","comuna":"Ñuñoa","timezone":"America/Santiago"},"service":{"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","name":"Corte de pelo","duration_minutes":30,"price_clp":15000,"modality":"IN_PERSON","requires_prepayment":false},"staff":{"id":"3f2b9a10-0000-4000-8000-cccccccccccc","display_name":"Ana"},"can_cancel":true,"can_reschedule":true,"has_review":false,"created_at":"2026-03-05T18:42:11-03:00"}}}},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"`TOKEN_MISSING`, `TOKEN_EXPIRED`, `TOKEN_INVALID` o `USER_INACTIVE`: no hay sesión utilizable. Solo `TOKEN_EXPIRED` justifica renovar y reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"No llegó ningún access token utilizable","value":{"code":"TOKEN_MISSING","message":"Necesitas iniciar sesión para continuar.","details":null,"hint":"Envía `Authorization: Bearer <access_token>`.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`INSUFFICIENT_ROLE`: la cuenta no puede usar esta superficie. Le ocurre al rol `GUEST`, que gestiona su reserva con el `manage_token` de `POST /api/v1/public/bookings/{booking_id}/confirm`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: no hay ningún negocio **publicado** con ese slug —el inexistente, el que sigue en borrador y el dado de baja responden igual—. `SERVICE_NOT_FOUND`: el servicio no existe, está retirado del catálogo o es de otro local.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"`SLOT_TAKEN`: alguien ocupó ese hueco entre el cálculo y el alta. `TOO_MANY_PENDING`: la cuenta acumula el máximo de reservas sin confirmar. `BUSINESS_UNAVAILABLE`: el local está publicado pero ahora mismo no acepta reservas por internet; su disponibilidad ya venía vacía y esto solo alcanza a quien llegó en la misma carrera.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"hueco_ocupado":{"summary":"Alguien ganó ese hueco entre el cálculo y el alta","value":{"code":"SLOT_TAKEN","message":"Ese horario ya no está disponible.","details":null,"hint":"Vuelve a pedir GET /api/v1/public/businesses/{slug}/availability y elige otro hueco.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`DATETIME_NAIVE`: `starts_at` llegó sin offset. `MIN_ADVANCE`: la cita es demasiado inmediata para la antelación que exige el local. `SLOT_UNAVAILABLE`: esa hora no está en la rejilla publicada o ya no está libre.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"get":{"tags":["bookings"],"summary":"Listar mis reservas, próximas o pasadas","description":"Página de las reservas de **quien llama**, y de nadie más. El filtro\n`customer_id` va en la consulta, no después: una reserva de otro cliente no\naparece aquí bajo ninguna combinación de parámetros.\n\n`scope` resuelve en el servidor las dos pestañas de «Mis reservas»:\n`upcoming` deja las que aún no han terminado ordenadas de la más próxima a la\nmás lejana, y `past` las ya terminadas de la más reciente a la más antigua. Sin\n`scope` se devuelven todas, de la más reciente a la más antigua. **El filtro y\nel orden se aplican antes de paginar**, así que `total` cuenta exactamente las\nfilas que se están paginando y ninguna página llega incompleta.\n\n`status` se puede repetir y `from` acota por hora de inicio; los tres filtros se\ncombinan.\n\n`page_size` mayor que 100 **no es un error**: se acota a\n100 y la respuesta devuelve en `page_size` el valor realmente\naplicado.","operationId":"bookings_list_my_bookings","security":[{"HTTPBearer":[]}],"parameters":[{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"$ref":"#/components/schemas/BookingStatus"}},{"type":"null"}],"description":"Filtra por estado. Se puede repetir (`?status=CONFIRMED&status=PAID`) y los valores se combinan con **O**. Omitido, no filtra nada.","title":"Status"},"description":"Filtra por estado. Se puede repetir (`?status=CONFIRMED&status=PAID`) y los valores se combinan con **O**. Omitido, no filtra nada."},{"name":"from","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Devuelve solo las citas que empiezan en este instante o después (ISO 8601 **con offset**). Se exige la zona horaria por la misma razón que al reservar: un instante ingenuo no delimita nada. Se combina con `scope`.","examples":["2026-03-01T00:00:00-03:00"],"title":"From"},"description":"Devuelve solo las citas que empiezan en este instante o después (ISO 8601 **con offset**). Se exige la zona horaria por la misma razón que al reservar: un instante ingenuo no delimita nada. Se combina con `scope`."},{"name":"scope","in":"query","required":false,"schema":{"anyOf":[{"enum":["upcoming","past"],"type":"string"},{"type":"null"}],"description":"Las dos mitades de «mis reservas», resueltas en el servidor:\n\n- `upcoming`: las que aún no han terminado (`ends_at >= ahora`), de la más próxima a la más lejana.\n- `past`: las que ya terminaron (`ends_at < ahora`), de la más reciente a la más antigua.\n\nOmitido, se devuelven **todas**, de la más reciente a la más antigua. El filtro y el orden se aplican antes de paginar, así que `total` y `items` describen siempre el mismo conjunto.","title":"Scope"},"description":"Las dos mitades de «mis reservas», resueltas en el servidor:\n\n- `upcoming`: las que aún no han terminado (`ends_at >= ahora`), de la más próxima a la más lejana.\n- `past`: las que ya terminaron (`ends_at < ahora`), de la más reciente a la más antigua.\n\nOmitido, se devuelven **todas**, de la más reciente a la más antigua. El filtro y el orden se aplican antes de paginar, así que `total` y `items` describen siempre el mismo conjunto.","examples":{"proximas":{"summary":"Próximas citas","description":"Lo que la pestaña «Próximas» de /mi-cuenta pide.","value":"upcoming"},"pasadas":{"summary":"Historial","description":"Lo que pide la pestaña «Pasadas».","value":"past"}}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"Página que se pide, empezando en 1.","default":1,"title":"Page"},"description":"Página que se pide, empezando en 1."},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"Filas por página. Valores mayores que 100 no dan error: se acotan a 100 y la respuesta devuelve el valor aplicado.","default":20,"title":"Page Size"},"description":"Filas por página. Valores mayores que 100 no dan error: se acotan a 100 y la respuesta devuelve el valor aplicado."}],"responses":{"200":{"description":"Página de reservas **propias**. Una lista vacía es una respuesta legítima, no un error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Page_BookingOut_"},"examples":{"una_pagina":{"summary":"Primera página de «Próximas»","value":{"items":[{"id":"3f2b9a10-0000-4000-8000-dddddddddddd","status":"CONFIRMED","source":"WEB","starts_at":"2026-03-10T09:00:00-03:00","ends_at":"2026-03-10T09:30:00-03:00","price_clp":15000,"business":{"id":"3f2b9a10-0000-4000-8000-aaaaaaaaaaaa","slug":"barberia-nunoa","name":"Barbería Ñuñoa","address":"Av. Irarrázaval 1234","comuna":"Ñuñoa","timezone":"America/Santiago"},"service":{"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","name":"Corte de pelo","duration_minutes":30,"price_clp":15000,"modality":"IN_PERSON","requires_prepayment":false},"staff":{"id":"3f2b9a10-0000-4000-8000-cccccccccccc","display_name":"Ana"},"cancelled_reason":null,"can_cancel":true,"can_reschedule":true,"has_review":false,"created_at":"2026-03-05T18:42:11-03:00"}],"total":1,"page":1,"page_size":20}}}}}},"401":{"description":"`TOKEN_MISSING`, `TOKEN_EXPIRED`, `TOKEN_INVALID` o `USER_INACTIVE`: no hay sesión utilizable. Solo `TOKEN_EXPIRED` justifica renovar y reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"No llegó ningún access token utilizable","value":{"code":"TOKEN_MISSING","message":"Necesitas iniciar sesión para continuar.","details":null,"hint":"Envía `Authorization: Bearer <access_token>`.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`INSUFFICIENT_ROLE`: la cuenta no puede usar esta superficie. Le ocurre al rol `GUEST`, que gestiona su reserva con el `manage_token` de `POST /api/v1/public/bookings/{booking_id}/confirm`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"filtro_invalido":{"summary":"Un `page` fuera de rango o un `status` que no está en el enum","value":{"code":"VALIDATION_ERROR","message":"Los datos enviados no son válidos.","details":[{"loc":["query","page"],"msg":"Input should be greater than 0"}],"hint":"Revisa los parámetros de la consulta contra el esquema de la operación.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/bookings/{booking_id}":{"get":{"tags":["bookings"],"summary":"Consultar una reserva mía","description":"Una reserva propia, con el local, el servicio y la persona ya resueltos, y con\n`can_cancel` / `can_reschedule` calculados contra la ventana de cancelación\nvigente del negocio.\n\nUna reserva de otro cliente responde `404`, igual que un identificador\ninventado: la API no confirma qué reservas existen.","operationId":"bookings_get_my_booking","security":[{"HTTPBearer":[]}],"parameters":[{"name":"booking_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"Identificador de la reserva, devuelto al crearla y en el listado.","examples":["3f2b9a10-0000-4000-8000-dddddddddddd"],"title":"Booking Id"},"description":"Identificador de la reserva, devuelto al crearla y en el listado."}],"responses":{"200":{"description":"La reserva, con el local, el servicio y la persona resueltos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingOut"},"example":{"id":"3f2b9a10-0000-4000-8000-dddddddddddd","status":"CONFIRMED","source":"WEB","starts_at":"2026-03-10T09:00:00-03:00","ends_at":"2026-03-10T09:30:00-03:00","price_clp":15000,"business":{"id":"3f2b9a10-0000-4000-8000-aaaaaaaaaaaa","slug":"barberia-nunoa","name":"Barbería Ñuñoa","address":"Av. Irarrázaval 1234","comuna":"Ñuñoa","timezone":"America/Santiago"},"service":{"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","name":"Corte de pelo","duration_minutes":30,"price_clp":15000,"modality":"IN_PERSON","requires_prepayment":false},"staff":{"id":"3f2b9a10-0000-4000-8000-cccccccccccc","display_name":"Ana"},"can_cancel":true,"can_reschedule":true,"has_review":false,"created_at":"2026-03-05T18:42:11-03:00"}}}},"401":{"description":"`TOKEN_MISSING`, `TOKEN_EXPIRED`, `TOKEN_INVALID` o `USER_INACTIVE`: no hay sesión utilizable. Solo `TOKEN_EXPIRED` justifica renovar y reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"No llegó ningún access token utilizable","value":{"code":"TOKEN_MISSING","message":"Necesitas iniciar sesión para continuar.","details":null,"hint":"Envía `Authorization: Bearer <access_token>`.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`INSUFFICIENT_ROLE`: la cuenta no puede usar esta superficie. Le ocurre al rol `GUEST`, que gestiona su reserva con el `manage_token` de `POST /api/v1/public/bookings/{booking_id}/confirm`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"`BOOKING_NOT_FOUND`: no hay ninguna reserva tuya con ese identificador. Una reserva de **otro cliente** responde exactamente igual: el identificador es la única referencia y probar UUID no debe revelar qué reservas existen.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"reserva_ajena_o_inexistente":{"summary":"No hay ninguna reserva tuya con ese identificador","value":{"code":"BOOKING_NOT_FOUND","message":"La reserva solicitada no existe.","details":null,"hint":"Toma el identificador de GET /api/v1/bookings.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/bookings/{booking_id}/cancel":{"post":{"tags":["bookings"],"summary":"Anular una reserva mía","description":"Anula la reserva y libera el hueco, que vuelve a la rejilla en cuanto la\noperación confirma. La fila **no se borra**: sigue en el historial con su\n`cancelled_reason`.\n\nEl cliente puede anular por su cuenta hasta `cancellation_window_hours` antes\ndel inicio de la cita; a partir de ahí la respuesta es\n`422 CANCELLATION_WINDOW_CLOSED` y solo el local puede hacerlo. Ese límite es el\nmismo que anticipa `can_cancel`, así que una reserva con `can_cancel: true` no\npuede responder ese 422.\n\nAnular una reserva ya cerrada es `409 INVALID_TRANSITION`, no una operación sin\nefecto: el estado terminal es una respuesta, no un no-op.","operationId":"bookings_cancel_my_booking","security":[{"HTTPBearer":[]}],"parameters":[{"name":"booking_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"Identificador de la reserva, devuelto al crearla y en el listado.","examples":["3f2b9a10-0000-4000-8000-dddddddddddd"],"title":"Booking Id"},"description":"Identificador de la reserva, devuelto al crearla y en el listado."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingCancel"},"examples":{"con_motivo":{"summary":"Con motivo","description":"El texto se guarda en `cancelled_reason`.","value":{"reason":"Me surgió un imprevisto"}},"sin_motivo":{"summary":"Sin motivo","description":"El motivo es opcional: un cuerpo vacío basta.","value":{}}}}}},"responses":{"200":{"description":"Reserva anulada. El hueco vuelve a la rejilla en el acto y `can_cancel` pasa a `false`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingOut"},"examples":{"reserva_anulada":{"summary":"La reserva queda `CANCELLED` y el hueco vuelve a la rejilla","value":{"id":"3f2b9a10-0000-4000-8000-dddddddddddd","status":"CANCELLED","source":"WEB","starts_at":"2026-03-10T09:00:00-03:00","ends_at":"2026-03-10T09:30:00-03:00","price_clp":15000,"business":{"id":"3f2b9a10-0000-4000-8000-aaaaaaaaaaaa","slug":"barberia-nunoa","name":"Barbería Ñuñoa","address":"Av. Irarrázaval 1234","comuna":"Ñuñoa","timezone":"America/Santiago"},"service":{"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","name":"Corte de pelo","duration_minutes":30,"price_clp":15000,"modality":"IN_PERSON","requires_prepayment":false},"staff":{"id":"3f2b9a10-0000-4000-8000-cccccccccccc","display_name":"Ana"},"cancelled_reason":"Me surgió un imprevisto","can_cancel":false,"can_reschedule":false,"has_review":false,"created_at":"2026-03-05T18:42:11-03:00"}}}}}},"401":{"description":"`TOKEN_MISSING`, `TOKEN_EXPIRED`, `TOKEN_INVALID` o `USER_INACTIVE`: no hay sesión utilizable. Solo `TOKEN_EXPIRED` justifica renovar y reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"No llegó ningún access token utilizable","value":{"code":"TOKEN_MISSING","message":"Necesitas iniciar sesión para continuar.","details":null,"hint":"Envía `Authorization: Bearer <access_token>`.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`INSUFFICIENT_ROLE`: la cuenta no puede usar esta superficie. Le ocurre al rol `GUEST`, que gestiona su reserva con el `manage_token` de `POST /api/v1/public/bookings/{booking_id}/confirm`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"`BOOKING_NOT_FOUND`: no hay ninguna reserva tuya con ese identificador. Una reserva de **otro cliente** responde exactamente igual: el identificador es la única referencia y probar UUID no debe revelar qué reservas existen.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"reserva_ajena_o_inexistente":{"summary":"No hay ninguna reserva tuya con ese identificador","value":{"code":"BOOKING_NOT_FOUND","message":"La reserva solicitada no existe.","details":null,"hint":"Toma el identificador de GET /api/v1/bookings.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"409":{"description":"`INVALID_TRANSITION`: la reserva ya está `CANCELLED` o `NO_SHOW`, y ninguna de las dos vuelve atrás.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"`CANCELLATION_WINDOW_CLOSED`: entró la ventana de cancelación del local y a estas alturas solo el negocio puede anularla. `details` trae `window_hours` y `deadline`. Es exactamente lo que anticipa `can_cancel: false`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"ventana_cerrada":{"summary":"Entró la ventana de cancelación del local","value":{"code":"CANCELLATION_WINDOW_CLOSED","message":"Ya no se puede anular esta reserva por internet.","details":{"window_hours":24,"deadline":"2026-03-09T09:00:00-03:00"},"hint":"Llama al local: dentro de la ventana solo el negocio puede anularla.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/bookings/{booking_id}/reschedule":{"post":{"tags":["bookings"],"summary":"Mover una reserva mía de hora","description":"Mueve la cita de hora —y opcionalmente de persona— **conservando el `id`**: es\nla misma reserva, no una anulación seguida de un alta, de modo que el pago y el\nhistorial siguen apuntando a ella. El estado tampoco cambia: una `PAID` sigue\n`PAID`.\n\n`ends_at` se vuelve a derivar de la duración del servicio y no se envía. El\nhueco nuevo se defiende igual que en el alta, así que una hora que otra persona\ngane por medio segundo responde `409 SLOT_TAKEN`.\n\nLa ventana de cancelación se mide sobre el `starts_at` **vigente** —lo que el\nlocal protege es el hueco que ya tenía apartado— y la antelación mínima sobre la\nhora **nueva**.","operationId":"bookings_reschedule_my_booking","security":[{"HTTPBearer":[]}],"parameters":[{"name":"booking_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"Identificador de la reserva, devuelto al crearla y en el listado.","examples":["3f2b9a10-0000-4000-8000-dddddddddddd"],"title":"Booking Id"},"description":"Identificador de la reserva, devuelto al crearla y en el listado."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingReschedule"},"examples":{"nueva_hora":{"summary":"Mover de hora","description":"Con `staff_id`, además cambia de persona.","value":{"starts_at":"2026-03-11T11:00:00-03:00","staff_id":"3f2b9a10-0000-4000-8000-cccccccccccc"}},"misma_persona":{"summary":"Mover sin cambiar de persona","description":"Sin `staff_id`, la atiende quien ya la tenía.","value":{"starts_at":"2026-03-11T11:00:00-03:00"}}}}}},"responses":{"200":{"description":"Reserva movida. Conserva el **mismo `id`** y el mismo estado: solo cambian `starts_at`, `ends_at` y, si se pidió, la persona.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingOut"},"examples":{"reserva_movida":{"summary":"Mismo `id` y mismo estado, otra hora","value":{"id":"3f2b9a10-0000-4000-8000-dddddddddddd","status":"CONFIRMED","source":"WEB","starts_at":"2026-03-11T11:00:00-03:00","ends_at":"2026-03-11T11:30:00-03:00","price_clp":15000,"business":{"id":"3f2b9a10-0000-4000-8000-aaaaaaaaaaaa","slug":"barberia-nunoa","name":"Barbería Ñuñoa","address":"Av. Irarrázaval 1234","comuna":"Ñuñoa","timezone":"America/Santiago"},"service":{"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","name":"Corte de pelo","duration_minutes":30,"price_clp":15000,"modality":"IN_PERSON","requires_prepayment":false},"staff":{"id":"3f2b9a10-0000-4000-8000-cccccccccccc","display_name":"Ana"},"cancelled_reason":null,"can_cancel":true,"can_reschedule":true,"has_review":false,"created_at":"2026-03-05T18:42:11-03:00"}}}}}},"401":{"description":"`TOKEN_MISSING`, `TOKEN_EXPIRED`, `TOKEN_INVALID` o `USER_INACTIVE`: no hay sesión utilizable. Solo `TOKEN_EXPIRED` justifica renovar y reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"sin_sesion":{"summary":"No llegó ningún access token utilizable","value":{"code":"TOKEN_MISSING","message":"Necesitas iniciar sesión para continuar.","details":null,"hint":"Envía `Authorization: Bearer <access_token>`.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"403":{"description":"`INSUFFICIENT_ROLE`: la cuenta no puede usar esta superficie. Le ocurre al rol `GUEST`, que gestiona su reserva con el `manage_token` de `POST /api/v1/public/bookings/{booking_id}/confirm`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"`BOOKING_NOT_FOUND`: no hay ninguna reserva tuya con ese identificador. Una reserva de **otro cliente** responde exactamente igual: el identificador es la única referencia y probar UUID no debe revelar qué reservas existen.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"reserva_ajena_o_inexistente":{"summary":"No hay ninguna reserva tuya con ese identificador","value":{"code":"BOOKING_NOT_FOUND","message":"La reserva solicitada no existe.","details":null,"hint":"Toma el identificador de GET /api/v1/bookings.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"409":{"description":"`SLOT_TAKEN`: otra transacción ganó ese hueco. `INVALID_TRANSITION`: la reserva está cerrada (`CANCELLED` o `NO_SHOW`) y ya no se mueve.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"hueco_ocupado":{"summary":"Otra transacción ganó el hueco nuevo","value":{"code":"SLOT_TAKEN","message":"Ese horario ya no está disponible.","details":null,"hint":"Vuelve a pedir GET /api/v1/public/businesses/{slug}/availability y elige otro hueco.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`CANCELLATION_WINDOW_CLOSED`: entró la ventana del local. `MIN_ADVANCE`: la hora nueva es demasiado inmediata. `SERVICE_NOT_ASSIGNED`: el `staff_id` pedido no presta ese servicio. `SLOT_UNAVAILABLE`: la hora nueva no está libre. `DATETIME_NAIVE`: `starts_at` llegó sin offset.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/bookings/{booking_id}/review":{"post":{"tags":["bookings"],"summary":"Valorar una reserva propia con la cuenta iniciada","description":"Publica la valoración de **una cita propia** con la cuenta iniciada.\n\nSolo se valora una visita **propia, viva y ya terminada**: la reserva tiene que\nestar `CONFIRMED` o `PAID` (una `PENDING`, `CANCELLED` o `NO_SHOW` responde\n`422 BOOKING_NOT_REVIEWABLE`) y su `ends_at` tiene que haber pasado (si no,\n`422 BOOKING_NOT_FINISHED`). Cada reserva admite **una sola** reseña: la segunda\nes `409 REVIEW_EXISTS`.\n\nLa reseña y la reputación del local se escriben en la misma transacción, así que\nen cuanto la respuesta llega, `rating_avg` y `rating_count` de\n`GET /api/v1/public/businesses/{slug}` ya la cuentan.\n\nUna reserva de otro cliente responde `404`, igual que un identificador\ninventado: la API no confirma qué reservas existen.","operationId":"bookings_create_my_review","security":[{"HTTPBearer":[]}],"parameters":[{"name":"booking_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"Identificador de la reserva que se quiere valorar.","examples":["3f2b9a10-0000-4000-8000-dddddddddddd"],"title":"Booking Id"},"description":"Identificador de la reserva que se quiere valorar."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewCreate"},"examples":{"con_comentario":{"summary":"Puntuar y contar por qué","description":"El comentario es opcional pero es lo que otros clientes leen.","value":{"rating":5,"comment":"Puntualísimos y el corte quedó tal cual lo pedí."}},"solo_estrellas":{"summary":"Puntuar sin escribir nada","description":"Sin `comment`, la reseña cuenta igual para el promedio del local.","value":{"rating":4}}}}}},"responses":{"201":{"description":"Reseña publicada y reputación del local ya actualizada.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewOut"},"example":{"id":"3f2b9a10-0000-4000-8000-eeeeeeeeeeee","booking_id":"3f2b9a10-0000-4000-8000-dddddddddddd","rating":5,"comment":"Puntualísimos y el corte quedó tal cual lo pedí.","published_at":"2026-03-10T13:05:00Z"}}}},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"`REVIEW_EXISTS`: esa reserva ya tiene reseña. Una visita admite una sola opinión, y lo garantiza también el índice único `uq_reviews_booking_id`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"ya_resenada":{"summary":"Esa visita ya tiene reseña","value":{"code":"REVIEW_EXISTS","message":"Esta reserva ya fue valorada.","details":null,"hint":"Cada reserva admite una sola opinión.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`BOOKING_NOT_REVIEWABLE`: el estado de la reserva no admite opinión (`PENDING`, `CANCELLED` o `NO_SHOW`); `details` trae el estado actual y los que sí valen. `BOOKING_NOT_FINISHED`: la cita todavía no ha terminado. `VALIDATION_ERROR`: `rating` fuera de 1..5 o comentario demasiado largo.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"`TOKEN_MISSING`, `TOKEN_EXPIRED`, `TOKEN_INVALID` o `USER_INACTIVE`: no hay sesión utilizable. Solo `TOKEN_EXPIRED` justifica renovar y reintentar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"`BOOKING_NOT_FOUND`: no hay ninguna reserva **tuya** con ese identificador. La de otro cliente responde exactamente igual: probar UUID no debe revelar qué reservas existen.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"reserva_ajena_o_inexistente":{"summary":"No hay ninguna reserva tuya con ese identificador","value":{"code":"BOOKING_NOT_FOUND","message":"La reserva solicitada no existe.","details":null,"hint":"Toma el identificador de GET /api/v1/bookings.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/public/bookings/{booking_id}/review":{"post":{"tags":["public"],"summary":"Valorar una reserva desde el enlace de autogestión","description":"Publica la valoración de una cita reservada **sin cuenta**, con el `manage_token`\nque entregó `POST /api/v1/public/bookings/{booking_id}/confirm` y que viaja\ndentro del enlace del correo.\n\nSolo se valora una visita **propia, viva y ya terminada**: la reserva tiene que\nestar `CONFIRMED` o `PAID` (una `PENDING`, `CANCELLED` o `NO_SHOW` responde\n`422 BOOKING_NOT_REVIEWABLE`) y su `ends_at` tiene que haber pasado (si no,\n`422 BOOKING_NOT_FINISHED`). Cada reserva admite **una sola** reseña: la segunda\nes `409 REVIEW_EXISTS`.\n\nLa reseña y la reputación del local se escriben en la misma transacción, así que\nen cuanto la respuesta llega, `rating_avg` y `rating_count` de\n`GET /api/v1/public/businesses/{slug}` ya la cuentan.\n\nLa credencial va en la query porque su portador es un correo, no una cabecera. El\ntoken está atado a *esta* reserva: el de otra responde `401`, igual que uno\ncaducado, uno manipulado o un access token. Quien firma la reseña es el cliente\nde la reserva que el token abre, así que aquí no hay forma de opinar sobre la\ncita de un tercero.","operationId":"public_create_guest_review","parameters":[{"name":"booking_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"Identificador de la reserva, el mismo que va en el enlace del correo.","examples":["3f2b9a10-0000-4000-8000-dddddddddddd"],"title":"Booking Id"},"description":"Identificador de la reserva, el mismo que va en el enlace del correo."},{"name":"token","in":"query","required":true,"schema":{"type":"string","description":"`manage_token` entregado al confirmar la reserva (`POST /api/v1/public/bookings/{booking_id}/confirm`). Es un JWT de propósito `booking_manage` atado a **esta** reserva y válido hasta una semana después de la cita: no abre sesión y `Authorization: Bearer` lo rechaza. Cualquier fallo —firma, plazo, propósito o reserva— responde `401 TOKEN_INVALID`.","examples":["eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."],"title":"Token"},"description":"`manage_token` entregado al confirmar la reserva (`POST /api/v1/public/bookings/{booking_id}/confirm`). Es un JWT de propósito `booking_manage` atado a **esta** reserva y válido hasta una semana después de la cita: no abre sesión y `Authorization: Bearer` lo rechaza. Cualquier fallo —firma, plazo, propósito o reserva— responde `401 TOKEN_INVALID`."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewCreate"},"examples":{"con_comentario":{"summary":"Puntuar y contar por qué","description":"El comentario es opcional pero es lo que otros clientes leen.","value":{"rating":5,"comment":"Puntualísimos y el corte quedó tal cual lo pedí."}},"solo_estrellas":{"summary":"Puntuar sin escribir nada","description":"Sin `comment`, la reseña cuenta igual para el promedio del local.","value":{"rating":4}}}}}},"responses":{"201":{"description":"Reseña publicada y reputación del local ya actualizada.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewOut"},"example":{"id":"3f2b9a10-0000-4000-8000-eeeeeeeeeeee","booking_id":"3f2b9a10-0000-4000-8000-dddddddddddd","rating":5,"comment":"Puntualísimos y el corte quedó tal cual lo pedí.","published_at":"2026-03-10T13:05:00Z"}}}},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"`REVIEW_EXISTS`: esa reserva ya tiene reseña. Una visita admite una sola opinión, y lo garantiza también el índice único `uq_reviews_booking_id`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"ya_resenada":{"summary":"Esa visita ya tiene reseña","value":{"code":"REVIEW_EXISTS","message":"Esta reserva ya fue valorada.","details":null,"hint":"Cada reserva admite una sola opinión.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"`BOOKING_NOT_REVIEWABLE`: el estado de la reserva no admite opinión (`PENDING`, `CANCELLED` o `NO_SHOW`); `details` trae el estado actual y los que sí valen. `BOOKING_NOT_FINISHED`: la cita todavía no ha terminado. `VALIDATION_ERROR`: `rating` fuera de 1..5 o comentario demasiado largo.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"`TOKEN_INVALID`: el `token` de la URL no sirve. Comparten esta misma respuesta la firma manipulada, el plazo agotado, un access token, un token de otro propósito, el de **otra** reserva y el de una reserva que ya no existe. Lleva la cabecera `WWW-Authenticate: Bearer realm=\"booking_manage\"`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"token_no_valido":{"summary":"El `token` de la URL no sirve","value":{"code":"TOKEN_INVALID","message":"El enlace no es válido o ha caducado.","details":null,"hint":"Usa el enlace del correo de esta misma reserva.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/public/businesses/{slug}/reviews":{"get":{"tags":["public"],"summary":"Listar las reseñas publicadas de un local","description":"Reseñas publicadas de un local, paginadas y ordenadas por `published_at`\n**descendente**: lo primero que se lee es lo último que se escribió.\n\nLa respuesta trae además `rating_avg` y `rating_count`, las mismas cifras que\npublica la ficha del local, para que la pestaña de opiniones se pinte con una\nsola llamada.\n\nDe quien reseñó viaja únicamente `author_name` —nombre de pila e inicial, o\n`Usuario eliminado` si la cuenta ejerció el derecho al olvido—: ni correo, ni\nteléfono, ni identificador de usuario, ni la reserva que hay detrás (SEC-01).\n\n`page_size` admite de 1 a 50; un valor mayor responde `422`\nen lugar de recortarse. Un slug no publicado responde `404 BUSINESS_NOT_FOUND`.","operationId":"public_list_public_reviews","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","description":"Identificador del local en la URL del marketplace, en minúsculas y con guiones.","examples":["barberia-nunoa"],"title":"Slug"},"description":"Identificador del local en la URL del marketplace, en minúsculas y con guiones."},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"Página que se pide, empezando en 1.","default":1,"title":"Page"},"description":"Página que se pide, empezando en 1."},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":50,"minimum":1,"description":"Reseñas por página, entre 1 y 50. A diferencia de los listados del panel, un valor mayor **no** se recorta: responde `422`.","default":20,"title":"Page Size"},"description":"Reseñas por página, entre 1 y 50. A diferencia de los listados del panel, un valor mayor **no** se recorta: responde `422`."}],"responses":{"200":{"description":"Página de reseñas del local, de la más reciente a la más antigua. Una lista vacía es una respuesta legítima, no un error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewsPage"},"example":{"items":[{"id":"3f2b9a10-0000-4000-8000-eeeeeeeeeeee","rating":5,"comment":"Puntualísimos y el corte quedó tal cual lo pedí.","author_name":"Camila R.","published_at":"2026-03-10T13:05:00Z"},{"id":"3f2b9a10-0000-4000-8000-ffffffffffff","rating":4,"author_name":"Usuario eliminado","published_at":"2026-03-01T18:20:00Z"}],"total":2,"page":1,"rating_avg":4.5,"rating_count":2}}}},"404":{"description":"`BUSINESS_NOT_FOUND`: no hay ningún negocio **publicado** con ese slug. El inexistente, el que sigue en borrador y el dado de baja responden igual: la vitrina no revela qué slugs están ocupados.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"local_no_publicado":{"summary":"El slug no corresponde a ningún local publicado","value":{"code":"BUSINESS_NOT_FOUND","message":"El negocio solicitado no existe.","details":null,"hint":"Busca el local en GET /api/v1/public/search y usa el `slug` que devuelve.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/webhooks/mercadopago":{"post":{"tags":["payments"],"summary":"Recibir una notificación de Mercado Pago","description":"Recibe las notificaciones de Mercado Pago y las aplica a la reserva\ncorrespondiente. **No lleva autenticación**: quien llama es la pasarela, y lo\nque la acredita es la firma HMAC de la cabecera `x-signature`.\n\nEl secreto con el que se verifica depende del emisor. Un `type=payment` es el\ncobro de una cita, hecho con la cuenta del vendedor: el `user_id` del cuerpo\nresuelve su credencial y se valida con **su** `webhook_secret`, o con el de la\nplataforma si no configuró ninguno. Los tipos de suscripción validan siempre con\nel secreto de la plataforma. Una firma que no cuadra —o cuyo `ts` se sale de la\nventana de tolerancia— responde `401 WEBHOOK_SIGNATURE_INVALID` y no deja\nrastro en `webhook_events`.\n\nEs idempotente por `x-request-id`: la reentrega de un aviso ya resuelto responde\n`200 {\"status\": \"duplicate\"}` sin repetir ningún efecto. Un pago `approved` por\nel importe pactado deja el cobro `APPROVED` y la reserva `PAID`; si la reserva\nseguía pendiente de verificar su código, el pago la confirma. Un importe que no\ncuadra o una reserva que pertenece a otro negocio responden `200` con\n`{\"status\": \"failed\"}` y quedan anotados para conciliación manual: reintentarlos\nno los arreglaría. El único código que pide un reintento es `503`.","operationId":"payments_receive_mercadopago_notification","parameters":[{"name":"data.id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Identificador del recurso del que habla la notificación: el id del pago cuando `type=payment` y el del preapproval en los tipos de suscripción. Es además el dato que entra en el manifiesto firmado, así que una notificación sin él no puede verificarse.","examples":["1330028302"],"title":"Data.Id"},"description":"Identificador del recurso del que habla la notificación: el id del pago cuando `type=payment` y el del preapproval en los tipos de suscripción. Es además el dato que entra en el manifiesto firmado, así que una notificación sin él no puede verificarse."},{"name":"type","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Tipo de notificación. Lukin consume `payment` (cobro de una reserva), `subscription_preapproval` y `subscription_authorized_payment` (suscripción SaaS); cualquier otro se registra y se ignora.","examples":["payment"],"title":"Type"},"description":"Tipo de notificación. Lukin consume `payment` (cobro de una reserva), `subscription_preapproval` y `subscription_authorized_payment` (suscripción SaaS); cualquier otro se registra y se ignora."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookAck"},"examples":{"aplicado":{"summary":"El pago estaba `approved` por el importe pactado: la reserva pasa a `PAID`","value":{"status":"processed","error":null}},"reentrega":{"summary":"Ya se había resuelto una entrega con el mismo `x-request-id`","value":{"status":"duplicate","error":null}}}}}},"429":{"description":"Límite de peticiones excedido","headers":{"Retry-After":{"description":"Segundos que hay que esperar antes de repetir la petición. Coincide con `details.retry_after` del cuerpo.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"`WEBHOOK_SIGNATURE_INVALID`: la cabecera `x-signature` no valida contra el secreto del emisor. No se registra ninguna fila en `webhook_events`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"firma_invalida":{"summary":"`x-signature` no valida contra el secreto del emisor","value":{"code":"WEBHOOK_SIGNATURE_INVALID","message":"La firma de la notificación no es válida.","details":null,"hint":"Revisa el `webhook_secret` configurado para esta aplicación en Mercado Pago.","request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"503":{"description":"`MP_UNAVAILABLE`: no se pudo consultar el pago en Mercado Pago. La notificación queda anotada como `FAILED` y este es el único código con el que se pide al proveedor que reintente.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"requestBody":{"required":true,"description":"Cuerpo de la notificación, tal y como lo envía Mercado Pago. Se archiva íntegro en `webhook_events.payload`.","content":{"application/json":{"examples":{"cobro_de_una_reserva":{"summary":"Aviso de un cobro (`type=payment`), con sus dos cabeceras","description":"La entrega llega con dos cabeceras que esta operación **exige**, y que no forman\nparte del cuerpo:\n\n```http\nPOST /api/v1/webhooks/mercadopago?type=payment&data.id=1330028302\nx-signature: ts=1772136764,v1=****************************************************************\nx-request-id: 9f0c2e1a-0000-4000-8000-3d5b7c9e1f02\nContent-Type: application/json\n```\n\n`x-signature` es la credencial: `ts` es el instante del manifiesto y `v1`\nel HMAC-SHA256 de `id:{data.id};request-id:{x-request-id};ts:{ts};`\ncon el secreto del emisor —el `webhook_secret` del vendedor en un `type=payment`,\nel de la plataforma en los tipos de suscripción—. Aquí va tapado: un digest con\nforma real solo sirve para que alguien lo copie. Si no valida, o si `ts` se sale\nde la ventana de tolerancia, la respuesta es `401 WEBHOOK_SIGNATURE_INVALID`.\n\n`x-request-id` es la clave de idempotencia: Mercado Pago repite el mismo\nvalor al reintentar un aviso, se guarda en `webhook_events.event_id` y es lo que\nhace que una reentrega ya resuelta responda `200 {\"status\": \"duplicate\"}` sin\nrepetir ningún efecto. Sin ella se compone un identificador con\n`{type}:{data.id}:{body.id}`, que es peor pero sigue siendo estable.","value":{"id":122820162551,"live_mode":true,"type":"payment","date_created":"2026-03-09T18:12:44.000-04:00","user_id":44444444,"api_version":"v1","action":"payment.updated","data":{"id":"1330028302"}}}}}}}}},"/api/v1/agent/guide":{"get":{"tags":["agent"],"summary":"Guía de uso de la API para agentes autónomos","description":"Devuelve en **markdown** el manual del canal agéntico: el flujo\n`search → availability → booking → checkout → status` con ejemplos `curl` paso a\npaso, y las reglas que no se deducen del contrato —que el consentimiento lo da\nla persona y no el agente, que las comunas salen de un catálogo cerrado, que las\nhoras salen de la matriz de disponibilidad y que una pre-reserva aparta el hueco\nsolo unos minutos—.\n\nEs el primer sitio al que ir cuando lo único que se tiene es la URL de la API.\nLa respuesta es pública, se puede leer desde cualquier origen y se cachea cinco\nminutos.","operationId":"agent_guide","responses":{"200":{"description":"La guía completa en markdown, tal y como se versiona en el repositorio.","content":{"text/markdown":{"schema":{"type":"string"},"examples":{"guia":{"summary":"Las primeras líneas de la guía","value":"# Guía de Lukin para agentes autónomos\n\nLukin es una plataforma de reservas de servicios en Chile…\n\n## 1. Buscar\n\n```bash\ncurl -s \"$LUKIN_API/api/v1/public/comunas?q=nunoa&limit=5\"\n```\n"}}}}},"422":{"description":"Los datos enviados no son válidos.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Error interno del servidor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/agent/search":{"post":{"tags":["agent"],"summary":"Buscar locales con hora libre para lo que pide una persona","description":"Espejo HTTP de la tool MCP `search_lukin_services`. Es la **primera** llamada de\ncualquier conversación sobre reservar: devuelve el `slug` y el `business_id` con\nlos que se piden agenda y se crea la reserva.\n\nEntiende el lenguaje de una persona —el servicio en sus palabras, la comuna con\no sin tildes, las fechas en relativo («mañana», «el sábado», «en 3 días»)— y\ncada resultado trae hasta tres horas libres **reales** en ISO 8601 con offset,\nlos servicios con su precio y su duración, la distancia y si el local puede\ncobrar por internet.\n\nCero resultados **no** es un error: la respuesta llega con `200` y\n`next_step_hint` sugiere comunas cercanas o ampliar `radius_km`.","operationId":"agent_search","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchLukinServicesInput"},"examples":{"esta_semana":{"summary":"Un corte de pelo en Ñuñoa con hora esta semana","value":{"service_type":"corte de pelo","comuna":"Ñuñoa","date_from":"mañana","date_to":"en 3 días","limit":5}},"sin_fechas":{"summary":"Sin rango: no se filtra por agenda, pero vienen las próximas horas","value":{"service_type":"masaje descontracturante","comuna":"Providencia"}}}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchLukinServicesOutput"},"examples":{"un_resultado":{"summary":"Un local con hueco en el rango pedido","value":{"results":[{"business_id":"9d1f2c34-5b6a-47c8-9e01-2f3a4b5c6d7e","slug":"barberia-nunoa","name":"Barbería Ñuñoa","comuna":"Ñuñoa","distance_km":2.4,"rating":{"avg":4.8,"count":37},"services":[{"service_id":"3fa85f64-5717-4562-b3fc-2c963f66afa6","name":"Corte de pelo","price_clp":15000,"duration_minutes":30,"modality":"IN_PERSON","requires_prepayment":false}],"next_available_slots":["2026-09-15T09:00:00-03:00","2026-09-15T10:30:00-03:00","2026-09-16T11:00:00-03:00"],"profile_url":"http://localhost:3000/l/barberia-nunoa","payment_enabled":true}],"total":1,"query_understood":{"comuna":"Ñuñoa","service_terms":["corte de pelo","barbería","peluquería"],"modality":"IN_PERSON","date_from":"2026-09-15","date_to":"2026-09-17"},"next_step_hint":"Pide la agenda de `barberia-nunoa` con `POST /api/v1/agent/availability` usando el `service_id` del servicio que le interese a la persona."}}}}}},"429":{"description":"Cupo del canal agotado para esta clave de agente o esta IP. La cabecera `Retry-After` y el campo `retry_after` dicen cuántos segundos esperar.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/AgentProblem"},"examples":{"cupo_agotado":{"summary":"Se acabó el cupo del canal para esta clave o esta IP","value":{"type":"http://localhost:8000/api/v1/agent/guide#errors-RATE_LIMITED","title":"Se agotó el cupo de peticiones","status":429,"code":"RATE_LIMITED","message":"Has superado el límite de peticiones permitido.","hint":"Vuelve a intentarlo dentro de 30 segundos.","suggestions":[],"retry_after":30,"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Fallo inesperado del servidor. El cuerpo nunca trae el detalle: se correlaciona por `request_id` con los logs.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/AgentProblem"},"examples":{"error_interno":{"summary":"Algo se rompió por nuestra parte","value":{"type":"http://localhost:8000/api/v1/agent/guide#errors-INTERNAL_ERROR","title":"Error interno del servidor","status":500,"code":"INTERNAL_ERROR","message":"Error interno del servidor","hint":"Reintenta en unos segundos.","suggestions":[],"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"La búsqueda no se puede interpretar: una fecha imposible (`INVALID_DATE`), una comuna que no está en el catálogo (`UNKNOWN_COMUNA`), un rango de más de siete días (`DATE_RANGE_TOO_LARGE`) o una búsqueda presencial sin comuna (`COMUNA_REQUIRED`).","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/AgentProblem"},"examples":{"fecha_imposible":{"summary":"El 31 de febrero no existe","value":{"type":"http://localhost:8000/api/v1/agent/guide#errors-INVALID_DATE","title":"La fecha no se entiende","status":422,"code":"INVALID_DATE","message":"No entiendo la fecha «31/02/2026».","hint":"Manda la fecha en ISO 8601 (`2026-02-28`) o en lenguaje natural («mañana», «el sábado», «en 3 días»).","suggestions":["2026-09-15","2026-09-16"],"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}}},"x-lukin-agent-step":"search"}},"/api/v1/agent/availability":{"post":{"tags":["agent"],"summary":"Horas libres reales de un servicio, día a día","description":"Espejo HTTP de la tool MCP `get_availability_matrix`. Es la **única** fuente de\nhoras válidas: una hora inventada se rechaza al crear la reserva.\n\nIdentifica el local por su `slug` o su `business_id` y el servicio por su\n`service_id`, los tres tal y como los devolvió la búsqueda. Sin `date_to` mira\nla semana entera desde `date_from`. Cada hueco trae `starts_at` y `ends_at` en\nISO 8601 con el offset del negocio —cópialos tal cual— más el `staff_id` y el\nnombre de quien atendería.\n\nDevuelve **todos** los días del rango, con `slots` vacío en los que no queda\nnada, y no falla por pedir de más: una fecha pasada se adelanta a hoy y un rango\ndemasiado ancho se recorta, avisando siempre en `warnings`. `total_slots: 0` es\nuna respuesta legítima, no un error.","operationId":"agent_availability","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AvailabilityMatrixInput"},"examples":{"una_semana":{"summary":"La agenda de un servicio desde mañana","value":{"business":"barberia-nunoa","service_id":"3fa85f64-5717-4562-b3fc-2c963f66afa6","date_from":"mañana"}},"con_persona":{"summary":"Solo las horas de una profesional concreta","value":{"business":"9d1f2c34-5b6a-47c8-9e01-2f3a4b5c6d7e","service_id":"3fa85f64-5717-4562-b3fc-2c963f66afa6","date_from":"2026-09-15","date_to":"2026-09-17","staff_id":"9c8f2b1e-4d3a-4c5b-8e7f-1a2b3c4d5e6f"}}}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AvailabilityMatrixOutput"},"examples":{"dos_dias":{"summary":"Dos días del rango: uno con hueco y otro sin nada","value":{"timezone":"America/Santiago","business":{"id":"9d1f2c34-5b6a-47c8-9e01-2f3a4b5c6d7e","slug":"barberia-nunoa","name":"Barbería Ñuñoa"},"service":{"id":"3fa85f64-5717-4562-b3fc-2c963f66afa6","name":"Corte de pelo","duration_minutes":30,"price_clp":15000,"requires_prepayment":false},"days":[{"date":"2026-09-15","slots":[{"starts_at":"2026-09-15T09:00:00-03:00","ends_at":"2026-09-15T09:30:00-03:00","staff_id":"9c8f2b1e-4d3a-4c5b-8e7f-1a2b3c4d5e6f","staff_name":"Camila Soto"}]},{"date":"2026-09-16","slots":[]}],"total_slots":1,"warnings":[],"next_step_hint":"Para reservar, llama a `POST /api/v1/agent/bookings` copiando `starts_at` tal cual —con su offset— y el `service_id` de esta respuesta."}}}}}},"429":{"description":"Cupo del canal agotado para esta clave de agente o esta IP. La cabecera `Retry-After` y el campo `retry_after` dicen cuántos segundos esperar.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/AgentProblem"},"examples":{"cupo_agotado":{"summary":"Se acabó el cupo del canal para esta clave o esta IP","value":{"type":"http://localhost:8000/api/v1/agent/guide#errors-RATE_LIMITED","title":"Se agotó el cupo de peticiones","status":429,"code":"RATE_LIMITED","message":"Has superado el límite de peticiones permitido.","hint":"Vuelve a intentarlo dentro de 30 segundos.","suggestions":[],"retry_after":30,"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Fallo inesperado del servidor. El cuerpo nunca trae el detalle: se correlaciona por `request_id` con los logs.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/AgentProblem"},"examples":{"error_interno":{"summary":"Algo se rompió por nuestra parte","value":{"type":"http://localhost:8000/api/v1/agent/guide#errors-INTERNAL_ERROR","title":"Error interno del servidor","status":500,"code":"INTERNAL_ERROR","message":"Error interno del servidor","hint":"Reintenta en unos segundos.","suggestions":[],"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"El local (`BUSINESS_NOT_FOUND`), el servicio (`SERVICE_NOT_FOUND`) o la persona (`STAFF_NOT_FOUND`) no existen. `suggestions` trae siempre las alternativas: los slugs parecidos, el catálogo activo con su `service_id` o quién sí presta el servicio.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/AgentProblem"},"examples":{"servicio_de_otra_conversacion":{"summary":"El `service_id` no es de este local: viene el catálogo entero","value":{"type":"http://localhost:8000/api/v1/agent/guide#errors-SERVICE_NOT_FOUND","title":"El servicio no existe en este local","status":404,"code":"SERVICE_NOT_FOUND","message":"El servicio pedido no existe en Barbería Ñuñoa.","hint":"Usa uno de los `service_id` de `suggestions`, que son los servicios activos de este local.","suggestions":[{"service_id":"3fa85f64-5717-4562-b3fc-2c963f66afa6","name":"Corte de pelo","price_clp":15000,"duration_minutes":30}],"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Un identificador que no es un UUID (`INVALID_UUID`), un local que no se identifica ni por slug ni por UUID (`INVALID_BUSINESS_REF`) o una fecha que no se entiende (`INVALID_DATE`).","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/AgentProblem"},"examples":{"uuid_mal_copiado":{"summary":"El `service_id` no es un UUID","value":{"type":"http://localhost:8000/api/v1/agent/guide#errors-INVALID_UUID","title":"El identificador no es un UUID","status":422,"code":"INVALID_UUID","message":"El campo `service_id` no es un identificador válido.","hint":"Copia el `service_id` tal y como lo devolvió la búsqueda: 36 caracteres con guiones.","suggestions":[],"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}}},"x-lukin-agent-step":"availability"}},"/api/v1/agent/bookings":{"post":{"tags":["agent"],"summary":"Abrir una pre-reserva en nombre de una persona","description":"Espejo HTTP de la tool MCP `execute_agent_booking`. La reserva nace `PENDING` y\naparta el hueco **solo** hasta `expires_at`: **no** está confirmada al volver de\nesta llamada y no debes decirle a nadie que su cita está hecha.\n\nQuien la confirma es el titular, abriendo `confirmation_url` y tecleando el\ncódigo de cuatro dígitos que le enviamos; si el servicio exige prepago, también\nsirve pagar con `payment_checkout_url`. Si nadie hace ninguna de las dos cosas\nantes de `expires_at`, la hora vuelve a la agenda.\n\nCopia el `starts_at` de la matriz de disponibilidad tal cual: una hora\ninventada se rechaza con `SLOT_TAKEN` y hasta cinco alternativas. Necesita el\nnombre del titular y sus **dos** contactos —correo y móvil— y\n`consent_accepted: true`, que declara que le mostraste la política de privacidad\ny aceptó explícitamente; sin eso no se crea nada. Manda un `idempotency_key`\npropio y reintenta con él si pierdes la respuesta: te devolverá la misma reserva\ncon `created: false` en vez de duplicarla.\n\nLa IP y el `User-Agent` de **esta** petición se archivan junto al consentimiento\ndeclarado, que es lo que lo convierte en una prueba oponible (SEC-01).","operationId":"agent_create_booking","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentBookingInput"},"examples":{"reserva_normal":{"summary":"Una hora copiada de la matriz, con el consentimiento ya obtenido","value":{"business":"barberia-nunoa","service_id":"3fa85f64-5717-4562-b3fc-2c963f66afa6","starts_at":"2026-09-15T09:00:00-03:00","customer":{"full_name":"Javiera Rojas","email":"javiera@correo.cl","phone":"+56912345678"},"consent_accepted":true,"idempotency_key":"conversacion-1042-reserva-1"}}}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentBookingOutput"},"examples":{"pre_reserva":{"summary":"La hora queda apartada unos minutos, no confirmada","value":{"booking_id":"5c2e7a90-3d41-4a2b-9f10-6b7c8d9e0f12","status":"PENDING","created":true,"expires_at":"2026-09-14T18:10:00-03:00","otp_channel":"EMAIL","payment_checkout_url":null,"confirmation_url":"http://localhost:3000/reserva/5c2e7a90-3d41-4a2b-9f10-6b7c8d9e0f12/confirmar?token=eyJhbGciOiJIUzI1NiJ9.****.****","status_token":"eyJhbGciOiJIUzI1NiJ9.****.****","summary_for_user":"Barbería Ñuñoa, Corte de pelo el martes 15 de septiembre a las 09:00 (-03:00), $15.000. Falta que confirmes con el código que te enviamos por correo.","next_step_hint":"Pásale `confirmation_url` a la persona y sigue la reserva con `POST /api/v1/agent/bookings/status` usando `status_token`."}}}}}},"429":{"description":"Cupo del canal agotado para esta clave de agente o esta IP. La cabecera `Retry-After` y el campo `retry_after` dicen cuántos segundos esperar.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/AgentProblem"},"examples":{"cupo_agotado":{"summary":"Se acabó el cupo del canal para esta clave o esta IP","value":{"type":"http://localhost:8000/api/v1/agent/guide#errors-RATE_LIMITED","title":"Se agotó el cupo de peticiones","status":429,"code":"RATE_LIMITED","message":"Has superado el límite de peticiones permitido.","hint":"Vuelve a intentarlo dentro de 30 segundos.","suggestions":[],"retry_after":30,"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Fallo inesperado del servidor. El cuerpo nunca trae el detalle: se correlaciona por `request_id` con los logs.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/AgentProblem"},"examples":{"error_interno":{"summary":"Algo se rompió por nuestra parte","value":{"type":"http://localhost:8000/api/v1/agent/guide#errors-INTERNAL_ERROR","title":"Error interno del servidor","status":500,"code":"INTERNAL_ERROR","message":"Error interno del servidor","hint":"Reintenta en unos segundos.","suggestions":[],"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"El local (`BUSINESS_NOT_FOUND`), el servicio (`SERVICE_NOT_FOUND`) o la persona (`STAFF_NOT_FOUND`) no existen.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/AgentProblem"},"examples":{"servicio_inexistente":{"summary":"El servicio no es de este local: el catálogo se relee, no se adivina","value":{"type":"http://localhost:8000/api/v1/agent/guide#errors-SERVICE_NOT_FOUND","title":"El servicio no existe en este local","status":404,"code":"SERVICE_NOT_FOUND","message":"El servicio indicado no existe en este local.","hint":"Vuelve a `POST /api/v1/agent/search` y copia el `service_id` que devuelve para este local.","suggestions":[],"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"409":{"description":"El estado se opone: la hora ya no está libre (`SLOT_TAKEN`, con hasta cinco alternativas en `suggestions`), el local no acepta reservas ahora (`BUSINESS_UNAVAILABLE`), la `idempotency_key` ya se usó para otra reserva distinta (`IDEMPOTENCY_KEY_REUSED`) o el titular acumula demasiadas pendientes (`TOO_MANY_PENDING`).","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/AgentProblem"},"examples":{"hora_ocupada":{"summary":"Alguien se adelantó: vienen alternativas de verdad","value":{"type":"http://localhost:8000/api/v1/agent/guide#errors-SLOT_TAKEN","title":"La hora pedida ya no está disponible","status":409,"code":"SLOT_TAKEN","message":"Esa hora ya no está disponible.","hint":"Ofrécele a la persona una de las horas de `suggestions` y vuelve a llamar con la que elija.","suggestions":["2026-09-15T09:30:00-03:00","2026-09-15T10:00:00-03:00"],"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"Falta el consentimiento explícito del titular (`CONSENT_REQUIRED`), faltan sus contactos (`CUSTOMER_CONTACT_REQUIRED`), la hora no respeta la antelación mínima del local (`MIN_ADVANCE`, con alternativas) o algún dato no se entiende (`INVALID_DATETIME`, `INVALID_UUID`, `INVALID_EMAIL`, `INVALID_PHONE`).","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/AgentProblem"},"examples":{"sin_consentimiento":{"summary":"El agente no puede aceptar la política por la persona","value":{"type":"http://localhost:8000/api/v1/agent/guide#errors-CONSENT_REQUIRED","title":"Falta el consentimiento del titular","status":422,"code":"CONSENT_REQUIRED","message":"No se puede reservar sin el consentimiento explícito del titular de la cita.","hint":"Muestra http://localhost:3000/privacidad y pide aceptación explícita; el titular la confirmará en la página de confirmación.","suggestions":[],"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}}},"x-lukin-agent-step":"booking"}},"/api/v1/agent/bookings/status":{"post":{"tags":["agent"],"summary":"Consultar en qué punto está una reserva","description":"Espejo HTTP de la tool MCP `get_booking_status`. Llámala después de reservar,\ncada vez que necesites saber qué ha pasado, en vez de volver a preguntarle a la\npersona.\n\nNecesita el `booking_id` y el token de **esa** reserva: el `status_token` que\ndevolvió el alta o el `manage_token` que se entrega tras verificar el código\n—los dos nombres se aceptan en el mismo campo—. El token de otra reserva se\nrechaza con `TOKEN_INVALID` y no revela nada.\n\nDevuelve el estado, el motivo si se canceló, la hora de la cita en ISO 8601 con\nel offset del local, el servicio, quién atiende, el estado del cobro y\n`next_action_hint`, la frase con lo que toca hacer ahora. Mientras siga\n`PENDING` trae además `confirmation_url` y, si hay un cobro esperando,\n`payment_checkout_url`. No devuelve datos de contacto del titular.","operationId":"agent_booking_status","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingStatusInput"},"examples":{"con_status_token":{"summary":"Con el token que devolvió el alta","value":{"booking_id":"5c2e7a90-3d41-4a2b-9f10-6b7c8d9e0f12","token":"eyJhbGciOiJIUzI1NiJ9.****.****"}},"con_manage_token":{"summary":"Con el token de gestión que se entrega tras verificar el código","value":{"booking_id":"5c2e7a90-3d41-4a2b-9f10-6b7c8d9e0f12","manage_token":"eyJhbGciOiJIUzI1NiJ9.****.****"}}}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingStatusOutput"},"examples":{"pendiente":{"summary":"Sigue pendiente: falta que el titular teclee su código","value":{"booking_id":"5c2e7a90-3d41-4a2b-9f10-6b7c8d9e0f12","status":"PENDING","cancelled_reason":null,"otp_verified":false,"starts_at":"2026-09-15T09:00:00-03:00","ends_at":"2026-09-15T09:30:00-03:00","timezone":"America/Santiago","business":{"name":"Barbería Ñuñoa","slug":"barberia-nunoa","address":"Av. Irarrázaval 1234"},"service":{"name":"Corte de pelo","price_clp":15000,"duration_minutes":30},"staff_name":"Camila Soto","payment_status":null,"expires_at":"2026-09-14T18:10:00-03:00","confirmation_url":"http://localhost:3000/reserva/5c2e7a90-3d41-4a2b-9f10-6b7c8d9e0f12/confirmar?token=eyJhbGciOiJIUzI1NiJ9.****.****","payment_checkout_url":null,"next_action_hint":"Pásale `confirmation_url` a la persona: la reserva caduca en unos minutos si nadie teclea el código."}}}}}},"429":{"description":"Cupo del canal agotado para esta clave de agente o esta IP. La cabecera `Retry-After` y el campo `retry_after` dicen cuántos segundos esperar.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/AgentProblem"},"examples":{"cupo_agotado":{"summary":"Se acabó el cupo del canal para esta clave o esta IP","value":{"type":"http://localhost:8000/api/v1/agent/guide#errors-RATE_LIMITED","title":"Se agotó el cupo de peticiones","status":429,"code":"RATE_LIMITED","message":"Has superado el límite de peticiones permitido.","hint":"Vuelve a intentarlo dentro de 30 segundos.","suggestions":[],"retry_after":30,"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"500":{"description":"Fallo inesperado del servidor. El cuerpo nunca trae el detalle: se correlaciona por `request_id` con los logs.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/AgentProblem"},"examples":{"error_interno":{"summary":"Algo se rompió por nuestra parte","value":{"type":"http://localhost:8000/api/v1/agent/guide#errors-INTERNAL_ERROR","title":"Error interno del servidor","status":500,"code":"INTERNAL_ERROR","message":"Error interno del servidor","hint":"Reintenta en unos segundos.","suggestions":[],"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"401":{"description":"El token no autoriza a consultar esta reserva, o no vino ninguno (`TOKEN_INVALID`). Es el mismo error para un token de otra reserva que para uno caducado: no revela cuál de las dos cosas ocurrió.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/AgentProblem"},"examples":{"token_de_otra_reserva":{"summary":"El token no es de esta reserva","value":{"type":"http://localhost:8000/api/v1/agent/guide#errors-TOKEN_INVALID","title":"El token no autoriza a consultar esta reserva","status":401,"code":"TOKEN_INVALID","message":"El token no autoriza a consultar esta reserva.","hint":"Usa el `status_token` que devolvió el alta de ESA reserva, o el `manage_token` que se entrega al verificar su código.","suggestions":[],"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"404":{"description":"La reserva ya no existe (`BOOKING_NOT_FOUND`), aunque el token fuera válido cuando se emitió.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/AgentProblem"},"examples":{"reserva_borrada":{"summary":"La reserva ya no existe","value":{"type":"http://localhost:8000/api/v1/agent/guide#errors-BOOKING_NOT_FOUND","title":"La reserva no existe","status":404,"code":"BOOKING_NOT_FOUND","message":"La reserva solicitada ya no existe.","hint":"Comprueba el `booking_id`; si la reserva se canceló hace mucho, vuelve a empezar por la búsqueda.","suggestions":[],"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}},"422":{"description":"El `booking_id` no es un UUID (`INVALID_UUID`) o falta algún campo del cuerpo (`VALIDATION_ERROR`, con el campo culpable en `hint`).","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/AgentProblem"},"examples":{"uuid_mal_copiado":{"summary":"El `booking_id` llegó recortado o con texto alrededor","value":{"type":"http://localhost:8000/api/v1/agent/guide#errors-INVALID_UUID","title":"El identificador no es un UUID","status":422,"code":"INVALID_UUID","message":"El identificador de la reserva no es un UUID válido.","hint":"Copia el `booking_id` tal cual lo devolvió `POST /api/v1/agent/bookings`, sin recortarlo.","suggestions":[],"request_id":"3f2b9a10-0000-4000-8000-abcdefabcdef"}}}}}}},"x-lukin-agent-step":"status"}}},"components":{"schemas":{"AccessTokenOut":{"properties":{"access_token":{"type":"string","title":"Access Token","description":"JWT de acceso recién emitido."},"token_type":{"type":"string","title":"Token Type","description":"Siempre `bearer`.","default":"bearer"},"expires_in":{"type":"integer","title":"Expires In","description":"Segundos que vive el access token.","examples":[900]}},"type":"object","required":["access_token","expires_in"],"title":"AccessTokenOut","description":"Respuesta de `POST /auth/refresh`: solo la credencial corta.\n\nEs `AuthResponse` **sin** el bloque `user`: quien renueva ya sabe quién es y\nrepetir su perfil en cada rotación obligaría a la ruta a cargarlo para nada.\nEl refresh rotado no aparece aquí —viaja en `Set-Cookie`, ADR-015—."},"AdminBusinessOut":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"slug":{"type":"string","title":"Slug","description":"Identificador público del negocio en el marketplace."},"name":{"type":"string","title":"Name"},"comuna":{"type":"string","title":"Comuna"},"owner_email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Owner Email","description":"Correo del propietario."},"is_published":{"type":"boolean","title":"Is Published","description":"`false` lo retira del marketplace."},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At","description":"Borrado lógico hecho por su dueño. La ficha sigue listándose aquí."},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["id","slug","name","comuna","is_published","created_at"],"title":"AdminBusinessOut","description":"Una ficha de negocio vista desde la administración.\n\n`owner_email` viaja resuelto —no el `owner_id`— porque el uso real de esta\nlista es contactar con quien responde por el local; obligar a una segunda\nconsulta por cada fila no aportaría nada.","examples":[{"comuna":"Providencia","created_at":"2026-01-15T10:30:00Z","id":"1c5f9b1e-2b5a-4a4f-8f2b-6d0f6a1a1e10","is_published":true,"name":"Peluquería Lukin","owner_email":"ada@peluqueria-lukin.cl","slug":"peluqueria-lukin"}]},"AdminBusinessUpdate":{"properties":{"is_published":{"type":"boolean","title":"Is Published","description":"`false` suspende el negocio y lo saca del marketplace; `true` lo vuelve a publicar."}},"type":"object","required":["is_published"],"title":"AdminBusinessUpdate","description":"Suspensión o reactivación de una ficha por parte de la plataforma.","examples":[{"is_published":false},{"is_published":true}]},"AdminSubscriptionOut":{"properties":{"business_id":{"type":"string","format":"uuid","title":"Business Id","description":"Negocio al que pertenece la suscripción."},"slug":{"type":"string","title":"Slug","description":"Identificador público del negocio en el marketplace."},"plan":{"$ref":"#/components/schemas/SubscriptionPlan","description":"Plan contratado o en prueba."},"status":{"$ref":"#/components/schemas/SubscriptionStatus","description":"`TRIAL` mientras dura la prueba, `ACTIVE` con la domiciliación al día, `PAST_DUE` cuando un cobro falló —sigue dando servicio durante los días de gracia— y `CANCELLED` tras la baja."},"trial_ends_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Trial Ends At","description":"Instante en que caduca el periodo de prueba. Es lo que decide la vigencia de un `TRIAL`; `null` en una suscripción que nunca lo tuvo."},"current_period_end":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Current Period End","description":"Fin del periodo ya pagado, es decir la fecha del próximo cargo. Un `PAST_DUE` sigue dando servicio hasta esta fecha más los días de gracia; `null` mientras no se haya cobrado ningún mes."}},"type":"object","required":["business_id","slug","plan","status"],"title":"AdminSubscriptionOut","description":"La suscripción SaaS de un negocio, vista desde la administración.\n\nViaja con `business_id` y `slug` —y no con el identificador de la propia\nsuscripción— porque el uso real es al revés de como está guardada: quien\nmira esta lista parte de un local del que le hablaron (su slug es lo que\naparece en la URL pública) y quiere saber cómo está su cuenta, no parte de\nuna fila de `subscriptions`. Con esos dos campos, soporte llega a la ficha\ndel negocio sin una consulta más.\n\nLas dos fechas son las que **deciden** la vigencia (`trial_ends_at` para un\n`TRIAL`, `current_period_end` más los días de gracia para un `PAST_DUE`), así\nque la lista explica por sí sola por qué un local está o no publicado.","examples":[{"business_id":"1c5f9b1e-2b5a-4a4f-8f2b-6d0f6a1a1e10","current_period_end":"2026-08-28T13:00:00Z","plan":"PREMIUM","slug":"peluqueria-lukin","status":"PAST_DUE","trial_ends_at":"2026-01-29T13:00:00Z"}]},"AdminUserOut":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email","description":"Correo de la cuenta, si tiene."},"full_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Full Name","description":"Nombre con el que se le atiende."},"phone":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Phone","description":"Teléfono en E.164, si tiene."},"role":{"$ref":"#/components/schemas/UserRole","description":"Rol global de la cuenta."},"is_active":{"type":"boolean","title":"Is Active","description":"`false` cierra el acceso y sus sesiones abiertas."},"anonymized_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Anonymized At","description":"Instante en que se ejercitó el derecho al olvido (SEC-01). Una cuenta anonimizada ya no se puede modificar."},"created_at":{"type":"string","format":"date-time","title":"Created At","description":"Alta de la cuenta."}},"type":"object","required":["id","role","is_active","created_at"],"title":"AdminUserOut","description":"Una cuenta vista desde la administración de la plataforma.","examples":[{"created_at":"2026-01-15T10:30:00Z","email":"ada@peluqueria-lukin.cl","full_name":"Ada Lovelace","id":"8f14e45f-ceea-467a-9f47-1f2c3a6b9f11","is_active":true,"phone":"+56912345678","role":"OWNER"}]},"AdminUserUpdate":{"properties":{"role":{"anyOf":[{"$ref":"#/components/schemas/UserRole"},{"type":"null"}],"description":"Nuevo rol global. Se omite para no tocarlo."},"is_active":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Active","description":"`false` desactiva la cuenta y revoca todas sus sesiones; `true` la reactiva. Se omite para no tocarlo."}},"type":"object","title":"AdminUserUpdate","description":"Cambio operativo sobre una cuenta: su rol, su acceso, o ambos.","examples":[{"role":"CLIENT"},{"is_active":false}]},"AgentBookingBusiness":{"properties":{"name":{"type":"string","title":"Name","description":"Nombre comercial del local."},"slug":{"type":"string","title":"Slug","description":"Identificador del local en la URL del marketplace.","examples":["barberia-nunoa"]},"address":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Address","description":"Dirección de la cita presencial; `null` si el local no la publicó."},"comuna":{"type":"string","title":"Comuna","description":"Comuna del local."},"logo_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo Url","description":"Logotipo del local; `null` si todavía no subió ninguno."}},"type":"object","required":["name","slug","comuna"],"title":"AgentBookingBusiness","description":"El local, reducido a lo que hace falta para reconocerlo y llegar hasta él.\n\nSin `id`: el enlace ya apunta a una reserva concreta y el identificador\ninterno del negocio no le sirve de nada a quien mira esta pantalla. Con\n`logo_url`, que es lo primero por lo que un cliente reconoce dónde reservó.","examples":[{"address":"Av. Irarrázaval 1234","comuna":"Ñuñoa","logo_url":"https://cdn.lukin.cl/negocios/barberia-nunoa/logo.png","name":"Barbería Ñuñoa","slug":"barberia-nunoa"}]},"AgentBookingCustomer":{"properties":{"full_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Full Name","description":"Nombre con el que el local atenderá la cita."},"email_masked":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email Masked","description":"Correo del titular, ofuscado. `null` si reservó solo con teléfono.","examples":["j***@gmail.com"]},"phone_masked":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Phone Masked","description":"Teléfono del titular, ofuscado. `null` si reservó solo con correo.","examples":["+56 9 ****5678"]}},"type":"object","title":"AgentBookingCustomer","description":"Quién figura como titular, con el contacto **enmascarado** (SEC-01).\n\nEl nombre viaja entero porque es lo que el titular necesita para comprobar\nque el agente no se equivocó de persona; el correo y el teléfono no, porque\nesta respuesta la recibe quien tenga el enlace y no necesariamente quien\nreservó.","examples":[{"email_masked":"j***@gmail.com","full_name":"Javiera Rojas","phone_masked":"+56 9 ****5678"}]},"AgentBookingInput":{"properties":{"business":{"type":"string","minLength":1,"title":"Business","description":"El local donde se quiere la cita, identificado por su `slug` (`barberia-demo`) o por su `business_id` (UUID), tal y como los devolvió `search_lukin_services`. También vale la URL de su ficha pública. Un local que no está publicado responde `BUSINESS_NOT_FOUND`.","examples":["barberia-demo"]},"service_id":{"type":"string","minLength":1,"title":"Service Id","description":"UUID del servicio que se reserva, el mismo `service_id` que devolvieron la búsqueda y la agenda. Tiene que ser un servicio activo **de ese negocio**: si no lo es, la respuesta es `SERVICE_NOT_FOUND` con el catálogo activo entero en `suggestions`.","examples":["3fa85f64-5717-4562-b3fc-2c963f66afa6"]},"starts_at":{"type":"string","minLength":1,"title":"Starts At","description":"Cuándo empieza la cita. Copia **tal cual** el `starts_at` que devolvió `get_availability_matrix`: ISO 8601 con el offset del negocio (`2026-09-15T15:00:00-03:00`) es la forma que no admite interpretación. Se acepta también lenguaje natural en español —`mañana a las 15:00`, `el sábado a las 9 am`—, que se interpreta en la **zona horaria del negocio**. Sin hora no hay reserva: `mañana` a secas responde `INVALID_DATETIME`. Una hora que no salió de la agenda acaba en `SLOT_TAKEN` con las que sí están libres.","examples":["2026-09-15T15:00:00-03:00"]},"customer":{"$ref":"#/components/schemas/AgentCustomerInput","description":"Quién va a la cita: su nombre y sus DOS contactos. El correo es por donde sale el código con el que confirma la reserva; el teléfono es el segundo factor anti-abuso del canal agéntico. Pídeselos a la persona: inventarlos crea una cita que el local no puede atender."},"consent_accepted":{"type":"boolean","title":"Consent Accepted","description":"Declara que le mostraste a la persona la política de privacidad y los términos de Lukin y que **te dijo que sí**, con esas palabras. No lo infieras de «resérvame hora»: quien pide una hora no ha leído nada. Con `false` no se crea nada y la respuesta es `CONSENT_REQUIRED`. Enviar `true` sin haber preguntado es una infracción legal de quien opera el agente (Ley 19.628), no un detalle de formato.","examples":[true]},"staff_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Staff Id","description":"UUID de la persona con la que se quiere la cita, cuando quien reserva pidió una en concreto. Omítelo y el local reparte entre quienes prestan el servicio y están libres, que es lo habitual. Una persona que no presta ese servicio responde `STAFF_NOT_FOUND` con quienes sí lo prestan.","examples":["9c8f2b1e-4d3a-4c5b-8e7f-1a2b3c4d5e6f"]},"idempotency_key":{"anyOf":[{"type":"string","maxLength":128},{"type":"null"}],"title":"Idempotency Key","description":"Clave que TÚ eliges para esta petición (un UUID, el id de la conversación) y que puedes repetir sin miedo: máximo 128 caracteres. Si pierdes la respuesta por un timeout, vuelve a llamar con la MISMA clave y los MISMOS datos: recibirás la reserva que ya se creó, con `created: false` y sin mandarle otro código a la persona. La misma clave con datos distintos responde `IDEMPOTENCY_KEY_REUSED`.","examples":["conversacion-1042-reserva-1"]},"agent_note":{"anyOf":[{"type":"string","maxLength":280},{"type":"null"}],"title":"Agent Note","description":"Nota interna tuya sobre esta petición (máximo 280 caracteres): con qué palabras pidió la hora la persona, por qué elegiste ese horario. Queda en el registro de la llamada para auditoría. **No** se le muestra al negocio ni al titular, así que no la uses para pedirle nada al local ni para dejar instrucciones que alguien deba leer.","examples":["Pidió la primera hora libre de la tarde con quien sea."]}},"type":"object","required":["business","service_id","starts_at","customer","consent_accepted"],"title":"AgentBookingInput","description":"Los argumentos de `execute_agent_booking`, ya normalizados.\n\nComo en F8-T06, los campos son **texto tal y como lo escribe un modelo** y la\nnormalización ocurre al validar: así un UUID mal copiado, un correo imposible\no una aceptación que nadie dio fallan con su `code` propio **antes** de tocar\nla base de datos. Lo normalizado vive en atributos privados porque no es\nparte del contrato que el agente rellena.\n\n`starts_at` es la excepción y se queda en crudo a propósito: `mañana a las\n15:00` solo se puede interpretar en la zona horaria del negocio, y el negocio\ntodavía no está resuelto cuando este validador corre. Lo convierte\n`run_booking` en cuanto sabe de qué local se habla."},"AgentBookingOutput":{"properties":{"booking_id":{"type":"string","title":"Booking Id","description":"UUID de la reserva. Guárdalo: es lo que identifica la cita en `get_booking_status` y en cualquier gestión posterior."},"status":{"type":"string","title":"Status","description":"Siempre `PENDING`: la reserva aparta el hueco pero NO está confirmada hasta que el titular teclee su código. No le digas a la persona que su cita está hecha."},"created":{"type":"boolean","title":"Created","description":"`true` si esta llamada abrió la reserva; `false` si tu `idempotency_key` ya había abierto esta misma y te la devolvemos tal cual, sin mandar un segundo código."},"expires_at":{"type":"string","title":"Expires At","description":"Hasta cuándo se guarda el hueco, en ISO 8601 con el offset del negocio. Pasado ese instante la reserva se cancela sola y la hora vuelve a la agenda: dilo al ofrecer el enlace."},"otp_channel":{"type":"string","title":"Otp Channel","description":"Por dónde le llegó el código al titular: `EMAIL` o `SMS`. Sirve para decirle dónde mirar sin adivinar."},"payment_checkout_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Payment Checkout Url","description":"Enlace de Mercado Pago para pagar por adelantado, o `null` si este servicio no exige prepago o el local no cobra por internet. Un `null` NO invalida la reserva: se paga en el local."},"confirmation_url":{"type":"string","title":"Confirmation Url","description":"Página donde el titular revisa la cita y la confirma con su código. Es el enlace que hay que pasarle a la persona, tal cual y entero: lleva dentro la credencial de esta reserva."},"status_token":{"type":"string","title":"Status Token","description":"La misma credencial que va dentro de `confirmation_url`, suelta. Pásala como `token` a `get_booking_status` para seguir la reserva sin tener que parsear la URL. No se la enseñes a nadie más."},"summary_for_user":{"type":"string","title":"Summary For User","description":"La reserva contada en una frase en español —local, servicio, día y hora local con offset, precio en pesos y qué falta—, lista para leérsela a la persona sin reescribirla."},"next_step_hint":{"type":"string","title":"Next Step Hint","description":"Qué te toca hacer a ti ahora, en una frase: qué enlace pasar y qué tool llamar después. Es distinta según haya prepago o no."}},"type":"object","required":["booking_id","status","created","expires_at","otp_channel","payment_checkout_url","confirmation_url","status_token","summary_for_user","next_step_hint"],"title":"AgentBookingOutput","description":"La pre-reserva recién abierta y lo que hay que hacer con ella.\n\nNo lleva ningún dato de contacto del titular: el agente ya sabe a quién\nrepresenta y esta salida no es un sitio del que sacar correos (SEC-01)."},"AgentBookingView":{"properties":{"booking_id":{"type":"string","format":"uuid","title":"Booking Id","description":"Identificador de la pre-reserva."},"status":{"$ref":"#/components/schemas/BookingStatus","description":"Estado actual. `PENDING` mientras se pueda confirmar; `CANCELLED` si se agotó el plazo (mira `cancelled_reason`); `CONFIRMED` o `PAID` si ya se confirmó o se pagó."},"cancelled_reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cancelled Reason","description":"Marca del motivo de la anulación, en mayúsculas y sin traducir (`EXPIRED` cuando venció el plazo). `null` si la reserva sigue viva.","examples":["EXPIRED"]},"starts_at":{"type":"string","format":"date-time","title":"Starts At","description":"Inicio de la cita."},"ends_at":{"type":"string","format":"date-time","title":"Ends At","description":"Fin de la cita, derivado de la duración del servicio."},"timezone":{"type":"string","title":"Timezone","description":"Zona horaria IANA del negocio. Es la zona en la que hay que pintar `starts_at`, `ends_at` y `expires_at`.","examples":["America/Santiago"]},"business":{"$ref":"#/components/schemas/AgentBookingBusiness","description":"Local donde es la cita."},"service":{"$ref":"#/components/schemas/ServiceSummary","description":"Prestación reservada, con su precio actual."},"staff_name":{"type":"string","title":"Staff Name","description":"Nombre con el que el local presenta a quien atiende."},"customer":{"$ref":"#/components/schemas/AgentBookingCustomer","description":"Titular de la reserva."},"otp_channel":{"$ref":"#/components/schemas/OtpChannel","description":"Canal por el que salió el código de confirmación de la pre-reserva."},"otp_verified":{"type":"boolean","title":"Otp Verified","description":"`true` cuando el titular ya canjeó el código (o el pago lo dio por canjeado)."},"consent_required":{"type":"boolean","title":"Consent Required","description":"`true` mientras el consentimiento que consta sea el **declarado por el agente** y no el otorgado por el titular. La pantalla de confirmación tiene que pedirlo antes de seguir."},"expires_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Expires At","description":"Hasta cuándo se guarda el hueco sin confirmar."},"requires_payment":{"type":"boolean","title":"Requires Payment","description":"`true` si el servicio exige prepago. Que exista enlace de pago depende además de que el negocio tenga su cuenta de cobro conectada: míralo en `payment_checkout_url`."},"payment_status":{"anyOf":[{"$ref":"#/components/schemas/PaymentStatus"},{"type":"null"}],"description":"Estado del último intento de cobro; `null` si no hay ninguno."},"payment_checkout_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Payment Checkout Url","description":"Checkout de Mercado Pago al que enviar al titular. Solo trae valor mientras el intento sigue `PENDING`: un cobro ya resuelto no lleva a ninguna parte."}},"type":"object","required":["booking_id","status","starts_at","ends_at","timezone","business","service","staff_name","customer","otp_channel","otp_verified","consent_required","requires_payment"],"title":"AgentBookingView","description":"La pre-reserva tal y como la ve el titular antes de confirmarla.\n\n**No lleva `manage_token`** y nunca lo llevará: la credencial de autogestión\nse entrega al confirmar (F4-T07), no al mirar.","examples":[{"booking_id":"3f2b9a10-0000-4000-8000-dddddddddddd","business":{"address":"Av. Irarrázaval 1234","comuna":"Ñuñoa","logo_url":"https://cdn.lukin.cl/negocios/barberia-nunoa/logo.png","name":"Barbería Ñuñoa","slug":"barberia-nunoa"},"consent_required":true,"customer":{"email_masked":"j***@gmail.com","full_name":"Javiera Rojas","phone_masked":"+56 9 ****5678"},"ends_at":"2026-03-10T12:30:00Z","expires_at":"2026-03-09T18:20:00Z","otp_channel":"EMAIL","otp_verified":false,"payment_checkout_url":"https://www.mercadopago.cl/checkout/v1/redirect?pref_id=1234567890-abcd","payment_status":"PENDING","requires_payment":true,"service":{"duration_minutes":30,"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","modality":"IN_PERSON","name":"Corte de pelo","price_clp":15000,"requires_prepayment":false},"staff_name":"Camila Soto","starts_at":"2026-03-10T12:00:00Z","status":"PENDING","timezone":"America/Santiago"}]},"AgentBusinessOut":{"properties":{"business_id":{"type":"string","title":"Business Id","description":"UUID del local; identifícalo con esto o con `slug` en las demás tools.","examples":["3fa85f64-5717-4562-b3fc-2c963f66afa6"]},"slug":{"type":"string","title":"Slug","description":"Identificador del local en la URL del marketplace, p. ej. `barberia-nunoa`.","examples":["barberia-nunoa"]},"name":{"type":"string","title":"Name","description":"Nombre comercial del local, para nombrarlo en la respuesta.","examples":["Barbería Ñuñoa"]},"comuna":{"type":"string","title":"Comuna","description":"Comuna del local, con la grafía del catálogo de Chile.","examples":["Ñuñoa"]},"distance_km":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Distance Km","description":"Kilómetros entre el centroide de la comuna buscada y el local, redondeados al metro. Es `null` en una búsqueda `ONLINE` sin comuna.","examples":[2.4]},"rating":{"$ref":"#/components/schemas/RatingOut","description":"Valoración media del local y cuántas la sostienen.","examples":[{"avg":4.8,"count":37}]},"services":{"items":{"$ref":"#/components/schemas/AgentServiceOut"},"type":"array","title":"Services","description":"Hasta 5 servicios activos que explican por qué salió este local, con su precio y su duración.","examples":[[{"duration_minutes":30,"modality":"IN_PERSON","name":"Corte de pelo","price_clp":15000,"requires_prepayment":false,"service_id":"3fa85f64-5717-4562-b3fc-2c963f66afa6"}]]},"next_available_slots":{"items":{"type":"string"},"type":"array","title":"Next Available Slots","description":"Hasta 3 horas libres reales, **como mucho una por día** del rango consultado, en ISO 8601 con offset y en **hora local del local** (`2026-09-15T09:00:00-03:00`), ordenadas de antes a después. Con un rango de un solo día viene una sola hora, aunque el local tenga la tarde entera libre: para ofrecer alternativas dentro del mismo día pide la agenda con `get_availability_matrix`. Salen de la agenda de verdad, no del horario de apertura. Vacío significa que no le quedaba ninguna en los días mirados.","examples":[["2026-09-15T09:00:00-03:00","2026-09-15T10:30:00-03:00","2026-09-16T11:00:00-03:00"]]},"profile_url":{"type":"string","title":"Profile Url","description":"Ficha pública del local en el marketplace; puedes dársela a la persona.","examples":["http://localhost:3000/l/barberia-nunoa"]},"payment_enabled":{"type":"boolean","title":"Payment Enabled","description":"`true` si este local puede cobrar por internet ahora mismo. Si es `false`, no le prometas pago online: la reserva se paga en el local.","examples":[true]}},"type":"object","required":["business_id","slug","name","comuna","rating","profile_url","payment_enabled"],"title":"AgentBusinessOut","description":"Un local que puede atender lo que se pidió, con lo justo para decidir."},"AgentCustomerInput":{"properties":{"full_name":{"type":"string","title":"Full Name","description":"Nombre y apellido con los que el local va a atender a la persona, entre 2 y 120 caracteres. Tal y como ella lo diga: no lo abrevies ni lo inventes.","examples":["Javiera Rojas"]},"email":{"type":"string","title":"Email","description":"Correo del titular, al que llega el código de confirmación y el enlace de la reserva. Se normaliza a minúsculas; un correo con sintaxis imposible responde `INVALID_EMAIL`.","examples":["javiera@correo.cl"]},"phone":{"type":"string","title":"Phone","description":"Móvil chileno del titular. OBLIGATORIO en este canal aunque la reserva de la web se conforme con un solo contacto: es el segundo factor anti-abuso que impide reservar en masa a nombre de contactos inventados. Se acepta como se escriba (`9 1234 5678`, `+56 9 1234 5678`) y se normaliza a E.164; un número imposible responde `INVALID_PHONE` y su falta, `CUSTOMER_CONTACT_REQUIRED`.","examples":["+56912345678"]}},"type":"object","required":["full_name","email","phone"],"title":"AgentCustomerInput","description":"El titular de la cita: nombre y los dos contactos.\n\nLos tres campos son obligatorios y el esquema lo publica —ver\n`_customer_schema`—, pero ninguno lo es **para pydantic**, y esa diferencia\nes deliberada. El SDK valida los argumentos *antes* de llamar a la tool, así\nque un campo que él rechace sale del canal como un error de texto del\nprotocolo, sin `code` ni `hint`. Declarándolos con valor por defecto, la\nllamada llega hasta `AgentBookingInput`, que es quien contesta\n`CUSTOMER_CONTACT_REQUIRED` con la explicación entera. Un esquema que dice la\nverdad y un error que el agente puede corregir, en vez de tener que elegir\nentre los dos."},"AgentServiceOut":{"properties":{"service_id":{"type":"string","title":"Service Id","description":"UUID del servicio; es el que se manda para pedir agenda o reservar.","examples":["3fa85f64-5717-4562-b3fc-2c963f66afa6"]},"name":{"type":"string","title":"Name","description":"Nombre de la prestación tal y como la ofrece el local.","examples":["Corte de pelo"]},"price_clp":{"type":"integer","title":"Price Clp","description":"Precio en pesos chilenos, entero y sin decimales (15000 son $15.000).","examples":[15000]},"duration_minutes":{"type":"integer","title":"Duration Minutes","description":"Cuánto dura la cita, en minutos, para que puedas anunciarlo.","examples":[30]},"modality":{"$ref":"#/components/schemas/ServiceModality","description":"`IN_PERSON` en la dirección del local u `ONLINE` por videollamada.","examples":["IN_PERSON"]},"requires_prepayment":{"type":"boolean","title":"Requires Prepayment","description":"`true` si la reserva de este servicio solo se confirma tras pagar por internet; `false` si se paga en el local.","examples":[false]}},"type":"object","required":["service_id","name","price_clp","duration_minutes","modality","requires_prepayment"],"title":"AgentServiceOut","description":"Un servicio del local que explica por qué salió en la búsqueda."},"AgentSlotOut":{"properties":{"starts_at":{"type":"string","title":"Starts At","description":"Comienzo del hueco en ISO 8601 **con offset** del negocio, p. ej. `2026-09-15T10:30:00-03:00`. Cópialo tal cual para reservar."},"ends_at":{"type":"string","title":"Ends At","description":"Fin del hueco en ISO 8601 con offset. Siempre es `starts_at` más la duración del servicio."},"staff_id":{"type":"string","title":"Staff Id","description":"UUID de la persona que atendería ese hueco; sirve como `staff_id` al reservar para asegurar que sea ella."},"staff_name":{"type":"string","title":"Staff Name","description":"Nombre visible de esa persona, para nombrarla al ofrecer la hora."}},"type":"object","required":["starts_at","ends_at","staff_id","staff_name"],"title":"AgentSlotOut","description":"Un hueco reservable concreto: cuándo, hasta cuándo y con quién.\n\n`starts_at` es el valor que hay que copiar **tal cual** a la tool de reserva:\nlleva ya el offset del negocio, así que no hay ninguna conversión que hacer\nni ninguna zona que adivinar."},"AuthResponse":{"properties":{"access_token":{"type":"string","title":"Access Token"},"token_type":{"type":"string","title":"Token Type","default":"bearer"},"expires_in":{"type":"integer","title":"Expires In"},"user":{"$ref":"#/components/schemas/UserOut"}},"type":"object","required":["access_token","expires_in","user"],"title":"AuthResponse","description":"Respuesta canónica de un login correcto (sin el refresh: va en cookie)."},"AvailabilityMatrixInput":{"properties":{"business":{"type":"string","minLength":1,"title":"Business","description":"El local del que se quiere la agenda, identificado por su `slug` (`barberia-demo`) o por su `business_id` (UUID), tal y como los devolvió `search_lukin_services`. También vale la URL de su ficha pública. El nombre comercial no sirve: un slug inventado responde `BUSINESS_NOT_FOUND` con los parecidos en `suggestions`.","examples":["barberia-demo"]},"service_id":{"type":"string","minLength":1,"title":"Service Id","description":"UUID del servicio cuya agenda se pide, tal y como viene en `services[].service_id` de la búsqueda. Tiene que ser un servicio activo **de ese mismo negocio**: si no lo es, la respuesta es `SERVICE_NOT_FOUND` con el catálogo activo entero en `suggestions`, cada entrada con su `service_id`, su nombre, su precio en pesos y su duración.","examples":["3fa85f64-5717-4562-b3fc-2c963f66afa6"]},"date_from":{"type":"string","minLength":1,"title":"Date From","description":"Primer día del que se quiere ver la agenda. Acepta ISO (`2026-09-15`), `DD/MM/YYYY` y lenguaje natural en español: `hoy`, `mañana`, `pasado mañana`, `en 3 días`, `el sábado`, `próximo lunes`. Una fecha que ya pasó **no es un error**: se adelanta a hoy y se avisa en `warnings` con `DATE_FROM_ADJUSTED_TO_TODAY`. Una fecha que no se entiende responde `INVALID_DATE`.","examples":["mañana"]},"date_to":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Date To","description":"Último día del rango, con los mismos formatos que `date_from`. Por defecto, `date_from` más 6 días, es decir, una semana completa. El rango no puede pasar de 14 días: si te pasas no se rechaza la llamada, se recorta y se avisa con `RANGE_TRUNCATED_TO_14_DAYS` en `warnings`.","examples":["el sábado"]},"staff_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Staff Id","description":"UUID de la persona con la que se quiere la hora, cuando quien reserva pidió una en concreto. Omítelo para ver la agenda de **todo** el equipo que presta el servicio, que es lo habitual. Una persona dada de baja o que no presta ese servicio responde `STAFF_NOT_FOUND` con quienes sí lo prestan en `suggestions`.","examples":["9c8f2b1e-4d3a-4c5b-8e7f-1a2b3c4d5e6f"]}},"type":"object","required":["business","service_id","date_from"],"title":"AvailabilityMatrixInput","description":"Los argumentos de `get_availability_matrix`, ya normalizados.\n\nLos campos son **texto tal y como lo escribe un modelo** —«mañana», el slug\ncon la URL alrededor— y la normalización ocurre al validar, no al usarlos:\nasí un UUID mal copiado o una fecha imposible fallan con su `code` propio\nantes de tocar la base de datos, y el resto del módulo trabaja siempre con\n`UUID` y con `date`.\n\nLo normalizado vive en atributos privados y se lee por propiedad porque no\nes parte del contrato que el agente rellena: el `inputSchema` publica lo que\nhay que escribir."},"AvailabilityMatrixOutput":{"properties":{"timezone":{"type":"string","title":"Timezone","description":"Zona horaria IANA del negocio (`America/Santiago`), la que llevan el offset de cada hueco y las fechas de `days`."},"business":{"$ref":"#/components/schemas/BusinessRefOut","description":"El local del que es esta agenda, ya resuelto por slug o por UUID."},"service":{"$ref":"#/components/schemas/ServiceRefOut","description":"El servicio consultado, con su duración, su precio y si exige prepago."},"days":{"items":{"$ref":"#/components/schemas/DayOut"},"type":"array","title":"Days","description":"Todos los días del rango consultado, en orden y sin saltarse ninguno; los que no tienen hueco viajan con `slots` vacío."},"total_slots":{"type":"integer","title":"Total Slots","description":"Cuántos huecos trae la respuesta sumando todos los días. Un cero no es un error: significa que no queda hora en ese rango."},"warnings":{"items":{"$ref":"#/components/schemas/WarningOut"},"type":"array","title":"Warnings","description":"Recortes aplicados a lo que pediste (fecha adelantada a hoy, rango acortado, huecos truncados). Vacío si se contestó exactamente lo pedido."},"next_step_hint":{"type":"string","title":"Next Step Hint","description":"Qué hacer a continuación, en una frase: reservar la primera hora, ampliar el rango o preguntar por otro servicio."}},"type":"object","required":["timezone","business","service","days","total_slots","warnings","next_step_hint"],"title":"AvailabilityMatrixOutput","description":"La agenda de un servicio, día a día, con lo que hace falta para reservar."},"AvailabilityResponse":{"properties":{"timezone":{"type":"string","title":"Timezone","description":"Zona horaria IANA del negocio (`America/Santiago`). Es la zona en la que están expresados todos los `starts_at` y `ends_at`.","examples":["America/Santiago"]},"service_id":{"type":"string","format":"uuid","title":"Service Id","description":"Servicio cuya disponibilidad se calculó."},"date_from":{"type":"string","format":"date","title":"Date From","description":"Primer día de la ventana, en hora local del negocio."},"date_to":{"type":"string","format":"date","title":"Date To","description":"Último día de la ventana, incluido. Igual a `date_from` si no se envió."},"slots":{"items":{"$ref":"#/components/schemas/SlotOut"},"type":"array","title":"Slots","description":"Huecos libres ordenados por instante y, a igualdad de hora, por plaza. Lista vacía si el local no atiende esos días, si nadie presta el servicio o si no está aceptando reservas por internet ahora mismo: no hay horas es una respuesta, no un error."}},"type":"object","required":["timezone","service_id","date_from","date_to","slots"],"title":"AvailabilityResponse","description":"Los huecos de un servicio en una ventana de días, con la zona que los explica.\n\n`date_from` y `date_to` se devuelven aunque el cliente los haya enviado: la\nrespuesta a `date_to` omitido dice explícitamente qué día se calculó, y una\ncaché intermedia (`Cache-Control: public`) no obliga a recomponer la\npregunta para entender la respuesta.","examples":[{"date_from":"2026-03-10","date_to":"2026-03-10","service_id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","slots":[{"ends_at":"2026-03-10T09:30:00-03:00","staff_member_id":"3f2b9a10-0000-4000-8000-cccccccccccc","staff_name":"Ana","starts_at":"2026-03-10T09:00:00-03:00"},{"ends_at":"2026-03-10T09:30:00-03:00","staff_member_id":"3f2b9a10-0000-4000-8000-dddddddddddd","staff_name":"Benjamín","starts_at":"2026-03-10T09:00:00-03:00"},{"ends_at":"2026-03-10T09:45:00-03:00","staff_member_id":"3f2b9a10-0000-4000-8000-cccccccccccc","staff_name":"Ana","starts_at":"2026-03-10T09:15:00-03:00"}],"timezone":"America/Santiago"}]},"B2BRequest":{"properties":{"email":{"type":"string","format":"email","title":"Email","description":"Correo del vendedor. Se normaliza a minúsculas."}},"type":"object","required":["email"],"title":"B2BRequest","description":"Petición del reto de acceso al panel: solo el correo del vendedor."},"B2BRequestAccepted":{"properties":{"masked_destination":{"type":"string","title":"Masked Destination","description":"Correo ofuscado al que se envió el código y el enlace.","examples":["o*****r@lukin.cl"]},"resend_after_seconds":{"type":"integer","title":"Resend After Seconds","description":"Segundos que hay que esperar antes de poder pedir otro código.","examples":[60]}},"type":"object","required":["masked_destination","resend_after_seconds"],"title":"B2BRequestAccepted","description":"Acuse del reto emitido. Nunca revela si esa cuenta existe.\n\n`masked_destination` permite al frontend decir «te escribimos a o****r@…»\nsin exponer la dirección completa a quien solo teclea correos ajenos."},"B2BVerify":{"properties":{"email":{"anyOf":[{"type":"string","format":"email"},{"type":"null"}],"title":"Email","description":"Correo con el que se pidió el código."},"code":{"anyOf":[{"type":"string","maxLength":6,"minLength":6,"pattern":"^\\d+$"},{"type":"null"}],"title":"Code","description":"Código de 6 dígitos recibido por correo."},"token":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Token","description":"JWT `purpose=magic_link` que viaja en el enlace del correo."},"accept_privacy":{"type":"boolean","title":"Accept Privacy","description":"Aceptación expresa de los Términos y de la Política de Privacidad. Obligatoria solo en el primer acceso, cuando la cuenta aún no existe.","default":false}},"type":"object","title":"B2BVerify","description":"Canje del reto: **o bien** correo + código **o bien** el token del enlace.\n\nLas dos vías son dos representaciones del mismo reto —el `jti` del token es\nel id del `OtpCode`—, así que usar una inutiliza la otra."},"BillingCycle":{"type":"string","enum":["MONTHLY","ANNUAL"],"title":"BillingCycle","description":"Cada cuánto se domicilia el plan.\n\nEl anual no es un plan distinto: es el mismo tramo cobrado de una vez con\ndos meses de descuento, y por eso vive en su propia columna y no en el\ncódigo del plan."},"BlockCreate":{"properties":{"staff_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Staff Id","description":"Plaza de **este** negocio a la que afecta el bloqueo. `null` lo hace del local completo: se lo resta a toda la plantilla por igual."},"kind":{"$ref":"#/components/schemas/ScheduleBlockKind","description":"`BREAK` (colación o descanso), `BLOCK` (tramo no reservable) o `EXCEPTION` (alteración puntual del horario habitual de ese día)."},"starts_at":{"type":"string","format":"date-time","title":"Starts At","description":"Instante de inicio, **con offset obligatorio** (`2026-04-06T13:00:00-04:00`). Se guarda en UTC. Un datetime sin zona se rechaza: no identifica ningún instante concreto."},"ends_at":{"type":"string","format":"date-time","title":"Ends At","description":"Instante de fin, con offset obligatorio y posterior a `starts_at`. Se guarda en UTC."},"weekly_recurrence":{"type":"boolean","title":"Weekly Recurrence","description":"`true` repite el bloqueo **cada semana**, en el mismo día y a la misma **hora local del negocio**, sin fecha de fin: una colación de 13:00 sigue siendo de 13:00 después del cambio de hora. Para terminarla se borra el bloqueo. Un bloqueo recurrente dura menos de siete días.","default":false},"reason":{"anyOf":[{"type":"string","maxLength":200},{"type":"null"}],"title":"Reason","description":"Nota interna que el equipo ve en el calendario del panel (máx. 200 caracteres). No se publica al cliente final."}},"type":"object","required":["kind","starts_at","ends_at"],"title":"BlockCreate","description":"Alta de un bloqueo, puntual o semanal.","examples":[{"ends_at":"2026-04-06T14:00:00-04:00","kind":"BREAK","reason":"Colación","starts_at":"2026-04-06T13:00:00-04:00","weekly_recurrence":true},{"ends_at":"2026-09-18T18:00:00-03:00","kind":"EXCEPTION","reason":"Fiestas Patrias","staff_id":"3f2b9a10-0000-4000-8000-aaaaaaaaaaaa","starts_at":"2026-09-18T09:00:00-03:00","weekly_recurrence":false}]},"BlockOccurrence":{"properties":{"block_id":{"type":"string","format":"uuid","title":"Block Id","description":"Bloqueo del que procede esta ocurrencia."},"staff_member_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Staff Member Id","description":"Plaza afectada; `null` si el bloqueo es del local completo."},"kind":{"$ref":"#/components/schemas/ScheduleBlockKind"},"starts_at":{"type":"string","format":"date-time","title":"Starts At","description":"Inicio de **esta** ocurrencia, en UTC."},"ends_at":{"type":"string","format":"date-time","title":"Ends At","description":"Fin de **esta** ocurrencia, en UTC."},"weekly_recurrence":{"type":"boolean","title":"Weekly Recurrence","description":"`true` si procede de un bloqueo semanal; la ocurrencia en sí es puntual."},"reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Reason"}},"type":"object","required":["block_id","kind","starts_at","ends_at","weekly_recurrence"],"title":"BlockOccurrence","description":"Una repetición concreta de un bloqueo dentro de la ventana consultada.\n\nUn bloqueo puntual produce exactamente una; uno semanal, tantas como semanas\ncaigan en el rango. `block_id` las devuelve al bloqueo del que salen, que es\nel identificador con el que se edita o se borra: las ocurrencias no tienen\nvida propia y no se pueden modificar de una en una."},"BlockOut":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"staff_member_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Staff Member Id","description":"Plaza afectada; `null` si el bloqueo es del local completo."},"kind":{"$ref":"#/components/schemas/ScheduleBlockKind"},"starts_at":{"type":"string","format":"date-time","title":"Starts At","description":"Inicio en UTC. Si se repite, el de la primera vez."},"ends_at":{"type":"string","format":"date-time","title":"Ends At","description":"Fin en UTC de esa misma ocurrencia."},"weekly_recurrence":{"type":"boolean","title":"Weekly Recurrence"},"reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Reason"},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["id","kind","starts_at","ends_at","weekly_recurrence","created_at"],"title":"BlockOut","description":"Un bloqueo tal y como se guarda: su **primera** ocurrencia y su regla.\n\nNo es lo que pinta el calendario. Para eso está `BlockOccurrence`, que\ndevuelve la recurrencia ya expandida día a día: aquí `starts_at` es el\ninstante del que arranca la repetición, no el de la semana que se mira."},"BlockUpdate":{"properties":{"staff_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Staff Id","description":"Plaza de **este** negocio a la que afecta el bloqueo. `null` lo hace del local completo: se lo resta a toda la plantilla por igual."},"kind":{"anyOf":[{"$ref":"#/components/schemas/ScheduleBlockKind"},{"type":"null"}],"description":"`BREAK` (colación o descanso), `BLOCK` (tramo no reservable) o `EXCEPTION` (alteración puntual del horario habitual de ese día)."},"starts_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Starts At","description":"Instante de inicio, **con offset obligatorio** (`2026-04-06T13:00:00-04:00`). Se guarda en UTC. Un datetime sin zona se rechaza: no identifica ningún instante concreto."},"ends_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Ends At","description":"Instante de fin, con offset obligatorio y posterior a `starts_at`. Se guarda en UTC."},"weekly_recurrence":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Weekly Recurrence","description":"`true` repite el bloqueo **cada semana**, en el mismo día y a la misma **hora local del negocio**, sin fecha de fin: una colación de 13:00 sigue siendo de 13:00 después del cambio de hora. Para terminarla se borra el bloqueo. Un bloqueo recurrente dura menos de siete días."},"reason":{"anyOf":[{"type":"string","maxLength":200},{"type":"null"}],"title":"Reason","description":"Nota interna que el equipo ve en el calendario del panel (máx. 200 caracteres). No se publica al cliente final."}},"type":"object","title":"BlockUpdate","description":"Edición **parcial** de un bloqueo: solo se toca lo que venga en el cuerpo.\n\nEl rango no se valida aquí sino sobre el **resultado** de aplicar los campos\nrecibidos (`block_service.update_block`): un `PATCH {\"ends_at\": …}` tiene que\ncompararse con el `starts_at` que ya está guardado, no con nada.\n\nUn `STAFF` no puede enviar `staff_id`: reasignar un bloqueo a otra persona es\ngestionar la agenda ajena (`403 STAFF_SELF_ONLY`).","examples":[{"reason":"Almuerzo"},{"ends_at":"2026-04-06T14:30:00-04:00","starts_at":"2026-04-06T13:30:00-04:00"}]},"Body_businesses_add_gallery_item":{"properties":{"file":{"type":"string","contentMediaType":"application/octet-stream","title":"File","description":"Imagen PNG, JPEG o WEBP de hasta 5 MB."}},"type":"object","required":["file"],"title":"Body_businesses_add_gallery_item"},"Body_businesses_upload_logo":{"properties":{"file":{"type":"string","contentMediaType":"application/octet-stream","title":"File","description":"Imagen PNG, JPEG o WEBP de hasta 2 MB."}},"type":"object","required":["file"],"title":"Body_businesses_upload_logo"},"BookingCancel":{"properties":{"reason":{"anyOf":[{"type":"string","maxLength":200},{"type":"null"}],"title":"Reason","description":"Motivo libre, como mucho 200 caracteres. Un texto en blanco se guarda como `null`: «sin motivo» y «espacios» son lo mismo.","examples":["Me surgió un imprevisto"]}},"type":"object","title":"BookingCancel","description":"Motivo opcional con el que el cliente anula su cita.","examples":[{"reason":"Me surgió un imprevisto"}]},"BookingOut":{"properties":{"id":{"type":"string","format":"uuid","title":"Id","description":"Identificador de la reserva. No cambia al reprogramarla."},"status":{"$ref":"#/components/schemas/BookingStatus","description":"Estado del ciclo de vida: `PENDING`, `CONFIRMED`, `PAID`, `CANCELLED` o `NO_SHOW`. Los dos últimos son terminales."},"source":{"$ref":"#/components/schemas/BookingSource","description":"Por dónde entró la cita: `WEB` desde el marketplace, `DASHBOARD` si la apuntó el propio local, `AGENT` si la creó un agente autónomo."},"starts_at":{"type":"string","format":"date-time","title":"Starts At","description":"Inicio de la cita en **hora local del negocio**, ISO 8601 con offset (`-03:00` o `-04:00` en Chile, según la fecha)."},"ends_at":{"type":"string","format":"date-time","title":"Ends At","description":"Fin de la cita, también con offset. Se deriva de la duración del servicio y viaja resuelto para no obligar al cliente a sumarla."},"price_clp":{"type":"integer","title":"Price Clp","description":"Importe **pactado** al reservar, en pesos chilenos enteros. Es una foto: subir la tarifa del catálogo no reescribe lo ya acordado.","examples":[15000]},"business":{"$ref":"#/components/schemas/BusinessSummary","description":"Local donde es la cita."},"service":{"$ref":"#/components/schemas/ServiceSummary","description":"Prestación reservada."},"staff":{"$ref":"#/components/schemas/StaffSummary","description":"Persona que atenderá la cita."},"cancelled_reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cancelled Reason","description":"Motivo de la anulación, o `null` si la reserva sigue viva. El valor `EXPIRED` lo escribe el sistema cuando una `PENDING` agota su plazo."},"can_cancel":{"type":"boolean","title":"Can Cancel","description":"`true` si el cliente todavía puede anularla **por su cuenta**: el estado lo permite y no ha entrado la ventana de cancelación del local (`cancellation_window_hours`). Con `false`, `POST /cancel` responde `409 INVALID_TRANSITION` o `422 CANCELLATION_WINDOW_CLOSED`."},"can_reschedule":{"type":"boolean","title":"Can Reschedule","description":"`true` si el cliente todavía puede moverla de hora por su cuenta. Se calcula con la misma ventana que `can_cancel`, medida siempre sobre el `starts_at` vigente."},"has_review":{"type":"boolean","title":"Has Review","description":"`true` si esta visita ya tiene una valoración escrita. Con `true`, `POST /api/v1/reviews` responde `409 REVIEW_EXISTS`: la pantalla no debe ofrecer el formulario, o se pierde lo que la persona escriba."},"created_at":{"type":"string","format":"date-time","title":"Created At","description":"Cuándo se creó la reserva, en hora local del negocio."}},"type":"object","required":["id","status","source","starts_at","ends_at","price_clp","business","service","staff","can_cancel","can_reschedule","has_review","created_at"],"title":"BookingOut","description":"Una reserva tal y como la ve su cliente, con el local, el servicio y la persona.\n\nEs la respuesta de las cinco operaciones de `/api/v1/bookings` y el tipo que\nviaja dentro de `Page[BookingOut]`. `GuestBookingDetail` (F4-T07) **hereda**\nde aquí: el invitado y el cliente con cuenta ven exactamente la misma\nreserva, y lo único que cambia es por qué puerta entraron.","examples":[{"business":{"address":"Av. Irarrázaval 1234","comuna":"Ñuñoa","id":"3f2b9a10-0000-4000-8000-aaaaaaaaaaaa","name":"Barbería Ñuñoa","slug":"barberia-nunoa","timezone":"America/Santiago"},"can_cancel":true,"can_reschedule":true,"created_at":"2026-03-05T18:42:11-03:00","ends_at":"2026-03-10T09:30:00-03:00","has_review":false,"id":"3f2b9a10-0000-4000-8000-dddddddddddd","price_clp":15000,"service":{"duration_minutes":30,"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","modality":"IN_PERSON","name":"Corte de pelo","price_clp":15000,"requires_prepayment":false},"source":"WEB","staff":{"display_name":"Ana","id":"3f2b9a10-0000-4000-8000-cccccccccccc"},"starts_at":"2026-03-10T09:00:00-03:00","status":"CONFIRMED"}]},"BookingReschedule":{"properties":{"starts_at":{"type":"string","format":"date-time","title":"Starts At","description":"Nuevo inicio de la cita, con zona horaria. El `ends_at` se vuelve a derivar de la duración del servicio y **no** se envía.","examples":["2026-03-11T11:00:00-03:00"]},"staff_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Staff Id","description":"Persona destino. Omitida, la cita se queda con quien ya la atendía; si esa persona causó baja, hay que elegir otra (`422 SERVICE_NOT_ASSIGNED`)."}},"type":"object","required":["starts_at"],"title":"BookingReschedule","description":"Nueva hora —y opcionalmente nueva persona— de una reserva que ya existe.","examples":[{"staff_id":"3f2b9a10-0000-4000-8000-cccccccccccc","starts_at":"2026-03-11T11:00:00-03:00"}]},"BookingSource":{"type":"string","enum":["WEB","DASHBOARD","AGENT"],"title":"BookingSource","description":"Por dónde entró la reserva.\n\n- `WEB`: marketplace público, con o sin cuenta (RF-03.02).\n- `DASHBOARD`: la registró el propio negocio desde su panel.\n- `AGENT`: la creó un asistente autónomo por MCP (RF-08.04)."},"BookingStatus":{"type":"string","enum":["PENDING","CONFIRMED","PAID","CANCELLED","NO_SHOW"],"title":"BookingStatus","description":"Ciclo de vida de una reserva (RF-03.03).\n\n- `PENDING`: creada y con la hora ya bloqueada, a la espera del OTP o del\n  prepago; `expires_at` marca hasta cuándo se le guarda el hueco.\n- `CONFIRMED`: validada, sin cobro pendiente.\n- `PAID`: el webhook de Mercado Pago confirmó el pago (RF-04.02).\n- `CANCELLED` y `NO_SHOW`: cerradas. **Liberan la hora sin borrar la fila**,\n  porque quedan fuera del predicado de `ex_bookings_staff_no_overlap`.\n\nLos tres primeros son los estados «vivos»: son los que ocupan agenda."},"BookingStatusInput":{"properties":{"booking_id":{"type":"string","title":"Booking Id","description":"UUID de la reserva que quieres consultar, tal y como lo devolvió `execute_agent_booking` o la confirmación del código, p. ej. `3fa85f64-5717-4562-b3fc-2c963f66afa6`."},"token":{"type":"string","title":"Token","description":"Token que autoriza a leer ESTA reserva: el `manage_token` que se entrega tras verificar el código del titular, o el `status_token` (`agent_confirm`) que devolvió `execute_agent_booking`. Cópialo tal cual; el de otra reserva se rechaza con `TOKEN_INVALID`."}},"type":"object","required":["booking_id","token"],"title":"BookingStatusInput","description":"Los argumentos de `get_booking_status`: qué reserva y con qué credencial.\n\n`token` acepta también el nombre `manage_token` —`AliasChoices` declara los\ndos, y `populate_by_name` de `LukinToolInput` mantiene abierto el del propio\ncampo— porque ese es el nombre con el que la reserva de la vitrina devuelve\nla credencial: obligar a renombrarla para consultarla sería una fuente de\nreintentos gratis. Se declara como *validation alias* y no como `alias` a\nsecas para que construir el modelo desde Python siga siendo\n`BookingStatusInput(token=...)`, que es como lo lee todo el proyecto.\n\nEl UUID se normaliza al validar, no al usarse: un identificador mal copiado\nfalla con `INVALID_UUID` y su `hint` antes de tocar la base, y el resto del\nmódulo trabaja siempre con un `uuid.UUID`."},"BookingStatusOutput":{"properties":{"booking_id":{"type":"string","title":"Booking Id","description":"UUID de la reserva consultada, el mismo que se pidió."},"status":{"type":"string","title":"Status","description":"Estado de la reserva: `PENDING` (esperando confirmación o pago), `CONFIRMED`, `PAID`, `CANCELLED` o `NO_SHOW`."},"cancelled_reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cancelled Reason","description":"Motivo por el que se canceló, si se canceló; `null` en cualquier otro estado. `EXPIRED` significa que nadie confirmó a tiempo."},"otp_verified":{"type":"boolean","title":"Otp Verified","description":"`true` si el titular ya tecleó el código que verifica su contacto. Una reserva `PENDING` con `false` sigue esperando ese paso."},"starts_at":{"type":"string","title":"Starts At","description":"Comienzo de la cita en ISO 8601 **con el offset del negocio**, p. ej. `2026-09-15T10:30:00-03:00`. Léelo tal cual: ya está en la hora local del local."},"ends_at":{"type":"string","title":"Ends At","description":"Fin de la cita en ISO 8601 con el offset del negocio, en el mismo formato que `starts_at`."},"timezone":{"type":"string","title":"Timezone","description":"Zona horaria del local en formato IANA, p. ej. `America/Santiago`."},"business":{"$ref":"#/components/schemas/StatusBusinessOut","description":"El local donde se atiende la cita: nombre, slug y dirección."},"service":{"$ref":"#/components/schemas/StatusServiceOut","description":"El servicio reservado, con su precio pactado y su duración."},"staff_name":{"type":"string","title":"Staff Name","description":"Nombre de quien atiende la cita, tal y como lo publica el local."},"payment_status":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Payment Status","description":"Estado del último intento de cobro: `PENDING`, `APPROVED`, `REJECTED` o `REFUNDED`; `null` si esta reserva nunca tuvo cobro por internet (se paga en el local)."},"expires_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Expires At","description":"Hasta cuándo se guarda el hueco de una reserva `PENDING`, en ISO 8601 con offset; `null` cuando la reserva ya no está esperando nada."},"confirmation_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Confirmation Url","description":"Enlace en el que el titular confirma la reserva. Solo viaja si sigue `PENDING`; en cualquier otro estado es `null` porque ya no hay nada que confirmar."},"payment_checkout_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Payment Checkout Url","description":"Enlace de Mercado Pago para pagar la reserva. Solo viaja si hay un intento de cobro `PENDING` con su preferencia viva; `null` si no hay nada que pagar o el enlace ya no lleva a ninguna parte."},"next_action_hint":{"type":"string","title":"Next Action Hint","description":"Qué toca hacer ahora, en una frase que puedes leerle a la persona. Es distinta para cada estado y nombra el campo que hay que usar."}},"type":"object","required":["booking_id","status","cancelled_reason","otp_verified","starts_at","ends_at","timezone","business","service","staff_name","payment_status","expires_at","next_action_hint"],"title":"BookingStatusOutput","description":"El estado completo de una reserva, listo para leérselo a una persona.\n\nNo lleva ningún dato de contacto del titular (SEC-01): el agente ya sabe a\nquién representa, y esta tool no es un sitio del que sacar correos."},"BusinessCreate":{"properties":{"name":{"type":"string","maxLength":120,"minLength":3,"title":"Name","description":"Nombre comercial del local, tal y como lo verá el cliente."},"description":{"anyOf":[{"type":"string","maxLength":1000},{"type":"null"}],"title":"Description","description":"Texto libre de presentación del local. Opcional."},"address":{"type":"string","maxLength":200,"minLength":3,"title":"Address","description":"Dirección de calle y número. Se muestra en la ficha pública."},"comuna":{"type":"string","maxLength":80,"minLength":2,"title":"Comuna","description":"Comuna del local. Es un **valor cerrado**: tiene que existir en `GET /api/v1/public/comunas`, y se compara sin tildes ni mayúsculas (`ñuñoa`, `NUNOA` y ` Ñuñoa ` valen igual). Se guarda siempre con la grafía canónica del catálogo. Una comuna que no exista responde `422` con code `UNKNOWN_COMUNA` y una lista de sugerencias en `details.suggestions`."},"lat":{"type":"number","maximum":90.0,"minimum":-90.0,"title":"Lat","description":"Latitud en grados decimales (WGS 84)."},"lng":{"type":"number","maximum":180.0,"minimum":-180.0,"title":"Lng","description":"Longitud en grados decimales (WGS 84)."}},"type":"object","required":["name","address","comuna","lat","lng"],"title":"BusinessCreate","description":"Alta del perfil comercial. El slug, el dueño y la publicación no se piden.\n\nEl `slug` lo deriva el backend del nombre, `owner_id` sale de la sesión y\n`is_published` nace en `false`: un local recién creado no aparece en el\nmarketplace hasta que su dueño lo publica con un `PATCH`.","examples":[{"address":"Av. Irarrázaval 1234","comuna":"Ñuñoa","description":"Cortes clásicos y afeitado a navaja.","lat":-33.45,"lng":-70.66,"name":"Barbería Ñuñoa"}]},"BusinessOut":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"slug":{"type":"string","title":"Slug","description":"Identificador público en la URL. Inmutable tras la creación."},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"logo_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo Url"},"gallery_urls":{"items":{"type":"string"},"type":"array","title":"Gallery Urls"},"address":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Address"},"comuna":{"type":"string","title":"Comuna"},"lat":{"type":"number","title":"Lat","description":"Latitud derivada de `location` con `ST_Y`."},"lng":{"type":"number","title":"Lng","description":"Longitud derivada de `location` con `ST_X`."},"timezone":{"type":"string","title":"Timezone","description":"Zona horaria IANA en la que se calcula la agenda."},"is_published":{"type":"boolean","title":"Is Published"},"rating_avg":{"type":"number","title":"Rating Avg"},"rating_count":{"type":"integer","title":"Rating Count"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"}},"type":"object","required":["id","slug","name","comuna","lat","lng","timezone","is_published","rating_avg","rating_count","created_at","updated_at"],"title":"BusinessOut","description":"Vista completa del negocio para su propio equipo."},"BusinessPublic":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"slug":{"type":"string","title":"Slug","description":"Identificador del local en la URL del marketplace."},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"logo_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo Url","description":"Logotipo del local, o `null`."},"gallery_urls":{"items":{"type":"string"},"type":"array","title":"Gallery Urls","description":"Fotos de la galería en el orden en que las subió el local."},"address":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Address"},"comuna":{"type":"string","title":"Comuna","description":"Comuna del local; es el filtro de primer nivel del buscador."},"lat":{"type":"number","title":"Lat","description":"Latitud de la ubicación del local (WGS 84)."},"lng":{"type":"number","title":"Lng","description":"Longitud de la ubicación del local (WGS 84)."},"timezone":{"type":"string","title":"Timezone","description":"Zona horaria IANA del local (`America/Santiago` por defecto). Las horas de `hours` están expresadas en ella."},"hours":{"items":{"$ref":"#/components/schemas/HoursEntry"},"type":"array","title":"Hours","description":"Matriz de apertura ordenada por `(weekday, opens_at)`. Un día sin tramos es un día cerrado; varios tramos el mismo día son jornada partida."},"services":{"items":{"$ref":"#/components/schemas/ServicePublic"},"type":"array","title":"Services","description":"Catálogo **activo**, ordenado por nombre. Los retirados no aparecen."},"staff":{"items":{"$ref":"#/components/schemas/StaffPublic"},"type":"array","title":"Staff","description":"Plantilla **activa**, ordenada por `display_name`."},"rating_avg":{"type":"number","title":"Rating Avg","description":"Media de las valoraciones recibidas, de 0 a 5."},"rating_count":{"type":"integer","title":"Rating Count","description":"Número de valoraciones que componen la media."}},"type":"object","required":["id","slug","name","comuna","lat","lng","timezone","rating_avg","rating_count"],"title":"BusinessPublic","description":"Ficha completa del local publicado: portada, horario, catálogo y equipo.\n\nEs la respuesta de `GET /api/v1/public/businesses/{slug}` y llega resuelta de\nuna sola vez —horario, servicios activos y plantilla activa incluidos— para\nque el SSR del marketplace pinte la página con una única llamada y los\nagentes de la Fase 8 no tengan que encadenar peticiones.\n\nNo publica `is_published` ni `deleted_at`: si esta ficha existe, el negocio\nestá publicado y vivo; y si no lo está, la ruta responde `404\nBUSINESS_NOT_FOUND` sin distinguirlo de un slug inventado.","examples":[{"address":"Av. Irarrázaval 1234, local 5","comuna":"Ñuñoa","description":"Barbería clásica en pleno Ñuñoa, con tres sillones y café de cortesía.","gallery_urls":["/media/businesses/3f2b9a10-0000-4000-8000-aaaaaaaaaaaa/gallery/3c4d.webp"],"hours":[{"closes_at":"13:00:00","opens_at":"09:00:00","weekday":0},{"closes_at":"18:00:00","opens_at":"14:00:00","weekday":0},{"closes_at":"14:00:00","opens_at":"10:00:00","weekday":5}],"id":"3f2b9a10-0000-4000-8000-aaaaaaaaaaaa","lat":-33.4569,"lng":-70.5975,"logo_url":"/media/businesses/3f2b9a10-0000-4000-8000-aaaaaaaaaaaa/logo-1a2b.webp","name":"Barbería Ñuñoa","rating_avg":4.7,"rating_count":128,"services":[{"description":"Corte clásico con máquina y tijera, incluye lavado.","duration_minutes":30,"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","modality":"IN_PERSON","name":"Corte de pelo","price_clp":15000,"requires_prepayment":false}],"slug":"barberia-nunoa","staff":[{"display_name":"Ana","id":"3f2b9a10-0000-4000-8000-cccccccccccc"}],"timezone":"America/Santiago"}]},"BusinessRefOut":{"properties":{"id":{"type":"string","title":"Id","description":"UUID del negocio, el que hay que usar para reservar."},"slug":{"type":"string","title":"Slug","description":"Slug del negocio en la URL de su ficha pública."},"name":{"type":"string","title":"Name","description":"Nombre comercial del local, para nombrarlo en la respuesta."}},"type":"object","required":["id","slug","name"],"title":"BusinessRefOut","description":"El local al que pertenece la agenda, ya resuelto."},"BusinessSettings":{"properties":{"timezone":{"type":"string","title":"Timezone","description":"Zona horaria IANA del local (por ejemplo `America/Santiago` o `Pacific/Easter`). Es la zona en la que se interpretan los horarios de apertura, los bloqueos y las horas que ve el cliente. Cambiarla **no** mueve las reservas ya creadas: se guardan como instante absoluto.","default":"America/Santiago"},"slot_interval_minutes":{"type":"integer","multipleOf":5.0,"maximum":120.0,"minimum":5.0,"title":"Slot Interval Minutes","description":"Granularidad de la agenda, en minutos: cada cuánto empieza un hueco reservable (15 → 10:00, 10:15, 10:30…). No es la duración de la cita, que la fija cada servicio.","default":15},"min_advance_minutes":{"type":"integer","maximum":10080.0,"minimum":0.0,"title":"Min Advance Minutes","description":"Anticipación mínima, en minutos, entre el momento de reservar y el inicio de la cita. Los huecos más próximos que ese margen no se ofrecen. `0` permite reservar para ahora mismo.","default":60},"cancellation_window_hours":{"type":"integer","maximum":720.0,"minimum":0.0,"title":"Cancellation Window Hours","description":"Ventana de cancelación para el cliente, en horas antes del inicio de la cita. Pasada esa hora ya no puede anular ni reprogramar por su cuenta; el negocio siempre puede. `0` deja cancelar hasta el último minuto.","default":24},"buffer_minutes":{"type":"integer","multipleOf":5.0,"maximum":120.0,"minimum":0.0,"title":"Buffer Minutes","description":"Margen en minutos que se reserva **antes y después** de cada cita (limpieza, traslado, notas). No se cobra ni se muestra al cliente: solo impide que dos citas queden pegadas.","default":0}},"type":"object","title":"BusinessSettings","description":"Políticas de reserva vigentes del negocio.\n\nEs la **única** forma en la que el motor de disponibilidad (F4-T01) y el\nalta de reservas (F4-T04) leen estas políticas: nadie consulta las columnas\nsueltas de `businesses`, para que un cambio de nombre o de default no se\nfiltre a media docena de módulos.","examples":[{"buffer_minutes":0,"cancellation_window_hours":24,"min_advance_minutes":60,"slot_interval_minutes":15,"timezone":"America/Santiago"}]},"BusinessSettingsUpdate":{"properties":{"timezone":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Timezone","description":"Zona horaria IANA del local (por ejemplo `America/Santiago` o `Pacific/Easter`). Es la zona en la que se interpretan los horarios de apertura, los bloqueos y las horas que ve el cliente. Cambiarla **no** mueve las reservas ya creadas: se guardan como instante absoluto."},"slot_interval_minutes":{"anyOf":[{"type":"integer","multipleOf":5.0,"maximum":120.0,"minimum":5.0},{"type":"null"}],"title":"Slot Interval Minutes","description":"Granularidad de la agenda, en minutos: cada cuánto empieza un hueco reservable (15 → 10:00, 10:15, 10:30…). No es la duración de la cita, que la fija cada servicio."},"min_advance_minutes":{"anyOf":[{"type":"integer","maximum":10080.0,"minimum":0.0},{"type":"null"}],"title":"Min Advance Minutes","description":"Anticipación mínima, en minutos, entre el momento de reservar y el inicio de la cita. Los huecos más próximos que ese margen no se ofrecen. `0` permite reservar para ahora mismo."},"cancellation_window_hours":{"anyOf":[{"type":"integer","maximum":720.0,"minimum":0.0},{"type":"null"}],"title":"Cancellation Window Hours","description":"Ventana de cancelación para el cliente, en horas antes del inicio de la cita. Pasada esa hora ya no puede anular ni reprogramar por su cuenta; el negocio siempre puede. `0` deja cancelar hasta el último minuto."},"buffer_minutes":{"anyOf":[{"type":"integer","multipleOf":5.0,"maximum":120.0,"minimum":0.0},{"type":"null"}],"title":"Buffer Minutes","description":"Margen en minutos que se reserva **antes y después** de cada cita (limpieza, traslado, notas). No se cobra ni se muestra al cliente: solo impide que dos citas queden pegadas."}},"type":"object","title":"BusinessSettingsUpdate","description":"Edición parcial de las políticas: solo se toca lo que venga en el cuerpo.\n\n`extra='ignore'`, como en `BusinessUpdate`: el dashboard reenvía el objeto\nque acaba de leer y no debe recibir un 422 por incluir una clave que aquí no\nse edita.\n\nUn cuerpo **vacío** sí es un 422: `PATCH {}` no es «no cambies nada», es una\nllamada que el cliente no quería hacer, y responder 200 escondería el error\nhasta que alguien se preguntara por qué su formulario no guarda.","examples":[{"buffer_minutes":10,"slot_interval_minutes":30},{"timezone":"Pacific/Easter"}]},"BusinessSummary":{"properties":{"id":{"type":"string","format":"uuid","title":"Id","description":"Identificador del negocio."},"slug":{"type":"string","title":"Slug","description":"Identificador del local en la URL del marketplace.","examples":["barberia-nunoa"]},"name":{"type":"string","title":"Name","description":"Nombre comercial del local."},"address":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Address","description":"Dirección de la cita presencial; `null` si el local no la publicó."},"comuna":{"type":"string","title":"Comuna","description":"Comuna del local."},"timezone":{"type":"string","title":"Timezone","description":"Zona horaria IANA del negocio. Es la zona en la que están expresados `starts_at`, `ends_at` y `created_at` de la reserva.","examples":["America/Santiago"]}},"type":"object","required":["id","slug","name","comuna","timezone"],"title":"BusinessSummary","description":"El local, reducido a lo que hace falta para volver a él y llegar hasta él.\n\nSeis campos: el `id` y el `slug` para enlazar la ficha del marketplace, el\n`name` y la `address` para pintar la tarjeta, la `comuna` para agrupar y la\n`timezone` para que el cliente sepa en qué zona están expresados los\ninstantes de la reserva. Ni galería, ni descripción, ni valoración: eso es\nla ficha pública (`BusinessPublic`, F3-T10) y aquí sobraría en cada fila del\nhistorial.","examples":[{"address":"Av. Irarrázaval 1234","comuna":"Ñuñoa","id":"3f2b9a10-0000-4000-8000-aaaaaaaaaaaa","name":"Barbería Ñuñoa","slug":"barberia-nunoa","timezone":"America/Santiago"}]},"BusinessUpdate":{"properties":{"name":{"anyOf":[{"type":"string","maxLength":120,"minLength":3},{"type":"null"}],"title":"Name","description":"Nombre comercial del local, tal y como lo verá el cliente."},"description":{"anyOf":[{"type":"string","maxLength":1000},{"type":"null"}],"title":"Description","description":"Texto libre de presentación del local. Opcional."},"address":{"anyOf":[{"type":"string","maxLength":200,"minLength":3},{"type":"null"}],"title":"Address","description":"Dirección de calle y número. Se muestra en la ficha pública."},"comuna":{"anyOf":[{"type":"string","maxLength":80,"minLength":2},{"type":"null"}],"title":"Comuna","description":"Comuna del local. Es un **valor cerrado**: tiene que existir en `GET /api/v1/public/comunas`, y se compara sin tildes ni mayúsculas (`ñuñoa`, `NUNOA` y ` Ñuñoa ` valen igual). Se guarda siempre con la grafía canónica del catálogo. Una comuna que no exista responde `422` con code `UNKNOWN_COMUNA` y una lista de sugerencias en `details.suggestions`."},"lat":{"anyOf":[{"type":"number","maximum":90.0,"minimum":-90.0},{"type":"null"}],"title":"Lat","description":"Latitud en grados decimales (WGS 84)."},"lng":{"anyOf":[{"type":"number","maximum":180.0,"minimum":-180.0},{"type":"null"}],"title":"Lng","description":"Longitud en grados decimales (WGS 84)."},"is_published":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Published","description":"Publica el negocio en el marketplace (`/api/v1/public/*`) o lo retira de él sin borrarlo."}},"type":"object","title":"BusinessUpdate","description":"Edición parcial: solo se toca lo que venga en el cuerpo.\n\n`extra='ignore'` a propósito. El campo que más se cuela es `slug` —el\nfrontend reenvía el `BusinessOut` que acaba de leer—, y es justo el que no se\npuede cambiar: ignorarlo devuelve 200 con la fila intacta en vez de un 422\nque obligaría a cada cliente a podar el objeto antes de enviarlo.","examples":[{"is_published":true,"name":"Barbería Ñuñoa Centro"}]},"CalendarBooking":{"properties":{"id":{"type":"string","format":"uuid","title":"Id","description":"Identificador de la reserva. No cambia al moverla."},"starts_at":{"type":"string","format":"date-time","title":"Starts At","description":"Inicio de la cita en **hora local del negocio**, ISO 8601 con offset (`-03:00` o `-04:00` en Chile, según la fecha)."},"ends_at":{"type":"string","format":"date-time","title":"Ends At","description":"Fin de la cita, también con offset. Se deriva de la duración del servicio."},"staff_member_id":{"type":"string","format":"uuid","title":"Staff Member Id","description":"Plaza del equipo en la que cae la cita: la columna del calendario."},"staff_name":{"type":"string","title":"Staff Name","description":"Nombre con el que el local presenta a esa persona.","examples":["Ana"]},"service":{"$ref":"#/components/schemas/ServiceSummary","description":"Prestación reservada, con su duración, su tarifa actual y `requires_prepayment`, para saber si a la cita le falta un cobro."},"customer_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Customer Name","description":"Nombre con el que se apuntó al cliente. Puede ser `null` si la cuenta que reservó nunca lo declaró.","examples":["Javiera Rojas"]},"customer_email_masked":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Customer Email Masked","description":"Correo del cliente **ofuscado**, o `null` si no dejó ninguno. La agenda es una lista que ve toda la plantilla: basta para reconocer a quien ya vino, no para leérselo a nadie (SEC-01).","examples":["j***@example.com"]},"customer_phone_masked":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Customer Phone Masked","description":"Teléfono del cliente ofuscado, o `null` si no dejó ninguno.","examples":["+56 9 ****5678"]},"status":{"$ref":"#/components/schemas/BookingStatus","description":"Estado del ciclo de vida: `PENDING`, `CONFIRMED`, `PAID`, `CANCELLED` o `NO_SHOW`. Los dos últimos son terminales."},"source":{"$ref":"#/components/schemas/BookingSource","description":"Por dónde entró la cita: `WEB` desde el marketplace, `DASHBOARD` si la apuntó el propio local, `AGENT` si la creó un agente autónomo."},"price_clp":{"type":"integer","title":"Price Clp","description":"Importe **pactado** al reservar, en pesos chilenos enteros.","examples":[15000]}},"type":"object","required":["id","starts_at","ends_at","staff_member_id","staff_name","service","status","source","price_clp"],"title":"CalendarBooking","description":"Una cita tal y como la pinta el calendario del negocio.\n\nEs la respuesta de las **seis** operaciones de la agenda: listar, apuntar,\nmover, confirmar, anular y marcar la ausencia. Que las seis devuelvan la\nmisma forma es lo que permite al panel reemplazar la fila en su estado local\ncon lo que responde la mutación, sin recargar el calendario entero.","examples":[{"customer_email_masked":"j***@example.com","customer_name":"Javiera Rojas","customer_phone_masked":"+56 9 ****5678","ends_at":"2026-03-10T09:30:00-03:00","id":"3f2b9a10-0000-4000-8000-dddddddddddd","price_clp":15000,"service":{"duration_minutes":30,"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","modality":"IN_PERSON","name":"Corte de pelo","price_clp":15000,"requires_prepayment":false},"source":"DASHBOARD","staff_member_id":"3f2b9a10-0000-4000-8000-cccccccccccc","staff_name":"Ana","starts_at":"2026-03-10T09:00:00-03:00","status":"CONFIRMED"}]},"CheckoutOut":{"properties":{"payment_id":{"type":"string","format":"uuid","title":"Payment Id","description":"Identificador del intento de cobro en Lukin. Dos peticiones seguidas sobre la misma reserva devuelven **el mismo** valor mientras el checkout siga vigente."},"checkout_url":{"type":"string","title":"Checkout Url","description":"URL de Checkout Pro a la que redirigir al comprador (`init_point` de la preferencia). Los datos de la tarjeta no pasan por Lukin."},"expires_at":{"type":"string","format":"date-time","title":"Expires At","description":"Instante en que caduca esta preferencia de pago. Después hay que volver a pedir el checkout, que creará uno nuevo."},"amount_clp":{"type":"integer","title":"Amount Clp","description":"Importe a cobrar, en pesos chilenos enteros. Es el precio congelado en la reserva, no el que tenga hoy el catálogo."}},"type":"object","required":["payment_id","checkout_url","expires_at","amount_clp"],"title":"CheckoutOut","description":"A dónde mandar al comprador y hasta cuándo sirve ese enlace.","examples":[{"amount_clp":15000,"checkout_url":"https://www.mercadopago.cl/checkout/v1/redirect?pref_id=1234567890-abcd","expires_at":"2026-03-09T18:30:00Z","payment_id":"8c1f0e64-0000-4000-8000-aaaaaaaaaaaa"}]},"ClientBookingCreate":{"properties":{"business_slug":{"type":"string","maxLength":60,"minLength":3,"title":"Business Slug","description":"Identificador del local en la URL del marketplace. Debe estar publicado."},"service_id":{"type":"string","format":"uuid","title":"Service Id","description":"Servicio activo del catálogo del local. Su duración define el largo de la cita y su precio se congela en la reserva."},"staff_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Staff Id","description":"Persona concreta con la que se quiere la cita. Omitido, el motor reparte entre quienes prestan el servicio y estén libres."},"starts_at":{"type":"string","format":"date-time","title":"Starts At","description":"Inicio de la cita **con zona horaria**. Una hora ingenua responde `422`: sin offset no se sabe si son las nueve de Santiago o de Madrid.","examples":["2026-03-10T09:00:00-03:00"]}},"type":"object","required":["business_slug","service_id","starts_at"],"title":"ClientBookingCreate","description":"Alta de una reserva desde la cuenta del cliente.\n\nNo lleva datos de contacto —los pone la sesión— ni `accept_privacy`: quien\ntiene cuenta ya consintió al crearla (F2-T04), y volver a pedírselo en cada\nreserva convertiría una prueba oponible en una casilla de trámite. Esa es la\núnica diferencia real con `GuestBookingCreate` (F4-T05).","examples":[{"business_slug":"barberia-nunoa","service_id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","staff_id":"3f2b9a10-0000-4000-8000-cccccccccccc","starts_at":"2026-03-10T09:00:00-03:00"}]},"ComunaOut":{"properties":{"name":{"type":"string","title":"Name","description":"Nombre canónico de la comuna, con tildes y mayúsculas. Es el valor exacto que hay que enviar en el campo `comuna` del perfil comercial."},"region":{"type":"string","title":"Region","description":"Región a la que pertenece la comuna."},"lat":{"type":"number","title":"Lat","description":"Latitud del centroide de la comuna (WGS 84)."},"lng":{"type":"number","title":"Lng","description":"Longitud del centroide de la comuna (WGS 84)."}},"type":"object","required":["name","region","lat","lng"],"title":"ComunaOut","description":"Una comuna del catálogo cerrado de Chile.","examples":[{"lat":-33.527298,"lng":-70.541309,"name":"La Florida","region":"Metropolitana de Santiago"}]},"ConsentCreate":{"properties":{"consent_type":{"$ref":"#/components/schemas/ConsentType","description":"`TERMS` y `PRIVACY_POLICY` son obligatorios para usar el servicio; `MARKETING` es opcional y revocable."},"policy_version":{"type":"string","maxLength":32,"minLength":1,"title":"Policy Version","description":"Versión del texto que se acepta, tal y como la publica `GET /api/v1/public/legal/{privacy,terms}`. `MARKETING` usa la versión de la política de privacidad.","examples":["2026-08-01"]}},"type":"object","required":["consent_type","policy_version"],"title":"ConsentCreate","description":"Aceptación de un texto legal por parte del titular de la sesión.\n\n`policy_version` viaja en el cuerpo **a propósito**, en vez de darla por\nsupuesta en el servidor: es la forma de saber que el cliente enseñó la misma\nversión que la plataforma exige hoy. Si no coinciden, la respuesta es\n`422 POLICY_VERSION_OUTDATED` y no se guarda nada.","examples":[{"consent_type":"PRIVACY_POLICY","policy_version":"2026-08-01"},{"consent_type":"MARKETING","policy_version":"2026-08-01"}]},"ConsentOut":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"consent_type":{"$ref":"#/components/schemas/ConsentType"},"policy_version":{"type":"string","title":"Policy Version","description":"Versión del texto que aceptó el titular."},"granted_at":{"type":"string","format":"date-time","title":"Granted At","description":"Instante en que se otorgó, en UTC."},"revoked_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Revoked At","description":"Instante en que el titular lo retiró, o `null` si sigue vigente. Solo `MARKETING` se puede revocar."},"ip":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ip","description":"IP desde la que se otorgó."},"user_agent":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"User Agent","description":"Agente con el que se otorgó."}},"type":"object","required":["id","consent_type","policy_version","granted_at"],"title":"ConsentOut","description":"Prueba de un consentimiento, con su trazabilidad y su revocación.","examples":[{"consent_type":"MARKETING","granted_at":"2026-08-15T14:32:07.512Z","id":"6f9619ff-8b86-d011-b42d-00c04fc964ff","ip":"198.51.100.9","policy_version":"2026-08-01","user_agent":"Mozilla/5.0"}]},"ConsentType":{"type":"string","enum":["PRIVACY_POLICY","TERMS","MARKETING"],"title":"ConsentType","description":"Consentimiento otorgado por el titular (SEC-01)."},"DailyBookings":{"properties":{"date":{"type":"string","format":"date","title":"Date","description":"Día local del negocio.","examples":["2026-03-09"]},"count":{"type":"integer","title":"Count","description":"Citas que empiezan ese día, **en cualquier estado**: es la misma cuenta que suma `bookings_total`, repartida por fecha.","examples":[5]}},"type":"object","required":["date","count"],"title":"DailyBookings","description":"Las citas de un día del periodo. Un día sin ninguna vale `0`, no falta.","examples":[{"count":5,"date":"2026-03-09"},{"count":4,"date":"2026-03-10"},{"count":6,"date":"2026-03-11"},{"count":3,"date":"2026-03-12"},{"count":6,"date":"2026-03-13"},{"count":0,"date":"2026-03-14"},{"count":0,"date":"2026-03-15"}]},"DashboardBookingCreate":{"properties":{"service_id":{"type":"string","format":"uuid","title":"Service Id","description":"Servicio activo del catálogo del local. Su duración define el largo de la cita y su precio se congela en la reserva."},"staff_id":{"type":"string","format":"uuid","title":"Staff Id","description":"Persona que atenderá la cita. Obligatoria: la agenda del panel se apunta por columnas. Con membresía `STAFF` solo se admite la propia."},"starts_at":{"type":"string","format":"date-time","title":"Starts At","description":"Inicio de la cita **con zona horaria**. Una hora ingenua responde `422 DATETIME_NAIVE`. No se exige antelación mínima ni que la hora esté en la rejilla publicada: el mostrador encaja donde le quepa.","examples":["2026-03-10T09:00:00-03:00"]},"customer":{"$ref":"#/components/schemas/DashboardCustomer","description":"Datos de contacto de quien viene."}},"additionalProperties":false,"type":"object","required":["service_id","staff_id","starts_at","customer"],"title":"DashboardBookingCreate","description":"Cita apuntada a mano desde el panel.\n\n`staff_id` es **obligatorio**, al revés que en las dos altas de cara al\ncliente: quien apunta la cita tiene el calendario delante y ya eligió la\ncolumna, así que dejar que el motor reparta sería reasignarle la agenda a un\nnegocio que sabe mejor que nadie quién atiende. Además es el campo sobre el\nque se aplica el alcance por rol: un miembro con membresía `STAFF` solo\npuede apuntar en su propia plaza (`403 STAFF_SCOPE`).\n\nTampoco lleva `accept_privacy`: el cliente no está delante de ningún\nformulario y no puede consentir nada por él el local (SEC-01). Lo que el\npanel apunta es un dato de contacto para avisarle de su cita, y la prueba de\nconsentimiento la produce el propio cliente cuando entra por la vitrina.","examples":[{"customer":{"email":"javiera@example.com","full_name":"Javiera Rojas","phone":"+56912345678"},"service_id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","staff_id":"3f2b9a10-0000-4000-8000-cccccccccccc","starts_at":"2026-03-10T09:00:00-03:00"}]},"DashboardBookingUpdate":{"properties":{"starts_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Starts At","description":"Nuevo inicio de la cita, con zona horaria. Omitido, la cita conserva su hora. El `ends_at` se vuelve a derivar de la duración del servicio y **no** se envía.","examples":["2026-03-10T11:00:00-03:00"]},"staff_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Staff Id","description":"Persona destino. Omitida, la cita se queda con quien ya la atendía. Con membresía `STAFF` no se admite ninguna otra plaza que la propia (`403 STAFF_SCOPE`): reasignar la agenda del equipo es del dueño."}},"additionalProperties":false,"type":"object","title":"DashboardBookingUpdate","description":"Mover una cita de hora, de persona, o de las dos cosas.\n\nLos dos campos son opcionales **por separado** pero no a la vez: un `PATCH`\nvacío no es una edición parcial, es una petición sin contenido. Enviar solo\n`staff_id` mueve la cita de columna conservando la hora; enviar solo\n`starts_at`, al revés.","examples":[{"staff_id":"3f2b9a10-0000-4000-8000-cccccccccccc","starts_at":"2026-03-10T11:00:00-03:00"}]},"DashboardCustomer":{"properties":{"full_name":{"type":"string","maxLength":120,"minLength":2,"title":"Full Name","description":"Nombre con el que el local atenderá la cita.","examples":["Javiera Rojas"]},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email","description":"Correo de contacto. Se normaliza a minúsculas antes de guardarlo; una dirección ilegible responde `422 INVALID_EMAIL`. Opcional si se anota el teléfono.","examples":["javiera@example.com"]},"phone":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Phone","description":"Teléfono móvil. Se acepta como se escriba (`9 1234 5678`, `+56 9 1234 5678`) y se normaliza a E.164 asumiendo Chile si no trae prefijo; un número imposible responde `422 INVALID_PHONE`. Opcional si se anota el correo.","examples":["+56912345678"]}},"additionalProperties":false,"type":"object","required":["full_name"],"title":"DashboardCustomer","description":"Quién viene, apuntado por el mostrador.\n\nA diferencia de `GuestCustomer` (F4-T05), que exige **los dos** contactos\nporque el canal del código de confirmación lo decide el despliegue, aquí\nbasta **uno**: no hay ningún reto que enviar —la reserva nace confirmada— y\nquien está al teléfono muchas veces solo deja el número. Lo que no se admite\nes **ninguno**: sin contacto no hay forma de avisar de un cambio de hora, y\nla fila `users` tampoco lo permitiría (`ck_users_contact_required`, F1-T04).\n\nLos dos datos se normalizan en la frontera con los normalizadores de\n`user_service` (ADR-019, la única puerta del proyecto), de modo que llegan\ncanónicos a `get_or_create_guest` y el mismo cliente apuntado desde el panel\ny desde la vitrina resuelve a **una sola** fila.","examples":[{"email":"javiera@example.com","full_name":"Javiera Rojas","phone":"+56912345678"}]},"DayOut":{"properties":{"date":{"type":"string","title":"Date","description":"El día en ISO (`2026-09-15`), en la zona horaria del negocio que indica `timezone`."},"slots":{"items":{"$ref":"#/components/schemas/AgentSlotOut"},"type":"array","title":"Slots","description":"Huecos libres de ese día, en orden de hora. Vacío significa «ese día no queda nada»: el día sigue apareciendo para que se vea que se miró."}},"type":"object","required":["date","slots"],"title":"DayOut","description":"Un día del rango con sus huecos, aunque no tenga ninguno."},"ErasureAccepted":{"properties":{"message":{"type":"string","title":"Message","description":"Texto para el titular, ya en pasado: la baja está hecha."},"anonymized_at":{"type":"string","format":"date-time","title":"Anonymized At","description":"Instante en que la cuenta quedó anonimizada, en UTC. Es el mismo valor que guarda `users.anonymized_at` y sirve de acuse ante una reclamación."}},"type":"object","required":["message","anonymized_at"],"title":"ErasureAccepted","description":"Acuse del borrado ya ejecutado: qué pasó y cuándo.","examples":[{"anonymized_at":"2026-08-31T14:32:07.512Z","message":"Tu cuenta se eliminó. Conservamos tus reservas y cobros sin ningún dato que te identifique."}]},"ErasureRequest":{"properties":{"confirm":{"type":"string","const":"ELIMINAR","title":"Confirm","description":"Escribe exactamente `ELIMINAR`. Cualquier otro texto —o su ausencia— responde `422 CONFIRMATION_MISMATCH` y no borra nada.","examples":["ELIMINAR"]}},"type":"object","required":["confirm"],"title":"ErasureRequest","description":"Confirmación escrita del borrado por la vía con sesión (SEC-01).\n\nUn cuerpo de un solo campo, y ese campo es una palabra que hay que teclear.\nNo es burocracia: el borrado es **irreversible**, así que un `POST` suelto\n—el que dispara un doble clic, o un enlace visitado por error— no puede\nbastar para ejecutarlo.","examples":[{"confirm":"ELIMINAR"}]},"ErrorResponse":{"description":"Contrato de error de la API.\n\nLos cinco campos están **siempre** presentes; `details` y `hint` valen\n`null` cuando no aplican, de modo que los clientes (incluidos los agentes de\nla Fase 8) puedan leerlos sin comprobar su existencia.","properties":{"code":{"description":"Identificador estable y legible por máquina del error (`NOT_FOUND`, `CONFLICT`, `REFRESH_REUSED`…). Es el campo sobre el que un cliente debe ramificar; nunca el `message`.","examples":["NOT_FOUND"],"title":"Code","type":"string"},"message":{"description":"Descripción en español, apta para mostrar a una persona.","examples":["El recurso solicitado no existe."],"title":"Message","type":"string"},"details":{"anyOf":[{"additionalProperties":true,"type":"object"},{"items":{},"type":"array"},{"type":"null"}],"default":null,"description":"Contexto estructurado del error. En los errores de validación es la lista `[{loc, msg, type}]` de FastAPI.","title":"Details"},"hint":{"anyOf":[{"type":"string"},{"type":"null"}],"default":null,"description":"Sugerencia accionable para resolver el error, si la hay.","title":"Hint"},"request_id":{"description":"Identificador de la petición, idéntico a la cabecera `X-Request-ID` de la respuesta. Es la clave para localizar el traceback en los logs del servidor.","examples":["3f2b9a10-0000-4000-8000-abcdefabcdef"],"title":"Request Id","type":"string"}},"required":["code","message","request_id"],"title":"ErrorResponse","type":"object"},"ExportBooking":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"business":{"$ref":"#/components/schemas/ExportBusinessRef"},"service":{"$ref":"#/components/schemas/ExportServiceRef"},"staff_name":{"type":"string","title":"Staff Name","description":"`display_name` de quien atendió. Es lo **único** que se publica de esa persona: ni su correo, ni su teléfono, ni su identificador."},"starts_at":{"type":"string","format":"date-time","title":"Starts At"},"ends_at":{"type":"string","format":"date-time","title":"Ends At"},"status":{"$ref":"#/components/schemas/BookingStatus"},"price_clp":{"type":"integer","title":"Price Clp","description":"Precio pactado en pesos chilenos (ADR-016)."},"source":{"$ref":"#/components/schemas/BookingSource","description":"Por dónde entró la reserva."},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["id","business","service","staff_name","starts_at","ends_at","status","price_clp","source","created_at"],"title":"ExportBooking","description":"Una cita del titular, con el precio que se le congeló al reservar."},"ExportBusinessRef":{"properties":{"slug":{"type":"string","title":"Slug","description":"Identificador del local en `/negocio/{slug}`."},"name":{"type":"string","title":"Name","description":"Nombre comercial."}},"type":"object","required":["slug","name"],"title":"ExportBusinessRef","description":"El local de una reserva, con lo que ya es público en el marketplace."},"ExportMembership":{"properties":{"business_slug":{"type":"string","title":"Business Slug"},"role":{"$ref":"#/components/schemas/UserRole","description":"`OWNER` si es su propietario, `STAFF` si figura en la plantilla."}},"type":"object","required":["business_slug","role"],"title":"ExportMembership","description":"Relación del titular con un negocio: propiedad o plantilla."},"ExportPayment":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"booking_id":{"type":"string","format":"uuid","title":"Booking Id"},"amount_clp":{"type":"integer","title":"Amount Clp"},"status":{"$ref":"#/components/schemas/PaymentStatus"},"provider_payment_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider Payment Id","description":"Identificador del cobro en la pasarela, si llegó."},"paid_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Paid At","description":"Cuándo se acreditó, o `null`."}},"type":"object","required":["id","booking_id","amount_clp","status"],"title":"ExportPayment","description":"Un intento de cobro de una reserva del titular."},"ExportReview":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"booking_id":{"type":"string","format":"uuid","title":"Booking Id"},"rating":{"type":"integer","title":"Rating","description":"Estrellas, de 1 a 5."},"comment":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Comment"},"published_at":{"type":"string","format":"date-time","title":"Published At"}},"type":"object","required":["id","booking_id","rating","published_at"],"title":"ExportReview","description":"Una valoración escrita por el titular."},"ExportServiceRef":{"properties":{"name":{"type":"string","title":"Name","description":"Nombre del servicio en el momento de la exportación."}},"type":"object","required":["name"],"title":"ExportServiceRef","description":"La prestación reservada. Solo el nombre: el precio pactado va en la reserva."},"ExportUser":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email","description":"Correo del titular, o `null`."},"full_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Full Name","description":"Nombre con el que figura."},"phone":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Phone","description":"Teléfono en E.164 (`users.phone_e164`), o `null`."},"role":{"$ref":"#/components/schemas/UserRole","description":"Rol en la plataforma."},"created_at":{"type":"string","format":"date-time","title":"Created At","description":"Alta de la cuenta, en UTC."}},"type":"object","required":["id","role","created_at"],"title":"ExportUser","description":"Ficha del titular. Es el **único** sitio del documento con datos de contacto."},"GalleryItemOut":{"properties":{"url":{"type":"string","title":"Url","description":"URL pública de la foto recién subida.","examples":["/media/businesses/9f1c.../gallery/2ad9....jpg"]},"gallery_urls":{"items":{"type":"string"},"type":"array","title":"Gallery Urls","description":"Galería completa del negocio, en orden de subida. La última posición es la foto de esta respuesta."}},"type":"object","required":["url","gallery_urls"],"title":"GalleryItemOut","description":"La foto añadida y la galería completa tras añadirla."},"GuestBookingConfirm":{"properties":{"code":{"type":"string","maxLength":4,"minLength":4,"pattern":"^\\d+$","title":"Code","description":"Código de 4 dígitos recibido por correo o SMS.","examples":["0000"]},"accept_privacy":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Accept Privacy","description":"Aceptación expresa de los Términos y de la Política de Privacidad, para la reserva que abrió un agente en tu nombre. Es obligatorio —y tiene que valer `true`— cuando la reserva tiene `source=AGENT` y el único consentimiento que consta es el que **declaró** el agente; en cualquier otro caso sobra y se ignora."}},"type":"object","required":["code"],"title":"GuestBookingConfirm","description":"Canje del código de confirmación.","examples":[{"code":"0000"}]},"GuestBookingConfirmed":{"properties":{"status":{"type":"string","const":"CONFIRMED","title":"Status","description":"Siempre `CONFIRMED`: es el estado que deja esta operación.","default":"CONFIRMED"},"manage_token":{"type":"string","title":"Manage Token","description":"JWT `purpose=booking_manage` con el que el invitado gestiona **esta** reserva sin cuenta. No abre sesión: como token de propósito, `decode_access_token` lo rechaza."},"manage_url":{"type":"string","title":"Manage Url","description":"Enlace absoluto a la reserva en el marketplace, con el token incluido."},"requires_payment":{"type":"boolean","title":"Requires Payment","description":"`true` cuando todavía falta pagar para asegurar la cita: el servicio exige prepago **y** el negocio tiene cuenta de cobro conectada. Si exige prepago pero el negocio no puede cobrar, la reserva se confirma igual y esto vale `false`.","default":false},"payment_checkout_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Payment Checkout Url","description":"Checkout de Mercado Pago al que enviar al cliente. Viene con valor exactamente cuando `requires_payment` es `true`."},"payment_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Payment Id","description":"Identificador del intento de cobro creado junto con la confirmación, con el que consultar su estado. `null` si no hay nada que pagar."}},"type":"object","required":["manage_token","manage_url"],"title":"GuestBookingConfirmed","description":"Reserva confirmada, credencial de vuelta y —si toca— el paso de pago.\n\nLos tres campos de pago van juntos y se leen como uno solo: `requires_payment`\ndice si todavía falta pagar, y `payment_checkout_url` y `payment_id` traen el\ncheckout ya creado **en esta misma petición** cuando la respuesta es que sí.\nSe resuelven aquí y no en una segunda llamada porque el invitado acaba de\nteclear su código y está mirando la pantalla: mandarlo a pedir el enlace por\nsu cuenta añadiría un viaje que puede fallar justo cuando ya decidió pagar.\n\nQue haya prepago lo decide `service.requires_prepayment` (F4-T06), pero\npoder cobrarlo lo decide la bóveda del negocio (F5-T06): un local con prepago\nconfigurado y sin cuenta de Mercado Pago conectada confirma la reserva igual\ny responde `requires_payment: false`, porque no hay checkout que ofrecer. La\ncita no se pierde por un ajuste que el dueño no terminó.","examples":[{"manage_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...","manage_url":"http://localhost:3000/reserva/3f2b9a10-0000-4000-8000-dddddddddddd?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...","payment_checkout_url":"https://www.mercadopago.cl/checkout/v1/redirect?pref_id=1234567890-abcd","payment_id":"8c1f0e64-0000-4000-8000-aaaaaaaaaaaa","requires_payment":true,"status":"CONFIRMED"}]},"GuestBookingConsent":{"properties":{"accept_privacy":{"type":"boolean","title":"Accept Privacy","description":"Aceptación expresa de la Política de Privacidad vigente. Con `false` no se registra nada y la respuesta es `422 CONSENT_REQUIRED`."},"accept_terms":{"type":"boolean","title":"Accept Terms","description":"Aceptación expresa de los Términos y Condiciones vigentes. Con `false` no se registra nada y la respuesta es `422 CONSENT_REQUIRED`."}},"type":"object","required":["accept_privacy","accept_terms"],"title":"GuestBookingConsent","description":"Aceptación expresa del titular sobre una pre-reserva abierta por un agente.\n\nLos dos campos son `bool` y no `Literal[True]` a propósito, al revés que en\n`GuestBookingCreate`: aquí un `false` **es** una respuesta del titular —«no\nacepto»— y merece el `422 CONSENT_REQUIRED` del contrato, con su `code` y su\n`hint`, en vez del error de validación genérico que produciría un tipo que\nni siquiera admite el valor. La diferencia importa para quien pinta la\npantalla: un `code` sobre el que ramificar, no un `loc` que interpretar.\n\nVan los dos y no uno solo porque son dos documentos distintos\n(`consent_service.REQUIRED_CONSENT_TYPES`) y la ley no admite dar por\naceptado el que no se preguntó.","examples":[{"accept_privacy":true,"accept_terms":true}]},"GuestBookingCreate":{"properties":{"business_slug":{"type":"string","maxLength":60,"minLength":3,"title":"Business Slug","description":"Identificador del local en la URL del marketplace. Debe estar publicado."},"service_id":{"type":"string","format":"uuid","title":"Service Id","description":"Servicio activo del catálogo del local. Su duración define el largo de la cita y su precio se congela en la reserva."},"staff_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Staff Id","description":"Persona concreta con la que se quiere la cita. Omitido, el motor reparte entre quienes prestan el servicio y estén libres."},"starts_at":{"type":"string","format":"date-time","title":"Starts At","description":"Inicio de la cita **con zona horaria**. Una hora ingenua responde `422`: sin offset no se sabe si son las nueve de Santiago o de Madrid.","examples":["2026-03-10T09:00:00-03:00"]},"customer":{"$ref":"#/components/schemas/GuestCustomer","description":"Datos de contacto de quien reserva."},"accept_privacy":{"type":"boolean","const":true,"title":"Accept Privacy","description":"Aceptación expresa de los Términos y de la Política de Privacidad. Solo se admite `true`: sin ella no hay reserva."}},"type":"object","required":["business_slug","service_id","starts_at","customer","accept_privacy"],"title":"GuestBookingCreate","description":"Alta de una reserva sin cuenta desde la vitrina.","examples":[{"accept_privacy":true,"business_slug":"barberia-nunoa","customer":{"email":"javiera@example.com","full_name":"Javiera Rojas","phone":"+56912345678"},"service_id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","staff_id":"3f2b9a10-0000-4000-8000-cccccccccccc","starts_at":"2026-03-10T09:00:00-03:00"}]},"GuestBookingDetail":{"properties":{"id":{"type":"string","format":"uuid","title":"Id","description":"Identificador de la reserva. No cambia al reprogramarla."},"status":{"$ref":"#/components/schemas/BookingStatus","description":"Estado del ciclo de vida: `PENDING`, `CONFIRMED`, `PAID`, `CANCELLED` o `NO_SHOW`. Los dos últimos son terminales."},"source":{"$ref":"#/components/schemas/BookingSource","description":"Por dónde entró la cita: `WEB` desde el marketplace, `DASHBOARD` si la apuntó el propio local, `AGENT` si la creó un agente autónomo."},"starts_at":{"type":"string","format":"date-time","title":"Starts At","description":"Inicio de la cita en **hora local del negocio**, ISO 8601 con offset (`-03:00` o `-04:00` en Chile, según la fecha)."},"ends_at":{"type":"string","format":"date-time","title":"Ends At","description":"Fin de la cita, también con offset. Se deriva de la duración del servicio y viaja resuelto para no obligar al cliente a sumarla."},"price_clp":{"type":"integer","title":"Price Clp","description":"Importe **pactado** al reservar, en pesos chilenos enteros. Es una foto: subir la tarifa del catálogo no reescribe lo ya acordado.","examples":[15000]},"business":{"$ref":"#/components/schemas/BusinessSummary","description":"Local donde es la cita."},"service":{"$ref":"#/components/schemas/ServiceSummary","description":"Prestación reservada."},"staff":{"$ref":"#/components/schemas/StaffSummary","description":"Persona que atenderá la cita."},"cancelled_reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cancelled Reason","description":"Motivo de la anulación, o `null` si la reserva sigue viva. El valor `EXPIRED` lo escribe el sistema cuando una `PENDING` agota su plazo."},"can_cancel":{"type":"boolean","title":"Can Cancel","description":"`true` si el cliente todavía puede anularla **por su cuenta**: el estado lo permite y no ha entrado la ventana de cancelación del local (`cancellation_window_hours`). Con `false`, `POST /cancel` responde `409 INVALID_TRANSITION` o `422 CANCELLATION_WINDOW_CLOSED`."},"can_reschedule":{"type":"boolean","title":"Can Reschedule","description":"`true` si el cliente todavía puede moverla de hora por su cuenta. Se calcula con la misma ventana que `can_cancel`, medida siempre sobre el `starts_at` vigente."},"has_review":{"type":"boolean","title":"Has Review","description":"`true` si esta visita ya tiene una valoración escrita. Con `true`, `POST /api/v1/reviews` responde `409 REVIEW_EXISTS`: la pantalla no debe ofrecer el formulario, o se pierde lo que la persona escriba."},"created_at":{"type":"string","format":"date-time","title":"Created At","description":"Cuándo se creó la reserva, en hora local del negocio."},"manage_token":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Manage Token","description":"Enlace renovado, y **casi siempre `null`**. El token se emitió con `exp = ends_at + 7 días` sobre la cita original, así que al reprogramar hacia adelante puede quedarse corto: cuando eso ocurre, la respuesta de `POST /reschedule` trae aquí uno nuevo que hay que guardar en lugar del anterior. El de la URL sigue siendo válido hasta su propio vencimiento: reprogramar no revoca nada."}},"type":"object","required":["id","status","source","starts_at","ends_at","price_clp","business","service","staff","can_cancel","can_reschedule","has_review","created_at"],"title":"GuestBookingDetail","description":"La reserva del invitado, tal y como la devuelven las tres rutas del enlace.\n\nEs `BookingOut` (F4-T06) **más un campo**, y nada más: mismo `id`, mismos\nresúmenes de local, servicio y persona, mismas horas en la zona del negocio\ny los mismos `can_cancel` / `can_reschedule`. Quien reserva sin cuenta no ve\nuna reserva distinta de la de quien tiene cuenta; solo llega por otra puerta.\n\nEl nombre importa: `GuestBookingOut` (F4-T05) es la respuesta del **alta** y\nno tiene nada que ver con esta. Aquella dice «reserva apartada, mira tu\ncorreo»; esta es el detalle completo de una reserva ya confirmada.","examples":[{"business":{"address":"Av. Irarrázaval 1234","comuna":"Ñuñoa","id":"3f2b9a10-0000-4000-8000-aaaaaaaaaaaa","name":"Barbería Ñuñoa","slug":"barberia-nunoa","timezone":"America/Santiago"},"can_cancel":true,"can_reschedule":true,"created_at":"2026-03-05T18:42:11-03:00","ends_at":"2026-03-10T09:30:00-03:00","has_review":false,"id":"3f2b9a10-0000-4000-8000-dddddddddddd","price_clp":15000,"service":{"duration_minutes":30,"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","modality":"IN_PERSON","name":"Corte de pelo","price_clp":15000,"requires_prepayment":false},"source":"WEB","staff":{"display_name":"Ana","id":"3f2b9a10-0000-4000-8000-cccccccccccc"},"starts_at":"2026-03-10T09:00:00-03:00","status":"CONFIRMED"}]},"GuestBookingOut":{"properties":{"booking_id":{"type":"string","format":"uuid","title":"Booking Id","description":"Identificador de la reserva recién creada."},"status":{"type":"string","const":"PENDING","title":"Status","description":"Siempre `PENDING`: la reserva no se confirma hasta canjear el código.","default":"PENDING"},"otp_channel":{"$ref":"#/components/schemas/OtpChannel","description":"Canal por el que salió el código, según `BOOKING_OTP_CHANNEL`."},"otp_destination_masked":{"type":"string","title":"Otp Destination Masked","description":"Destino ofuscado del código.","examples":["j***@example.com","+56 9 ****5678"]},"starts_at":{"type":"string","format":"date-time","title":"Starts At","description":"Inicio de la cita."},"ends_at":{"type":"string","format":"date-time","title":"Ends At","description":"Fin de la cita, derivado de la duración del servicio."},"expires_at":{"type":"string","format":"date-time","title":"Expires At","description":"Hasta cuándo se guarda el hueco sin confirmar (`PENDING_TTL_MINUTES`, 15 minutos por defecto)."}},"type":"object","required":["booking_id","otp_channel","otp_destination_masked","starts_at","ends_at","expires_at"],"title":"GuestBookingOut","description":"Reserva creada y reto enviado: lo que la vitrina necesita para pedir el código.\n\n`otp_destination_masked` es lo único que se dice del destino del código:\nbasta para que el cliente compruebe que escribió bien su contacto y no sirve\npara leérselo a nadie.","examples":[{"booking_id":"3f2b9a10-0000-4000-8000-dddddddddddd","ends_at":"2026-03-10T12:30:00Z","expires_at":"2026-03-09T18:15:00Z","otp_channel":"EMAIL","otp_destination_masked":"j***@example.com","starts_at":"2026-03-10T12:00:00Z","status":"PENDING"}]},"GuestCustomer":{"properties":{"full_name":{"type":"string","maxLength":120,"minLength":2,"title":"Full Name","description":"Nombre y apellido con los que el local atenderá la cita."},"email":{"type":"string","format":"email","title":"Email","description":"Correo de contacto. Se normaliza a minúsculas antes de guardarlo."},"phone":{"type":"string","title":"Phone","description":"Teléfono móvil. Se acepta como se escriba (`9 1234 5678`, `+56 9 1234 5678`) y se normaliza a E.164 asumiendo Chile si no trae prefijo; un número imposible responde `422 INVALID_PHONE`.","examples":["+56912345678"]}},"type":"object","required":["full_name","email","phone"],"title":"GuestCustomer","description":"Quién reserva, cuando no hay sesión detrás.\n\nLos tres datos son obligatorios: el nombre para que el local sepa a quién\natiende, y **los dos** contactos porque el canal del reto lo decide el\ndespliegue (`BOOKING_OTP_CHANNEL`) y no el cliente. Pedir solo uno dejaría\nreservas que no se pueden confirmar el día que el operador cambiara el\ncanal a SMS.","examples":[{"email":"javiera@example.com","full_name":"Javiera Rojas","phone":"+56912345678"}]},"GuestOtpResent":{"properties":{"otp_channel":{"$ref":"#/components/schemas/OtpChannel","description":"Canal por el que salió el código nuevo."},"resend_available_at":{"type":"string","format":"date-time","title":"Resend Available At","description":"Instante a partir del cual se puede pedir otro código (`OTP_RESEND_COOLDOWN_SECONDS`, 60 s)."}},"type":"object","required":["otp_channel","resend_available_at"],"title":"GuestOtpResent","description":"Acuse del reenvío del código.\n\nNo repite el destino ofuscado: quien reenvía ya lo recibió en el alta, y\ndevolverlo otra vez multiplicaría las respuestas que hablan del contacto de\nun tercero por cada petición que alguien quiera lanzar.","examples":[{"otp_channel":"EMAIL","resend_available_at":"2026-03-09T18:01:00Z"}]},"HealthOut":{"properties":{"status":{"type":"string","enum":["ok","degraded"],"title":"Status","description":"`ok` si todas las dependencias responden; `degraded` si alguna falla."},"db":{"type":"string","enum":["ok","error"],"title":"Db","description":"Estado de la conexión con PostgreSQL comprobado con `SELECT 1`."}},"type":"object","required":["status","db"],"title":"HealthOut","description":"Resultado de la sonda de salud."},"HoursEntry":{"properties":{"weekday":{"type":"integer","maximum":6.0,"minimum":0.0,"title":"Weekday","description":"Día de la semana, **0 = lunes** … 6 = domingo, igual que `datetime.date.weekday()` en Python."},"opens_at":{"type":"string","format":"time","title":"Opens At","description":"Hora de apertura, en hora local del negocio (`HH:MM:SS`)."},"closes_at":{"type":"string","format":"time","title":"Closes At","description":"Hora de cierre, en hora local del negocio. Debe ser posterior a `opens_at`: un tramo no cruza la medianoche."}},"type":"object","required":["weekday","opens_at","closes_at"],"title":"HoursEntry","description":"Un tramo de apertura del local en un día de la semana.\n\n`opens_at` y `closes_at` son horas **locales del negocio**\n(`Business.timezone`), no UTC: el local abre a las nueve todo el año, y es\nel motor de disponibilidad quien las convierte a instantes absolutos día a\ndía, con el desfase horario que toque.","examples":[{"closes_at":"13:00:00","opens_at":"09:00:00","weekday":0},{"closes_at":"18:00:00","opens_at":"14:00:00","weekday":0},{"closes_at":"14:00:00","opens_at":"10:00:00","weekday":5}]},"HoursMatrix":{"items":{"$ref":"#/components/schemas/HoursEntry"},"type":"array","title":"HoursMatrix","description":"La semana completa del local: una lista de tramos, ordenada y sin choques.\n\nAl validar deja el contenido **ordenado por `(weekday, opens_at)`**, de modo\nque la respuesta del `PUT`, la del `GET` y la matriz que consume el motor de\ndisponibilidad tengan siempre el mismo orden y el frontend no tenga que\nreordenar nada para pintar la tabla.\n\nUna lista vacía es válida y significa **cerrado toda la semana**: es como se\nborra la matriz entera.","examples":[[{"closes_at":"13:00:00","opens_at":"09:00:00","weekday":0},{"closes_at":"18:00:00","opens_at":"14:00:00","weekday":0},{"closes_at":"14:00:00","opens_at":"10:00:00","weekday":5}],[]]},"LegalDocumentOut":{"properties":{"version":{"type":"string","title":"Version","description":"Versión vigente. Es el valor que hay que enviar en `policy_version` al registrar el consentimiento.","examples":["2026-08-01"]},"url":{"type":"string","title":"Url","description":"Enlace público al texto completo.","examples":["https://lukin.cl/privacidad"]},"effective_at":{"type":"string","format":"date","title":"Effective At","description":"Fecha desde la que rige esta versión (`YYYY-MM-DD`).","examples":["2026-08-01"]},"title":{"type":"string","title":"Title","description":"Nombre del documento.","examples":["Política de Privacidad"]}},"type":"object","required":["version","url","effective_at","title"],"title":"LegalDocumentOut","description":"Texto legal vigente: qué versión rige hoy y dónde leerla.","examples":[{"effective_at":"2026-08-01","title":"Política de Privacidad","url":"https://lukin.cl/privacidad","version":"2026-08-01"}]},"LoginRequest":{"properties":{"email":{"type":"string","format":"email","title":"Email","description":"Correo de la cuenta."},"password":{"type":"string","title":"Password","description":"Contraseña en claro."}},"type":"object","required":["email","password"],"title":"LoginRequest","description":"Credenciales de un login con contraseña.","examples":[{"email":"ada@lukin.cl","password":"una-contrasena-segura"}]},"LogoOut":{"properties":{"logo_url":{"type":"string","title":"Logo Url","description":"URL pública del logo. Sustituye a la anterior, que ya no existe.","examples":["/media/businesses/9f1c.../logo-0c7b....png"]}},"type":"object","required":["logo_url"],"title":"LogoOut","description":"URL pública del logo recién subido."},"MarketplaceQuotaOut":{"properties":{"limit":{"type":"integer","title":"Limit","description":"Reservas del marketplace que admite al mes el tramo contratado. Es el mismo número que `PlanOut.marketplace_bookings_monthly` publica para ese tramo, y viaja **también aquí** porque el catálogo se filtra: con `SUBSCRIPTION_FREE_PUBLIC` apagado, `GET /plans` no devuelve el tramo gratuito, de modo que el único negocio que no podía leer su propio tope era justo el que lo tiene.","examples":[30]},"used":{"type":"integer","title":"Used","description":"Reservas que Lukin le trajo a este negocio en el mes en curso: las de la ficha pública y las del canal agéntico. **Las citas que el negocio registra desde su panel no cuentan** y no tienen tope en ningún tramo. El mes es el natural **del negocio**, cortado en su zona horaria: en Chile continental, contarlo en UTC le regalaría al mes anterior las tres primeras horas del día 1. Es la misma cuenta que aplica la guarda del alta, no una aproximación.","examples":[22]},"remaining":{"type":"integer","minimum":0.0,"title":"Remaining","description":"Reservas que le quedan al negocio este mes, `limit - used` acotado a cero. Nunca es negativo: bajar el ajuste del tope a mitad de mes puede dejar `used` por encima de `limit`, y ahí lo que queda es cero, no una deuda. Con `0`, la ficha del local deja de ofrecer horas por internet hasta que empiece el mes siguiente; mover de hora una cita que ya existe sigue funcionando, porque reprogramar no gasta cupo.","examples":[8]}},"type":"object","required":["limit","used","remaining"],"title":"MarketplaceQuotaOut","description":"Cuánto queda del tope mensual de reservas del marketplace de este negocio.\n\n**Existe solo cuando el tramo contratado tiene tope.** En `SubscriptionOut`\nviaja como un objeto entero o como `null`, y no como tres campos sueltos, a\npropósito: así «este tramo no tiene tope» es **un** `null` y no una\ncombinación de nulos que un cliente pudiera recibir a medias. Los tres\nnúmeros o llegan juntos o no llega ninguno.\n\nVan los tres —y no solo uno— porque cada uno solo se entiende con los otros:\n«llevas 22» leído solo es una estadística, no un aviso, y «te quedan 8» leído\nsolo esconde el denominador, que es lo que decide si eso es mucho o poco.\nQuien pinte la pantalla elige la frase; el contrato no le obliga a restar.\n\nY `remaining` viaja calculado, en vez de dejar el `limit - used` al cliente,\nporque **puede salir negativo** y ahí la resta ingenua diría «te quedan −4\nreservas».\n\n`used > limit` no es una rareza: es el estado con el que **empieza** casi todo\nnegocio que llega al tramo gratuito. La prueba corre en el tramo más alto, que\nno tiene tope, y al caducar la fila cae al gratuito el mismo día\n(`subscription_service.DOWNGRADE_PLAN`) sin que el recuento del mes se\nreinicie: quien llenó su mes de prueba estrena el gratis con el cupo ya\npasado. La segunda vía es bajar el ajuste a mitad de mes, porque\n`plans.monthly_bookings` lo resuelve en el momento de preguntarlo. (Por la\nvía de las altas simultáneas **no** puede pasar: `booking_service` toma un\n`SELECT … FOR UPDATE` sobre la fila del negocio antes de contar, justamente\npara que dos reservas no gasten la misma última plaza del mes.)","examples":[{"limit":30,"remaining":8,"used":22}]},"MerchantCredentialIn":{"properties":{"access_token":{"type":"string","maxLength":512,"minLength":20,"format":"password","title":"Access Token","description":"Access token privado de la cuenta de Mercado Pago del negocio (`APP_USR-…` en producción, `TEST-…` en sandbox). Se valida contra la API del proveedor y se guarda **cifrado**: no vuelve a salir por ninguna respuesta.","writeOnly":true},"public_key":{"type":"string","maxLength":128,"minLength":8,"title":"Public Key","description":"Clave pública de la misma cuenta. No es secreta: la usa el checkout en el navegador, y la API la devuelve tal cual."},"webhook_secret":{"anyOf":[{"type":"string","maxLength":256,"minLength":8,"format":"password","writeOnly":true},{"type":"null"}],"format":"password","title":"Webhook Secret","description":"Secreto con el que **este vendedor** firma sus notificaciones (`x-signature`). Es opcional y también se guarda cifrado; sin él, el webhook valida con el secreto de la plataforma.","writeOnly":true}},"type":"object","required":["access_token","public_key"],"title":"MerchantCredentialIn","description":"Credencial que el vendedor conecta desde el dashboard.\n\n`extra='ignore'` sigue la convención de F3-T01: un dashboard que mande un\ncampo de más —o el propio `status`, que es de salida— no recibe un 422 por\nalgo que la API sencillamente no usa."},"MerchantCredentialOut":{"properties":{"status":{"type":"string","const":"CONNECTED","title":"Status","description":"Siempre `CONNECTED`: si el negocio no tiene credencial la API responde `404 MERCHANT_CREDENTIAL_NOT_FOUND`, no un estado vacío.","default":"CONNECTED"},"last4":{"type":"string","maxLength":4,"minLength":4,"title":"Last4","description":"Últimos cuatro caracteres del access token guardado. Es lo único que el dashboard puede mostrar para que el vendedor reconozca cuál de sus credenciales está conectada."},"mp_user_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Mp User Id","description":"Identificador de vendedor que devolvió Mercado Pago al validar el token."},"public_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Public Key","description":"Clave pública de la cuenta, tal y como se envió al conectar."},"has_webhook_secret":{"type":"boolean","title":"Has Webhook Secret","description":"`true` si la bóveda guarda además un secreto de firma propio del vendedor. El secreto en sí no se devuelve nunca."},"validated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Validated At","description":"Instante (UTC) de la última validación correcta contra Mercado Pago."}},"type":"object","required":["last4","has_webhook_secret"],"title":"MerchantCredentialOut","description":"Estado de la bóveda de un negocio, sin una sola pieza de material sensible."},"MetricsOut":{"properties":{"range":{"$ref":"#/components/schemas/MetricsRange","description":"Periodo efectivamente calculado y zona horaria que lo delimita."},"bookings_total":{"type":"integer","title":"Bookings Total","description":"Citas que empiezan dentro del periodo, **en cualquier estado**: es la suma de los cinco valores de `bookings_by_status`.","examples":[24]},"bookings_by_status":{"additionalProperties":{"type":"integer"},"propertyNames":{"$ref":"#/components/schemas/BookingStatus"},"type":"object","title":"Bookings By Status","description":"Reparto por estado. Trae **siempre las cinco claves** (`PENDING`, `CONFIRMED`, `PAID`, `CANCELLED`, `NO_SHOW`), con `0` en las que no tuvieron ninguna: así la gráfica no tiene que rellenar huecos."},"revenue_clp":{"type":"integer","title":"Revenue Clp","description":"**Caja**: suma de los pagos `APPROVED` cuyo `paid_at` cae en el periodo, en pesos enteros. Un local que no usa prepago tiene aquí un cero legítimo; lo que ha vendido está en `booked_value_clp`.","examples":[105000]},"booked_value_clp":{"type":"integer","title":"Booked Value Clp","description":"**Compromiso**: suma de los precios pactados en las citas `CONFIRMED` y `PAID` del periodo. Es lo que vale la agenda vendida, se haya cobrado o no.","examples":[285000]},"occupancy_rate":{"type":"number","maximum":1.0,"minimum":0.0,"title":"Occupancy Rate","description":"Minutos vendidos por todo el equipo entre minutos disponibles, en `[0, 1]`. Los minutos disponibles son horario del local ∩ turnos, menos bloqueos y colaciones. Vale `0` si nadie tenía turno.","examples":[0.42]},"no_show_rate":{"type":"number","maximum":1.0,"minimum":0.0,"title":"No Show Rate","description":"Ausencias entre citas **que ya han empezado** (`CONFIRMED`, `PAID` y `NO_SHOW` con `starts_at` anterior a ahora), en `[0, 1]`. Las citas futuras no entran: todavía no se sabe si alguien faltará, y contarlas hundiría la tasa a medida que se llena la agenda.","examples":[0.05]},"top_services":{"items":{"$ref":"#/components/schemas/ServiceMetrics"},"type":"array","title":"Top Services","description":"Los 5 servicios con más citas `CONFIRMED` o `PAID` en el periodo, de más a menos. Como mucho 5 elementos; lista vacía si no hubo ninguna."},"bookings_per_day":{"items":{"$ref":"#/components/schemas/DailyBookings"},"type":"array","title":"Bookings Per Day","description":"Serie diaria **completa**: un elemento por cada día del periodo, en orden y con `0` en los días sin citas."},"occupancy_by_staff":{"items":{"$ref":"#/components/schemas/StaffOccupancy"},"type":"array","title":"Occupancy By Staff","description":"Ocupación persona a persona, en el mismo orden en que el panel lista la plantilla. Incluye a quien no tuvo ni una cita, porque un cero es justo lo que el dueño está buscando."}},"type":"object","required":["range","bookings_total","bookings_by_status","revenue_clp","booked_value_clp","occupancy_rate","no_show_rate","top_services","bookings_per_day","occupancy_by_staff"],"title":"MetricsOut","description":"El resumen del negocio en una ventana de días.\n\nEs la respuesta de `GET /api/v1/businesses/{business_id}/metrics` y la\nfuente del dashboard B2B (UI-01.01). Todos los campos son **obligatorios**:\nun periodo sin una sola cita responde con ceros y listas vacías, nunca con\ncampos ausentes, para que el cliente tipado no tenga que preguntarse si el\nnúmero llegó.","examples":[{"booked_value_clp":285000,"bookings_by_status":{"CANCELLED":2,"CONFIRMED":12,"NO_SHOW":1,"PAID":7,"PENDING":2},"bookings_per_day":[{"count":5,"date":"2026-03-09"},{"count":4,"date":"2026-03-10"},{"count":6,"date":"2026-03-11"},{"count":3,"date":"2026-03-12"},{"count":6,"date":"2026-03-13"},{"count":0,"date":"2026-03-14"},{"count":0,"date":"2026-03-15"}],"bookings_total":24,"no_show_rate":0.05,"occupancy_by_staff":[{"available_minutes":2400,"booked_minutes":1200,"display_name":"Ana","rate":0.5,"staff_member_id":"3f2b9a10-0000-4000-8000-cccccccccccc"},{"available_minutes":2400,"booked_minutes":816,"display_name":"Benjamín","rate":0.34,"staff_member_id":"3f2b9a10-0000-4000-8000-dddddddddddd"}],"occupancy_rate":0.42,"range":{"date_from":"2026-03-09","date_to":"2026-03-15","timezone":"America/Santiago"},"revenue_clp":105000,"top_services":[{"bookings":11,"name":"Corte de pelo","revenue_clp":165000,"service_id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb"},{"bookings":8,"name":"Color","revenue_clp":120000,"service_id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbc"}]}]},"MetricsRange":{"properties":{"date_from":{"type":"string","format":"date","title":"Date From","description":"Primer día del periodo, **incluido**, en hora local del negocio.","examples":["2026-03-09"]},"date_to":{"type":"string","format":"date","title":"Date To","description":"Último día del periodo, **también incluido**.","examples":["2026-03-15"]},"timezone":{"type":"string","title":"Timezone","description":"Zona horaria IANA del negocio. Es la que decide a qué día pertenece cada cita: una reserva de las 23:00 en Santiago es del día siguiente en UTC, y las cifras del panel siguen el calendario del local, no el del servidor.","examples":["America/Santiago"]}},"type":"object","required":["date_from","date_to","timezone"],"title":"MetricsRange","description":"La ventana que se calculó, en días **locales del negocio**.\n\nSe devuelve aunque el cliente la haya enviado entera: los dos extremos\nadmiten valores por defecto (los últimos treinta días), así que sin este\nbloque una respuesta sin parámetros no diría a qué periodo se refiere.","examples":[{"date_from":"2026-03-09","date_to":"2026-03-15","timezone":"America/Santiago"}]},"MyBusinessOut":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"slug":{"type":"string","title":"Slug","description":"Identificador público en la URL. Inmutable tras la creación."},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"logo_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo Url"},"gallery_urls":{"items":{"type":"string"},"type":"array","title":"Gallery Urls"},"address":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Address"},"comuna":{"type":"string","title":"Comuna"},"lat":{"type":"number","title":"Lat","description":"Latitud derivada de `location` con `ST_Y`."},"lng":{"type":"number","title":"Lng","description":"Longitud derivada de `location` con `ST_X`."},"timezone":{"type":"string","title":"Timezone","description":"Zona horaria IANA en la que se calcula la agenda."},"is_published":{"type":"boolean","title":"Is Published"},"rating_avg":{"type":"number","title":"Rating Avg"},"rating_count":{"type":"integer","title":"Rating Count"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"membership_role":{"$ref":"#/components/schemas/UserRole","description":"Relación con **este** negocio: `OWNER` si es su propietario, `STAFF` si figura activo en la plantilla."}},"type":"object","required":["id","slug","name","comuna","lat","lng","timezone","is_published","rating_avg","rating_count","created_at","updated_at","membership_role"],"title":"MyBusinessOut","description":"Un negocio del usuario actual, con la relación que tiene con él."},"OtpChannel":{"type":"string","enum":["EMAIL","SMS"],"title":"OtpChannel","description":"Canal por el que se entrega un código de un solo uso."},"Page_AdminBusinessOut_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/AdminBusinessOut"},"type":"array","title":"Items","description":"Filas de esta página, ya ordenadas."},"total":{"type":"integer","title":"Total","description":"Filas totales que cumplen el filtro.","examples":[47]},"page":{"type":"integer","title":"Page","description":"Página devuelta, empezando en 1.","examples":[1]},"page_size":{"type":"integer","title":"Page Size","description":"Tamaño de página aplicado (máximo 100).","examples":[20]}},"type":"object","required":["items","total","page","page_size"],"title":"Page[AdminBusinessOut]"},"Page_AdminSubscriptionOut_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/AdminSubscriptionOut"},"type":"array","title":"Items","description":"Filas de esta página, ya ordenadas."},"total":{"type":"integer","title":"Total","description":"Filas totales que cumplen el filtro.","examples":[47]},"page":{"type":"integer","title":"Page","description":"Página devuelta, empezando en 1.","examples":[1]},"page_size":{"type":"integer","title":"Page Size","description":"Tamaño de página aplicado (máximo 100).","examples":[20]}},"type":"object","required":["items","total","page","page_size"],"title":"Page[AdminSubscriptionOut]"},"Page_AdminUserOut_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/AdminUserOut"},"type":"array","title":"Items","description":"Filas de esta página, ya ordenadas."},"total":{"type":"integer","title":"Total","description":"Filas totales que cumplen el filtro.","examples":[47]},"page":{"type":"integer","title":"Page","description":"Página devuelta, empezando en 1.","examples":[1]},"page_size":{"type":"integer","title":"Page Size","description":"Tamaño de página aplicado (máximo 100).","examples":[20]}},"type":"object","required":["items","total","page","page_size"],"title":"Page[AdminUserOut]"},"Page_BookingOut_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/BookingOut"},"type":"array","title":"Items","description":"Filas de esta página, ya ordenadas."},"total":{"type":"integer","title":"Total","description":"Filas totales que cumplen el filtro.","examples":[47]},"page":{"type":"integer","title":"Page","description":"Página devuelta, empezando en 1.","examples":[1]},"page_size":{"type":"integer","title":"Page Size","description":"Tamaño de página aplicado (máximo 100).","examples":[20]}},"type":"object","required":["items","total","page","page_size"],"title":"Page[BookingOut]"},"PasswordForgotAccepted":{"properties":{"status":{"type":"string","const":"PASSWORD_RESET_REQUESTED","title":"Status","default":"PASSWORD_RESET_REQUESTED"}},"type":"object","title":"PasswordForgotAccepted","description":"Acuse del `forgot`. Es **idéntico** exista o no la cuenta.\n\nUn cuerpo distinto —o un 404— convertiría este endpoint en un enumerador de\nusuarios registrados, que es justo lo que el 202 constante evita."},"PasswordForgotRequest":{"properties":{"email":{"type":"string","format":"email","title":"Email","description":"Correo al que se enviará el enlace, si existe la cuenta."}},"type":"object","required":["email"],"title":"PasswordForgotRequest","description":"Solicitud del enlace de restablecimiento.","examples":[{"email":"ada@lukin.cl"}]},"PasswordResetRequest":{"properties":{"token":{"type":"string","title":"Token","description":"Token `password_reset` recibido por correo."},"password":{"type":"string","maxLength":128,"minLength":8,"title":"Password","description":"Contraseña nueva, con la misma longitud exigida en el alta."}},"type":"object","required":["token","password"],"title":"PasswordResetRequest","description":"Canje del enlace de restablecimiento por una contraseña nueva.","examples":[{"password":"una-contrasena-segura","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}]},"PasswordUpdated":{"properties":{"status":{"type":"string","const":"PASSWORD_UPDATED","title":"Status","default":"PASSWORD_UPDATED"}},"type":"object","title":"PasswordUpdated","description":"Resultado del `reset`: la contraseña quedó cambiada."},"PaymentStatus":{"type":"string","enum":["PENDING","APPROVED","REJECTED","REFUNDED"],"title":"PaymentStatus","description":"Estado del intento de cobro, tal y como lo reporta el webhook.\n\n`PENDING` es el estado de nacimiento: la fila se crea junto con la\npreferencia de pago, antes de que el cliente llegue al checkout."},"PaymentStatusOut":{"properties":{"status":{"anyOf":[{"$ref":"#/components/schemas/PaymentStatus"},{"type":"string","const":"NONE"}],"title":"Status","description":"Estado del último cobro relevante de la reserva, o `NONE` si nunca se inició uno. Gana el intento ya resuelto sobre el que sigue `PENDING`."},"payment_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Payment Id","description":"Identificador del intento de cobro, o `null` si no hay ninguno."},"amount_clp":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Amount Clp","description":"Importe del intento, en pesos chilenos enteros; `null` si no hay pago."},"paid_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Paid At","description":"Instante en que la pasarela aprobó el cobro. Solo lo trae un pago `APPROVED`; en cualquier otro estado es `null`."},"booking_status":{"$ref":"#/components/schemas/BookingStatus","description":"Estado de la reserva en el mismo instante que el pago. Tras un cobro aprobado vale `PAID`."}},"type":"object","required":["status","booking_status"],"title":"PaymentStatusOut","description":"Estado del cobro de una reserva, y el de la reserva misma.\n\nLos dos viajan juntos porque la pantalla de retorno de Mercado Pago\n(F7-T07) los necesita a la vez y preguntarlos por separado abriría una\nventana en la que el pago ya está `APPROVED` y la reserva todavía no\n`PAID`: el usuario vería «pagado» y «pendiente» en la misma pantalla.\n\nUna reserva sin ningún intento responde `status: \"NONE\"` con los tres\ncampos del pago a `null`. No es un `404`: la reserva existe, y «todavía no\nhas pagado» es una respuesta legítima que la pantalla sabe pintar.","examples":[{"amount_clp":15000,"booking_status":"PAID","paid_at":"2026-03-09T18:12:44Z","payment_id":"8c1f0e64-0000-4000-8000-aaaaaaaaaaaa","status":"APPROVED"}]},"PlanCapability":{"type":"string","enum":["METRICS"],"title":"PlanCapability","description":"Lo que un tramo desbloquea y el inferior no.\n\nUn valor y ni uno más: cada capacidad tiene un punto de aplicación real en\nel backend. Una capacidad que no se aplica en ninguna parte es una promesa\nde la página de precios disfrazada de código. `SMS_REMINDERS` estuvo aquí y\nse retiró al decidir que Lukin no usa SMS (ADR-079); el enum se queda con\nun solo valor a propósito, en vez de desaparecer, porque el mecanismo de la\nescalera sigue vivo y el próximo canal —WhatsApp— entrará por aquí."},"PlanOut":{"properties":{"code":{"$ref":"#/components/schemas/SubscriptionPlan","description":"Código del tramo. Es el mismo valor que se envía al contratarlo."},"monthly_price_clp":{"type":"integer","title":"Monthly Price Clp","description":"Precio mensual en pesos chilenos enteros, impuestos incluidos. Es el importe exacto que se domiciliará cada mes."},"annual_price_clp":{"type":"integer","title":"Annual Price Clp","description":"Precio de un año por adelantado, impuestos incluidos: equivale a diez mensualidades, porque el ciclo anual regala dos meses."},"annual_available":{"type":"boolean","title":"Annual Available","description":"Si este tramo se puede contratar con ciclo ANUAL. **Hoy `false` en todos**: Mercado Pago aplica un tope de $350.000 CLP por transacción y el anual de PREMIUM son 384.300, medido contra su API el 2026-09-04. Viaja por plan y no como dato global porque el tope es por transacción: INDIVIDUAL (111.300) y BASIC (244.300) sí caben, así que reactivarlo puede hacerse tramo a tramo. `annual_price_clp` se sigue publicando —es un hecho de la oferta— y pedir un ciclo anual con esto en `false` responde 422 `ANNUAL_BILLING_UNAVAILABLE`. **Salvo en un tramo cuyo precio es 0**, donde `false` significa otra cosa: no hay año que pagar por adelantado, así que no se ofrece, pero pedirlo tampoco se rechaza —no hay nada que cobrar y el ciclo se ignora—. No es un `false` que se encienda moviendo nada."},"max_staff":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Staff","description":"Profesionales que admite el tramo, contando al propietario. `null` significa sin tope."},"marketplace_bookings_monthly":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Marketplace Bookings Monthly","description":"Reservas del marketplace al mes que admite el tramo, o `null` si no tiene tope. Cuentan solo las que trae Lukin —la ficha pública y el canal agéntico—; las citas que el negocio apunta desde su panel no gastan cupo y no tienen límite en ningún tramo. Alcanzado el tope, la ficha del local deja de ofrecer horas por internet hasta que empiece el mes siguiente. Viaja el número ya resuelto y no el nombre del ajuste que lo guarda, igual que `monthly_price_clp`: puede moverse sin versión nueva de la API, así que no conviene cachearlo indefinidamente. La misma cifra aparece en prosa dentro de `features`, para que la vitrina no tenga que redactarla."},"whatsapp_messages_monthly":{"type":"integer","title":"Whatsapp Messages Monthly","description":"Mensajes de WhatsApp incluidos al mes. WhatsApp lleva el aviso de que una cita queda agendada con un profesional y, **solo en PREMIUM y si el negocio lo activa**, puede llevar además el código de la reserva del cliente final. Todo lo demás —el cobro, el estado de la suscripción, los avisos de morosidad— viaja por correo. El canal aún no está disponible; el cupo describe la oferta del tramo."},"capabilities":{"items":{"$ref":"#/components/schemas/PlanCapability"},"type":"array","title":"Capabilities","description":"Funciones que este tramo desbloquea y los inferiores no. Pedir una que el tramo no incluye responde 402 `PLAN_UPGRADE_REQUIRED`."},"features":{"items":{"type":"string"},"type":"array","title":"Features","description":"Prestaciones incluidas, en el orden en que se muestran en la página de precios. Cada tramo **de pago** contiene todas las del anterior; el gratuito no es un peldaño de esa escalera sino otra oferta: cambia «reservas ilimitadas» por la línea de su tope, que se redacta con el valor vivo de `marketplace_bookings_monthly`. Son frases para pintar tal cual, no identificadores: no ramifiques por su texto."}},"type":"object","required":["code","monthly_price_clp","annual_price_clp","annual_available","max_staff","marketplace_bookings_monthly","whatsapp_messages_monthly","capabilities","features"],"title":"PlanOut","description":"Un tramo del catálogo comercial de Lukin, con sus precios vigentes.","examples":[{"annual_available":false,"annual_price_clp":384300,"capabilities":["METRICS"],"code":"PREMIUM","features":["agenda","reservas ilimitadas","ficha en el marketplace","cobro online con tu cuenta","recordatorios por email","horarios y bloqueos por profesional","métricas del panel"],"monthly_price_clp":38430,"whatsapp_messages_monthly":200}]},"PrivacyConfirm":{"properties":{"email":{"anyOf":[{"type":"string","format":"email"},{"type":"null"}],"title":"Email","description":"Correo del titular, tal y como figura en Lukin. Se normaliza a minúsculas antes de buscar la cuenta y de enviar el código.","examples":["javiera@example.cl"]},"phone":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Phone","description":"Teléfono del titular en cualquier forma habitual (`+56 9 1234 5678`, `9 1234 5678`); se normaliza a E.164. Es la **única** vía de quien reservó como invitado dejando solo su móvil.","examples":["+56912345678"]},"code":{"type":"string","maxLength":4,"minLength":4,"pattern":"^\\d+$","title":"Code","description":"Código de 4 dígitos recibido por correo o SMS. Su longitud y su vida las fija `OTP_POLICY[PRIVACY]`.","examples":["0000"]},"action":{"type":"string","enum":["EXPORT","ERASURE"],"title":"Action","description":"La misma acción que se pidió al solicitar el código."},"confirm":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Confirm","description":"Confirmación escrita para las acciones irreversibles. `EXPORT` no la mira; `ERASURE` exige exactamente `ELIMINAR` y sin ella responde `422 CONFIRMATION_MISMATCH` **sin gastar el código**.","examples":["ELIMINAR"]}},"type":"object","required":["code","action"],"title":"PrivacyConfirm","description":"Canje del código: el mismo contacto de la solicitud, más el reto.","examples":[{"action":"EXPORT","code":"0000","email":"javiera@example.cl"},{"action":"EXPORT","code":"0000","phone":"+56912345678"}]},"PrivacyRequest":{"properties":{"email":{"anyOf":[{"type":"string","format":"email"},{"type":"null"}],"title":"Email","description":"Correo del titular, tal y como figura en Lukin. Se normaliza a minúsculas antes de buscar la cuenta y de enviar el código.","examples":["javiera@example.cl"]},"phone":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Phone","description":"Teléfono del titular en cualquier forma habitual (`+56 9 1234 5678`, `9 1234 5678`); se normaliza a E.164. Es la **única** vía de quien reservó como invitado dejando solo su móvil.","examples":["+56912345678"]},"action":{"type":"string","enum":["EXPORT","ERASURE"],"title":"Action","description":"`EXPORT` entrega una copia de los datos del titular; `ERASURE` ejerce el derecho al olvido, que es **irreversible** y exige además `confirm: 'ELIMINAR'` al canjear el código."}},"type":"object","required":["action"],"title":"PrivacyRequest","description":"Solicitud de un derecho ARCO por parte de quien no tiene sesión.","examples":[{"action":"EXPORT","email":"javiera@example.cl"},{"action":"EXPORT","phone":"+56912345678"}]},"PrivacyRequestAccepted":{"properties":{"message":{"type":"string","title":"Message","description":"Texto para el titular. No dice si el contacto existe: un mensaje distinto por caso convertiría el endpoint en un enumerador de clientes."}},"type":"object","required":["message"],"title":"PrivacyRequestAccepted","description":"Acuse de una solicitud ARCO. Es **siempre el mismo**, exista o no la cuenta.","examples":[{"message":"Si ese contacto corresponde a una cuenta de Lukin, te enviamos un código para verificar la solicitud."}]},"QueryUnderstoodOut":{"properties":{"comuna":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Comuna","description":"Comuna del catálogo a la que se resolvió el texto recibido, o `null`.","examples":["Ñuñoa"]},"service_terms":{"items":{"type":"string"},"type":"array","title":"Service Terms","description":"Los términos con los que se buscó de verdad: la clave del catálogo y sus sinónimos, o el texto tal cual si no está catalogado.","examples":[["corte de pelo","barbería","peluquería"]]},"modality":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Modality","description":"Modalidad aplicada (`ONLINE`, `IN_PERSON`) o `null` si entraron las dos.","examples":["IN_PERSON"]},"date_from":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Date From","description":"Primer día del rango en ISO (`2026-09-15`), o `null` si no se filtró agenda.","examples":["2026-09-15"]},"date_to":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Date To","description":"Último día del rango en ISO; igual a `date_from` cuando se pidió un solo día.","examples":["2026-09-17"]}},"type":"object","title":"QueryUnderstoodOut","description":"Cómo se interpretó la petición. Es lo que el agente le repite a la persona."},"RatingOut":{"properties":{"avg":{"type":"number","title":"Avg","description":"Media de las valoraciones recibidas, de 0 a 5.","examples":[4.8]},"count":{"type":"integer","title":"Count","description":"Cuántas valoraciones sostienen esa media; 0 significa que aún no tiene.","examples":[37]}},"type":"object","required":["avg","count"],"title":"RatingOut","description":"La valoración del local, para que el agente pueda comparar dos opciones."},"ReadinessCheck":{"properties":{"key":{"type":"string","title":"Key","description":"Identificador estable de la verificación. El catálogo es **cerrado**: `has_hours`, `has_active_service`, `has_active_staff`, `has_staff_schedule`, `has_staff_service_assignment`, `has_logo`, `has_description` y `has_merchant_credential`."},"ok":{"type":"boolean","title":"Ok","description":"`true` si la verificación se cumple ahora mismo."},"required":{"type":"boolean","title":"Required","description":"`true` si su incumplimiento **impide publicar** el negocio. Las verificaciones informativas (`required: false`) solo avisan."},"message":{"type":"string","title":"Message","description":"Qué tiene que hacer el vendedor para cumplirla."}},"type":"object","required":["key","ok","required","message"],"title":"ReadinessCheck","description":"Una verificación de la checklist de publicación.\n\n`key` es un identificador **estable**: el dashboard (`ReadinessChecklist`,\nF6-T15) lo usa para enlazar cada fila con la pantalla que la resuelve, así\nque se ramifica sobre él y nunca sobre `message`, que es prosa traducible."},"ReadinessOut":{"properties":{"is_ready":{"type":"boolean","title":"Is Ready","description":"`true` si **todas** las verificaciones `required` se cumplen. Es la condición que exige `PATCH /businesses/{business_id}` para `is_published: true`."},"checks":{"items":{"$ref":"#/components/schemas/ReadinessCheck"},"type":"array","title":"Checks","description":"Las verificaciones aplicables, en orden estable de presentación."}},"type":"object","required":["is_ready","checks"],"title":"ReadinessOut","description":"Estado de completitud del negocio de cara a publicarse (RF-02.01).\n\n`is_ready` resume **solo** las verificaciones `required`: un negocio sin\nlogo está listo para venderse aunque su ficha luzca peor.\n\nLa lista no es fija: `has_merchant_credential` aparece únicamente cuando el\ncatálogo tiene algún servicio activo con `requires_prepayment: true`, que es\ncuando la falta de credencial pasa a tener consecuencias (sin ella el cobro\nen línea se degrada en silencio)."},"RegisterRequest":{"properties":{"email":{"type":"string","format":"email","title":"Email","description":"Correo del titular. Se normaliza a minúsculas."},"password":{"type":"string","maxLength":128,"minLength":8,"title":"Password","description":"Contraseña en claro, entre 8 y 128 caracteres. Se almacena solo su hash Argon2id."},"full_name":{"type":"string","maxLength":120,"minLength":2,"title":"Full Name","description":"Nombre y apellido con los que se le atenderá."},"phone":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Phone","description":"Teléfono opcional. Se normaliza a E.164 asumiendo Chile si no trae prefijo."},"accept_privacy":{"type":"boolean","const":true,"title":"Accept Privacy","description":"Aceptación expresa de los términos y de la política de privacidad. Solo se admite `true`: sin ella no hay alta."}},"type":"object","required":["email","password","full_name","accept_privacy"],"title":"RegisterRequest","description":"Alta de un cliente final con correo y contraseña (RF-01.01).","examples":[{"accept_privacy":true,"email":"ada@lukin.cl","full_name":"Ada Lovelace","password":"una-contrasena-segura","phone":"+56912345678"}]},"ReviewCreate":{"properties":{"rating":{"type":"integer","maximum":5.0,"minimum":1.0,"title":"Rating","description":"Estrellas de 1 a 5. Fuera de ese rango la respuesta es `422`: es el mismo intervalo que defiende la base.","examples":[5]},"comment":{"anyOf":[{"type":"string","maxLength":1000},{"type":"null"}],"title":"Comment","description":"Opinión libre, como mucho 1000 caracteres. Un texto en blanco se guarda como `null`: «sin comentario» y «espacios» son lo mismo.","examples":["Puntualísimos y el corte quedó tal cual lo pedí."]}},"type":"object","required":["rating"],"title":"ReviewCreate","description":"Lo que escribe el cliente: estrellas obligatorias, texto opcional.\n\nLa nota es el dato que mueve el `rating_avg` del negocio y por eso es\nobligatoria; el comentario no, porque exigir un texto para poder puntuar\nsolo produce reseñas de una palabra.","examples":[{"comment":"Puntualísimos y el corte quedó tal cual lo pedí.","rating":5}]},"ReviewOut":{"properties":{"id":{"type":"string","format":"uuid","title":"Id","description":"Identificador de la reseña."},"booking_id":{"type":"string","format":"uuid","title":"Booking Id","description":"Reserva reseñada. Es única: una visita admite una sola opinión."},"rating":{"type":"integer","title":"Rating","description":"Estrellas otorgadas, de 1 a 5.","examples":[5]},"comment":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Comment","description":"Opinión escrita, o `null` si solo se puntuó."},"published_at":{"type":"string","format":"date-time","title":"Published At","description":"Momento en que la reseña se hizo pública, en UTC. Es el campo temporal de la reseña: `created_at` no se publica.","examples":["2026-03-10T13:05:00Z"]}},"type":"object","required":["id","booking_id","rating","published_at"],"title":"ReviewOut","description":"La reseña recién escrita, devuelta a quien la firmó.\n\nLleva `booking_id` —y no un resumen de la reserva— porque el cliente que\nacaba de puntuar tiene la cita delante: lo que necesita de vuelta es la\nconfirmación de que quedó atada a *esa* visita.","examples":[{"booking_id":"3f2b9a10-0000-4000-8000-dddddddddddd","comment":"Puntualísimos y el corte quedó tal cual lo pedí.","id":"3f2b9a10-0000-4000-8000-eeeeeeeeeeee","published_at":"2026-03-10T13:05:00Z","rating":5}]},"ReviewPublic":{"properties":{"id":{"type":"string","format":"uuid","title":"Id","description":"Identificador de la reseña."},"rating":{"type":"integer","title":"Rating","description":"Estrellas otorgadas, de 1 a 5.","examples":[5]},"comment":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Comment","description":"Opinión escrita, o `null` si solo se puntuó."},"author_name":{"type":"string","title":"Author Name","description":"Firma pública de quien reseñó: nombre de pila e inicial del primer apellido (`Camila R.`). `Usuario eliminado` si la cuenta ejerció el derecho al olvido, `Cliente` si nunca registró un nombre.","examples":["Camila R."]},"published_at":{"type":"string","format":"date-time","title":"Published At","description":"Publicación de la reseña, en UTC. Es el campo por el que se ordena.","examples":["2026-03-10T13:05:00Z"]}},"type":"object","required":["id","rating","author_name","published_at"],"title":"ReviewPublic","description":"Una reseña tal y como se lee en la ficha del local, sin identificar a nadie.\n\nNo viaja `booking_id`: la vitrina no dice qué reserva hay detrás de cada\nopinión, porque ese identificador es la referencia con la que se gestiona la\ncita (F4-T07). Tampoco el `id` del autor, su correo ni su teléfono.","examples":[{"author_name":"Camila R.","comment":"Puntualísimos y el corte quedó tal cual lo pedí.","id":"3f2b9a10-0000-4000-8000-eeeeeeeeeeee","published_at":"2026-03-10T13:05:00Z","rating":5}]},"ReviewsPage":{"properties":{"items":{"items":{"$ref":"#/components/schemas/ReviewPublic"},"type":"array","title":"Items","description":"Reseñas de esta página, de la más reciente a la más antigua."},"total":{"type":"integer","title":"Total","description":"Reseñas totales del local, no las de esta página.","examples":[2]},"page":{"type":"integer","title":"Page","description":"Página devuelta, empezando en 1.","examples":[1]},"rating_avg":{"type":"number","title":"Rating Avg","description":"Media de las valoraciones del local, redondeada a dos decimales. Vale `0` mientras no haya ninguna.","examples":[4.5]},"rating_count":{"type":"integer","title":"Rating Count","description":"Número de valoraciones que componen la media.","examples":[2]}},"type":"object","required":["items","total","page","rating_avg","rating_count"],"title":"ReviewsPage","description":"Una página de reseñas del local y el resumen de su reputación.\n\n`rating_avg` y `rating_count` son las columnas denormalizadas de\n`businesses`, las mismas que publica la ficha (`BusinessPublic`, F3-T10) y\nlas que mantiene `review_service.recalculate_rating` en la transacción de\ncada alta. Viajan en la página para que la cabecera de la pestaña de\nopiniones no necesite una segunda llamada.","examples":[{"items":[{"author_name":"Camila R.","comment":"Puntualísimos y el corte quedó tal cual lo pedí.","id":"3f2b9a10-0000-4000-8000-eeeeeeeeeeee","published_at":"2026-03-10T13:05:00Z","rating":5},{"author_name":"Usuario eliminado","id":"3f2b9a10-0000-4000-8000-ffffffffffff","published_at":"2026-03-01T18:20:00Z","rating":4}],"page":1,"rating_avg":4.5,"rating_count":2,"total":2}]},"ScheduleBlockKind":{"type":"string","enum":["BREAK","BLOCK","EXCEPTION"],"title":"ScheduleBlockKind","description":"Por qué un tramo de agenda deja de estar disponible (RF-02.02).\n\n- `BREAK`: colación o descanso, normalmente recurrente cada semana.\n- `BLOCK`: cierre puntual del local o del trabajador (vacaciones, feriado).\n- `EXCEPTION`: alteración excepcional del horario habitual de ese día.\n\nLa distinción es semántica, no operativa: los tres restan disponibilidad\npor igual en el algoritmo de RF-03.01; sirven para explicar el hueco en el\ncalendario del panel B2B."},"ScheduleEntry":{"properties":{"weekday":{"type":"integer","maximum":6.0,"minimum":0.0,"title":"Weekday","description":"Día de la semana, **0 = lunes** … 6 = domingo, igual que `datetime.date.weekday()` en Python."},"starts_at":{"type":"string","format":"time","title":"Starts At","description":"Hora de entrada, en hora local del negocio (`HH:MM:SS`)."},"ends_at":{"type":"string","format":"time","title":"Ends At","description":"Hora de salida, en hora local del negocio. Debe ser posterior a `starts_at`: un tramo no cruza la medianoche."}},"type":"object","required":["weekday","starts_at","ends_at"],"title":"ScheduleEntry","description":"Un tramo de trabajo habitual de una persona en un día de la semana.\n\n`starts_at` y `ends_at` son horas **locales del negocio**\n(`Business.timezone`), igual que las del local: el turno empieza a las diez\ntodo el año y es el motor de disponibilidad quien lo convierte a instantes\nabsolutos día a día.\n\nLa **colación no se resta aquí**: se modela como un `ScheduleBlock` de tipo\n`BREAK` (F3-T08). Partir el turno en dos tramos es la otra forma de contarlo\ny también está permitida —el mismo `weekday` admite varias entradas—, pero\nuna pausa que se repite cada semana pertenece a los bloqueos, que sí saben\nde excepciones y de fechas concretas.","examples":[{"ends_at":"14:00:00","starts_at":"10:00:00","weekday":0},{"ends_at":"13:00:00","starts_at":"10:00:00","weekday":2},{"ends_at":"18:00:00","starts_at":"15:00:00","weekday":2}]},"ScheduleMatrix":{"items":{"$ref":"#/components/schemas/ScheduleEntry"},"type":"array","title":"ScheduleMatrix","description":"La semana completa de un trabajador: una lista de tramos, ordenada y sin choques.\n\nAl validar deja el contenido **ordenado por `(weekday, starts_at)`**, de modo\nque la respuesta del `PUT`, la del `GET` y la matriz que consume el motor de\ndisponibilidad salgan siempre igual y el dashboard no tenga que reordenar\nnada para pintar la tabla.\n\nUna lista vacía es válida y significa **sin horario**: es como se borra la\nagenda de alguien que deja de atender sin darlo de baja.\n\nLo que aquí **no** se comprueba es la contención en las horas del local: esa\nregla necesita leer la base y vive en `staff_service.replace_schedule`\n(`SCHEDULE_OUTSIDE_BUSINESS_HOURS`).","examples":[[{"ends_at":"14:00:00","starts_at":"10:00:00","weekday":0},{"ends_at":"13:00:00","starts_at":"10:00:00","weekday":2},{"ends_at":"18:00:00","starts_at":"15:00:00","weekday":2}],[]]},"SearchLukinServicesInput":{"properties":{"service_type":{"type":"string","maxLength":100,"minLength":1,"title":"Service Type","description":"Qué se busca, tal y como lo dijo la persona: «corte de pelo», «kine», «uñas», «dentista». Se traduce al catálogo de tipos de servicio de Lukin y se busca por **todos** sus sinónimos, así que no hace falta acertar con el nombre exacto del local. Un servicio que el catálogo aún no conoce se busca con el texto tal cual. Obligatorio. Máximo 100 caracteres.","examples":["corte de pelo"]},"comuna":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Comuna","description":"Comuna de Chile donde buscar, p. ej. `Ñuñoa`, `La Florida` o `Las Condes`. Se compara sin tildes ni mayúsculas («Nunoa» vale) y admite el envoltorio hablado («en la comuna de Ñuñoa»). Su centroide es el centro del radio de búsqueda. **Obligatoria salvo con `modality: \"ONLINE\"`**, donde no hay distancia que medir. Una comuna que no exista responde `UNKNOWN_COMUNA` con las parecidas en `suggestions`.","examples":["La Florida"]},"modality":{"anyOf":[{"$ref":"#/components/schemas/ServiceModality"},{"type":"null"}],"description":"`IN_PERSON` para atención en el local (respeta `radius_km`) u `ONLINE` para videollamada, que **desactiva el filtro por distancia** y por eso es la única modalidad que puede buscarse sin `comuna`. Omítela para que entren las dos, que es lo habitual cuando la persona no dijo nada al respecto.","examples":["IN_PERSON"]},"radius_km":{"type":"number","maximum":30.0,"minimum":1.0,"title":"Radius Km","description":"Radio de búsqueda en kilómetros alrededor del centroide de `comuna`, entre 1 y 30. Por defecto 5. Súbelo cuando la respuesta venga vacía; no se aplica con `modality: \"ONLINE\"`.","default":5.0,"examples":[5.0]},"date_from":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Date From","description":"Primer día del rango en el que se quiere hora. Acepta ISO (`2026-09-15`), `DD/MM/YYYY` y lenguaje natural en español: `hoy`, `mañana`, `pasado mañana`, `en 3 días`, `el sábado`, `próximo lunes`. **Sin este campo no se filtra por agenda**: salen todos los locales que coinciden y `next_available_slots` trae igualmente sus próximas horas libres de los siguientes 3 días. Una fecha que no se entiende responde `INVALID_DATE`.","examples":["mañana"]},"date_to":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Date To","description":"Último día del rango, con los mismos formatos que `date_from`. Por defecto es el propio `date_from`, es decir, un solo día. Un local entra en los resultados si tiene hueco en **cualquier** día del rango, que es lo que significa «esta semana». El rango no puede pasar de 7 días: por encima responde `DATE_RANGE_TOO_LARGE`.","examples":["en 3 días"]},"limit":{"type":"integer","maximum":20.0,"minimum":1.0,"title":"Limit","description":"Cuántos locales devolver, entre 1 y 20. Por defecto 10. Pide pocos: son para leérselos a una persona, no para recorrerlos. `total` dice cuántos había antes de mirar la agenda.","default":10,"examples":[10]}},"type":"object","required":["service_type"],"title":"SearchLukinServicesInput","description":"Los argumentos de `search_lukin_services`, ya normalizados.\n\nLos campos son **texto tal y como lo escribe un modelo** —«mañana», «en la\ncomuna de Ñuñoa»— y la normalización ocurre al validar, no al usarlos: así\nuna fecha imposible o un rango de un mes fallan con su `code` propio antes\nde que se haya tocado la base de datos, y el resto del módulo trabaja\nsiempre con `date` y con términos de catálogo.\n\nLo normalizado vive en atributos privados y se lee por propiedad porque no\nes parte del contrato que el agente rellena: el `inputSchema` publica lo que\nhay que escribir, y `query_understood` publica lo que se entendió."},"SearchLukinServicesOutput":{"properties":{"results":{"items":{"$ref":"#/components/schemas/AgentBusinessOut"},"type":"array","title":"Results","description":"Los locales que pueden atender, en orden de cercanía. Vacío no es un error: lee `next_step_hint` para saber qué proponer.","examples":[[{"business_id":"9d1f2c34-5b6a-47c8-9e01-2f3a4b5c6d7e","comuna":"Ñuñoa","distance_km":2.4,"name":"Barbería Ñuñoa","next_available_slots":["2026-09-15T09:00:00-03:00","2026-09-15T10:30:00-03:00","2026-09-16T11:00:00-03:00"],"payment_enabled":true,"profile_url":"http://localhost:3000/l/barberia-nunoa","rating":{"avg":4.8,"count":37},"services":[{"duration_minutes":30,"modality":"IN_PERSON","name":"Corte de pelo","price_clp":15000,"requires_prepayment":false,"service_id":"3fa85f64-5717-4562-b3fc-2c963f66afa6"}],"slug":"barberia-nunoa"}]]},"total":{"type":"integer","title":"Total","description":"Cuántos locales cumplen la búsqueda **antes** de mirar la agenda. Puede ser mayor que `results` si varios no tenían hueco.","examples":[7]},"query_understood":{"$ref":"#/components/schemas/QueryUnderstoodOut","description":"Cómo se interpretó lo que se pidió; repítelo para confirmar.","examples":[{"comuna":"Ñuñoa","date_from":"2026-09-15","date_to":"2026-09-17","modality":"IN_PERSON","service_terms":["corte de pelo","barbería","peluquería"]}]},"next_step_hint":{"type":"string","title":"Next Step Hint","description":"Qué hacer ahora, en una frase: reservar en uno de los resultados, probar en otra comuna o ampliar el radio.","examples":["3 locales con hora en Ñuñoa. El más cercano es Barbería Ñuñoa, a 2,4 km, con hueco mañana a las 09:00. Pide su agenda con `get_availability_matrix` usando su `slug` y el `service_id` que trae."]}},"type":"object","required":["total","query_understood","next_step_hint"],"title":"SearchLukinServicesOutput","description":"El resultado de la búsqueda, con lo que hay y con qué hacer a continuación."},"SearchPage":{"properties":{"items":{"items":{"$ref":"#/components/schemas/SearchResult"},"type":"array","title":"Items","description":"Filas de esta página, ya ordenadas."},"total":{"type":"integer","title":"Total","description":"Filas totales que cumplen el filtro.","examples":[47]},"page":{"type":"integer","title":"Page","description":"Página devuelta, empezando en 1.","examples":[1]},"page_size":{"type":"integer","title":"Page Size","description":"Resultados por página aplicados (máximo 20).","examples":[20]}},"type":"object","required":["items","total","page","page_size"],"title":"SearchPage","description":"Una página de resultados del buscador.\n\n`total` es el número de locales que cumplen el filtro, no los de esta\npágina: es lo que permite al marketplace pintar «47 resultados» y decidir si\nhay más que pedir.","examples":[{"items":[{"business_id":"3f2b9a10-0000-4000-8000-aaaaaaaaaaaa","comuna":"Ñuñoa","distance_km":1.284,"logo_url":"/media/businesses/3f2b9a10-0000-4000-8000-aaaaaaaaaaaa/logo-1a2b.webp","matched_services":[{"duration_minutes":30,"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","modality":"IN_PERSON","name":"Corte de pelo","price_clp":15000}],"name":"Barbería Ñuñoa","rating_avg":4.7,"rating_count":128,"slug":"barberia-nunoa"}],"page":1,"page_size":20,"total":1}]},"SearchResult":{"properties":{"business_id":{"type":"string","format":"uuid","title":"Business Id","description":"Identificador del local."},"slug":{"type":"string","title":"Slug","description":"Identificador del local en la URL del marketplace.","examples":["barberia-nunoa"]},"name":{"type":"string","title":"Name","description":"Nombre comercial del local."},"comuna":{"type":"string","title":"Comuna","description":"Comuna del local, con la grafía del catálogo."},"distance_km":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Distance Km","description":"Distancia en kilómetros entre el punto de referencia y el local, redondeada al metro. Es `null` cuando la búsqueda no llevaba punto de referencia (ni `lat`+`lng` ni `comuna`)."},"rating_avg":{"type":"number","title":"Rating Avg","description":"Media de las valoraciones recibidas, de 0 a 5."},"rating_count":{"type":"integer","title":"Rating Count","description":"Número de valoraciones que componen la media."},"logo_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo Url","description":"Logotipo del local, o `null`."},"matched_services":{"items":{"$ref":"#/components/schemas/ServiceMatch"},"type":"array","title":"Matched Services","description":"Hasta 5 servicios activos que coinciden con `q`. Si el local coincidió por su nombre, por su descripción o por su equipo —o si no se buscó texto— viajan 3 servicios activos como muestra del catálogo. En una búsqueda con `date`, el **primero** es el servicio al que pertenece `next_available_slot`; los que se evaluaron antes que él y no tenían hueco ese día no se listan."},"next_available_slot":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Next Available Slot","description":"Primera hora libre del local ese día para `matched_services[0]` —el primer servicio candidato con cupo—, en **hora local del local** con su offset (`2026-09-15T09:00:00-03:00`): la misma cadena que devuelve `GET /api/v1/public/businesses/{slug}/availability` para ese servicio y ese día, que es donde se reserva. Es `null` cuando la búsqueda no llevaba `date`: consultar la agenda es caro y no se hace si no se pide. Con `date`, un local sin cupo no aparece en la respuesta, así que este campo nunca es `null` en una búsqueda con fecha."}},"type":"object","required":["business_id","slug","name","comuna","rating_avg","rating_count"],"title":"SearchResult","description":"Un local en la lista de resultados, con por qué salió y a qué distancia.\n\nNo es la ficha del local: es la tarjeta que decide si merece la pena\nabrirla. Por eso no viajan ni la galería, ni el horario, ni la dirección\n—que sí están en `GET /api/v1/public/businesses/{slug}`— y sí viajan la\ndistancia, la valoración y los servicios que coincidieron con la búsqueda.","examples":[{"business_id":"3f2b9a10-0000-4000-8000-aaaaaaaaaaaa","comuna":"Ñuñoa","distance_km":1.284,"logo_url":"/media/businesses/3f2b9a10-0000-4000-8000-aaaaaaaaaaaa/logo-1a2b.webp","matched_services":[{"duration_minutes":30,"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","modality":"IN_PERSON","name":"Corte de pelo","price_clp":15000}],"name":"Barbería Ñuñoa","rating_avg":4.7,"rating_count":128,"slug":"barberia-nunoa"}]},"ServiceCreate":{"properties":{"name":{"type":"string","maxLength":120,"minLength":2,"title":"Name","description":"Nombre de la prestación tal y como la verá el cliente al reservar."},"description":{"anyOf":[{"type":"string","maxLength":1000},{"type":"null"}],"title":"Description","description":"Texto libre que explica en qué consiste el servicio. Opcional: si llega en blanco se guarda como nulo."},"price_clp":{"type":"integer","minimum":0.0,"title":"Price Clp","description":"Precio en **pesos chilenos**, como número entero y sin decimales ni separadores (`15000` son $15.000). El cero es válido y describe un servicio de cortesía; el importe se copia como foto en la reserva, así que cambiarlo aquí no altera ninguna cita ya hecha."},"duration_minutes":{"type":"integer","multipleOf":5.0,"maximum":480.0,"minimum":5.0,"title":"Duration Minutes","description":"Duración de la cita en minutos, entre 5 y 480 y siempre múltiplo de 5: la agenda avanza en pasos de `slot_interval_minutes` y una duración fuera de esa rejilla desalinearía todos los horarios del día."},"modality":{"$ref":"#/components/schemas/ServiceModality","description":"Cómo se presta: `IN_PERSON` en la dirección del local u `ONLINE` por videollamada. En ambos casos ocupa la agenda del trabajador."},"requires_prepayment":{"type":"boolean","title":"Requires Prepayment","description":"Si es `true`, la reserva exige pasar por el **checkout de pago antes de confirmarse**: el cliente recibe un enlace de Mercado Pago y la cita queda pendiente hasta que el pago se aprueba.","default":false},"is_active":{"type":"boolean","title":"Is Active","description":"Un servicio inactivo se retira del catálogo público y deja de poder reservarse, pero sigue visible en el panel y conserva su historial.","default":true}},"type":"object","required":["name","price_clp","duration_minutes","modality"],"title":"ServiceCreate","description":"Alta de una prestación en el catálogo del negocio.\n\n`business_id` no se pide: sale del path de la ruta y de la membresía que ya\nresolvió `require_business_member`. Enviarlo en el cuerpo permitiría intentar\ncolar un servicio en el catálogo de otro local.","examples":[{"description":"Corte clásico con máquina y tijera, incluye lavado.","duration_minutes":30,"is_active":true,"modality":"IN_PERSON","name":"Corte de pelo","price_clp":15000,"requires_prepayment":false}]},"ServiceMatch":{"properties":{"id":{"type":"string","format":"uuid","title":"Id","description":"Identificador del servicio, con el que se pide agenda."},"name":{"type":"string","title":"Name","description":"Nombre de la prestación tal y como la ofrece el local."},"price_clp":{"type":"integer","title":"Price Clp","description":"Precio en pesos chilenos, entero y sin decimales (`15000` son $15.000)."},"duration_minutes":{"type":"integer","title":"Duration Minutes","description":"Duración de la cita en minutos."},"modality":{"$ref":"#/components/schemas/ServiceModality","description":"`IN_PERSON` en la dirección del local u `ONLINE` por videollamada."}},"type":"object","required":["id","name","price_clp","duration_minutes","modality"],"title":"ServiceMatch","description":"El servicio por el que un local aparece en los resultados.\n\nCinco campos: lo que cabe en la línea de una tarjeta de resultado. La ficha\ncompleta —descripción, prepago, quién lo presta— está a un clic, en\n`GET /api/v1/public/businesses/{slug}`."},"ServiceMetrics":{"properties":{"service_id":{"type":"string","format":"uuid","title":"Service Id","description":"Servicio del catálogo, aunque hoy esté retirado (`is_active=false`)."},"name":{"type":"string","title":"Name","description":"Nombre del servicio tal y como lo tiene el catálogo ahora.","examples":["Corte de pelo"]},"bookings":{"type":"integer","title":"Bookings","description":"Citas `CONFIRMED` o `PAID` de ese servicio en la ventana.","examples":[11]},"revenue_clp":{"type":"integer","title":"Revenue Clp","description":"Suma de los precios **pactados** en esas mismas citas, en pesos enteros. Es su aporte a `booked_value_clp`.","examples":[165000]}},"type":"object","required":["service_id","name","bookings","revenue_clp"],"title":"ServiceMetrics","description":"Un servicio del ranking: cuántas citas trajo y cuánto valen.\n\n`bookings` y `revenue_clp` describen **el mismo conjunto** de reservas —las\n`CONFIRMED` y `PAID` de este servicio dentro de la ventana—, de modo que la\nfila se puede leer sola: «once cortes, ciento sesenta y cinco mil pesos».\nEse importe es por tanto la parte de `booked_value_clp` que aporta este\nservicio, **no** la de `revenue_clp`: el dinero cobrado lo registra un\n`Payment` que no sabe de servicios, y repartirlo por catálogo sería inventar\nuna imputación que la base no tiene.","examples":[{"bookings":11,"name":"Corte de pelo","revenue_clp":165000,"service_id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb"},{"bookings":8,"name":"Color","revenue_clp":120000,"service_id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbc"}]},"ServiceModality":{"type":"string","enum":["ONLINE","IN_PERSON"],"title":"ServiceModality","description":"Cómo se presta el servicio (RF-02.02, filtro del buscador de RF-04.01).\n\n- `ONLINE`: videollamada o atención remota; no consume el aforo del local.\n- `IN_PERSON`: presencial en la dirección del negocio.\n\nEs el valor por defecto `IN_PERSON` el que refleja el caso habitual del\nmarketplace; un servicio online sigue ocupando la agenda del trabajador."},"ServiceOut":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"business_id":{"type":"string","format":"uuid","title":"Business Id","description":"Negocio al que pertenece el servicio."},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"price_clp":{"type":"integer","title":"Price Clp","description":"Precio en **pesos chilenos**, como número entero y sin decimales ni separadores (`15000` son $15.000). El cero es válido y describe un servicio de cortesía; el importe se copia como foto en la reserva, así que cambiarlo aquí no altera ninguna cita ya hecha."},"duration_minutes":{"type":"integer","title":"Duration Minutes","description":"Duración de la cita en minutos, entre 5 y 480 y siempre múltiplo de 5: la agenda avanza en pasos de `slot_interval_minutes` y una duración fuera de esa rejilla desalinearía todos los horarios del día."},"modality":{"$ref":"#/components/schemas/ServiceModality","description":"Cómo se presta: `IN_PERSON` en la dirección del local u `ONLINE` por videollamada. En ambos casos ocupa la agenda del trabajador."},"requires_prepayment":{"type":"boolean","title":"Requires Prepayment","description":"Si es `true`, la reserva exige pasar por el **checkout de pago antes de confirmarse**: el cliente recibe un enlace de Mercado Pago y la cita queda pendiente hasta que el pago se aprueba."},"is_active":{"type":"boolean","title":"Is Active","description":"Un servicio inactivo se retira del catálogo público y deja de poder reservarse, pero sigue visible en el panel y conserva su historial."},"has_bookings":{"type":"boolean","title":"Has Bookings","description":"`true` si al menos una reserva —de cualquier estado, también cancelada— apunta a este servicio. Es lo que decide si un `DELETE` lo borra de verdad o solo lo desactiva, así que el panel puede advertirlo antes de pulsar."},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"}},"type":"object","required":["id","business_id","name","price_clp","duration_minutes","modality","requires_prepayment","is_active","has_bookings","created_at","updated_at"],"title":"ServiceOut","description":"Vista de una prestación para el equipo del negocio."},"ServicePublic":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"name":{"type":"string","title":"Name","description":"Nombre de la prestación tal y como se ofrece al cliente."},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"Detalle libre del servicio; `null` si el local no lo escribió."},"price_clp":{"type":"integer","title":"Price Clp","description":"Precio en **pesos chilenos**, entero y sin decimales (`15000` son $15.000). Es el importe que se cobrará si el servicio exige prepago."},"duration_minutes":{"type":"integer","title":"Duration Minutes","description":"Duración de la cita en minutos, siempre múltiplo de 5."},"modality":{"$ref":"#/components/schemas/ServiceModality","description":"`IN_PERSON` en la dirección del local u `ONLINE` por videollamada."},"requires_prepayment":{"type":"boolean","title":"Requires Prepayment","description":"Si es `true`, la reserva pasa por el **checkout de Mercado Pago antes de confirmarse** y queda pendiente hasta que el pago se aprueba."}},"type":"object","required":["id","name","price_clp","duration_minutes","modality","requires_prepayment"],"title":"ServicePublic","description":"Una prestación del catálogo, tal y como la ve quien aún no ha reservado.\n\nSolo se sirven servicios **activos**, así que no hay `is_active` que\npublicar: si está en la lista, se puede reservar. Tampoco viaja el\n`business_id` —la ficha ya dice de qué negocio es— ni `has_bookings`, que es\nun dato de gestión interna.","examples":[{"description":"Corte clásico con máquina y tijera, incluye lavado.","duration_minutes":30,"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","modality":"IN_PERSON","name":"Corte de pelo","price_clp":15000,"requires_prepayment":false}]},"ServiceRefOut":{"properties":{"id":{"type":"string","title":"Id","description":"UUID del servicio, el que hay que usar para reservar."},"name":{"type":"string","title":"Name","description":"Nombre del servicio tal y como lo publica el negocio."},"duration_minutes":{"type":"integer","title":"Duration Minutes","description":"Cuánto dura la cita en minutos; es exactamente la diferencia entre `ends_at` y `starts_at` de cada hueco."},"price_clp":{"type":"integer","title":"Price Clp","description":"Precio en pesos chilenos, sin decimales ni separadores."},"requires_prepayment":{"type":"boolean","title":"Requires Prepayment","description":"`true` si el negocio exige pagar por internet antes de confirmar la cita; dilo antes de reservar."}},"type":"object","required":["id","name","duration_minutes","price_clp","requires_prepayment"],"title":"ServiceRefOut","description":"El servicio cuya agenda se ha calculado, con lo que hay que decirle a la persona."},"ServiceSummary":{"properties":{"id":{"type":"string","format":"uuid","title":"Id","description":"Identificador del servicio en el catálogo del local."},"name":{"type":"string","title":"Name","description":"Nombre de la prestación tal y como la ofrece el local."},"duration_minutes":{"type":"integer","title":"Duration Minutes","description":"Duración de la cita en minutos; es la que separa `starts_at` de `ends_at`."},"price_clp":{"type":"integer","title":"Price Clp","description":"Precio **actual** del catálogo, en pesos chilenos enteros. El importe pactado en la reserva es `BookingOut.price_clp`.","examples":[15000]},"modality":{"$ref":"#/components/schemas/ServiceModality","description":"`IN_PERSON` en la dirección del local u `ONLINE` por videollamada."},"requires_prepayment":{"type":"boolean","title":"Requires Prepayment","description":"Si es `true`, la cita exige pasar por el checkout de Mercado Pago. Viaja en cada reserva para que el cliente sepa si le queda algo por pagar sin consultar el catálogo."}},"type":"object","required":["id","name","duration_minutes","price_clp","modality","requires_prepayment"],"title":"ServiceSummary","description":"La prestación reservada, congelada en el momento de la consulta.\n\n`requires_prepayment` es **obligatorio** (decisión global 13): con él, la\npantalla de `/mi-cuenta` y el detalle de la reserva deciden si enseñan\n«Pagar ahora» sin pedir el catálogo aparte, que sería una llamada por fila.\n\nLo que aquí se lee es el `Service` **de ahora**, no el de cuando se reservó:\nel precio pactado vive en `BookingOut.price_clp` (ADR-020) y puede diferir\nde `ServiceSummary.price_clp` si el local subió la tarifa después. Los dos\nviajan a propósito: uno es lo que se cobra, el otro lo que cuesta hoy.","examples":[{"duration_minutes":30,"id":"3f2b9a10-0000-4000-8000-bbbbbbbbbbbb","modality":"IN_PERSON","name":"Corte de pelo","price_clp":15000,"requires_prepayment":false}]},"ServiceUpdate":{"properties":{"name":{"anyOf":[{"type":"string","maxLength":120,"minLength":2},{"type":"null"}],"title":"Name","description":"Nombre de la prestación tal y como la verá el cliente al reservar."},"description":{"anyOf":[{"type":"string","maxLength":1000},{"type":"null"}],"title":"Description","description":"Texto libre que explica en qué consiste el servicio. Opcional: si llega en blanco se guarda como nulo."},"price_clp":{"anyOf":[{"type":"integer","minimum":0.0},{"type":"null"}],"title":"Price Clp","description":"Precio en **pesos chilenos**, como número entero y sin decimales ni separadores (`15000` son $15.000). El cero es válido y describe un servicio de cortesía; el importe se copia como foto en la reserva, así que cambiarlo aquí no altera ninguna cita ya hecha."},"duration_minutes":{"anyOf":[{"type":"integer","multipleOf":5.0,"maximum":480.0,"minimum":5.0},{"type":"null"}],"title":"Duration Minutes","description":"Duración de la cita en minutos, entre 5 y 480 y siempre múltiplo de 5: la agenda avanza en pasos de `slot_interval_minutes` y una duración fuera de esa rejilla desalinearía todos los horarios del día."},"modality":{"anyOf":[{"$ref":"#/components/schemas/ServiceModality"},{"type":"null"}],"description":"Cómo se presta: `IN_PERSON` en la dirección del local u `ONLINE` por videollamada. En ambos casos ocupa la agenda del trabajador."},"requires_prepayment":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Requires Prepayment","description":"Si es `true`, la reserva exige pasar por el **checkout de pago antes de confirmarse**: el cliente recibe un enlace de Mercado Pago y la cita queda pendiente hasta que el pago se aprueba."},"is_active":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Active","description":"Un servicio inactivo se retira del catálogo público y deja de poder reservarse, pero sigue visible en el panel y conserva su historial."}},"type":"object","title":"ServiceUpdate","description":"Edición parcial: solo se modifica lo que venga en el cuerpo.\n\nCada campo repite **exactamente** los límites del alta. Si divergieran, el\nformulario de edición del panel aceptaría lo que el de creación rechaza (o\nal revés) y el contrato dejaría de ser una sola fuente.","examples":[{"price_clp":20000,"requires_prepayment":true}]},"SitemapEntry":{"properties":{"slug":{"type":"string","title":"Slug","description":"Identificador del local en la URL del marketplace.","examples":["barberia-nunoa"]},"updated_at":{"type":"string","format":"date-time","title":"Updated At","description":"Última modificación del perfil comercial, en UTC. Es el `lastmod` de la entrada del sitemap."}},"type":"object","required":["slug","updated_at"],"title":"SitemapEntry","description":"Un local publicado, reducido a lo que necesita un `<url>` del sitemap."},"SitemapPage":{"properties":{"items":{"items":{"$ref":"#/components/schemas/SitemapEntry"},"type":"array","title":"Items","description":"Filas de esta página, ya ordenadas."},"total":{"type":"integer","title":"Total","description":"Filas totales que cumplen el filtro.","examples":[47]},"page":{"type":"integer","title":"Page","description":"Página devuelta, empezando en 1.","examples":[1]},"page_size":{"type":"integer","title":"Page Size","description":"Entradas por página aplicadas (máximo 500).","examples":[500]}},"type":"object","required":["items","total","page","page_size"],"title":"SitemapPage","description":"Una página del catálogo de locales publicados, en orden estable.\n\nLa recorre el generador del sitemap de F7-T01 pidiendo páginas hasta agotar\n`total`. El orden (`updated_at` descendente, `id` ascendente) es **estable**:\nsin el desempate por `id`, dos locales guardados en la misma transacción\ncomparten `updated_at` y podrían repetirse o desaparecer entre páginas.","examples":[{"items":[{"slug":"barberia-nunoa","updated_at":"2026-08-30T14:05:11.482913Z"},{"slug":"spa-la-florida","updated_at":"2026-08-29T09:12:00.000000Z"}],"page":1,"page_size":500,"total":2}]},"SlotOut":{"properties":{"starts_at":{"type":"string","format":"date-time","title":"Starts At","description":"Inicio del hueco en **hora local del negocio**, ISO 8601 con offset (`-03:00` o `-04:00` en Chile, según la fecha)."},"ends_at":{"type":"string","format":"date-time","title":"Ends At","description":"Fin del hueco, también con offset. La diferencia con `starts_at` es la duración del servicio."},"staff_member_id":{"type":"string","format":"uuid","title":"Staff Member Id","description":"Plaza que atendería la cita. Es el identificador que se envía al crear la reserva y el mismo que publica el equipo del local."},"staff_name":{"type":"string","title":"Staff Name","description":"Nombre visible de esa persona, tal y como lo presenta el local."}},"type":"object","required":["starts_at","ends_at","staff_member_id","staff_name"],"title":"SlotOut","description":"Un hueco reservable concreto, con la persona que lo atendería.\n\n`starts_at` es el valor que se envía tal cual al crear la reserva (F4-T04):\nla API acepta el instante con offset y no exige convertirlo a UTC.","examples":[{"ends_at":"2026-03-10T09:30:00-03:00","staff_member_id":"3f2b9a10-0000-4000-8000-cccccccccccc","staff_name":"Ana","starts_at":"2026-03-10T09:00:00-03:00"},{"ends_at":"2026-03-10T09:30:00-03:00","staff_member_id":"3f2b9a10-0000-4000-8000-dddddddddddd","staff_name":"Benjamín","starts_at":"2026-03-10T09:00:00-03:00"},{"ends_at":"2026-03-10T09:45:00-03:00","staff_member_id":"3f2b9a10-0000-4000-8000-cccccccccccc","staff_name":"Ana","starts_at":"2026-03-10T09:15:00-03:00"}]},"SsoLoginRequest":{"properties":{"id_token":{"type":"string","maxLength":8192,"minLength":1,"title":"Id Token","description":"`id_token` (JWT) emitido por el proveedor para esta aplicación. Se verifica firma RS256, `aud`, `iss`, `exp` e `iat`."},"accept_privacy":{"type":"boolean","title":"Accept Privacy","description":"Aceptación expresa de los Términos y de la Política de Privacidad. Obligatoria solo en el primer acceso, cuando la cuenta aún no existe.","default":false}},"type":"object","required":["id_token"],"title":"SsoLoginRequest","description":"Cuerpo de `POST /api/v1/auth/sso/{provider}` (F2-T07).\n\n`id_token` es el JWT que el SDK del proveedor entrega al frontend; el\nbackend lo verifica contra el JWKS de Google o de Apple y **nunca** se fía\nde nada que venga fuera de él (ni correo, ni nombre, ni `sub`): todo lo que\nidentifica al titular sale de los claims firmados.\n\n`accept_privacy` solo se mira cuando el correo del token no corresponde a\nninguna cuenta: aceptar es lo que permite crearla y deja la prueba en\n`consent_records` (SEC-01). Para una cuenta que ya existe el campo es\nirrelevante y la sesión se abre igual.","examples":[{"accept_privacy":true,"id_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6IjEyMyJ9…"}]},"StaffInvite":{"properties":{"email":{"type":"string","format":"email","title":"Email","description":"Correo de la persona invitada. Si ya tiene cuenta en Lukin se reutiliza; si no, se crea una sin contraseña que entrará con el enlace del correo."},"display_name":{"type":"string","maxLength":80,"minLength":2,"title":"Display Name","description":"Nombre con el que esta persona aparece en la vitrina de **este** negocio. Es propio de cada local, no el nombre de la cuenta."}},"type":"object","required":["email","display_name"],"title":"StaffInvite","description":"Alta de una persona en la plantilla a partir de su correo.","examples":[{"display_name":"Ana","email":"ana@peluqueria-lukin.cl"}]},"StaffOccupancy":{"properties":{"staff_member_id":{"type":"string","format":"uuid","title":"Staff Member Id","description":"Plaza del equipo: la columna del calendario."},"display_name":{"type":"string","title":"Display Name","description":"Nombre con el que el local presenta a esa persona.","examples":["Ana"]},"booked_minutes":{"type":"integer","title":"Booked Minutes","description":"Minutos vendidos: la duración de sus citas `CONFIRMED`, `PAID` y `NO_SHOW`. La ausencia cuenta porque la hora se ocupó igual.","examples":[1200]},"available_minutes":{"type":"integer","title":"Available Minutes","description":"Minutos que podía atender en el periodo. Cero si no tenía turno.","examples":[2400]},"rate":{"type":"number","maximum":1.0,"minimum":0.0,"title":"Rate","description":"`booked_minutes / available_minutes`, acotado a `[0, 1]`. Vale `0` cuando no tenía ni un minuto disponible: sin agenda no hay ocupación que medir.","examples":[0.5]}},"type":"object","required":["staff_member_id","display_name","booked_minutes","available_minutes","rate"],"title":"StaffOccupancy","description":"Cuánta de su agenda tiene ocupada una persona del equipo.\n\n`available_minutes` son los minutos que esa persona **podía** atender:\nhorario del local ∩ su turno semanal, menos bloqueos y colaciones. No\nincluye las reservas: descontarlas también sería restar dos veces lo mismo y\ndejaría a todo el mundo con una ocupación del cien por cien.","examples":[{"available_minutes":2400,"booked_minutes":1200,"display_name":"Ana","rate":0.5,"staff_member_id":"3f2b9a10-0000-4000-8000-cccccccccccc"},{"available_minutes":2400,"booked_minutes":816,"display_name":"Benjamín","rate":0.34,"staff_member_id":"3f2b9a10-0000-4000-8000-dddddddddddd"}]},"StaffOut":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"user_id":{"type":"string","format":"uuid","title":"User Id","description":"Cuenta de Lukin que hay detrás de esta plaza."},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email","description":"Correo de la cuenta; `null` si se dio de alta solo con teléfono."},"display_name":{"type":"string","title":"Display Name"},"is_active":{"type":"boolean","title":"Is Active"},"is_owner":{"type":"boolean","title":"Is Owner","description":"`true` si esta plaza es la del propietario del negocio, que entró en la plantilla al crearlo y no puede invitarse a sí mismo."},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["id","user_id","display_name","is_active","is_owner","created_at"],"title":"StaffOut","description":"Una persona de la plantilla, tal y como la ve el equipo del negocio."},"StaffPublic":{"properties":{"id":{"type":"string","format":"uuid","title":"Id","description":"Identificador de la **plaza** en la plantilla de este negocio, que es el que se envía al pedir disponibilidad o al crear la reserva."},"display_name":{"type":"string","title":"Display Name","description":"Nombre con el que el local presenta a esta persona en su vitrina."}},"type":"object","required":["id","display_name"],"title":"StaffPublic","description":"Quién atiende, reducido a lo mínimo indispensable para elegir.\n\n**Dos campos y ni uno más.** El cliente necesita saber con quién se atiende y\npoder pedir disponibilidad de esa persona; para eso basta el `id` de la plaza\ny el nombre con el que el local la presenta. La cuenta que hay detrás\n(`user_id`), su correo y su teléfono son datos personales del trabajador y no\ntienen ninguna función en la vitrina (SEC-01).","examples":[{"display_name":"Ana","id":"3f2b9a10-0000-4000-8000-cccccccccccc"}]},"StaffServicesUpdate":{"properties":{"service_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Service Ids","description":"Servicios del catálogo de **este** negocio que la persona presta, activos o retirados. La lista es el conjunto completo: lo que no venga queda sin asignar, y `[]` la deja sin ninguno. Sin identificadores repetidos."}},"type":"object","required":["service_ids"],"title":"StaffServicesUpdate","description":"Conjunto **completo** de servicios que presta un trabajador (F3-T06).\n\nNo es un delta: lo que llega en `service_ids` sustituye a lo que hubiera. Se\neligió el reemplazo total —y no un `POST`/`DELETE` por asignación— porque el\npanel edita esta relación con una lista de casillas: enviar el estado final\nhace la operación idempotente y libra al cliente de calcular qué añadir y\nqué quitar.\n\nLos identificadores **no pueden repetirse**. La clave primaria de\n`staff_services` es `(staff_member_id, service_id)`, así que un duplicado no\ndescribe nada nuevo: casi siempre es un error del cliente al componer la\nlista, y devolverlo como `422` es más útil que absorberlo en silencio.","examples":[{"service_ids":["3f2b9a10-0000-4000-8000-aaaaaaaaaaaa","3f2b9a10-0000-4000-8000-bbbbbbbbbbbb"]},{"service_ids":[]}]},"StaffSummary":{"properties":{"id":{"type":"string","format":"uuid","title":"Id","description":"Identificador de la plaza en la plantilla del local."},"display_name":{"type":"string","title":"Display Name","description":"Nombre con el que el local presenta a esta persona."}},"type":"object","required":["id","display_name"],"title":"StaffSummary","description":"Quién atiende, en dos campos y ni uno más.\n\nEs el mismo recorte que publica `StaffPublic` (F3-T10) y por el mismo\nmotivo: la cuenta que hay detrás (`user_id`), su correo y su teléfono son\ndatos personales del trabajador y no tienen ninguna función en la pantalla\nde un cliente (SEC-01).","examples":[{"display_name":"Ana","id":"3f2b9a10-0000-4000-8000-cccccccccccc"}]},"StaffUpdate":{"properties":{"display_name":{"anyOf":[{"type":"string","maxLength":80,"minLength":2},{"type":"null"}],"title":"Display Name","description":"Nombre con el que esta persona aparece en la vitrina de **este** negocio. Es propio de cada local, no el nombre de la cuenta."},"is_active":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Active","description":"`false` retira a la persona de la agenda sin borrar su historial; `true` la reincorpora."}},"type":"object","title":"StaffUpdate","description":"Edición parcial: solo se toca lo que venga en el cuerpo.\n\n`is_active` es la **única** vía de reactivar a alguien que se dio de baja:\nvolver a invitarlo responde `409 STAFF_ALREADY_MEMBER`, porque la fila nunca\nse borra (las reservas históricas la referencian).","examples":[{"display_name":"Ana Pérez","is_active":true}]},"StatusBusinessOut":{"properties":{"name":{"type":"string","title":"Name","description":"Nombre comercial del local, tal y como se anuncia."},"slug":{"type":"string","title":"Slug","description":"Identificador del local en Lukin, p. ej. `barberia-demo`; sirve para volver a encontrarlo con la búsqueda."},"address":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Address","description":"Dirección de la calle, o `null` si el local no la publicó."}},"type":"object","required":["name","slug","address"],"title":"StatusBusinessOut","description":"El local de la cita: lo justo para decirle a alguien dónde presentarse."},"StatusServiceOut":{"properties":{"name":{"type":"string","title":"Name","description":"Nombre del servicio reservado, p. ej. `Corte de pelo`."},"price_clp":{"type":"integer","title":"Price Clp","description":"Precio pactado en ESTA reserva, en pesos chilenos enteros y sin decimales. Es la foto del precio del día en que se reservó, así que puede diferir del que hoy publica el catálogo."},"duration_minutes":{"type":"integer","title":"Duration Minutes","description":"Duración del servicio en minutos, la misma que ocupa la cita en la agenda."}},"type":"object","required":["name","price_clp","duration_minutes"],"title":"StatusServiceOut","description":"El servicio reservado, con el precio que se pactó en esta cita."},"SubscriptionCheckoutOut":{"properties":{"checkout_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Checkout Url","description":"URL de Mercado Pago en la que el titular autoriza la domiciliación (`init_point` del preapproval). Los datos de la tarjeta no pasan por Lukin.\n\n**`null` significa que no hay ninguna URL a la que mandar al titular.** El caso normal, y el único que se puede provocar, es el tramo contratado que no cuesta nada: ahí no se crea ninguna domiciliación, el alta queda **cerrada al responder** y el negocio ya está en su tramo nuevo.\n\nHay un segundo caso, anómalo: que Mercado Pago haya creado la domiciliación de un tramo **de pago** sin devolver su `init_point`. Entonces no hay a dónde ir pero tampoco hay nada cerrado: la suscripción queda contratada y **sin autorizar**, y el titular no ha pagado. No se ha observado nunca —la respuesta de creación de `/preapproval` siempre lo trae; el campo es opcional porque las de **consulta** no—, y no se convierte en un 502 porque la domiciliación ya existe y reintentar crearía una segunda, que es justo lo que el `MP_UNAVAILABLE` de esta operación promete que no ocurre. Lukin lo registra en su log.\n\n**Los dos casos no se distinguen desde este cuerpo**, y tampoco por el `status` que responde el `GET`: no hay un estado que signifique «anómalo». Lo que deja el segundo caso depende de dónde venía el negocio, medido en los tres caminos: `PAST_DUE` si no tenía ninguna suscripción vigente, `TRIAL` —con el tramo de pago ya escrito— si seguía en su prueba, que es el caso más común porque todo negocio nace así, y `ACTIVE` **en el tramo gratuito** si venía de él, que es indistinguible de un alta gratuita cerrada mirando solo `plan` y `status`.\n\nLa comprobación que sí vale, y vale en los cuatro caminos: el alta quedó cerrada si el `GET` de este recurso responde **el tramo que pediste** en `ACTIVE`. Cualquier otra cosa es una suscripción sin autorizar. Para el par que se parece —los dos `ACTIVE` sobre el tramo gratuito— sirve además `upgrade_pending`, que dice `true` exactamente en el anómalo: hay una subida contratada y sin cobrar.\n\nHagas la comprobación o no, la conducta correcta al recibir `null` es la misma: **repregunta el `GET` y pinta lo que responda**, nunca redirijas.\n\nFue una cadena vacía hasta esta versión, y por eso es `null` ahora: `\"\"` tipa como `string`, así que un cliente que redirigiera sin mirar acababa navegando a su propia página y el compilador no tenía nada que objetar."}},"type":"object","required":["checkout_url"],"title":"SubscriptionCheckoutOut","description":"A dónde mandar al dueño para que autorice el cobro mensual, si hay a dónde.","examples":[{"checkout_url":"https://www.mercadopago.cl/subscriptions/checkout?preapproval_id=2c9380849"}]},"SubscriptionCreate":{"properties":{"plan":{"$ref":"#/components/schemas/SubscriptionPlan","description":"Plan a contratar. Cambiar de plan es volver a llamar a esta ruta con el otro código, siempre que la suscripción no esté ya activa."},"billing_cycle":{"$ref":"#/components/schemas/BillingCycle","description":"Cada cuánto se domicilia. El anual cobra diez mensualidades de una vez y no se prorratea si se cambia a mitad de período. Pedirlo sobre un tramo que sí cobra, con `annual_available` en `false`, responde 422 `ANNUAL_BILLING_UNAVAILABLE`.\n\n**En un tramo de precio 0 este campo no significa nada y se ignora**: no hay cargo que repetir cada mes ni cada año, y la suscripción queda `MONTHLY` mandes lo que mandes. Se acepta a propósito, y no por indulgencia: `ANNUAL_BILLING_UNAVAILABLE` habla de un tope **por transacción** de Mercado Pago, y este tramo no llega a Mercado Pago. Rechazarlo además ataba el alta del gratuito a un interruptor que no habla de él —con el anual encendido se aceptaba, apagado se rechazaba—, de modo que decidir el precio anual de PREMIUM cambiaba de rebote si se podía contratar el tramo que no cuesta nada.","default":"MONTHLY"}},"type":"object","required":["plan"],"title":"SubscriptionCreate","description":"Plan que el negocio quiere contratar.","examples":[{"plan":"PREMIUM"}]},"SubscriptionOut":{"properties":{"plan":{"$ref":"#/components/schemas/SubscriptionPlan","description":"Plan contratado o en prueba."},"status":{"$ref":"#/components/schemas/SubscriptionStatus","description":"`TRIAL` mientras dura la prueba, `ACTIVE` con la domiciliación al día, `PAST_DUE` cuando un cobro falló —sigue dando servicio durante los días de gracia— y `CANCELLED` tras la baja."},"current_period_end":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Current Period End","description":"Fin del periodo ya pagado, es decir la fecha del próximo cargo. `null` mientras no se haya cobrado ningún mes."},"trial_ends_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Trial Ends At","description":"Instante en que caduca el periodo de prueba. `null` en una suscripción que nunca lo tuvo."},"grace_ends_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Grace Ends At","description":"Instante en que un `PAST_DUE` deja de dar servicio y el negocio se despublica: `current_period_end` más los días de gracia. `null` en cualquier otro estado, y también en un `PAST_DUE` cuyo cobro falló antes de que hubiera periodo pagado. Se publica para que el panel distinga «tu negocio dejará de estar visible» de «ya no lo está»: los días de gracia son configuración del despliegue y no viajan al cliente, así que sin esta fecha el aviso solo sabe hablar en futuro."},"provider_preapproval_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider Preapproval Id","description":"Identificador de la domiciliación en Mercado Pago, con el que el titular la encuentra en su propio panel. `null` cuando no hay ninguna: una suscripción que solo está en prueba y una del tramo gratuito, que no domicilia nada. Ojo: en el tramo gratuito **sí** viaja mientras hay una subida de tramo esperando autorización, y esa combinación es justo la que anuncia `upgrade_pending`; no la deduzcas por tu cuenta cruzando los dos campos."},"billing_cycle":{"$ref":"#/components/schemas/BillingCycle","description":"Ciclo con el que está domiciliado el plan contratado."},"amount_clp":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Amount Clp","description":"Importe **contratado**, en pesos chilenos enteros e impuestos incluidos: lo que se domicilia en cada cargo del `billing_cycle`. No tiene por qué coincidir con el precio que `GET /plans` publica hoy para ese tramo —una domiciliación en curso no cambia de importe porque suba la lista de precios—, así que el panel debe pintar **este** campo y no el del catálogo cuando quiera decir cuánto se está pagando. `null` cuando no hay importe contratado: una suscripción que solo está en prueba (nunca se domicilió nada) o una anterior a la versión que estrenó este campo, cuyo importe lo conserva Mercado Pago."},"upgrade_pending":{"type":"boolean","title":"Upgrade Pending","description":"Si hay una subida de tramo contratada y todavía **sin cobrar**. Ocurre al contratar un tramo de pago desde el gratuito: la suscripción conserva su tramo hasta que Mercado Pago confirme la autorización, así que durante esa ventana `plan` sigue diciendo `FREE` y `status` sigue diciendo `ACTIVE`. Es la única señal de que el alta está a medias: sin ella, la pantalla de vuelta del pago ve un `ACTIVE` y canta un éxito que todavía no ha ocurrido, mientras el panel sigue mostrando el tramo anterior. **No dice a qué tramo se sube**: eso solo lo sabe la domiciliación hasta que se cobra.","default":false},"marketplace_quota":{"anyOf":[{"$ref":"#/components/schemas/MarketplaceQuotaOut"},{"type":"null"}],"description":"Cuánto queda del tope mensual de reservas del marketplace. `null` cuando aquí no hay cupo del que hablar, por uno de dos motivos: el tramo contratado **no tiene tope** —hoy, los tres de pago— o la suscripción está **dada de baja**, y entonces el negocio ya no está publicado y el marketplace no le trae nada. Lo que `null` no significa nunca es «no se ha contado»: en un tramo con tope y vigente, el objeto llega siempre y con sus tres números.\n\nSe calcula en cada respuesta y no se guarda en ninguna columna: el recuento es una consulta sobre las reservas del mes y un contador materializado podría divergir del hecho que describe. Solo se consulta cuando hay tope que comprobar, así que la inmensa mayoría de las suscripciones —las de pago— no pagan ninguna cuenta de más.\n\nEs aquí y no en `GET /plans` porque el catálogo **se filtra**: con `SUBSCRIPTION_FREE_PUBLIC` apagado el tramo gratuito no se anuncia, y el panel del negocio que lo tiene contratado no encontraba su tramo para poder decir su tope. Este campo describe **esta** suscripción, así que funciona con el anuncio encendido y apagado."}},"type":"object","required":["plan","status","billing_cycle"],"title":"SubscriptionOut","description":"Estado de la suscripción SaaS de un negocio.","examples":[{"amount_clp":38430,"billing_cycle":"MONTHLY","current_period_end":"2026-09-30T14:00:00Z","plan":"PREMIUM","provider_preapproval_id":"2c93808493d7f4e60193e0a1b2c30001","status":"ACTIVE","trial_ends_at":"2026-08-15T14:00:00Z","upgrade_pending":false}]},"SubscriptionPlan":{"type":"string","enum":["FREE","INDIVIDUAL","BASIC","PREMIUM"],"title":"SubscriptionPlan","description":"Plan SaaS contratado por el negocio.\n\nCuatro tramos ordenados de menor a mayor: `FREE`, el gratuito con tope de\nreservas; `INDIVIDUAL` para quien trabaja solo; `BASIC` para un equipo\npequeño y `PREMIUM`, el tramo alto, con las métricas del panel."},"SubscriptionStatus":{"type":"string","enum":["TRIAL","ACTIVE","PAST_DUE","CANCELLED"],"title":"SubscriptionStatus","description":"Estado de la suscripción SaaS del negocio.\n\n`CANCELLED` es el único estado que **no** ocupa el índice único parcial\n`uq_subscriptions_business_id_active`: el historial de bajas puede tener\ntantas filas como haga falta, pero solo una suscripción vigente por negocio."},"UserBusinessOut":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"slug":{"type":"string","title":"Slug","description":"Identificador público en la URL del marketplace."},"name":{"type":"string","title":"Name"},"role":{"type":"string","enum":["OWNER","STAFF"],"title":"Role","description":"`OWNER` si es su dueño; `STAFF` si figura activo en la plantilla."}},"type":"object","required":["id","slug","name","role"],"title":"UserBusinessOut","description":"Un negocio al que el titular tiene acceso, y con qué papel.\n\n`role` es la **membresía en ese negocio**, no el rol global de la cuenta: el\nmismo usuario es `OWNER` en el local que fundó y `STAFF` en el de un colega."},"UserExport":{"properties":{"format_version":{"type":"string","const":"1","title":"Format Version","description":"Versión del formato de este documento.","default":"1"},"exported_at":{"type":"string","format":"date-time","title":"Exported At","description":"Instante en que se generó la copia, en UTC."},"user":{"$ref":"#/components/schemas/ExportUser"},"consents":{"items":{"$ref":"#/components/schemas/ConsentOut"},"type":"array","title":"Consents","description":"Histórico completo de consentimientos, revocados incluidos (F2-T09)."},"bookings":{"items":{"$ref":"#/components/schemas/ExportBooking"},"type":"array","title":"Bookings","description":"Todas las reservas del titular, de la más antigua."},"payments":{"items":{"$ref":"#/components/schemas/ExportPayment"},"type":"array","title":"Payments","description":"Cobros de esas reservas, de los más antiguos."},"reviews":{"items":{"$ref":"#/components/schemas/ExportReview"},"type":"array","title":"Reviews","description":"Valoraciones que escribió el titular."},"memberships":{"items":{"$ref":"#/components/schemas/ExportMembership"},"type":"array","title":"Memberships","description":"Negocios **vivos** en los que es propietario o trabajador activo. Un local dado de baja deja de figurar aquí, aunque las reservas que el titular hizo en él sigan en `bookings`."}},"type":"object","required":["exported_at","user"],"title":"UserExport","description":"Copia completa de los datos personales del titular (portabilidad, SEC-01).\n\nSale igual por las dos vías —`GET /api/v1/me/data-export` con sesión y\n`POST /api/v1/public/privacy/confirm` con código— porque es el mismo\nderecho ejercido con dos credenciales distintas.","examples":[{"bookings":[{"business":{"name":"Barbería Ñuñoa","slug":"barberia-nunoa"},"created_at":"2026-03-01T12:05:00Z","ends_at":"2026-03-02T13:30:00Z","id":"3f6c1f0e-2b1a-4c3d-9e8f-7a6b5c4d3e2f","price_clp":15000,"service":{"name":"Corte de pelo"},"source":"WEB","staff_name":"Ana","starts_at":"2026-03-02T13:00:00Z","status":"PAID"}],"consents":[{"consent_type":"PRIVACY_POLICY","granted_at":"2026-03-01T12:00:00Z","id":"0d1f2e3a-4b5c-6d7e-8f90-1a2b3c4d5e6f","ip":"198.51.100.9","policy_version":"2026-08-01","user_agent":"Mozilla/5.0"}],"exported_at":"2026-08-30T14:32:07.512Z","format_version":"1","memberships":[{"business_slug":"barberia-nunoa","role":"STAFF"}],"payments":[{"amount_clp":15000,"booking_id":"3f6c1f0e-2b1a-4c3d-9e8f-7a6b5c4d3e2f","id":"9a8b7c6d-5e4f-4a3b-8c9d-0e1f2a3b4c5d","paid_at":"2026-03-01T12:06:00Z","provider_payment_id":"1234567890","status":"APPROVED"}],"reviews":[{"booking_id":"3f6c1f0e-2b1a-4c3d-9e8f-7a6b5c4d3e2f","comment":"Impecable.","id":"1b2c3d4e-5f60-4718-9a2b-3c4d5e6f7a8b","published_at":"2026-03-02T14:00:00Z","rating":5}],"user":{"created_at":"2026-03-01T12:00:00Z","email":"javiera@example.cl","full_name":"Javiera Soto","id":"6f9619ff-8b86-d011-b42d-00c04fc964ff","phone":"+56912345678","role":"CLIENT"}}]},"UserMe":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email"},"full_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Full Name"},"phone":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Phone"},"role":{"$ref":"#/components/schemas/UserRole"},"businesses":{"items":{"$ref":"#/components/schemas/UserBusinessOut"},"type":"array","title":"Businesses","description":"Negocios propios y aquellos donde el titular está en plantilla, por nombre."},"consent_required":{"type":"boolean","title":"Consent Required","description":"`true` si faltan los Términos o la Política de Privacidad en su versión vigente. El cliente debe pedir la aceptación otra vez."}},"type":"object","required":["id","role","consent_required"],"title":"UserMe","description":"Perfil del usuario autenticado con lo que el frontend necesita al arrancar.\n\nAdemás de la identidad (`UserOut`) trae la cartera de negocios —para pintar\nel selector del panel sin una segunda llamada— y `consent_required`, que\nindica si hay que volver a pedir los textos legales porque cambió su versión\n(SEC-01)."},"UserOut":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email"},"full_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Full Name"},"phone":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Phone"},"role":{"$ref":"#/components/schemas/UserRole"}},"type":"object","required":["id","role"],"title":"UserOut","description":"Vista pública mínima del usuario autenticado."},"UserRole":{"type":"string","enum":["SUPERADMIN","OWNER","STAFF","CLIENT","GUEST"],"title":"UserRole","description":"Rol de la cuenta. `GUEST` es el Shadow User de RF-03.02: nace de una\nreserva sin login y se puede promover a `CLIENT` al registrarse."},"UserUpdate":{"properties":{"full_name":{"anyOf":[{"type":"string","maxLength":120,"minLength":2},{"type":"null"}],"title":"Full Name","description":"Nombre y apellido. `null` lo borra; omitirlo lo deja como está."},"phone":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Phone","description":"Teléfono en cualquier formato: se normaliza a E.164 asumiendo Chile si no trae prefijo. `null` lo borra; omitirlo lo deja como está."}},"type":"object","title":"UserUpdate","description":"Cambios parciales del perfil (`PATCH /auth/me`).\n\nSemántica de PATCH estricta, con **tres** estados por campo: ausente no toca\nnada, `null` **borra** el valor y una cadena lo sustituye. Sin esa\ndistinción no habría forma de retirar un teléfono, porque mandar `null` se\nconfundiría con no mandarlo.\n\nEl correo no se cambia aquí: mover la dirección de una cuenta es un cambio\nde identidad que exige verificar el destino, y ese es otro flujo.","examples":[{"full_name":"Ada Lovelace","phone":"9 1234 5678"},{}]},"WarningOut":{"properties":{"code":{"type":"string","title":"Code","description":"Qué se recortó, en mayúsculas: `DATE_FROM_ADJUSTED_TO_TODAY`, `RANGE_TRUNCATED_TO_14_DAYS` o `SLOTS_TRUNCATED`."},"message":{"type":"string","title":"Message","description":"El recorte explicado en una frase, lista para leérsela a la persona."}},"type":"object","required":["code","message"],"title":"WarningOut","description":"Un recorte aplicado a la petición, dicho en voz alta.\n\nNo es un error: la respuesta es válida y está completa **dentro** de lo que\nse contestó. El `code` es para ramificar y el `message` para que el agente\npueda explicárselo a la persona sin inventarse el motivo."},"WebhookAck":{"properties":{"status":{"type":"string","enum":["processed","duplicate","ignored","failed"],"title":"Status","description":"Desenlace de esta entrega. `processed`: se aplicó. `duplicate`: ya se había resuelto una entrega con el mismo `x-request-id`. `ignored`: auténtica, pero sin efecto en Lukin. `failed`: no se pudo aplicar y queda para conciliación manual."},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error","description":"Código corto del fallo cuando `status` es `failed` (`BUSINESS_MISMATCH`, `PAYMENT_AMOUNT_MISMATCH`, …); `null` en el resto de desenlaces."}},"type":"object","required":["status"],"title":"WebhookAck","description":"Lo que Lukin responde a Mercado Pago al recibir una notificación.\n\nQuien lo consume es una pasarela, y a una pasarela solo le importa el código\nHTTP: mientras sea `2xx` deja de reintentar. El cuerpo existe para el humano\nque depura una conciliación, y por eso `failed` viaja con `200` y no con un\n`5xx`: un importe descuadrado o una reserva de otro negocio no se arreglan\nreintentando, así que la entrega se cierra y el descuadre queda anotado en\n`webhook_events` con este mismo `error`.","examples":[{"status":"processed"}]},"AgentProblem":{"description":"Cuerpo de error de las rutas espejo del canal agéntico (F8-T09).\n\n**Por qué no es `ErrorResponse`.** Las cuatro rutas de `/api/v1/agent/*`\nejecutan las mismas funciones que las tools MCP y fallan con los mismos\n`code`, así que tienen que devolver lo mismo que el canal MCP: `code`,\n`message`, `hint`, `suggestions` y, cuando hay una espera que respetar,\n`retry_after`. `ErrorResponse` no tiene dónde poner las dos últimas —su\n`details` es un saco sin forma— y precisamente `suggestions` es lo que\nconvierte un «no» en un «prueba con esto», que es el único motivo por el que\nun agente no vuelve a preguntarle a la persona.\n\nLos tres campos de RFC 9457 (`type`, `title`, `status`) se añaden encima y no\nen lugar de nada: `type` apunta al ancla de la guía que explica ese código,\n`title` nombra el problema y `status` repite la línea de estado dentro del\ncuerpo, que es lo que permite reenviar el error entero a un modelo sin\narrastrar la respuesta HTTP.\n\n`request_id` se conserva **igual que en `ErrorResponse`**: es la clave con la\nque se localiza el traceback en los logs, y perderla en este canal habría\ndejado sin diagnóstico justo a las llamadas que no hace una persona.","properties":{"type":{"description":"URI que identifica el tipo de problema: el ancla de la guía para agentes que explica ese `code`. No es la dirección del error que acaba de ocurrir, sino la del tipo al que pertenece.","examples":["http://localhost:8000/api/v1/agent/guide#errors-SLOT_TAKEN"],"title":"Type","type":"string"},"title":{"description":"Resumen corto del tipo de problema, en español y estable: no cambia entre dos ocurrencias del mismo `code`.","examples":["La hora pedida ya no está disponible"],"title":"Title","type":"string"},"status":{"description":"El mismo código HTTP de la línea de estado, repetido dentro del cuerpo para que el documento se pueda reenviar entero.","examples":[409],"title":"Status","type":"integer"},"code":{"description":"Identificador estable del error, idéntico al que devuelve la tool MCP equivalente (`SLOT_TAKEN`, `INVALID_DATE`, `TOKEN_INVALID`…). Es el campo sobre el que hay que ramificar; nunca el `message`.","examples":["SLOT_TAKEN"],"title":"Code","type":"string"},"message":{"description":"Qué ha pasado, en una frase en español apta para leérsela a una persona.","examples":["Esa hora ya no está disponible."],"title":"Message","type":"string"},"hint":{"description":"Qué hacer a continuación. Cadena vacía cuando no hay nada que sugerir, nunca ausente: se puede leer sin comprobar si existe.","examples":["Elige una de las horas de `suggestions` y vuelve a llamar."],"title":"Hint","type":"string"},"suggestions":{"description":"Alternativas concretas que el agente puede ofrecer sin volver a preguntar: horas libres, slugs parecidos o el catálogo de servicios con su `service_id`. Lista vacía cuando no hay ninguna.","examples":[["2026-09-15T16:00:00-03:00","2026-09-15T16:30:00-03:00"]],"items":{"anyOf":[{"type":"string"},{"additionalProperties":true,"type":"object"}]},"title":"Suggestions","type":"array"},"retry_after":{"anyOf":[{"type":"integer"},{"type":"null"}],"default":null,"description":"Segundos que hay que esperar antes de reintentar. **Solo aparece cuando hay una espera que respetar** —hoy, el 429 del cupo— y coincide con la cabecera `Retry-After` de la respuesta.","examples":[30],"title":"Retry After"},"request_id":{"description":"Identificador de la petición, idéntico a la cabecera `X-Request-ID`. Es la clave para localizar el traceback en los logs del servidor.","examples":["3f2b9a10-0000-4000-8000-abcdefabcdef"],"title":"Request Id","type":"string"}},"required":["type","title","status","code","message","hint","suggestions","request_id"],"title":"AgentProblem","type":"object"}},"securitySchemes":{"HTTPBearer":{"type":"http","scheme":"bearer"}}},"tags":[{"name":"health","description":"Sondas de estado del servicio y de sus dependencias."},{"name":"auth","description":"Registro, inicio de sesión (magic link, OTP y SSO), rotación de tokens y cierre de sesión."},{"name":"businesses","description":"Gestión del negocio por su equipo: perfil comercial, horarios, servicios, staff y bloqueos. Todo el ámbito es multi-tenant."},{"name":"bookings","description":"Ciclo de vida de la reserva: disponibilidad, creación, confirmación, reprogramación y cancelación."},{"name":"public","description":"Marketplace público sin autenticación: buscador geoespacial, perfiles de negocios publicados y reserva de invitado."},{"name":"payments","description":"Suscripción SaaS del negocio, bóveda de credenciales de Mercado Pago, checkout delegado y webhooks de pago."},{"name":"compliance","description":"Derechos ARCO de la Ley 19.628: portabilidad de datos y derecho al olvido o anonimización, con consentimiento explícito."},{"name":"admin","description":"Operaciones reservadas al rol SuperAdmin de la plataforma."},{"name":"agent","description":"Superficie para agentes autónomos (Agentic Web y MCP): guía de uso, búsqueda de servicios, matriz de disponibilidad y pre-reserva."}]}