@thepayulink/server 2.0.1 → 2.2.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 +25 -2
- package/dist/index.d.ts +107 -5
- package/dist/payulink-server.cjs +71 -8
- package/package.json +1 -1
- package/src/index.js +72 -8
package/README.md
CHANGED
|
@@ -22,8 +22,9 @@ Node 18+ (uses global `fetch`). Works with `import` and `require`.
|
|
|
22
22
|
import PayuLink, { rupees } from '@thepayulink/server';
|
|
23
23
|
|
|
24
24
|
const pl = new PayuLink({
|
|
25
|
-
keyId: process.env.PAYULINK_KEY_ID,
|
|
26
|
-
keySecret: process.env.PAYULINK_KEY_SECRET,
|
|
25
|
+
keyId: process.env.PAYULINK_KEY_ID, // pl_live_… / pl_test_…
|
|
26
|
+
keySecret: process.env.PAYULINK_KEY_SECRET, // backend only — never ship this
|
|
27
|
+
webhookSecret: process.env.PAYULINK_WEBHOOK_SECRET, // whs_… from panel → Config (NOT keySecret)
|
|
27
28
|
});
|
|
28
29
|
|
|
29
30
|
// 1) Create an order for the TRUE amount, computed on your server.
|
|
@@ -64,6 +65,28 @@ toRupees(19950); // 199.5
|
|
|
64
65
|
| `orders.isPaid(orderId)` | boolean | Convenience. |
|
|
65
66
|
| `verifyPaymentSignature({ payulink_order_id, payulink_payment_id, payulink_signature })` | boolean | Verify the client success handoff. |
|
|
66
67
|
| `webhooks.constructEvent(rawBody, headers)` | event | Verify + parse a webhook (throws if invalid or stale). |
|
|
68
|
+
| `webhooks.test()` | result | Send a `webhook.test` event to your callback URL. |
|
|
69
|
+
| `orders.list({ count?, skip?, status?, receipt?, utr?, from?, to? })` | collection | List orders, e.g. find one by UTR. |
|
|
70
|
+
| `payouts.create({ amount, mode, beneficiary, receipt?, narration? })` | payout | Pay out from your balance (`mode`: `bank` or `upi`). |
|
|
71
|
+
| `payouts.fetch(id)` / `payouts.list({...})` | payout / collection | Payout status. |
|
|
72
|
+
| `payouts.cancel(id)` | payout | Cancel a pending payout (bank: immediate; UPI: cancel request). |
|
|
73
|
+
| `balance.fetch()` | balance | `available`, `locked`, `frozen`, `total` in paise. |
|
|
74
|
+
| `transactions.list({ count?, skip?, type?, from?, to? })` | collection | Every balance movement. |
|
|
75
|
+
|
|
76
|
+
### Payouts
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
const payout = await pl.payouts.create({
|
|
80
|
+
amount: rupees(2500), // PAISE; UPI payouts must be whole rupees
|
|
81
|
+
mode: 'bank', // or 'upi' with beneficiary: { vpa }
|
|
82
|
+
receipt: 'withdrawal_981', // idempotency key
|
|
83
|
+
beneficiary: { name: 'Ravi Kumar', account_number: '918020012345678', ifsc: 'UTIB0000123' },
|
|
84
|
+
});
|
|
85
|
+
// -> { id: 'ZC…', status: 'pending', … } the amount is locked from your available balance
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Statuses: `pending` → `processing` → `processed`, or `cancelled` / `failed` / `rejected`.
|
|
89
|
+
Listen for `payout.processing`, `payout.completed` and `payout.cancelled` webhooks.
|
|
67
90
|
|
|
68
91
|
### Idempotency
|
|
69
92
|
|
package/dist/index.d.ts
CHANGED
|
@@ -7,8 +7,12 @@ export interface PayuLinkOptions {
|
|
|
7
7
|
keySecret: string;
|
|
8
8
|
/** Gateway base URL. Default https://payulink.io/api */
|
|
9
9
|
apiBase?: string;
|
|
10
|
-
/**
|
|
11
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Webhook signing secret (webhook_secret_v2, whs_…) from merchant panel → Config.
|
|
12
|
+
* Required to verify webhooks — it is NOT your keySecret. Pass [newSecret, oldSecret]
|
|
13
|
+
* while rotating.
|
|
14
|
+
*/
|
|
15
|
+
webhookSecret?: string | string[];
|
|
12
16
|
/** Request timeout in ms. Default 15000. */
|
|
13
17
|
timeout?: number;
|
|
14
18
|
}
|
|
@@ -24,7 +28,10 @@ export interface CreateOrderParams {
|
|
|
24
28
|
notes?: Record<string, unknown>;
|
|
25
29
|
/** Optional customer prefill. */
|
|
26
30
|
customer?: { name?: string; contact?: string; email?: string };
|
|
27
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* @deprecated Only affects legacy version-1 callbacks. Signed (v2) webhooks always go to
|
|
33
|
+
* the URL configured in the merchant panel.
|
|
34
|
+
*/
|
|
28
35
|
notifyUrl?: string;
|
|
29
36
|
}
|
|
30
37
|
|
|
@@ -57,6 +64,81 @@ export interface PaymentHandoff {
|
|
|
57
64
|
signature?: string;
|
|
58
65
|
}
|
|
59
66
|
|
|
67
|
+
export interface ListParams {
|
|
68
|
+
/** 1–100, default 10 */
|
|
69
|
+
count?: number;
|
|
70
|
+
skip?: number;
|
|
71
|
+
/** epoch seconds */
|
|
72
|
+
from?: number;
|
|
73
|
+
to?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface Collection<T> {
|
|
77
|
+
entity: 'collection';
|
|
78
|
+
count: number;
|
|
79
|
+
has_more: boolean;
|
|
80
|
+
items: T[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface PayoutBeneficiary {
|
|
84
|
+
name?: string;
|
|
85
|
+
account_number?: string;
|
|
86
|
+
ifsc?: string;
|
|
87
|
+
vpa?: string;
|
|
88
|
+
mobile?: string;
|
|
89
|
+
email?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface CreatePayoutParams {
|
|
93
|
+
/** PAISE. UPI payouts must be whole rupees. */
|
|
94
|
+
amount: number;
|
|
95
|
+
mode: 'bank' | 'upi';
|
|
96
|
+
/** bank: name + account_number + ifsc · upi: vpa (name optional) */
|
|
97
|
+
beneficiary: PayoutBeneficiary;
|
|
98
|
+
receipt?: string;
|
|
99
|
+
narration?: string;
|
|
100
|
+
currency?: 'INR';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface Payout {
|
|
104
|
+
/** ZC… (bank) or MR-… (UPI) */
|
|
105
|
+
id: string;
|
|
106
|
+
entity: 'payout';
|
|
107
|
+
mode: 'bank' | 'upi';
|
|
108
|
+
amount: number;
|
|
109
|
+
currency: string;
|
|
110
|
+
receipt: string | null;
|
|
111
|
+
status: 'pending' | 'processing' | 'processed' | 'cancelled' | 'failed' | 'rejected' | 'reversed';
|
|
112
|
+
beneficiary: PayoutBeneficiary;
|
|
113
|
+
narration: string | null;
|
|
114
|
+
utr: string | null;
|
|
115
|
+
cancel_requested?: boolean;
|
|
116
|
+
created_at: number;
|
|
117
|
+
processed_at: number | null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface Balance {
|
|
121
|
+
entity: 'balance';
|
|
122
|
+
currency: string;
|
|
123
|
+
available: number;
|
|
124
|
+
locked: number;
|
|
125
|
+
frozen: number;
|
|
126
|
+
total: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface Transaction {
|
|
130
|
+
id: string;
|
|
131
|
+
entity: 'transaction';
|
|
132
|
+
type: string;
|
|
133
|
+
direction: 'credit' | 'debit' | 'lock' | 'locked_spent' | 'other';
|
|
134
|
+
amount: number;
|
|
135
|
+
currency: string;
|
|
136
|
+
available_before: number | null;
|
|
137
|
+
available_after: number | null;
|
|
138
|
+
description: string | null;
|
|
139
|
+
created_at: number;
|
|
140
|
+
}
|
|
141
|
+
|
|
60
142
|
export interface WebhookEvent {
|
|
61
143
|
id?: string;
|
|
62
144
|
event_type?: string;
|
|
@@ -87,12 +169,32 @@ export default class PayuLink {
|
|
|
87
169
|
create(params: CreateOrderParams): Promise<Order>;
|
|
88
170
|
fetch(orderId: string): Promise<Order>;
|
|
89
171
|
isPaid(orderId: string): Promise<boolean>;
|
|
172
|
+
list(params?: ListParams & { status?: string; receipt?: string; utr?: string }): Promise<Collection<Order>>;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
payouts: {
|
|
176
|
+
create(params: CreatePayoutParams): Promise<Payout>;
|
|
177
|
+
fetch(payoutId: string): Promise<Payout>;
|
|
178
|
+
list(params?: ListParams & { status?: Payout['status']; mode?: 'bank' | 'upi'; receipt?: string; utr?: string }): Promise<Collection<Payout>>;
|
|
179
|
+
/** Bank: cancels a pending payout. UPI: files a cancel request (HTTP 202, cancel_requested: true). */
|
|
180
|
+
cancel(payoutId: string, params?: { reason?: string }): Promise<Payout>;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
balance: {
|
|
184
|
+
fetch(): Promise<Balance>;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
transactions: {
|
|
188
|
+
/** type: comma-separated names, e.g. "payout_lock,payout_debit" */
|
|
189
|
+
list(params?: ListParams & { type?: string }): Promise<Collection<Transaction>>;
|
|
90
190
|
};
|
|
91
191
|
|
|
92
192
|
webhooks: {
|
|
93
|
-
|
|
193
|
+
/** Sends a webhook.test event to your callback URL. */
|
|
194
|
+
test(): Promise<{ entity: 'webhook_test'; status: 'queued' | 'not_sent'; event_id?: string; reason?: string }>;
|
|
195
|
+
verify(rawBody: string | Buffer, signature: string, timestamp: string | number, secret?: string | string[]): boolean;
|
|
94
196
|
/** Verifies signature + ±5min timestamp tolerance. Throws PayuLinkError if invalid. */
|
|
95
|
-
constructEvent(rawBody: string | Buffer, headers: Record<string, any>, secret?: string): WebhookEvent;
|
|
197
|
+
constructEvent(rawBody: string | Buffer, headers: Record<string, any>, secret?: string | string[]): WebhookEvent;
|
|
96
198
|
};
|
|
97
199
|
|
|
98
200
|
/** signature = HMAC_SHA256(order_id + "|" + payment_id, key_secret) */
|
package/dist/payulink-server.cjs
CHANGED
|
@@ -47,6 +47,16 @@ var PayuLinkError = class extends Error {
|
|
|
47
47
|
this.body = body;
|
|
48
48
|
}
|
|
49
49
|
};
|
|
50
|
+
function query(params = {}) {
|
|
51
|
+
const q = new URLSearchParams();
|
|
52
|
+
for (const [k, v] of Object.entries(params || {})) if (v !== void 0 && v !== null && v !== "") q.set(k, String(v));
|
|
53
|
+
const s = q.toString();
|
|
54
|
+
return s ? `?${s}` : "";
|
|
55
|
+
}
|
|
56
|
+
function requireId(id, name) {
|
|
57
|
+
if (!id) throw new PayuLinkError(`${name} is required`);
|
|
58
|
+
return id;
|
|
59
|
+
}
|
|
50
60
|
function safeEqual(a, b) {
|
|
51
61
|
const ba = Buffer.from(String(a ?? ""));
|
|
52
62
|
const bb = Buffer.from(String(b ?? ""));
|
|
@@ -58,8 +68,10 @@ var PayuLink = class {
|
|
|
58
68
|
* @param {string} opts.keyId Publishable key (pl_live_… / pl_test_…).
|
|
59
69
|
* @param {string} opts.keySecret Secret key. BACKEND ONLY — never ship this.
|
|
60
70
|
* @param {string} [opts.apiBase] Default https://payulink.io/api
|
|
61
|
-
* @param {string} [opts.webhookSecret] Webhook signing secret (webhook_secret_v2
|
|
62
|
-
* merchant panel).
|
|
71
|
+
* @param {string|string[]} [opts.webhookSecret] Webhook signing secret (webhook_secret_v2,
|
|
72
|
+
* whs_…, from merchant panel → Config). Required to verify
|
|
73
|
+
* webhooks. Pass [new, old] while rotating — see constructEvent.
|
|
74
|
+
* It is NOT your keySecret: the two are different secrets.
|
|
63
75
|
* @param {number} [opts.timeout] Request timeout ms. Default 15000.
|
|
64
76
|
*/
|
|
65
77
|
constructor(opts = {}) {
|
|
@@ -68,15 +80,33 @@ var PayuLink = class {
|
|
|
68
80
|
this.keyId = opts.keyId;
|
|
69
81
|
this.keySecret = opts.keySecret;
|
|
70
82
|
this.apiBase = String(opts.apiBase || "https://payulink.io/api").replace(/\/$/, "");
|
|
71
|
-
this.webhookSecret = opts.webhookSecret ||
|
|
83
|
+
this.webhookSecret = opts.webhookSecret || null;
|
|
72
84
|
this.timeout = opts.timeout || 15e3;
|
|
73
85
|
this.mode = /^pl_test_/.test(opts.keyId) ? "test" : "live";
|
|
74
86
|
this.orders = {
|
|
75
87
|
create: (params) => this._createOrder(params),
|
|
76
88
|
fetch: (orderId) => this._fetchOrder(orderId),
|
|
77
|
-
isPaid: async (orderId) => (await this._fetchOrder(orderId)).status === "paid"
|
|
89
|
+
isPaid: async (orderId) => (await this._fetchOrder(orderId)).status === "paid",
|
|
90
|
+
list: (params) => this._request("GET", "/v1/orders" + query(params))
|
|
91
|
+
};
|
|
92
|
+
this.payouts = {
|
|
93
|
+
create: (params) => this._createPayout(params),
|
|
94
|
+
fetch: (payoutId) => this._request("GET", `/v1/payouts/${encodeURIComponent(requireId(payoutId, "payoutId"))}`),
|
|
95
|
+
list: (params) => this._request("GET", "/v1/payouts" + query(params)),
|
|
96
|
+
cancel: (payoutId, params = {}) => this._request(
|
|
97
|
+
"POST",
|
|
98
|
+
`/v1/payouts/${encodeURIComponent(requireId(payoutId, "payoutId"))}/cancel`,
|
|
99
|
+
{ body: params }
|
|
100
|
+
)
|
|
101
|
+
};
|
|
102
|
+
this.balance = {
|
|
103
|
+
fetch: () => this._request("GET", "/v1/balance")
|
|
104
|
+
};
|
|
105
|
+
this.transactions = {
|
|
106
|
+
list: (params) => this._request("GET", "/v1/transactions" + query(params))
|
|
78
107
|
};
|
|
79
108
|
this.webhooks = {
|
|
109
|
+
test: () => this._request("POST", "/v1/webhooks/test"),
|
|
80
110
|
verify: (rawBody, signature, timestamp, secret) => this._verifyWebhook(rawBody, signature, timestamp, secret),
|
|
81
111
|
constructEvent: (rawBody, headers, secret) => this._constructEvent(rawBody, headers, secret)
|
|
82
112
|
};
|
|
@@ -125,6 +155,10 @@ var PayuLink = class {
|
|
|
125
155
|
* @param {string} [p.receipt] Your reference. Unique per merchant — reusing it
|
|
126
156
|
* returns the SAME order, which makes retries safe.
|
|
127
157
|
* @param {object} [p.notes] Arbitrary key/value metadata.
|
|
158
|
+
* @param {object} [p.customer] { name, contact, email } — prefill for the checkout.
|
|
159
|
+
*
|
|
160
|
+
* Webhooks always go to the URL set in the merchant panel. (`notifyUrl` is still sent for
|
|
161
|
+
* backward compatibility but only affects legacy version-1 callbacks, not signed webhooks.)
|
|
128
162
|
*/
|
|
129
163
|
_createOrder(p = {}) {
|
|
130
164
|
const amount = Math.round(Number(p.amount));
|
|
@@ -144,6 +178,33 @@ var PayuLink = class {
|
|
|
144
178
|
}
|
|
145
179
|
});
|
|
146
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* Pay money out from your PayuLink balance. The amount is locked immediately.
|
|
183
|
+
* @param {object} p
|
|
184
|
+
* @param {number} p.amount PAISE. UPI payouts must be whole rupees.
|
|
185
|
+
* @param {'bank'|'upi'} p.mode
|
|
186
|
+
* @param {object} p.beneficiary bank: { name, account_number, ifsc } · upi: { vpa, name? } · optional mobile, email
|
|
187
|
+
* @param {string} [p.receipt] Your reference; reusing it returns the same payout.
|
|
188
|
+
* @param {string} [p.narration]
|
|
189
|
+
*/
|
|
190
|
+
_createPayout(p = {}) {
|
|
191
|
+
const amount = Math.round(Number(p.amount));
|
|
192
|
+
if (!Number.isFinite(amount) || amount < 100) {
|
|
193
|
+
throw new PayuLinkError("`amount` is required, in PAISE, and must be >= 100 (\u20B91).");
|
|
194
|
+
}
|
|
195
|
+
if (p.mode !== "bank" && p.mode !== "upi") throw new PayuLinkError('`mode` must be "bank" or "upi".');
|
|
196
|
+
if (!p.beneficiary) throw new PayuLinkError("`beneficiary` is required.");
|
|
197
|
+
return this._request("POST", "/v1/payouts", {
|
|
198
|
+
body: {
|
|
199
|
+
amount,
|
|
200
|
+
currency: p.currency || "INR",
|
|
201
|
+
mode: p.mode,
|
|
202
|
+
beneficiary: p.beneficiary,
|
|
203
|
+
receipt: p.receipt,
|
|
204
|
+
narration: p.narration
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
}
|
|
147
208
|
/** Authoritative order status. */
|
|
148
209
|
_fetchOrder(orderId) {
|
|
149
210
|
if (!orderId) throw new PayuLinkError("orderId is required");
|
|
@@ -176,9 +237,11 @@ var PayuLink = class {
|
|
|
176
237
|
*/
|
|
177
238
|
_verifyWebhook(rawBody, signature, timestamp, secret) {
|
|
178
239
|
if (!signature || !timestamp) return false;
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
240
|
+
const keys = [].concat(secret || this.webhookSecret || []).filter(Boolean);
|
|
241
|
+
if (keys.length === 0) {
|
|
242
|
+
throw new PayuLinkError("No webhook secret: pass `webhookSecret` (whs_\u2026, merchant panel \u2192 Config) to new PayuLink({...}). Webhooks are not signed with your keySecret.");
|
|
243
|
+
}
|
|
244
|
+
return keys.some((key) => safeEqual("sha256=" + import_node_crypto.default.createHmac("sha256", key).update(`${timestamp}.${String(rawBody)}`).digest("hex"), signature));
|
|
182
245
|
}
|
|
183
246
|
/**
|
|
184
247
|
* Verify + parse a webhook. Throws if the signature is bad or the timestamp is
|
|
@@ -194,7 +257,7 @@ var PayuLink = class {
|
|
|
194
257
|
*
|
|
195
258
|
* @param {string|Buffer} rawBody
|
|
196
259
|
* @param {object} headers The request headers object.
|
|
197
|
-
* @param {string} [secret]
|
|
260
|
+
* @param {string|string[]} [secret] Overrides opts.webhookSecret.
|
|
198
261
|
* @param {number} [toleranceSec=300]
|
|
199
262
|
*/
|
|
200
263
|
_constructEvent(rawBody, headers = {}, secret, toleranceSec = 300) {
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -29,6 +29,18 @@ export class PayuLinkError extends Error {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
function query(params = {}) {
|
|
33
|
+
const q = new URLSearchParams();
|
|
34
|
+
for (const [k, v] of Object.entries(params || {})) if (v !== undefined && v !== null && v !== '') q.set(k, String(v));
|
|
35
|
+
const s = q.toString();
|
|
36
|
+
return s ? `?${s}` : '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function requireId(id, name) {
|
|
40
|
+
if (!id) throw new PayuLinkError(`${name} is required`);
|
|
41
|
+
return id;
|
|
42
|
+
}
|
|
43
|
+
|
|
32
44
|
function safeEqual(a, b) {
|
|
33
45
|
const ba = Buffer.from(String(a ?? ''));
|
|
34
46
|
const bb = Buffer.from(String(b ?? ''));
|
|
@@ -41,8 +53,10 @@ export default class PayuLink {
|
|
|
41
53
|
* @param {string} opts.keyId Publishable key (pl_live_… / pl_test_…).
|
|
42
54
|
* @param {string} opts.keySecret Secret key. BACKEND ONLY — never ship this.
|
|
43
55
|
* @param {string} [opts.apiBase] Default https://payulink.io/api
|
|
44
|
-
* @param {string} [opts.webhookSecret] Webhook signing secret (webhook_secret_v2
|
|
45
|
-
* merchant panel).
|
|
56
|
+
* @param {string|string[]} [opts.webhookSecret] Webhook signing secret (webhook_secret_v2,
|
|
57
|
+
* whs_…, from merchant panel → Config). Required to verify
|
|
58
|
+
* webhooks. Pass [new, old] while rotating — see constructEvent.
|
|
59
|
+
* It is NOT your keySecret: the two are different secrets.
|
|
46
60
|
* @param {number} [opts.timeout] Request timeout ms. Default 15000.
|
|
47
61
|
*/
|
|
48
62
|
constructor(opts = {}) {
|
|
@@ -51,7 +65,9 @@ export default class PayuLink {
|
|
|
51
65
|
this.keyId = opts.keyId;
|
|
52
66
|
this.keySecret = opts.keySecret;
|
|
53
67
|
this.apiBase = String(opts.apiBase || 'https://payulink.io/api').replace(/\/$/, '');
|
|
54
|
-
|
|
68
|
+
// No fallback to keySecret. Webhooks are never signed with it, so the old fallback
|
|
69
|
+
// turned a missing env var into "every webhook fails signature" with no clue why.
|
|
70
|
+
this.webhookSecret = opts.webhookSecret || null;
|
|
55
71
|
this.timeout = opts.timeout || 15000;
|
|
56
72
|
this.mode = /^pl_test_/.test(opts.keyId) ? 'test' : 'live';
|
|
57
73
|
|
|
@@ -59,8 +75,24 @@ export default class PayuLink {
|
|
|
59
75
|
create: (params) => this._createOrder(params),
|
|
60
76
|
fetch: (orderId) => this._fetchOrder(orderId),
|
|
61
77
|
isPaid: async (orderId) => (await this._fetchOrder(orderId)).status === 'paid',
|
|
78
|
+
list: (params) => this._request('GET', '/v1/orders' + query(params)),
|
|
79
|
+
};
|
|
80
|
+
// Payouts, balance and transactions replace the legacy /openapi payout + acct endpoints.
|
|
81
|
+
this.payouts = {
|
|
82
|
+
create: (params) => this._createPayout(params),
|
|
83
|
+
fetch: (payoutId) => this._request('GET', `/v1/payouts/${encodeURIComponent(requireId(payoutId, 'payoutId'))}`),
|
|
84
|
+
list: (params) => this._request('GET', '/v1/payouts' + query(params)),
|
|
85
|
+
cancel: (payoutId, params = {}) => this._request('POST',
|
|
86
|
+
`/v1/payouts/${encodeURIComponent(requireId(payoutId, 'payoutId'))}/cancel`, { body: params }),
|
|
87
|
+
};
|
|
88
|
+
this.balance = {
|
|
89
|
+
fetch: () => this._request('GET', '/v1/balance'),
|
|
90
|
+
};
|
|
91
|
+
this.transactions = {
|
|
92
|
+
list: (params) => this._request('GET', '/v1/transactions' + query(params)),
|
|
62
93
|
};
|
|
63
94
|
this.webhooks = {
|
|
95
|
+
test: () => this._request('POST', '/v1/webhooks/test'),
|
|
64
96
|
verify: (rawBody, signature, timestamp, secret) =>
|
|
65
97
|
this._verifyWebhook(rawBody, signature, timestamp, secret),
|
|
66
98
|
constructEvent: (rawBody, headers, secret) => this._constructEvent(rawBody, headers, secret),
|
|
@@ -109,6 +141,10 @@ export default class PayuLink {
|
|
|
109
141
|
* @param {string} [p.receipt] Your reference. Unique per merchant — reusing it
|
|
110
142
|
* returns the SAME order, which makes retries safe.
|
|
111
143
|
* @param {object} [p.notes] Arbitrary key/value metadata.
|
|
144
|
+
* @param {object} [p.customer] { name, contact, email } — prefill for the checkout.
|
|
145
|
+
*
|
|
146
|
+
* Webhooks always go to the URL set in the merchant panel. (`notifyUrl` is still sent for
|
|
147
|
+
* backward compatibility but only affects legacy version-1 callbacks, not signed webhooks.)
|
|
112
148
|
*/
|
|
113
149
|
_createOrder(p = {}) {
|
|
114
150
|
const amount = Math.round(Number(p.amount));
|
|
@@ -130,6 +166,28 @@ export default class PayuLink {
|
|
|
130
166
|
});
|
|
131
167
|
}
|
|
132
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Pay money out from your PayuLink balance. The amount is locked immediately.
|
|
171
|
+
* @param {object} p
|
|
172
|
+
* @param {number} p.amount PAISE. UPI payouts must be whole rupees.
|
|
173
|
+
* @param {'bank'|'upi'} p.mode
|
|
174
|
+
* @param {object} p.beneficiary bank: { name, account_number, ifsc } · upi: { vpa, name? } · optional mobile, email
|
|
175
|
+
* @param {string} [p.receipt] Your reference; reusing it returns the same payout.
|
|
176
|
+
* @param {string} [p.narration]
|
|
177
|
+
*/
|
|
178
|
+
_createPayout(p = {}) {
|
|
179
|
+
const amount = Math.round(Number(p.amount));
|
|
180
|
+
if (!Number.isFinite(amount) || amount < 100) {
|
|
181
|
+
throw new PayuLinkError('`amount` is required, in PAISE, and must be >= 100 (₹1).');
|
|
182
|
+
}
|
|
183
|
+
if (p.mode !== 'bank' && p.mode !== 'upi') throw new PayuLinkError('`mode` must be "bank" or "upi".');
|
|
184
|
+
if (!p.beneficiary) throw new PayuLinkError('`beneficiary` is required.');
|
|
185
|
+
return this._request('POST', '/v1/payouts', {
|
|
186
|
+
body: { amount, currency: p.currency || 'INR', mode: p.mode, beneficiary: p.beneficiary,
|
|
187
|
+
receipt: p.receipt, narration: p.narration },
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
133
191
|
/** Authoritative order status. */
|
|
134
192
|
_fetchOrder(orderId) {
|
|
135
193
|
if (!orderId) throw new PayuLinkError('orderId is required');
|
|
@@ -165,10 +223,16 @@ export default class PayuLink {
|
|
|
165
223
|
*/
|
|
166
224
|
_verifyWebhook(rawBody, signature, timestamp, secret) {
|
|
167
225
|
if (!signature || !timestamp) return false;
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
226
|
+
const keys = [].concat(secret || this.webhookSecret || []).filter(Boolean);
|
|
227
|
+
if (keys.length === 0) {
|
|
228
|
+
throw new PayuLinkError('No webhook secret: pass `webhookSecret` (whs_…, merchant panel → Config) '
|
|
229
|
+
+ 'to new PayuLink({...}). Webhooks are not signed with your keySecret.');
|
|
230
|
+
}
|
|
231
|
+
// Several secrets are accepted so a rotation never drops events: deliveries already
|
|
232
|
+
// queued (and their retries, for up to ~30h) stay signed with the secret that was
|
|
233
|
+
// current when each event was created.
|
|
234
|
+
return keys.some((key) => safeEqual('sha256=' + crypto.createHmac('sha256', key)
|
|
235
|
+
.update(`${timestamp}.${String(rawBody)}`).digest('hex'), signature));
|
|
172
236
|
}
|
|
173
237
|
|
|
174
238
|
/**
|
|
@@ -185,7 +249,7 @@ export default class PayuLink {
|
|
|
185
249
|
*
|
|
186
250
|
* @param {string|Buffer} rawBody
|
|
187
251
|
* @param {object} headers The request headers object.
|
|
188
|
-
* @param {string} [secret]
|
|
252
|
+
* @param {string|string[]} [secret] Overrides opts.webhookSecret.
|
|
189
253
|
* @param {number} [toleranceSec=300]
|
|
190
254
|
*/
|
|
191
255
|
_constructEvent(rawBody, headers = {}, secret, toleranceSec = 300) {
|