@thepayulink/server 1.0.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 +134 -0
- package/dist/index.d.ts +81 -0
- package/dist/payulink-server.cjs +162 -0
- package/package.json +42 -0
- package/src/index.js +136 -0
package/README.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# @thepayulink/server
|
|
2
|
+
|
|
3
|
+
Server-side SDK for Elite Yatra / PayuLink. Create orders with a **fixed amount**,
|
|
4
|
+
check payment status, and verify signatures & webhooks — all with your secret key.
|
|
5
|
+
This is the backend half of the checkout, analogous to the `razorpay` Node SDK.
|
|
6
|
+
|
|
7
|
+
> The **secret key never leaves your backend.** The client SDK only ever receives an `order_id`.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @thepayulink/server
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Node 18+ (uses global `fetch`). Works with `import` and `require`.
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
import Payulink from '@thepayulink/server';
|
|
21
|
+
|
|
22
|
+
const ey = new Payulink({
|
|
23
|
+
keyId: process.env.PAYULINK_PUBLISHABLE_KEY, // pl_pk_… (optional)
|
|
24
|
+
keySecret: process.env.PAYULINK_SECRET_KEY, // pl_sk_… (required, backend only)
|
|
25
|
+
apiBase: 'https://eliteyatra.vip',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// 1) Create an order for the TRUE amount (computed on your server).
|
|
29
|
+
const order = await ey.orders.create({
|
|
30
|
+
amount: 19900, // rupees
|
|
31
|
+
item: 'Kailash Mansarovar Yatra — 2 travellers',
|
|
32
|
+
customer: { name: 'Asha', mobile: '9876543210' },
|
|
33
|
+
});
|
|
34
|
+
// -> { order_id, key_id, amount, item, status: 'created' }
|
|
35
|
+
|
|
36
|
+
// 2) Send order.order_id to your client → open it in the client SDK.
|
|
37
|
+
|
|
38
|
+
// 3) Later, confirm the payment authoritatively before fulfilment.
|
|
39
|
+
const status = await ey.orders.fetch(order.order_id);
|
|
40
|
+
if (status.paid) {
|
|
41
|
+
// deliver the booking — this reflects PayuLink's webhook-verified status
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Express example
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
import express from 'express';
|
|
49
|
+
import Payulink from '@thepayulink/server';
|
|
50
|
+
|
|
51
|
+
const ey = new Payulink({ keySecret: process.env.PAYULINK_SECRET_KEY });
|
|
52
|
+
const app = express();
|
|
53
|
+
app.use(express.json());
|
|
54
|
+
|
|
55
|
+
// Client asks your backend to start a payment.
|
|
56
|
+
app.post('/create-order', async (req, res) => {
|
|
57
|
+
const pkg = await db.getPackage(req.body.packageId); // trusted price
|
|
58
|
+
const order = await ey.orders.create({
|
|
59
|
+
amount: pkg.bookingAmount * (req.body.travellers || 1),
|
|
60
|
+
item: pkg.title,
|
|
61
|
+
customer: req.body.customer,
|
|
62
|
+
});
|
|
63
|
+
res.json({ orderId: order.order_id, keyId: order.key_id });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Client reports success → confirm before trusting it.
|
|
67
|
+
app.post('/confirm', async (req, res) => {
|
|
68
|
+
const s = await ey.orders.fetch(req.body.orderId);
|
|
69
|
+
res.json({ paid: s.paid, utr: s.utr });
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Verifying a payment
|
|
74
|
+
|
|
75
|
+
Two equivalent ways, pick one:
|
|
76
|
+
|
|
77
|
+
**A. Fetch the order (simplest, always correct):**
|
|
78
|
+
|
|
79
|
+
```js
|
|
80
|
+
const s = await ey.orders.fetch(orderId);
|
|
81
|
+
if (s.paid) { /* confirmed */ }
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**B. Verify the signature from the client success callback (Razorpay-style):**
|
|
85
|
+
|
|
86
|
+
The client success payload includes `{ order_id, utr, signature }`. Verify it:
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
const ok = ey.verifyPaymentSignature({ order_id, utr, signature });
|
|
90
|
+
if (ok) { /* authentic success */ }
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Webhooks
|
|
94
|
+
|
|
95
|
+
Set `PAYULINK_MERCHANT_WEBHOOK_URL` on the pay server to your endpoint. It receives
|
|
96
|
+
signed events; verify with the raw body and the `x-payulink-signature` header:
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
// Use a raw body parser on this route so the signature matches byte-for-byte.
|
|
100
|
+
app.post('/webhooks/eliteyatra', express.raw({ type: '*/*' }), (req, res) => {
|
|
101
|
+
let event;
|
|
102
|
+
try {
|
|
103
|
+
event = ey.webhooks.constructEvent(req.body, req.get('x-payulink-signature'));
|
|
104
|
+
} catch (e) {
|
|
105
|
+
return res.status(400).send('Invalid signature');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (event.event === 'payin.verified') {
|
|
109
|
+
// event.data: { order_id, status, utr, amount, item }
|
|
110
|
+
fulfilBooking(event.data.order_id);
|
|
111
|
+
}
|
|
112
|
+
res.sendStatus(200);
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Events: `payin.verified` (paid), `payin.failed`, `payin.expired`, `payin.cancelled`.
|
|
117
|
+
|
|
118
|
+
## API
|
|
119
|
+
|
|
120
|
+
| Method | Returns | Notes |
|
|
121
|
+
| --- | --- | --- |
|
|
122
|
+
| `orders.create({ amount, item?, customer? })` | `Order` | Amount is fixed server-side. |
|
|
123
|
+
| `orders.fetch(orderId)` | `OrderStatus` | Authoritative status; `.paid` boolean. |
|
|
124
|
+
| `orders.isPaid(orderId)` | `boolean` | Convenience. |
|
|
125
|
+
| `payments.fetch(orderId)` | `OrderStatus` | Alias of `orders.fetch`. |
|
|
126
|
+
| `verifyPaymentSignature({ order_id, utr, signature })` | `boolean` | Verify client success handoff. |
|
|
127
|
+
| `webhooks.constructEvent(rawBody, signature)` | `WebhookEvent` | Throws on bad signature. |
|
|
128
|
+
| `webhooks.verify(rawBody, signature)` | `boolean` | Non-throwing check. |
|
|
129
|
+
|
|
130
|
+
Errors throw `PayulinkError` with `.status` and `.body`.
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
UNLICENSED — internal to Elite Yatra.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export interface PayulinkConfig {
|
|
2
|
+
/** Secret key (pl_sk_…). Required. Backend only. */
|
|
3
|
+
keySecret: string;
|
|
4
|
+
/** Publishable key (pl_pk_…). Optional. */
|
|
5
|
+
keyId?: string;
|
|
6
|
+
/** Pay server base URL. Default https://eliteyatra.vip. */
|
|
7
|
+
apiBase?: string;
|
|
8
|
+
/** Secret to verify inbound webhooks (defaults to keySecret). */
|
|
9
|
+
webhookSecret?: string;
|
|
10
|
+
/** Request timeout in ms. Default 15000. */
|
|
11
|
+
timeout?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CreateOrderParams {
|
|
15
|
+
/** Amount in rupees. Required. */
|
|
16
|
+
amount: number;
|
|
17
|
+
item?: string;
|
|
18
|
+
customer?: { name?: string; mobile?: string; email?: string };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Order {
|
|
22
|
+
order_id: string;
|
|
23
|
+
key_id: string | null;
|
|
24
|
+
amount: number;
|
|
25
|
+
item: string;
|
|
26
|
+
status: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface OrderStatus {
|
|
30
|
+
merchant_order_no: string;
|
|
31
|
+
/** 0=SUCCESS, 1=FAILED, 2=PENDING, 3=EXPIRED, … */
|
|
32
|
+
status: number;
|
|
33
|
+
status_label: string;
|
|
34
|
+
paid: boolean;
|
|
35
|
+
utr: string | null;
|
|
36
|
+
amount: number;
|
|
37
|
+
item: string;
|
|
38
|
+
signature: string | null;
|
|
39
|
+
expires_at: number | null;
|
|
40
|
+
server_now: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface WebhookEvent {
|
|
44
|
+
id: string;
|
|
45
|
+
event: string;
|
|
46
|
+
created_at: number;
|
|
47
|
+
data: {
|
|
48
|
+
order_id: string;
|
|
49
|
+
status: number;
|
|
50
|
+
status_label: string;
|
|
51
|
+
utr: string | null;
|
|
52
|
+
amount: number;
|
|
53
|
+
item: string;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class PayulinkError extends Error {
|
|
58
|
+
status?: number;
|
|
59
|
+
body?: unknown;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default class Payulink {
|
|
63
|
+
constructor(config: PayulinkConfig);
|
|
64
|
+
|
|
65
|
+
orders: {
|
|
66
|
+
create(params: CreateOrderParams): Promise<Order>;
|
|
67
|
+
fetch(orderId: string): Promise<OrderStatus>;
|
|
68
|
+
isPaid(orderId: string): Promise<boolean>;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
payments: {
|
|
72
|
+
fetch(orderId: string): Promise<OrderStatus>;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
webhooks: {
|
|
76
|
+
verify(rawBody: string | Buffer, signature: string, secret?: string): boolean;
|
|
77
|
+
constructEvent(rawBody: string | Buffer, signature: string, secret?: string): WebhookEvent;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
verifyPaymentSignature(p: { order_id: string; utr?: string | null; signature: string }): boolean;
|
|
81
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
var __create = Object.create;
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
20
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
21
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
22
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
23
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
24
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
25
|
+
mod
|
|
26
|
+
));
|
|
27
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
28
|
+
|
|
29
|
+
// src/index.js
|
|
30
|
+
var src_exports = {};
|
|
31
|
+
__export(src_exports, {
|
|
32
|
+
PayulinkError: () => PayulinkError,
|
|
33
|
+
default: () => Payulink
|
|
34
|
+
});
|
|
35
|
+
module.exports = __toCommonJS(src_exports);
|
|
36
|
+
var import_node_crypto = __toESM(require("node:crypto"), 1);
|
|
37
|
+
var PayulinkError = class extends Error {
|
|
38
|
+
constructor(message, { status, body } = {}) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "PayulinkError";
|
|
41
|
+
this.status = status;
|
|
42
|
+
this.body = body;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
function safeEqual(a, b) {
|
|
46
|
+
const ba = Buffer.from(String(a || ""));
|
|
47
|
+
const bb = Buffer.from(String(b || ""));
|
|
48
|
+
return ba.length === bb.length && ba.length > 0 && import_node_crypto.default.timingSafeEqual(ba, bb);
|
|
49
|
+
}
|
|
50
|
+
var Payulink = class {
|
|
51
|
+
/**
|
|
52
|
+
* @param {object} opts
|
|
53
|
+
* @param {string} opts.keySecret Secret key (pl_sk_…). Required. Backend only.
|
|
54
|
+
* @param {string} [opts.keyId] Publishable key (pl_pk_…). Optional.
|
|
55
|
+
* @param {string} [opts.apiBase] Pay server URL. Default https://eliteyatra.vip.
|
|
56
|
+
* @param {string} [opts.webhookSecret] Secret used to verify inbound webhooks (defaults to keySecret).
|
|
57
|
+
* @param {number} [opts.timeout] Request timeout in ms. Default 15000.
|
|
58
|
+
*/
|
|
59
|
+
constructor(opts = {}) {
|
|
60
|
+
if (!opts.keySecret) throw new PayulinkError("`keySecret` (pl_sk_\u2026) is required");
|
|
61
|
+
this.keySecret = opts.keySecret;
|
|
62
|
+
this.keyId = opts.keyId || null;
|
|
63
|
+
this.apiBase = String(opts.apiBase || "https://eliteyatra.vip").replace(/\/$/, "");
|
|
64
|
+
this.webhookSecret = opts.webhookSecret || opts.keySecret;
|
|
65
|
+
this.timeout = opts.timeout || 15e3;
|
|
66
|
+
this.orders = {
|
|
67
|
+
create: (params) => this._createOrder(params),
|
|
68
|
+
fetch: (orderId) => this._fetchOrder(orderId),
|
|
69
|
+
isPaid: async (orderId) => (await this._fetchOrder(orderId)).paid === true
|
|
70
|
+
};
|
|
71
|
+
this.payments = { fetch: (orderId) => this._fetchOrder(orderId) };
|
|
72
|
+
this.webhooks = {
|
|
73
|
+
verify: (rawBody, signature, secret) => this._verifyWebhook(rawBody, signature, secret),
|
|
74
|
+
constructEvent: (rawBody, signature, secret) => this._constructEvent(rawBody, signature, secret)
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
async _request(method, path, { body, auth } = {}) {
|
|
78
|
+
const ctrl = new AbortController();
|
|
79
|
+
const t = setTimeout(() => ctrl.abort(), this.timeout);
|
|
80
|
+
let res;
|
|
81
|
+
try {
|
|
82
|
+
res = await fetch(`${this.apiBase}${path}`, {
|
|
83
|
+
method,
|
|
84
|
+
headers: {
|
|
85
|
+
"Content-Type": "application/json",
|
|
86
|
+
...auth ? { Authorization: `Bearer ${this.keySecret}` } : {},
|
|
87
|
+
...this.keyId ? { "x-payulink-key": this.keyId } : {}
|
|
88
|
+
},
|
|
89
|
+
body: body ? JSON.stringify(body) : void 0,
|
|
90
|
+
signal: ctrl.signal
|
|
91
|
+
});
|
|
92
|
+
} catch (e) {
|
|
93
|
+
clearTimeout(t);
|
|
94
|
+
throw new PayulinkError(`Network error: ${e.message}`);
|
|
95
|
+
}
|
|
96
|
+
clearTimeout(t);
|
|
97
|
+
let data = {};
|
|
98
|
+
try {
|
|
99
|
+
data = await res.json();
|
|
100
|
+
} catch {
|
|
101
|
+
}
|
|
102
|
+
if (!res.ok) throw new PayulinkError(data.error || `Request failed (HTTP ${res.status})`, { status: res.status, body: data });
|
|
103
|
+
return data;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Create an order with a server-fixed amount. The client only receives the
|
|
107
|
+
* returned `order_id` and cannot change the amount.
|
|
108
|
+
* @param {object} params { amount (rupees), item?, customer? }
|
|
109
|
+
* @returns {Promise<{order_id, key_id, amount, item, status}>}
|
|
110
|
+
*/
|
|
111
|
+
_createOrder(params = {}) {
|
|
112
|
+
const amount = Math.round(Number(params.amount) || 0);
|
|
113
|
+
if (!(amount >= 1)) throw new PayulinkError("`amount` (in rupees) is required and must be >= 1");
|
|
114
|
+
return this._request("POST", "/api/orders", {
|
|
115
|
+
auth: true,
|
|
116
|
+
body: { amount, item: params.item, customer: params.customer }
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Fetch the authoritative status of an order.
|
|
121
|
+
* @returns {Promise<{merchant_order_no, status, status_label, paid, utr, amount, signature}>}
|
|
122
|
+
*/
|
|
123
|
+
_fetchOrder(orderId) {
|
|
124
|
+
if (!orderId) throw new PayulinkError("orderId is required");
|
|
125
|
+
return this._request("GET", `/api/order/${encodeURIComponent(orderId)}`);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Verify a payment handoff signature returned to the client on success.
|
|
129
|
+
* Mirrors Razorpay's signature check.
|
|
130
|
+
* @param {object} p { order_id, utr, signature }
|
|
131
|
+
* @returns {boolean}
|
|
132
|
+
*/
|
|
133
|
+
verifyPaymentSignature({ order_id, utr, signature } = {}) {
|
|
134
|
+
if (!order_id || !signature) return false;
|
|
135
|
+
const expected = import_node_crypto.default.createHmac("sha256", this.keySecret).update(`${order_id}|${utr || ""}`).digest("hex");
|
|
136
|
+
return safeEqual(expected, signature);
|
|
137
|
+
}
|
|
138
|
+
_verifyWebhook(rawBody, signature, secret) {
|
|
139
|
+
const expected = import_node_crypto.default.createHmac("sha256", secret || this.webhookSecret).update(String(rawBody)).digest("hex");
|
|
140
|
+
return safeEqual(expected, signature);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Verify an inbound webhook and return the parsed event. Throws if the
|
|
144
|
+
* signature is invalid. Pass the RAW request body (string/Buffer) and the
|
|
145
|
+
* `x-payulink-signature` header.
|
|
146
|
+
*/
|
|
147
|
+
_constructEvent(rawBody, signature, secret) {
|
|
148
|
+
if (!this._verifyWebhook(rawBody, signature, secret)) {
|
|
149
|
+
throw new PayulinkError("Invalid webhook signature", { status: 401 });
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
return JSON.parse(String(rawBody));
|
|
153
|
+
} catch {
|
|
154
|
+
throw new PayulinkError("Invalid webhook JSON", { status: 400 });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
159
|
+
0 && (module.exports = {
|
|
160
|
+
PayulinkError
|
|
161
|
+
});
|
|
162
|
+
(()=>{const ns=module.exports,C=ns.default;if(C){C.PayulinkError=ns.PayulinkError;C.Payulink=C;C.default=C;module.exports=C;}})();
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@thepayulink/server",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Server-side SDK for Elite Yatra / PayuLink — create orders, check payment status, and verify signatures & webhooks with your secret key.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/payulink-server.cjs",
|
|
7
|
+
"module": "./src/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./src/index.js",
|
|
13
|
+
"require": "./dist/payulink-server.cjs"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "node build.mjs"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"eliteyatra",
|
|
29
|
+
"payulink",
|
|
30
|
+
"payment",
|
|
31
|
+
"server",
|
|
32
|
+
"sdk",
|
|
33
|
+
"upi",
|
|
34
|
+
"orders",
|
|
35
|
+
"webhooks"
|
|
36
|
+
],
|
|
37
|
+
"author": "Elite Yatra",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"esbuild": "^0.23.0"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// @thepayulink/server — backend SDK for Elite Yatra / PayuLink.
|
|
2
|
+
//
|
|
3
|
+
// import Payulink from '@thepayulink/server';
|
|
4
|
+
// const ey = new Payulink({ keyId: 'pl_pk_…', keySecret: 'pl_sk_…' });
|
|
5
|
+
// const order = await ey.orders.create({ amount: 19900, item: 'Kailash Yatra' });
|
|
6
|
+
// // …open `order.order_id` in the client SDK…
|
|
7
|
+
// const status = await ey.orders.fetch(order.order_id); // { paid, status, utr, … }
|
|
8
|
+
//
|
|
9
|
+
// Requires Node 18+ (global fetch).
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
export class PayulinkError extends Error {
|
|
13
|
+
constructor(message, { status, body } = {}) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'PayulinkError';
|
|
16
|
+
this.status = status;
|
|
17
|
+
this.body = body;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function safeEqual(a, b) {
|
|
22
|
+
const ba = Buffer.from(String(a || ''));
|
|
23
|
+
const bb = Buffer.from(String(b || ''));
|
|
24
|
+
return ba.length === bb.length && ba.length > 0 && crypto.timingSafeEqual(ba, bb);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default class Payulink {
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} opts
|
|
30
|
+
* @param {string} opts.keySecret Secret key (pl_sk_…). Required. Backend only.
|
|
31
|
+
* @param {string} [opts.keyId] Publishable key (pl_pk_…). Optional.
|
|
32
|
+
* @param {string} [opts.apiBase] Pay server URL. Default https://eliteyatra.vip.
|
|
33
|
+
* @param {string} [opts.webhookSecret] Secret used to verify inbound webhooks (defaults to keySecret).
|
|
34
|
+
* @param {number} [opts.timeout] Request timeout in ms. Default 15000.
|
|
35
|
+
*/
|
|
36
|
+
constructor(opts = {}) {
|
|
37
|
+
if (!opts.keySecret) throw new PayulinkError('`keySecret` (pl_sk_…) is required');
|
|
38
|
+
this.keySecret = opts.keySecret;
|
|
39
|
+
this.keyId = opts.keyId || null;
|
|
40
|
+
this.apiBase = String(opts.apiBase || 'https://eliteyatra.vip').replace(/\/$/, '');
|
|
41
|
+
this.webhookSecret = opts.webhookSecret || opts.keySecret;
|
|
42
|
+
this.timeout = opts.timeout || 15000;
|
|
43
|
+
|
|
44
|
+
this.orders = {
|
|
45
|
+
create: (params) => this._createOrder(params),
|
|
46
|
+
fetch: (orderId) => this._fetchOrder(orderId),
|
|
47
|
+
isPaid: async (orderId) => (await this._fetchOrder(orderId)).paid === true,
|
|
48
|
+
};
|
|
49
|
+
// Alias for Razorpay-familiar naming.
|
|
50
|
+
this.payments = { fetch: (orderId) => this._fetchOrder(orderId) };
|
|
51
|
+
this.webhooks = {
|
|
52
|
+
verify: (rawBody, signature, secret) => this._verifyWebhook(rawBody, signature, secret),
|
|
53
|
+
constructEvent: (rawBody, signature, secret) => this._constructEvent(rawBody, signature, secret),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async _request(method, path, { body, auth } = {}) {
|
|
58
|
+
const ctrl = new AbortController();
|
|
59
|
+
const t = setTimeout(() => ctrl.abort(), this.timeout);
|
|
60
|
+
let res;
|
|
61
|
+
try {
|
|
62
|
+
res = await fetch(`${this.apiBase}${path}`, {
|
|
63
|
+
method,
|
|
64
|
+
headers: {
|
|
65
|
+
'Content-Type': 'application/json',
|
|
66
|
+
...(auth ? { Authorization: `Bearer ${this.keySecret}` } : {}),
|
|
67
|
+
...(this.keyId ? { 'x-payulink-key': this.keyId } : {}),
|
|
68
|
+
},
|
|
69
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
70
|
+
signal: ctrl.signal,
|
|
71
|
+
});
|
|
72
|
+
} catch (e) {
|
|
73
|
+
clearTimeout(t);
|
|
74
|
+
throw new PayulinkError(`Network error: ${e.message}`);
|
|
75
|
+
}
|
|
76
|
+
clearTimeout(t);
|
|
77
|
+
let data = {};
|
|
78
|
+
try { data = await res.json(); } catch { /* non-JSON */ }
|
|
79
|
+
if (!res.ok) throw new PayulinkError(data.error || `Request failed (HTTP ${res.status})`, { status: res.status, body: data });
|
|
80
|
+
return data;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Create an order with a server-fixed amount. The client only receives the
|
|
85
|
+
* returned `order_id` and cannot change the amount.
|
|
86
|
+
* @param {object} params { amount (rupees), item?, customer? }
|
|
87
|
+
* @returns {Promise<{order_id, key_id, amount, item, status}>}
|
|
88
|
+
*/
|
|
89
|
+
_createOrder(params = {}) {
|
|
90
|
+
const amount = Math.round(Number(params.amount) || 0);
|
|
91
|
+
if (!(amount >= 1)) throw new PayulinkError('`amount` (in rupees) is required and must be >= 1');
|
|
92
|
+
return this._request('POST', '/api/orders', {
|
|
93
|
+
auth: true,
|
|
94
|
+
body: { amount, item: params.item, customer: params.customer },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Fetch the authoritative status of an order.
|
|
100
|
+
* @returns {Promise<{merchant_order_no, status, status_label, paid, utr, amount, signature}>}
|
|
101
|
+
*/
|
|
102
|
+
_fetchOrder(orderId) {
|
|
103
|
+
if (!orderId) throw new PayulinkError('orderId is required');
|
|
104
|
+
return this._request('GET', `/api/order/${encodeURIComponent(orderId)}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Verify a payment handoff signature returned to the client on success.
|
|
109
|
+
* Mirrors Razorpay's signature check.
|
|
110
|
+
* @param {object} p { order_id, utr, signature }
|
|
111
|
+
* @returns {boolean}
|
|
112
|
+
*/
|
|
113
|
+
verifyPaymentSignature({ order_id, utr, signature } = {}) {
|
|
114
|
+
if (!order_id || !signature) return false;
|
|
115
|
+
const expected = crypto.createHmac('sha256', this.keySecret).update(`${order_id}|${utr || ''}`).digest('hex');
|
|
116
|
+
return safeEqual(expected, signature);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
_verifyWebhook(rawBody, signature, secret) {
|
|
120
|
+
const expected = crypto.createHmac('sha256', secret || this.webhookSecret).update(String(rawBody)).digest('hex');
|
|
121
|
+
return safeEqual(expected, signature);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Verify an inbound webhook and return the parsed event. Throws if the
|
|
126
|
+
* signature is invalid. Pass the RAW request body (string/Buffer) and the
|
|
127
|
+
* `x-payulink-signature` header.
|
|
128
|
+
*/
|
|
129
|
+
_constructEvent(rawBody, signature, secret) {
|
|
130
|
+
if (!this._verifyWebhook(rawBody, signature, secret)) {
|
|
131
|
+
throw new PayulinkError('Invalid webhook signature', { status: 401 });
|
|
132
|
+
}
|
|
133
|
+
try { return JSON.parse(String(rawBody)); }
|
|
134
|
+
catch { throw new PayulinkError('Invalid webhook JSON', { status: 400 }); }
|
|
135
|
+
}
|
|
136
|
+
}
|