@bhooai/nexus-payments 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/package.json +20 -0
- package/src/errors.ts +15 -0
- package/src/http.ts +53 -0
- package/src/index.ts +106 -0
- package/src/providers/PayPalProvider.ts +192 -0
- package/src/providers/PayUProvider.ts +164 -0
- package/src/providers/PayoneerProvider.ts +122 -0
- package/src/providers/RazorpayProvider.ts +184 -0
- package/src/providers/SkrillProvider.ts +117 -0
- package/src/signature.ts +24 -0
- package/src/types.ts +150 -0
- package/src/webhooks.ts +58 -0
- package/tests/payments.test.ts +290 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
3
|
+
import {
|
|
4
|
+
createPayments,
|
|
5
|
+
RazorpayProvider,
|
|
6
|
+
PayPalProvider,
|
|
7
|
+
PayUProvider,
|
|
8
|
+
SkrillProvider,
|
|
9
|
+
PayoneerProvider,
|
|
10
|
+
WebhookRouter,
|
|
11
|
+
payuRequestHash,
|
|
12
|
+
payuResponseHash,
|
|
13
|
+
hmacSha256Hex,
|
|
14
|
+
sha512Hex,
|
|
15
|
+
md5Hex,
|
|
16
|
+
type HttpTransport,
|
|
17
|
+
type HttpRequest,
|
|
18
|
+
type HttpResponse,
|
|
19
|
+
} from '../src/index.js';
|
|
20
|
+
|
|
21
|
+
/** Build a mock transport that routes by URL substring to canned handlers. */
|
|
22
|
+
function mockTransport(routes: { match: string; respond: (req: HttpRequest) => HttpResponse }[]): HttpTransport {
|
|
23
|
+
return async (req) => {
|
|
24
|
+
for (const r of routes) {
|
|
25
|
+
if (req.url.includes(r.match)) return r.respond(req);
|
|
26
|
+
}
|
|
27
|
+
return { status: 404, body: JSON.stringify({ error: `no mock for ${req.url}` }) };
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A capturing transport: records calls, delegates to an inner transport. */
|
|
32
|
+
function capturing(inner: HttpTransport): { transport: HttpTransport; calls: HttpRequest[] } {
|
|
33
|
+
const calls: HttpRequest[] = [];
|
|
34
|
+
return {
|
|
35
|
+
calls,
|
|
36
|
+
transport: async (req) => {
|
|
37
|
+
calls.push(req);
|
|
38
|
+
return inner(req);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('signature helpers', () => {
|
|
44
|
+
it('hmacSha256Hex matches node crypto', () => {
|
|
45
|
+
expect(hmacSha256Hex('secret', 'msg')).toBe(
|
|
46
|
+
createHmac('sha256', 'secret').update('msg').digest('hex'),
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
it('sha512Hex matches node crypto', () => {
|
|
50
|
+
expect(sha512Hex('abc')).toBe(createHash('sha512').update('abc').digest('hex'));
|
|
51
|
+
});
|
|
52
|
+
it('md5Hex matches node crypto', () => {
|
|
53
|
+
expect(md5Hex('abc')).toBe(createHash('md5').update('abc').digest('hex'));
|
|
54
|
+
});
|
|
55
|
+
it('PayU request hash joins 10 udf fields + salt', () => {
|
|
56
|
+
const h = payuRequestHash('KEY', 'SALT', {
|
|
57
|
+
txnid: 't1', amount: '10.00', productinfo: 'pi', firstname: 'fn', email: 'e@x.com', udf: ['', '', '', '', '', '', '', '', '', ''],
|
|
58
|
+
});
|
|
59
|
+
// Equals sha512 of the exact 17-field pipe-joined sequence (10 udf fields).
|
|
60
|
+
const manual = sha512Hex(['KEY', 't1', '10.00', 'pi', 'fn', 'e@x.com', '', '', '', '', '', '', '', '', '', '', 'SALT'].join('|'));
|
|
61
|
+
expect(h).toBe(manual);
|
|
62
|
+
// And it's stable (deterministic).
|
|
63
|
+
expect(h).toHaveLength(128);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('RazorpayProvider', () => {
|
|
68
|
+
const cfg = { enabled: true, sandbox: true, keyId: 'rzp_key', keySecret: 'rzp_secret', webhookSecret: 'wh_secret' };
|
|
69
|
+
function provider(routes: { match: string; respond: (req: HttpRequest) => HttpResponse }[]) {
|
|
70
|
+
return new RazorpayProvider(cfg as any, mockTransport(routes));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
it('creates an order (paise conversion + status mapping)', async () => {
|
|
74
|
+
const p = provider([{ match: '/orders', respond: () => ({ status: 200, body: JSON.stringify({ id: 'order_123', status: 'created', amount: 1000, currency: 'INR', receipt: 'ref1' }) }) }]);
|
|
75
|
+
const order = await p.createOrder({ amount: 10, currency: 'INR', reference: 'ref1' });
|
|
76
|
+
expect(order.id).toBe('order_123');
|
|
77
|
+
expect(order.status).toBe('created');
|
|
78
|
+
expect(order.amount).toBe(10);
|
|
79
|
+
expect(order.reference).toBe('ref1');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('verifies a webhook with a valid HMAC signature', async () => {
|
|
83
|
+
const p = provider([]);
|
|
84
|
+
const raw = JSON.stringify({ event: 'payment.captured', payload: { x: 1 } });
|
|
85
|
+
const sig = hmacSha256Hex('wh_secret', raw);
|
|
86
|
+
const ev = await p.verifyWebhook({ rawBody: raw, headers: { 'x-razorpay-signature': sig } });
|
|
87
|
+
expect(ev.verified).toBe(true);
|
|
88
|
+
expect(ev.event).toBe('payment.captured');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('rejects a tampered signature', async () => {
|
|
92
|
+
const p = provider([]);
|
|
93
|
+
const ev = await p.verifyWebhook({ rawBody: '{"a":1}', headers: { 'x-razorpay-signature': 'deadbeef' } });
|
|
94
|
+
expect(ev.verified).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('maps paid + failed order statuses', async () => {
|
|
98
|
+
const p = provider([
|
|
99
|
+
{ match: '/orders/paid', respond: () => ({ status: 200, body: JSON.stringify({ id: 'paid', status: 'paid', amount: 1000, currency: 'INR' }) }) },
|
|
100
|
+
{ match: '/orders/failed', respond: () => ({ status: 200, body: JSON.stringify({ id: 'failed', status: 'failed', amount: 1000, currency: 'INR' }) }) },
|
|
101
|
+
]);
|
|
102
|
+
expect((await p.getOrderStatus('paid')).status).toBe('paid');
|
|
103
|
+
expect((await p.getOrderStatus('failed')).status).toBe('failed');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('PayPalProvider', () => {
|
|
108
|
+
const cfg = { enabled: true, sandbox: true, clientId: 'cid', clientSecret: 'csec', webhookId: 'wh-1' };
|
|
109
|
+
function provider(routes: { match: string; respond: (req: HttpRequest) => HttpResponse }[]) {
|
|
110
|
+
const t = mockTransport(routes);
|
|
111
|
+
const cap = capturing(t);
|
|
112
|
+
return { p: new PayPalProvider(cfg as any, cap.transport), calls: cap.calls };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
it('caches the OAuth2 token across calls', async () => {
|
|
116
|
+
const { p, calls } = provider([
|
|
117
|
+
{ match: '/oauth2/token', respond: () => ({ status: 200, body: JSON.stringify({ access_token: 'tok', expires_in: 3600 }) }) },
|
|
118
|
+
{ match: '/v2/checkout/orders', respond: () => ({ status: 200, body: JSON.stringify({ id: 'o1', status: 'CREATED', links: [{ rel: 'approve', href: 'https://approve' }] }) }) },
|
|
119
|
+
]);
|
|
120
|
+
await p.createOrder({ amount: 10, currency: 'USD', reference: 'r1' });
|
|
121
|
+
await p.createOrder({ amount: 20, currency: 'USD', reference: 'r2' });
|
|
122
|
+
const tokenCalls = calls.filter((c) => c.url.includes('/oauth2/token')).length;
|
|
123
|
+
expect(tokenCalls).toBe(1); // token reused
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('createOrder returns the approve URL', async () => {
|
|
127
|
+
const { p } = provider([
|
|
128
|
+
{ match: '/oauth2/token', respond: () => ({ status: 200, body: JSON.stringify({ access_token: 'tok', expires_in: 3600 }) }) },
|
|
129
|
+
{ match: '/v2/checkout/orders', respond: () => ({ status: 200, body: JSON.stringify({ id: 'o1', status: 'CREATED', links: [{ rel: 'approve', href: 'https://approve' }] }) }) },
|
|
130
|
+
]);
|
|
131
|
+
const order = await p.createOrder({ amount: 10, currency: 'USD', reference: 'r1' });
|
|
132
|
+
expect(order.paymentUrl).toBe('https://approve');
|
|
133
|
+
expect(order.status).toBe('created');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('capture maps COMPLETED', async () => {
|
|
137
|
+
const { p } = provider([
|
|
138
|
+
{ match: '/oauth2/token', respond: () => ({ status: 200, body: JSON.stringify({ access_token: 'tok', expires_in: 3600 }) }) },
|
|
139
|
+
{ match: '/capture', respond: () => ({ status: 200, body: JSON.stringify({ id: 'o1', status: 'COMPLETED', purchase_units: [{ reference_id: 'r1', payments: { captures: [{ id: 'cap1', amount: { value: '10.00', currency_code: 'USD' } }] } }] }) }) },
|
|
140
|
+
]);
|
|
141
|
+
const order = await p.capture({ orderId: 'o1' });
|
|
142
|
+
expect(order.status).toBe('captured');
|
|
143
|
+
expect(order.paymentId).toBe('cap1');
|
|
144
|
+
expect(order.amount).toBe(10);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('verifies a webhook via the verify-webhook-signature API', async () => {
|
|
148
|
+
const { p } = provider([
|
|
149
|
+
{ match: '/oauth2/token', respond: () => ({ status: 200, body: JSON.stringify({ access_token: 'tok', expires_in: 3600 }) }) },
|
|
150
|
+
{ match: '/verify-webhook-signature', respond: () => ({ status: 200, body: JSON.stringify({ verification_status: 'SUCCESS' }) }) },
|
|
151
|
+
]);
|
|
152
|
+
const ev = await p.verifyWebhook({
|
|
153
|
+
rawBody: JSON.stringify({ event_type: 'CHECKOUT.ORDER.APPROVED' }),
|
|
154
|
+
headers: {
|
|
155
|
+
'paypal-auth-algo': 'SHA256withRSA',
|
|
156
|
+
'paypal-cert-url': 'https://cert',
|
|
157
|
+
'paypal-transmission-id': 't1',
|
|
158
|
+
'paypal-transmission-sig': 'sig',
|
|
159
|
+
'paypal-transmission-time': '2024-01-01',
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
expect(ev.verified).toBe(true);
|
|
163
|
+
expect(ev.event).toBe('CHECKOUT.ORDER.APPROVED');
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('PayUProvider', () => {
|
|
168
|
+
const cfg = { enabled: true, sandbox: true, merchantKey: 'KEY', salt: 'SALT' };
|
|
169
|
+
const p = new PayUProvider(cfg as any, mockTransport([]));
|
|
170
|
+
|
|
171
|
+
it('createOrder builds a checkout URL + request hash', async () => {
|
|
172
|
+
const order = await p.createOrder({ amount: 10, currency: 'INR', reference: 't1', description: 'pi', customer: { name: 'fn', email: 'e@x.com' } });
|
|
173
|
+
expect(order.paymentUrl).toContain('test.payu.in');
|
|
174
|
+
const params = order.raw as Record<string, string>;
|
|
175
|
+
const expected = payuRequestHash('KEY', 'SALT', { txnid: 't1', amount: '10.00', productinfo: 'pi', firstname: 'fn', email: 'e@x.com', udf: ['', '', '', '', '', '', '', '', '', ''] });
|
|
176
|
+
expect(params.hash).toBe(expected);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('verifies a well-formed response hash, rejects a tampered one', async () => {
|
|
180
|
+
const fields = { status: 'success', txnid: 't1', amount: '10.00', productinfo: 'pi', firstname: 'fn', email: 'e@x.com' };
|
|
181
|
+
const udf = ['', '', '', '', '', '', '', '', '', ''];
|
|
182
|
+
const goodHash = payuResponseHash('KEY', 'SALT', { ...fields, udf });
|
|
183
|
+
const goodBody = `status=success&txnid=t1&amount=10.00&productinfo=pi&firstname=fn&email=e%40x.com&hash=${goodHash}`;
|
|
184
|
+
const ev = await p.verifyWebhook({ rawBody: goodBody, headers: {} });
|
|
185
|
+
expect(ev.verified).toBe(true);
|
|
186
|
+
expect(ev.event).toBe('payment.success');
|
|
187
|
+
|
|
188
|
+
const badBody = `status=failed&txnid=t1&amount=10.00&productinfo=pi&firstname=fn&email=e%40x.com&hash=${goodHash}`;
|
|
189
|
+
const ev2 = await p.verifyWebhook({ rawBody: badBody, headers: {} });
|
|
190
|
+
expect(ev2.verified).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('SkrillProvider', () => {
|
|
195
|
+
const cfg = { enabled: true, sandbox: true, merchantEmail: 'mer@x.com', secretWord: 'sekret' };
|
|
196
|
+
const p = new SkrillProvider(cfg as any, mockTransport([]));
|
|
197
|
+
|
|
198
|
+
it('createOrder builds a pay.skrill.com URL', async () => {
|
|
199
|
+
const order = await p.createOrder({ amount: 10, currency: 'EUR', reference: 't1' });
|
|
200
|
+
expect(order.paymentUrl).toContain('https://pay.skrill.com/');
|
|
201
|
+
expect(order.paymentUrl).toContain('pay_to_email=mer%40x.com');
|
|
202
|
+
expect(order.paymentUrl).toContain('amount=10.00');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('verifies md5sig (uppercase), rejects tampered', async () => {
|
|
206
|
+
const fields = { mb_transaction_id: 't1', amount: '10.00', currency: 'EUR' };
|
|
207
|
+
const sig = md5Hex(`mer@x.comsekrett110.00EUR`).toUpperCase();
|
|
208
|
+
const body = `mb_transaction_id=t1&amount=10.00¤cy=EUR&md5sig=${sig}&status=2`;
|
|
209
|
+
const ev = await p.verifyWebhook({ rawBody: body, headers: {} });
|
|
210
|
+
expect(ev.verified).toBe(true);
|
|
211
|
+
|
|
212
|
+
const body2 = `mb_transaction_id=t1&amount=99.00¤cy=EUR&md5sig=${sig}&status=2`;
|
|
213
|
+
const ev2 = await p.verifyWebhook({ rawBody: body2, headers: {} });
|
|
214
|
+
expect(ev2.verified).toBe(false);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('PayoneerProvider', () => {
|
|
219
|
+
const cfg = { enabled: true, sandbox: true, programId: 'prog1', apiKey: 'apiK', webhookSecret: 'wh' };
|
|
220
|
+
const p = new PayoneerProvider(cfg as any, mockTransport([]));
|
|
221
|
+
|
|
222
|
+
it('createOrder signs the canonical query', async () => {
|
|
223
|
+
const order = await p.createOrder({ amount: 10, currency: 'USD', reference: 'r1' });
|
|
224
|
+
const params = order.raw as Record<string, string>;
|
|
225
|
+
// canonical = sorted(query without signature) ; signature = hmac(apiKey, canonical)
|
|
226
|
+
const sorted = Object.entries(params).filter(([k]) => k !== 'signature').sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([k, v]) => `${k}=${v}`).join('&');
|
|
227
|
+
expect(params.signature).toBe(hmacSha256Hex('apiK', sorted));
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('verifies the HMAC webhook signature, rejects tampered', async () => {
|
|
231
|
+
const raw = JSON.stringify({ event: 'payment.completed', amount: 10 });
|
|
232
|
+
const sig = hmacSha256Hex('wh', raw);
|
|
233
|
+
const ev = await p.verifyWebhook({ rawBody: raw, headers: { 'x-payoneer-signature': sig } });
|
|
234
|
+
expect(ev.verified).toBe(true);
|
|
235
|
+
const ev2 = await p.verifyWebhook({ rawBody: raw + 'x', headers: { 'x-payoneer-signature': sig } });
|
|
236
|
+
expect(ev2.verified).toBe(false);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('WebhookRouter + createPayments factory', () => {
|
|
241
|
+
it('createPayments only instantiates enabled providers', () => {
|
|
242
|
+
const svc = createPayments({
|
|
243
|
+
razorpay: { enabled: true, sandbox: true, keyId: 'k', keySecret: 's' },
|
|
244
|
+
paypal: { enabled: false, sandbox: true, clientId: '', clientSecret: '' },
|
|
245
|
+
});
|
|
246
|
+
expect([...svc.providers.keys()]).toEqual(['razorpay']);
|
|
247
|
+
expect(() => svc.get('paypal')).toThrow();
|
|
248
|
+
expect(svc.get('razorpay').name).toBe('razorpay');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('webhook router dispatches verified events and ACKs 200', async () => {
|
|
252
|
+
const fakeProvider = {
|
|
253
|
+
name: 'fake',
|
|
254
|
+
async createOrder() { return {} as any; },
|
|
255
|
+
async capture() { return {} as any; },
|
|
256
|
+
async refund() { return {} as any; },
|
|
257
|
+
async getOrderStatus() { return {} as any; },
|
|
258
|
+
async verifyWebhook() { return { verified: true, event: 'payment.captured', provider: 'fake' }; },
|
|
259
|
+
};
|
|
260
|
+
const providers = new Map([['fake', fakeProvider as any]]);
|
|
261
|
+
let received: string | undefined;
|
|
262
|
+
const router = new WebhookRouter(providers, (ev) => { received = ev.event; });
|
|
263
|
+
let sentStatus = 0; let sentBody: unknown;
|
|
264
|
+
await router.handler({
|
|
265
|
+
params: { provider: 'fake' },
|
|
266
|
+
headers: {},
|
|
267
|
+
body: '{}',
|
|
268
|
+
state: {},
|
|
269
|
+
json(data, status) { sentStatus = status ?? 200; sentBody = data; },
|
|
270
|
+
} as any);
|
|
271
|
+
expect(received).toBe('payment.captured');
|
|
272
|
+
expect(sentStatus).toBe(200);
|
|
273
|
+
expect((sentBody as { ok: boolean }).ok).toBe(true);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('webhook router returns 401 on bad signature', async () => {
|
|
277
|
+
const fakeProvider = { name: 'fake', async verifyWebhook() { return { verified: false, provider: 'fake' }; }, async createOrder() { return {} as any; }, async capture() { return {} as any; }, async refund() { return {} as any; }, async getOrderStatus() { return {} as any; } };
|
|
278
|
+
const router = new WebhookRouter(new Map([['fake', fakeProvider as any]]), () => {});
|
|
279
|
+
let sentStatus = 0;
|
|
280
|
+
await router.handler({ params: { provider: 'fake' }, headers: {}, body: '', state: {}, json: (_d, s) => { sentStatus = s ?? 200; } } as any);
|
|
281
|
+
expect(sentStatus).toBe(401);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('webhook router returns 404 for unknown provider', async () => {
|
|
285
|
+
const router = new WebhookRouter(new Map(), () => {});
|
|
286
|
+
let sentStatus = 0;
|
|
287
|
+
await router.handler({ params: { provider: 'nope' }, headers: {}, body: '', state: {}, json: (_d, s) => { sentStatus = s ?? 200; } } as any);
|
|
288
|
+
expect(sentStatus).toBe(404);
|
|
289
|
+
});
|
|
290
|
+
});
|
package/tsconfig.json
ADDED