@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,122 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PaymentProvider,
|
|
3
|
+
CreateOrderInput,
|
|
4
|
+
Order,
|
|
5
|
+
CaptureInput,
|
|
6
|
+
RefundInput,
|
|
7
|
+
RefundResult,
|
|
8
|
+
WebhookVerifyInput,
|
|
9
|
+
WebhookEvent,
|
|
10
|
+
HttpTransport,
|
|
11
|
+
OrderStatus,
|
|
12
|
+
} from '../types.js';
|
|
13
|
+
import { hmacSha256Hex, safeEqual } from '../signature.js';
|
|
14
|
+
import { PaymentError } from '../errors.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Payoneer (happy-path). Payoneer's API requires a registered partner program
|
|
18
|
+
* and has no public sandbox without provisioned credentials, so this adapter is
|
|
19
|
+
* structurally complete but verified by signature tests rather than live calls.
|
|
20
|
+
*
|
|
21
|
+
* The flow modeled here: the merchant builds a signed payment request (HMAC over
|
|
22
|
+
* the canonical params) and redirects to a Payoneer checkout URL; Payoneer posts
|
|
23
|
+
* a webhook signed with the same shared secret (HMAC-SHA256 over the raw body).
|
|
24
|
+
*/
|
|
25
|
+
interface PayoneerConfig {
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
sandbox: boolean;
|
|
28
|
+
programId: string;
|
|
29
|
+
apiKey: string;
|
|
30
|
+
/** Shared secret for webhook HMAC verification. */
|
|
31
|
+
webhookSecret?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function checkoutUrl(sandbox: boolean, programId: string): string {
|
|
35
|
+
return sandbox
|
|
36
|
+
? `https://api.sandbox.payoneer.com/checkout/${programId}`
|
|
37
|
+
: `https://api.payoneer.com/checkout/${programId}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function mapStatus(status: string): OrderStatus {
|
|
41
|
+
switch (status) {
|
|
42
|
+
case 'initiated': return 'created';
|
|
43
|
+
case 'pending': return 'pending';
|
|
44
|
+
case 'authorized': return 'authorized';
|
|
45
|
+
case 'completed': case 'paid': return 'captured';
|
|
46
|
+
case 'failed': return 'failed';
|
|
47
|
+
case 'cancelled': return 'cancelled';
|
|
48
|
+
case 'refunded': return 'refunded';
|
|
49
|
+
default: return 'pending';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class PayoneerProvider implements PaymentProvider {
|
|
54
|
+
readonly name = 'payoneer';
|
|
55
|
+
private readonly cfg: PayoneerConfig;
|
|
56
|
+
private readonly transport: HttpTransport;
|
|
57
|
+
|
|
58
|
+
constructor(cfg: PayoneerConfig, transport: HttpTransport) {
|
|
59
|
+
this.cfg = cfg;
|
|
60
|
+
this.transport = transport;
|
|
61
|
+
if (!cfg.programId || !cfg.apiKey) throw new PaymentError('payoneer programId/apiKey required', { code: 'CONFIG', provider: 'payoneer' });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async createOrder(input: CreateOrderInput): Promise<Order> {
|
|
65
|
+
const params = new URLSearchParams();
|
|
66
|
+
params.set('program_id', this.cfg.programId);
|
|
67
|
+
params.set('reference', input.reference);
|
|
68
|
+
params.set('amount', input.amount.toFixed(2));
|
|
69
|
+
params.set('currency', input.currency);
|
|
70
|
+
if (input.customer?.email) params.set('payee_email', input.customer.email);
|
|
71
|
+
if (input.returnUrl) params.set('return_url', input.returnUrl);
|
|
72
|
+
// Canonical signature: HMAC-SHA256(apiKey, sorted query string).
|
|
73
|
+
const canonical = canonicalQuery(params);
|
|
74
|
+
const signature = hmacSha256Hex(this.cfg.apiKey, canonical);
|
|
75
|
+
params.set('signature', signature);
|
|
76
|
+
return {
|
|
77
|
+
id: input.reference,
|
|
78
|
+
reference: input.reference,
|
|
79
|
+
status: 'created',
|
|
80
|
+
amount: input.amount,
|
|
81
|
+
currency: input.currency,
|
|
82
|
+
paymentUrl: `${checkoutUrl(this.cfg.sandbox, this.cfg.programId)}?${params.toString()}`,
|
|
83
|
+
raw: Object.fromEntries(params),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async capture(input: CaptureInput): Promise<Order> {
|
|
88
|
+
return this.getOrderStatus(input.orderId);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async refund(_input: RefundInput): Promise<RefundResult> {
|
|
92
|
+
return { id: `payoneer-refund`, status: 'pending', amount: 0, raw: { note: 'Payoneer refund via partner API (v1)' } };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async getOrderStatus(orderId: string): Promise<Order> {
|
|
96
|
+
return { id: orderId, reference: orderId, status: 'pending', amount: 0, currency: '', raw: { note: 'Payoneer status via webhook (v1)' } };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async verifyWebhook(input: WebhookVerifyInput): Promise<WebhookEvent> {
|
|
100
|
+
const secret = this.cfg.webhookSecret;
|
|
101
|
+
if (!secret) return { verified: false, provider: this.name, data: 'no webhookSecret configured' };
|
|
102
|
+
const sig = input.headers['x-payoneer-signature'] ?? input.headers['X-Payoneer-Signature'];
|
|
103
|
+
const raw = typeof input.rawBody === 'string' ? input.rawBody : input.rawBody.toString();
|
|
104
|
+
const expected = hmacSha256Hex(secret, raw);
|
|
105
|
+
const verified = typeof sig === 'string' && safeEqual(sig, expected);
|
|
106
|
+
let event: string | undefined;
|
|
107
|
+
let data: unknown;
|
|
108
|
+
try {
|
|
109
|
+
const parsed = JSON.parse(raw);
|
|
110
|
+
event = parsed.event ?? parsed.status;
|
|
111
|
+
data = parsed;
|
|
112
|
+
} catch {
|
|
113
|
+
data = raw;
|
|
114
|
+
}
|
|
115
|
+
return { verified, event, data, provider: this.name };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Sort params by key and encode as `k=v&k=v` (deterministic for signing). */
|
|
120
|
+
function canonicalQuery(params: URLSearchParams): string {
|
|
121
|
+
return [...params.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([k, v]) => `${k}=${v}`).join('&');
|
|
122
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PaymentProvider,
|
|
3
|
+
CreateOrderInput,
|
|
4
|
+
Order,
|
|
5
|
+
CaptureInput,
|
|
6
|
+
RefundInput,
|
|
7
|
+
RefundResult,
|
|
8
|
+
WebhookVerifyInput,
|
|
9
|
+
WebhookEvent,
|
|
10
|
+
HttpTransport,
|
|
11
|
+
OrderStatus,
|
|
12
|
+
ConnectionTest,
|
|
13
|
+
} from '../types.js';
|
|
14
|
+
import { jsonRequest, basicAuth } from '../http.js';
|
|
15
|
+
import { hmacSha256Hex, safeEqual } from '../signature.js';
|
|
16
|
+
import { PaymentError } from '../errors.js';
|
|
17
|
+
|
|
18
|
+
interface RazorpayConfig {
|
|
19
|
+
enabled: boolean;
|
|
20
|
+
sandbox: boolean;
|
|
21
|
+
keyId: string;
|
|
22
|
+
keySecret: string;
|
|
23
|
+
/** Webhook secret configured in the Razorpay dashboard. */
|
|
24
|
+
webhookSecret?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const BASE = 'https://api.razorpay.com/v1';
|
|
28
|
+
|
|
29
|
+
/** Razorpay status → unified status. */
|
|
30
|
+
function mapStatus(s: string): OrderStatus {
|
|
31
|
+
switch (s) {
|
|
32
|
+
case 'created': return 'created';
|
|
33
|
+
case 'attempted': return 'pending';
|
|
34
|
+
case 'paid': return 'paid';
|
|
35
|
+
case 'captured': return 'captured';
|
|
36
|
+
case 'failed': return 'failed';
|
|
37
|
+
case 'refunded': return 'refunded';
|
|
38
|
+
default: return 'pending';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Razorpay uses minor units (paise) for INR; for non-INR it's the smallest unit too. */
|
|
43
|
+
function toMinor(amount: number, currency: string): number {
|
|
44
|
+
// Razorpay expects amounts in the smallest currency unit. For currencies with
|
|
45
|
+
// 3 decimal places (KWD/BHD/JOD) the factor is 1000; otherwise 100. v1 handles
|
|
46
|
+
// the common 2-decimal case plus the 0-decimal case (JPY).
|
|
47
|
+
const threeDp = ['KWD', 'BHD', 'JOD', 'OMR', 'TND'];
|
|
48
|
+
const zeroDp = ['JPY', 'KRW', 'VND', 'ISK'];
|
|
49
|
+
if (threeDp.includes(currency)) return Math.round(amount * 1000);
|
|
50
|
+
if (zeroDp.includes(currency)) return Math.round(amount);
|
|
51
|
+
return Math.round(amount * 100);
|
|
52
|
+
}
|
|
53
|
+
function fromMinor(amount: number, currency: string): number {
|
|
54
|
+
const threeDp = ['KWD', 'BHD', 'JOD', 'OMR', 'TND'];
|
|
55
|
+
const zeroDp = ['JPY', 'KRW', 'VND', 'ISK'];
|
|
56
|
+
if (threeDp.includes(currency)) return amount / 1000;
|
|
57
|
+
if (zeroDp.includes(currency)) return amount;
|
|
58
|
+
return amount / 100;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class RazorpayProvider implements PaymentProvider {
|
|
62
|
+
readonly name = 'razorpay';
|
|
63
|
+
private readonly cfg: RazorpayConfig;
|
|
64
|
+
private readonly transport: HttpTransport;
|
|
65
|
+
|
|
66
|
+
constructor(cfg: RazorpayConfig, transport: HttpTransport) {
|
|
67
|
+
this.cfg = cfg;
|
|
68
|
+
this.transport = transport;
|
|
69
|
+
if (!cfg.keyId || !cfg.keySecret) throw new PaymentError('razorpay keyId/keySecret required', { code: 'CONFIG', provider: 'razorpay' });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private auth(): string {
|
|
73
|
+
return basicAuth(this.cfg.keyId, this.cfg.keySecret);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async createOrder(input: CreateOrderInput): Promise<Order> {
|
|
77
|
+
const body = JSON.stringify({
|
|
78
|
+
amount: toMinor(input.amount, input.currency),
|
|
79
|
+
currency: input.currency,
|
|
80
|
+
receipt: input.reference,
|
|
81
|
+
notes: input.description ? { description: input.description } : undefined,
|
|
82
|
+
});
|
|
83
|
+
const res = await jsonRequest(this.transport, this.name, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
url: `${BASE}/orders`,
|
|
86
|
+
headers: { 'content-type': 'application/json', authorization: this.auth() },
|
|
87
|
+
body,
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
id: res.id,
|
|
91
|
+
reference: res.receipt ?? input.reference,
|
|
92
|
+
status: mapStatus(res.status),
|
|
93
|
+
amount: fromMinor(res.amount, res.currency),
|
|
94
|
+
currency: res.currency,
|
|
95
|
+
raw: res,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async capture(input: CaptureInput): Promise<Order> {
|
|
100
|
+
const paymentId = input.paymentId;
|
|
101
|
+
if (!paymentId) throw new PaymentError('razorpay capture requires paymentId', { code: 'CONFIG', provider: 'razorpay' });
|
|
102
|
+
// Resolve the order currency once so we can convert a partial-capture amount.
|
|
103
|
+
const order = await this.getOrderStatus(input.orderId);
|
|
104
|
+
const body = JSON.stringify({
|
|
105
|
+
amount: input.amount != null ? toMinor(input.amount, order.currency) : undefined,
|
|
106
|
+
currency: order.currency,
|
|
107
|
+
});
|
|
108
|
+
const res = await jsonRequest(this.transport, this.name, {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
url: `${BASE}/payments/${paymentId}/capture`,
|
|
111
|
+
headers: { 'content-type': 'application/json', authorization: this.auth() },
|
|
112
|
+
body,
|
|
113
|
+
});
|
|
114
|
+
return {
|
|
115
|
+
id: res.order_id ?? input.orderId,
|
|
116
|
+
reference: order.reference,
|
|
117
|
+
status: mapStatus(res.status),
|
|
118
|
+
amount: fromMinor(res.amount, res.currency ?? order.currency),
|
|
119
|
+
currency: res.currency ?? order.currency,
|
|
120
|
+
paymentId: res.id,
|
|
121
|
+
raw: res,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async refund(input: RefundInput): Promise<RefundResult> {
|
|
126
|
+
const body = JSON.stringify({
|
|
127
|
+
amount: input.amount != null ? toMinor(input.amount, 'INR') : undefined,
|
|
128
|
+
notes: input.reason ? { reason: input.reason } : undefined,
|
|
129
|
+
});
|
|
130
|
+
const res = await jsonRequest(this.transport, this.name, {
|
|
131
|
+
method: 'POST',
|
|
132
|
+
url: `${BASE}/payments/${input.paymentId}/refund`,
|
|
133
|
+
headers: { 'content-type': 'application/json', authorization: this.auth() },
|
|
134
|
+
body,
|
|
135
|
+
});
|
|
136
|
+
return { id: res.id, status: res.status, amount: fromMinor(res.amount, res.currency || 'INR'), raw: res };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async getOrderStatus(orderId: string): Promise<Order> {
|
|
140
|
+
const res = await jsonRequest(this.transport, this.name, {
|
|
141
|
+
method: 'GET',
|
|
142
|
+
url: `${BASE}/orders/${orderId}`,
|
|
143
|
+
headers: { authorization: this.auth() },
|
|
144
|
+
});
|
|
145
|
+
return {
|
|
146
|
+
id: res.id,
|
|
147
|
+
reference: res.receipt ?? '',
|
|
148
|
+
status: mapStatus(res.status),
|
|
149
|
+
amount: fromMinor(res.amount, res.currency),
|
|
150
|
+
currency: res.currency,
|
|
151
|
+
raw: res,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Live probe: list orders. Authenticates the key pair without moving money. */
|
|
156
|
+
async testConnection(): Promise<ConnectionTest> {
|
|
157
|
+
const res = await jsonRequest(this.transport, this.name, {
|
|
158
|
+
method: 'GET',
|
|
159
|
+
url: `${BASE}/orders?count=1`,
|
|
160
|
+
headers: { authorization: this.auth() },
|
|
161
|
+
});
|
|
162
|
+
const count = Array.isArray(res.items) ? res.items.length : 0;
|
|
163
|
+
return { ok: true, detail: `authenticated; ${count} order(s) in the first page` };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async verifyWebhook(input: WebhookVerifyInput): Promise<WebhookEvent> {
|
|
167
|
+
const secret = this.cfg.webhookSecret;
|
|
168
|
+
if (!secret) return { verified: false, provider: this.name, data: 'no webhook secret configured' };
|
|
169
|
+
const sig = input.headers['x-razorpay-signature'] ?? input.headers['X-Razorpay-Signature'];
|
|
170
|
+
const raw = typeof input.rawBody === 'string' ? input.rawBody : input.rawBody.toString();
|
|
171
|
+
const expected = hmacSha256Hex(secret, raw);
|
|
172
|
+
const verified = typeof sig === 'string' && safeEqual(sig, expected);
|
|
173
|
+
let event: string | undefined;
|
|
174
|
+
let data: unknown;
|
|
175
|
+
try {
|
|
176
|
+
const parsed = JSON.parse(raw);
|
|
177
|
+
event = parsed.event;
|
|
178
|
+
data = parsed.payload;
|
|
179
|
+
} catch {
|
|
180
|
+
data = raw;
|
|
181
|
+
}
|
|
182
|
+
return { verified, event, data, provider: this.name };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PaymentProvider,
|
|
3
|
+
CreateOrderInput,
|
|
4
|
+
Order,
|
|
5
|
+
CaptureInput,
|
|
6
|
+
RefundInput,
|
|
7
|
+
RefundResult,
|
|
8
|
+
WebhookVerifyInput,
|
|
9
|
+
WebhookEvent,
|
|
10
|
+
HttpTransport,
|
|
11
|
+
OrderStatus,
|
|
12
|
+
} from '../types.js';
|
|
13
|
+
import { md5Hex, safeEqual } from '../signature.js';
|
|
14
|
+
import { PaymentError } from '../errors.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Skrill Quick Checkout (happy-path). The merchant redirects the buyer to
|
|
18
|
+
* https://pay.skrill.com/ with payment params; Skrill posts a status report
|
|
19
|
+
* (IPN) to `status_url` whose `md5sig` we verify.
|
|
20
|
+
*
|
|
21
|
+
* md5sig (status report) = MD5(merchant_email + secret_word + mb_transaction_id
|
|
22
|
+
* + amount + currency), sent uppercase by Skrill.
|
|
23
|
+
*/
|
|
24
|
+
interface SkrillConfig {
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
sandbox: boolean;
|
|
27
|
+
/** Skrill merchant email (the pay_to_email). */
|
|
28
|
+
merchantEmail: string;
|
|
29
|
+
/** Secret word set in the Skrill merchant account. */
|
|
30
|
+
secretWord: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const CHECKOUT_URL = 'https://pay.skrill.com/';
|
|
34
|
+
|
|
35
|
+
function mapStatus(status: string): OrderStatus {
|
|
36
|
+
switch (status) {
|
|
37
|
+
case '0': case 'pending': return 'pending';
|
|
38
|
+
case '2': case 'processed': return 'captured';
|
|
39
|
+
case '1': case 'cancelled': return 'cancelled';
|
|
40
|
+
case '-1': case 'failed': return 'failed';
|
|
41
|
+
default: return 'pending';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class SkrillProvider implements PaymentProvider {
|
|
46
|
+
readonly name = 'skrill';
|
|
47
|
+
private readonly cfg: SkrillConfig;
|
|
48
|
+
private readonly transport: HttpTransport;
|
|
49
|
+
|
|
50
|
+
constructor(cfg: SkrillConfig, transport: HttpTransport) {
|
|
51
|
+
this.cfg = cfg;
|
|
52
|
+
this.transport = transport;
|
|
53
|
+
if (!cfg.merchantEmail) throw new PaymentError('skrill merchantEmail required', { code: 'CONFIG', provider: 'skrill' });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async createOrder(input: CreateOrderInput): Promise<Order> {
|
|
57
|
+
const params = new URLSearchParams();
|
|
58
|
+
params.set('pay_to_email', this.cfg.merchantEmail);
|
|
59
|
+
params.set('transaction_id', input.reference);
|
|
60
|
+
params.set('amount', input.amount.toFixed(2));
|
|
61
|
+
params.set('currency', input.currency);
|
|
62
|
+
params.set('detail1_description', input.description ?? 'Order');
|
|
63
|
+
params.set('detail1_text', input.reference);
|
|
64
|
+
if (input.returnUrl) params.set('return_url', input.returnUrl);
|
|
65
|
+
if (input.cancelUrl) params.set('cancel_url', input.cancelUrl);
|
|
66
|
+
if (input.returnUrl) params.set('status_url', input.returnUrl);
|
|
67
|
+
return {
|
|
68
|
+
id: input.reference,
|
|
69
|
+
reference: input.reference,
|
|
70
|
+
status: 'created',
|
|
71
|
+
amount: input.amount,
|
|
72
|
+
currency: input.currency,
|
|
73
|
+
paymentUrl: `${CHECKOUT_URL}?${params.toString()}`,
|
|
74
|
+
raw: Object.fromEntries(params),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Skrill captures on the hosted page; no server-side capture. */
|
|
79
|
+
async capture(input: CaptureInput): Promise<Order> {
|
|
80
|
+
return this.getOrderStatus(input.orderId);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async refund(_input: RefundInput): Promise<RefundResult> {
|
|
84
|
+
return { id: `skrill-refund`, status: 'pending', amount: 0, raw: { note: 'Skrill refund via merchant panel (v1)' } };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async getOrderStatus(orderId: string): Promise<Order> {
|
|
88
|
+
return { id: orderId, reference: orderId, status: 'pending', amount: 0, currency: '', raw: { note: 'Skrill status via webhook (v1)' } };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async verifyWebhook(input: WebhookVerifyInput): Promise<WebhookEvent> {
|
|
92
|
+
const raw = typeof input.rawBody === 'string' ? input.rawBody : input.rawBody.toString();
|
|
93
|
+
const fields = parseForm(raw);
|
|
94
|
+
const provided = fields.md5sig;
|
|
95
|
+
if (!provided) return { verified: false, provider: this.name, data: 'no md5sig in payload' };
|
|
96
|
+
if (!this.cfg.secretWord) return { verified: false, provider: this.name, data: 'no secretWord configured' };
|
|
97
|
+
const message = `${this.cfg.merchantEmail}${this.cfg.secretWord}${fields.mb_transaction_id ?? ''}${fields.amount ?? ''}${fields.currency ?? ''}`;
|
|
98
|
+
const expected = md5Hex(message).toUpperCase();
|
|
99
|
+
const verified = safeEqual(provided.toUpperCase(), expected);
|
|
100
|
+
return {
|
|
101
|
+
verified,
|
|
102
|
+
event: fields.status ? `payment.${fields.status}` : undefined,
|
|
103
|
+
data: fields,
|
|
104
|
+
provider: this.name,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function parseForm(body: string): Record<string, string> {
|
|
110
|
+
const out: Record<string, string> = {};
|
|
111
|
+
for (const pair of body.split('&')) {
|
|
112
|
+
if (!pair) continue;
|
|
113
|
+
const [k, ...rest] = pair.split('=');
|
|
114
|
+
out[decodeURIComponent(k!)] = decodeURIComponent(rest.join('='));
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
package/src/signature.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createHmac, createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/** HMAC-SHA256 hex digest (Razorpay webhooks + PayU v2). */
|
|
4
|
+
export function hmacSha256Hex(key: string | Buffer, message: string): string {
|
|
5
|
+
return createHmac('sha256', key).update(message).digest('hex');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** SHA-512 hex digest (PayU payment hash). */
|
|
9
|
+
export function sha512Hex(message: string): string {
|
|
10
|
+
return createHash('sha512').update(message).digest('hex');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** MD5 hex digest (Skrill Quick Checkout signature). */
|
|
14
|
+
export function md5Hex(message: string): string {
|
|
15
|
+
return createHash('md5').update(message).digest('hex');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Constant-time string compare (webhook signature verification). */
|
|
19
|
+
export function safeEqual(a: string, b: string): boolean {
|
|
20
|
+
if (a.length !== b.length) return false;
|
|
21
|
+
let diff = 0;
|
|
22
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
23
|
+
return diff === 0;
|
|
24
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified payment provider interface. All gateway adapters implement this so the
|
|
3
|
+
* app code is gateway-agnostic. Amounts are expressed in MAJOR units (e.g. 10.50
|
|
4
|
+
* = $10.50); each provider converts to its native representation (Razorpay paise,
|
|
5
|
+
* PayPal decimal string, PayU string, etc.).
|
|
6
|
+
*
|
|
7
|
+
* Tiering (documented in README):
|
|
8
|
+
* - Razorpay: full (REST + signature verify)
|
|
9
|
+
* - PayPal: full (Orders v2 + OAuth2)
|
|
10
|
+
* - PayU: happy-path (hash-based create + webhook verify)
|
|
11
|
+
* - Skrill: happy-path (Quick Checkout URL + MD5 signature)
|
|
12
|
+
* - Payoneer: happy-path (signed redirect; Payoneer has no public sandbox
|
|
13
|
+
* without a registered program — verified structurally, not live)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type OrderStatus =
|
|
17
|
+
| 'created'
|
|
18
|
+
| 'pending'
|
|
19
|
+
| 'authorized'
|
|
20
|
+
| 'paid'
|
|
21
|
+
| 'captured'
|
|
22
|
+
| 'failed'
|
|
23
|
+
| 'refunded'
|
|
24
|
+
| 'cancelled';
|
|
25
|
+
|
|
26
|
+
export interface Money {
|
|
27
|
+
/** Amount in major units (e.g. 10.50). */
|
|
28
|
+
amount: number;
|
|
29
|
+
/** ISO 4217 currency code. */
|
|
30
|
+
currency: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CustomerInfo {
|
|
34
|
+
email?: string;
|
|
35
|
+
name?: string;
|
|
36
|
+
phone?: string;
|
|
37
|
+
userId?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CreateOrderInput {
|
|
41
|
+
amount: number;
|
|
42
|
+
currency: string;
|
|
43
|
+
/** Merchant order id / receipt reference. */
|
|
44
|
+
reference: string;
|
|
45
|
+
description?: string;
|
|
46
|
+
customer?: CustomerInfo;
|
|
47
|
+
/** Hosted-checkout return URL (PayPal/Skrill/PayU). */
|
|
48
|
+
returnUrl?: string;
|
|
49
|
+
cancelUrl?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface Order {
|
|
53
|
+
/** Provider order id. */
|
|
54
|
+
id: string;
|
|
55
|
+
/** Merchant reference echoed back. */
|
|
56
|
+
reference: string;
|
|
57
|
+
status: OrderStatus;
|
|
58
|
+
amount: number;
|
|
59
|
+
currency: string;
|
|
60
|
+
/** Hosted checkout / redirect URL, when the provider offers one. */
|
|
61
|
+
paymentUrl?: string;
|
|
62
|
+
/** Provider payment id once a payment exists. */
|
|
63
|
+
paymentId?: string;
|
|
64
|
+
/** Raw provider response for debugging / extra fields. */
|
|
65
|
+
raw: unknown;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface CaptureInput {
|
|
69
|
+
orderId: string;
|
|
70
|
+
paymentId?: string;
|
|
71
|
+
/** Partial capture amount (major units); omitted = full. */
|
|
72
|
+
amount?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface RefundInput {
|
|
76
|
+
paymentId: string;
|
|
77
|
+
amount?: number;
|
|
78
|
+
reason?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface RefundResult {
|
|
82
|
+
id: string;
|
|
83
|
+
status: string;
|
|
84
|
+
amount: number;
|
|
85
|
+
raw: unknown;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface WebhookVerifyInput {
|
|
89
|
+
rawBody: string | Buffer;
|
|
90
|
+
headers: Record<string, string>;
|
|
91
|
+
/** Route params (e.g. provider name) if mounted on a param route. */
|
|
92
|
+
params?: Record<string, string>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface WebhookEvent {
|
|
96
|
+
verified: boolean;
|
|
97
|
+
/** Event type (e.g. "payment.captured", "refund.created"). */
|
|
98
|
+
event?: string;
|
|
99
|
+
/** Normalized payload — provider-specific. */
|
|
100
|
+
data?: unknown;
|
|
101
|
+
/** Provider name that produced the event. */
|
|
102
|
+
provider?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Result of a live connectivity probe (admin test console). */
|
|
106
|
+
export interface ConnectionTest {
|
|
107
|
+
ok: boolean;
|
|
108
|
+
/** Human note on success (e.g. "authenticated as sandbox merchant"). */
|
|
109
|
+
detail?: string;
|
|
110
|
+
/** Error message when ok is false. */
|
|
111
|
+
error?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface PaymentProvider {
|
|
115
|
+
readonly name: string;
|
|
116
|
+
createOrder(input: CreateOrderInput): Promise<Order>;
|
|
117
|
+
capture(input: CaptureInput): Promise<Order>;
|
|
118
|
+
refund(input: RefundInput): Promise<RefundResult>;
|
|
119
|
+
getOrderStatus(orderId: string): Promise<Order>;
|
|
120
|
+
verifyWebhook(input: WebhookVerifyInput): Promise<WebhookEvent>;
|
|
121
|
+
/**
|
|
122
|
+
* Optional live check that exercises the provider's API (e.g. listing orders /
|
|
123
|
+
* exchanging an OAuth token) WITHOUT creating money movement. Implemented by
|
|
124
|
+
* providers that can be probed anonymously; the admin test console calls it to
|
|
125
|
+
* show a green/red signal. Providers without a safe probe omit it and are
|
|
126
|
+
* reported as "configured — no public probe".
|
|
127
|
+
*/
|
|
128
|
+
testConnection?(): Promise<ConnectionTest>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Injectable HTTP transport so providers are testable without live gateways. */
|
|
132
|
+
export interface HttpRequest {
|
|
133
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
134
|
+
url: string;
|
|
135
|
+
headers?: Record<string, string>;
|
|
136
|
+
body?: string;
|
|
137
|
+
}
|
|
138
|
+
export interface HttpResponse {
|
|
139
|
+
status: number;
|
|
140
|
+
body: string;
|
|
141
|
+
headers?: Record<string, string>;
|
|
142
|
+
}
|
|
143
|
+
export type HttpTransport = (req: HttpRequest) => Promise<HttpResponse>;
|
|
144
|
+
|
|
145
|
+
/** Provider configuration as it appears in nexus.config.ts `payments.<provider>`. */
|
|
146
|
+
export interface ProviderConfig {
|
|
147
|
+
enabled: boolean;
|
|
148
|
+
sandbox: boolean;
|
|
149
|
+
[key: string]: unknown;
|
|
150
|
+
}
|
package/src/webhooks.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { RequestContext, Handler } from '@bhooai/nexus-core/http';
|
|
2
|
+
import type { PaymentProvider, WebhookEvent } from './types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Webhook router: mounts on a route like `/payments/webhook/:provider` and
|
|
6
|
+
* dispatches the raw body to the named provider's `verifyWebhook`. Verified
|
|
7
|
+
* events are handed to `onEvent` (the app's business logic); unverified events
|
|
8
|
+
* are rejected with 401. The handler always responds 200 to the gateway once the
|
|
9
|
+
* event is accepted (so it isn't retried), or 401 on bad signatures.
|
|
10
|
+
*/
|
|
11
|
+
export class WebhookRouter {
|
|
12
|
+
private readonly providers: Map<string, PaymentProvider>;
|
|
13
|
+
private readonly onEvent: (event: WebhookEvent) => Promise<void> | void;
|
|
14
|
+
|
|
15
|
+
constructor(providers: Map<string, PaymentProvider>, onEvent: (event: WebhookEvent) => Promise<void> | void) {
|
|
16
|
+
this.providers = providers;
|
|
17
|
+
this.onEvent = onEvent;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** nexus-core Handler for `POST /payments/webhook/:provider`. */
|
|
21
|
+
handler: Handler = async (ctx) => {
|
|
22
|
+
const providerName = ctx.params.provider;
|
|
23
|
+
const provider = providerName ? this.providers.get(providerName) : undefined;
|
|
24
|
+
if (!provider) {
|
|
25
|
+
ctx.json({ error: `unknown provider: ${providerName}` }, 404);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
// The body parser may have parsed JSON/form; webhooks need the RAW body for
|
|
29
|
+
// signature verification. The bodyParser stores the original string under
|
|
30
|
+
// state.__rawBody when available; fall back to re-serializing the parsed body.
|
|
31
|
+
const rawBody = (ctx.state.__rawBody as string | Buffer | undefined) ?? serializeBody(ctx.body);
|
|
32
|
+
const headers: Record<string, string> = {};
|
|
33
|
+
for (const [k, v] of Object.entries(ctx.headers)) {
|
|
34
|
+
if (typeof v === 'string') headers[k] = v;
|
|
35
|
+
else if (Array.isArray(v) && v.length) headers[k] = v[0]!;
|
|
36
|
+
}
|
|
37
|
+
const event = await provider.verifyWebhook({ rawBody, headers, params: ctx.params });
|
|
38
|
+
if (!event.verified) {
|
|
39
|
+
ctx.json({ error: 'signature verification failed' }, 401);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
await this.onEvent(event);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
// Log but still ACK so the gateway doesn't retry; the app should persist + retry internally.
|
|
46
|
+
ctx.json({ ok: false, error: (err as Error).message }, 200);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
ctx.json({ ok: true }, 200);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function serializeBody(body: unknown): string {
|
|
54
|
+
if (typeof body === 'string') return body;
|
|
55
|
+
if (Buffer.isBuffer(body)) return body.toString();
|
|
56
|
+
if (body == null) return '';
|
|
57
|
+
try { return JSON.stringify(body); } catch { return String(body); }
|
|
58
|
+
}
|