'use strict'; // Paynex merchant API client for Node.js (>= 18, uses global fetch). Zero dependencies. // // const { Paynex } = require('./paynex'); // const px = new Paynex({ baseUrl: 'https://pay.example.com', keyId: 'pk_…', secret: 'sk_…' }); // const p = await px.createPayment({ amount: '1500.00', merchant_ref: 'INV-1042', // return_url: 'https://shop.example.com/thanks', cancel_url: 'https://shop.example.com/cart' }, 'INV-1042-1500.00'); // res.redirect(p.checkout_url); // // Never put keyId/secret in a browser or mobile app — call this from your server only. const crypto = require('crypto'); class PaynexError extends Error { constructor(status, code, message) { super(`paynex: ${code} (${status}): ${message}`); this.name = 'PaynexError'; this.httpStatus = status; this.code = code; this.detail = message; } } class Paynex { constructor({ baseUrl, keyId, secret, timeoutMs = 30000, fetch: fetchImpl } = {}) { if (!baseUrl || !keyId || !secret) throw new Error('paynex: baseUrl, keyId and secret are required'); this.baseUrl = baseUrl.replace(/\/+$/, ''); this.keyId = keyId; this.secret = secret; this.timeoutMs = timeoutMs; this.fetch = fetchImpl || globalThis.fetch; } /** Signature recipe: hex(HMAC-SHA256(secret, ts\nnonce\nMETHOD\npath\nhex(sha256(body)))) */ static signature(secret, ts, nonce, method, path, body) { const bodyHash = crypto.createHash('sha256').update(body || '').digest('hex'); const msg = [ts, nonce, method.toUpperCase(), path, bodyHash].join('\n'); return crypto.createHmac('sha256', secret).update(msg).digest('hex'); } /** Headers for one request; path includes /v1 and excludes the query string. */ sign(method, path, body) { const ts = String(Math.floor(Date.now() / 1000)); const nonce = crypto.randomBytes(16).toString('hex'); return { Authorization: `Paynex-HMAC key_id=${this.keyId}`, 'X-PG-Timestamp': ts, 'X-PG-Nonce': nonce, 'X-PG-Signature': Paynex.signature(this.secret, ts, nonce, method, path, body), }; } async request(method, pathWithQuery, payload, idempotencyKey) { const body = payload === undefined || payload === null ? '' : JSON.stringify(payload); const path = pathWithQuery.split('?')[0]; const headers = { ...this.sign(method, path, body), 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': 'paynex-node/1.0' }; if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey; const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), this.timeoutMs); try { const res = await this.fetch(this.baseUrl + pathWithQuery, { method, headers, body: body || undefined, signal: ctl.signal }); const text = await res.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch (_) { data = null; } if (!res.ok) { const e = (data && data.error) || {}; throw new PaynexError(res.status, e.code || `http_${res.status}`, e.message || text.trim()); } return data; } finally { clearTimeout(timer); } } // ---- payments ---- createPayment(params, idempotencyKey) { if (!idempotencyKey) throw new Error('paynex: idempotencyKey is required (e.g. "-")'); return this.request('POST', '/v1/payments', params, idempotencyKey); } getPayment(id) { return this.request('GET', `/v1/payments/${encodeURIComponent(id)}`); } listPayments({ status, merchant_ref, limit, cursor } = {}) { const q = new URLSearchParams(); if (status) q.set('status', status); if (merchant_ref) q.set('merchant_ref', merchant_ref); if (limit) q.set('limit', String(limit)); if (cursor) q.set('cursor', cursor); const qs = q.toString(); return this.request('GET', '/v1/payments' + (qs ? `?${qs}` : '')); } cancelPayment(id) { return this.request('POST', `/v1/payments/${encodeURIComponent(id)}/cancel`); } verifyPayment(id) { return this.request('POST', `/v1/payments/${encodeURIComponent(id)}/verify`); } // ---- refunds / store ---- createRefund({ payment_id, amount, reason }, idempotencyKey) { return this.request('POST', '/v1/refunds', { payment_id, amount, reason }, idempotencyKey); } getRefund(id) { return this.request('GET', `/v1/refunds/${encodeURIComponent(id)}`); } getStore() { return this.request('GET', '/v1/store'); } // ---- inbound ---- /** * Verify a webhook. `signatureHeader` = req.headers['x-pg-signature'], `rawBody` = the exact * bytes received (Buffer or string — do NOT re-serialise parsed JSON). Returns the event. */ static verifyWebhook(webhookSecret, signatureHeader, rawBody, toleranceSec = 300) { let t, v1; for (const part of String(signatureHeader || '').split(',')) { const [k, v] = part.trim().split('='); if (k === 't') t = v; else if (k === 'v1') v1 = v; } if (!t || !v1) throw new PaynexError(401, 'bad_signature', 'missing t/v1'); if (toleranceSec > 0 && Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) throw new PaynexError(401, 'bad_signature', 'timestamp outside tolerance'); const expected = crypto.createHmac('sha256', webhookSecret).update(t + '.').update(rawBody).digest(); const got = Buffer.from(String(v1).toLowerCase(), 'hex'); if (got.length !== expected.length || !crypto.timingSafeEqual(got, expected)) throw new PaynexError(401, 'bad_signature', 'signature mismatch'); return JSON.parse(Buffer.isBuffer(rawBody) ? rawBody.toString('utf8') : rawBody); } /** Express middleware: app.post('/paynex/webhook', express.raw({ type: () => true }), Paynex.webhookHandler(secret, async (event) => {…})) */ static webhookHandler(webhookSecret, fn) { return async (req, res) => { let ev; try { ev = Paynex.verifyWebhook(webhookSecret, req.headers['x-pg-signature'], req.body); } catch (e) { res.status(401).send(String(e.message)); return; } try { await fn(ev, req); res.status(200).end(); } catch (e) { res.status(500).send('handler error'); } // Paynex retries }; } /** Verify the signed return redirect (?pg_ref&pg_status&pg_ts&pg_sig). UX only — confirm with getPayment(). */ static verifyReturn(webhookSecret, query, toleranceSec = 300) { const { pg_ref, pg_status, pg_ts, pg_sig } = query || {}; if (!pg_ref || !pg_status || !pg_ts || !pg_sig) return null; if (toleranceSec > 0 && Math.abs(Date.now() / 1000 - Number(pg_ts)) > toleranceSec) return null; const expected = crypto.createHmac('sha256', webhookSecret).update(`${pg_ref}.${pg_status}.${pg_ts}`).digest(); const got = Buffer.from(String(pg_sig).toLowerCase(), 'hex'); if (got.length !== expected.length || !crypto.timingSafeEqual(got, expected)) return null; return { paymentId: pg_ref, status: pg_status, at: new Date(Number(pg_ts) * 1000) }; } } module.exports = { Paynex, PaynexError };