Desde tu sistema · SDK Node / TypeScript
Docs · Desde tu sistema Descargar .md

SDK de Node / TypeScript

Un solo archivo, sin dependencias, para usar la API de QuickPay desde tu servidor (Node 18 o superior). Cubre enlaces de pago, cobro por lote y la verificación de la firma de los webhooks; para aliados, también la API directa.

Instalar

bash
curl -o quickpay.ts https://docs.pagaconquickpay.com/developers/sdk/node.ts

Crear un enlace y consultarlo

js
import { QuickPay } from './quickpay';

const qp = new QuickPay({ apiKey: process.env.QUICKPAY_KEY }); // "tu-comercio:sk_test_…"

const enlace = await qp.crearEnlace({ monto: 150, concepto: 'Pedido #1024', metadata: { pedido: '1024' } });
console.log(enlace.url); // compártela con tu cliente

const estado = await qp.obtenerEnlace(enlace.id);
console.log(estado.estado); // pendiente → pagado

Verificar un webhook

js
import { verificarWebhook } from './quickpay';

// cuerpo: el texto EXACTO que llegó (sin parsear). secreto: el que te mostró el portal al crear el webhook.
const valido = verificarWebhook({
  cuerpo,
  firma: req.headers['x-quicklink-signature-v2'],
  timestamp: req.headers['x-quicklink-timestamp'],
  secreto: process.env.QUICKPAY_WEBHOOK_SECRET,
});

La llave va siempre en tu servidor, nunca en el navegador ni en una app.

Código fuente

typescript
/**
 * API de QuickPay — SDK Node/TypeScript (mínimo, un solo archivo)
 * ================================================================
 *
 * SDK oficial para la **API de QuickPay**: 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 ./quickpay
 * ```
 *
 * 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 { QuickPay, nuevaIdempotencyKey } from './quickpay';
 *
 * const qp = new QuickPay({
 *   baseUrl: process.env.QUICKLINK_BASE_URL!,        // {{base_url}}, ej. https://pay.pagaconquickpay.com
 *   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: '01020000...', banco: '0102', identificacion: 'J123456789', nombre: 'ACME C.A.' },
 *   pagador:  { tipo: 'telefono', valor: '04125551234', banco: '0105', identificacion: 'V12345678', 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`. Sólo integraciones habilitadas (si no, 403 pagar_no_habilitado).
 * const pago = await qp.pagar({
 *   idempotency_key: nuevaIdempotencyKey(),
 *   monto: { valor: 250.0 },
 *   concepto: 'Reintegro',
 *   ordenante:    { tipo: 'cuenta',   valor: '01020000...', banco: '0102', identificacion: 'J...', nombre: 'Mi Comercio' },
 *   beneficiario: { tipo: 'telefono', valor: '04145556677', banco: '0134', identificacion: 'V87654321', 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.
 *
 * // saldo/movimientos son de uso interno: con una llave de comercio responden 403 cuenta_no_permitida.
 * ```
 *
 * ## Manejo de errores
 *
 * Toda respuesta de error lanza `QuickPayError`. 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 `QuickPayError` con `.motivo` y
 * `.resultado` para que puedas ramificar:
 *
 * ```ts
 * import { QuickPayError } from './quickpay';
 *
 * try {
 *   await qp.cobrar(req);
 * } catch (e) {
 *   if (e instanceof QuickPayError) {
 *     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 cuando aplica
 *   }
 * }
 * ```
 *
 * 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)
 */
import { createHmac, timingSafeEqual } from 'node:crypto';


// ─────────────────────────────────────────────────────────────────────────
//  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. "0102". */
  banco: string;
  /** Cédula/RIF con prefijo de persona (V/E/J/P…), ej. "V12345678". */
  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, igual para todos los bancos. */
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
  | 'SIN_DEBITO_EN_EXTRACTO' // pagar: el banco no respondió y el débito no está en el estado de cuenta
  | 'DESCONOCIDO';

