SDK de Node / TypeScript

Cliente tipado y sin dependencias para la Pay API. Copia el archivo a tu proyecto y listo — un solo archivo, contrato completo (cobrar, pagar, consultar, saldo, webhooks).

bash
# 1) Descarga el cliente a tu proyecto
curl -o quicklink.ts https://docs.quickoffer.io/developers/sdk/node.ts
js
import { QuickLink } from './quicklink';
const qp = new QuickLink({ baseUrl: 'https://pay.quickoffer.io', apiKey: 'acme:sk_test_…' });

const cobro = await qp.cobrar({ monto: { valor: 1.0 }, pagador, cobrador });
console.log(cobro.estado); // ACEPTADA → LIQUIDADA

Código fuente

typescript
/**
 * QuickLink Pay API — SDK Node/TypeScript (mínimo, un solo archivo)
 * ================================================================
 *
 * SDK oficial para la **QuickLink Pay API**: una sola integración para cobrar y
 * pagar sobre los rieles bancarios de Venezuela (Pago Móvil, Crédito Inmediato,
 * Débito Inmediato/C2P). Sin dependencias: usa `fetch` nativo (Node ≥ 18).
 *
 * Tipos y comportamiento derivados del contrato real (`contract.ts`, `openapi.ts`,
 * `server.ts`). Montos en **VES**.
 *
 * --------------------------------------------------------------------------
 * README
 * --------------------------------------------------------------------------
 *
 * ## Instalación
 *
 * No tiene dependencias. Cópialo a tu proyecto o publícalo como paquete interno:
 *
 * ```bash
 * npm i typescript --save-dev      # solo si aún no usas TS
 * # luego importa ./quicklink
 * ```
 *
 * Requisitos: **Node.js ≥ 18** (fetch nativo). En TS, `"target": "ES2022"` o superior.
 *
 * ## Autenticación
 *
 * Cada llamada usa la cabecera `x-switch-key` con el formato `<comercio>:<api_key>`.
 * El SDK la construye por ti a partir de `apiKey`, que ya debe venir con ese formato:
 *
 *   - `mi_comercio:sk_test_…`  → ambiente de prueba (no mueve dinero real)
 *   - `mi_comercio:sk_live_…`  → producción
 *
 * **El modo (test/live) lo fija la API key, NO el cliente.** No envíes `mode` ni
 * `tenant_id`: el servidor los deriva de la llave.
 *
 * ## Uso rápido
 *
 * ```ts
 * import { QuickLink, nuevaIdempotencyKey } from './quicklink';
 *
 * const qp = new QuickLink({
 *   baseUrl: process.env.QUICKLINK_BASE_URL!,        // {{base_url}}, ej. https://pay.quickoffer.io
 *   apiKey:  process.env.QUICKLINK_API_KEY!,         // "mi_comercio:sk_test_xxx"
 * });
 *
 * // 1) Cobrar (débito inmediato / C2P). Si requiere OTP, pídelo primero (ver abajo).
 * const cobro = await qp.cobrar({
 *   idempotency_key: nuevaIdempotencyKey(),
 *   monto: { valor: 320.0, moneda: 'VES' },
 *   concepto: 'Matrícula 2026-1',
 *   cobrador: { tipo: 'cuenta',   valor: '01380000...', banco: '0138', identificacion: 'J00378944781', nombre: 'UFT' },
 *   pagador:  { tipo: 'telefono', valor: '04125551234', banco: '0105', identificacion: 'V13759368', nombre: 'Ana Pérez' },
 *   otp: '12345678',                 // si ya lo capturaste del pagador
 *   origen_ip: '200.1.2.3',
 * });
 * console.log(cobro.estado, cobro.id);
 *
 * // 2) Flujo C2P con OTP en 2 pasos
 * const intent = {
 *   idempotency_key: nuevaIdempotencyKey(),
 *   monto: { valor: 320.0 },
 *   concepto: 'Matrícula',
 *   cobrador, pagador,
 * };
 * await qp.solicitarToken(intent);   // el banco del pagador emite el OTP (~120s)
 * // ...el pagador te dicta el OTP...
 * const ok = await qp.cobrar({ ...intent, idempotency_key: nuevaIdempotencyKey(), otp: '12345678' });
 *
 * // 3) Pagar (enviar dinero). El riel lo elige `via`.
 * const pago = await qp.pagar({
 *   idempotency_key: nuevaIdempotencyKey(),
 *   monto: { valor: 250.0 },
 *   concepto: 'Reintegro',
 *   ordenante:    { tipo: 'cuenta',   valor: '01380000...', banco: '0138', identificacion: 'J...', nombre: 'QuickLink' },
 *   beneficiario: { tipo: 'telefono', valor: '04145556677', banco: '0134', identificacion: 'V14589678', nombre: 'María' },
 *   via: 'credito_inmediato',        // 'credito_inmediato' (CCE, predominante) | 'pago_movil' (P2P por teléfono)
 *   proposito: 'transferencia',
 * });
 *
 * // 4) Estados asíncronos: consulta hasta liquidación
 * const estado = await qp.consultar({ id: pago.id });
 * // ACEPTADA/PENDIENTE → vuelve a consultar; LIQUIDADA es el final OK.
 *
 * // 5) Consultas read-only
 * const bancos = await qp.bancos();
 * const s = await qp.saldo({ cuenta: '01380000...', moneda: 'VES' });
 * const movs = await qp.movimientos({ cuenta: '01380000...', desde: '2026-06-01' });
 * ```
 *
 * ## Manejo de errores
 *
 * Toda respuesta de error lanza `QuickLinkError`. Cuando el banco **rechaza** un
 * cobro/pago, el resultado llega con `estado: 'RECHAZADA'` y un `motivo_rechazo`
 * normalizado; el SDK lo lanza también como `QuickLinkError` con `.motivo` y
 * `.resultado` para que puedas ramificar:
 *
 * ```ts
 * import { QuickLinkError } from './quicklink';
 *
 * try {
 *   await qp.cobrar(req);
 * } catch (e) {
 *   if (e instanceof QuickLinkError) {
 *     console.error(e.motivo);       // p.ej. 'SALDO_INSUFICIENTE' | 'OTP_INVALIDO' | 'BLOQUEO_RIESGO'
 *     console.error(e.statusCode);   // HTTP (0 si fue rechazo de negocio con 200)
 *     console.error(e.resultado);    // el Resultado crudo cuando aplica (incluye .raw del banco)
 *   }
 * }
 * ```
 *
 * Si prefieres inspeccionar el estado tú mismo (sin try/catch en rechazos de
 * negocio), pasa `{ lanzarEnRechazo: false }` al constructor: entonces `cobrar`,
 * `pagar`, etc. devuelven el `Resultado` incluso si `estado === 'RECHAZADA'`, y
 * solo lanzan ante errores HTTP/red.
 *
 * ## Idempotencia
 *
 * Envía siempre un `idempotency_key` (UUID) único por operación. Reintentar con
 * la misma llave nunca duplica el cobro/pago: devuelve el resultado original.
 * Usa `nuevaIdempotencyKey()` (UUID v4) que el SDK exporta.
 *
 * --------------------------------------------------------------------------
 * (fin README)
 */

