openapi: 3.1.0
info:
  title: POS Colombia API
  version: "1.1.0"
  description: >
    API pública REST de lectura y escritura de POS Colombia. Permite leer y
    crear/editar productos, categorías, clientes e inventario, y registrar
    ventas desde tu propio sistema. Incluye entorno de pruebas (sandbox, con
    llaves pk_test_) y un servidor MCP. Disponible en planes Profesional,
    Empresa y Cadena con pago activo. Textos y errores en español.
  contact:
    name: Soporte POS Colombia
    email: gerencia@poscolombia.com
    url: https://poscolombia.com/desarrolladores
  license:
    name: Propietario
servers:
  - url: https://poscolombia.com/api/v1
    description: Producción (llaves pk_live_) y sandbox (llaves pk_test_)
security:
  - apiKey: []
tags:
  - name: Órdenes
  - name: Productos
  - name: Categorías
  - name: Domicilios
  - name: Inventario
  - name: Clientes
  - name: Sandbox
paths:
  /orders:
    get:
      tags: [Órdenes]
      summary: Listar órdenes
      description: Órdenes del org (excluye cotizaciones). Scope orders:read.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
        - name: status
          in: query
          schema:
            type: string
            enum: [open, pending, preparing, ready, delivered, cancelled]
        - name: from
          in: query
          description: created_at >= from (ISO 8601)
          schema: { type: string, format: date-time }
        - name: to
          in: query
          description: created_at < to (ISO 8601, exclusivo)
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Order" }
                  pagination: { $ref: "#/components/schemas/Pagination" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/AccessDenied" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Órdenes]
      summary: Registrar una venta
      description: >
        Crea una orden. El server calcula totales/impuestos, descuenta
        inventario y (según config) la manda a cocina. Scope orders:write.
        Consume 1 del cupo mensual de escritura. Mandá clientReference (uuid)
        para idempotencia: un reintento con el mismo valor devuelve la misma
        orden, sin duplicar la venta.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrderCreate" }
      responses:
        "201":
          description: Orden creada
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Order" }
        "200":
          description: >
            Reintento idempotente — mismo clientReference; se devuelve la misma
            orden, sin duplicar la venta.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Order" }
                  replayed: { type: boolean, example: true }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/AccessDenied" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: >
            idempotency_conflict — carrera concurrente con el mismo
            clientReference; reintentá en un momento.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/WriteQuotaOrRateLimited" }
  /orders/summary:
    get:
      tags: [Órdenes]
      summary: Resumen de ventas
      description: Totales del rango + desglose por día (Bogotá UTC-5). Scope orders:read.
      parameters:
        - name: from
          in: query
          schema: { type: string, format: date-time }
        - name: to
          in: query
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrdersSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/AccessDenied" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /orders/{id}:
    get:
      tags: [Órdenes]
      summary: Consultar una venta
      description: Una venta con sus líneas, estado de cocina y de pago. Scope orders:read.
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      responses:
        "200":
          description: La venta
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Order" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/AccessDenied" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /orders/{id}/pay:
    post:
      tags: [Órdenes]
      summary: Marcar como pagada una venta creada por la API
      description: >
        Registra que la venta ya se cobró (p. ej. pago en línea confirmado). No
        cambia el estado de cocina. Solo ventas creadas por la API. Scope
        orders:write. Consume 1 del cupo; si ya estaba pagada responde 200 con
        replayed=true sin consumir cupo.
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                paymentMethod:
                  type: string
                  enum: [cash, card, transfer, nequi, daviplata]
                paymentReference: { type: string, maxLength: 120 }
      responses:
        "200":
          description: Venta pagada (o ya lo estaba, con replayed=true)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Order" }
                  replayed: { type: boolean }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/AccessDenied" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: order_cancelled (la venta está anulada) o delivery_fee_pending (la tarifa de envío está por cotizar).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/WriteQuotaOrRateLimited" }
  /orders/{id}/cancel:
    post:
      tags: [Órdenes]
      summary: Anular una venta creada por la API
      description: >
        Anula la venta y devuelve el inventario que descontó. Solo ventas creadas
        por la API, no entregadas y sin factura electrónica vigente. Si ya está
        pagada exige confirmPaidVoid=true. Scope orders:write. Consume 1 del
        cupo; si ya estaba anulada responde 200 con replayed=true.
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, maxLength: 300 }
                confirmPaidVoid: { type: boolean }
      responses:
        "200":
          description: Venta anulada (o ya lo estaba, con replayed=true)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Order" }
                  replayed: { type: boolean }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/AccessDenied" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: >
            order_delivered (se anula en el POS), invoice_active (primero la nota
            crédito) o requires_void_confirm (está pagada).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/WriteQuotaOrRateLimited" }
  /orders/quote:
    post:
      tags: [Órdenes]
      summary: Calcular los totales de una orden sin crearla
      description: >
        Devuelve subtotal, impuestos, envío (por zona o coordenadas) y total de
        un pedido, con el mismo cálculo que usa la creación. No escribe nada: no
        crea la orden ni el cliente, no descuenta inventario y no consume cupo de
        escritura. Scope: basta uno de products:read, orders:read u
        orders:write. El total es informativo: la orden se recalcula al crearla.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrderQuoteRequest" }
            example:
              orderType: domicilio
              items:
                - productId: 7b0c2f0e-5a1d-4a52-9d6f-0f6f6c0a1b11
                  quantity: 2
                  modifiers:
                    - modifierId: 2f1b7c1e-9d3a-4c55-8a6e-3b1f0d2c4e99
              deliveryZoneId: 0c7f6a4e-2b1d-4f8a-9c3e-5d6b7a8f9e10
      responses:
        "200":
          description: Totales calculados
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/OrderQuote" }
              example:
                data:
                  order_type: domicilio
                  currency: COP
                  items:
                    - product_id: 7b0c2f0e-5a1d-4a52-9d6f-0f6f6c0a1b11
                      product_name: Roll California
                      quantity: 2
                      unit_price: 30000
                      subtotal: 60000
                      notes: null
                  unavailable_product_ids: []
                  subtotal: 60000
                  tax_amount: 4800
                  delivery_fee: 5000
                  delivery_fee_pending: false
                  delivery_zone:
                    id: 0c7f6a4e-2b1d-4f8a-9c3e-5d6b7a8f9e10
                    name: Laureles
                    estimated_minutes: 30
                  total_amount: 69800
        "400":
          description: >
            validation_error, unsupported_in_v1, customer_not_found u
            order_rejected (ningún producto válido, opción que no es de ese
            producto o sin existencias; el mismo rechazo que daría la creación).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/AccessDenied" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /products:
    get:
      tags: [Productos]
      summary: Listar productos
      description: Scope products:read.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
        - name: active
          in: query
          schema: { type: boolean, default: true }
        - name: category_id
          in: query
          schema: { type: string, format: uuid }
        - name: include
          in: query
          required: false
          description: "modifiers = agrega modifier_groups con las adiciones/opciones de cada producto"
          schema: { type: string, enum: [modifiers] }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Product" }
                  pagination: { $ref: "#/components/schemas/Pagination" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/AccessDenied" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Productos]
      summary: Crear producto
      description: Scope products:write. Consume 1 del cupo.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProductCreate" }
      responses:
        "201":
          description: Creado
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Product" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/AccessDenied" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/WriteQuotaOrRateLimited" }
  /products/top:
    get:
      tags: [Productos]
      summary: Top productos por ventas
      description: Scope orders:read o products:read.
      parameters:
        - name: from
          in: query
          schema: { type: string, format: date-time }
        - name: to
          in: query
          schema: { type: string, format: date-time }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
        - name: sort
          in: query
          schema: { type: string, enum: [units, revenue], default: units }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TopProducts" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/AccessDenied" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /products/{id}:
    patch:
      tags: [Productos]
      summary: Editar producto
      description: Scope products:write. Consume 1 del cupo. 404 si el id no es de tu org.
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProductUpdate" }
      responses:
        "200":
          description: Actualizado
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Product" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/AccessDenied" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/WriteQuotaOrRateLimited" }
  /categories:
    get:
      tags: [Categorías]
      summary: Listar categorías
      description: Categorías en el orden del POS. Scope products:read.
      parameters:
        - name: active
          in: query
          schema: { type: boolean, default: true }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        name: { type: string }
                        display_order: { type: integer }
                        is_active: { type: boolean }
                        created_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/AccessDenied" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Categorías]
      summary: Crear categoría
      description: >
        Scope categories:write. Consume 1 del cupo. Categorías es write-only en
        v1 (para leer el catálogo usá GET /products, que trae category_id).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CategoryCreate" }
      responses:
        "201":
          description: Creada
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Category" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/AccessDenied" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/WriteQuotaOrRateLimited" }
  /categories/{id}:
    patch:
      tags: [Categorías]
      summary: Editar categoría
      description: Scope categories:write. 404 si no es de tu org.
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CategoryUpdate" }
      responses:
        "200":
          description: Actualizada
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Category" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/AccessDenied" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/WriteQuotaOrRateLimited" }
  /ingredients:
    post:
      tags: [Inventario]
      summary: Crear insumo
      description: >
        Scope inventory:write. Consume 1 del cupo. Anti-duplicados: nombre
        activo repetido (normalizado) → 409; archivado → se reactiva.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IngredientCreate" }
      responses:
        "201":
          description: Creado
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Ingredient" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/AccessDenied" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/WriteQuotaOrRateLimited" }
    patch:
      tags: [Inventario]
      summary: Editar insumo
      description: >
        Scope inventory:write. El id va en el body. currentStock se ignora acá;
        para mover stock usá POST /inventory/adjust. 404 si no es de tu org.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IngredientUpdate" }
      responses:
        "200":
          description: Actualizado
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Ingredient" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/AccessDenied" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/WriteQuotaOrRateLimited" }
  /inventory/adjust:
    post:
      tags: [Inventario]
      summary: Ajustar stock de un insumo
      description: >
        Ajuste relativo (delta firmado, sin piso) o conteo físico absoluto
        (countedStock, piso 0). Respeta bodegas vía locationId. Scope
        inventory:write. Consume 1 del cupo. 404 si el insumo no es de tu org.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StockAdjustment" }
      responses:
        "200":
          description: Ajustado
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Ingredient" }
                  adjustment:
                    type: object
                    properties:
                      previous_stock: { type: number }
                      new_stock: { type: number }
                      delta: { type: number }
                      mode: { type: string, enum: [delta, count] }
                      location_id: { type: string, format: uuid, nullable: true }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/AccessDenied" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/WriteQuotaOrRateLimited" }
  /customers:
    get:
      tags: [Clientes]
      summary: Listar clientes
      description: Scope customers:read.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
        - name: search
          in: query
          description: Substring CI en name o phone
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Customer" }
                  pagination: { $ref: "#/components/schemas/Pagination" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/AccessDenied" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Clientes]
      summary: Crear cliente
      description: Scope customers:write. Consume 1 del cupo.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomerCreate" }
      responses:
        "201":
          description: Creado
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Customer" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/AccessDenied" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/WriteQuotaOrRateLimited" }
  /customers/{id}:
    patch:
      tags: [Clientes]
      summary: Editar cliente
      description: Scope customers:write. 404 si no es de tu org.
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomerUpdate" }
      responses:
        "200":
          description: Actualizado
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Customer" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/AccessDenied" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/WriteQuotaOrRateLimited" }
  /delivery-zones:
    get:
      tags: [Domicilios]
      summary: Listar zonas de domicilio
      description: >
        Zonas de domicilio del negocio con su tarifa y tiempo estimado (las
        mismas que usa la caja), de la más barata a la más cara. Scope: basta
        uno de products:read, orders:read u orders:write. El id de la zona se
        manda como deliveryZoneId al cotizar o crear una orden. En sandbox, la
        primera consulta deja dos zonas de ejemplo.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
        - name: active
          in: query
          description: false = incluye las zonas apagadas
          schema: { type: boolean, default: true }
        - name: location_id
          in: query
          description: Sede. Trae las zonas de esa sede y las que aplican a todas.
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/DeliveryZone" }
                  pagination: { $ref: "#/components/schemas/Pagination" }
              example:
                data:
                  - id: 0c7f6a4e-2b1d-4f8a-9c3e-5d6b7a8f9e10
                    name: Laureles
                    delivery_fee: 5000
                    estimated_minutes: 30
                    is_active: true
                    location_id: null
                    location_name: null
                    has_area: true
                    radius_km_min: 0
                    radius_km_max: 3
                    created_at: "2026-10-01T15:00:00Z"
                pagination: { limit: 50, offset: 0, total: 1, has_more: false }
        "400":
          description: invalid_location_id (location_id no es un uuid)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/AccessDenied" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /sandbox/reset:
    post:
      tags: [Sandbox]
      summary: Limpiar la org sandbox
      description: >
        Borra la data de prueba (órdenes, clientes, productos, categorías) de la
        org sandbox. Solo funciona con una llave pk_test_. Nunca vacía una org
        real.
      responses:
        "200":
          description: Sandbox limpio
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      reset: { type: boolean, example: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
components:
  securitySchemes:
    apiKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: >
        Llave de API pk_live_ (producción) o pk_test_ (sandbox), 48 chars
        base32. Se genera en Configuración → API Keys del dashboard.
  parameters:
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
    Offset:
      name: offset
      in: query
      schema: { type: integer, minimum: 0, default: 0 }
    ResourceId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
  responses:
    Unauthorized:
      description: Falta la llave, es inválida, está revocada o expirada
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    AccessDenied:
      description: Plan no permite API o suscripción no activa (402)
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/Error"
              - type: object
                properties:
                  reason:
                    type: string
                    enum: [plan_too_low, trial_only, subscription_inactive, subscription_expired, no_subscription]
    Forbidden:
      description: La llave no tiene el scope requerido (insufficient_scope)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: El recurso no existe en tu org
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Conflict:
      description: Choque de unicidad (p. ej. SKU repetido)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ValidationError:
      description: El body no pasó la validación
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Rate limit excedido (Retry-After en segundos)
      headers:
        Retry-After:
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    WriteQuotaOrRateLimited:
      description: >
        429: rate_limited (con Retry-After) o write_quota_exceeded (con used/cap).
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/Error"
              - type: object
                properties:
                  used: { type: integer }
                  cap: { type: integer, nullable: true }
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error: { type: string, example: insufficient_scope }
        detail: { type: string }
        message: { type: string }
    Pagination:
      type: object
      properties:
        limit: { type: integer }
        offset: { type: integer }
        total: { type: integer, nullable: true }
        has_more: { type: boolean }
    Order:
      type: object
      properties:
        id: { type: string, format: uuid }
        order_number: { type: string }
        status:
          type: string
          enum: [open, pending, preparing, ready, delivered, cancelled]
        customer_name: { type: string, nullable: true }
        customer_phone: { type: string, nullable: true }
        total_amount: { type: number }
        subtotal: { type: number }
        tax_amount: { type: number }
        payment_method:
          type: string
          nullable: true
          description: >
            Medio de pago de la venta. Al LEER puede traer valores que la API no
            acepta al crear ni al pagar: mixed (pago combinado), credito (fiado)
            y plataforma (venta registrada en la caja que le paga al negocio una
            plataforma de domicilios, DiDi Food o Rappi: no entró plata a la
            caja ni al banco, queda por cobrar a la plataforma). En
            GET /orders/{id} el campo source dice cuál (didi, rappi).
        source:
          type: string
          nullable: true
          description: >
            Por dónde entró la venta: pos (caja), web (creada por la API),
            whatsapp, rappi, didi. Viene en GET /orders/{id} y en las respuestas
            de escritura; el listado GET /orders no lo trae.
        payment_status:
          type: string
          nullable: true
          description: null = sin pago registrado; paid = pagada.
        payment_reference: { type: string, nullable: true }
        delivery_address: { type: string, nullable: true }
        delivery_neighborhood: { type: string, nullable: true }
        delivery_fee: { type: number, nullable: true }
        delivery_fee_pending:
          type: boolean
          description: true = la dirección no cayó en una zona con tarifa; el negocio la cotiza.
        delivery_status:
          type: string
          nullable: true
          enum: [pending, assigned, picked_up, delivered, returned]
          description: Domicilio. picked_up = en ruta ("Va en camino").
        order_type: { type: string, enum: [local, para_llevar, domicilio] }
        table_number: { type: integer, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    OrderItem:
      type: object
      required: [productId, quantity]
      properties:
        productId: { type: string, format: uuid }
        quantity: { type: number, description: "Entero, o decimal para venta por peso" }
        notes: { type: string }
        modifiers:
          type: array
          description: >
            Modificadores de la línea. El server re-lee price_delta/ingrediente
            del catálogo (anti-tamper): solo usa modifierId.
          items:
            type: object
            required: [modifierId]
            properties:
              modifierId: { type: string, format: uuid }
    OrderCreate:
      type: object
      required: [orderType, items, paymentMethod, clientReference]
      properties:
        orderType: { type: string, enum: [local, para_llevar, domicilio] }
        items:
          type: array
          minItems: 1
          maxItems: 200
          items: { $ref: "#/components/schemas/OrderItem" }
        paymentMethod:
          type: string
          enum: [cash, card, transfer, nequi, daviplata]
        clientReference:
          type: string
          format: uuid
          description: Idempotencia — un uuid por intento de venta (obligatorio).
        customerId: { type: string, format: uuid }
        customerName: { type: string, description: "Cliente ocasional (si no mandás customerId). Si el negocio exige nombre y apellido, completo (ej. Wendy Mosquera); si no, 400 customer_full_name_required." }
        customerPhone: { type: string, description: "Teléfono del cliente ocasional" }
        notes: { type: string }
        autoAccept:
          type: boolean
          description: true = entra directo a cocina (preparing), sin Aceptar/Rechazar en el POS.
        paid:
          type: boolean
          description: true = ya la cobraste (pago en línea); nace con payment_status=paid.
        paymentReference: { type: string, maxLength: 120 }
        deliveryAddress: { type: string }
        deliveryNeighborhood: { type: string }
        deliveryReferences: { type: string }
        deliveryLat: { type: number }
        deliveryLng: { type: number }
        deliveryZoneId:
          type: string
          format: uuid
          description: >
            Zona de reparto del negocio. La tarifa la calcula el server con las
            zonas configuradas (por coordenadas o por esta zona), nunca del body.
      description: >
        Necesita al menos una línea en items. v1 NO soporta customItems,
        variantId, mitad y mitad, cuentas abiertas, fiado, pago mixto, cobro
        por plataforma (plataforma) ni cupones: un body que los use recibe 400.
    OrderQuoteRequest:
      type: object
      required: [orderType, items]
      description: >
        Mismo cuerpo de OrderCreate; paymentMethod y clientReference son
        opcionales. Los precios nunca se toman del body.
      properties:
        orderType: { type: string, enum: [local, para_llevar, domicilio] }
        items:
          type: array
          minItems: 1
          maxItems: 200
          items: { $ref: "#/components/schemas/OrderItem" }
        customerId: { type: string, format: uuid }
        customerPhone: { type: string }
        deliveryZoneId:
          type: string
          format: uuid
          description: Zona de GET /delivery-zones.
        deliveryLat: { type: number }
        deliveryLng: { type: number }
        paymentMethod:
          type: string
          enum: [cash, card, transfer, nequi, daviplata]
    OrderQuote:
      type: object
      properties:
        order_type: { type: string, enum: [local, para_llevar, domicilio] }
        currency: { type: string, example: COP }
        items:
          type: array
          items:
            type: object
            properties:
              product_id: { type: string, format: uuid }
              product_name: { type: string }
              quantity: { type: number }
              unit_price:
                type: number
                description: Precio unitario con las opciones elegidas.
              subtotal: { type: number }
              notes: { type: string, nullable: true }
        unavailable_product_ids:
          type: array
          description: Productos del body que no existen o están apagados (la creación los omite).
          items: { type: string }
        subtotal:
          type: number
          description: Base sin impuestos.
        tax_amount: { type: number }
        delivery_fee: { type: number }
        delivery_fee_pending:
          type: boolean
          description: true = domicilio sin zona con tarifa; el negocio la cotiza al recibir la orden.
        delivery_zone:
          type: object
          nullable: true
          properties:
            id: { type: string, format: uuid }
            name: { type: string, nullable: true }
            estimated_minutes: { type: integer, nullable: true }
        total_amount:
          type: number
          description: subtotal + tax_amount + delivery_fee.
    DeliveryZone:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        delivery_fee:
          type: integer
          description: Tarifa del envío en pesos enteros (COP).
        estimated_minutes: { type: integer, nullable: true }
        is_active: { type: boolean }
        location_id:
          type: string
          format: uuid
          nullable: true
          description: Sede de la zona; null = aplica a todas.
        location_name: { type: string, nullable: true }
        has_area:
          type: boolean
          description: >
            true = la zona tiene área en el mapa: un pedido con coordenadas que
            caiga adentro toma esta tarifa. false = solo se elige por su id.
        radius_km_min: { type: number, nullable: true }
        radius_km_max: { type: number, nullable: true }
        created_at: { type: string, format: date-time }
    OrdersSummary:
      type: object
      properties:
        range:
          type: object
          properties:
            from: { type: string, format: date-time }
            to: { type: string, format: date-time }
        summary:
          type: object
          properties:
            total_sales: { type: number }
            order_count: { type: integer }
            average_ticket: { type: integer }
        by_day:
          type: array
          items:
            type: object
            properties:
              date: { type: string, format: date }
              total: { type: number }
              count: { type: integer }
        truncated: { type: boolean }
    TopProducts:
      type: object
      properties:
        range:
          type: object
          properties:
            from: { type: string, format: date-time }
            to: { type: string, format: date-time }
        sort: { type: string, enum: [units, revenue] }
        products:
          type: array
          items:
            type: object
            properties:
              product_id: { type: string }
              product_name: { type: string }
              units: { type: number }
              revenue: { type: number }
        truncated: { type: boolean }
    Product:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        description: { type: string, nullable: true }
        price: { type: number }
        sku: { type: string, nullable: true }
        category_id: { type: string, format: uuid, nullable: true }
        category_name: { type: string, nullable: true }
        image_url: { type: string, nullable: true }
        is_active: { type: boolean }
        stock: { type: number, nullable: true }
        sold_out:
          type: boolean
          description: true = controla stock propio y llegó a 0.
        modifier_groups:
          type: array
          description: Solo con ?include=modifiers. Adiciones/opciones con los ids que acepta POST /orders.
          items:
            type: object
            properties:
              id: { type: string, format: uuid }
              name: { type: string }
              description: { type: string, nullable: true }
              is_required: { type: boolean }
              min_selections: { type: integer }
              max_selections: { type: integer }
              modifiers:
                type: array
                items:
                  type: object
                  properties:
                    id: { type: string, format: uuid }
                    name: { type: string }
                    price_delta: { type: number }
        tax_category:
          type: string
          enum: [standard, reduced, exempt, consumo]
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    ProductCreate:
      type: object
      required: [name, price]
      properties:
        name: { type: string, maxLength: 100 }
        price: { type: number, minimum: 1 }
        description: { type: string }
        categoryId: { type: string, format: uuid }
        sku: { type: string, maxLength: 64 }
        barcode: { type: string, maxLength: 128 }
        taxCategory:
          type: string
          enum: [standard, reduced, exempt, consumo]
        isActive: { type: boolean, default: true }
        isAvailable: { type: boolean, default: true }
        stock: { type: number, minimum: 0, nullable: true }
        costPerUnit: { type: number, minimum: 0, nullable: true }
        imageUrl: { type: string }
        sortOrder: { type: integer, default: 0 }
    ProductUpdate:
      allOf:
        - $ref: "#/components/schemas/ProductCreate"
      description: Todos los campos opcionales (partial).
      required: []
    Category:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        description: { type: string, nullable: true }
        color: { type: string, nullable: true }
        icon: { type: string, nullable: true }
        sort_order: { type: integer }
        is_active: { type: boolean }
    CategoryCreate:
      type: object
      required: [name]
      properties:
        name: { type: string, maxLength: 50 }
        description: { type: string }
        color: { type: string }
        icon: { type: string }
        sortOrder: { type: integer, default: 0 }
        isActive: { type: boolean, default: true }
    CategoryUpdate:
      allOf:
        - $ref: "#/components/schemas/CategoryCreate"
      required: []
    Ingredient:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        category: { type: string, nullable: true }
        unit: { type: string }
        current_stock: { type: number }
        min_stock: { type: number }
        cost_per_unit: { type: number }
        supplier: { type: string, nullable: true }
        is_active: { type: boolean }
        is_preparation: { type: boolean }
        yield_quantity: { type: number, nullable: true }
    IngredientCreate:
      type: object
      required: [name, unit]
      properties:
        name: { type: string, maxLength: 100 }
        unit: { type: string }
        category: { type: string }
        currentStock: { type: number, minimum: 0, default: 0 }
        minStock: { type: number, minimum: 0, default: 0 }
        costPerUnit: { type: number, minimum: 0, default: 0 }
        supplier: { type: string }
        isActive: { type: boolean, default: true }
    IngredientUpdate:
      type: object
      required: [id]
      description: >
        Partial de IngredientCreate + el id del insumo (en el body).
        currentStock se ignora (usá POST /inventory/adjust).
      properties:
        id: { type: string, format: uuid }
        name: { type: string, maxLength: 100 }
        unit: { type: string }
        category: { type: string }
        minStock: { type: number, minimum: 0 }
        costPerUnit: { type: number, minimum: 0 }
        supplier: { type: string }
        isActive: { type: boolean }
    StockAdjustment:
      type: object
      required: [ingredientId]
      description: >
        Mandá delta (ajuste relativo firmado) O countedStock (conteo físico
        absoluto), no ambos.
      properties:
        ingredientId: { type: string, format: uuid }
        delta:
          type: number
          description: Ajuste relativo firmado. Positivo = entra, negativo = sale.
        countedStock:
          type: number
          minimum: 0
          description: Conteo físico absoluto; el stock se fija a este valor.
        notes: { type: string, maxLength: 500 }
        locationId:
          type: string
          format: uuid
          description: Bodega (si el org usa bodegas); por defecto la principal.
    Customer:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        phone: { type: string, nullable: true }
        email: { type: string, nullable: true }
        total_orders: { type: integer }
        total_spent: { type: number }
        last_order_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
    CustomerCreate:
      type: object
      required: [name]
      properties:
        name: { type: string, maxLength: 100 }
        idType:
          type: string
          enum: [NIT, CC, CE, PP, consumidor_final]
          default: consumidor_final
        idNumber: { type: string, description: "Documento CO, hasta 15 dígitos" }
        email: { type: string, format: email }
        phone: { type: string, description: "7-10 dígitos" }
        address: { type: string }
        city: { type: string }
        notes: { type: string }
        contactName: { type: string }
        contactPhone: { type: string }
    CustomerUpdate:
      allOf:
        - $ref: "#/components/schemas/CustomerCreate"
      required: []