/** Resultado: forma de respuesta de toda operación de dinero. */
export interface Resultado {
  /** Id interno del switch (idempotente). */
  id: string;
  estado: EstadoTransaccion;
  /** 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;
}

// ── 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;
}

// ─────────────────────────────────────────────────────────────────────────
//  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.
 */
export class QuickPayError 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 = 'QuickPayError';
    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
// ─────────────────────────────────────────────────────────────────────────

// ── Enlaces y lotes (comercios) ───────────────────────────────────────────

export interface CrearEnlaceRequest {
  monto: number;
  moneda?: 'VES';
  /** Lo que ve tu cliente al pagar (obligatorio, hasta 140 caracteres). */
  concepto: string;
  /** Horas hasta que vence: de 1 a 720 (por omisión 24). */
  expira_horas?: number;
  /** Vencimiento exacto (ISO 8601, futuro). Si lo envías, manda sobre expira_horas. */
  expira_en?: string;
  /** A dónde vuelve el cliente al terminar. No es prueba de pago: confirma con el webhook o con obtenerEnlace. */
  success_url?: string;
  /** A dónde vuelve el cliente si el pago no puede continuar (venció, lo cancelaste o eligió "Cancelar y volver"); llega con `?estado=expirado|cancelado|rechazado`. */
  failure_url?: string;
  /** Tus datos (hasta 20 claves). */
  metadata?: Record<string, string | number | boolean>;
  /** Si reintentas con la misma llave, recibes el mismo enlace (no se duplica). */
  idempotency_key?: string;
}

export interface Enlace {
  id: string;
  url: string;
  wa_deeplink?: string;
  qr_png?: string;
  monto: number;
  moneda: string;
  concepto: string | null;
  estado: 'pendiente' | 'cobrando' | 'procesando' | 'revision' | 'pagado' | 'expirado' | 'cancelado';
  expires_at: string;
  /** Sólo en consultar y listar. */
  comercio?: string;
  txn_id?: string | null;
  created_at?: string;
  paid_at?: string | null;
  /** Propina que agregó quien pagó (Bs; 0 si no agregó). `monto` no la incluye. Sólo en consultar y listar. */
  propina?: number;
  /** Lo debitado: monto + propina. Sólo en consultar y listar. */
  total?: number;
  /** Código del comprobante (QP-XXXXX-XXXXX), sólo si el enlace está pagado (al consultar y al listar). Se verifica en pagaconquickpay.com/verificar. */
  codigo_verificacion?: string | null;
  /** Quien pagó: alta_cliente = true si marcó "Quiero ser cliente de <comercio> y recibir sus novedades". Sólo en consultar y listar. */
  pagador?: { alta_cliente: boolean };
  /** Sólo en la respuesta de crear. */
  mode?: 'test' | 'live';
  metadata?: Record<string, string | number | boolean> | null;
  /** true si repetiste la creación con la misma idempotency_key. */
  idempotent_replay?: boolean;
}

export interface FiltroEnlaces {
  estado?: Enlace['estado'] | Enlace['estado'][];
  desde?: Date | string | number;
  hasta?: Date | string | number;
  /** Concepto, nombre de quien pagó, referencia, id o código de verificación. */
  q?: string;
  /** Monto del enlace en Bs (sin propina), incluidos. */
  min?: number;
  max?: number;
  con_propina?: boolean;
  mode?: 'test' | 'live';
  origen?: 'api' | 'panel' | 'mi_pagina' | 'boton' | 'whatsapp';
  /** Por omisión 50, máximo 200. */
  limit?: number;
  offset?: number;
}

export interface CrearLoteRequest {
  nombre?: string;
  expira_horas?: number;
  idempotency_key?: string;
  items: { monto: number; concepto: string; referencia_externa?: string; cliente?: { nombre?: string; telefono?: string }; idempotency_key?: string }[];
}

export interface Lote {
  id: string;
  total: number;
  mode: 'test' | 'live';
  items: { n: number; link_id: string; url: string; referencia_externa: string | null; monto: number; concepto: string; estado: string; paid_at: string | null }[];
  [k: string]: unknown;
}

export interface QuickPayOptions {
  /** Base URL del API. Por omisión "https://pay.pagaconquickpay.com". 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 `QuickPayError`
   * 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 QuickPay {
  private readonly baseUrl: string;
  private readonly apiKey: string;
  private readonly lanzarEnRechazo: boolean;
  private readonly timeoutMs: number;
  private readonly _fetch: typeof fetch;

  constructor(opts: QuickPayOptions) {
    if (!opts?.apiKey) throw new QuickPayError('Falta apiKey (formato "<comercio>:<api_key>").');
    if (!opts.apiKey.includes(':')) {
      throw new QuickPayError('apiKey debe tener el formato "<comercio>:<api_key>".');
    }
    this.baseUrl = (opts.baseUrl ?? 'https://pay.pagaconquickpay.com').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 QuickPayError('fetch no disponible: usa Node ≥ 18 o pasa { fetch } en las opciones.');
    }
    this._fetch = f.bind(globalThis);
  }

  // ── Comercios: enlaces de pago y cobro por lote ─────────────────────────

  /** Crea un enlace de pago (POST /links). El cliente paga en la `url` que devuelve. */
  async crearEnlace(req: CrearEnlaceRequest): Promise<Enlace> {
    return this.request<Enlace>('POST', '/links', { body: { moneda: 'VES', ...req } });
  }

  /** Consulta un enlace (GET /links/:id): su `estado` dice si ya se pagó. */
  async obtenerEnlace(id: string): Promise<Enlace> {
    return this.request<Enlace>('GET', `/links/${encodeURIComponent(id)}`, {});
  }

