openapi: 3.0.3
info:
  title: Bitalcer Custody-as-a-Service — Partner API
  version: "1.0.0"
  description: |
    API servidor-a-servidor para empresas partner de Bitalcer (Custody-as-a-Service).
    Permite crear sub-usuarios, gestionar sus wallets multi-chain (BSC/Polygon/BTC),
    ejecutar retiros y transferencias internas contra el saldo prepagado del partner
    (Partner Treasury), convertir entre Quetzales y dólares digitales, y recibir
    webhooks de eventos en tiempo real.

    Todas las operaciones de un partner se realizan sobre **usuarios propios** del
    partner, identificados por un `external_user_id` que el partner define y
    controla — Bitalcer nunca expone ni gestiona credenciales de esos usuarios
    finales. El KYC del usuario final es responsabilidad del partner (ver checklist
    de seguridad y los Términos de Servicio de API).

    Ambientes: `sandbox` (testnet real — BSC/Polygon Amoy, BTC testnet) y
    `production`. Cada ambiente tiene su propio par de credenciales
    (`api_key` / `hmac_secret`), obtenidas desde el dashboard del partner tras
    completar el alta KYB de la empresa.
  contact:
    name: Bitalcer — Integraciones
    email: partners@bitalcer.com
servers:
  - url: https://api.bitalcer.com
    description: Producción y sandbox (mismo host, separados por credenciales de ambiente)

security:
  - hmacAuth: []

tags:
  - name: Salud
  - name: Usuarios
  - name: Wallets
  - name: Transacciones
  - name: Conversión
  - name: Tesorería