// ─────────────────────────────────────────────────────────────────────────
//  Tipos del contrato (derivados de contract.ts)
// ─────────────────────────────────────────────────────────────────────────

/** Instrumento: origen o destino del dinero. Todos los bancos soportan cuenta o teléfono. */
export interface Instrumento {
  /** "cuenta" (nº de 20 dígitos) o "telefono". */
  tipo: 'cuenta' | 'telefono';
  /** Nº de cuenta o teléfono. */
  valor: string;
  /** Código BCV del banco (4 dígitos), ej. "0138". */
  banco: string;
  /** Cédula/RIF con prefijo de persona (V/E/J/P…), ej. "V13759368". */
  identificacion: string;
  /** Nombre del titular (≤ 40 caracteres). */
  nombre: string;
}

/** Monto a mover. `moneda` por defecto "VES" (ISO-4217). */
export interface Monto {
  valor: number;
  /** ISO-4217, 3 letras. Por defecto "VES". */
  moneda?: string;
}

/** Canal por el que entra la operación. */
export type Canal = 'boton_pago' | 'billetera_digital' | 'pos' | 'merchant' | 'vpos';

/** Estado normalizado de una transacción (la liquidación interbancaria es asíncrona). */
export type EstadoTransaccion =
  | 'ACEPTADA' // el banco recibió y aprobó (síncrono o "enviada a BCV")
  | 'PENDIENTE' // aceptada, en liquidación; requiere consultar/webhook
  | 'LIQUIDADA' // confirmada y liquidada (final OK)
  | 'RECHAZADA' // rechazo de negocio (ver motivo_rechazo)
  | 'ERROR' // fallo técnico/transporte (reintentable)
  | 'DESCONOCIDA'; // sin respuesta; consultar antes de reintegrar

