openapi: 3.1.0

info:
  title: Vectrakpital API
  version: "1.0.0"
  summary: Saldos, cambios, depósitos y retiros de tu organización.
  description: |
    La API de Vectrakpital es la misma superficie que usa el portal del cliente. No una copia: los
    *mismos* endpoints, así que una regla que se aprieta en un sitio no se puede olvidar en el otro.

    Tres cosas deciden si una integración funciona, y las tres son fáciles de pasar por alto:

    **Todo monto es un entero en unidades mínimas del activo.** Nunca un decimal, nunca un float, y el
    número de decimales es por activo: USDT lleva cuatro y COPM lleva dos. Léelos de la respuesta; no
    los asumas. Enviar `1000` donde tocaba `10000000` es un error de factor mil que ninguna validación
    puede detectar, porque los dos son montos válidos.

    **Nada de esto mueve dinero por su cuenta.** Un cambio o un retiro que creas es una *solicitud*.
    Una persona en Vectrakpital la aprueba, y sólo entonces sale algo. Consulta el estado; no des por
    hecho que un `201` significa que el dinero ya salió.

    **Tu llave es la delegación del acceso de una persona.** Si a esa persona le reducen permisos, la
    llave se reduce con ella en la siguiente petición. Una llave que deja de funcionar normalmente no
    ha caducado: alguien cambió un puesto.
  contact:
    name: Vectrakpital
    email: soporte@vectrakpital.com
  license:
    name: Proprietary

servers:
  - url: https://back.vectrakpital.com
    description: Producción

tags:
  - name: Cuenta
    description: Tu organización, sus saldos y su extracto.
  - name: Cotizaciones
    description: Precios. Consultar uno no te compromete a nada.
  - name: Cambios
    description: Convertir un activo en otro.
  - name: Depósitos
    description: Dinero que entra.
  - name: Retiros
    description: Dinero que sale, y a dónde puede ir.

security:
  - ApiKey: []

