openapi: 3.1.0
info:
  title: Tikin API
  version: "2026-09-08"
  summary: La plataforma de dinero de Tikin, para tenants.
  license: { name: Proprietary, identifier: LicenseRef-Tikin-Proprietary }
  description: |
    El API con el que un **tenant** (una empresa cliente de la plataforma) opera su
    dinero: cuentas, saldos, transferencias, bonos y payouts.

    El entorno sandbox llega pronto; los tokens `tk_test_` quedarán reservados para él.

    ## Las reglas del contrato

    - **Los montos son texto en unidades mínimas** del asset (`COPM` = centavos de peso,
      `DUSD` = millonésimas de dólar). Nunca números flotantes: JSON los corrompe.
      En todo request, `amount` es **lo que RECIBE el destino**; el origen debita
      `amount + fee`. Una sola regla, en todos los recursos.
    - **Todo lo que mueve dinero exige `Idempotency-Key`**: reintentar con la misma llave
      es el MISMO pedido (respuesta `200` en vez de `201`). La misma llave con OTRO
      cuerpo — incluidos los bytes de un archivo en multipart — es `409`.
    - **Los errores hablan**: `code` de máquina (del catálogo cerrado `ErrorCode`),
      `message` humano, `doc_url`, y `details` tipados por código.
    - **Nada es instantáneo por decreto**: payouts y lotes devuelven un recurso con
      `status` y se siguen por listados, polling o webhooks.

    ## Los dos ámbitos

    Cada tag declara su ámbito en `x-ambito`. **`plataforma`** = disponible para todo
    tenant (Accounts, Money, Platform). **`producto-tikin`** = superficie del producto
    de Tikin, no primitivo de plataforma (Bonuses hoy; Identity y cards en camino) —
    un tenant la habilita por acuerdo, no por default.

    ## Rate limits

    Por token: ráfagas cortas por encima del sostenido devuelven `429` con
    `Retry-After` (segundos). Diseña reintentos con backoff exponencial + jitter y
    respeta el header. El lookup de destinatarios tiene un límite propio, más agresivo.

    ## Tikin-Request-Id

    **Toda respuesta** — éxito o error — trae el header `Tikin-Request-Id`, el id
    único de ese request de tu lado al nuestro. Guárdalo en tus logs y cítalo al
    escribir a soporte: con él encontramos tu request en segundos.
  contact: { name: Tikin, url: "https://tikin.is" }
servers:
  - url: https://api.tikin.is
    description: Producción — mueve dinero real.
security:
  - tenantToken: []
tags:
  - name: Accounts
    x-ambito: plataforma
    description: "Las cuentas de tus usuarios: crearlas, buscarlas, declarar su verificación (KYC)."
  - name: Balances
    x-ambito: plataforma
    description: Los bolsillos y su saldo — la verdad del libro, jamás un cero falso.
  - name: Movements
    x-ambito: plataforma
    description: El historial contable de una cuenta, paginado por cursor.
  - name: Transfers
    x-ambito: plataforma
    description: >-
      Mover dinero entre cuentas. **Interna** (dentro de tu tenant) o **entre tenants**
      (hacia una cuenta de otro tenant de la plataforma) — la segunda liquida entre
      libros y puede llevar la tarifa de tu acuerdo.
  - name: Bonuses
    x-ambito: producto-tikin
    description: >-
      Pagos de empresa a usuarios. **Individual** (síncrono) o **masivo** (lote por
      archivo: subir → revisar → disparar). Casos distintos a propósito. Los bonos son
      una **capacidad de producto** que Tikin ofrece a sus empresas — no un primitivo
      de la plataforma, y no todo tenant la tiene habilitada.
  - name: Payouts
    x-ambito: plataforma
    description: >-
      Sacar dinero hacia el mundo (banco, efectivo). SIEMPRE asíncrono: reservado al
      instante, avanza por estados, cancelable mientras no haya salido.
  - name: Quotes
    x-ambito: plataforma
    description: Saber el costo ANTES de mover — cuánto debita el origen, cuánto recibe el destino.
  - name: Webhooks
    x-ambito: plataforma
    description: Avisos firmados con lo que pasó. El polling y los listados son tu red de seguridad.
  - name: Platform
    x-ambito: plataforma
    description: >-
      El plano de tu operación: quién eres (tenant), el catálogo de assets con su
      escala como dato, y las tasas de cambio informativas.

