openapi: 3.1.0

info:
  title: DovExpress Public Integration API
  version: 1.0.0
  summary: Creación de guías, consulta de tracking con POD, catálogo de estados y gestión de webhook.
  description: |
    API pública de integración de DovExpress / DovExpress public integration API.

    **Autenticación / Authentication:** todas las rutas requieren el header `api-key`
    (la llave del cliente, `Clients.api_key`). No hay OAuth ni bearer token en esta superficie.

    **Envelope de respuesta / Response envelope:** todas las respuestas exitosas comparten la
    misma forma, producida por un único helper (`api/src/handlers/response/helper.ts`):

    ```json
    { "success": true, "message": "<mensaje fijo por endpoint>", "data": <payload> }
    ```

    `DELETE /api/webhook` es la única excepción: no incluye la clave `data` porque el handler
    llama a `res.respond()` sin payload.

    **Códigos de estado HTTP:** el helper responde `200 OK` en todos los casos de éxito,
    **incluida la creación de guías** — `POST /api/orders/create` NO devuelve `201`.

    **Errores:** todos los errores pasan por un único handler
    (`api/src/handlers/error/processError.ts`) y comparten la forma del schema `Error`.
    Nótese que `GET /api/orders/{id}` y `GET /api/orders/reference/{reference}` responden
    **401** (no 404) cuando la guía no existe o pertenece a otro cliente: el handler no
    distingue "no existe" de "no es tuya".

    **Zona horaria:** los eventos de tracking se registran en hora de Costa Rica (UTC−6, sin
    horario de verano) y se serializan como ISO-8601 UTC.

    **Montos:** los campos monetarios son columnas `Decimal` de Postgres y se serializan como
    **string** en JSON (p. ej. `"12500"`), nunca como número.

  contact:
    name: DovExpress
    url: https://dovexpresscr.com

  license:
    name: Proprietary
    url: https://dovexpresscr.com

servers:
  - url: https://api.dovexpresscr.com
    description: Producción

security:
  - ApiKeyAuth: []

tags:
  - name: Orders
    description: Creación y consulta de guías (órdenes de entrega).
  - name: Statuses
    description: Catálogo de estados con el código DOV estable.
  - name: Webhook
    description: Registro del endpoint que recibe los cambios de estado.

