@thepayulink/server 1.0.0 → 2.0.1

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 CHANGED
@@ -1,10 +1,12 @@
1
1
  # @thepayulink/server
2
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.
3
+ Backend SDK for the [PayuLink](https://payulink.io) Checkout API. Create orders
4
+ with a **server-fixed amount**, check status, and verify payment signatures and
5
+ webhooks. This is the backend half of the checkout, analogous to the `razorpay`
6
+ Node SDK.
6
7
 
7
- > The **secret key never leaves your backend.** The client SDK only ever receives an `order_id`.
8
+ > Your **`key_secret` never leaves your backend.** The client SDK only ever
9
+ > receives an `order_id`.
8
10
 
9
11
  ## Install
10
12
 
@@ -17,118 +19,104 @@ Node 18+ (uses global `fetch`). Works with `import` and `require`.
17
19
  ## Quick start
18
20
 
19
21
  ```js
20
- import Payulink from '@thepayulink/server';
22
+ import PayuLink, { rupees } from '@thepayulink/server';
21
23
 
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',
24
+ const pl = new PayuLink({
25
+ keyId: process.env.PAYULINK_KEY_ID, // pl_live_/ pl_test_…
26
+ keySecret: process.env.PAYULINK_KEY_SECRET, // backend only never ship this
26
27
  });
27
28
 
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' },
29
+ // 1) Create an order for the TRUE amount, computed on your server.
30
+ const order = await pl.orders.create({
31
+ amount: rupees(199.5), // PAISE — 19950. rupees() keeps you honest.
32
+ receipt: 'booking_1042', // your reference; also your idempotency key
33
+ notes: { packageId: 'kailash' },
33
34
  });
34
- // -> { order_id, key_id, amount, item, status: 'created' }
35
+ // -> { id: 'order_…', amount: 19950, currency: 'INR', status: 'created', }
35
36
 
36
- // 2) Send order.order_id to your client open it in the client SDK.
37
+ // 2) Hand order.id to the client SDK (@thepayulink/checkout) with your key_id.
37
38
 
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 });
39
+ // 3) Verify the handoff before you fulfil.
40
+ app.post('/verify', express.json(), (req, res) => {
41
+ if (!pl.verifyPaymentSignature(req.body)) return res.status(400).send('bad signature');
42
+ fulfil(req.body.payulink_order_id);
43
+ res.sendStatus(200);
70
44
  });
71
45
  ```
72
46
 
73
- ## Verifying a payment
74
-
75
- Two equivalent ways, pick one:
47
+ ## Amounts are in paise
76
48
 
77
- **A. Fetch the order (simplest, always correct):**
49
+ `19950` is ₹199.50 same convention as Razorpay. Use the `rupees()` helper and
50
+ you can't get the factor of 100 wrong:
78
51
 
79
52
  ```js
80
- const s = await ey.orders.fetch(orderId);
81
- if (s.paid) { /* confirmed */ }
53
+ import { rupees, toRupees } from '@thepayulink/server';
54
+ rupees(199.5); // 19950
55
+ toRupees(19950); // 199.5
82
56
  ```
83
57
 
84
- **B. Verify the signature from the client success callback (Razorpay-style):**
58
+ ## API
85
59
 
86
- The client success payload includes `{ order_id, utr, signature }`. Verify it:
60
+ | Method | Returns | Purpose |
61
+ | --- | --- | --- |
62
+ | `orders.create({ amount, currency?, receipt?, notes? })` | order | Create an amount-fixed order. |
63
+ | `orders.fetch(orderId)` | order | Authoritative status. |
64
+ | `orders.isPaid(orderId)` | boolean | Convenience. |
65
+ | `verifyPaymentSignature({ payulink_order_id, payulink_payment_id, payulink_signature })` | boolean | Verify the client success handoff. |
66
+ | `webhooks.constructEvent(rawBody, headers)` | event | Verify + parse a webhook (throws if invalid or stale). |
87
67
 
88
- ```js
89
- const ok = ey.verifyPaymentSignature({ order_id, utr, signature });
90
- if (ok) { /* authentic success */ }
91
- ```
68
+ ### Idempotency
92
69
 
93
- ## Webhooks
70
+ `receipt` is unique per merchant. Reusing one returns the **same** order instead
71
+ of creating a second — so a double-clicked button or a retried request can't
72
+ charge twice. Reusing it with a *different* amount is a `409`.
94
73
 
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:
74
+ ### Signatures
75
+
76
+ `signature = HMAC_SHA256(order_id + "|" + payment_id, key_secret)` — the same
77
+ check as Razorpay's `razorpay_signature`. Only someone holding your `key_secret`
78
+ could have produced it, which is why the browser callback alone is never proof.
79
+
80
+ ### Webhooks
97
81
 
98
82
  ```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) => {
83
+ app.post('/webhooks/payulink', express.raw({ type: '*/*' }), (req, res) => {
101
84
  let event;
102
85
  try {
103
- event = ey.webhooks.constructEvent(req.body, req.get('x-payulink-signature'));
86
+ event = pl.webhooks.constructEvent(req.body, req.headers); // throws if bad or stale
104
87
  } 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);
88
+ return res.status(400).send(e.message);
111
89
  }
112
- res.sendStatus(200);
90
+ if (event.event_type === 'payin.verified') fulfil(event.data.merchant_order_no);
91
+ res.sendStatus(200); // 2xx = delivered; anything else is retried
113
92
  });
114
93
  ```
115
94
 
116
- Events: `payin.verified` (paid), `payin.failed`, `payin.expired`, `payin.cancelled`.
95
+ Pass the **raw** body a re-serialised object won't match the signature.
96
+ `constructEvent` also rejects timestamps outside ±5 minutes, so a captured
97
+ webhook can't be replayed later. Deliveries retry 8 times over ~30h, so
98
+ **dedupe on `X-PayuLink-Event-Id`**.
117
99
 
118
- ## API
100
+ ## Errors
119
101
 
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. |
102
+ Failures throw `PayuLinkError` with `.status`, `.code` and `.body`:
103
+
104
+ ```js
105
+ import { PayuLinkError } from '@thepayulink/server';
106
+ try { await pl.orders.create({ amount: 5 }); }
107
+ catch (e) { if (e instanceof PayuLinkError) console.error(e.code, e.message); }
108
+ ```
109
+
110
+ ## Upgrading from 1.x
129
111
 
130
- Errors throw `PayulinkError` with `.status` and `.body`.
112
+ 1.x talked to a self-hosted proxy, used `pl_sk_…` keys and **rupee** amounts.
131
113
 
132
- ## License
114
+ | 1.x | 2.x |
115
+ | --- | --- |
116
+ | `new Payulink({ keySecret: 'pl_sk_…' })` | `new PayuLink({ keyId: 'pl_live_…', keySecret })` |
117
+ | `amount: 19900` (rupees) | `amount: rupees(199)` (paise) |
118
+ | `order.order_id` | `order.id` |
119
+ | `verifyPaymentSignature({ order_id, utr, signature })` | `verifyPaymentSignature({ payulink_order_id, payulink_payment_id, payulink_signature })` |
120
+ | `webhooks.constructEvent(body, sig)` | `webhooks.constructEvent(body, headers)` — timestamp checked |
133
121
 
134
- UNLICENSED — internal to Elite Yatra.
122
+ License: MIT
package/dist/index.d.ts CHANGED
@@ -1,81 +1,100 @@
1
- export interface PayulinkConfig {
2
- /** Secret key (pl_sk_…). Required. Backend only. */
1
+ // Type definitions for @thepayulink/server
2
+
3
+ export interface PayuLinkOptions {
4
+ /** Publishable key: pl_live_… / pl_test_… */
5
+ keyId: string;
6
+ /** Secret key. BACKEND ONLY — never ship this to a browser or app. */
3
7
  keySecret: string;
4
- /** Publishable key (pl_pk_…). Optional. */
5
- keyId?: string;
6
- /** Pay server base URL. Default https://eliteyatra.vip. */
8
+ /** Gateway base URL. Default https://payulink.io/api */
7
9
  apiBase?: string;
8
- /** Secret to verify inbound webhooks (defaults to keySecret). */
10
+ /** Webhook signing secret (webhook_secret_v2). Falls back to keySecret. */
9
11
  webhookSecret?: string;
10
12
  /** Request timeout in ms. Default 15000. */
11
13
  timeout?: number;
12
14
  }
13
15
 
14
16
  export interface CreateOrderParams {
15
- /** Amount in rupees. Required. */
17
+ /** Amount in PAISE. 19950 = ₹199.50. Minimum 100. */
16
18
  amount: number;
17
- item?: string;
18
- customer?: { name?: string; mobile?: string; email?: string };
19
+ /** Default 'INR'. */
20
+ currency?: string;
21
+ /** Your reference. Unique per merchant — reusing it returns the SAME order. */
22
+ receipt?: string;
23
+ /** Arbitrary key/value metadata. */
24
+ notes?: Record<string, unknown>;
25
+ /** Optional customer prefill. */
26
+ customer?: { name?: string; contact?: string; email?: string };
27
+ /** Per-order webhook override. */
28
+ notifyUrl?: string;
19
29
  }
20
30
 
21
31
  export interface Order {
22
- order_id: string;
23
- key_id: string | null;
32
+ /** e.g. order_9f2c4b… */
33
+ id: string;
34
+ entity: 'order';
35
+ /** PAISE */
24
36
  amount: number;
25
- item: string;
26
- status: string;
37
+ amount_paid: number;
38
+ amount_due: number;
39
+ currency: string;
40
+ receipt: string | null;
41
+ status: 'created' | 'attempted' | 'paid' | 'expired' | 'failed';
42
+ mode: 'live' | 'test';
43
+ attempts: number;
44
+ notes: Record<string, unknown>;
45
+ payment_id?: string;
46
+ utr?: string;
47
+ created_at: number | null;
27
48
  }
28
49
 
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;
50
+ export interface PaymentHandoff {
51
+ payulink_order_id?: string;
52
+ payulink_payment_id?: string;
53
+ payulink_signature?: string;
54
+ /** Aliases also accepted. */
55
+ order_id?: string;
56
+ payment_id?: string;
57
+ signature?: string;
41
58
  }
42
59
 
43
60
  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
- };
61
+ id?: string;
62
+ event_type?: string;
63
+ data?: Record<string, any>;
64
+ [k: string]: any;
55
65
  }
