/** * Recarga.do — Librería oficial para Node.js * ========================================== * * Un solo archivo. Sin dependencias. Node 12 o superior. * * INSTALACIÓN COMPLETA: * * const { RecargaDo } = require('./recargado'); * * USO COMPLETO: * * const rd = new RecargaDo('su_api_key', 'su_api_secret'); * * const venta = await rd.recargar(1, '8095551234', 100, 'FACTURA-00123'); * * if (venta.exitosa()) { * console.log('Recarga entregada. Código:', venta.authCode); * } else if (venta.pendiente()) { * console.log('En verificación. NO cobre todavía.'); * } else { * console.log('Rechazada:', venta.mensaje); * } * * Esta librería se encarga sola de las cosas que, hechas a mano, cuestan dinero: * * - Genera y reutiliza la clave de idempotencia a partir de SU número de factura, * de modo que un reintento nunca recarga dos veces al mismo cliente. * - Usa un tiempo de espera de 40 segundos en las recargas (la API espera hasta * 30 al proveedor; un timeout corto crea pendientes innecesarios). * - Reintenta sola ante cortes de red y errores del servidor, siempre con la misma * clave. Nunca reintenta un error de credenciales, porque eso bloquea la cuenta. * - Distingue los tres desenlaces posibles —entregada, en verificación, rechazada— * en vez de los dos que la gente suele programar. * * IMPORTANTE: esto corre en su servidor, nunca en el navegador. Un api_secret * dentro de un bundle de JavaScript es dinero al alcance de cualquiera. * * Documentación: https://doc.recarga.do * * @version 1.0.0 */ 'use strict'; const https = require('https'); const http = require('http'); const crypto = require('crypto'); const { URL } = require('url'); const VERSION = '1.0.0'; const BASE_URL = 'https://api.recarga.do'; // Credenciales públicas del ambiente de pruebas. No mueven dinero real. const SANDBOX_KEY = 'rk_sbx_8c4cacb52c6b8f65bf389480ce3f8891'; const SANDBOX_SECRET = 'sk_sbx_a8e4de1dbd5e135a4be191cfd8901f386e541279a6550b1d30af9fbeaa65bbd0'; // Desenlaces en los que NO se sabe si la recarga se entregó. El importe sigue // retenido. Nunca se dan por fallidos: eso llevaría a cobrar dos veces. const CODIGOS_DESCONOCIDOS = ['INTERNAL_ERROR', 'SERVICE_TIMEOUT', 'SERVICE_UNAVAILABLE']; // ----------------------------------------------------------------------------- // ERROR // ----------------------------------------------------------------------------- /** * Se lanza cuando algo impide operar: credenciales incorrectas, permisos * insuficientes, parámetros mal formados o la API caída. * * Los rechazos normales de una recarga (número inválido, saldo insuficiente) NO * lanzan este error: llegan como un Resultado fallido, porque son desenlaces de * negocio que su aplicación debe mostrarle al cajero, no errores de programación. */ class RecargaDoError extends Error { constructor(mensaje, codigo = 'ERROR', httpStatus = 0, requestId = null, detalles = {}) { super(mensaje); this.name = 'RecargaDoError'; this.codigo = codigo; this.httpStatus = httpStatus; this.requestId = requestId; this.detalles = detalles; } } // ----------------------------------------------------------------------------- // RESULTADO DE UNA RECARGA // ----------------------------------------------------------------------------- /** * El desenlace de una recarga. Siempre es exactamente uno de tres: * * exitosa() → entregada y cobrada. Imprima el recibo. * pendiente() → resultado desconocido. El dinero está retenido, NO cobrado. * No reintente: consulte el estado. * fallida() → rechazada. El saldo ya volvió a su cuenta automáticamente. */ class Resultado { constructor() { /** @type {string} SUCCESS | PENDING | FAILED */ this.estado = 'PENDING'; /** * Identificador de la operación con el nombre `reference`. Es el mismo valor que * `xid`: otras pasarelas usan uno u otro nombre. Para anular basta `transactionId`. */ this.referencia = null; /** * Datos adicionales. En electricidad prepago trae `token_recarga`: los dígitos que * el cliente teclea en su medidor. Imprímalos en el recibo — ese token es el producto. */ this.detalles = {}; /** @type {number|null} Identificador de la transacción en Recarga.do */ this.transactionId = null; /** @type {string|null} Código de autorización, para el recibo del cliente */ this.authCode = null; /** @type {string|null} Referencia interna de la transacción */ this.xid = null; /** @type {number|null} */ this.monto = null; /** @type {string|null} */ this.identificador = null; /** @type {string} Mensaje en español, listo para mostrarle al cajero */ this.mensaje = ''; /** @type {string|null} Código de error estable, si fue rechazada */ this.errorCode = null; /** @type {string|null} Referencia para soporte. Guárdelo siempre. */ this.requestId = null; /** @type {string|null} La clave usada. Guárdela junto a la venta. */ this.claveIdempotencia = null; /** @type {boolean} true si la API repitió una respuesta ya emitida */ this.repetida = false; /** @type {object} Respuesta completa de la API */ this.crudo = {}; } /** ¿Se entregó la recarga? Cobre y entregue el recibo. */ exitosa() { return this.estado === 'SUCCESS'; } /** * ¿Quedó en verificación? El importe está retenido, ni cobrado ni devuelto. * Dígale al cajero «en verificación» y consulte el estado más tarde. */ pendiente() { return this.estado === 'PENDING'; } /** ¿Fue rechazada? El saldo ya volvió solo. Puede corregir y vender de nuevo. */ fallida() { return this.estado === 'FAILED'; } /** El token del medidor en electricidad prepago. null en los demás productos. */ tokenRecarga() { return this.detalles?.token_recarga ?? null; } } // ----------------------------------------------------------------------------- // CLIENTE // ----------------------------------------------------------------------------- class RecargaDo { /** * @param {string} apiKey Su clave. En producción, léala de process.env. * @param {string} apiSecret Su secreto. NUNCA lo exponga en el navegador. */ constructor(apiKey, apiSecret, baseUrl = BASE_URL) { if (!apiKey || !apiSecret) { throw new RecargaDoError( 'Faltan las credenciales. Use new RecargaDo(apiKey, apiSecret), o RecargaDo.pruebas() para el ambiente de pruebas.', 'SIN_CREDENCIALES' ); } this.apiKey = apiKey; this.apiSecret = apiSecret; this.baseUrl = baseUrl.replace(/\/+$/, ''); /** Tiempo de espera para consultas, en segundos */ this.timeoutConsulta = 15; /** * Tiempo de espera para recargas. 40 s a propósito: la API espera hasta 30 * al proveedor. Bajarlo produce operaciones PENDING que no hacían falta. */ this.timeoutRecarga = 40; /** Reintentos automáticos ante fallos de red o del servidor */ this.maxReintentos = 2; } /** * Cliente conectado al ambiente de pruebas, con credenciales públicas. * No genera recargas reales ni cobra un peso. */ static pruebas() { return new RecargaDo(SANDBOX_KEY, SANDBOX_SECRET); } // --------------------------------------------------------------------------- // LA OPERACIÓN PRINCIPAL // --------------------------------------------------------------------------- /** * Vende una recarga. * * const venta = await rd.recargar(1, '8095551234', 100, 'FACTURA-00123'); * * @param {number} servicioId Id del servicio, de servicios(). Ej.: 1 = CLARO * @param {string} identificador Teléfono, contrato o NIC, según el servicio * @param {number} monto Monto en pesos, dentro del rango del servicio * @param {string} [referencia] SU número de factura, ticket o venta. ESTO ES IMPORTANTE: * es lo que impide cobrar dos veces. Si la red se cae y * reintenta con la misma referencia, Recarga.do reconoce la * operación y devuelve el resultado original en lugar de * recargar otra vez. Debe ser única por venta, al menos * durante 24 horas. Si la omite, se genera una al azar y * usted queda responsable de guardarla (viene en * resultado.claveIdempotencia) para poder reintentar. * @param {string} [terminal] Opcional. Su caja o terminal, ej. 'CAJA-01'. * @returns {Promise} Siempre uno de tres: exitosa, pendiente o fallida. */ async recargar(servicioId, identificador, monto, referencia = null, terminal = null) { const clave = referencia !== null && referencia !== '' ? String(referencia) : uuid(); if (clave.length > 128) { throw new RecargaDoError('La referencia no puede pasar de 128 caracteres.', 'REFERENCIA_MUY_LARGA'); } const cuerpo = { service_id: parseInt(servicioId, 10), identifier: String(identificador), amount: normalizarMonto(monto), }; if (terminal) cuerpo.terminal_id = String(terminal); let r; try { r = await this._peticion('POST', '/v1/recargas', cuerpo, { 'Idempotency-Key': clave }, this.timeoutRecarga); } catch (e) { if (!(e instanceof RecargaDoError) || e.codigo !== 'SIN_CONEXION') throw e; // La red se cortó. NO sabemos si la recarga llegó a ejecutarse, así que // jamás la damos por fallida: eso llevaría a cobrarle dos veces al cliente. const res = new Resultado(); res.estado = 'PENDING'; res.errorCode = 'SIN_CONEXION'; res.claveIdempotencia = clave; res.identificador = String(identificador); res.monto = cuerpo.amount; res.mensaje = 'No hubo respuesta de Recarga.do. No se sabe si la recarga se ejecutó. ' + `NO vuelva a cobrar: reintente con la misma referencia "${clave}", ` + 'o consulte el historial de ese número antes de nada.'; return res; } return this._interpretarRecarga(r, clave); } /** * PASO 1 de la venta: pregunta qué se le puede vender a este identificador. * * Obligatorio en paquetes de datos y en facturas: las opciones dependen del número o * del contrato, y el precio lo pone el operador, no usted. * * const c = await rd.consultar(50, '8094964444'); * for (const o of c.opciones) console.log(o.nombre, o.precio); * const venta = await rd.comprar(50, '8094964444', elegida.opcion_id, 'FACT-1042'); */ async consultar(servicioId, identificador) { const r = await this._peticion('POST', '/v1/consultas', { service_id: parseInt(servicioId, 10), identifier: String(identificador), }, {}, this.timeoutConsulta); const json = r.json || {}; if (!json.success) { const e = json.error || {}; throw new RecargaDoError(e.message || 'No se pudo consultar.', e.code || 'ERROR', r.status, r.requestId, e.details || {}); } const d = json.data || {}; return { flujo: d.flujo ?? null, titular: d.titular ?? null, requiereSeleccion: !!d.requiere_seleccion, opciones: Array.isArray(d.opciones) ? d.opciones : [], monto: d.monto ?? null, sinDeuda: !!d.sin_deuda, mensaje: d.mensaje ?? '', origenDatos: d.origen_datos ?? null, requestId: r.requestId, crudo: json, hayOpciones() { return this.opciones.length > 0; }, opcion(id) { return this.opciones.find(o => String(o.opcion_id) === String(id)) ?? null; }, }; } /** * PASO 2: compra la opción elegida. Para paquetes y facturas. * * No se envía el precio: lo fija el operador y la API lo relee antes de cobrar. */ async comprar(servicioId, identificador, opcionId, referencia = null, terminal = null) { const clave = referencia !== null && referencia !== '' ? String(referencia) : uuid(); if (clave.length > 128) { throw new RecargaDoError('La referencia no puede pasar de 128 caracteres.', 'REFERENCIA_MUY_LARGA'); } const cuerpo = { service_id: parseInt(servicioId, 10), identifier: String(identificador), opcion_id: String(opcionId), }; if (terminal) cuerpo.terminal_id = String(terminal); let r; try { r = await this._peticion('POST', '/v1/recargas', cuerpo, { 'Idempotency-Key': clave }, this.timeoutRecarga); } catch (e) { if (!(e instanceof RecargaDoError) || e.codigo !== 'SIN_CONEXION') throw e; const res = new Resultado(); res.estado = 'PENDING'; res.errorCode = 'SIN_CONEXION'; res.claveIdempotencia = clave; res.identificador = String(identificador); res.mensaje = 'No hubo respuesta de Recarga.do. No se sabe si la compra se ejecutó. ' + `NO vuelva a cobrar: reintente con la misma referencia "${clave}".`; return res; } return this._interpretarRecarga(r, clave); } /** Vende cualquier servicio eligiendo solo el flujo correcto. */ async vender({ service_id, identifier, amount = null, opcion_id = null, referencia = null, terminal = null }) { if (opcion_id) return this.comprar(service_id, identifier, opcion_id, referencia, terminal); if (amount !== null) return this.recargar(service_id, identifier, amount, referencia, terminal); throw new RecargaDoError( 'Falta `amount` (monto libre) u `opcion_id` (paquetes y facturas). Llame primero a consultar().', 'DATOS_INCOMPLETOS'); } /** ¿Este servicio necesita consultar antes de vender? Cachee el catálogo. */ async requiereConsulta(servicioId) { const s = (await this.servicios()).find(x => parseInt(x.id, 10) === parseInt(servicioId, 10)); return !!(s && s.requiere_consulta); } /** * Espera a que una recarga pendiente se resuelva, consultando su estado. * * if (venta.pendiente()) { * venta = await rd.esperarResultado(venta.transactionId); * } * * @param {number} transactionId * @param {number} [segundos] Tiempo máximo de espera. Por defecto 90. */ async esperarResultado(transactionId, segundos = 90) { const limite = Date.now() + segundos * 1000; let intervalo = 3; for (;;) { const t = await this.transaccion(transactionId); let estado = String(t.status || 'PENDING').toUpperCase(); if ((estado !== 'PENDING' && estado !== 'PROCESSING') || Date.now() >= limite) { const res = new Resultado(); res.estado = estado === 'PROCESSING' ? 'PENDING' : estado; res.transactionId = t.transaction_id != null ? parseInt(t.transaction_id, 10) : transactionId; res.authCode = t.auth_code || null; res.xid = t.xid || null; res.monto = t.amount != null ? Number(t.amount) : null; res.identificador = t.identifier || null; res.mensaje = t.message || mensajePorEstado(res.estado); res.crudo = t; return res; } await dormir(intervalo * 1000); if (intervalo < 10) intervalo += 2; // espaciar para no gastar el límite de peticiones } } // --------------------------------------------------------------------------- // CONSULTAS // --------------------------------------------------------------------------- /** * Cuánto puede gastar ahora mismo. * * Valide siempre contra 'saldo_disponible', nunca contra 'saldo_actual': * el actual incluye dinero ya comprometido en operaciones en curso. */ async saldo() { return this._datos(await this._peticion('GET', '/v1/saldo')); } /** Atajo: los pesos que puede gastar ahora, como número. */ async disponible() { const s = await this.saldo(); return Number(s.saldo_disponible || 0); } /** * Qué puede vender. * * await rd.servicios(); // todo el catálogo * await rd.servicios('Telefonía'); // solo una categoría * await rd.servicios(null, 'claro'); // buscar por nombre * * El catálogo cambia rara vez: consúltelo al arrancar y guárdelo en memoria * unas horas. Consultarlo en cada pantalla agota el límite de peticiones. */ async servicios(categoria = null, buscar = null) { return this._datos(await this._peticion('GET', '/v1/servicios' + query({ categoria, q: buscar }))); } /** Las categorías disponibles: Telefonía, Electricidad, Agua, etc. */ async categorias() { return this._datos(await this._peticion('GET', '/v1/categorias')); } /** Paquetes de datos de un servicio móvil. */ async paquetes(servicioId) { return this._datos(await this._peticion('GET', `/v1/servicios/${parseInt(servicioId, 10)}/paquetes`)); } /** Detalle de una transacción. Solo ve las de su propia empresa. */ async transaccion(transactionId) { return this._datos(await this._peticion('GET', `/v1/transacciones/${parseInt(transactionId, 10)}`)); } /** * Historial, paginado. * Filtros: page, per_page (máx. 200), status, date_from, date_to, identifier. */ async transacciones(filtros = {}) { return this._datos(await this._peticion('GET', '/v1/transacciones' + query(filtros))); } /** Recibo en JSON de una transacción entregada. */ async recibo(transactionId) { return this._datos(await this._peticion('GET', `/v1/transacciones/${parseInt(transactionId, 10)}/recibo`)); } /** Datos de su empresa y, sobre todo, en qué ambiente está operando. */ async cuenta() { return this._datos(await this._peticion('GET', '/v1/cuenta')); } /** * ¿Estoy en pruebas? true significa que ninguna recarga es real. * Compruébelo antes de su primera venta de verdad. */ async enPruebas() { const c = await this.cuenta(); return Boolean(c.sandbox) || (c.ambiente && c.ambiente !== 'production'); } // =========================================================================== // A PARTIR DE AQUÍ ES MECÁNICA INTERNA. NO HACE FALTA LEERLA PARA INTEGRAR. // =========================================================================== /** Convierte la respuesta de POST /v1/recargas en uno de los tres desenlaces. */ _interpretarRecarga(r, clave) { const res = new Resultado(); res.claveIdempotencia = clave; res.requestId = r.requestId; res.repetida = r.repetida; res.crudo = r.json; const json = r.json; // 201 — entregada if (json.success && json.data) { const d = json.data; res.estado = String(d.status || 'SUCCESS').toUpperCase(); res.referencia = d.reference ?? null; res.detalles = (d.details && typeof d.details === 'object') ? d.details : {}; res.transactionId = d.transaction_id != null ? parseInt(d.transaction_id, 10) : null; res.authCode = d.auth_code || null; res.xid = d.xid || null; res.monto = d.amount != null ? Number(d.amount) : null; res.identificador = d.identifier || null; res.mensaje = d.message || json.message || 'Recarga procesada'; return res; } const error = json.error || {}; const codigo = error.code || 'ERROR'; const mensaje = error.message || 'La operación no se pudo completar.'; const det = error.details && typeof error.details === 'object' ? error.details : {}; // Flujo equivocado: se intentó vender un paquete o una factura como monto libre. // Se lanza porque no es algo que el cajero corrija reintentando: la integración // está incompleta. if (det.consultar_opciones_en) { throw new RecargaDoError( `${mensaje} Llame primero a consultar() y pase el opcion_id a comprar().`, 'REQUIERE_CONSULTA', r.status, r.requestId, det ); } // 202 — en verificación: el dinero queda retenido, ni cobrado ni devuelto if (codigo === 'TRANSACTION_PENDING' || r.status === 202) { res.estado = 'PENDING'; res.transactionId = det.transaction_id != null ? parseInt(det.transaction_id, 10) : null; res.xid = det.xid || null; res.errorCode = codigo; res.mensaje = mensaje; return res; } // Credenciales o permisos. No se ejecutó nada, y no es algo que el cajero // pueda corregir: es configuración rota. if (codigo === 'UNAUTHENTICATED' || codigo === 'FORBIDDEN') { throw new RecargaDoError(mensaje, codigo, r.status, r.requestId, det); } // Desenlaces en los que NO se sabe si la recarga se entregó. if (CODIGOS_DESCONOCIDOS.includes(codigo)) { res.estado = 'PENDING'; res.errorCode = codigo; res.mensaje = 'Recarga.do no pudo confirmar el resultado de esta operación. No vuelva a cobrar sin consultar antes su estado.'; return res; } // La misma referencia sigue procesándose. Es correcto NO reintentar. if (codigo === 'IDEMPOTENT_REQUEST_IN_PROGRESS') { res.estado = 'PENDING'; res.errorCode = codigo; res.mensaje = 'La operación anterior con esta misma referencia sigue en curso. Consulte su estado; no la repita con otra referencia.'; return res; } // Conflicto de idempotencia: esa referencia ya identifica OTRA operación. // Esta no se ejecutó, así que es seguro darla por fallida. if (codigo === 'IDEMPOTENCY_CONFLICT') { res.estado = 'FAILED'; res.errorCode = codigo; res.mensaje = `La referencia "${clave}" ya se usó para una operación distinta. ` + 'Cada venta necesita su propia referencia; revise que su numeración no se repita.'; return res; } // Fallo del servidor sin código de rechazo reconocible (500, 503, 504). // Un 502 sí se conoce: es el proveedor rechazando, y ese sí es definitivo. if (r.status >= 500 && r.status !== 502) { res.estado = 'PENDING'; res.errorCode = codigo; res.mensaje = `Recarga.do respondió con un error (${r.status}) sin confirmar el resultado. Consulte el estado antes de volver a cobrar.`; return res; } // Todo lo demás —número inválido, monto fuera de rango, saldo insuficiente, // rechazo de la telco— es un desenlace normal de venta que el cajero puede // corregir. Nunca lanza: un punto de venta no debe caerse porque alguien // tecleó un número mal. El saldo, si se reservó, ya volvió solo. res.estado = 'FAILED'; res.errorCode = codigo; res.mensaje = mensaje; res.transactionId = det.transaction_id != null ? parseInt(det.transaction_id, 10) : null; return res; } /** Extrae data de una respuesta correcta, o lanza si vino un error. */ _datos(r) { const json = r.json; if (json.success) return json.data !== undefined ? json.data : {}; const error = json.error || {}; throw new RecargaDoError( error.message || 'Error en la consulta.', error.code || 'ERROR', r.status, r.requestId, error.details || {} ); } /** * Ejecuta la petición HTTP, con reintentos seguros. * * Reintenta solo lo que se puede reintentar sin riesgo: cortes de red, * errores 5xx y exceso de peticiones, siempre repitiendo la MISMA clave de * idempotencia, de modo que la API reconozca la operación en vez de * duplicarla. Nunca reintenta un 401: diez fallos de credenciales bloquean * la cuenta 15 minutos, así que un bucle de reintentos se bloquearía solo. */ async _peticion(metodo, ruta, cuerpo = null, headersExtra = {}, timeout = null) { const espera = timeout != null ? timeout : this.timeoutConsulta; const url = this.baseUrl + ruta; const headers = Object.assign( { Authorization: `Bearer ${this.apiKey}.${this.apiSecret}`, Accept: 'application/json', 'User-Agent': `recargado-node/${VERSION}`, }, headersExtra ); let intento = 0; let ultimoFallo = ''; while (intento <= this.maxReintentos) { if (intento > 0) { await dormir((Math.pow(2, intento) - 1) * 1000); // 1 s, 3 s } intento++; const r = await enviar(metodo, url, cuerpo, headers, espera); if (r.errorRed) { ultimoFallo = r.errorRed; continue; // corte de red: reintentar con la misma clave } // Reintentable: fallo del servidor o exceso de peticiones. El 502 NO entra: // es el proveedor rechazando la operación, y eso es definitivo. if ((r.status >= 500 && r.status !== 502) || r.status === 429) { ultimoFallo = `La API respondió ${r.status}.`; if (intento <= this.maxReintentos) continue; } return r; } throw new RecargaDoError( `No se pudo contactar con Recarga.do tras ${intento} intentos. ${ultimoFallo} ` + 'Si estaba recargando, NO lo dé por fallido: consulte el historial con ' + "transacciones({ identifier: numero }) antes de volver a cobrar.", 'SIN_CONEXION', 0 ); } } // ----------------------------------------------------------------------------- // AUXILIARES // ----------------------------------------------------------------------------- /** Una sola llamada HTTP, sin dependencias externas. */ function enviar(metodo, url, cuerpo, headers, timeoutSegundos) { return new Promise((resolve) => { const u = new URL(url); const cliente = u.protocol === 'http:' ? http : https; const payload = cuerpo !== null ? JSON.stringify(cuerpo) : null; const cabeceras = Object.assign({}, headers); if (payload !== null) { cabeceras['Content-Type'] = 'application/json'; cabeceras['Content-Length'] = Buffer.byteLength(payload); } const req = cliente.request( { method: metodo, hostname: u.hostname, port: u.port || (u.protocol === 'http:' ? 80 : 443), path: u.pathname + u.search, headers: cabeceras, timeout: timeoutSegundos * 1000, }, (res) => { let datos = ''; res.setEncoding('utf8'); res.on('data', (c) => (datos += c)); res.on('end', () => { let json; try { json = JSON.parse(datos); } catch (e) { return resolve({ errorRed: `La API devolvió una respuesta que no es JSON (HTTP ${res.statusCode}).`, status: res.statusCode, json: {}, requestId: null, repetida: false, }); } resolve({ errorRed: null, status: res.statusCode, json, requestId: json.request_id || res.headers['x-request-id'] || null, repetida: String(res.headers['idempotent-replay'] || '').toLowerCase() === 'true', }); }); } ); req.on('timeout', () => { req.destroy(); resolve({ errorRed: `Sin respuesta en ${timeoutSegundos} s.`, status: 0, json: {}, requestId: null, repetida: false }); }); req.on('error', (e) => { resolve({ errorRed: e.message, status: 0, json: {}, requestId: null, repetida: false }); }); if (payload !== null) req.write(payload); req.end(); }); } function dormir(ms) { return new Promise((r) => setTimeout(r, ms)); } function query(params) { const partes = []; for (const [k, v] of Object.entries(params)) { if (v !== null && v !== undefined && v !== '') { partes.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`); } } return partes.length ? '?' + partes.join('&') : ''; } function normalizarMonto(monto) { const m = Number(monto); if (!isFinite(m)) { throw new RecargaDoError(`El monto debe ser un número. Recibido: ${JSON.stringify(monto)}`, 'MONTO_INVALIDO'); } const r = Math.round(m * 100) / 100; if (r <= 0) throw new RecargaDoError('El monto debe ser mayor que cero.', 'MONTO_INVALIDO'); return r; } function mensajePorEstado(estado) { if (estado === 'SUCCESS') return 'Recarga entregada.'; if (estado === 'FAILED') return 'Recarga rechazada. El saldo fue devuelto.'; return 'Operación en verificación.'; } function uuid() { if (crypto.randomUUID) return crypto.randomUUID(); const b = crypto.randomBytes(16); b[6] = (b[6] & 0x0f) | 0x40; b[8] = (b[8] & 0x3f) | 0x80; const h = b.toString('hex'); return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`; } module.exports = { RecargaDo, RecargaDoError, Resultado };