openapi: 3.1.0
info:
  title: Redfloral Partner API
  license:
    name: Uso restringido a socios de Redfloral
    identifier: LicenseRef-Redfloral-Partner
  version: 1.0.0-beta
  summary: API para florerías socias de Redfloral.
  description: |
    Catálogo, disponibilidad de despacho, pedidos y webhooks de una florería socia.
    El socio manda sus pedidos a Redfloral como **venta local** (cobrados en su propio
    sistema), Redfloral los despacha y le avisa de los cambios por webhook.

    **Beta durante el piloto**: el contrato puede cambiar con aviso directo a los socios;
    después se congela y solo se agregan campos opcionales. Los campos desconocidos del
    body se ignoran.

    Guías (flujo, casos que sorprenden, webhooks y verificación de firma):
    https://docs.redfloral.cl

    **Autenticación**: `Authorization: Bearer rf_live_<prefix>_<secreto>`. Una key pertenece
    a una florería; nunca se manda un `company_id`. **Límites**: 120 solicitudes/minuto de
    lectura y 120/hora de escritura por key (429 con `Retry-After`). Las respuestas que
    pasan por el límite traen `X-RateLimit-Limit` y `X-RateLimit-Remaining`; los 401 y 403
    de autenticación o scope no los traen.

    **Scopes**: `catalog:read`, `orders:read`, `orders:write` (los tres por defecto). Cada
    operación declara los suyos en `x-required-scopes`.

    **Listas paginadas**: `?page=` y `?page_size=` (máx. 100, por defecto 50).
    `zones`, `schedules` y `webhooks` devuelven todo en una sola página.
servers:
  - url: https://api.redfloral.cl/api/partner/v1
    description: Producción
security:
  - bearerAuth: []
x-example-values:
  order_item: &order_item
    id: 70123
    external_id: WC-10452
    external_url: https://mitienda.cl/wp-admin/post.php?post=10452
    status: {slug: processing, name: Aceptado}
    is_internal: true
    created_by_api: true
    secure_key: 6b3f3f0a-2c1e-4b6e-9f55-0d7b1a2c3d4e
    status_url: https://redfloral.cl/estado-del-pedido/6b3f3f0a-2c1e-4b6e-9f55-0d7b1a2c3d4e
    shipping_date: '2026-11-02'
    schedule_id: 31
    schedule_text: Entre 10:00 y 14:00 hrs
    zone_id: 12
    zone_text: Ñuñoa
    customer: {name: Ana Pérez, email: ana@correo.cl, phone_code: '56', phone: '912345678'}
    address: {type: DrParticular, name: María González, street: Los Aromos, street_number: '1234', more_info: Depto 501, phone: '987654321', latitude: -33.41, longitude: -70.6}
    card_message: Feliz cumpleaños
    anonymous: false
    total_products: 35980
    total_shipping: 3990
    total_discount: 0
    total: 39970
    delivered_at: null
    created_at: '2026-10-23T15:04:05Z'
    updated_at: '2026-10-23T15:04:05Z'
    warnings: []
  order_detail: &order_detail
    id: 70123
    external_id: WC-10452
    external_url: https://mitienda.cl/wp-admin/post.php?post=10452
    status: {slug: processing, name: Aceptado}
    is_internal: true
    created_by_api: true
    secure_key: 6b3f3f0a-2c1e-4b6e-9f55-0d7b1a2c3d4e
    status_url: https://redfloral.cl/estado-del-pedido/6b3f3f0a-2c1e-4b6e-9f55-0d7b1a2c3d4e
    shipping_date: '2026-11-02'
    schedule_id: 31
    schedule_text: Entre 10:00 y 14:00 hrs
    zone_id: 12
    zone_text: Ñuñoa
    customer: {name: Ana Pérez, email: ana@correo.cl, phone_code: '56', phone: '912345678'}
    address: {type: DrParticular, name: María González, street: Los Aromos, street_number: '1234', more_info: Depto 501, phone: '987654321', latitude: -33.41, longitude: -70.6}
    card_message: Feliz cumpleaños
    anonymous: false
    total_products: 35980
    total_shipping: 3990
    total_discount: 0
    total: 39970
    delivered_at: null
    created_at: '2026-10-23T15:04:05Z'
    updated_at: '2026-10-23T15:04:05Z'
    warnings: []
    url_photo: https://redfloral.cl/foto-de-entrega/6b3f3f0a-2c1e-4b6e-9f55-0d7b1a2c3d4e
    photos: null
    products:
      - {id: 1, product_id: 8891, variant_id: null, shared: false, name: Ramo Primavera, quantity: 1, price: 29990, note: '', options: []}
      - {id: 2, product_id: null, variant_id: null, shared: false, name: Globo personalizado, quantity: 1, price: 5990, note: '', options: []}
    history:
      - {status: processing, status_name: Aceptado, message: Pedido creado por API, created_at: '2026-10-23T15:04:05Z'}
    document_type: boleta
    document: null
  webhook_item: &webhook_item
    id: 3
    name: ERP
    url: https://mitienda.cl/redfloral/webhook
    events: [order.status_changed, order.photo_uploaded, product.updated, product.price_changed]
    is_active: true
    consecutive_failures: 0
    disabled_by_failures: false
    disabled_reason: null
    disabled_at: null
    last_delivery_at: null
    created_at: '2026-10-23T15:00:00Z'
    updated_at: '2026-10-23T15:00:00Z'
    secret: whsec_Zm9vYmFyYmF6cXV4MTIzNDU2Nzg5MGFiY2RlZg
tags:
  - name: Identidad
    description: Validar la key y consultar el plan.
  - name: Catálogo y despacho
    description: Productos, precios por fecha, comunas, horarios y disponibilidad.
  - name: Pedidos
    description: Crear, consultar y cancelar pedidos (ventas locales) y subirles la boleta o factura.
  - name: Webhooks
    description: Gestión de webhooks y consulta de entregas. Los eventos están en la sección `webhooks`.