56
66
 
57
- export class PayulinkError extends Error {
67
+ export class PayuLinkError extends Error {
58
68
  status?: number;
69
+ code?: string;
59
70
  body?: unknown;
60
71
  }
61
72
 
62
- export default class Payulink {
63
- constructor(config: PayulinkConfig);
73
+ /** Convert rupees to paise. rupees(199.5) === 19950 */
74
+ export function rupees(r: number): number;
75
+ /** Convert paise to rupees. toRupees(19950) === 199.5 */
76
+ export function toRupees(paise: number): number;
77
+
78
+ export default class PayuLink {
79
+ constructor(options: PayuLinkOptions);
80
+
81
+ readonly keyId: string;
82
+ readonly apiBase: string;
83
+ /** Derived from the key prefix. */
84
+ readonly mode: 'live' | 'test';
64
85
 
65
86
  orders: {
66
87
  create(params: CreateOrderParams): Promise<Order>;
67
- fetch(orderId: string): Promise<OrderStatus>;
88
+ fetch(orderId: string): Promise<Order>;
68
89
  isPaid(orderId: string): Promise<boolean>;
69
90
  };
70
91
 
71
- payments: {
72
- fetch(orderId: string): Promise<OrderStatus>;
73
- };
74
-
75
92
  webhooks: {
76
- verify(rawBody: string | Buffer, signature: string, secret?: string): boolean;
77
- constructEvent(rawBody: string | Buffer, signature: string, secret?: string): WebhookEvent;
93
+ verify(rawBody: string | Buffer, signature: string, timestamp: string | number, secret?: string): boolean;
94
+ /** Verifies signature + ±5min timestamp tolerance. Throws PayuLinkError if invalid. */
95
+ constructEvent(rawBody: string | Buffer, headers: Record<string, any>, secret?: string): WebhookEvent;
78
96
  };
79
97
 
80
- verifyPaymentSignature(p: { order_id: string; utr?: string | null; signature: string }): boolean;
98
+ /** signature = HMAC_SHA256(order_id + "|" + payment_id, key_secret) */
99
+ verifyPaymentSignature(p: PaymentHandoff): boolean;
81
100
  }
@@ -29,52 +29,62 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
29
29
  // src/index.js
30
30
  var src_exports = {};
31
31
  __export(src_exports, {
32
- PayulinkError: () => PayulinkError,
33
- default: () => Payulink
32
+ PayuLinkError: () => PayuLinkError,
33
+ default: () => PayuLink,
34
+ rupees: () => rupees,
35
+ toRupees: () => toRupees
34
36
  });
35
37
  module.exports = __toCommonJS(src_exports);
36
38
  var import_node_crypto = __toESM(require("node:crypto"), 1);
37
- var PayulinkError = class extends Error {
38
- constructor(message, { status, body } = {}) {
39
+ var rupees = (r) => Math.round(Number(r) * 100);
40
+ var toRupees = (paise) => Number(paise) / 100;
41
+ var PayuLinkError = class extends Error {
42
+ constructor(message, { status, code, body } = {}) {
39
43
  super(message);
40
- this.name = "PayulinkError";
44
+ this.name = "PayuLinkError";
41
45
  this.status = status;
46
+ this.code = code;
42
47
  this.body = body;
43
48
  }
44
49
  };
45
50
  function safeEqual(a, b) {
46
- const ba = Buffer.from(String(a || ""));
47
- const bb = Buffer.from(String(b || ""));
51
+ const ba = Buffer.from(String(a ?? ""));
52
+ const bb = Buffer.from(String(b ?? ""));
48
53
  return ba.length === bb.length && ba.length > 0 && import_node_crypto.default.timingSafeEqual(ba, bb);
49
54
  }
50
- var Payulink = class {
55
+ var PayuLink = class {
51
56
  /**
52
57
  * @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
+ * @param {string} opts.keyId Publishable key (pl_live_/ pl_test_…).
59
+ * @param {string} opts.keySecret Secret key. BACKEND ONLY — never ship this.
60
+ * @param {string} [opts.apiBase] Default https://payulink.io/api
61
+ * @param {string} [opts.webhookSecret] Webhook signing secret (webhook_secret_v2 from your
62
+ * merchant panel). Falls back to keySecret.
63
+ * @param {number} [opts.timeout] Request timeout ms. Default 15000.
58
64
  */
59
65
  constructor(opts = {}) {
60
- if (!opts.keySecret) throw new PayulinkError("`keySecret` (pl_sk_\u2026) is required");
66
+ if (!opts.keyId) throw new PayuLinkError("`keyId` (pl_live_\u2026 / pl_test_\u2026) is required");
67
+ if (!opts.keySecret) throw new PayuLinkError("`keySecret` is required and must stay on your server");
68
+ this.keyId = opts.keyId;
61
69
  this.keySecret = opts.keySecret;
62
- this.keyId = opts.keyId || null;
63
- this.apiBase = String(opts.apiBase || "https://eliteyatra.vip").replace(/\/$/, "");
70
+ this.apiBase = String(opts.apiBase || "https://payulink.io/api").replace(/\/$/, "");
64
71
  this.webhookSecret = opts.webhookSecret || opts.keySecret;
65
72
  this.timeout = opts.timeout || 15e3;
73
+ this.mode = /^pl_test_/.test(opts.keyId) ? "test" : "live";
66
74
  this.orders = {
67
75
  create: (params) => this._createOrder(params),
68
76
  fetch: (orderId) => this._fetchOrder(orderId),
69
- isPaid: async (orderId) => (await this._fetchOrder(orderId)).paid === true
77
+ isPaid: async (orderId) => (await this._fetchOrder(orderId)).status === "paid"
70
78
  };
71
- this.payments = { fetch: (orderId) => this._fetchOrder(orderId) };
72
79
  this.webhooks = {
73
- verify: (rawBody, signature, secret) => this._verifyWebhook(rawBody, signature, secret),
74
- constructEvent: (rawBody, signature, secret) => this._constructEvent(rawBody, signature, secret)
80
+ verify: (rawBody, signature, timestamp, secret) => this._verifyWebhook(rawBody, signature, timestamp, secret),
81
+ constructEvent: (rawBody, headers, secret) => this._constructEvent(rawBody, headers, secret)
75
82
  };
76
83
  }
77
- async _request(method, path, { body, auth } = {}) {
84
+ get _authHeader() {
85
+ return "Basic " + Buffer.from(`${this.keyId}:${this.keySecret}`).toString("base64");
86
+ }
87
+ async _request(method, path, { body } = {}) {
78
88
  const ctrl = new AbortController();
79
89
  const t = setTimeout(() => ctrl.abort(), this.timeout);
80
90
  let res;
@@ -83,15 +93,14 @@ var Payulink = class {
83
93
  method,
84
94
  headers: {
85
95
  "Content-Type": "application/json",
86
- ...auth ? { Authorization: `Bearer ${this.keySecret}` } : {},
87
- ...this.keyId ? { "x-payulink-key": this.keyId } : {}
96
+ Authorization: this._authHeader
88
97
  },
89
98
  body: body ? JSON.stringify(body) : void 0,
90
99
  signal: ctrl.signal
91
100
  });
92
101
  } catch (e) {
93
102
  clearTimeout(t);
94
- throw new PayulinkError(`Network error: ${e.message}`);
103
+ throw new PayuLinkError(`Network error: ${e.message}`);
95
104
  }
96
105
  clearTimeout(t);
97
106
  let data = {};
@@ -99,64 +108,122 @@ var Payulink = class {
99
108
  data = await res.json();
100
109
  } catch {
101
110
  }
102
- if (!res.ok) throw new PayulinkError(data.error || `Request failed (HTTP ${res.status})`, { status: res.status, body: data });
111
+ if (!res.ok) {
112
+ const err = data && data.error ? data.error : {};
113
+ throw new PayuLinkError(
114
+ err.description || `Request failed (HTTP ${res.status})`,
115
+ { status: res.status, code: err.code, body: data }
116
+ );
117
+ }
103
118
  return data;
104
119
  }
105
120
  /**
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}>}
121
+ * Create an order with a server-fixed amount.
122
+ * @param {object} p
123
+ * @param {number} p.amount Amount in PAISE (19950 = ₹199.50). Minimum 100.
124
+ * @param {string} [p.currency] INR (default).
125
+ * @param {string} [p.receipt] Your reference. Unique per merchant — reusing it
126
+ * returns the SAME order, which makes retries safe.
127
+ * @param {object} [p.notes] Arbitrary key/value metadata.
110
128
  */
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 }
129
+ _createOrder(p = {}) {
130
+ const amount = Math.round(Number(p.amount));
131
+ if (!Number.isFinite(amount) || amount < 100) {
132
+ throw new PayuLinkError("`amount` is required, in PAISE, and must be >= 100 (\u20B91). Tip: use the `rupees()` helper \u2014 rupees(199.5) === 19950.");
133
+ }
134
+ return this._request("POST", "/v1/orders", {
135
+ body: {
136
+ amount,
137
+ currency: p.currency || "INR",
138
+ receipt: p.receipt,
139
+ notes: p.notes,
140
+ customer_name: p.customer?.name,
141
+ customer_mobile: p.customer?.contact,
142
+ customer_email: p.customer?.email,
143
+ notify_url: p.notifyUrl
144
+ }
117
145
  });
118
146
  }
119
- /**
120
- * Fetch the authoritative status of an order.
121
- * @returns {Promise<{merchant_order_no, status, status_label, paid, utr, amount, signature}>}
122
- */
147
+ /** Authoritative order status. */
123
148
  _fetchOrder(orderId) {
124
- if (!orderId) throw new PayulinkError("orderId is required");
125
- return this._request("GET", `/api/order/${encodeURIComponent(orderId)}`);
149
+ if (!orderId) throw new PayuLinkError("orderId is required");
150
+ return this._request("GET", `/v1/orders/${encodeURIComponent(orderId)}`);
126
151
  }
127
152
  /**
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}
153
+ * Verify the success handoff the client SDK returns. Mirrors Razorpay's
154
+ * `validatePaymentVerification`.
155
+ *
156
+ * signature = hex(HMAC_SHA256(order_id + "|" + payment_id, key_secret))
157
+ *
158
+ * ALWAYS do this on your server before fulfilling. The client callback alone
159
+ * is not proof of payment.
160
+ *
161
+ * @param {object} p { payulink_order_id, payulink_payment_id, payulink_signature }
162
+ * (also accepts order_id / payment_id / signature)
132
163
  */
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");
164
+ verifyPaymentSignature(p = {}) {
165
+ const orderId = p.payulink_order_id || p.order_id;
166
+ const paymentId = p.payulink_payment_id || p.payment_id;
167
+ const signature = p.payulink_signature || p.signature;
168
+ if (!orderId || !paymentId || !signature) return false;
169
+ const expected = import_node_crypto.default.createHmac("sha256", this.keySecret).update(`${orderId}|${paymentId}`).digest("hex");
136
170
  return safeEqual(expected, signature);
137
171
  }
138
- _verifyWebhook(rawBody, signature, secret) {
139
- const expected = import_node_crypto.default.createHmac("sha256", secret || this.webhookSecret).update(String(rawBody)).digest("hex");
172
+ /**
173
+ * Verify a webhook. PayuLink signs webhooks Stripe-style:
174
+ * X-PayuLink-Signature: sha256=<hex> over `${timestamp}.${rawBody}`
175
+ * X-PayuLink-Timestamp: <epoch ms>
176
+ */
177
+ _verifyWebhook(rawBody, signature, timestamp, secret) {
178
+ if (!signature || !timestamp) return false;
179
+ const key = secret || this.webhookSecret;
180
+ const expected = "sha256=" + import_node_crypto.default.createHmac("sha256", key).update(`${timestamp}.${String(rawBody)}`).digest("hex");
140
181
  return safeEqual(expected, signature);
141
182
  }
142
183
  /**
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.
184
+ * Verify + parse a webhook. Throws if the signature is bad or the timestamp is
185
+ * outside the tolerance window (replay protection).
186
+ *
187
+ * Pass the RAW body (string/Buffer) — a re-serialised object will not match.
188
+ *
189
+ * app.post('/webhook', express.raw({type:'*\/*'}), (req, res) => {
190
+ * const event = pl.webhooks.constructEvent(req.body, req.headers);
191
+ * if (event.event_type === 'payin.verified') fulfil(event);
192
+ * res.sendStatus(200);
193
+ * });
194
+ *
195
+ * @param {string|Buffer} rawBody
196
+ * @param {object} headers The request headers object.
197
+ * @param {string} [secret]
198
+ * @param {number} [toleranceSec=300]
146
199
  */
147
- _constructEvent(rawBody, signature, secret) {
148
- if (!this._verifyWebhook(rawBody, signature, secret)) {
149
- throw new PayulinkError("Invalid webhook signature", { status: 401 });
200
+ _constructEvent(rawBody, headers = {}, secret, toleranceSec = 300) {
201
+ const get = (n) => headers[n] || headers[n.toLowerCase()] || (typeof headers.get === "function" ? headers.get(n) : void 0);
202
+ const signature = get("X-PayuLink-Signature");
203
+ const timestamp = get("X-PayuLink-Timestamp");
204
+ if (!signature) throw new PayuLinkError("Missing X-PayuLink-Signature", { status: 401 });
205
+ if (!timestamp) throw new PayuLinkError("Missing X-PayuLink-Timestamp", { status: 401 });
206
+ const ageSec = Math.abs(Date.now() - Number(timestamp)) / 1e3;
207
+ if (!Number.isFinite(ageSec) || ageSec > toleranceSec) {
208
+ throw new PayuLinkError(`Webhook timestamp outside tolerance (${Math.round(ageSec)}s)`, { status: 401 });
209
+ }
210
+ if (!this._verifyWebhook(rawBody, signature, timestamp, secret)) {
211
+ throw new PayuLinkError("Invalid webhook signature", { status: 401 });
150
212
  }
151
213
  try {
152
- return JSON.parse(String(rawBody));
214
+ const event = JSON.parse(String(rawBody));
215
+ event.id = event.id || get("X-PayuLink-Event-Id");
216
+ event.event_type = event.event_type || get("X-PayuLink-Event-Type");
217
+ return event;
153
218
  } catch {
154
- throw new PayulinkError("Invalid webhook JSON", { status: 400 });
219
+ throw new PayuLinkError("Invalid webhook JSON", { status: 400 });
155
220
  }
156
221
  }
157
222
  };
158
223
  // Annotate the CommonJS export names for ESM import in node:
159
224
  0 && (module.exports = {
160
- PayulinkError
225
+ PayuLinkError,
226
+ rupees,
227
+ toRupees
161
228
  });
162
229
  (()=>{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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
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.",
3
+ "version": "2.0.1",
4
+ "description": "PayuLink server SDK — create orders and verify payments/webhooks with your key_secret.",
5
5
  "type": "module",
6
6
  "main": "./dist/payulink-server.cjs",
7
7
  "module": "./src/index.js",
@@ -22,10 +22,10 @@
22
22
  "node": ">=18"
23
23
  },
24
24
  "scripts": {
25
- "build": "node build.mjs"
25
+ "build": "node build.mjs",
26
+ "prepublishOnly": "npm run build"
26
27
  },
27
28
  "keywords": [
28
- "eliteyatra",
29
29
  "payulink",
30
30
  "payment",
31
31
  "server",
@@ -34,9 +34,10 @@
34
34
  "orders",
35
35
  "webhooks"
36
36
  ],
37
- "author": "Elite Yatra",
37
+ "author": "PayuLink",
38
38
  "license": "MIT",
39
39
  "devDependencies": {
40
40
  "esbuild": "^0.23.0"
41
- }
41
+ },
42
+ "homepage": "https://payulink.io"
42
43
  }
package/src/index.js CHANGED
@@ -1,60 +1,77 @@
1
- // @thepayulink/server — backend SDK for Elite Yatra / PayuLink.
1
+ // @thepayulink/server — backend SDK for the PayuLink Checkout API (v1).
2
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, … }
3
+ // import PayuLink from '@thepayulink/server';
4
+ // const pl = new PayuLink({ keyId: 'pl_live_…', keySecret: '…' });
5
+ //
6
+ // // 1. Create an order on YOUR server. The amount is fixed here and the
7
+ // // client can never change it.
8
+ // const order = await pl.orders.create({ amount: 19950, receipt: 'rcpt#1' }); // ₹199.50 in PAISE
9
+ //
10
+ // // 2. Hand order.id to the client SDK, which opens it with the PUBLISHABLE key_id.
11
+ //
12
+ // // 3. Verify the success handoff before you fulfil anything.
13
+ // if (pl.verifyPaymentSignature(req.body)) fulfil(order.id);
8
14
  //
9
15
  // Requires Node 18+ (global fetch).
10
16
  import crypto from 'node:crypto';
11
17
 
12
- export class PayulinkError extends Error {
13
- constructor(message, { status, body } = {}) {
18
+ /** Amount helpers the API speaks PAISE, like Razorpay. */
19
+ export const rupees = (r) => Math.round(Number(r) * 100);
20
+ export const toRupees = (paise) => Number(paise) / 100;
21
+
22
+ export class PayuLinkError extends Error {
23
+ constructor(message, { status, code, body } = {}) {
14
24
  super(message);
15
- this.name = 'PayulinkError';
25
+ this.name = 'PayuLinkError';
16
26
  this.status = status;
27
+ this.code = code;
17
28
  this.body = body;
18
29
  }
19
30
  }
20
31
 
21
32
  function safeEqual(a, b) {
22
- const ba = Buffer.from(String(a || ''));
23
- const bb = Buffer.from(String(b || ''));
33
+ const ba = Buffer.from(String(a ?? ''));
34
+ const bb = Buffer.from(String(b ?? ''));
24
35
  return ba.length === bb.length && ba.length > 0 && crypto.timingSafeEqual(ba, bb);
25
36
  }
26
37
 
27
- export default class Payulink {
38
+ export default class PayuLink {
28
39
  /**
29
40
  * @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.
41
+ * @param {string} opts.keyId Publishable key (pl_live_/ pl_test_…).
42
+ * @param {string} opts.keySecret Secret key. BACKEND ONLY — never ship this.
43
+ * @param {string} [opts.apiBase] Default https://payulink.io/api
44
+ * @param {string} [opts.webhookSecret] Webhook signing secret (webhook_secret_v2 from your
45
+ * merchant panel). Falls back to keySecret.
46
+ * @param {number} [opts.timeout] Request timeout ms. Default 15000.
35
47
  */
36
48
  constructor(opts = {}) {
37
- if (!opts.keySecret) throw new PayulinkError('`keySecret` (pl_sk_…) is required');
49
+ if (!opts.keyId) throw new PayuLinkError('`keyId` (pl_live_ / pl_test_…) is required');
50
+ if (!opts.keySecret) throw new PayuLinkError('`keySecret` is required and must stay on your server');
51
+ this.keyId = opts.keyId;
38
52
  this.keySecret = opts.keySecret;
39
- this.keyId = opts.keyId || null;
40
- this.apiBase = String(opts.apiBase || 'https://eliteyatra.vip').replace(/\/$/, '');
53
+ this.apiBase = String(opts.apiBase || 'https://payulink.io/api').replace(/\/$/, '');
41
54
  this.webhookSecret = opts.webhookSecret || opts.keySecret;
42
55
  this.timeout = opts.timeout || 15000;
56
+ this.mode = /^pl_test_/.test(opts.keyId) ? 'test' : 'live';
43
57
 
44
58
  this.orders = {
45
59
  create: (params) => this._createOrder(params),
46
60
  fetch: (orderId) => this._fetchOrder(orderId),
47
- isPaid: async (orderId) => (await this._fetchOrder(orderId)).paid === true,
61
+ isPaid: async (orderId) => (await this._fetchOrder(orderId)).status === 'paid',
48
62
  };
49
- // Alias for Razorpay-familiar naming.
50
- this.payments = { fetch: (orderId) => this._fetchOrder(orderId) };
51
63
  this.webhooks = {
52
- verify: (rawBody, signature, secret) => this._verifyWebhook(rawBody, signature, secret),
53
- constructEvent: (rawBody, signature, secret) => this._constructEvent(rawBody, signature, secret),
64
+ verify: (rawBody, signature, timestamp, secret) =>
65
+ this._verifyWebhook(rawBody, signature, timestamp, secret),
66
+ constructEvent: (rawBody, headers, secret) => this._constructEvent(rawBody, headers, secret),
54
67
  };
55
68
  }
56
69
 
57
- async _request(method, path, { body, auth } = {}) {
70
+ get _authHeader() {
71
+ return 'Basic ' + Buffer.from(`${this.keyId}:${this.keySecret}`).toString('base64');
72
+ }
73
+
74
+ async _request(method, path, { body } = {}) {
58
75
  const ctrl = new AbortController();
59
76
  const t = setTimeout(() => ctrl.abort(), this.timeout);
60
77
  let res;
@@ -63,74 +80,137 @@ export default class Payulink {
63
80
  method,
64
81
  headers: {
65
82
  'Content-Type': 'application/json',
66
- ...(auth ? { Authorization: `Bearer ${this.keySecret}` } : {}),
67
- ...(this.keyId ? { 'x-payulink-key': this.keyId } : {}),
83
+ Authorization: this._authHeader,
68
84
  },
69
85
  body: body ? JSON.stringify(body) : undefined,
70
86
  signal: ctrl.signal,
71
87
  });
72
88
  } catch (e) {
73
89
  clearTimeout(t);
74
- throw new PayulinkError(`Network error: ${e.message}`);
90
+ throw new PayuLinkError(`Network error: ${e.message}`);
75
91
  }
76
92
  clearTimeout(t);
93
+
77
94
  let data = {};
78
95
  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 });
96
+ if (!res.ok) {
97
+ const err = data && data.error ? data.error : {};
98
+ throw new PayuLinkError(err.description || `Request failed (HTTP ${res.status})`,
99
+ { status: res.status, code: err.code, body: data });
100
+ }
80
101
  return data;
81
102
  }
82
103
 
83
104
  /**
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}>}
105
+ * Create an order with a server-fixed amount.
106
+ * @param {object} p
107
+ * @param {number} p.amount Amount in PAISE (19950 = ₹199.50). Minimum 100.
108
+ * @param {string} [p.currency] INR (default).
109
+ * @param {string} [p.receipt] Your reference. Unique per merchant — reusing it
110
+ * returns the SAME order, which makes retries safe.
111
+ * @param {object} [p.notes] Arbitrary key/value metadata.
88
112
  */
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 },
113
+ _createOrder(p = {}) {
114
+ const amount = Math.round(Number(p.amount));
115
+ if (!Number.isFinite(amount) || amount < 100) {
116
+ throw new PayuLinkError('`amount` is required, in PAISE, and must be >= 100 (₹1). '
117
+ + 'Tip: use the `rupees()` helper — rupees(199.5) === 19950.');
118
+ }
119
+ return this._request('POST', '/v1/orders', {
120
+ body: {
121
+ amount,
122
+ currency: p.currency || 'INR',
123
+ receipt: p.receipt,
124
+ notes: p.notes,
125
+ customer_name: p.customer?.name,
126
+ customer_mobile: p.customer?.contact,
127
+ customer_email: p.customer?.email,
128
+ notify_url: p.notifyUrl,
129
+ },
95
130
  });
96
131
  }
97
132
 
98
- /**
99
- * Fetch the authoritative status of an order.
100
- * @returns {Promise<{merchant_order_no, status, status_label, paid, utr, amount, signature}>}
101
- */
133
+ /** Authoritative order status. */
102
134
  _fetchOrder(orderId) {
103
- if (!orderId) throw new PayulinkError('orderId is required');
104
- return this._request('GET', `/api/order/${encodeURIComponent(orderId)}`);
135
+ if (!orderId) throw new PayuLinkError('orderId is required');
136
+ return this._request('GET', `/v1/orders/${encodeURIComponent(orderId)}`);
105
137
  }
106
138
 
107
139
  /**
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}
140
+ * Verify the success handoff the client SDK returns. Mirrors Razorpay's
141
+ * `validatePaymentVerification`.
142
+ *
143
+ * signature = hex(HMAC_SHA256(order_id + "|" + payment_id, key_secret))
144
+ *
145
+ * ALWAYS do this on your server before fulfilling. The client callback alone
146
+ * is not proof of payment.
147
+ *
148
+ * @param {object} p { payulink_order_id, payulink_payment_id, payulink_signature }
149
+ * (also accepts order_id / payment_id / signature)
112
150
  */
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');
151
+ verifyPaymentSignature(p = {}) {
152
+ const orderId = p.payulink_order_id || p.order_id;
153
+ const paymentId = p.payulink_payment_id || p.payment_id;
154
+ const signature = p.payulink_signature || p.signature;
155
+ if (!orderId || !paymentId || !signature) return false;
156
+ const expected = crypto.createHmac('sha256', this.keySecret)
157
+ .update(`${orderId}|${paymentId}`).digest('hex');
116
158
  return safeEqual(expected, signature);
117
159
  }
118
160
 
119
- _verifyWebhook(rawBody, signature, secret) {
120
- const expected = crypto.createHmac('sha256', secret || this.webhookSecret).update(String(rawBody)).digest('hex');
161
+ /**
162
+ * Verify a webhook. PayuLink signs webhooks Stripe-style:
163
+ * X-PayuLink-Signature: sha256=<hex> over `${timestamp}.${rawBody}`
164
+ * X-PayuLink-Timestamp: <epoch ms>
165
+ */
166
+ _verifyWebhook(rawBody, signature, timestamp, secret) {
167
+ if (!signature || !timestamp) return false;
168
+ const key = secret || this.webhookSecret;
169
+ const expected = 'sha256=' + crypto.createHmac('sha256', key)
170
+ .update(`${timestamp}.${String(rawBody)}`).digest('hex');
121
171
  return safeEqual(expected, signature);
122
172
  }
123
173
 
124
174
  /**
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.
175
+ * Verify + parse a webhook. Throws if the signature is bad or the timestamp is
176
+ * outside the tolerance window (replay protection).
177
+ *
178
+ * Pass the RAW body (string/Buffer) — a re-serialised object will not match.
179
+ *
180
+ * app.post('/webhook', express.raw({type:'*\/*'}), (req, res) => {
181
+ * const event = pl.webhooks.constructEvent(req.body, req.headers);
182
+ * if (event.event_type === 'payin.verified') fulfil(event);
183
+ * res.sendStatus(200);
184
+ * });
185
+ *
186
+ * @param {string|Buffer} rawBody
187
+ * @param {object} headers The request headers object.
188
+ * @param {string} [secret]
189
+ * @param {number} [toleranceSec=300]
128
190
  */
129
- _constructEvent(rawBody, signature, secret) {
130
- if (!this._verifyWebhook(rawBody, signature, secret)) {
131
- throw new PayulinkError('Invalid webhook signature', { status: 401 });
191
+ _constructEvent(rawBody, headers = {}, secret, toleranceSec = 300) {
192
+ const get = (n) => headers[n] || headers[n.toLowerCase()] ||
193
+ (typeof headers.get === 'function' ? headers.get(n) : undefined);
194
+ const signature = get('X-PayuLink-Signature');
195
+ const timestamp = get('X-PayuLink-Timestamp');
196
+ if (!signature) throw new PayuLinkError('Missing X-PayuLink-Signature', { status: 401 });
197
+ if (!timestamp) throw new PayuLinkError('Missing X-PayuLink-Timestamp', { status: 401 });
198
+
199
+ // Replay window: a captured webhook must not be replayable forever.
200
+ const ageSec = Math.abs(Date.now() - Number(timestamp)) / 1000;
201
+ if (!Number.isFinite(ageSec) || ageSec > toleranceSec) {
202
+ throw new PayuLinkError(`Webhook timestamp outside tolerance (${Math.round(ageSec)}s)`, { status: 401 });
203
+ }
204
+ if (!this._verifyWebhook(rawBody, signature, timestamp, secret)) {
205
+ throw new PayuLinkError('Invalid webhook signature', { status: 401 });
206
+ }
207
+ try {
208
+ const event = JSON.parse(String(rawBody));
209
+ event.id = event.id || get('X-PayuLink-Event-Id');
210
+ event.event_type = event.event_type || get('X-PayuLink-Event-Type');
211
+ return event;
212
+ } catch {
213
+ throw new PayuLinkError('Invalid webhook JSON', { status: 400 });
132
214
  }
133
- try { return JSON.parse(String(rawBody)); }
134
- catch { throw new PayulinkError('Invalid webhook JSON', { status: 400 }); }
135
215
  }
136
216
  }