paths:
  /api/v1/client:
    get:
      operationId: getClient
      tags: [Cuenta]
      summary: Tu organización
      description: |
        También es la forma más barata de saber qué puede hacer tu llave: `permissions` es la
        intersección entre sus alcances y el puesto desde el que se emitió, que es exactamente lo que
        el servidor exige.
      responses:
        "200":
          description: Obtenido
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ClientEnvelope"
        "401": { $ref: "#/components/responses/Unauthorised" }

  /api/v1/balances:
    get:
      operationId: getBalances
      tags: [Cuenta]
      summary: Lo que tiene tu organización
      description: |
        Una fila por activo, con los decimales que usa ese activo. **Lee `assetDecimals` de aquí en
        vez de fijarlo en tu código**: es lo que reporta el mercado, y no es la convención en cadena.
      responses:
        "200":
          description: Obtenido
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BalancesEnvelope"
        "401": { $ref: "#/components/responses/Unauthorised" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/ledger:
    get:
      operationId: getLedger
      tags: [Cuenta]
      summary: Tu extracto
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Obtenido
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Envelope" }
        "401": { $ref: "#/components/responses/Unauthorised" }

  /api/v1/quotes:
    post:
      operationId: createQuote
      tags: [Cotizaciones]
      summary: Pedir un precio
      description: |
        Una cotización es un precio sostenido durante una ventana corta. Pedirla no te compromete a
        nada y no mueve nada: es seguro llamarla tantas veces como quieras, y seguro abandonarla.

        Envía **o** `amountBaseMinor` (lo que vas a entregar) **o** `amountQuoteMinor` (lo que quieres
        recibir), nunca los dos.

        Un `422` con código `quotes.quote_below_executable_rate` no es un fallo transitorio. Significa
        que el precio ofrecido quedaría por debajo de lo que cuesta la operación, y la plataforma se
        niega a entregarte una pérdida. Reintentar no cambia nada hasta que el mercado se mueva.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateQuote" }
      responses:
        "201":
          description: Cotizado
          content:
            application/json:
              schema: { $ref: "#/components/schemas/QuoteEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422":
          description: El monto está fuera de rango, o el precio quedaría por debajo del coste
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v1/orders:
    post:
      operationId: createOrder
      tags: [Cambios]
      summary: Aceptar una cotización
      description: |
        **Esto crea una solicitud, no una ejecución.** Retiene el monto para que un mismo saldo no
        respalde dos operaciones, y espera a que una persona en Vectrakpital la apruebe.

        La cotización tiene que seguir vigente. Una caducada responde `410`: pide otra en vez de
        reintentar esta llamada.

        Requiere el alcance `portal:trade`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [quoteId]
              properties:
                quoteId: { type: string, examples: ["019fb6be-73e6-736c-a255-3f6515326948"] }
      responses:
        "201":
          description: Solicitado
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Envelope" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "410":
          description: La vigencia de la cotización se acabó
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: Saldo insuficiente, o por encima de un límite
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    get:
      operationId: listOrders
      tags: [Cambios]
      summary: Tus cambios
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Obtenido
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Envelope" }
        "401": { $ref: "#/components/responses/Unauthorised" }

  /api/v1/deposits/wallets:
    get:
      operationId: listDepositWallets
      tags: [Depósitos]
      summary: Dónde enviar
      description: |
        Una billetera por activo. Un activo que la plataforma no puede recibir está **ausente** en vez
        de aparecer con la dirección en blanco, así que todo lo que está en esta lista es un sitio al
        que el dinero puede llegar de verdad.

        **Léela antes de cada depósito en vez de guardarla en caché.** Las direcciones son de la cuenta
        contra la que opera la plataforma, y ésa no es una elección permanente.

        Requiere el alcance `portal:deposit`.
      responses:
        "200":
          description: Obtenido
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WalletsEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/deposits/declarations:
    post:
      operationId: declareDeposit
      tags: [Depósitos]
      summary: Anunciar un depósito antes de enviarlo
      description: |
        La respuesta trae **el monto exacto a enviar**, que es lo que declaraste más una pequeña cola
        aleatoria en los últimos decimales. Esa cola es todo el mecanismo de reconocimiento: casar una
        llegada sólo por el monto falla en cuanto dos organizaciones envían un redondo 1 000.

        Envía `expectedAmountMinor` exacto, a `address` por `network`. Enviar otra cifra no es un
        error: el dinero simplemente espera a que alguien lo atribuya a mano.

        Una declaración abierta a la vez. Una segunda responde `409`.

        Requiere el alcance `portal:deposit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DeclareDeposit" }
      responses:
        "201":
          description: Declarado
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeclarationEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: Ya tienes una declaración abierta
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: El monto es demasiado pequeño para hacerse único, o el activo no tiene billetera
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v1/deposits/open-declaration:
    get:
      operationId: getOpenDeclaration
      tags: [Depósitos]
      summary: Tu declaración abierta, si hay
      description: "Responde `data: null` cuando no hay ninguna. Eso no es un error."
      responses:
        "200":
          description: Obtenido
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeclarationEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorised" }

  /api/v1/deposits:
    get:
      operationId: listDeposits
      tags: [Depósitos]
      summary: Depósitos que llegaron
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Obtenido
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Envelope" }
        "401": { $ref: "#/components/responses/Unauthorised" }

  /api/v1/withdrawals/destinations:
    get:
      operationId: listWithdrawalDestinations
      tags: [Retiros]
      summary: A dónde puede ir tu dinero
      description: |
        Las direcciones vuelven **enmascaradas**. No porque se le oculten a su dueño, sino porque toda
        superficie que pinta una dirección de retiro viva es una superficie que vale la pena suplantar.

        Un destino es inutilizable hasta que pase un período de enfriamiento **y** alguien en
        Vectrakpital lo registre en el mercado. `usable` te lo dice; no lo deduzcas de otra cosa.
      responses:
        "200":
          description: Obtenido
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DestinationsEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorised" }

  /api/v1/withdrawals/destinations/bank:
    post:
      operationId: registerBankDestination
      tags: [Retiros]
      summary: Registrar una cuenta bancaria
      description: |
        Nace inutilizable, a propósito. El enfriamiento es la única defensa que funciona contra una
        sesión que no es realmente tuya: quien entre puede añadir su propia cuenta, y nada más en el
        flujo puede distinguir eso de que lo pidas tú.

        Requiere el alcance `portal:destinations`, que **no** es el mismo que el de retirar.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RegisterBankDestination" }
      responses:
        "201":
          description: Registrado
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Envelope" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: Ya registrado
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v1/withdrawals:
    post:
      operationId: requestWithdrawal
      tags: [Retiros]
      summary: Pedir que se envíe dinero
      description: |
        **Una solicitud, no una transferencia.** Todo retiro lo aprueba una persona en Vectrakpital,
        sin un umbral por debajo del cual no. El monto queda retenido desde que lo pides.

        El destino tiene que ser ya utilizable: regístralo mucho antes de necesitarlo.

        Requiere el alcance `portal:withdraw`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RequestWithdrawal" }
      responses:
        "201":
          description: Solicitado
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Envelope" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422":
          description: Saldo insuficiente, o el destino todavía no es utilizable
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    get:
      operationId: listWithdrawals
      tags: [Retiros]
      summary: Tus retiros
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Obtenido
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Envelope" }
        "401": { $ref: "#/components/responses/Unauthorised" }

components:
  securitySchemes:
    ApiKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        `vk_live_<prefijo>.<secreto>` — la cadena entera, en una sola cabecera.

        `Authorization: Bearer vk_live_...` también se acepta, para clientes que van primero a ésa.

        El secreto se muestra una vez, al emitirlo, y se guarda sólo como hash. Nadie puede volver a
        leerlo, Vectrakpital incluida. Una llave perdida se reemplaza, nunca se recupera.

  parameters:
    Page:
      name: page
      in: query
      schema: { type: integer, minimum: 1, default: 1 }
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 10 }

  responses:
    Unauthorised:
      description: |
        Sin credencial, o con una que no autentica. **Todas las causas responden igual** —llave
        desconocida, secreto equivocado, revocada, caducada, dirección no permitida— porque
        distinguirlas ayudaría a alguien a saber qué mitad del token acertó.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: |
        Autenticado, pero falta el alcance. Compáralo con `permissions` de `/api/v1/client`: ese campo
        es la intersección que el servidor realmente exige.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Envelope:
      type: object
      description: Toda respuesta tiene esta forma, salga bien o mal.
      required: [success, message, data, code, timestamp]
      properties:
        success: { type: boolean }
        message: { type: [string, "null"] }
        data: {}
        code:
          type: [string, "null"]
          description: |
            Identificador estable y legible por máquina del resultado, p. ej. `quotes.quote_expired`.
            **Ramifica por esto, nunca por `message`**: el mensaje está traducido y puede reescribirse.
        timestamp: { type: string, format: date-time }
        traceId:
          type: [string, "null"]
          description: Cítalo en una solicitud de soporte y se encuentra la llamada exacta.
        pagination:
          type: [object, "null"]
          properties:
            page: { type: integer }
            limit: { type: integer }
            total: { type: integer }
            totalPages: { type: integer }

    Error:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            success: { const: false }
            data: { const: null }

    Money:
      type: object
      description: |
        Un monto, siempre entero en unidades mínimas del activo. 10 000 USDT con cuatro decimales son
        `100000000`. No hay forma decimal en ninguna parte de esta API.
      required: [assetSymbol, assetDecimals, amountMinor]
      properties:
        assetSymbol: { type: string, examples: ["USDT"] }
        assetDecimals:
          type: integer
          description: Léelo, no lo asumas. USDT es 4 aquí y COPM es 2.
          examples: [4]
        amountMinor: { type: integer, examples: [100000000] }

    ClientEnvelope:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                id: { type: string }
                name: { type: string }
                status: { type: string, enum: [draft, active, suspended] }
                canOperate:
                  type: boolean
                  description: Falso significa que las cotizaciones siguen y nada más.
                permissions:
                  type: array
                  items: { type: string }
                  description: Qué puede hacer esta credencial. Ya intersecada con los alcances de la llave.

    BalancesEnvelope:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data:
              type: array
              items: { $ref: "#/components/schemas/Money" }

    CreateQuote:
      type: object
      required: [baseSymbol, quoteSymbol]
      properties:
        baseSymbol: { type: string, examples: ["USDT"] }
        quoteSymbol: { type: string, examples: ["COPM"] }
        amountBaseMinor:
          type: integer
          description: Lo que entregas. Envía éste o `amountQuoteMinor`, no los dos.
        amountQuoteMinor:
          type: integer
          description: Lo que quieres recibir.

    QuoteEnvelope:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                id: { type: string }
                rateQuoteMinor:
                  type: integer
                  description: La tasa ofrecida, en unidades mínimas del quote por cada unidad entera del base.
                amountBaseMinor: { type: integer }
                amountQuoteMinor: { type: integer }
                expiresAt: { type: string, format: date-time }

    WalletsEnvelope:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data:
              type: array
              items:
                type: object
                properties:
                  assetSymbol: { type: string, examples: ["USDT"] }
                  network:
                    type: string
                    description: |
                      No es decoración. La misma dirección es válida en varias cadenas, y una
                      transferencia enviada por la equivocada no se recupera.
                    examples: ["TRC20"]
                  address: { type: string }

    DeclareDeposit:
      type: object
      required: [assetSymbol, assetDecimals, declaredAmountMinor]
      properties:
        assetSymbol: { type: string, examples: ["USDT"] }
        assetDecimals: { type: integer, examples: [4] }
        declaredAmountMinor:
          type: integer
          description: Aproximadamente lo que piensas enviar. La respuesta te da la cifra exacta.
          examples: [100000000]

    DeclarationEnvelope:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data:
              type: [object, "null"]
              properties:
                id: { type: string }
                reference:
                  type: string
                  description: Un código corto que una persona puede dictar. No es la clave de casado; ésa es el monto.
                  examples: ["VK-7K2QMD"]
                expectedAmountMinor:
                  type: integer
                  description: "**Envía exactamente esto.** La cola rara es lo que hace reconocible la llegada."
                  examples: [100000037]
                expectedAmount: { type: string, examples: ["10000.0037"] }
                network: { type: [string, "null"] }
                address: { type: [string, "null"] }
                expiresAt: { type: string, format: date-time }
                expiresInSeconds: { type: integer }

    DestinationsEnvelope:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data:
              type: array
              items:
                type: object
                properties:
                  id: { type: string }
                  label: { type: string }
                  assetSymbol: { type: string }
                  summary:
                    type: string
                    description: Enmascarada. Suficiente para reconocerla, no para copiarla.
                  usable:
                    type: boolean
                    description: Pasó los dos filtros. No lo deduzcas de otra cosa.

    RegisterBankDestination:
      type: object
      required: [label, assetSymbol, bankName, accountKind, accountNumber, holderName, holderDocument]
      properties:
        label: { type: string, examples: ["Bancolombia principal"] }
        assetSymbol: { type: string, examples: ["COPM"] }
        bankName: { type: string }
        accountKind: { type: string, enum: [savings, checking] }
        accountNumber: { type: string }
        holderName:
          type: string
          description: |
            Obligatorio aunque nada aquí pueda verificarlo: de quién sea la cuenta decide qué ruta de
            pago puede tomar un retiro hacia ella.
        holderDocument: { type: string }

    RequestWithdrawal:
      type: object
      required: [destinationId, assetSymbol, assetDecimals, amountMinor]
      properties:
        destinationId: { type: string }
        assetSymbol: { type: string, examples: ["COPM"] }
        assetDecimals: { type: integer, examples: [2] }
        amountMinor: { type: integer, examples: [300000000] }