/** Enum común de rechazos. El crudo del banco se preserva en `Resultado.raw`. */
export type MotivoRechazo =
  | 'SALDO_INSUFICIENTE'
  | 'OTP_INVALIDO'
  | 'CUENTA_NO_EXISTE'
  | 'CUENTA_BLOQUEADA'
  | 'CUENTA_NO_DEBITO'
  | 'CUENTA_NO_CREDITO'
  | 'MONTO_INVALIDO'
  | 'EXCEDE_LIMITE'
  | 'BENEFICIARIO_NO_COINCIDE'
  | 'NO_AFILIADO_SERVICIO'
  | 'BANCO_NO_DISPONIBLE'
  | 'DUPLICADA'
  | 'FUERA_DE_HORARIO'
  | 'CANCELADA_POR_PAGADOR'
  | 'PENDIENTE_BCV'
  | 'RECHAZO_TECNICO'
  | 'FIRMA_AUTH'
  | 'NO_SOPORTADO'
  | 'BLOQUEO_RIESGO' // bloqueado por el motor de riesgo/fraude
  | 'DESCONOCIDO';

/** Resultado: forma de respuesta de toda operación de dinero. */
export interface Resultado {
  /** Id interno del switch (idempotente). */
  id: string;
  estado: EstadoTransaccion;
  /** Banco que ejecutó la operación, ej. "banco_plaza". */
  banco_usado: string;
  /** Referencia del banco (referencia_c / numeroReferencia / secuencial…), o null. */
  referencia_banco: string | null;
  /** Id BCV cuando aplica, o null. */
  endtoend: string | null;
  /** Motivo normalizado si estado === 'RECHAZADA'; null en caso contrario. */
  motivo_rechazo: MotivoRechazo | null;
  /** Nº de intentos del switch. */
  intentos: number;
  /** Respuesta cruda del banco (auditable). */
  raw?: unknown;
}

// ── Campos comunes a toda operación de dinero. `tenant_id` y `mode` los fija el
//    servidor desde la API key: el cliente NO los envía (omitidos a propósito).
interface BaseMonetario {
  /** UUID único por operación (idempotencia). Usa `nuevaIdempotencyKey()`. */
  idempotency_key: string;
  monto: Monto;
  /** Concepto/descripción (≤ 120 caracteres). */
  concepto: string;
  /** Por defecto "boton_pago". */
  canal?: Canal;
  /** IP del usuario final (varios bancos la exigen). */
  origen_ip?: string;
  /** Hint de enrutamiento: banco preferido. */
  banco_preferido?: string;
}

/** Request de `cobrar` (débito inmediato / C2P). */
export interface CobrarRequest extends BaseMonetario {
  /** Quién cobra (tu cuenta). */
  cobrador: Instrumento;
  /** Quién paga (a quien se debita). */
  pagador: Instrumento;
  /** OTP del banco emisor, si ya se capturó. Si no, pídelo con `solicitarToken`. */
  otp?: string;
}

/** Request de `solicitarToken` (paso previo: pide OTP al banco del pagador). */
export interface TokenRequest extends BaseMonetario {
  cobrador: Instrumento;
  pagador: Instrumento;
}

/** Request de `pagar` (enviar dinero a un beneficiario). */
export interface PagarRequest extends BaseMonetario {
  /** Origen del dinero (tu tesorería). */
  ordenante: Instrumento;
  /** Destinatario. */
  beneficiario: Instrumento;
  /** Por defecto "transferencia". */
  proposito?: 'transferencia' | 'nomina' | 'proveedor';
  /**
   * Riel de envío. Por defecto "credito_inmediato" (CCE, predominante; admite
   * teléfono o cuenta; liquida asíncrono BCV → ACEPTADA y luego LIQUIDADA).
   * "pago_movil" es P2P por teléfono, instantáneo → LIQUIDADA.
   * A cuenta siempre es crédito inmediato.
   */
  via?: 'credito_inmediato' | 'pago_movil';
}

/** Request de `consultar` (estado/liquidación de una operación previa). */
export interface ConsultarRequest {
  /** Id devuelto por cobrar/pagar/token. */
  id: string;
}

/** Query de `saldo` (read-only). */
export interface SaldoQuery {
  cuenta: string;
  moneda?: string;
  /** Banco específico (hint). */
  banco?: string;
}

/** Query de `movimientos` (read-only, conciliación). */
export interface MovimientosQuery {
  cuenta: string;
  /** Fecha inicio (requerida), ej. "2026-06-01". */
  desde: string;
  /** Fecha fin (opcional). */
  hasta?: string;
  /** Banco específico (hint). */
  banco?: string;
}

/** Item de `bancos()`. */
export interface BancoInfo {
  banco: string;
  capabilities: string[];
}

// ─────────────────────────────────────────────────────────────────────────
//  Error
// ─────────────────────────────────────────────────────────────────────────