paths:
  /partner/v1/ping:
    get:
      tags: [Salud]
      summary: Verifica que las credenciales y la firma HMAC son válidas
      operationId: ping
      responses:
        "200":
          description: Credenciales válidas
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: ok }
                  partner_id: { type: integer, example: 7 }
                  environment: { type: string, enum: [sandbox, production] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /partner/v1/users:
    post:
      tags: [Usuarios]
      summary: Da de alta (u obtiene, si ya existe) un usuario del partner
      description: |
        Idempotente: si el `external_user_id` ya existe para este partner, devuelve
        el registro existente en vez de crear uno duplicado. Este paso es
        **obligatorio antes** de poder usar cualquier otro endpoint con el mismo
        `external_user_id` — wallets, transacciones y conversión resuelven el
        usuario interno de Bitalcer a partir de este registro, y devuelven
        `404 partner_user_not_found` si no existe.
      operationId: createUser
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [external_user_id]
              properties:
                external_user_id:
                  type: string
                  description: Identificador del usuario en el sistema del partner. Único por partner.
                  example: cust-00321
      responses:
        "201":
          description: Usuario creado u obtenido
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PartnerUser" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /partner/v1/wallets:
    post:
      tags: [Wallets]
      summary: Crea una wallet on-chain para un usuario del partner
      operationId: createWallet
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [external_user_id]
              properties:
                external_user_id: { type: string, example: cust-00321 }
                chain:
                  type: string
                  enum: [bsc, polygon, btc, bitcoin]
                  default: bsc
                  description: Red sobre la que se crea la wallet. `btc`/`bitcoin` usan el gateway BTC separado.
      responses:
        "201":
          description: Wallet creada
          content:
            application/json:
              schema:
                type: object
                properties:
                  external_user_id: { type: string }
                  address: { type: string, example: "0x4b934FAa4cDEd3FACbf83AC653eCF5ab155cB697" }
                  chain: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: El `external_user_id` no fue dado de alta vía `POST /partner/v1/users`
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /partner/v1/wallets/{external_user_id}/balance:
    get:
      tags: [Wallets]
      summary: Consulta el saldo de un token de un usuario del partner
      operationId: getBalance
      parameters:
        - $ref: "#/components/parameters/ExternalUserIdPath"
        - name: token
          in: query
          schema: { type: string, default: USDT }
          example: USDT
        - name: chain
          in: query
          schema: { type: string, enum: [bsc, polygon, btc, bitcoin], default: bsc }
      responses:
        "200":
          description: |
            Saldo del token solicitado. La forma exacta del objeto refleja la
            respuesta del motor de custodia subyacente (passthrough) — siempre
            incluye al menos `balance` y `token`, puede incluir campos adicionales
            según la red.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
                example: { balance: "125.50000000", token: "USDT", chain: "bsc" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: Usuario o wallet no encontrados
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /partner/v1/wallets/{external_user_id}/address:
    get:
      tags: [Wallets]
      summary: Obtiene la dirección de depósito de un usuario (la crea si aún no existe)
      operationId: getAddress
      parameters:
        - $ref: "#/components/parameters/ExternalUserIdPath"
        - name: chain
          in: query
          schema: { type: string, enum: [bsc, polygon, btc, bitcoin], default: bsc }
      responses:
        "200":
          description: Dirección de depósito
          content:
            application/json:
              schema:
                type: object
                properties:
                  external_user_id: { type: string }
                  chain: { type: string }
                  address: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: Usuario no encontrado
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /partner/v1/transactions/withdraw:
    post:
      tags: [Transacciones]
      summary: Retira fondos de un usuario del partner hacia una dirección externa
      description: |
        Se descuenta del saldo prepagado del **Partner Treasury** (no del balance
        del usuario individual) tras validar saldo suficiente y límites de volumen
        diario/mensual del partner. **BTC aún no soportado** (`501`) — solo
        BSC/Polygon en esta versión.
      operationId: withdraw
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [external_user_id, to_address, amount, token]
              properties:
                external_user_id: { type: string }
                to_address: { type: string, example: "0x9f2c...e31a" }
                amount: { type: string, example: "100.00" }
                token: { type: string, example: USDT }
                chain: { type: string, enum: [bsc, polygon], default: bsc }
                fee_model:
                  type: object
                  description: Modelo de fee opcional, mismo formato que el endpoint interno de retiro.
      responses:
        "201":
          description: Retiro enviado
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
                description: Resultado del motor cripto (incluye típicamente `tx_hash`).
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402":
          description: Saldo insuficiente en el Partner Treasury (`INSUFFICIENT_PARTNER_TREASURY`)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429":
          description: |
            Límite de volumen diario/mensual del partner excedido
            (`DAILY_VOLUME_LIMIT_EXCEEDED` / `MONTHLY_VOLUME_LIMIT_EXCEEDED`), o
            rate limit de requests excedido (`too_many_requests`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "501":
          description: Cadena no soportada todavía para retiro de partners (`CHAIN_NOT_YET_SUPPORTED`, ej. BTC)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /partner/v1/transactions/transfer:
    post:
      tags: [Transacciones]
      summary: Transferencia interna entre dos usuarios del partner
      description: Igual que `withdraw` en cuanto a validación de Partner Treasury y límites de volumen, pero ambos extremos son usuarios del propio partner (sin salir a una dirección externa).
      operationId: transfer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sender_external_user_id, receiver_external_user_id, amount, token]
              properties:
                sender_external_user_id: { type: string }
                receiver_external_user_id: { type: string }
                amount: { type: string, example: "25.00" }
                token: { type: string, example: USDT }
      responses:
        "201":
          description: Transferencia ejecutada
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402":
          description: Saldo insuficiente en el Partner Treasury
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429":
          description: Límite de volumen excedido o rate limit de requests excedido
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /partner/v1/treasury/balance:
    get:
      tags: [Tesorería]
      summary: Consulta el saldo prepagado del Partner Treasury
      operationId: treasuryBalance
      responses:
        "200":
          description: Saldo actual
          content:
            application/json:
              schema:
                type: object
                properties:
                  partner_id: { type: integer }
                  available_balance_usd: { type: number, format: double, example: 4250.00 }
                  reserved_balance_usd: { type: number, format: double, example: 0 }
                  tier: { type: string, example: standard }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /partner/v1/conversion/quote:
    get:
      tags: [Conversión]
      summary: Cotiza una conversión Q ↔ dólares digitales sin crear orden
      operationId: conversionQuote
      parameters:
        - name: external_user_id
          in: query
          required: true
          schema: { type: string }
        - name: base_asset
          in: query
          required: true
          schema: { type: string, example: GTQ }
        - name: quote_asset
          in: query
          required: true
          schema: { type: string, example: USDT }
        - name: amount
          in: query
          required: true
          schema: { type: number, format: double, example: 750 }
      responses:
        "200":
          description: Cotización (no crea orden ni reserva fondos)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConversionQuote" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: Usuario no encontrado
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /partner/v1/conversion/orders:
    post:
      tags: [Conversión]
      summary: Crea una orden de conversión en estado `quoted`
      description: |
        Paso intermedio obligatorio entre `quote` (solo previsualización) y
        `execute` (ejecución real) — `execute` requiere el `order_id` devuelto
        aquí. La orden expira (`quote_expired`) si no se ejecuta dentro de la
        ventana de validez de la cotización.
      operationId: createConversionOrder
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [external_user_id, base_asset, quote_asset, requested_amount]
              properties:
                external_user_id: { type: string }
                base_asset: { type: string, example: GTQ }
                quote_asset: { type: string, example: USDT }
                side: { type: string, enum: [buy, sell], default: buy }
                requested_amount: { type: number, format: double, example: 750 }
      responses:
        "201":
          description: Orden creada en estado `quoted`
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConversionOrder" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: Usuario no encontrado
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "409":
          description: Par no disponible / cotización vencida / inventario insuficiente
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /partner/v1/conversion/execute:
    post:
      tags: [Conversión]
      summary: Ejecuta una orden de conversión previamente creada
      operationId: executeConversion
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [external_user_id, order_id]
              properties:
                external_user_id: { type: string }
                order_id: { type: string, format: uuid }
      responses:
        "200":
          description: Conversión ejecutada
          content:
            application/json:
              schema:
                type: object
                properties:
                  order_id: { type: string, format: uuid }
                  pair: { type: string, example: GTQUSDT }
                  executed_amount: { type: number, format: double }
                  price_applied: { type: number, format: double }
                  fee: { type: number, format: double }
                  status: { type: string, example: completed }
                  ledger_group_id: { type: string }
                  mode: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: Orden o usuario no encontrados (`order_not_found`)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "409":
          description: Estado inválido de la orden o cotización vencida (`invalid_state`, `quote_expired`)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "422":
          description: Saldo insuficiente del usuario para ejecutar la conversión (`insufficient_funds`)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /partner/v1/conversion/status:
    get:
      tags: [Conversión]
      summary: Consulta el estado de una orden de conversión
      operationId: conversionStatus
      parameters:
        - name: external_user_id
          in: query
          required: true
          schema: { type: string }
        - name: order_id
          in: query
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Estado de la orden
          content:
            application/json:
              schema:
                type: object
                properties:
                  order: { type: object, additionalProperties: true }
                  mode: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: Orden no encontrada
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

components:
  securitySchemes:
    hmacAuth:
      type: apiKey
      in: header
      name: x-api-key
      description: |
        Autenticación HMAC-SHA256 de 3 headers (no representable de forma nativa
        en OpenAPI 3.0 — documentado aquí en texto):

        1. `x-api-key`: la API key del partner (texto plano), identifica al partner y su ambiente.
        2. `x-timestamp`: epoch en **milisegundos**, como string. Debe estar dentro de
           ±5 minutos (300000 ms) del reloj del servidor o la firma se rechaza
           (protección anti-replay).
        3. `x-signature`: `hash_hmac('sha256', "{x-timestamp}:{raw_body}", hmac_secret)`,
           en hexadecimal. `{raw_body}` es el **cuerpo exacto** enviado (string vacío
           para requests sin body, ej. los `GET`) — no debe re-serializarse desde un
           objeto ya parseado, porque el orden de llaves o el espaciado pueden
           cambiar el resultado del HMAC.

        El `hmac_secret` se muestra una única vez al generar o rotar la API key desde
        el dashboard del partner — guárdalo de forma segura (ver checklist de seguridad).
  parameters:
    ExternalUserIdPath:
      name: external_user_id
      in: path
      required: true
      schema: { type: string }
      example: cust-00321
  schemas:
    PartnerUser:
      type: object
      properties:
        id: { type: integer }
        partner_id: { type: integer }
        external_user_id: { type: string }
        internal_user_id: { type: integer, nullable: true, description: "Null hasta que el usuario tenga su primera wallet creada." }
        status: { type: string, example: active }
    ConversionQuote:
      type: object
      properties:
        status: { type: string, example: quoted }
        mode: { type: string }
        user_id: { type: integer }
        pair: { type: string, example: GTQUSDT }
        base_asset: { type: string }
        quote_asset: { type: string }
        requested_amount: { type: number, format: double }
        estimated_amount_out: { type: number, format: double }
        price_applied: { type: number, format: double }
        fee_amount: { type: number, format: double }
        fee_bps: { type: integer }
        fee_percent: { type: number, format: double }
    ConversionOrder:
      type: object
      properties:
        status: { type: string, example: quoted }
        mode: { type: string }
        order_id: { type: string, format: uuid }
        pair: { type: string }
        side: { type: string, enum: [buy, sell] }
        requested_amount: { type: number, format: double }
        estimated_amount_out: { type: number, format: double }
        price_applied: { type: number, format: double }
        fee_amount: { type: number, format: double }
        fee_bps: { type: integer }
        fee_percent: { type: number, format: double }
        quote_expires_at: { type: string, format: date-time }
    ErrorResponse:
      type: object
      properties:
        error:
          type: string
          description: Código de error estable, pensado para manejo programático (no para mostrar al usuario final).
          example: invalid_request
  responses:
    BadRequest:
      description: Parámetros faltantes o inválidos (`invalid_request`)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    Unauthorized:
      description: Headers de autenticación faltantes, API key desconocida/inactiva, o firma inválida (`missing_auth_headers`, `unauthorized`, `invalid_signature`)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    UnprocessableEntity:
      description: La operación no pudo completarse por una regla de negocio (saldo insuficiente, wallet inexistente, etc.)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    TooManyRequests:
      description: Rate limit del partner excedido (`too_many_requests`). El header `Retry-After` indica los segundos de espera.
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string, example: too_many_requests }
              retry_after_seconds: { type: integer, example: 60 }
