@thepayulink/checkout 1.0.0 → 2.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 +63 -73
- package/dist/payulink-checkout.cjs +42 -334
- package/dist/payulink-checkout.esm.js +42 -334
- package/dist/payulink-checkout.umd.js +42 -334
- package/package.json +5 -9
- package/src/index.js +130 -94
- package/src/overlay.js +141 -0
- package/src/modal.js +0 -362
- package/src/style.js +0 -251
package/src/index.js
CHANGED
|
@@ -1,122 +1,158 @@
|
|
|
1
|
-
// @thepayulink/checkout —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
1
|
+
// @thepayulink/checkout — browser checkout for the PayuLink Checkout API (v1).
|
|
2
|
+
//
|
|
3
|
+
// import PayuLink from '@thepayulink/checkout';
|
|
4
|
+
//
|
|
5
|
+
// const pl = new PayuLink({ key: 'pl_live_…' }); // PUBLISHABLE key only
|
|
6
|
+
// pl.open({
|
|
7
|
+
// order_id: orderId, // created by YOUR server
|
|
8
|
+
// prefill: { name: 'A Kumar', contact: '9876543210' },
|
|
9
|
+
// handler: (r) => {
|
|
10
|
+
// // POST these three to your server and verify the signature there.
|
|
11
|
+
// // r.payulink_order_id, r.payulink_payment_id, r.payulink_signature
|
|
12
|
+
// },
|
|
13
|
+
// onDismiss: () => {},
|
|
14
|
+
// });
|
|
15
|
+
//
|
|
16
|
+
// The secret key is NEVER used here. The amount always comes from the stored
|
|
17
|
+
// order, so nothing the browser does can change what is charged.
|
|
18
|
+
import { openOverlay } from './overlay.js';
|
|
5
19
|
|
|
6
20
|
const DEFAULTS = {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
// Brand shown on the left panel.
|
|
12
|
-
name: 'Elite Yatra',
|
|
13
|
-
logo: null, // falls back to `${assetBase}/payulink-logo.png`
|
|
14
|
-
brandColor: null, // accent for the left panel gradient, e.g. '#0b3068'
|
|
21
|
+
apiBase: 'https://payulink.io/api',
|
|
22
|
+
name: null, // falls back to the merchant name on the order
|
|
23
|
+
theme: { color: '#38BDF8' },
|
|
24
|
+
pollMs: 2500,
|
|
15
25
|
};
|
|
16
26
|
|
|
17
|
-
|
|
18
|
-
const n = Math.round(Number(amount) || 0);
|
|
19
|
-
if (!(n >= 1)) throw new Error('PayulinkCheckout: `amount` (in rupees) is required and must be >= 1');
|
|
20
|
-
return Math.min(10000000, n);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export default class PayulinkCheckout {
|
|
27
|
+
export default class PayuLink {
|
|
24
28
|
constructor(options = {}) {
|
|
25
29
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
26
|
-
throw new Error('
|
|
30
|
+
throw new Error('PayuLink checkout runs in the browser only. On React Native use '
|
|
31
|
+
+ '@thepayulink/checkout-react-native; on a server use @thepayulink/server.');
|
|
32
|
+
}
|
|
33
|
+
const key = options.key || options.keyId || options.key_id;
|
|
34
|
+
if (!key) throw new Error('PayuLink: `key` (your publishable pl_live_… / pl_test_… key) is required');
|
|
35
|
+
if (/^pl_(live|test)_/.test(key) === false) {
|
|
36
|
+
console.warn('PayuLink: `key` should look like pl_live_… or pl_test_…');
|
|
27
37
|
}
|
|
28
|
-
|
|
38
|
+
// Guard the classic footgun.
|
|
39
|
+
if (options.keySecret || options.key_secret) {
|
|
40
|
+
throw new Error('PayuLink: never pass a key_secret to the browser SDK. It belongs on your server only.');
|
|
41
|
+
}
|
|
42
|
+
this.options = Object.assign({}, DEFAULTS, options, { key });
|
|
29
43
|
}
|
|
30
44
|
|
|
31
45
|
/**
|
|
32
|
-
* Open the checkout
|
|
33
|
-
* @param {object}
|
|
34
|
-
* @param {
|
|
35
|
-
* @param {
|
|
36
|
-
* @param {
|
|
37
|
-
* @param {
|
|
38
|
-
* @param {function} [
|
|
39
|
-
* @param {function} [payment.onDismiss] Called when the user closes the modal without paying.
|
|
40
|
-
* @param {string} [payment.key] Optional public key sent as `x-payulink-key` header.
|
|
46
|
+
* Open the checkout for a server-created order.
|
|
47
|
+
* @param {object} p
|
|
48
|
+
* @param {string} p.order_id Required. From POST /v1/orders on your backend.
|
|
49
|
+
* @param {object} [p.prefill] { name, contact, email }
|
|
50
|
+
* @param {function} [p.handler] Called on success with the signed handoff.
|
|
51
|
+
* @param {function} [p.onDismiss] Called if the customer closes without paying.
|
|
52
|
+
* @param {function} [p.onError]
|
|
41
53
|
* @returns {{ close: () => void }}
|
|
42
54
|
*/
|
|
43
|
-
open(
|
|
44
|
-
const o = Object.assign({}, this.options,
|
|
55
|
+
open(p = {}) {
|
|
56
|
+
const o = Object.assign({}, this.options, p);
|
|
45
57
|
const apiBase = String(o.apiBase || '').replace(/\/$/, '');
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const key = o.key || o.publicKey;
|
|
49
|
-
const orderId = o.orderId || o.order_id || null;
|
|
58
|
+
const orderId = o.order_id || o.orderId;
|
|
59
|
+
if (!orderId) throw new Error('PayuLink: `order_id` is required. Create it on your server with POST /v1/orders.');
|
|
50
60
|
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
let data = {};
|
|
55
|
-
try { data = await res.json(); } catch { throw new Error(`Server busy (HTTP ${res.status})`); }
|
|
56
|
-
if (!res.ok) throw new Error(data.error || 'Could not start payment');
|
|
57
|
-
return data;
|
|
61
|
+
const onError = (e) => {
|
|
62
|
+
if (typeof o.onError === 'function') o.onError(e);
|
|
63
|
+
else console.error('PayuLink:', e.message || e);
|
|
58
64
|
};
|
|
59
65
|
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
try {
|
|
63
|
-
es = new EventSource(`${apiBase}/api/order/${orderNo}/events`);
|
|
64
|
-
es.onmessage = (e) => { try { onUpdate(JSON.parse(e.data)); } catch {} };
|
|
65
|
-
es.onerror = () => {};
|
|
66
|
-
} catch {}
|
|
67
|
-
const poll = setInterval(async () => {
|
|
68
|
-
try { onUpdate(await fetch(`${apiBase}/api/order/${orderNo}`).then((r) => r.json())); } catch {}
|
|
69
|
-
}, 20000);
|
|
70
|
-
return () => { clearInterval(poll); if (es) es.close(); };
|
|
71
|
-
};
|
|
66
|
+
const handle = { _overlay: null, _stop: null, close() { this._stop?.(); this._overlay?.close(); } };
|
|
67
|
+
let settled = false;
|
|
72
68
|
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
watchOrder,
|
|
81
|
-
onSuccess: (info) => { if (typeof o.handler === 'function') o.handler(info); if (typeof o.onSuccess === 'function') o.onSuccess(info); },
|
|
82
|
-
onDismiss: () => { if (typeof o.onDismiss === 'function') o.onDismiss(); },
|
|
69
|
+
const finish = (result) => {
|
|
70
|
+
if (settled) return;
|
|
71
|
+
settled = true;
|
|
72
|
+
handle._stop?.();
|
|
73
|
+
handle._overlay?.close();
|
|
74
|
+
if (typeof o.handler === 'function') o.handler(result);
|
|
75
|
+
if (typeof o.onSuccess === 'function') o.onSuccess(result);
|
|
83
76
|
};
|
|
84
|
-
const onError = (e) => { if (typeof o.onError === 'function') o.onError(e); else console.error('PayulinkCheckout:', e.message); };
|
|
85
77
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
78
|
+
(async () => {
|
|
79
|
+
// 1. Open a session with the PUBLISHABLE key. No secret, ever.
|
|
80
|
+
let session;
|
|
81
|
+
try {
|
|
82
|
+
const res = await fetch(`${apiBase}/v1/checkout/session`, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'Content-Type': 'application/json' },
|
|
85
|
+
body: JSON.stringify({
|
|
86
|
+
key_id: o.key,
|
|
87
|
+
order_id: orderId,
|
|
88
|
+
customer: o.prefill ? {
|
|
89
|
+
name: o.prefill.name,
|
|
90
|
+
contact: o.prefill.contact,
|
|
91
|
+
email: o.prefill.email,
|
|
92
|
+
} : undefined,
|
|
93
|
+
}),
|
|
94
|
+
});
|
|
95
|
+
session = await res.json().catch(() => ({}));
|
|
96
|
+
if (!res.ok) {
|
|
97
|
+
const err = session?.error || {};
|
|
98
|
+
throw new Error(err.description || `Could not start payment (HTTP ${res.status})`);
|
|
99
|
+
}
|
|
100
|
+
} catch (e) {
|
|
101
|
+
return onError(e);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 2. Show the hosted payment page. It already handles the QR, UPI intent
|
|
105
|
+
// and auto-verification — we deliberately don't reimplement that here.
|
|
106
|
+
handle._overlay = openOverlay({
|
|
107
|
+
url: session.payment_link,
|
|
108
|
+
upiIntentUrl: session.upi_intent_url,
|
|
109
|
+
amount: session.amount,
|
|
110
|
+
currency: session.currency,
|
|
111
|
+
merchantName: o.name || session.merchant_name,
|
|
112
|
+
themeColor: (o.theme && o.theme.color) || DEFAULTS.theme.color,
|
|
113
|
+
mode: session.mode,
|
|
114
|
+
onDismiss: () => {
|
|
115
|
+
if (settled) return;
|
|
116
|
+
settled = true;
|
|
117
|
+
handle._stop?.();
|
|
118
|
+
if (typeof o.onDismiss === 'function') o.onDismiss();
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// 3. Poll for the authoritative result. The status endpoint is the only
|
|
123
|
+
// place the signed handoff is released, and only for this session.
|
|
124
|
+
const poll = setInterval(async () => {
|
|
125
|
+
try {
|
|
126
|
+
const r = await fetch(`${apiBase}/v1/checkout/session/${encodeURIComponent(session.session_token)}`);
|
|
127
|
+
const s = await r.json().catch(() => ({}));
|
|
128
|
+
if (s.status === 'paid' && s.payulink_signature) {
|
|
129
|
+
finish({
|
|
130
|
+
payulink_order_id: s.payulink_order_id,
|
|
131
|
+
payulink_payment_id: s.payulink_payment_id,
|
|
132
|
+
payulink_signature: s.payulink_signature,
|
|
133
|
+
utr: s.utr,
|
|
134
|
+
amount: s.amount,
|
|
135
|
+
});
|
|
136
|
+
} else if (s.status === 'failed' || s.status === 'expired') {
|
|
137
|
+
clearInterval(poll);
|
|
138
|
+
handle._overlay?.close();
|
|
139
|
+
onError(new Error(`Payment ${s.status}`));
|
|
140
|
+
}
|
|
141
|
+
} catch { /* transient — keep polling */ }
|
|
142
|
+
}, o.pollMs);
|
|
143
|
+
handle._stop = () => clearInterval(poll);
|
|
144
|
+
})();
|
|
102
145
|
|
|
103
|
-
|
|
104
|
-
const amount = normalizeAmount(o.amount);
|
|
105
|
-
const item = o.item || o.description || o.name;
|
|
106
|
-
return openModal(Object.assign({}, common, {
|
|
107
|
-
amount,
|
|
108
|
-
description: item,
|
|
109
|
-
createOrder: (customer) => post({ amount, item, customer }),
|
|
110
|
-
}));
|
|
146
|
+
return handle;
|
|
111
147
|
}
|
|
112
148
|
|
|
113
|
-
/** Convenience: build + open in one call. */
|
|
114
149
|
static open(options = {}) {
|
|
115
|
-
return new
|
|
150
|
+
return new PayuLink(options).open(options);
|
|
116
151
|
}
|
|
117
152
|
}
|
|
118
153
|
|
|
119
|
-
// UMD/global convenience when loaded via a plain <script> (esbuild build attaches this too).
|
|
120
154
|
if (typeof window !== 'undefined') {
|
|
121
|
-
window.
|
|
155
|
+
window.PayuLink = PayuLink;
|
|
156
|
+
// Back-compat with the older global name.
|
|
157
|
+
window.PayulinkCheckout = PayuLink;
|
|
122
158
|
}
|
package/src/overlay.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Checkout overlay — hosts the PayuLink payment page in a focused modal.
|
|
2
|
+
//
|
|
3
|
+
// Deliberately thin: the hosted page (/pay/{token}) already renders the QR, the
|
|
4
|
+
// UPI intent and the auto-verification polling. Reimplementing that here would
|
|
5
|
+
// mean a second copy of money-path UI drifting out of sync with the real one.
|
|
6
|
+
//
|
|
7
|
+
// Self-contained (its own styles), with no dependency on the pre-v1 modal that
|
|
8
|
+
// drove the removed client-supplied-amount flow.
|
|
9
|
+
|
|
10
|
+
const NS = 'plco';
|
|
11
|
+
let stylesInjected = false;
|
|
12
|
+
|
|
13
|
+
const isMobile = () => typeof window !== 'undefined'
|
|
14
|
+
&& window.matchMedia('(max-width: 640px)').matches;
|
|
15
|
+
|
|
16
|
+
function injectStyles(themeColor) {
|
|
17
|
+
if (stylesInjected) return;
|
|
18
|
+
stylesInjected = true;
|
|
19
|
+
const css = `
|
|
20
|
+
.${NS}-root{position:fixed;inset:0;z-index:2147483000;display:flex;align-items:center;justify-content:center;
|
|
21
|
+
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,system-ui,sans-serif}
|
|
22
|
+
.${NS}-backdrop{position:absolute;inset:0;background:rgba(8,12,20,.62);backdrop-filter:blur(4px)}
|
|
23
|
+
.${NS}-sheet{position:relative;width:100%;max-width:420px;max-height:92vh;display:flex;flex-direction:column;
|
|
24
|
+
background:#fff;border-radius:16px;overflow:hidden;box-shadow:0 24px 70px -20px rgba(0,0,0,.5);
|
|
25
|
+
animation:${NS}-in .22s cubic-bezier(.16,1,.3,1)}
|
|
26
|
+
@keyframes ${NS}-in{from{opacity:0;transform:translateY(10px) scale(.99)}to{opacity:1;transform:none}}
|
|
27
|
+
.${NS}-head{display:flex;align-items:center;gap:10px;padding:14px 14px 12px;border-bottom:1px solid rgba(15,23,42,.08)}
|
|
28
|
+
.${NS}-brand{flex:1;min-width:0}
|
|
29
|
+
.${NS}-name{font-size:13px;font-weight:600;color:#0f172a;line-height:1.2;
|
|
30
|
+
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
31
|
+
.${NS}-amount{font-size:20px;font-weight:700;color:#0f172a;letter-spacing:-.02em;margin-top:2px;
|
|
32
|
+
font-variant-numeric:tabular-nums}
|
|
33
|
+
.${NS}-badge{font-size:10px;font-weight:700;letter-spacing:.08em;color:#92400e;background:#fef3c7;
|
|
34
|
+
padding:3px 7px;border-radius:5px}
|
|
35
|
+
.${NS}-close{border:0;background:rgba(15,23,42,.05);color:#475569;width:30px;height:30px;border-radius:8px;
|
|
36
|
+
font-size:20px;line-height:1;cursor:pointer;transition:background .15s}
|
|
37
|
+
.${NS}-close:hover{background:rgba(15,23,42,.1)}
|
|
38
|
+
.${NS}-close:focus-visible{outline:2px solid ${themeColor || '#38BDF8'};outline-offset:2px}
|
|
39
|
+
.${NS}-body{flex:1;min-height:420px;background:#f8fafc}
|
|
40
|
+
.${NS}-frame{width:100%;height:100%;min-height:420px;border:0;display:block}
|
|
41
|
+
.${NS}-msg{padding:36px 20px;text-align:center;color:#64748b;font-size:13px}
|
|
42
|
+
.${NS}-cta{display:block;margin:10px 14px 0;padding:13px;border-radius:10px;text-align:center;text-decoration:none;
|
|
43
|
+
font-weight:700;font-size:14px;color:#06121f;background:${themeColor || '#38BDF8'}}
|
|
44
|
+
.${NS}-foot{padding:10px;text-align:center;font-size:11px;color:#94a3b8}
|
|
45
|
+
@media (max-width:640px){
|
|
46
|
+
.${NS}-sheet{max-width:100%;height:100%;max-height:100%;border-radius:0}
|
|
47
|
+
}
|
|
48
|
+
@media (prefers-reduced-motion:reduce){.${NS}-sheet{animation:none}}`;
|
|
49
|
+
const el = document.createElement('style');
|
|
50
|
+
el.setAttribute('data-payulink', 'checkout');
|
|
51
|
+
el.textContent = css;
|
|
52
|
+
document.head.appendChild(el);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {object} o
|
|
57
|
+
* @param {string} o.url Hosted payment page URL.
|
|
58
|
+
* @param {string} [o.upiIntentUrl] Deep link, offered on mobile where an iframe is awkward.
|
|
59
|
+
* @param {number} o.amount In paise.
|
|
60
|
+
* @param {string} [o.currency]
|
|
61
|
+
* @param {string} [o.merchantName]
|
|
62
|
+
* @param {string} [o.themeColor]
|
|
63
|
+
* @param {string} [o.mode] 'test' shows a badge.
|
|
64
|
+
* @param {function} [o.onDismiss]
|
|
65
|
+
* @returns {{ close: () => void }}
|
|
66
|
+
*/
|
|
67
|
+
export function openOverlay(o = {}) {
|
|
68
|
+
injectStyles(o.themeColor);
|
|
69
|
+
|
|
70
|
+
const money = formatMoney(o.amount, o.currency);
|
|
71
|
+
const prevOverflow = document.body.style.overflow;
|
|
72
|
+
document.body.style.overflow = 'hidden';
|
|
73
|
+
|
|
74
|
+
const root = document.createElement('div');
|
|
75
|
+
root.className = `${NS}-root`;
|
|
76
|
+
root.setAttribute('role', 'dialog');
|
|
77
|
+
root.setAttribute('aria-modal', 'true');
|
|
78
|
+
root.setAttribute('aria-label', 'Payment');
|
|
79
|
+
root.innerHTML = `
|
|
80
|
+
<div class="${NS}-backdrop" data-close="1"></div>
|
|
81
|
+
<div class="${NS}-sheet">
|
|
82
|
+
<div class="${NS}-head">
|
|
83
|
+
<div class="${NS}-brand">
|
|
84
|
+
<div class="${NS}-name">${escapeHtml(o.merchantName || 'Payment')}</div>
|
|
85
|
+
<div class="${NS}-amount">${money}</div>
|
|
86
|
+
</div>
|
|
87
|
+
${o.mode === 'test' ? `<span class="${NS}-badge">TEST</span>` : ''}
|
|
88
|
+
<button class="${NS}-close" aria-label="Close" data-close="1">×</button>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="${NS}-body">
|
|
91
|
+
${o.url
|
|
92
|
+
? `<iframe class="${NS}-frame" src="${escapeHtml(o.url)}" allow="clipboard-write" referrerpolicy="origin"></iframe>`
|
|
93
|
+
: `<div class="${NS}-msg">Could not load the payment page.</div>`}
|
|
94
|
+
</div>
|
|
95
|
+
${o.upiIntentUrl && isMobile()
|
|
96
|
+
? `<a class="${NS}-cta" href="${escapeHtml(o.upiIntentUrl)}">Pay ${money} in a UPI app</a>`
|
|
97
|
+
: ''}
|
|
98
|
+
<div class="${NS}-foot">Secured by PayuLink</div>
|
|
99
|
+
</div>`;
|
|
100
|
+
|
|
101
|
+
let closed = false;
|
|
102
|
+
const close = () => {
|
|
103
|
+
if (closed) return;
|
|
104
|
+
closed = true;
|
|
105
|
+
document.removeEventListener('keydown', onKey);
|
|
106
|
+
document.body.style.overflow = prevOverflow;
|
|
107
|
+
root.remove();
|
|
108
|
+
};
|
|
109
|
+
const dismiss = () => {
|
|
110
|
+
close();
|
|
111
|
+
if (typeof o.onDismiss === 'function') o.onDismiss();
|
|
112
|
+
};
|
|
113
|
+
const onKey = (e) => { if (e.key === 'Escape') dismiss(); };
|
|
114
|
+
|
|
115
|
+
root.addEventListener('click', (e) => {
|
|
116
|
+
if (e.target instanceof Element && e.target.closest(`[data-close]`)) dismiss();
|
|
117
|
+
});
|
|
118
|
+
document.addEventListener('keydown', onKey);
|
|
119
|
+
document.body.appendChild(root);
|
|
120
|
+
root.querySelector(`.${NS}-close`)?.focus();
|
|
121
|
+
|
|
122
|
+
return { close };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function formatMoney(paise, currency) {
|
|
126
|
+
const n = Number(paise || 0) / 100;
|
|
127
|
+
try {
|
|
128
|
+
return new Intl.NumberFormat('en-IN', {
|
|
129
|
+
style: 'currency',
|
|
130
|
+
currency: currency || 'INR',
|
|
131
|
+
minimumFractionDigits: n % 1 === 0 ? 0 : 2,
|
|
132
|
+
}).format(n);
|
|
133
|
+
} catch {
|
|
134
|
+
return '₹' + n;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function escapeHtml(s) {
|
|
139
|
+
return String(s ?? '').replace(/[&<>"']/g, (c) =>
|
|
140
|
+
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
141
|
+
}
|