/**
 * Error de cualquier operación. Si fue un **rechazo de negocio** (estado
 * RECHAZADA), `motivo` trae el `motivo_rechazo` normalizado y `resultado` el
 * Resultado completo (incluye `.raw` del banco).
 */
export class QuickLinkError extends Error {
  /** Motivo normalizado de rechazo, cuando aplica. */
  readonly motivo: MotivoRechazo | null;
  /** Código HTTP (0 si fue un rechazo de negocio devuelto con 200). */
  readonly statusCode: number;
  /** Resultado crudo del switch, cuando la respuesta lo trae. */
  readonly resultado?: Resultado;
  /** Cuerpo de error tal como lo devolvió el servidor, cuando aplica. */
  readonly detalle?: unknown;

  constructor(
    message: string,
    opts: {
      motivo?: MotivoRechazo | null;
      statusCode?: number;
      resultado?: Resultado;
      detalle?: unknown;
    } = {},
  ) {
    super(message);
    this.name = 'QuickLinkError';
    this.motivo = opts.motivo ?? null;
    this.statusCode = opts.statusCode ?? 0;
    if (opts.resultado !== undefined) this.resultado = opts.resultado;
    if (opts.detalle !== undefined) this.detalle = opts.detalle;
  }
}

// ─────────────────────────────────────────────────────────────────────────
//  Cliente
// ─────────────────────────────────────────────────────────────────────────

export interface QuickLinkOptions {
  /** Base URL del API ({{base_url}}), ej. "https://pay.quickoffer.io". Sin barra final. */
  baseUrl: string;
  /** Llave en formato "<comercio>:<api_key>". La key (sk_test_/sk_live_) fija el modo. */
  apiKey: string;
  /**
   * Si true (por defecto), las operaciones de dinero lanzan `QuickLinkError`
   * cuando `estado === 'RECHAZADA'`. Si false, devuelven el Resultado y solo
   * lanzan ante errores HTTP/red.
   */
  lanzarEnRechazo?: boolean;
  /** Timeout por request en ms (por defecto 30000). */
  timeoutMs?: number;
  /** `fetch` a usar (por defecto el global). Útil para tests o proxies. */
  fetch?: typeof fetch;
}

const API_BASE = '/switch/v1';

export class QuickLink {
  private readonly baseUrl: string;
  private readonly apiKey: string;
  private readonly lanzarEnRechazo: boolean;
  private readonly timeoutMs: number;
  private readonly _fetch: typeof fetch;

  constructor(opts: QuickLinkOptions) {
    if (!opts?.baseUrl) throw new QuickLinkError('Falta baseUrl.');
    if (!opts?.apiKey) throw new QuickLinkError('Falta apiKey (formato "<comercio>:<api_key>").');
    if (!opts.apiKey.includes(':')) {
      throw new QuickLinkError('apiKey debe tener el formato "<comercio>:<api_key>".');
    }
    this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
    this.apiKey = opts.apiKey;
    this.lanzarEnRechazo = opts.lanzarEnRechazo ?? true;
    this.timeoutMs = opts.timeoutMs ?? 30_000;
    const f = opts.fetch ?? globalThis.fetch;
    if (typeof f !== 'function') {
      throw new QuickLinkError('fetch no disponible: usa Node ≥ 18 o pasa { fetch } en las opciones.');
    }
    this._fetch = f.bind(globalThis);
  }

  // ── Operaciones de dinero ──────────────────────────────────────────────

  /** Cobrar (debitar al pagador): débito inmediato / C2P. Incluye `otp` si ya lo capturaste. */
  async cobrar(req: CobrarRequest): Promise<Resultado> {
    return this.dinero('/cobrar', req);
  }

  /** Solicitar OTP al banco del pagador (paso previo a `cobrar` con token, TTL ~120s). */
  async solicitarToken(req: TokenRequest): Promise<Resultado> {
    return this.dinero('/cobrar/token', req);
  }

  /** Pagar (enviar dinero a un beneficiario). El riel lo elige `via`. */
  async pagar(req: PagarRequest): Promise<Resultado> {
    return this.dinero('/pagar', req);
  }

  /** Consultar el estado actualizado de una operación previa (PENDIENTE/LIQUIDADA/RECHAZADA). */
  async consultar(req: ConsultarRequest): Promise<Resultado> {
    // /consultar lanza ante RECHAZADA solo si así está configurado; por defecto
    // devolvemos el Resultado tal cual: consultar es informativo, no ejecuta dinero.
    const res = await this.request<Resultado>('POST', '/consultar', { body: req });
    return res;
  }

