/** * 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 `:`. * 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; /** 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 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 | 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 ":". 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 ":").'); if (!opts.apiKey.includes(':')) { throw new QuickPayError('apiKey debe tener el formato ":".'); } 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 { return this.request('POST', '/links', { body: { moneda: 'VES', ...req } }); } /** Consulta un enlace (GET /links/:id): su `estado` dice si ya se pagó. */ async obtenerEnlace(id: string): Promise { return this.request('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 { const fecha = (v: Date | string | number | undefined) => (v instanceof Date ? v.toISOString() : v === undefined ? undefined : String(v)); return this.request('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 { return this.request('POST', '/lotes', { body: { moneda: 'VES', ...req } }); } /** Consulta un lote y el estado de cada enlace (GET /lotes/:id). */ async obtenerLote(id: string): Promise { return this.request('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 { 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`. 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 { 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). Uso interno: 403 `cuenta_no_permitida` con una llave de comercio. */ 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. Uso interno: 403 `cuenta_no_permitida` con una llave de comercio. */ async movimientos(q: MovimientosQuery): Promise { 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 { const res = await this.request('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( 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 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=` 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 };