Docs · SDK Descargar quicklink.ts
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.tsjs
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 → LIQUIDADACó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;