paths:
  /ping:
    get:
      tags: [Identidad]
      operationId: ping
      summary: Valida la key
      description: Devuelve la florería y los scopes de la key. No exige ningún scope.
      x-required-scopes: []
      responses:
        '200':
          description: Key válida.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Ping'
              example:
                error: false
                company_id: 15
                company_name: Mi Florería
                key: {name: Tienda WooCommerce, prefix: a1b2c3d4}
                scopes: [orders:read, orders:write, catalog:read]
                server_time: '2026-10-23T15:04:05.123456Z'
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
  /me:
    get:
      tags: [Identidad]
      operationId: getMe
      summary: Plan y cupo de ventas locales
      description: |
        Permite avisar **antes** de que la florería quede bloqueada por excedente sin
        liquidar. Cada pedido por API consume cupo de venta local.
      x-required-scopes: [orders:read]
      responses:
        '200':
          description: Estado del plan.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Me'
              example:
                error: false
                company_id: 15
                plan: Plan Pro
                api_access: true
                orders_write_enabled: true
                free_orders_remaining: 12
                next_order_cost: 0
                pending_count: 0
                pending_limit: 20
                is_blocked: false
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
  /zones:
    get:
      tags: [Catálogo y despacho]
      operationId: listZones
      summary: Comunas activas de la florería
      description: |
        `id` es el `zone_id` del `POST /orders`. `name_cleaned` (minúsculas, sin acentos) sirve
        para mapear la comuna del cliente del socio.
      x-required-scopes: [catalog:read]
      responses:
        '200':
          description: Todas las comunas, en una sola página.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: {$ref: '#/components/schemas/Zone'}
              example:
                error: false
                data:
                  - {id: 12, name: Ñuñoa, name_cleaned: nunoa, price: 2500, time_end: '15:00', is_retirement: false}
                count: 1
                has_more: false
                page: 1
                page_size: 1
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
  /schedules:
    get:
      tags: [Catálogo y despacho]
      operationId: listSchedules
      summary: Plantilla semanal de horarios
      description: |
        `id` es el `shipping_schedule_id` del `POST /orders`. Para los horarios de un día
        concreto usa `/delivery-availability`.
      x-required-scopes: [catalog:read]
      responses:
        '200':
          description: Todos los horarios activos, en una sola página.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: {$ref: '#/components/schemas/Schedule'}
              example:
                error: false
                data:
                  - {id: 7, day: 0, type_day: AN, time_start: '10:00', time_end: '14:00', time_close: '09:00', price: 1000}
                count: 1
                has_more: false
                page: 1
                page_size: 1
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
  /delivery-availability:
    get:
      tags: [Catálogo y despacho]
      operationId: getDeliveryAvailability
      summary: Disponibilidad de despacho de una fecha y comuna
      description: |
        Mismas reglas que la web (día hábil, bloqueo de día, hora de corte, horarios abiertos,
        producto inactivo o excluido de alta demanda). Indica `zone_id` o `zone_text`; si el
        texto no coincide exactamente (sin importar mayúsculas ni acentos) responde 200 con
        `zone_id: null`, `available: false` y `reason: zone_not_matched`. No se cachea.
      x-required-scopes: [catalog:read]
      parameters:
        - name: date
          in: query
          required: true
          schema: {type: string, format: date}
          example: '2026-11-02'
        - name: zone_id
          in: query
          description: Id de `GET /zones`. Manda sobre `zone_text`.
          schema: {type: integer}
        - name: zone_text
          in: query
          description: Comuna en texto.
          schema: {type: string}
        - name: product_id
          in: query
          description: Considera producto inactivo / excluido de alta demanda.
          schema: {type: integer}
      responses:
        '200':
          description: Disponibilidad.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeliveryAvailability'
              example:
                error: false
                date: '2026-11-02'
                zone_id: 12
                zone_name: Ñuñoa
                available: true
                reason: null
                schedules:
                  - {id: 7, time_start: '10:00', time_end: '14:00', price: 3500}
                high_demand: false
                shipping_cost_base: 2500
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
  /products:
    get:
      tags: [Catálogo y despacho]
      operationId: listProducts
      summary: Productos de la florería, incluidos los que revende
      description: |
        Los revendidos llevan `shared: true` (precio, oferta, stock y estado del compartido;
        nombre, descripción y foto del original). El `id` es siempre el del producto original
        y es el `product_id` del `POST /orders`. Sin `is_active` lista activos e inactivos.
        Si Redfloral configuró la reventa para usar el precio y el stock del original, `price`,
        `price_sale`, `sale_date_end`, `is_stock` y `quantity` son los del original y `is_active`
        es `false` mientras el original esté inactivo o sin stock (sin cambio de forma).
      x-required-scopes: [catalog:read]
      parameters:
        - {name: search, in: query, schema: {type: string}}
        - {name: is_active, in: query, schema: {type: boolean}}
        - $ref: '#/components/parameters/UpdatedSince'
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PageSize'
      responses:
        '200':
          description: Página de productos.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: {$ref: '#/components/schemas/Product'}
              example:
                error: false
                data:
                  - id: 8891
                    shared: false
                    name: Ramo Primavera
                    description: Rosas y lirios.
                    type: 0
                    price: 25000
                    price_sale: null
                    sale_date_end: null
                    is_active: true
                    is_stock: true
                    quantity: 7
                    is_exclude_high_demand: false
                    photo: https://storage.googleapis.com/redfloral-bucket/p/8891.jpg
                    updated_at: '2026-10-23T12:00:00Z'
                count: 1
                has_more: false
                page: 1
                page_size: 50
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
  /products/prices:
    get:
      tags: [Catálogo y despacho]
      operationId: getProductPrices
      summary: Precio final por fecha de despacho
      description: |
        Misma fórmula del carrito: oferta vigente ese día y alta demanda de la florería.
        Sin `ids` pagina todo el catálogo. En productos tipo 3 los precios van en `variants` y
        en tipo 2 en `options` (`price*` es `null` en esos tipos).
      x-required-scopes: [catalog:read]
      parameters:
        - name: date
          in: query
          required: true
          schema: {type: string, format: date}
        - name: ids
          in: query
          description: Ids separados por coma (máx. 100).
          schema: {type: string}
          example: 8891,8892
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PageSize'
      responses:
        '200':
          description: Precios por producto.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    required: [date, high_demand_percentage]
                    properties:
                      date: {type: string, format: date}
                      high_demand_percentage: {type: integer}
                      data:
                        type: array
                        items: {$ref: '#/components/schemas/ProductPrice'}
              example:
                error: false
                date: '2026-11-02'
                high_demand_percentage: 10
                data:
                  - id: 8891
                    shared: false
                    type: 0
                    price: 27500
                    price_sale: 24750
                    final_price: 24750
                    high_demand_percentage: 10
                count: 1
                has_more: false
                page: 1
                page_size: 100
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
  /products/{id}:
    get:
      tags: [Catálogo y despacho]
      operationId: getProduct
      summary: Detalle de un producto
      description: Agrega imágenes, variantes y opciones al ítem del listado.
      x-required-scopes: [catalog:read]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: integer}
      responses:
        '200':
          description: Producto.
          content:
            application/json:
              schema:
                type: object
                required: [error, product]
                properties:
                  error: {type: boolean, const: false}
                  product: {$ref: '#/components/schemas/ProductDetail'}
              example:
                error: false
                product:
                  id: 8891
                  shared: false
                  name: Ramo Primavera
                  description: Rosas y lirios.
                  type: 0
                  price: 25000
                  price_sale: null
                  sale_date_end: null
                  is_active: true
                  is_stock: true
                  quantity: 7
                  is_exclude_high_demand: false
                  photo: https://storage.googleapis.com/redfloral-bucket/p/8891.jpg
                  updated_at: '2026-10-23T12:00:00Z'
                  is_message: true
                  message_max: 200
                  message_label: Mensaje de la tarjeta
                  personalized_min: null
                  personalized_max: null
                  personalized_max_combinations: 2
                  images:
                    - {id: 1, url: 'https://storage.googleapis.com/x/1.jpg', small: null, medium: 'https://storage.googleapis.com/x/1m.jpg', large: null}
                  variants: []
                  options: []
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
  /orders:
    get:
      tags: [Pedidos]
      operationId: listOrders
      summary: Pedidos de la florería
      description: |
        Incluye también los del marketplace y de la tienda de la florería. Se ven los mismos
        estados que en el panel. Para sincronizar usa `updated_since` (estricto `>`).
      x-required-scopes: [orders:read]
      parameters:
        - name: status
          in: query
          description: Uno o varios estados separados por coma.
          schema: {type: string}
          example: processing,in_transit,delivered
        - {name: shipping_date_from, in: query, schema: {type: string, format: date}}
        - {name: shipping_date_to, in: query, schema: {type: string, format: date}}
        - $ref: '#/components/parameters/UpdatedSince'
        - {name: external_id, in: query, schema: {type: string}}
        - {name: is_internal, in: query, schema: {type: boolean}}
        - {name: created_by_api, in: query, schema: {type: boolean}}
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PageSize'
      responses:
        '200':
          description: Página de pedidos, más recientes primero.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: {$ref: '#/components/schemas/Order'}
              example:
                error: false
                data:
                  - *order_item
                count: 1
                has_more: false
                page: 1
                page_size: 50
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
    post:
      tags: [Pedidos]
      operationId: createOrder
      summary: Crea un pedido (venta local)
      description: |
        Consume cupo de venta local y cobra excedente igual que el panel. Los totales se
        recalculan en el servidor (`total = Σ price × quantity - discount + shipping_cost`); el
        precio lo manda el socio. `discount` (opcional) es el descuento total del pedido en monto
        y no puede superar `total_products`. Lo que no se puede resolver (comuna, horario, fecha) **no falla**: el
        pedido se crea y la respuesta lleva `warnings`. Reintento con el mismo `external_id`
        → 200 con el pedido existente. Requiere además que la florería tenga habilitado el
        ingreso de ventas locales.

        `customer.email`, `customer.phone` y `shipping_cost` son obligatorios (`shipping_cost`
        mínimo 0; `0` es válido). Un producto que la florería revende (`shared: true`) queda
        vinculado al compartido y su stock se mueve igual que en el checkout web.

        Orden de validación: scope → body (400) → idempotencia (200) → zona/horario/productos
        (400) → bloqueo por excedente (422, no se crea nada).
      x-required-scopes: [orders:write]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OrderCreate'
            example:
              external_id: WC-10452
              external_url: https://mitienda.cl/wp-admin/post.php?post=10452
              customer: {name: Ana Pérez, email: ana@correo.cl, phone: '912345678', phone_code: '56'}
              address:
                zone_text: Ñuñoa
                zone_id: 12
                type: DrParticular
                name: María González
                street: Los Aromos
                street_number: '1234'
                more_info: Depto 501
                phone: '987654321'
                latitude: -33.41
                longitude: -70.6
              shipping_date: '2026-11-02'
              shipping_schedule_id: 7
              shipping_cost: 3990
              discount: 0
              card_message: Feliz cumpleaños
              anonymous: false
              document_type: boleta
              products:
                - {product_id: 8891, variant_id: null, quantity: 1, price: 29990, note: ''}
                - {product_id: 8892, quantity: 1, options: [{id: 501, quantity: 10}]}
                - {name: Globo personalizado, quantity: 1, price: 5990}
      responses:
        '201':
          description: Pedido creado.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/OrderResponse'}
              example:
                error: false
                created: true
                order: *order_detail
        '200':
          description: |
            Reintento: ya existía un pedido con ese `external_id` (`created: false`), o
            repetición de una `Idempotency-Key` (header `Idempotent-Replayed: true`, misma
            respuesta que la original).
          headers:
            Idempotent-Replayed:
              description: '`true` cuando la respuesta viene de una `Idempotency-Key` repetida.'
              schema: {type: string, const: 'true'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/OrderResponse'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '409':
          description: |
            `idempotency_conflict`: la `Idempotency-Key` ya se usó con otro cuerpo, o hay otra
            solicitud en curso con ella.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
              example: {error: true, code: idempotency_conflict, message: La Idempotency-Key ya se usó con un cuerpo distinto.}
        '422':
          description: |
            `internal_orders_blocked`: la florería alcanzó el límite de excedentes sin liquidar.
            No se creó nada.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
              example:
                error: true
                code: internal_orders_blocked
                message: Alcanzaste el límite de pedidos con costo de excedente sin liquidar. Liquida esas ventas para volver a registrar pedidos.
                pending_count: 20
                pending_limit: 20
        '429': {$ref: '#/components/responses/TooManyRequests'}
  /orders/{ref}:
    get:
      tags: [Pedidos]
      operationId: getOrder
      summary: Detalle de un pedido
      x-required-scopes: [orders:read]
      parameters:
        - $ref: '#/components/parameters/OrderRef'
      responses:
        '200':
          description: Pedido con productos, historial y foto de entrega.
          content:
            application/json:
              schema:
                type: object
                required: [error, order]
                properties:
                  error: {type: boolean, const: false}
                  order: {$ref: '#/components/schemas/OrderDetail'}
              example:
                error: false
                order: *order_detail
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
  /orders/{ref}/cancel:
    post:
      tags: [Pedidos]
      operationId: cancelOrder
      summary: Cancela un pedido creado por API
      description: |
        Solo pedidos creados por API en `pending` o `processing`. Devuelve el stock de los
        productos propios con control de stock y el cupo del compartido en los que revende; **no** revierte el
        costo de excedente. Es idempotente: cancelar un pedido ya cancelado responde 200.
      x-required-scopes: [orders:write]
      parameters:
        - $ref: '#/components/parameters/OrderRef'
      responses:
        '200':
          description: Pedido cancelado.
          content:
            application/json:
              schema:
                type: object
                required: [error, order]
                properties:
                  error: {type: boolean, const: false}
                  order: {$ref: '#/components/schemas/OrderDetail'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409':
          description: '`order_not_cancellable`: no lo creó la API o ya avanzó de estado (`status` en el cuerpo).'
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
              example: {error: true, code: order_not_cancellable, message: El pedido ya no se puede cancelar en su estado actual., status: in_transit}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '503':
          description: '`service_unavailable`: falta configuración interna en Redfloral.'
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
  /orders/{ref}/document:
    post:
      tags: [Pedidos]
      operationId: uploadOrderDocument
      summary: Sube la boleta o factura del pedido
      description: |
        Sube (o reemplaza) el documento tributario del pedido: archivo, folio y fecha de
        emisión. Vale para **cualquier** pedido de la florería (también los del marketplace,
        que lo necesitan para liquidarse), no solo los creados por API. El tipo de documento
        es el `document_type` del pedido, salvo que mandes `document_type` para corregirlo.

        Body `multipart/form-data` (no JSON). El folio no puede repetirse en otro pedido de
        la florería con el mismo tipo de documento (409 `duplicate_document`, con `order_id`
        del pedido que ya lo tiene). Redfloral revisa el documento con IA en segundo plano:
        la respuesta trae `document.validation = pending` y el resultado aparece después en
        `GET /orders/{ref}`. El cliente recibe el documento por email si mandas
        `notify_customer=true`, o si no lo mandas y la key tiene las notificaciones activadas. No exige la venta local habilitada (`orders_write_disabled`
        no aplica).
      x-required-scopes: [orders:write]
      parameters:
        - $ref: '#/components/parameters/OrderRef'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, number, date]
              properties:
                file: {type: string, format: binary, description: 'PDF, JPG o PNG, hasta 10 MB.'}
                number: {type: string, maxLength: 64, description: Folio del documento.}
                date: {type: string, format: date, description: Fecha de emisión.}
                document_type: {type: string, enum: [boleta, factura], description: 'Opcional. Cambia el `document_type` del pedido al del documento que subes; sin él, no cambia.'}
                notify_customer: {type: boolean, description: 'Opcional. `true` envía el documento por email al cliente y `false` no, solo en esta subida. Sin él rige la configuración de la key.'}
      responses:
        '200':
          description: Documento guardado; pedido actualizado.
          content:
            application/json:
              schema:
                type: object
                required: [error, order]
                properties:
                  error: {type: boolean, const: false}
                  order: {$ref: '#/components/schemas/OrderDetail'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '409':
          description: '`duplicate_document`: el folio ya está en otro pedido de la florería (`order_id` en el cuerpo).'
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
              example: {error: true, code: duplicate_document, message: 'El folio 1234 ya está registrado en el pedido #70100.', order_id: 70100}
        '415':
          description: '`unsupported_media_type`: el body no es `multipart/form-data`.'
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
  /webhooks:
    get:
      tags: [Webhooks]
      operationId: listWebhooks
      summary: Webhooks de la florería
      description: |
        Cualquier key válida gestiona los webhooks de su florería (los scopes solo limitan a
        qué eventos puede suscribirse). Incluye el `secret` para verificar la firma.
      x-required-scopes: []
      responses:
        '200':
          description: Todos los webhooks, en una sola página.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: {$ref: '#/components/schemas/Webhook'}
              example:
                error: false
                data:
                  - *webhook_item
                count: 1
                has_more: false
                page: 1
                page_size: 1
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
    post:
      tags: [Webhooks]
      operationId: createWebhook
      summary: Registra un webhook
      description: |
        La URL debe ser https y pública (se rechazan hosts que resuelven a IP privada,
        loopback o link-local, y URLs con credenciales); se vuelve a validar en cada envío.
        Cada evento exige su scope (`order.*` → `orders:read`; `product.*` y
        `availability.changed` → `catalog:read`), si no 403 `insufficient_scope`. Los
        comodines `*`, `order.*`, `product.*` y `availability.*` se expanden al registrar y
        **no incluyen `product.stock_changed`** (opt-in). Máximo 10 por florería.
      x-required-scopes: [orders:read, catalog:read]
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/WebhookCreate'}
            example:
              url: https://mitienda.cl/redfloral/webhook
              name: ERP
              events: [order.status_changed, order.photo_uploaded, product.updated, product.price_changed]
      responses:
        '201':
          description: Webhook creado.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WebhookResponse'}
              example:
                error: false
                webhook: *webhook_item
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '409':
          description: '`webhook_limit_reached`: máximo 10 webhooks por florería.'
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
  /webhooks/deliveries:
    get:
      tags: [Webhooks]
      operationId: listWebhookDeliveries
      summary: Últimos envíos
      description: 'Para depurar: resultado, intentos y último error de cada entrega.'
      x-required-scopes: []
      parameters:
        - {name: webhook_id, in: query, schema: {type: integer}}
        - name: event
          in: query
          schema: {type: string}
          example: order.created
        - name: status
          in: query
          schema: {$ref: '#/components/schemas/DeliveryStatus'}
        - name: include_payload
          in: query
          description: Agrega el cuerpo enviado.
          schema: {type: boolean}
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PageSize'
      responses:
        '200':
          description: Página de entregas, más recientes primero.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: {$ref: '#/components/schemas/WebhookDelivery'}
              example:
                error: false
                data:
                  - id: 9001
                    webhook_id: 3
                    event: order.status_changed
                    status: delivered
                    attempts: 1
                    status_code: 200
                    last_error: null
                    order_id: 70123
                    product_id: null
                    next_attempt_at: null
                    delivered_at: '2026-11-02T15:04:06Z'
                    created_at: '2026-11-02T15:04:05Z'
                count: 1
                has_more: false
                page: 1
                page_size: 50
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
  /webhooks/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: integer}
    get:
      tags: [Webhooks]
      operationId: getWebhook
      summary: Detalle de un webhook
      x-required-scopes: []
      responses:
        '200':
          description: Webhook.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WebhookResponse'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
    patch:
      tags: [Webhooks]
      operationId: updateWebhook
      summary: Edita un webhook
      description: |
        Todos los campos son opcionales. Reactivar (`is_active: true`) revalida la URL y limpia
        `consecutive_failures` y `disabled_reason`; es la forma de reactivar un webhook
        desactivado automáticamente.
      x-required-scopes: [orders:read, catalog:read]
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/WebhookUpdate'}
            example: {is_active: true}
      responses:
        '200':
          description: Webhook actualizado.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WebhookResponse'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
    delete:
      tags: [Webhooks]
      operationId: deleteWebhook
      summary: Elimina un webhook
      description: Las entregas pendientes se abandonan.
      x-required-scopes: []
      responses:
        '204': {description: 'Eliminado.'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
  /webhooks/{id}/rotate-secret:
    post:
      tags: [Webhooks]
      operationId: rotateWebhookSecret
      summary: Rota el secreto de firma
      description: El secreto anterior deja de valer de inmediato.
      x-required-scopes: []
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: integer}
      responses:
        '200':
          description: Webhook con el `secret` nuevo.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WebhookResponse'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
  /webhooks/{id}/test:
    post:
      tags: [Webhooks]
      operationId: testWebhook
      summary: Envía un evento `ping`
      description: |
        Encola un evento `ping` firmado a la URL del webhook (se entrega aunque no esté
        suscrito a nada). Revisa el resultado en `GET /webhooks/deliveries`.
      x-required-scopes: []
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: integer}
      responses:
        '202':
          description: Entrega encolada.
          content:
            application/json:
              schema:
                type: object
                required: [error, delivery]
                properties:
                  error: {type: boolean, const: false}
                  delivery: {$ref: '#/components/schemas/WebhookDelivery'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
webhooks:
  ping:
    post:
      operationId: webhook_ping
      summary: ping
      description: Respuesta a `POST /webhooks/{id}/test`.
      parameters:
        - $ref: '#/components/parameters/HeaderEvent'
        - $ref: '#/components/parameters/HeaderDelivery'
        - $ref: '#/components/parameters/HeaderSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/EventPing'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAck'}
  order.created:
    post:
      operationId: webhook_order_created
      summary: Pedido nuevo
      description: |
        Un pedido de la florería pasa a ser visible para la API (cualquier canal: marketplace,
        su tienda, venta local). Distingue con `is_internal` y `created_by_api`.
        Scope para suscribirse: `orders:read`.
      parameters:
        - $ref: '#/components/parameters/HeaderEvent'
        - $ref: '#/components/parameters/HeaderDelivery'
        - $ref: '#/components/parameters/HeaderSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/EventOrder'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAck'}
  order.status_changed:
    post:
      operationId: webhook_order_status_changed
      summary: Cambio de estado de un pedido
      description: |
        Cada cambio de estado es un evento (los pedidos no se agrupan). Incluye
        `previous_status`. Scope para suscribirse: `orders:read`.
      parameters:
        - $ref: '#/components/parameters/HeaderEvent'
        - $ref: '#/components/parameters/HeaderDelivery'
        - $ref: '#/components/parameters/HeaderSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/EventOrder'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAck'}
  order.delivered:
    post:
      operationId: webhook_order_delivered
      summary: Pedido entregado
      description: 'Además de `order.status_changed`. Scope para suscribirse: `orders:read`.'
      parameters:
        - $ref: '#/components/parameters/HeaderEvent'
        - $ref: '#/components/parameters/HeaderDelivery'
        - $ref: '#/components/parameters/HeaderSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/EventOrder'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAck'}
  order.cancelled:
    post:
      operationId: webhook_order_cancelled
      summary: Pedido cancelado
      description: 'Además de `order.status_changed`. Scope para suscribirse: `orders:read`.'
      parameters:
        - $ref: '#/components/parameters/HeaderEvent'
        - $ref: '#/components/parameters/HeaderDelivery'
        - $ref: '#/components/parameters/HeaderSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/EventOrder'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAck'}
  order.photo_uploaded:
    post:
      operationId: webhook_order_photo_uploaded
      summary: Foto de entrega subida
      description: |
        Se envía unos segundos después de subirse la foto, cuando ya existen los tres
        tamaños (`order.photos`). Scope para suscribirse: `orders:read`.
      parameters:
        - $ref: '#/components/parameters/HeaderEvent'
        - $ref: '#/components/parameters/HeaderDelivery'
        - $ref: '#/components/parameters/HeaderSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/EventOrder'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAck'}
  product.created:
    post:
      operationId: webhook_product_created
      summary: Producto nuevo
      description: 'Snapshot completo (forma de `GET /products/{id}`). Scope: `catalog:read`.'
      parameters:
        - $ref: '#/components/parameters/HeaderEvent'
        - $ref: '#/components/parameters/HeaderDelivery'
        - $ref: '#/components/parameters/HeaderSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/EventProduct'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAck'}
  product.updated:
    post:
      operationId: webhook_product_updated
      summary: Producto modificado
      description: |
        Datos, variantes, imágenes, personalización o cualquier combinación de cambios que no
        sea solo precio o solo estado. Los cambios del mismo producto en una ventana de ~45 s
        llegan juntos, con el estado final. Scope: `catalog:read`.
      parameters:
        - $ref: '#/components/parameters/HeaderEvent'
        - $ref: '#/components/parameters/HeaderDelivery'
        - $ref: '#/components/parameters/HeaderSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/EventProduct'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAck'}
  product.price_changed:
    post:
      operationId: webhook_product_price_changed
      summary: Precio modificado
      description: |
        Solo cambió precio, oferta, `sale_date_end` o el precio propio de un producto
        compartido. Scope: `catalog:read`.
      parameters:
        - $ref: '#/components/parameters/HeaderEvent'
        - $ref: '#/components/parameters/HeaderDelivery'
        - $ref: '#/components/parameters/HeaderSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/EventProduct'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAck'}
  product.status_changed:
    post:
      operationId: webhook_product_status_changed
      summary: Producto activado o desactivado
      description: 'Solo cambió `is_active`. Scope: `catalog:read`.'
      parameters:
        - $ref: '#/components/parameters/HeaderEvent'
        - $ref: '#/components/parameters/HeaderDelivery'
        - $ref: '#/components/parameters/HeaderSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/EventProduct'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAck'}
  product.deleted:
    post:
      operationId: webhook_product_deleted
      summary: Producto borrado
      description: |
        Payload liviano. También se envía cuando la florería deja de revender un producto.
        Scope: `catalog:read`.
      parameters:
        - $ref: '#/components/parameters/HeaderEvent'
        - $ref: '#/components/parameters/HeaderDelivery'
        - $ref: '#/components/parameters/HeaderSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/EventProductDeleted'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAck'}
  product.stock_changed:
    post:
      operationId: webhook_product_stock_changed
      summary: Stock modificado (opt-in)
      description: |
        Payload liviano. **No** lo incluyen los comodines: hay que pedirlo explícito al
        registrar el webhook. Scope: `catalog:read`.
      parameters:
        - $ref: '#/components/parameters/HeaderEvent'
        - $ref: '#/components/parameters/HeaderDelivery'
        - $ref: '#/components/parameters/HeaderSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/EventProductStock'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAck'}
  availability.changed:
    post:
      operationId: webhook_availability_changed
      summary: Cambio de disponibilidad de despacho
      description: |
        Bloqueo de día, alta demanda, comunas u horarios. Un solo evento por ventana aunque
        cambien muchas cosas (no uno por producto): vuelve a consultar
        `/delivery-availability` y `/products/prices`. Scope: `catalog:read`.
      parameters:
        - $ref: '#/components/parameters/HeaderEvent'
        - $ref: '#/components/parameters/HeaderDelivery'
        - $ref: '#/components/parameters/HeaderSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/EventAvailability'}
      responses:
        '2XX': {$ref: '#/components/responses/WebhookAck'}
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: rf_live_<prefix>_<secreto>
      description: |
        API key de la florería, con formato `rf_live_<prefix>_<secreto>`. Se entrega una sola
        vez; se envía en cada solicitud como `Authorization: Bearer <key>`.
  parameters:
    Page:
      name: page
      in: query
      schema: {type: integer, minimum: 1, default: 1}
    PageSize:
      name: page_size
      in: query
      schema: {type: integer, minimum: 1, maximum: 100, default: 50}
    UpdatedSince:
      name: updated_since
      in: query
      description: |
        ISO 8601 o `YYYY-MM-DD`; estricto `>`. Sin zona se interpreta como America/Santiago.
        Envía siempre ISO 8601 con zona.
      schema: {type: string}
      example: '2026-10-23T12:00:00-03:00'
    OrderRef:
      name: ref
      in: path
      required: true
      description: |
        Id numérico del pedido, o `ext:<external_id>` (solo pedidos creados por API).
      schema: {type: string}
      examples:
        id: {value: '70123'}
        external: {value: 'ext:WC-10452'}
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Texto único de hasta 255 caracteres. Durante 24 h, la misma key con el mismo cuerpo
        devuelve la misma respuesta sin crear nada; con otro cuerpo, 409
        `idempotency_conflict`. Solo se recuerdan las respuestas 2xx. Un header vacío
        se trata como si no viniera.
      schema: {type: string, maxLength: 255}
    HeaderEvent:
      name: X-Redfloral-Event
      in: header
      required: true
      description: Nombre del evento.
      schema: {type: string}
    HeaderDelivery:
      name: X-Redfloral-Delivery
      in: header
      required: true
      description: Id único de la entrega. **Deduplica por este valor** (entrega at-least-once).
      schema: {type: string}
    HeaderSignature:
      name: X-Redfloral-Signature
      in: header
      required: true
      description: |
        `t=<unix>,v1=<firma>` con `firma = hex(HMAC-SHA256(secret, "<t>.<cuerpo crudo>"))`.
        Calcula sobre los bytes crudos del cuerpo, compara en tiempo constante y rechaza si
        `t` se aleja más de 5 minutos de tu reloj. `secret` es el `whsec_...` del webhook.
      schema: {type: string}
      example: t=1793400000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  headers:
    RetryAfter:
      description: Segundos que debes esperar antes de reintentar.
      schema: {type: integer}
    RateLimitLimit:
      description: Tamaño del cupo de la ventana (lectura o escritura, según la solicitud).
      schema: {type: integer}
    RateLimitRemaining:
      description: Solicitudes que quedan en la ventana.
      schema: {type: integer}
  responses:
    BadRequest:
      description: '`validation_error`: body o parámetros inválidos (`fields` con las rutas de los campos).'
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example:
            error: true
            code: validation_error
            message: Hay campos inválidos.
            fields: {products.0.price: Asegúrese de que este valor es mayor o igual a 0.}
    Unauthorized:
      description: '`invalid_api_key`, `api_key_revoked` o `api_key_expired`.'
      headers:
        WWW-Authenticate:
          schema: {type: string}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example: {error: true, code: invalid_api_key, message: API key inválida.}
    Forbidden:
      description: |
        `api_access_disabled` (la florería no tiene la API habilitada), `insufficient_scope`
        (`required_scope` en el cuerpo) u `orders_write_disabled`.
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example: {error: true, code: insufficient_scope, message: La API key no tiene el scope 'orders:write'., required_scope: orders:write}
    NotFound:
      description: '`not_found`: no existe o es de otra florería.'
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example: {error: true, code: not_found, message: Recurso no encontrado.}
    TooManyRequests:
      description: '`rate_limited`: superaste el límite de la key.'
      headers:
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
        X-RateLimit-Limit:
          $ref: '#/components/headers/RateLimitLimit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/RateLimitRemaining'
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example: {error: true, code: rate_limited, message: Superaste el límite de solicitudes. Reintenta en 30 segundos., retry_after: 30}
    WebhookAck:
      description: Cualquier 2xx confirma la recepción. Cualquier otra respuesta, un redirect o un timeout (10 s) cuenta como fallo y se reintenta.
  schemas:
    Error:
      type: object
      required: [error, code, message]
      description: |
        Todo error. Programa contra `code`; `message` puede cambiar de redacción. Algunos
        errores agregan campos (`required_scope`, `retry_after`, `pending_count`,
        `pending_limit`, `status`, `order_id`).
      properties:
        error: {type: boolean, const: true}
        code:
          type: string
          enum:
            - invalid_api_key
            - api_key_revoked
            - api_key_expired
            - api_access_disabled
            - insufficient_scope
            - orders_write_disabled
            - validation_error
            - not_found
            - method_not_allowed
            - unsupported_media_type
            - rate_limited
            - internal_orders_blocked
            - order_not_cancellable
            - service_unavailable
            - webhook_limit_reached
            - idempotency_conflict
            - duplicate_document
            - error
        message: {type: string}
        fields:
          type: object
          description: 'Rutas de los campos inválidos, p. ej. `address.zone_id` o `products.0.name`.'
          additionalProperties: {type: string}
        required_scope: {type: string}
        retry_after: {type: integer}
        pending_count: {type: integer}
        pending_limit: {type: integer}
        status: {type: string}
        order_id: {type: integer}
      additionalProperties: true
    ListEnvelope:
      type: object
      required: [error, data, count, has_more, page, page_size]
      properties:
        error: {type: boolean, const: false}
        data: {type: array, items: {}}
        count: {type: integer, description: 'Total de ítems (no solo de la página).'}
        has_more: {type: boolean}
        page: {type: integer}
        page_size: {type: integer}
    Ping:
      type: object
      required: [error, company_id, company_name, key, scopes, server_time]
      properties:
        error: {type: boolean, const: false}
        company_id: {type: integer}
        company_name: {type: string}
        key:
          type: object
          required: [name, prefix]
          properties:
            name: {type: string}
            prefix: {type: string}
        scopes:
          type: array
          items: {$ref: '#/components/schemas/Scope'}
        server_time: {type: string, format: date-time}
    Scope:
      type: string
      enum: [orders:read, orders:write, catalog:read, catalog:write, availability:write, orders:status, billing:read]
    Me:
      type: object
      required: [error, company_id, plan, api_access, orders_write_enabled, free_orders_remaining, next_order_cost, pending_count, pending_limit, is_blocked]
      properties:
        error: {type: boolean, const: false}
        company_id: {type: integer}
        plan: {type: string}
        api_access: {type: boolean}
        orders_write_enabled: {type: boolean}
        free_orders_remaining:
          type: [integer, 'null']
          description: Ventas locales gratis que quedan en el cupo del plan.
        next_order_cost:
          type: [number, string, 'null']
          description: Costo del próximo pedido (0 si queda cupo).
        pending_count: {type: integer, description: 'Pedidos con excedente sin liquidar.'}
        pending_limit: {type: [integer, 'null']}
        is_blocked: {type: boolean}
    Zone:
      type: object
      required: [id, name, name_cleaned, price, time_end, is_retirement]
      properties:
        id: {type: integer, description: 'Es el `zone_id` del `POST /orders`.'}
        name: {type: string}
        name_cleaned: {type: string, description: 'Minúsculas y sin acentos.'}
        price: {type: integer}
        time_end: {type: [string, 'null'], description: 'Hora de corte (`HH:MM`).'}
        is_retirement: {type: boolean}
    Schedule:
      type: object
      required: [id, day, type_day, time_start, time_end, time_close, price]
      properties:
        id: {type: integer, description: 'Es el `shipping_schedule_id` del `POST /orders`.'}
        day: {type: integer, minimum: 0, maximum: 6, description: '0 = lunes.'}
        type_day: {type: string, enum: [DI, AN], description: 'DI mismo día · AN anticipado.'}
        time_start: {type: string}
        time_end: {type: string}
        time_close: {type: [string, 'null']}
        price: {type: integer}
    AvailabilityReason:
      type: string
      description: Motivo por el que no hay disponibilidad (`null` si la hay).
      enum:
        - day_blocked
        - not_working_day
        - zone_closed_for_today
        - zone_inactive
        - no_schedules
        - product_excluded_high_demand
        - product_inactive
        - date_in_past
        - zone_not_matched
    DeliveryAvailability:
      type: object
      required: [error, date, zone_id, zone_name, available, reason, schedules, high_demand, shipping_cost_base]
      properties:
        error: {type: boolean, const: false}
        date: {type: string, format: date}
        zone_id: {type: [integer, 'null']}
        zone_name: {type: [string, 'null']}
        available: {type: boolean}
        reason:
          oneOf:
            - $ref: '#/components/schemas/AvailabilityReason'
            - type: 'null'
        schedules:
          type: array
          items:
            type: object
            required: [id, time_start, time_end, price]
            properties:
              id: {type: integer, description: 'Es el `shipping_schedule_id`.'}
              time_start: {type: string}
              time_end: {type: string}
              price: {type: integer, description: 'Comuna + horario: lo que va en `shipping_cost`.'}
        high_demand: {type: boolean}
        shipping_cost_base: {type: [integer, 'null'], description: 'Precio de la comuna.'}
    Product:
      type: object
      required: [id, shared, name, description, type, price, price_sale, sale_date_end, is_active, is_stock, quantity, is_exclude_high_demand, photo, updated_at]
      properties:
        id: {type: integer, description: 'Id del producto original; es el `product_id` del `POST /orders`.'}
        shared: {type: boolean, description: 'La florería lo revende (precio, oferta, stock y estado del compartido; o los del producto original si la reventa está configurada para usarlos, y entonces `is_active` también exige el original activo y con stock).'}
        name: {type: string}
        description: {type: string}
        type:
          type: integer
          enum: [0, 1, 2, 3]
          description: 0 normal · 1 adicional · 2 por unidad de flores · 3 con variantes.
        price: {type: [integer, 'null']}
        price_sale: {type: [integer, 'null']}
        sale_date_end: {type: [string, 'null'], format: date}
        is_active: {type: boolean}
        is_stock: {type: boolean}
        quantity: {type: [integer, 'null']}
        is_exclude_high_demand: {type: boolean}
        photo: {type: [string, 'null'], format: uri}
        updated_at: {type: string, format: date-time}
    ProductDetail:
      allOf:
        - $ref: '#/components/schemas/Product'
        - type: object
          required: [is_message, message_max, message_label, personalized_min, personalized_max, personalized_max_combinations, images, variants, options]
          properties:
            is_message: {type: boolean}
            message_max: {type: [integer, 'null']}
            message_label: {type: [string, 'null']}
            personalized_min: {type: [integer, 'null']}
            personalized_max: {type: [integer, 'null']}
            personalized_max_combinations: {type: integer, description: 'Máximo de combinaciones de un producto por unidad (`type` 2), por defecto 2. `POST /orders` no lo valida.'}
            images:
              type: array
              items:
                type: object
                required: [id, url, small, medium, large]
                properties:
                  id: {type: integer}
                  url: {type: [string, 'null']}
                  small: {type: [string, 'null']}
                  medium: {type: [string, 'null']}
                  large: {type: [string, 'null']}
            variants:
              type: array
              items:
                type: object
                required: [id, sku, default, price, stock, photo, attributes]
                properties:
                  id: {type: integer}
                  sku: {type: [string, 'null']}
                  default: {type: boolean}
                  price: {type: [integer, 'null']}
                  stock: {type: [integer, 'null']}
                  photo: {type: [string, 'null']}
                  attributes:
                    type: array
                    items:
                      type: object
                      required: [attribute, value]
                      properties:
                        attribute: {type: string}
                        value: {type: string}
            options:
              description: Opciones disponibles de un producto tipo 2.
              type: array
              items:
                type: object
                required: [id, name, price, photo]
                properties:
                  id: {type: integer}
                  name: {type: string}
                  price: {type: [integer, 'null']}
                  photo: {type: [string, 'null']}
    ProductPrice:
      type: object
      required: [id, shared, type, price, price_sale, final_price, high_demand_percentage]
      properties:
        id: {type: integer}
        shared: {type: boolean}
        type: {type: integer, enum: [0, 1, 2, 3]}
        price: {type: [integer, 'null'], description: 'Precio normal con alta demanda.'}
        price_sale: {type: [integer, 'null'], description: 'Oferta vigente ese día con alta demanda, o null.'}
        final_price: {type: [integer, 'null']}
        high_demand_percentage: {type: integer}
        variants:
          type: array
          description: Solo en tipo 3.
          items:
            type: object
            required: [id, price]
            properties:
              id: {type: integer}
              price: {type: [integer, 'null']}
        options:
          type: array
          description: Solo en tipo 2.
          items:
            type: object
            required: [id, price]
            properties:
              id: {type: integer}
              price: {type: [integer, 'null']}
    WarningCode:
      type: string
      enum: [zone_not_matched, schedule_not_matched, date_not_available, options_stock_insufficient, variant_stock_insufficient]
    Warning:
      type: object
      required: [code, message]
      description: |
        Aviso, no error. `zone_not_matched` y `schedule_not_matched` se recalculan en cada
        lectura mientras falte la zona/horario; `date_not_available`,
        `options_stock_insufficient` y `variant_stock_insufficient` solo vienen en la
        respuesta del `POST`.
      properties:
        code: {$ref: '#/components/schemas/WarningCode'}
        message: {type: string}
        reason:
          description: Solo en `date_not_available`.
          type: string
          enum:
            - day_blocked
            - not_working_day
            - zone_closed_for_today
            - zone_inactive
            - no_schedules
            - product_excluded_high_demand
            - product_inactive
            - date_in_past
            - schedule_not_available
    OrderStatus:
      type: object
      required: [slug, name]
      properties:
        slug:
          type: string
          enum: [pending, processing, rejected, in_transit, delivered, problem, cancelled]
        name: {type: string}
    Order:
      type: object
      required: [id, external_id, external_url, status, is_internal, created_by_api, secure_key, status_url, shipping_date, schedule_id, schedule_text, zone_id, zone_text, customer, address, card_message, anonymous, total_products, total_shipping, total_discount, total, delivered_at, created_at, updated_at, warnings]
      properties:
        id: {type: integer}
        external_id: {type: [string, 'null']}
        external_url: {type: [string, 'null']}
        status: {$ref: '#/components/schemas/OrderStatus'}
        is_internal: {type: boolean, description: 'Venta local.'}
        created_by_api: {type: boolean}
        secure_key: {type: string, format: uuid}
        status_url: {type: string, format: uri, description: 'Página pública de seguimiento: puedes enviarla a tu cliente.'}
        shipping_date: {type: [string, 'null'], format: date}
        schedule_id: {type: [integer, 'null'], description: 'Id de `CompanySchedule` (no es el `shipping_schedule_id`).'}
        schedule_text: {type: [string, 'null']}
        zone_id: {type: [integer, 'null']}
        zone_text: {type: [string, 'null']}
        customer:
          type: object
          required: [name, email, phone_code, phone]
          properties:
            name: {type: string}
            email: {type: [string, 'null']}
            phone_code: {type: [string, 'null']}
            phone: {type: [string, 'null']}
        address:
          type: object
          required: [type, name, street, street_number, more_info, phone, latitude, longitude]
          properties:
            type: {type: [string, 'null']}
            name: {type: [string, 'null']}
            street: {type: [string, 'null']}
            street_number: {type: [string, 'null']}
            more_info: {type: [string, 'null']}
            phone: {type: [string, 'null']}
            latitude: {type: [number, 'null']}
            longitude: {type: [number, 'null']}
        card_message: {type: string}
        anonymous: {type: boolean, description: 'Envío anónimo: el destinatario no ve quién envía.'}
        total_products: {type: integer}
        total_shipping: {type: integer}
        total_discount: {type: integer}
        total: {type: integer}
        delivered_at: {type: [string, 'null'], format: date-time}
        created_at: {type: string, format: date-time}
        updated_at: {type: string, format: date-time}
        warnings:
          type: array
          items: {$ref: '#/components/schemas/Warning'}
    OrderDetail:
      allOf:
        - $ref: '#/components/schemas/Order'
        - type: object
          required: [url_photo, photos, products, history, document_type, document]
          properties:
            url_photo: {type: [string, 'null'], format: uri, description: 'Página pública de la foto de entrega.'}
            photos:
              description: URL públicas de la foto de entrega, o null si aún no hay foto.
              oneOf:
                - type: object
                  required: [xs, md, lg]
                  properties:
                    xs: {type: [string, 'null'], format: uri}
                    md: {type: [string, 'null'], format: uri}
                    lg: {type: [string, 'null'], format: uri}
                - type: 'null'
            products:
              type: array
              items:
                type: object
                required: [id, product_id, variant_id, shared, name, quantity, price, note, options]
                properties:
                  id: {type: integer}
                  product_id: {type: [integer, 'null']}
                  variant_id: {type: [integer, 'null']}
                  shared: {type: boolean}
                  name: {type: string}
                  quantity: {type: integer}
                  price: {type: integer}
                  note: {type: string}
                  options:
                    description: Opciones vendidas de un producto por unidad (`type` 2). Lista vacía en otros tipos y en pedidos antiguos.
                    type: array
                    items:
                      type: object
                      required: [id, name, quantity, price]
                      properties:
                        id: {type: integer}
                        name: {type: [string, 'null']}
                        quantity: {type: integer}
                        price: {type: integer, description: 'Precio unitario de catálogo de la opción para la fecha de despacho, alta demanda incluida.'}
            history:
              description: Más nuevo primero.
              type: array
              items:
                type: object
                required: [status, status_name, message, created_at]
                properties:
                  status: {type: string}
                  status_name: {type: string}
                  message: {type: string}
                  created_at: {type: string, format: date-time}
            document_type:
              type: string
              enum: [boleta, factura]
              description: Documento tributario del pedido. En el marketplace lo elige el cliente; en un pedido creado por API es el `document_type` del `POST /orders` (por defecto `boleta`).
            document:
              description: Boleta o factura subida (`POST /orders/{ref}/document` o el panel), o null si aún no hay.
              oneOf:
                - type: object
                  required: [number, date, file_url, page_url, validation]
                  properties:
                    number: {type: [string, 'null'], description: Folio.}
                    date: {type: [string, 'null'], format: date}
                    file_url: {type: string, format: uri}
                    page_url: {type: string, format: uri, description: Página pública del documento en Redfloral.}
                    validation:
                      type: string
                      enum: [pending, valid, partially_valid, invalid, unreadable, error, unsupported_format]
                      description: |
                        Revisión con IA de folio, monto y RUT emisor. `pending` mientras corre;
                        `partially_valid` = coincide el folio o el monto, no ambos.
                - type: 'null'
    OrderResponse:
      type: object
      required: [error, created, order]
      properties:
        error: {type: boolean, const: false}
        created: {type: boolean}
        order: {$ref: '#/components/schemas/OrderDetail'}
    OrderCreate:
      type: object
      required: [external_id, customer, address, shipping_date, shipping_cost, products]
      description: |
        Hay que indicar `shipping_schedule_id` **o** `schedule_text` (el id manda). Los campos
        desconocidos se ignoran.
      properties:
        external_id: {type: string, maxLength: 64, description: 'Tu número de pedido; único por florería.'}
        external_url: {type: [string, 'null'], format: uri, maxLength: 200}
        customer:
          type: object
          required: [name, email, phone]
          properties:
            name: {type: string, maxLength: 200}
            email: {type: string, format: email}
            phone: {type: string, maxLength: 32, description: 'Se acepta con el código pegado ("+56 9 1234 5678").'}
            phone_code: {type: string, maxLength: 8, default: '56'}
        address:
          type: object
          required: [zone_text, name, street, street_number]
          properties:
            zone_text: {type: string, maxLength: 64, description: 'Comuna tal como la tiene el socio; match exacto sin importar mayúsculas ni acentos.'}
            zone_id: {type: [integer, 'null'], description: 'Id de `GET /zones`; si viene, manda sobre `zone_text`.'}
            type: {type: string, maxLength: 20, default: DrParticular}
            name: {type: string, maxLength: 128, description: 'Nombre del destinatario.'}
            street: {type: string, maxLength: 128}
            street_number: {type: string, maxLength: 32}
            more_info: {type: [string, 'null'], maxLength: 240}
            phone: {type: [string, 'null'], maxLength: 96}
            latitude: {type: [number, 'null']}
            longitude: {type: [number, 'null']}
        shipping_date: {type: string, format: date}
        shipping_schedule_id: {type: [integer, 'null'], description: 'Id de `GET /schedules` o de `delivery-availability`.'}
        schedule_text:
          type: string
          maxLength: 64
          description: 'Respaldo del id; solo se resuelve si es exacto `HH:MM-HH:MM` de un horario activo.'
        shipping_cost: {type: integer, minimum: 0, maximum: 2147483647, description: 'Obligatorio. `0` es válido (envío gratis); ausente, `null` o negativo da 400.'}
        discount:
          type: integer
          minimum: 0
          maximum: 2147483647
          default: 0
          description: 'Opcional. Descuento total del pedido, en monto (sin cupones), que ya le aplicaste a tu cliente. Se resta de los productos, no del despacho, y vuelve como `total_discount`. Mayor que `total_products` (ya calculado por el servidor) o negativo da 400 `fields.discount`. Manda el precio de lista en cada línea y el descuento acá.'
        card_message: {type: [string, 'null']}
        anonymous:
          type: boolean
          default: false
          description: 'Opcional. `true` = envío anónimo: el pedido se marca para que la florería no revele quién envía. Vuelve en la respuesta como `anonymous`.'
        document_type:
          type: string
          enum: [boleta, factura]
          default: boleta
          description: 'Opcional. Documento tributario que emites por el pedido. Vuelve en el detalle como `document_type`; se puede corregir al subir el documento (`POST /orders/{ref}/document`).'
        products:
          type: array
          minItems: 1
          maxItems: 100
          items:
            type: object
            required: [quantity]
            description: Sin `product_id`, `name` es obligatorio (producto libre). `price` es obligatorio salvo en `type` 2 y 3.
            properties:
              product_id: {type: [integer, 'null']}
              variant_id: {type: [integer, 'null'], description: 'Solo junto a `product_id`. Obligatorio en un producto con variantes (`type` 3): 400 `products.N.variant_id` si falta, si no es de ese producto o no tiene precio. Si no alcanza el stock (se suma todo el pedido) el pedido se crea igual, con el aviso `variant_stock_insufficient`, y cancelar no devuelve esas líneas. No se acepta en un producto que la florería revende (`shared: true`): 400.'}
              options:
                type: [array, 'null']
                maxItems: 100
                description: |
                  Obligatorio en un producto por unidad (`type` 2) y solo aceptado ahí: 400
                  `products.N.options` si falta, si una opción no es de ese producto o está
                  inactiva, o si el total de unidades queda fuera de
                  `personalized_min`/`personalized_max`. Si no alcanza el stock (se suma todo el
                  pedido) el pedido se crea igual, con el aviso `options_stock_insufficient`.
                  Descuenta el stock de las opciones al crear el pedido y lo devuelve al
                  cancelar, salvo el de las opciones aceptadas sin stock suficiente. El `price` de la línea lo calcula Redfloral de las opciones, con el
                  recargo de alta demanda de `shipping_date`.
                items:
                  type: object
                  required: [id, quantity]
                  properties:
                    id: {type: integer, description: 'Id de la opción (`options` de `GET /products/<id>` o `/products/prices`). Sin repetir.'}
                    quantity: {type: integer, minimum: 1, maximum: 32767, description: 'Unidades de la opción por cada unidad de la línea.'}
              name: {type: string, maxLength: 200}
              quantity: {type: integer, minimum: 1, maximum: 32767}
              price: {type: integer, minimum: 0, maximum: 2147483647, description: 'Lo que cobró el socio; no se reemplaza por el del catálogo. Obligatorio, salvo en un producto por unidad (`type` 2) o con variantes (`type` 3): ahí se ignora y se calcula de `options` o de la variante, con el recargo de alta demanda de `shipping_date`.'}
              note: {type: [string, 'null']}
    DeliveryStatus:
      type: string
      enum: [pending, delivered, failed, abandoned]
    EventName:
      type: string
      enum:
        - order.created
        - order.status_changed
        - order.delivered
        - order.cancelled
        - order.photo_uploaded
        - product.created
        - product.updated
        - product.deleted
        - product.status_changed
        - product.price_changed
        - product.stock_changed
        - availability.changed
    Webhook:
      type: object
      required: [id, name, url, events, is_active, consecutive_failures, disabled_by_failures, disabled_reason, disabled_at, last_delivery_at, created_at, updated_at, secret]
      properties:
        id: {type: integer}
        name: {type: string}
        url: {type: string, format: uri}
        events:
          type: array
          items: {$ref: '#/components/schemas/EventName'}
        is_active: {type: boolean}
        consecutive_failures: {type: integer}
        disabled_by_failures:
          type: boolean
          description: >-
            `true` si el webhook se desactivó solo tras fallos consecutivos (con
            `disabled_reason` y `disabled_at`); `false` si está activo o lo pausaste a mano.
        disabled_reason: {type: [string, 'null']}
        disabled_at: {type: [string, 'null'], format: date-time}
        last_delivery_at: {type: [string, 'null'], format: date-time}
        created_at: {type: string, format: date-time}
        updated_at: {type: string, format: date-time}
        secret: {type: string, description: '`whsec_...`; la clave HMAC de la firma.'}
    WebhookResponse:
      type: object
      required: [error, webhook]
      properties:
        error: {type: boolean, const: false}
        webhook: {$ref: '#/components/schemas/Webhook'}
    WebhookCreate:
      type: object
      required: [url, events]
      properties:
        url: {type: string, maxLength: 500, description: 'https y pública.'}
        name: {type: string, maxLength: 200}
        events:
          type: array
          minItems: 1
          items:
            type: string
            description: Un evento o un comodín (`*`, `order.*`, `product.*`, `availability.*`).
    WebhookUpdate:
      type: object
      properties:
        url: {type: string, maxLength: 500}
        name: {type: string, maxLength: 200}
        events:
          type: array
          minItems: 1
          items: {type: string}
        is_active: {type: boolean}
    WebhookDelivery:
      type: object
      required: [id, webhook_id, event, status, attempts, status_code, last_error, order_id, product_id, next_attempt_at, delivered_at, created_at]
      properties:
        id: {type: integer}
        webhook_id: {type: integer}
        event: {type: string}
        status: {$ref: '#/components/schemas/DeliveryStatus'}
        attempts: {type: integer}
        status_code: {type: [integer, 'null']}
        last_error: {type: [string, 'null']}
        order_id: {type: [integer, 'null']}
        product_id: {type: [integer, 'null']}
        next_attempt_at: {type: [string, 'null'], format: date-time}
        delivered_at: {type: [string, 'null'], format: date-time}
        created_at: {type: string, format: date-time}
        payload: {type: object, description: 'Solo con `include_payload=true`.'}
    EventBase:
      type: object
      required: [event, occurred_at, company_id, delivery_id]
      properties:
        event: {$ref: '#/components/schemas/EventName'}
        occurred_at: {type: string, format: date-time}
        company_id: {type: integer}
        delivery_id: {type: integer, description: 'Igual a `X-Redfloral-Delivery`.'}
    EventPing:
      type: object
      required: [event, occurred_at, company_id, delivery_id, webhook_id, message]
      properties:
        event: {type: string, const: ping}
        occurred_at: {type: string, format: date-time}
        company_id: {type: integer}
        delivery_id: {type: integer}
        webhook_id: {type: integer}
        message: {type: string}
    EventOrder:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [order]
          properties:
            order: {$ref: '#/components/schemas/OrderDetail'}
            previous_status:
              description: Slug del estado anterior (en `order.status_changed`, `order.delivered` y `order.cancelled`).
              type: string
      example:
        event: order.status_changed
        occurred_at: '2026-11-02T15:04:05.123456+00:00'
        company_id: 15
        delivery_id: 9001
        previous_status: processing
        order: *order_detail
    ProductChange:
      type: string
      enum: [created, data, variants, images, personalization, price, status, stock]
    EventProduct:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [product, changes]
          properties:
            product: {$ref: '#/components/schemas/ProductDetail'}
            changes:
              type: array
              items: {$ref: '#/components/schemas/ProductChange'}
      example:
        event: product.price_changed
        occurred_at: '2026-11-02T15:04:50+00:00'
        company_id: 15
        delivery_id: 9002
        changes: [price]
        product:
          id: 8891
          shared: false
          name: Ramo Primavera
          description: Rosas y lirios.
          type: 0
          price: 25000
          price_sale: null
          sale_date_end: null
          is_active: true
          is_stock: true
          quantity: 7
          is_exclude_high_demand: false
          photo: null
          updated_at: '2026-11-02T15:04:49Z'
          is_message: false
          message_max: null
          message_label: null
          personalized_min: null
          personalized_max: null
          personalized_max_combinations: 2
          images: []
          variants: []
          options: []
    EventProductDeleted:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [product_id]
          properties:
            product_id: {type: integer}
      example:
        event: product.deleted
        occurred_at: '2026-11-02T15:10:00+00:00'
        company_id: 15
        delivery_id: 9003
        product_id: 8891
    EventProductStock:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [product_id, is_stock, quantity, variants]
          properties:
            product_id: {type: integer}
            is_stock: {type: [boolean, 'null']}
            quantity: {type: [integer, 'null']}
            variants:
              type: array
              items:
                type: object
                required: [id, stock]
                properties:
                  id: {type: integer}
                  stock: {type: [integer, 'null']}
      example:
        event: product.stock_changed
        occurred_at: '2026-11-02T15:12:00+00:00'
        company_id: 15
        delivery_id: 9004
        product_id: 8891
        is_stock: true
        quantity: 7
        variants: [{id: 51, stock: 3}]
    AvailabilityChange:
      type: object
      required: [type, action]
      properties:
        type: {type: string, enum: [day_block, high_demand, zone, schedule]}
        action: {type: string, enum: [created, updated, deleted]}
        date: {type: string, format: date, description: 'En `day_block` y `high_demand`.'}
        percentage: {type: integer, description: 'Solo en `high_demand`.'}
        is_active: {type: boolean, description: 'En `high_demand`, `zone` y `schedule`.'}
        zone_id: {type: integer, description: 'Solo en `zone` (id de `GET /zones`).'}
        schedule_id: {type: integer, description: 'Solo en `schedule` (id de `GET /schedules`).'}
        day: {type: integer, description: 'Solo en `schedule` (0 = lunes).'}
    EventAvailability:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [changes]
          properties:
            changes:
              type: array
              items: {$ref: '#/components/schemas/AvailabilityChange'}
      example:
        event: availability.changed
        occurred_at: '2026-11-02T15:15:00+00:00'
        company_id: 15
        delivery_id: 9005
        changes:
          - {type: day_block, action: created, date: '2026-11-14'}
          - {type: high_demand, action: updated, date: '2026-11-20', percentage: 20, is_active: true}
          - {type: zone, action: updated, zone_id: 12, is_active: true}
          - {type: schedule, action: updated, schedule_id: 7, day: 4, is_active: true}
