/** * 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 `:`. * 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 ":". 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 ":").'); if (!opts.apiKey.includes(':')) { throw new QuickLinkError('apiKey debe tener el formato ":".'); } 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 { return this.dinero('/cobrar', req); } /** Solicitar OTP al banco del pagador (paso previo a `cobrar` con token, TTL ~120s). */ async solicitarToken(req: TokenRequest): Promise { return this.dinero('/cobrar/token', req); } /** Pagar (enviar dinero a un beneficiario). El riel lo elige `via`. */ async pagar(req: PagarRequest): Promise { return this.dinero('/pagar', req); } /** Consultar el estado actualizado de una operación previa (PENDIENTE/LIQUIDADA/RECHAZADA). */ async consultar(req: ConsultarRequest): Promise { // /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('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 { 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 { 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 { return this.request('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 { const res = await this.request('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( method: 'GET' | 'POST', path: string, opts: { body?: unknown; query?: Record }, ): Promise { 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 = { '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;