  // ── Consultas read-only ────────────────────────────────────────────────

  /** Saldo de una cuenta cobradora (no lo filtran reglas de pago). */
  async saldo(q: SaldoQuery): Promise<unknown> {
    return this.request('GET', '/saldo', { query: { cuenta: q.cuenta, moneda: q.moneda, banco: q.banco } });
  }

  /** Movimientos de una cuenta para conciliación. `desde` es requerido. */
  async movimientos(q: MovimientosQuery): Promise<unknown> {
    if (!q?.desde) throw new QuickLinkError("'desde' es requerido para movimientos.");
    return this.request('GET', '/movimientos', {
      query: { cuenta: q.cuenta, desde: q.desde, hasta: q.hasta, banco: q.banco },
    });
  }

  /** Catálogo de bancos habilitados y sus capacidades. */
  async bancos(): Promise<BancoInfo[]> {
    return this.request<BancoInfo[]>('GET', '/bancos', {});
  }

  // ── Internos ───────────────────────────────────────────────────────────

  /** Ejecuta una operación de dinero (POST → Resultado) y aplica la política de rechazo. */
  private async dinero(path: string, body: unknown): Promise<Resultado> {
    const res = await this.request<Resultado>('POST', path, { body });
    if (this.lanzarEnRechazo && res?.estado === 'RECHAZADA') {
      throw new QuickLinkError(
        `Operación rechazada: ${res.motivo_rechazo ?? 'DESCONOCIDO'}`,
        { motivo: res.motivo_rechazo, statusCode: 0, resultado: res },
      );
    }
    return res;
  }

  private async request<T>(
    method: 'GET' | 'POST',
    path: string,
    opts: { body?: unknown; query?: Record<string, string | undefined> },
  ): Promise<T> {
    const url = new URL(this.baseUrl + API_BASE + path);
    if (opts.query) {
      for (const [k, v] of Object.entries(opts.query)) {
        if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v));
      }
    }

    const headers: Record<string, string> = {
      'x-switch-key': this.apiKey,
      accept: 'application/json',
    };
    const init: RequestInit = { method, headers };
    if (opts.body !== undefined && method !== 'GET') {
      headers['content-type'] = 'application/json';
      init.body = JSON.stringify(opts.body);
    }

    const ctrl = new AbortController();
    const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
    init.signal = ctrl.signal;

    let resp: Response;
    try {
      resp = await this._fetch(url.toString(), init);
    } catch (e) {
      if ((e as Error)?.name === 'AbortError') {
        throw new QuickLinkError(`Timeout (${this.timeoutMs}ms) en ${method} ${path}.`, {
          motivo: 'DESCONOCIDO',
        });
      }
      throw new QuickLinkError(`Error de red en ${method} ${path}: ${String(e)}`, {
        motivo: 'BANCO_NO_DISPONIBLE',
      });
    } finally {
      clearTimeout(timer);
    }

    const text = await resp.text();
    let data: unknown = undefined;
    if (text) {
      try {
        data = JSON.parse(text);
      } catch {
        data = text;
      }
    }

    if (!resp.ok) {
      const d = (data ?? {}) as { error?: string; detail?: unknown; motivo_rechazo?: MotivoRechazo };
      const motivo =
        (d.motivo_rechazo as MotivoRechazo | undefined) ?? statusToMotivo(resp.status);
      throw new QuickLinkError(
        `HTTP ${resp.status} en ${method} ${path}: ${d.error ?? resp.statusText}`,
        { statusCode: resp.status, motivo, detalle: d.detail ?? data },
      );
    }

    return data as T;
  }
}

// ─────────────────────────────────────────────────────────────────────────
//  Utilidades
// ─────────────────────────────────────────────────────────────────────────

/** Mapea códigos HTTP de error de transporte a un motivo normalizado aproximado. */
function statusToMotivo(status: number): MotivoRechazo | null {
  if (status === 401 || status === 403) return 'FIRMA_AUTH';
  if (status === 429) return 'EXCEDE_LIMITE';
  if (status === 404) return null;
  if (status >= 500) return 'BANCO_NO_DISPONIBLE';
  return null;
}

/** Genera un UUID v4 para usar como `idempotency_key`. */
export function nuevaIdempotencyKey(): string {
  const c = (globalThis as { crypto?: Crypto }).crypto;
  if (c && typeof c.randomUUID === 'function') return c.randomUUID();
  // Fallback (sin crypto): suficiente para idempotencia best-effort.
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
    const r = (Math.random() * 16) | 0;
    const v = ch === 'x' ? r : (r & 0x3) | 0x8;
    return v.toString(16);
  });
}

export default QuickLink;