paths:
  /v1/accounts:
    post:
      tags: [Accounts]
      operationId: createAccount
      summary: Crear una cuenta
      description: Da de alta una cuenta de usuario en tu tenant, con su bolsillo principal por moneda.
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json: { schema: { $ref: "#/components/schemas/NewAccount" } }
      responses:
        "201": { description: La cuenta nació., content: { application/json: { schema: { $ref: "#/components/schemas/Account" } } } }
        "200": { description: Replay — la misma llave ya la creó., content: { application/json: { schema: { $ref: "#/components/schemas/Account" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/BusinessRuleRejected" }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      tags: [Accounts]
      operationId: listAccounts
      summary: Listar cuentas
      description: Las cuentas de tu tenant, de la más nueva a la más vieja.
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Cursor" }
        - { name: "created_at[gte]", in: query, schema: { type: string, format: date-time } }
        - { name: "created_at[lte]", in: query, schema: { type: string, format: date-time } }
      responses:
        "200":
          description: Una página de cuentas.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items: { type: array, items: { $ref: "#/components/schemas/Account" } }
                  next_cursor: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/accounts/{account_id}:
    get:
      tags: [Accounts]
      operationId: getAccount
      summary: Obtener una cuenta
      description: La cuenta por su id — tuya; la de otro tenant es 404, no 403.
      parameters: [ { $ref: "#/components/parameters/AccountId" } ]
      responses:
        "200": { description: La cuenta., content: { application/json: { schema: { $ref: "#/components/schemas/Account" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/accounts/lookup:
    get:
      tags: [Accounts]
      operationId: lookupRecipient
      summary: Resolver un destinatario
      description: |
        Resuelve un alias de TU tenant (único dentro del tenant; el formato — como el
        `$` de Tikin — lo define cada tenant) o un teléfono a lo mínimo para confirmar
        un envío. Exactamente UNO de `alias` o `phone`; los dos, o ninguno, es
        `400 missing_query`. Para destinatarios de otro tenant, arma el destino
        cross-tenant con el par `tenant` + `alias`.

        Requiere el scope `lookup` y tiene rate limit propio y agresivo: es un
        directorio de personas, no un buscador. Para `person`, `display_name` llega
        abreviado ("Ana P."); el nombre legal completo solo para `business`.
      parameters:
        - { name: alias, in: query, schema: { type: string, example: "$bottico" } }
        - { name: phone, in: query, schema: { type: string, example: "+573001234567" } }
      responses:
        "200": { description: El destinatario., content: { application/json: { schema: { $ref: "#/components/schemas/Recipient" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/accounts/{account_id}/verification:
    put:
      tags: [Accounts]
      operationId: declareVerification
      summary: Declarar la verificación (KYC)
      description: |
        Declara qué verificó esta cuenta. **No es un switch de permisos**: es un hecho
        fechado con fuente. Sin declaración, la cuenta **recibe pero no envía** — la
        regla de la plataforma.
      parameters: [ { $ref: "#/components/parameters/AccountId" } ]
      requestBody:
        required: true
        content:
          application/json: { schema: { $ref: "#/components/schemas/VerificationDeclaration" } }
      responses:
        "200": { description: Declarada (o sin cambios)., content: { application/json: { schema: { $ref: "#/components/schemas/VerificationDeclaration" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/accounts/{account_id}/balances:
    get:
      tags: [Balances]
      operationId: getBalances
      summary: Los saldos de una cuenta
      description: >-
        Los bolsillos con su saldo **disponible** y **retenido**, por asset. Un error
        devuelve `503` — jamás un cero falso.
      parameters: [ { $ref: "#/components/parameters/AccountId" } ]
      responses:
        "200":
          description: Los bolsillos.
          content:
            application/json:
              schema:
                type: object
                properties:
                  balances: { type: array, items: { $ref: "#/components/schemas/Balance" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/Unavailable" }

  /v1/accounts/{account_id}/movements:
    get:
      tags: [Movements]
      operationId: listMovements
      summary: El historial de una cuenta
      description: Los movimientos contables, del más reciente al más viejo, paginados por cursor.
      parameters:
        - { $ref: "#/components/parameters/AccountId" }
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Cursor" }
        - { name: asset, in: query, schema: { $ref: "#/components/schemas/Asset" } }
        - { name: kind, in: query, schema: { $ref: "#/components/schemas/MovementKind" } }
        - { name: "created_at[gte]", in: query, schema: { type: string, format: date-time } }
        - { name: "created_at[lte]", in: query, schema: { type: string, format: date-time } }
      responses:
        "200":
          description: Una página de movimientos.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items: { type: array, items: { $ref: "#/components/schemas/Movement" } }
                  next_cursor: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/quotes:
    post:
      tags: [Quotes]
      operationId: createQuote
      summary: Cotizar una operación
      description: >-
        El costo ANTES de mover: cuánto recibe el destino, cuánto es la comisión, y
        cuánto debita el origen. No mueve nada, no reserva nada, no exige
        `Idempotency-Key`. Úsala para pintar la confirmación.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [operation, amount]
              properties:
                operation: { type: string, enum: [transfer, cross_tenant_transfer, bonus, payout] }
                amount: { $ref: "#/components/schemas/Money" }
                destination_tenant: { type: string, description: Solo para cross_tenant_transfer. }
      responses:
        "200":
          description: La cotización.
          content:
            application/json:
              schema:
                type: object
                properties:
                  amount: { $ref: "#/components/schemas/Money" }
                  fee: { $ref: "#/components/schemas/Money" }
                  total_debit: { $ref: "#/components/schemas/Money", description: "amount + fee — lo que sale del origen." }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/transfers:
    post:
      tags: [Transfers]
      operationId: createTransfer
      summary: Enviar una transferencia
      description: |
        Mueve dinero de una cuenta tuya a otra cuenta. El `destination` decide el caso:

        - **Interna** (`type: "account"`): entre cuentas de TU tenant. Síncrona.
        - **Entre tenants** (`type: "platform_account"`): hacia una cuenta de OTRO
          tenant. Lleva SIEMPRE el `tenant` destino, más el `alias` (que solo
          identifica junto con su tenant) o el `account_id`. Liquida entre libros;
          la tarifa de tu acuerdo va en `fee` — cotízala antes con `/v1/quotes`.

        `amount` es lo que RECIBE el destino; el origen debita `amount + fee`.
        Una cuenta que exige doble firma responde `202` (`pending_approval`); la
        aprobación la da otro miembro de la cuenta en su app de Tikin — no hay
        endpoint de aprobación en este API, y el desenlace llega por webhook
        (`transfer.completed` / `transfer.rejected`) o por el GET.
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NewTransfer" }
            examples:
              interna:
                summary: Interna (dentro del tenant)
                value:
                  source_account: "7b0d0f52-3f3e-4bf0-9d5a-111111111111"
                  destination: { type: account, account_id: "b937b865-32ad-44a5-a654-067154480465" }
                  amount: { raw: "2500000", asset: COPM }
                  description: "almuerzo"
              entre_tenants_por_alias:
                summary: Entre tenants, por tenant + alias
                value:
                  source_account: "7b0d0f52-3f3e-4bf0-9d5a-111111111111"
                  destination: { type: platform_account, tenant: "acme", alias: "$maria" }
                  amount: { raw: "2500000", asset: COPM }
      responses:
        "201": { description: Se movió., content: { application/json: { schema: { $ref: "#/components/schemas/Transfer" } } } }
        "200": { description: Replay de la misma llave., content: { application/json: { schema: { $ref: "#/components/schemas/Transfer" } } } }
        "202": { description: Requiere aprobación de otro miembro., content: { application/json: { schema: { $ref: "#/components/schemas/Transfer" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AuthorityInsufficient" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/BusinessRuleRejected" }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      tags: [Transfers]
      operationId: listTransfers
      summary: Listar transferencias
      description: Para conciliar y para recuperarse de un webhook perdido.
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Cursor" }
        - { name: status, in: query, schema: { $ref: "#/components/schemas/TransferStatus" } }
        - { name: source_account, in: query, schema: { type: string, format: uuid } }
        - { name: "created_at[gte]", in: query, schema: { type: string, format: date-time } }
        - { name: "created_at[lte]", in: query, schema: { type: string, format: date-time } }
      responses:
        "200":
          description: Una página de transferencias.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items: { type: array, items: { $ref: "#/components/schemas/Transfer" } }
                  next_cursor: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/transfers/{transfer_id}:
    get:
      tags: [Transfers]
      operationId: getTransfer
      summary: Obtener una transferencia
      description: La transferencia por su id, con su estado y desglose.
      parameters: [ { $ref: "#/components/parameters/TransferId" } ]
      responses:
        "200": { description: La transferencia., content: { application/json: { schema: { $ref: "#/components/schemas/Transfer" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/bonuses:
    post:
      tags: [Bonuses]
      operationId: payBonus
      summary: Pagar un bono individual
      description: >-
        Un bono de la cuenta de tu empresa a UNA persona. Síncrono; la comisión se
        cobra en el mismo acto y la respuesta la desglosa. `amount` es lo que recibe
        la persona; tu cuenta debita `amount + fee`. Para muchos, usa un lote.
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json: { schema: { $ref: "#/components/schemas/NewBonus" } }
      responses:
        "201": { description: Pagado., content: { application/json: { schema: { $ref: "#/components/schemas/Bonus" } } } }
        "200": { description: Replay., content: { application/json: { schema: { $ref: "#/components/schemas/Bonus" } } } }
        "202": { description: Requiere aprobación., content: { application/json: { schema: { $ref: "#/components/schemas/Bonus" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AuthorityInsufficient" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/BusinessRuleRejected" }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      tags: [Bonuses]
      operationId: listBonuses
      summary: Listar bonos individuales
      description: Los bonos pagados, del más reciente al más viejo.
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Cursor" }
        - { name: source_account, in: query, schema: { type: string, format: uuid } }
        - { name: "created_at[gte]", in: query, schema: { type: string, format: date-time } }
        - { name: "created_at[lte]", in: query, schema: { type: string, format: date-time } }
      responses:
        "200":
          description: Una página de bonos.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items: { type: array, items: { $ref: "#/components/schemas/Bonus" } }
                  next_cursor: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/bonus_batches:
    post:
      tags: [Bonuses]
      operationId: createBonusBatch
      summary: Subir un lote de bonos
      description: |
        El caso masivo, en tres actos — dispersar a cientos no debe ser un solo click:

        1. **Subir** (este endpoint): el archivo se valida fila por fila; el lote nace
           `validating` y pasa a `ready` o `rejected`.
        2. **Revisar**: `GET /v1/bonus_batches/{id}` — filas, total, errores.
        3. **Disparar**: `POST /v1/bonus_batches/{id}/execute` — recién ahí sale dinero.

        La `Idempotency-Key` cubre el cuerpo COMPLETO, **incluidos los bytes del
        archivo**: la misma llave con otro archivo es `409`.
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, source_account]
              properties:
                file: { type: string, format: binary, description: "La plantilla (XLSX/CSV)." }
                source_account: { type: string, format: uuid }
                description: { type: string, maxLength: 140 }
      responses:
        "201": { description: El lote nació y se está validando., content: { application/json: { schema: { $ref: "#/components/schemas/BonusBatch" } } } }
        "200": { description: Replay., content: { application/json: { schema: { $ref: "#/components/schemas/BonusBatch" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/BusinessRuleRejected" }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      tags: [Bonuses]
      operationId: listBonusBatches
      summary: Listar lotes
      description: Los lotes de bonos con su estado.
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Cursor" }
        - { name: status, in: query, schema: { $ref: "#/components/schemas/BonusBatchStatus" } }
      responses:
        "200":
          description: Una página de lotes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items: { type: array, items: { $ref: "#/components/schemas/BonusBatch" } }
                  next_cursor: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/bonus_batches/{batch_id}:
    get:
      tags: [Bonuses]
      operationId: getBonusBatch
      summary: El estado de un lote
      description: El lote con su conteo, total y archivos derivados.
      parameters: [ { $ref: "#/components/parameters/BatchId" } ]
      responses:
        "200": { description: El lote., content: { application/json: { schema: { $ref: "#/components/schemas/BonusBatch" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/bonus_batches/{batch_id}/execute:
    post:
      tags: [Bonuses]
      operationId: executeBonusBatch
      summary: Disparar un lote
      description: >-
        Ejecuta un lote `ready`. La `Idempotency-Key` se liga al par (lote, llave):
        reintentar es replay (`200`); otra llave sobre un lote ya disparado es `409`.
        El lote pasa a `executing` y termina `completed` o `partially_failed`.
      parameters:
        - { $ref: "#/components/parameters/BatchId" }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      responses:
        "202": { description: Disparado., content: { application/json: { schema: { $ref: "#/components/schemas/BonusBatch" } } } }
        "200": { description: Replay del mismo disparo., content: { application/json: { schema: { $ref: "#/components/schemas/BonusBatch" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AuthorityInsufficient" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/BusinessRuleRejected" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/bonus_batches/{batch_id}/cancel:
    post:
      tags: [Bonuses]
      operationId: cancelBonusBatch
      summary: Cancelar un lote
      description: Cancela un lote `validating` o `ready`. Uno `executing` ya no se cancela (`409`).
      parameters:
        - { $ref: "#/components/parameters/BatchId" }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      responses:
        "200": { description: Cancelado., content: { application/json: { schema: { $ref: "#/components/schemas/BonusBatch" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/bonus_batches/{batch_id}/errors:
    get:
      tags: [Bonuses]
      operationId: getBonusBatchErrors
      summary: El archivo de errores del lote
      description: Las filas rechazadas y por qué, como CSV. Autenticado con tu token, como todo.
      parameters: [ { $ref: "#/components/parameters/BatchId" } ]
      responses:
        "200": { description: El CSV de errores., content: { text/csv: { schema: { type: string } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/bonus_batches/{batch_id}/result:
    get:
      tags: [Bonuses]
      operationId: getBonusBatchResult
      summary: El archivo de resultado del lote
      description: Fila por fila, qué salió y qué no, como CSV.
      parameters: [ { $ref: "#/components/parameters/BatchId" } ]
      responses:
        "200": { description: El CSV de resultado., content: { text/csv: { schema: { type: string } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/payouts:
    post:
      tags: [Payouts]
      operationId: createPayout
      summary: Crear un payout
      description: |
        Saca dinero de una cuenta hacia el mundo. **Siempre asíncrono**: `amount + fee`
        quedan **reservados al instante** y el payout avanza:

        `received → approved → in_progress → completed`, con `rejected` / `failed` /
        `returned` / `canceled`. Si se rechaza o cancela, la reserva se libera ENTERA —
        la comisión jamás se cobra por un payout que no ocurrió. Un rechazo del banco
        DESPUÉS de completado llega como `returned`.

        El `destination` nombra la CAPACIDAD: `bank_account` o `cash_pickup` (la
        respuesta trae código e instrucciones para el beneficiario).
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json: { schema: { $ref: "#/components/schemas/NewPayout" } }
      responses:
        "201": { description: En cola — reservado y avanzando., content: { application/json: { schema: { $ref: "#/components/schemas/Payout" } } } }
        "200": { description: Replay., content: { application/json: { schema: { $ref: "#/components/schemas/Payout" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AuthorityInsufficient" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/BusinessRuleRejected" }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      tags: [Payouts]
      operationId: listPayouts
      summary: Listar payouts
      description: Para conciliar — y para encontrar los `returned` que el webhook te perdió.
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Cursor" }
        - { name: status, in: query, schema: { $ref: "#/components/schemas/PayoutStatus" } }
        - { name: source_account, in: query, schema: { type: string, format: uuid } }
        - { name: "created_at[gte]", in: query, schema: { type: string, format: date-time } }
        - { name: "created_at[lte]", in: query, schema: { type: string, format: date-time } }
      responses:
        "200":
          description: Una página de payouts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items: { type: array, items: { $ref: "#/components/schemas/Payout" } }
                  next_cursor: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/payouts/{payout_id}:
    get:
      tags: [Payouts]
      operationId: getPayout
      summary: El estado de un payout
      description: El payout con su estado, referencias y — si es efectivo — el código de cobro.
      parameters: [ { $ref: "#/components/parameters/PayoutId" } ]
      responses:
        "200": { description: El payout., content: { application/json: { schema: { $ref: "#/components/schemas/Payout" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/payouts/{payout_id}/cancel:
    post:
      tags: [Payouts]
      operationId: cancelPayout
      summary: Cancelar un payout
      description: >-
        Cancela un payout que todavía no salió (`received` o `approved`): la reserva se
        libera entera. Uno `in_progress` ya no se cancela (`409`) — el dinero pudo
        haber salido; espera el desenlace.
      parameters:
        - { $ref: "#/components/parameters/PayoutId" }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      responses:
        "200": { description: Cancelado y liberado., content: { application/json: { schema: { $ref: "#/components/schemas/Payout" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/webhook_endpoints:
    post:
      tags: [Webhooks]
      operationId: createWebhookEndpoint
      summary: Registrar un webhook
      description: >-
        A dónde te avisamos. Cada entrega va firmada:
        `Tikin-Signature: t=<unix_ts>,v1=<hmac_sha256(secret, t + "." + body)>` —
        rechaza entregas con `t` a más de 5 minutos (anti-replay). Reintentos con
        backoff hasta 24h; los listados son tu red de seguridad.
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url: { type: string, format: uri }
                events: { type: array, items: { $ref: "#/components/schemas/EventType" } }
      responses:
        "201":
          description: Registrado — el `secret` se muestra UNA vez.
          content: { application/json: { schema: { $ref: "#/components/schemas/WebhookEndpointWithSecret" } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/BusinessRuleRejected" }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      tags: [Webhooks]
      operationId: listWebhookEndpoints
      summary: Listar webhooks
      description: Tus endpoints registrados, sin secretos.
      responses:
        "200":
          description: Los endpoints.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items: { type: array, items: { $ref: "#/components/schemas/WebhookEndpoint" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/webhook_endpoints/{endpoint_id}:
    delete:
      tags: [Webhooks]
      operationId: deleteWebhookEndpoint
      summary: Borrar un webhook
      description: Deja de recibir avisos en esa URL. Inmediato.
      parameters: [ { $ref: "#/components/parameters/EndpointId" } ]
      responses:
        "204": { description: Borrado. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/webhook_endpoints/{endpoint_id}/rotate_secret:
    post:
      tags: [Webhooks]
      operationId: rotateWebhookSecret
      summary: Rotar el secret
      description: >-
        Un secret filtrado se rota, no se llora: el nuevo se muestra una vez y el viejo
        sigue válido 24h para que despliegues sin perder entregas.
      parameters:
        - { $ref: "#/components/parameters/EndpointId" }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      responses:
        "200": { description: El secret nuevo — guárdalo., content: { application/json: { schema: { $ref: "#/components/schemas/WebhookEndpointWithSecret" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/tenant:
    get:
      tags: [Platform]
      operationId: getTenant
      summary: Quién soy
      description: >-
        El tenant del token con el que preguntas. Úsalo para verificar credenciales al
        arrancar y para confirmar desde qué tenant estás operando — el `code` es el
        mismo identificador que viaja en los destinos entre tenants.
      responses:
        "200":
          description: Tu tenant.
          content:
            application/json:
              schema:
                type: object
                properties:
                  code: { type: string, description: "El identificador del tenant en la plataforma — el mismo de los destinos entre tenants.", example: acme }
                  name: { type: string, example: "Acme S.A.S." }
                  created_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/assets:
    get:
      tags: [Platform]
      operationId: listAssets
      summary: El catálogo de assets
      description: >-
        Los assets que el libro sabe contar, con su **escala como dato** — no como
        conocimiento tribal. `scale` es cuántos decimales de la moneda representan
        las unidades mínimas: `COPM` con escala 2 significa que `"2500000"` son
        $25.000,00 COP. Consúltalo una vez y cachéalo: cambia con anuncio, no por sorpresa.
      responses:
        "200":
          description: El catálogo.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        code: { $ref: "#/components/schemas/Asset" }
                        currency: { type: string, description: "La moneda ISO 4217 que el asset representa.", example: COP }
                        scale: { type: integer, description: "Decimales que separan las unidades mínimas de la unidad de la moneda.", example: 2 }
                        description: { type: string, example: "Centavos de peso colombiano." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/fx/rates:
    get:
      tags: [Platform]
      operationId: getFxRates
      summary: La tasa de cambio
      description: >-
        La tasa de un par, como texto — nunca un flotante. **Es informativa**: sirve
        para pintar equivalencias, no para prometerlas; la tasa ejecutable llega con
        FX transaccional (roadmap). Un par que no existe es `400 invalid_field`.
      parameters:
        - { name: pair, in: query, required: true, schema: { type: string, example: "USD/COP" }, description: "El par, como `BASE/QUOTE`." }
      responses:
        "200":
          description: La tasa.
          content:
            application/json:
              schema:
                type: object
                properties:
                  pair: { type: string, example: "USD/COP" }
                  rate: { type: string, description: "Cuántas unidades de la segunda moneda vale una de la primera. Texto, como todo número que importa.", example: "3912.45" }
                  source: { type: string, description: "De dónde salió la tasa.", example: trm }
                  as_of: { type: string, format: date-time }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/events:
    get:
      tags: [Platform]
      operationId: listEvents
      summary: El log de eventos
      description: |
        Todo lo que pasó en tu tenant, como eventos consultables — los MISMOS que
        viajan por webhook, pero en un log que puedes leer cuando quieras. **Si dudas
        de un webhook, lee este log: el log es la verdad.** Es la pieza de
        reconciliación definitiva: un cron que lo recorre desde el último cursor
        no se pierde nada, aunque tu endpoint haya estado caído un día entero.
      parameters:
        - { name: type, in: query, schema: { $ref: "#/components/schemas/EventType" } }
        - { name: "created_at[gte]", in: query, schema: { type: string, format: date-time } }
        - { name: "created_at[lte]", in: query, schema: { type: string, format: date-time } }
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Cursor" }
      responses:
        "200":
          description: Una página de eventos, del más reciente al más viejo.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items: { type: array, items: { $ref: "#/components/schemas/Event" } }
                  next_cursor: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/events/{event_id}/redeliver:
    post:
      tags: [Platform]
      operationId: redeliverEvent
      summary: Reenviar un evento
      description: >-
        Vuelve a entregar UN evento a tus webhooks suscritos a su tipo — recovery
        manual para cuando tu endpoint se perdió una entrega. La entrega reenviada
        va firmada igual que la original y con el mismo `id` de evento: tu
        procesamiento idempotente por `id` la absorbe sin duplicar nada.
      parameters: [ { $ref: "#/components/parameters/EventId" } ]
      responses:
        "202": { description: Encolado — la entrega sale ya., content: { application/json: { schema: { $ref: "#/components/schemas/Event" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/BusinessRuleRejected" }
        "429": { $ref: "#/components/responses/RateLimited" }


webhooks:
  event:
    post:
      tags: [Webhooks]
      summary: Lo que llega a tu servidor
      description: >-
        Cada entrega es UN evento. Responde `2xx` rápido y procesa async; cualquier
        otra respuesta se reintenta con backoff. Verifica SIEMPRE la firma.
      requestBody:
        content:
          application/json: { schema: { $ref: "#/components/schemas/Event" } }
      responses:
        "200": { description: Recibido. }

components:
  securitySchemes:
    tenantToken:
      type: http
      scheme: bearer
      description: >-
        Tu token de tenant: `tk_live_…`. El entorno sandbox llega pronto; los tokens
        `tk_test_` quedarán reservados para él. Scopes: `read`, `lookup`, `transfers`,
        `bonuses`, `payouts`, `webhooks`. Usa el mínimo.
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: >-
        UUID tuyo. Reintentar con la misma llave es el MISMO pedido (200 en vez de
        201, y la respuesta trae el header `Idempotent-Replay: true` para que tu
        observabilidad distinga el replay del original); la misma llave con otro
        cuerpo — bytes de archivos incluidos — es 409.
      schema: { type: string, format: uuid }
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    Cursor:
      name: cursor
      in: query
      description: El `next_cursor` de la página anterior.
      schema: { type: string }
    AccountId: { name: account_id, in: path, required: true, schema: { type: string, format: uuid } }
    TransferId: { name: transfer_id, in: path, required: true, schema: { type: string, format: uuid } }
    PayoutId: { name: payout_id, in: path, required: true, schema: { type: string, format: uuid } }
    BatchId: { name: batch_id, in: path, required: true, schema: { type: string, format: uuid } }
    EndpointId: { name: endpoint_id, in: path, required: true, schema: { type: string, format: uuid } }
    EventId: { name: event_id, in: path, required: true, schema: { type: string, format: uuid } }
  headers:
    TikinRequestId:
      description: >-
        El id único de este request. TODA respuesta del API lo trae — éxito o error;
        cítalo en soporte y encontramos tu request en segundos.
      schema: { type: string, example: "req_01J9ZK3V4E8QW6H2M0X7B5T9RD" }
  responses:
    BadRequest:
      description: La forma del pedido es inválida — `details` dice qué campo.
      headers:
        Tikin-Request-Id: { $ref: "#/components/headers/TikinRequestId" }
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Unauthorized:
      description: Token ausente, inválido, revocado, o sin el scope necesario.
      headers:
        Tikin-Request-Id: { $ref: "#/components/headers/TikinRequestId" }
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    AuthorityInsufficient:
      description: >-
        `authority_insufficient` — el rol del miembro en esa cuenta no puede hacer
        esto. No es el token: es el permiso dentro de la cuenta.
      headers:
        Tikin-Request-Id: { $ref: "#/components/headers/TikinRequestId" }
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    NotFound:
      description: No existe — o no es de tu tenant (misma respuesta a propósito).
      headers:
        Tikin-Request-Id: { $ref: "#/components/headers/TikinRequestId" }
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Conflict:
      description: >-
        `idempotency_key_reused` (misma llave, otro cuerpo) o `invalid_state`
        (la transición no es legal desde el estado actual — el detalle dice cuáles sí).
      headers:
        Tikin-Request-Id: { $ref: "#/components/headers/TikinRequestId" }
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    BusinessRuleRejected:
      description: >-
        La regla de negocio dijo no — `code` (del catálogo `ErrorCode`) dice cuál y
        `details` trae los números (p. ej. `monthly_limit_exceeded` ⇒
        `{used, limit, resets_at}`).
      headers:
        Tikin-Request-Id: { $ref: "#/components/headers/TikinRequestId" }
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    RateLimited:
      description: Demasiado rápido — respeta `Retry-After` (segundos) y usa backoff con jitter.
      headers:
        Retry-After: { schema: { type: integer } }
        Tikin-Request-Id: { $ref: "#/components/headers/TikinRequestId" }
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Unavailable:
      description: No se pudo saber (el libro no contestó). Jamás un dato inventado.
      headers:
        Tikin-Request-Id: { $ref: "#/components/headers/TikinRequestId" }
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
  schemas:
    Asset:
      type: string
      description: "`COPM` = centavos de peso (escala 2) · `DUSD` = millonésimas de dólar (escala 6)."
      enum: [COPM, DUSD]
    Money:
      type: object
      required: [raw, asset]
      properties:
        raw:
          type: string
          pattern: "^[1-9]\\d*$"
          maxLength: 19
          description: Entero positivo como texto, sin ceros a la izquierda, en unidades mínimas.
          example: "2500000"
        asset: { $ref: "#/components/schemas/Asset" }
    ErrorCode:
      type: string
      description: El catálogo CERRADO de códigos — haz tu switch exhaustivo contra esto.
      enum:
        - missing_field
        - invalid_field
        - missing_query
        - missing_idempotency_key
        - idempotency_key_reused
        - invalid_state
        - unauthorized
        - insufficient_scope
        - authority_insufficient
        - not_found
        - alias_taken
        - alias_reserved
        - alias_in_quarantine
        - insufficient_funds
        - capability_cannot_send
        - below_minimum
        - above_per_operation_limit
        - monthly_limit_exceeded
        - rate_limited
        - ledger_unavailable
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { $ref: "#/components/schemas/ErrorCode" }
            message: { type: string, example: "El cupo del mes se agotó: llevabas $1.000.000 y renueva el 1 de octubre." }
            doc_url: { type: string, format: uri, description: "El ancla del código en la doc — resuelve de verdad.", example: "https://docs.tikin.is/conceptos/errores#monthly_limit_exceeded" }
            details:
              type: object
              description: >-
                Tipado por código. `monthly_limit_exceeded` ⇒ {used, limit, resets_at};
                `insufficient_funds` ⇒ {available, requested}; `invalid_state` ⇒
                {current, allowed[]}; `invalid_field` ⇒ {field, reason}.
              additionalProperties: true
    NewAccount:
      type: object
      required: [type, alias]
      properties:
        type: { type: string, enum: [person, business] }
        alias: { type: string, description: "El alias público de la cuenta, único DENTRO de tu tenant. El formato lo define cada tenant — el `$` es la convención de Tikin. Cambiarlo tiene cuarentena de 90 días.", example: "$bottico" }
        legal_name: { type: string, description: "Obligatorio para `business`." }
        document: { $ref: "#/components/schemas/Document" }
        email: { type: string, format: email }
        phone: { type: string }
    Document:
      type: object
      required: [type, number]
      properties:
        type: { type: string, example: CC }
        number: { type: string, example: "1017654321" }
    Account:
      type: object
      properties:
        id: { type: string, format: uuid }
        type: { type: string, enum: [person, business] }
        alias: { type: string }
        legal_name: { type: [string, "null"] }
        verification: { $ref: "#/components/schemas/VerificationLevel" }
        created_at: { type: string, format: date-time }
    Recipient:
      type: object
      description: Lo mínimo para confirmar a quién le envías — sin datos sensibles.
      properties:
        account_id: { type: string, format: uuid }
        tenant: { type: string, description: "El tenant del destinatario — puede no ser el tuyo. Con esto armas el destino cross-tenant.", example: acme }
        alias: { type: string }
        display_name: { type: string, description: "Abreviado para person (\"Ana P.\"), legal completo para business." }
        type: { type: string, enum: [person, business] }
    VerificationLevel:
      type: string
      enum: [unverified, verified]
      description: "`unverified` recibe pero NO envía."
    VerificationDeclaration:
      type: object
      required: [level, source]
      properties:
        level: { $ref: "#/components/schemas/VerificationLevel" }
        source: { type: string, description: Quién verificó., example: didit }
        expires_at: { type: [string, "null"], format: date-time, description: "Default: 90 días." }
    Balance:
      type: object
      properties:
        purpose: { type: string, example: main }
        asset: { $ref: "#/components/schemas/Asset" }
        available: { type: string, description: Lo que se puede gastar., example: "2583478034" }
        held: { type: string, description: Reservado por operaciones en vuelo., example: "0" }
    MovementKind:
      type: string
      enum: [transfer_in, transfer_out, bonus_in, bonus_out, payin_in, payout_out, fee_out, adjustment_in, adjustment_out]
      description: El sufijo dice la dirección, siempre.
    Movement:
      type: object
      properties:
        id: { type: string, format: uuid }
        kind: { $ref: "#/components/schemas/MovementKind" }
        amount: { $ref: "#/components/schemas/Money" }
        counterparty: { $ref: "#/components/schemas/Recipient" }
        description: { type: [string, "null"] }
        related: { $ref: "#/components/schemas/RelatedResource" }
        created_at: { type: string, format: date-time }
    RelatedResource:
      type: object
      description: El recurso que causó el movimiento.
      required: [type, id]
      properties:
        type: { type: string, enum: [transfer, payout, bonus, bonus_batch, payin] }
        id: { type: string, format: uuid }
    AccountDestination:
      type: object
      title: Interna
      required: [type, account_id]
      properties:
        type: { const: account }
        account_id: { type: string, format: uuid }
    PlatformAccountDestination:
      type: object
      title: Entre tenants
      description: >-
        SIEMPRE con el `tenant` destino, más UNO de `alias` o `account_id`. El alias
        solo identifica junto con su tenant — es único dentro de cada tenant, no en
        toda la plataforma.
      required: [type, tenant]
      properties:
        type: { const: platform_account }
        tenant: { type: string, example: acme }
        alias: { type: string, example: "$maria" }
        account_id: { type: string, format: uuid }
    TransferDestination:
      oneOf:
        - { $ref: "#/components/schemas/AccountDestination" }
        - { $ref: "#/components/schemas/PlatformAccountDestination" }
      discriminator:
        propertyName: type
        mapping:
          account: "#/components/schemas/AccountDestination"
          platform_account: "#/components/schemas/PlatformAccountDestination"
    NewTransfer:
      type: object
      required: [source_account, destination, amount]
      properties:
        source_account: { type: string, format: uuid }
        destination: { $ref: "#/components/schemas/TransferDestination" }
        amount: { $ref: "#/components/schemas/Money", description: Lo que RECIBE el destino. }
        description: { type: string, maxLength: 140 }
    TransferStatus:
      type: string
      enum: [completed, pending_approval, rejected, expired]
      description: >-
        `pending_approval` se resuelve a `completed` o `rejected` (otro miembro decide
        en su app) o `expired` si nadie decidió a tiempo.
    Transfer:
      type: object
      properties:
        id: { type: string, format: uuid }
        scope: { type: string, enum: [internal, cross_tenant] }
        status: { $ref: "#/components/schemas/TransferStatus" }
        source_account: { type: string, format: uuid }
        destination: { $ref: "#/components/schemas/TransferDestination" }
        amount: { $ref: "#/components/schemas/Money" }
        fee: { $ref: "#/components/schemas/Money" }
        description: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
    NewBonus:
      type: object
      required: [source_account, destination_account, amount]
      properties:
        source_account: { type: string, format: uuid, description: La cuenta de tu empresa. }
        destination_account: { type: string, format: uuid }
        amount: { $ref: "#/components/schemas/Money", description: Lo que recibe la persona. }
        description: { type: string, maxLength: 140 }
    Bonus:
      type: object
      properties:
        id: { type: string, format: uuid }
        status: { type: string, enum: [completed, pending_approval, rejected, expired] }
        source_account: { type: string, format: uuid }
        destination_account: { type: string, format: uuid }
        amount: { $ref: "#/components/schemas/Money" }
        fee: { $ref: "#/components/schemas/Money" }
        description: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
    BonusBatchStatus:
      type: string
      enum: [validating, ready, rejected, executing, completed, partially_failed, canceled]
    BonusBatch:
      type: object
      properties:
        id: { type: string, format: uuid }
        status: { $ref: "#/components/schemas/BonusBatchStatus" }
        source_account: { type: string, format: uuid }
        rows: { type: integer, description: Filas válidas. }
        rows_rejected: { type: integer }
        total: { $ref: "#/components/schemas/Money" }
        description: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    BankAccountDestination:
      type: object
      title: Cuenta bancaria
      required: [type, bank, account_number, account_kind, holder]
      properties:
        type: { const: bank_account }
        bank: { type: string, example: bancolombia }
        account_number: { type: string }
        account_kind: { type: string, enum: [savings, checking] }
        holder: { $ref: "#/components/schemas/Holder" }
    CashPickupDestination:
      type: object
      title: Efectivo en punto
      required: [type, holder]
      properties:
        type: { const: cash_pickup }
        holder: { $ref: "#/components/schemas/Holder" }
    PayoutDestination:
      oneOf:
        - { $ref: "#/components/schemas/BankAccountDestination" }
        - { $ref: "#/components/schemas/CashPickupDestination" }
      discriminator:
        propertyName: type
        mapping:
          bank_account: "#/components/schemas/BankAccountDestination"
          cash_pickup: "#/components/schemas/CashPickupDestination"
    Holder:
      type: object
      required: [name, document]
      properties:
        name: { type: string }
        document: { $ref: "#/components/schemas/Document" }
        email: { type: [string, "null"], format: email }
    NewPayout:
      type: object
      required: [source_account, destination, amount]
      properties:
        source_account: { type: string, format: uuid }
        destination: { $ref: "#/components/schemas/PayoutDestination" }
        amount: { $ref: "#/components/schemas/Money", description: Lo que RECIBE el beneficiario. }
    PayoutStatus:
      type: string
      enum: [received, approved, in_progress, completed, rejected, failed, returned, canceled]
    Payout:
      type: object
      properties:
        id: { type: string, format: uuid }
        status: { $ref: "#/components/schemas/PayoutStatus" }
        source_account: { type: string, format: uuid }
        destination_type: { type: string, enum: [bank_account, cash_pickup] }
        destination_summary:
          type: object
          description: Lo pedido, auditable y enmascarado.
          properties:
            bank: { type: [string, "null"] }
            account_number_masked: { type: [string, "null"], example: "****4832" }
            holder_name: { type: string }
        amount: { $ref: "#/components/schemas/Money" }
        fee: { $ref: "#/components/schemas/Money" }
        pickup:
          type: [object, "null"]
          description: Solo `cash_pickup` en curso.
          properties:
            code: { type: string }
            instructions: { type: string }
            expires_at: { type: string, format: date-time }
        failure_reason: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    EventType:
      type: string
      enum:
        - transfer.completed
        - transfer.rejected
        - transfer.expired
        - payout.approved
        - payout.completed
        - payout.rejected
        - payout.failed
        - payout.returned
        - payout.canceled
        - bonus_batch.ready
        - bonus_batch.rejected
        - bonus_batch.completed
        - bonus_batch.partially_failed
        - account.verified
    Event:
      type: object
      description: Lo que llega a tu webhook. `data.object` es el recurso completo en su estado actual.
      required: [id, type, api_version, created_at, data]
      properties:
        id: { type: string, format: uuid }
        type: { $ref: "#/components/schemas/EventType" }
        api_version: { type: string, example: "2026-09-08" }
        created_at: { type: string, format: date-time }
        data:
          type: object
          required: [object]
          properties:
            object:
              description: El Transfer/Payout/BonusBatch/Account según `type`.
              oneOf:
                - { $ref: "#/components/schemas/Transfer" }
                - { $ref: "#/components/schemas/Payout" }
                - { $ref: "#/components/schemas/BonusBatch" }
                - { $ref: "#/components/schemas/Account" }
    WebhookEndpoint:
      type: object
      properties:
        id: { type: string, format: uuid }
        url: { type: string, format: uri }
        events: { type: array, items: { $ref: "#/components/schemas/EventType" } }
        created_at: { type: string, format: date-time }
    WebhookEndpointWithSecret:
      allOf:
        - { $ref: "#/components/schemas/WebhookEndpoint" }
        - type: object
          properties:
            secret: { type: string, description: "Guárdalo: no se puede volver a ver." }