  /**
   * Lista tus enlaces (GET /links), del más nuevo al más viejo. Todos los filtros son opcionales y se combinan.
   * `estado` acepta uno o varios; `desde`/`hasta` (Date, ISO o milisegundos) van sobre la fecha del pago o, si no se pagó,
   * la de creación: `desde` entra y `hasta` no.
   */
  async listarEnlaces(f: FiltroEnlaces = {}): Promise<Enlace[]> {
    const fecha = (v: Date | string | number | undefined) => (v instanceof Date ? v.toISOString() : v === undefined ? undefined : String(v));
    return this.request<Enlace[]>('GET', '/links', { query: {
      estado: Array.isArray(f.estado) ? f.estado.join(',') : f.estado, desde: fecha(f.desde), hasta: fecha(f.hasta), q: f.q,
      min: f.min?.toString(), max: f.max?.toString(), con_propina: f.con_propina === undefined ? undefined : String(f.con_propina),
      mode: f.mode, origen: f.origen, limit: f.limit?.toString(), offset: f.offset?.toString(),
    } });
  }

  /** Cancela un enlace (POST /links/:id/cancelar). Sólo si sigue `pendiente`; si no, lanza un error 409 `no_cancelable`. */
  async cancelarEnlace(id: string): Promise<{ id: string; estado: 'cancelado' }> {
    return this.request<{ id: string; estado: 'cancelado' }>('POST', `/links/${encodeURIComponent(id)}/cancelar`, {});
  }

  /**
   * Fuerza el resultado de un enlace de PRUEBA (POST /links/:id/simular) sin abrir la página de pago. Sólo con llave
   * `sk_test_`: en un enlace de producción el servidor responde 403.
   */
  async simularEnlace(id: string, resultado: 'pagado' | 'rechazado' | 'expirado'): Promise<{ id: string; estado: string; resultado: string; simulado: true }> {
    return this.request('POST', `/links/${encodeURIComponent(id)}/simular`, { body: { resultado } });
  }

  /** Crea hasta 500 enlaces en una sola llamada (POST /lotes). */
  async crearLote(req: CrearLoteRequest): Promise<Lote> {
    return this.request<Lote>('POST', '/lotes', { body: { moneda: 'VES', ...req } });
  }

  /** Consulta un lote y el estado de cada enlace (GET /lotes/:id). */
  async obtenerLote(id: string): Promise<Lote> {
    return this.request<Lote>('GET', `/lotes/${encodeURIComponent(id)}`, {});
  }

  // ── Aliados (API directa): 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`. Debita una cuenta de QuickPay: sólo para
   *  integraciones habilitadas; con una llave de comercio sin habilitación el switch responde 403 `pagar_no_habilitado`. */
  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). Uso interno: 403 `cuenta_no_permitida` con una llave de comercio. */
  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. Uso interno: 403 `cuenta_no_permitida` con una llave de comercio. */
  async movimientos(q: MovimientosQuery): Promise<unknown> {
    if (!q?.desde) throw new QuickPayError("'desde' es requerido para movimientos.");
    return this.request('GET', '/movimientos', {
      query: { cuenta: q.cuenta, desde: q.desde, hasta: q.hasta, banco: q.banco },
    });
  }

  // ── 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 QuickPayError(
        `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 QuickPayError(`Timeout (${this.timeoutMs}ms) en ${method} ${path}.`, {
          motivo: 'DESCONOCIDO',
        });
      }
      throw new QuickPayError(`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 QuickPayError(
        `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 QuickPay;

// ─────────────────────────────────────────────────────────────────────────
//  Webhooks: verificar la firma
// ─────────────────────────────────────────────────────────────────────────

/**
 * Verifica un webhook con la firma v2: HMAC-SHA256(secreto, `${timestamp}.${cuerpo}`), cabeceras
 * `x-quicklink-signature-v2: v2=<hex>` y `x-quicklink-timestamp` (segundos). Rechaza avisos con más de
 * `toleranciaSeg` de antigüedad (por omisión 300 s) para que nadie reenvíe uno viejo.
 * `cuerpo` es el texto EXACTO recibido, antes de parsearlo.
 */
export function verificarWebhook(o: { cuerpo: string; firma: string | undefined; timestamp: string | undefined; secreto: string; toleranciaSeg?: number }): boolean {
  if (!o.firma || !o.timestamp || !o.secreto) return false;
  const ts = Number(o.timestamp);
  if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > (o.toleranciaSeg ?? 300)) return false;
  const esperado = Buffer.from('v2=' + createHmac('sha256', o.secreto).update(`${o.timestamp}.${o.cuerpo}`).digest('hex'));
  const recibido = Buffer.from(String(o.firma));
  return esperado.length === recibido.length && timingSafeEqual(esperado, recibido);
}

/** Compatibilidad con el nombre anterior del SDK (QuickLink). */
export { QuickPay as QuickLink, QuickPayError as QuickLinkError };