paths:
  /api/orders/create:
    post:
      operationId: createOrder
      tags: [Orders]
      summary: Crear una guía
      description: |
        Crea **una** guía para el cliente autenticado y devuelve su código de tracking DovExpress.
        La guía nace en el estado `Created`.

        El cliente se resuelve exclusivamente desde el header `api-key`; el payload no puede
        indicar el cliente.

        **Idempotencia parcial por `reference_id`:** si el `reference_id` ya pertenece a una guía
        activa del mismo cliente, la respuesta es `409 ORDER_ALREADY_EXISTS`. Si dos peticiones
        concurrentes con la misma referencia chocan a nivel de índice único, el handler recupera la
        guía existente y responde `200` con sus datos en lugar de fallar (sin el objeto `pricing`).

        **Precio:** el objeto `pricing` aparece **solo** si el cliente tiene la tarifa por matriz
        habilitada y la guía se pudo cotizar. Para un cliente legacy la clave está ausente.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateOrderRequest'
            examples:
              minimo:
                summary: Payload mínimo (solo campos requeridos)
                value:
                  contactDetails:
                    contactName: Ana Rodríguez
                  addressDetails:
                    address: 200 m norte de la escuela, casa portón verde
                    postalCode: '10801'
                  packageDetails: {}
              completo:
                summary: Payload completo con medidas, COD y pagador
                value:
                  reference_id: LM00099123401
                  contactDetails:
                    contactName: Ana Rodríguez
                    contactPhone: '+50688887777'
                    contactPhone2: '+50622223333'
                  addressDetails:
                    address: 200 m norte de la escuela, casa portón verde
                    state: San José
                    region: San José
                    city: Carmen
                    country: CR
                    postalCode: '10801'
                    lat: 9.9355
                    lng: -84.0836
                    notes: Entregar después de las 2 p. m.
                  packageDetails:
                    product: Zapatos deportivos
                    quantity: 1
                    products:
                      - product: Zapatos deportivos
                        quantity: 1
                      - name: Medias
                        quantity: 2
                    weightKg: 1.5
                    lengthCm: 30
                    widthCm: 20
                    heightCm: 10
                  cod: 45000
                  payer: RECIPIENT
                  districtId: 0f5c9d02-4b3a-4d4e-8a1f-2c7e9b1d4a55
                  expected_pickup_date: '2026-08-20'
                  notes: Llamar antes de llegar
      responses:
        '200':
          description: |
            Guía creada. **No es `201`**: el helper de respuesta usa `200` por defecto.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreateOrderResponse'
              examples:
                legacy:
                  summary: Cliente legacy (sin tarifa por matriz)
                  value:
                    success: true
                    message: Order Created
                    data:
                      code: DOV_300145
                      referenceId: LM00099123401
                      id: 7c9e6679-7425-40de-944b-e07fc1f90ae7
                matriz:
                  summary: Cliente con tarifa por matriz
                  value:
                    success: true
                    message: Order Created
                    data:
                      code: DOV_300146
                      referenceId: LM00099123402
                      id: 1f4b2c8a-9d31-4e55-b0a7-3c6e8f2d1b04
                      pricing:
                        package_size: M
                        zone_group: GAM
                        volume_ordinal: 128
                        volume_tier: 2
                        price_base: '2500.00'
                        tax_rate: '0.13'
                        tax_amount: '325.00'
                        price_total: '2825.00'
                        tax_included: true
                        currency: CRC
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: |
            La `api-key` es válida pero la cuenta del cliente está eliminada (soft-delete). El
            chequeo del handler no filtra cuentas eliminadas y el servicio de creación sí, de modo
            que este es el único código por el que se manifiesta.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                success: false
                message: Client not found
                data: []
                status: 404
                code: '00'
        '409':
          description: |
            La referencia ya pertenece a una guía activa del cliente
            (`errorCode: ORDER_ALREADY_EXISTS`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                success: false
                message: Ya existe una guía activa con la referencia LM00099123401
                data:
                  reference_id: LM00099123401
                status: 409
                code: '13'
                errorCode: ORDER_ALREADY_EXISTS
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/orders/{id}:
    get:
      operationId: getOrderById
      tags: [Orders]
      summary: Consultar una guía por id interno
      description: |
        Devuelve el payload canónico de la guía — el **mismo** que se envía al webhook — con el
        historial completo de eventos y las URLs de POD por evento.

        Solo se resuelven guías del cliente autenticado.
      parameters:
        - $ref: '#/components/parameters/OrderId'
      responses:
        '200':
          $ref: '#/components/responses/OrderFound'
        '401':
          description: |
            `api-key` inválido/ausente **o** la guía no existe o pertenece a otro cliente.
            El handler usa `401` en ambos casos.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                guiaNoEncontrada:
                  summary: Guía inexistente o de otro cliente
                  value:
                    success: false
                    message: Orden no encontrada
                    data: []
                    status: 401
                    code: '00'
                apiKeyInvalida:
                  summary: api-key desconocida
                  value:
                    success: false
                    message: Client not found
                    data: []
                    status: 401
                    code: '00'
        '400':
          $ref: '#/components/responses/BadRequest'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/orders/reference/{reference}:
    get:
      operationId: getOrderByReference
      tags: [Orders]
      summary: Consultar una guía por referencia propia o por código DovExpress
      description: |
        Busca dentro de las guías del cliente autenticado por `reference_id` **o** por `code`
        (el número de tracking `DOV_…`). El valor se compara exacto, previo `trim()`.

        La respuesta es idéntica a la de `GET /api/orders/{id}`.
      parameters:
        - $ref: '#/components/parameters/OrderReference'
      responses:
        '200':
          $ref: '#/components/responses/OrderFound'
        '401':
          description: |
            `api-key` inválido/ausente **o** ninguna guía del cliente coincide con el valor.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                success: false
                message: Orden no encontrada
                data: []
                status: 401
                code: '00'
        '400':
          $ref: '#/components/responses/BadRequest'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/statuses:
    get:
      operationId: listStatuses
      tags: [Statuses]
      summary: Catálogo de estados
      description: |
        Devuelve el catálogo completo de estados no eliminados, ordenado ascendentemente por
        `code` (`dov_status_code`). Incluye estados internos de operación además de los que ve
        el cliente final.

        El `code` es el **código DOV**: numérico, propio de DovExpress, estable en el tiempo e
        independiente de cualquier código de transportista. Los códigos existentes arrancan en
        5001; un estado nuevo recibe `MAX(code) + 1`. Consultar este endpoint periódicamente
        mantiene el mapeo al día.
      responses:
        '200':
          description: Catálogo de estados.
          content:
            application/json:
              schema:
                type: object
                required: [success, message, data]
                properties:
                  success:
                    const: true
                  message:
                    const: Status List
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Status'
              example:
                success: true
                message: Status List
                data:
                  - code: 5006
                    name: Accepted
                    name_es: Aceptado
                    is_final: false
                    requires_photo: false
                    requires_signature: false
                  - code: 5009
                    name: Assigned to Driver
                    name_es: Asignado a Conductor
                    is_final: false
                    requires_photo: false
                    requires_signature: false
                  - code: 5013
                    name: Cargo Received
                    name_es: Carga Recibida
                    is_final: false
                    requires_photo: false
                    requires_signature: false
                  - code: 5014
                    name: Created
                    name_es: Creado
                    is_final: false
                    requires_photo: false
                    requires_signature: false
                  - code: 5015
                    name: Delivered
                    name_es: Entregado
                    is_final: true
                    requires_photo: true
                    requires_signature: false
                  - code: 5028
                    name: In Transit
                    name_es: En Tránsito
                    is_final: false
                    requires_photo: false
                    requires_signature: false
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/webhook:
    get:
      operationId: getWebhook
      tags: [Webhook]
      summary: Consultar el webhook registrado
      description: |
        Devuelve el webhook activo del cliente autenticado, o `data: null` si no hay ninguno.
        Cada cliente tiene **un** webhook (`client_id` es único).
      responses:
        '200':
          description: Webhook registrado, o `null` si no hay ninguno.
          content:
            application/json:
              schema:
                type: object
                required: [success, message, data]
                properties:
                  success:
                    const: true
                  message:
                    const: Webhook Found
                  data:
                    oneOf:
                      - $ref: '#/components/schemas/Webhook'
                      - type: 'null'
              examples:
                registrado:
                  summary: Con webhook registrado
                  value:
                    success: true
                    message: Webhook Found
                    data:
                      url: https://mi-sistema.example.com/hooks/dovexpress
                      updatedAt: '2026-08-01T15:42:10.331Z'
                sinWebhook:
                  summary: Sin webhook registrado
                  value:
                    success: true
                    message: Webhook Found
                    data: null
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'

    put:
      operationId: setWebhook
      tags: [Webhook]
      summary: Registrar o actualizar el webhook
      description: |
        Registra la URL que recibirá los cambios de estado. Es un **upsert**: reemplaza la URL
        anterior y reactiva un webhook previamente eliminado. La URL debe ser absoluta y válida.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetWebhookRequest'
            example:
              url: https://mi-sistema.example.com/hooks/dovexpress
      responses:
        '200':
          description: Webhook guardado.
          content:
            application/json:
              schema:
                type: object
                required: [success, message, data]
                properties:
                  success:
                    const: true
                  message:
                    const: Webhook Saved
                  data:
                    type: object
                    required: [url]
                    properties:
                      url:
                        type: string
                        format: uri
              example:
                success: true
                message: Webhook Saved
                data:
                  url: https://mi-sistema.example.com/hooks/dovexpress
        '400':
          description: |
            URL ausente o inválida. El mensaje es el del validador
            (`Invalid url` cuando el formato es incorrecto, `Required` cuando falta la clave).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                success: false
                message: Invalid url
                data:
                  - validation: url
                    code: invalid_string
                    message: Invalid url
                    path: [body, url]
                status: 400
                code: '00'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'

    delete:
      operationId: deleteWebhook
      tags: [Webhook]
      summary: Eliminar el webhook
      description: |
        Elimina el webhook del cliente autenticado (borrado físico). Es idempotente: si no había
        webhook registrado la respuesta es la misma.

        Esta es la única respuesta de la API que **no** trae la clave `data`.
      responses:
        '200':
          description: Webhook eliminado.
          content:
            application/json:
              schema:
                type: object
                required: [success, message]
                properties:
                  success:
                    const: true
                  message:
                    const: Webhook Deleted
              example:
                success: true
                message: Webhook Deleted
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'

webhooks:
  orderStatusChanged:
    post:
      operationId: orderStatusChangedWebhook
      tags: [Webhook]
      summary: Cambio de estado de una guía (enviado por DovExpress)
      description: |
        DovExpress hace `POST` a la URL registrada con `PUT /api/webhook` cada vez que una guía
        del cliente cambia de estado.

        **Cuándo se dispara:** al registrar un estado desde el panel, al actualizar el estado de
        una guía, en las operaciones masivas del panel (recepción de carga, recepción masiva,
        cambio masivo de estado), al marcar la entrega desde la app del conductor (que es la que
        produce el POD) y al anular una guía. **No** se dispara en la creación de la guía.

        **Payload:** un único objeto envuelto en `data` — el mismo que devuelven
        `GET /api/orders/{id}` y `GET /api/orders/reference/{reference}`. Todos los puntos de
        llamada reconstruyen este payload desde la base de datos, así que **no existe una
        variante distinta para operaciones masivas**: una operación masiva emite un `POST` por
        guía, cada uno con esta misma forma.

        **POD:** el arreglo `pod` de nivel superior es el del evento actual, y cada evento del
        historial trae el suyo. En una entrega con foto, el `POST` se envía **después** de que
        los archivos existen, de modo que `pod` llega poblado.

        **Entrega:** un intento único, `POST` con `Content-Type: application/json`. El receptor
        debe responder **exactamente `200`**: cualquier respuesta distinta de `200` (incluidos
        `201` o `204`), un error de red o un timeout de 15 s se registra como fallo del lado de
        DovExpress y el evento **no se reintenta** — recupere el estado por polling
        (`GET /api/orders/{id}` o `GET /api/orders/reference/{reference}`).

        **Seguridad:** DovExpress envía un header `api-key` cuyo valor es **la propia API key del
        cliente** — la misma con la que el cliente autentica contra `https://api.dovexpresscr.com`.
        El receptor debe compararlo contra su llave y rechazar con `401` cualquier petición que no
        coincida o que no traiga el header: `if (req.headers['api-key'] !== MY_API_KEY) return 401`.

        El header `Authorization: Basic <credencial>` fijo se sigue enviando por
        retrocompatibilidad con integraciones existentes, pero está **deprecado**: es compartido
        por todos los clientes (no es una credencial por cliente), así que no identifica a quien
        llama. La recomendación oficial es validar `api-key`.

        Como defensa adicional se recomienda exponer el endpoint solo por HTTPS, con una URL con
        un componente no adivinable, y validar el contenido contra la API antes de actuar sobre
        montos.
      security: []
      parameters:
        - name: api-key
          in: header
          required: true
          description: |
            Equals your own API key — validate it. Es la misma llave (`Clients.api_key`) que el
            cliente envía a `/api/*`. El receptor debe compararla contra su llave y rechazar con
            `401` si no coincide.
          schema:
            type: string
            examples: ['<YOUR_CLIENT_API_KEY>']
        - name: Authorization
          in: header
          required: false
          deprecated: true
          description: |
            **Deprecado.** Credencial `Basic` fija, compartida por todos los clientes, que se
            mantiene solo por retrocompatibilidad. No identifica al cliente; valide `api-key` en
            su lugar. Se eliminará en una versión futura.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties:
                data:
                  oneOf:
                    - $ref: '#/components/schemas/Order'
                    - type: 'null'
                  description: |
                    El payload de la guía. `null` únicamente si la guía dejó de existir entre el
                    cambio de estado y la construcción del payload.
            examples:
              entregado:
                summary: Delivered (5015) con POD poblado
                value:
                  data:
                    id: 7c9e6679-7425-40de-944b-e07fc1f90ae7
                    code: DOV_300145
                    reference_id: LM00099123401
                    contactDetails:
                      contactName: Ana Rodríguez
                      contactPhone: '+50688887777'
                      contactPhone2: null
                    addressDetails:
                      address: 200 m norte de la escuela, casa portón verde
                      state: San José
                      region: San José
                      city: Carmen
                      country: CR
                      postalCode: '10801'
                      lat: 9.9355
                      lng: -84.0836
                      notes: Entregar después de las 2 p. m.
                    packageDetails:
                      product: Zapatos deportivos - Medias
                      quantity: '1 - 2'
                    cod: '45000'
                    currentStatus:
                      code: 5015
                      statusId: 3b1f0c6e-52a4-4a7c-9f0d-8b2e5d7a1c31
                      statusName: Delivered
                      statusNameEs: Entregado
                      createdAt: '2026-08-12T18:18:14.407Z'
                      pod:
                        - type: photo
                          url: https://dovexpress.s3.amazonaws.com/uploads/photos/00000000-0000-0000-0000-000000000000-1700000000000
                    pod:
                      - type: photo
                        url: https://dovexpress.s3.amazonaws.com/uploads/photos/00000000-0000-0000-0000-000000000000-1700000000000
                    trackingHistory:
                      - code: 5014
                        statusId: 9c2a7e10-6d44-4b8f-a1c3-0e5b7f2d9a48
                        statusName: Created
                        statusNameEs: Creado
                        createdAt: '2026-08-05T00:02:55.114Z'
                        pod: []
                      - code: 5013
                        statusId: 4d8b1a53-2c77-4e19-8f6a-b3c5d0e7192f
                        statusName: Cargo Received
                        statusNameEs: Carga Recibida
                        createdAt: '2026-08-09T22:16:20.502Z'
                        pod: []
                      - code: 5009
                        statusId: 61e9c47d-8a12-4f30-9b5e-7d2c4a8f1b06
                        statusName: Assigned to Driver
                        statusNameEs: Asignado a Conductor
                        createdAt: '2026-08-09T23:32:31.008Z'
                        pod: []
                      - code: 5028
                        statusId: a7f3d215-9e08-4c6b-81d4-5f2b9c3e7a40
                        statusName: In Transit
                        statusNameEs: En Tránsito
                        createdAt: '2026-08-09T23:32:31.412Z'
                        pod: []
                      - code: 5015
                        statusId: 3b1f0c6e-52a4-4a7c-9f0d-8b2e5d7a1c31
                        statusName: Delivered
                        statusNameEs: Entregado
                        createdAt: '2026-08-12T18:18:14.407Z'
                        pod:
                          - type: photo
                            url: https://dovexpress.s3.amazonaws.com/uploads/photos/00000000-0000-0000-0000-000000000000-1700000000000
              enTransito:
                summary: In Transit (5028), aún sin POD
                value:
                  data:
                    id: 1f4b2c8a-9d31-4e55-b0a7-3c6e8f2d1b04
                    code: DOV_300146
                    reference_id: LM00099123402
                    contactDetails:
                      contactName: Luis Mora
                      contactPhone: '+50677776666'
                      contactPhone2: null
                    addressDetails:
                      address: Frente al parque, edificio azul, apto 3
                      state: Heredia
                      region: Heredia
                      city: Ulloa
                      country: CR
                      postalCode: '40103'
                      lat: null
                      lng: null
                      notes: null
                    packageDetails:
                      product: Audífonos inalámbricos
                      quantity: '1'
                    cod: null
                    currentStatus:
                      code: 5028
                      statusId: a7f3d215-9e08-4c6b-81d4-5f2b9c3e7a40
                      statusName: In Transit
                      statusNameEs: En Tránsito
                      createdAt: '2026-08-11T14:05:02.771Z'
                      pod: []
                    pod: []
                    trackingHistory:
                      - code: 5014
                        statusId: 9c2a7e10-6d44-4b8f-a1c3-0e5b7f2d9a48
                        statusName: Created
                        statusNameEs: Creado
                        createdAt: '2026-08-07T18:02:19.905Z'
                        pod: []
                      - code: 5028
                        statusId: a7f3d215-9e08-4c6b-81d4-5f2b9c3e7a40
                        statusName: In Transit
                        statusNameEs: En Tránsito
                        createdAt: '2026-08-11T14:05:02.771Z'
                        pod: []
      responses:
        '200':
          description: |
            **Único código aceptado.** Cualquier respuesta distinta de `200` (incluidos
            `201`/`204`), un error de red o un timeout de 15 s se registra como fallo del lado de
            DovExpress y el evento **no se reintenta**: recupere el estado por polling. El cuerpo
            de la respuesta se ignora.

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: api-key
      description: |
        Llave del cliente (`Clients.api_key`). Requerida en todas las rutas `/api/*`.
        Su ausencia produce `400 Required`; una llave desconocida produce
        `401 Client not found`.

  parameters:
    OrderId:
      name: id
      in: path
      required: true
      description: Identificador interno de la guía (UUID), el `id` devuelto al crearla.
      schema:
        type: string
        examples: ['7c9e6679-7425-40de-944b-e07fc1f90ae7']

    OrderReference:
      name: reference
      in: path
      required: true
      description: |
        `reference_id` del integrador **o** `code` de DovExpress (`DOV_…`). Coincidencia exacta
        tras eliminar espacios al inicio y al final.
      schema:
        type: string
        examples: ['LM00099123401', 'DOV_300145']

  headers:
    RateLimit-Limit:
      description: Máximo de peticiones permitidas en la ventana actual.
      schema:
        type: integer
    RateLimit-Remaining:
      description: Peticiones restantes en la ventana actual.
      schema:
        type: integer
    RateLimit-Reset:
      description: Segundos hasta que la ventana se reinicia.
      schema:
        type: integer
    Retry-After:
      description: Segundos que el cliente debe esperar antes de reintentar.
      schema:
        type: integer

  responses:
    OrderFound:
      description: Payload canónico de la guía, con historial de eventos y POD.
      content:
        application/json:
          schema:
            type: object
            required: [success, message, data]
            properties:
              success:
                const: true
              message:
                const: Order Found
              data:
                oneOf:
                  - $ref: '#/components/schemas/Order'
                  - type: 'null'
          examples:
            entregadaConPod:
              summary: Guía entregada (Delivered / 5015) con POD
              value:
                success: true
                message: Order Found
                data:
                  id: 7c9e6679-7425-40de-944b-e07fc1f90ae7
                  code: DOV_300145
                  reference_id: LM00099123401
                  contactDetails:
                    contactName: Ana Rodríguez
                    contactPhone: '+50688887777'
                    contactPhone2: null
                  addressDetails:
                    address: 200 m norte de la escuela, casa portón verde
                    state: San José
                    region: San José
                    city: Carmen
                    country: CR
                    postalCode: '10801'
                    lat: 9.9355
                    lng: -84.0836
                    notes: Entregar después de las 2 p. m.
                  packageDetails:
                    product: Zapatos deportivos - Medias
                    quantity: '1 - 2'
                  cod: '45000'
                  currentStatus:
                    code: 5015
                    statusId: 3b1f0c6e-52a4-4a7c-9f0d-8b2e5d7a1c31
                    statusName: Delivered
                    statusNameEs: Entregado
                    createdAt: '2026-08-12T18:18:14.407Z'
                    pod:
                      - type: photo
                        url: https://dovexpress.s3.amazonaws.com/uploads/photos/00000000-0000-0000-0000-000000000000-1700000000000
                  pod:
                    - type: photo
                      url: https://dovexpress.s3.amazonaws.com/uploads/photos/00000000-0000-0000-0000-000000000000-1700000000000
                  trackingHistory:
                    - code: 5014
                      statusId: 9c2a7e10-6d44-4b8f-a1c3-0e5b7f2d9a48
                      statusName: Created
                      statusNameEs: Creado
                      createdAt: '2026-08-05T00:02:55.114Z'
                      pod: []
                    - code: 5013
                      statusId: 4d8b1a53-2c77-4e19-8f6a-b3c5d0e7192f
                      statusName: Cargo Received
                      statusNameEs: Carga Recibida
                      createdAt: '2026-08-09T22:16:20.502Z'
                      pod: []
                    - code: 5009
                      statusId: 61e9c47d-8a12-4f30-9b5e-7d2c4a8f1b06
                      statusName: Assigned to Driver
                      statusNameEs: Asignado a Conductor
                      createdAt: '2026-08-09T23:32:31.008Z'
                      pod: []
                    - code: 5028
                      statusId: a7f3d215-9e08-4c6b-81d4-5f2b9c3e7a40
                      statusName: In Transit
                      statusNameEs: En Tránsito
                      createdAt: '2026-08-09T23:32:31.412Z'
                      pod: []
                    - code: 5015
                      statusId: 3b1f0c6e-52a4-4a7c-9f0d-8b2e5d7a1c31
                      statusName: Delivered
                      statusNameEs: Entregado
                      createdAt: '2026-08-12T18:18:14.407Z'
                      pod:
                        - type: photo
                          url: https://dovexpress.s3.amazonaws.com/uploads/photos/00000000-0000-0000-0000-000000000000-1700000000000
            recienCreada:
              summary: Guía recién creada, `pod` vacío
              value:
                success: true
                message: Order Found
                data:
                  id: 1f4b2c8a-9d31-4e55-b0a7-3c6e8f2d1b04
                  code: DOV_300146
                  reference_id: LM00099123402
                  contactDetails:
                    contactName: Luis Mora
                    contactPhone: '+50677776666'
                    contactPhone2: null
                  addressDetails:
                    address: Frente al parque, edificio azul, apto 3
                    state: Heredia
                    region: Heredia
                    city: Ulloa
                    country: CR
                    postalCode: '40103'
                    lat: null
                    lng: null
                    notes: null
                  packageDetails:
                    product: Audífonos inalámbricos
                    quantity: '1'
                  cod: null
                  currentStatus:
                    code: 5014
                    statusId: 9c2a7e10-6d44-4b8f-a1c3-0e5b7f2d9a48
                    statusName: Created
                    statusNameEs: Creado
                    createdAt: '2026-08-07T18:02:19.905Z'
                    pod: []
                  pod: []
                  trackingHistory:
                    - code: 5014
                      statusId: 9c2a7e10-6d44-4b8f-a1c3-0e5b7f2d9a48
                      statusName: Created
                      statusNameEs: Creado
                      createdAt: '2026-08-07T18:02:19.905Z'
                      pod: []

    BadRequest:
      description: |
        Payload o headers inválidos. Cubre los errores del validador de esquema (`message` es el
        mensaje de la primera incidencia y `data` el arreglo completo de incidencias) y los
        errores de negocio de la familia 400 (`INVALID_PICKUP_DATE`, `MEASUREMENTS_REQUIRED`,
        `INVALID_MEASUREMENTS`, `DISTRICT_REQUIRED`, `INVALID_PAYER`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            headerFaltante:
              summary: Falta el header `api-key`
              value:
                success: false
                message: Required
                data:
                  - code: invalid_type
                    expected: string
                    received: undefined
                    path: [headers, api-key]
                    message: Required
                status: 400
                code: '00'
            campoFaltante:
              summary: Falta `contactDetails.contactName`
              value:
                success: false
                message: Required
                data:
                  - code: invalid_type
                    expected: string
                    received: undefined
                    path: [body, contactDetails, contactName]
                    message: Required
                status: 400
                code: '00'
            pagadorInvalido:
              summary: '`payer` fuera del catálogo'
              value:
                success: false
                message: El pagador debe ser SENDER o RECIPIENT
                data: null
                status: 400
                code: '10'
                errorCode: INVALID_PAYER
            fechaRecoleccionPasada:
              summary: '`expected_pickup_date` anterior a hoy'
              value:
                success: false
                message: La fecha de recolección no puede ser anterior a hoy
                data: null
                status: 400
                code: '10'
                errorCode: INVALID_PICKUP_DATE

    Unauthorized:
      description: |
        `api-key` desconocida. También es el código con el que las rutas de consulta responden
        cuando la guía no existe o pertenece a otro cliente.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            success: false
            message: Client not found
            data: []
            status: 401
            code: '00'

    UnprocessableEntity:
      description: |
        El payload es correcto y DovExpress no puede cotizar la guía (familia fail-closed, solo
        aplica a clientes con tarifa por matriz): `PARCEL_NOT_QUOTABLE`, `DISTRICT_NOT_FOUND`,
        `DISTRICT_NOT_ZONED`, `ZONE_NOT_PRICED`, `TARIFF_NOT_FOUND`, `TARIFF_CELL_MISSING`,
        `PRICING_MATRIX_NOT_ENABLED`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            success: false
            message: El distrito indicado no tiene zona asignada
            data: null
            status: 422
            code: '11'
            errorCode: DISTRICT_NOT_ZONED

    TooManyRequests:
      description: |
        Límite de peticiones excedido. Hay dos limitadores en serie sobre `/api`: uno por
        `api-key` (600 peticiones por minuto por defecto) y uno por dirección IP como respaldo
        (3000 por minuto por defecto). Ambos usan una ventana de 60 s.
      headers:
        Retry-After:
          $ref: '#/components/headers/Retry-After'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimit-Limit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimit-Remaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimit-Reset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            success: false
            message: Demasiadas solicitudes, intente de nuevo más tarde
            data: []
            status: 429
            code: '08'
            errorCode: RATE_LIMITED

    InternalError:
      description: |
        Error inesperado. En producción el mensaje de un fallo no previsto se reemplaza por
        `Ocurrió un error inesperado` y no se expone el stack; un error deliberado conserva su
        mensaje. Un cuerpo JSON malformado también cae aquí, con `message: "The JSON is malformed"`
        y `code: "98"`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            success: false
            message: Ocurrió un error inesperado
            status: 500
            code: '00'

  schemas:
    CreateOrderRequest:
      type: object
      required: [contactDetails, addressDetails, packageDetails]
      description: |
        Los tres objetos `contactDetails`, `addressDetails` y `packageDetails` son obligatorios
        como objetos, aunque `packageDetails` admite estar vacío (`{}`). Todo campo no listado
        como requerido acepta `null` y se omite sin efecto.
      properties:
        reference_id:
          type: [string, 'null']
          description: |
            Referencia del sistema del integrador. Opcional, pero es lo que permite consultar la
            guía por `GET /api/orders/reference/{reference}`. Debe ser única entre las guías
            activas del cliente.
          examples: ['LM00099123401']
        contactDetails:
          type: object
          required: [contactName]
          properties:
            contactName:
              type: string
              description: Nombre de la persona que recibe. **Requerido.**
            contactPhone:
              type: [string, 'null']
            contactPhone2:
              type: [string, 'null']
        addressDetails:
          type: object
          required: [address, postalCode]
          properties:
            address:
              type: string
              minLength: 1
              maxLength: 2000
              description: Dirección de entrega en texto libre. **Requerida.**
            postalCode:
              type: string
              description: |
                Código de distrito de Costa Rica (`Districts.code`). **Requerido**; se usa para
                resolver provincia/cantón/distrito y, en tarifa por matriz, la zona.
              examples: ['10801']
            state:
              type: [string, 'null']
              description: Provincia.
            region:
              type: [string, 'null']
              description: Cantón.
            city:
              type: [string, 'null']
              description: Distrito.
            country:
              type: [string, 'null']
            lat:
              type: [number, 'null']
            lng:
              type: [number, 'null']
            notes:
              type: [string, 'null']
              description: |
                Notas de entrega. Tiene prioridad sobre `notes` de nivel superior. DovExpress
                normaliza el texto: elimina líneas de tipo `Prov:`/`Canton:`/`Distrito:`/`Barrio:`
                y colapsa saltos de línea y espacios.
        packageDetails:
          type: object
          description: |
            Objeto requerido; todos sus campos son opcionales. `product`, `quantity` y `products`
            se combinan en una sola cadena de producto y una sola cadena de cantidades.
          properties:
            product:
              type: [string, 'null']
              description: |
                Nombre del producto. Admite las formas heredadas `"Producto - 2"` y
                `"A - 1, B - 3"`, que DovExpress separa en producto y cantidad. Se limpian los
                sufijos `(Costa Rica)`, `(MX)` y `1 box <n>`.
            quantity:
              type: [number, 'null']
              description: Cantidad asociada a `product` cuando no viene embebida en la cadena.
            products:
              type: [array, 'null']
              description: Lista de productos; se concatena con `product`.
              items:
                type: object
                required: [quantity]
                properties:
                  product:
                    type: [string, 'null']
                  name:
                    type: [string, 'null']
                    description: Alternativa a `product`; se usa si `product` viene vacío.
                  quantity:
                    type: number
                    description: '**Requerido** en cada elemento.'
            weightKg:
              type: [number, 'null']
              description: |
                Peso declarado. Solo se usa en tarifa por matriz, donde peso y las tres
                dimensiones son obligatorios; un valor cero o negativo produce
                `400 INVALID_MEASUREMENTS`.
            lengthCm:
              type: [number, 'null']
            widthCm:
              type: [number, 'null']
            heightCm:
              type: [number, 'null']
        cod:
          type: [number, 'null']
          description: |
            Monto de mercadería a cobrar contra entrega. En tarifa por matriz con
            `payer: RECIPIENT`, el flete se suma a este monto para formar el cobro total.
        notes:
          type: [string, 'null']
          description: Notas de entrega. Se usa solo si `addressDetails.notes` viene vacío.
        payer:
          type: [string, 'null']
          description: |
            Quién paga el flete. Valores aceptados: `SENDER`, `RECIPIENT`. Se valida en el motor
            de precios: un valor desconocido produce `400 INVALID_PAYER`. Ausente, vacío o `null`
            equivale a `SENDER`.
          examples: ['RECIPIENT']
        districtId:
          type: [string, 'null']
          description: |
            Identificador del distrito en el catálogo de DovExpress. Alternativa a `postalCode`
            para resolver la zona en tarifa por matriz.
        expected_pickup_date:
          type: [string, 'null']
          description: |
            Fecha esperada de recolección, `YYYY-MM-DD` en hora de Costa Rica. Ausente significa
            hoy. Una fecha malformada o inexistente produce `400`; una fecha pasada produce
            `400 INVALID_PICKUP_DATE`. Una fecha futura se acepta sin tope.
          examples: ['2026-08-20']

    CreateOrderResponse:
      type: object
      required: [success, message, data]
      properties:
        success:
          const: true
        message:
          const: Order Created
        data:
          type: object
          required: [code, referenceId, id]
          properties:
            code:
              type: string
              description: Número de tracking de DovExpress.
              examples: ['DOV_300145']
            referenceId:
              type: [string, 'null']
              description: La referencia enviada, o `null` si no se envió.
            id:
              type: string
              description: Identificador interno de la guía (UUID).
            pricing:
              $ref: '#/components/schemas/Pricing'

    Pricing:
      type: object
      description: |
        Precio congelado al crear la guía. Presente **solo** para clientes con tarifa por matriz
        habilitada; ausente para clientes legacy y en la respuesta de recuperación por referencia
        duplicada. Los montos son strings con dos decimales.
      required:
        [
          package_size,
          zone_group,
          volume_ordinal,
          volume_tier,
          price_base,
          tax_rate,
          tax_amount,
          price_total,
          tax_included,
          currency,
        ]
      properties:
        package_size:
          type: string
          description: Código de la talla del paquete asignada por las dimensiones declaradas.
          examples: ['M']
        zone_group:
          type: string
          description: Grupo de zona de destino resuelto desde el distrito.
          examples: ['GAM']
        volume_ordinal:
          type: integer
          description: Posición de la guía dentro del volumen mensual del cliente.
        volume_tier:
          type: integer
          description: Escalón de volumen que aplicó al precio.
        price_base:
          type: string
          description: Flete antes de impuesto.
          examples: ['2500.00']
        tax_rate:
          type: string
          description: Tasa de impuesto aplicada.
          examples: ['0.13']
        tax_amount:
          type: string
          examples: ['325.00']
        price_total:
          type: string
          description: '`price_base + tax_amount`, exacto.'
          examples: ['2825.00']
        tax_included:
          type: boolean
          description: Si la versión de tarifa interpretaba sus montos como impuesto incluido.
        currency:
          const: CRC

    Order:
      type: object
      description: |
        Payload canónico de la guía. Es el mismo objeto en las dos rutas de consulta y en el
        webhook, construido por una sola función (`buildOrderStatusPayload`).
      required:
        [
          id,
          code,
          reference_id,
          contactDetails,
          addressDetails,
          packageDetails,
          cod,
          currentStatus,
          pod,
          trackingHistory,
        ]
      properties:
        id:
          type: string
          description: Identificador interno (UUID).
        code:
          type: string
          description: Número de tracking de DovExpress.
          examples: ['DOV_300145']
        reference_id:
          type: [string, 'null']
          description: Referencia del integrador.
        contactDetails:
          type: object
          required: [contactName, contactPhone, contactPhone2]
          properties:
            contactName:
              type: string
            contactPhone:
              type: [string, 'null']
            contactPhone2:
              type: [string, 'null']
        addressDetails:
          type: object
          required: [address, state, region, city, country, postalCode, lat, lng, notes]
          properties:
            address:
              type: string
            state:
              type: [string, 'null']
              description: Provincia.
            region:
              type: [string, 'null']
              description: Cantón.
            city:
              type: [string, 'null']
              description: Distrito.
            country:
              type: [string, 'null']
            postalCode:
              type: [string, 'null']
            lat:
              type: [number, 'null']
            lng:
              type: [number, 'null']
            notes:
              type: [string, 'null']
        packageDetails:
          type: object
          required: [product, quantity]
          properties:
            product:
              type: string
              description: |
                Productos concatenados con ` - `, tal como quedaron almacenados al crear la guía.
              examples: ['Zapatos deportivos - Medias']
            quantity:
              type: string
              description: |
                Cantidades concatenadas con ` - `, alineadas posicionalmente con `product`.
                Es una **cadena**, no un número.
              examples: ['1 - 2']
        cod:
          type: [string, 'null']
          description: |
            Monto total a cobrar contra entrega. Serializado como string por ser un decimal en
            base de datos; `null` si la guía no lleva cobro.
          examples: ['45000']
        currentStatus:
          oneOf:
            - $ref: '#/components/schemas/TrackingEvent'
            - type: 'null'
          description: |
            Último evento del historial, o `null` si la guía todavía no tiene ningún evento.
        pod:
          type: array
          description: |
            Prueba de entrega del **evento actual**. Vacío antes de la entrega y en cualquier
            evento que no capture archivos; se llena en el evento `Delivered` con la foto y, si
            el conductor la capturó, la firma. Duplica `currentStatus.pod` para que un integrador
            no tenga que descender al evento.
          items:
            $ref: '#/components/schemas/PodFile'
        trackingHistory:
          type: array
          description: |
            Historial completo de eventos no eliminados, en orden cronológico ascendente. Puede
            contener el mismo estado más de una vez.
          items:
            $ref: '#/components/schemas/TrackingEvent'

    TrackingEvent:
      type: object
      required: [code, statusId, statusName, statusNameEs, createdAt, pod]
      properties:
        code:
          type: [integer, 'null']
          description: |
            Código DOV del estado — el valor estable con el que mapear. `null` si al estado
            todavía no se le asignó código.
          examples: [5015]
        statusId:
          type: string
          description: |
            Identificador interno del estado (UUID) — el id de la fila `Status`. **No es la clave de
            mapeo:** mapee por `code` (`dov_status_code`).
        statusName:
          type: string
          description: Nombre del estado en inglés.
          examples: ['Delivered']
        statusNameEs:
          type: string
          description: Nombre del estado en español.
          examples: ['Entregado']
        createdAt:
          type: string
          format: date-time
          description: |
            Momento del evento, ISO-8601 UTC. El registro operativo es en hora de Costa Rica
            (UTC−6, sin horario de verano).
          examples: ['2026-08-12T18:18:14.407Z']
        pod:
          type: array
          description: Archivos de prueba de entrega asociados a este evento. Vacío si no hay.
          items:
            $ref: '#/components/schemas/PodFile'

    PodFile:
      type: object
      required: [type, url]
      properties:
        type:
          type: string
          enum: [photo, signature]
          description: |
            `photo` es la foto de entrega, `signature` la firma del receptor. Solo estos dos
            tipos se exponen; otros archivos internos de la guía se filtran.
        url:
          type: string
          format: uri
          description: |
            URL pública en S3. Las fotos viven bajo `uploads/photos/` y las firmas bajo
            `uploads/signatures/`, con nombre `<uuid>-<epoch_ms>`.
          examples:
            ['https://dovexpress.s3.amazonaws.com/uploads/photos/00000000-0000-0000-0000-000000000000-1700000000000']

    Status:
      type: object
      required: [code, name, name_es, is_final, requires_photo, requires_signature]
      properties:
        code:
          type: [integer, 'null']
          description: Código DOV, estable y único.
          examples: [5015]
        name:
          type: string
          description: Nombre en inglés.
          examples: ['Delivered']
        name_es:
          type: string
          description: Nombre en español.
          examples: ['Entregado']
        is_final:
          type: boolean
          description: Si el estado cierra el ciclo de la guía.
        requires_photo:
          type: boolean
          description: Si la operación exige foto al registrar este estado.
        requires_signature:
          type: boolean
          description: Si la operación exige firma al registrar este estado.

    Webhook:
      type: object
      required: [url, updatedAt]
      properties:
        url:
          type: string
          format: uri
        updatedAt:
          type: string
          format: date-time

    SetWebhookRequest:
      type: object
      required: [url]
      properties:
        url:
          type: string
          format: uri
          description: URL absoluta que recibirá los `POST` de cambio de estado.
          examples: ['https://mi-sistema.example.com/hooks/dovexpress']

    Error:
      type: object
      description: |
        Forma única de todos los errores de la API, producida por un solo handler. `stack` se
        incluye únicamente fuera de producción.
      required: [success, message, status, code]
      properties:
        success:
          const: false
        message:
          type: string
          description: |
            Mensaje legible. En los errores de validación es el mensaje de la primera incidencia
            del esquema; en los errores de negocio es el texto del catálogo (en español).
        data:
          description: |
            Contexto del error. Arreglo de incidencias en los errores de validación, `null` en
            los errores del catálogo de precios y de órdenes, `[]` en los errores genéricos, y un
            objeto con el dato ofensor en unos pocos casos (p. ej. `reference_id` en un 409).
        status:
          type: integer
          description: Repite el código HTTP de la respuesta.
          examples: [400]
        code:
          oneOf:
            - type: string
            - type: number
          description: |
            Código heredado, grueso. Los valores comunes son strings: `'00'` genérico,
            `'08'` límite de peticiones, `'10'` "corrija el payload", `'11'` no se puede cotizar,
            `'12'` no encontrado, `'13'` conflicto de estado, `'98'` JSON malformado. **No es un
            conjunto cerrado ni siempre un string:** un fallo no previsto puede propagar el código
            del driver de base de datos (p. ej. `'P2002'`) o un número (`100`), así que trátelo
            como opaco y ramifique sobre `errorCode`.
          examples: ['10']
        errorCode:
          type: string
          description: |
            Código simbólico estable, presente solo en los errores que lo definen. Es el valor
            sobre el que conviene ramificar.
          examples:
            [
              RATE_LIMITED,
              ORDER_ALREADY_EXISTS,
              INVALID_PAYER,
              INVALID_PICKUP_DATE,
              MEASUREMENTS_REQUIRED,
              INVALID_MEASUREMENTS,
              DISTRICT_REQUIRED,
              PARCEL_NOT_QUOTABLE,
              DISTRICT_NOT_FOUND,
              DISTRICT_NOT_ZONED,
              ZONE_NOT_PRICED,
              TARIFF_NOT_FOUND,
              TARIFF_CELL_MISSING,
              PRICING_MATRIX_NOT_ENABLED,
            ]
        stack:
          type: string
          description: Solo fuera de producción.
