@thepayulink/checkout 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/src/index.js CHANGED
@@ -1,122 +1,158 @@
1
- // @thepayulink/checkout — public API.
2
- // Opens the Elite Yatra / PayuLink UPI checkout modal and drives the real payment
3
- // through an Elite Yatra pay server (POST /api/checkout + SSE /api/order/:id/events).
4
- import { openModal } from './modal.js';
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
- // Where the Elite Yatra pay server is reachable. Override for staging/local.
8
- apiBase: 'https://eliteyatra.vip',
9
- // Base URL that serves /upi/*.png and /payulink-logo.png (defaults to `${apiBase}/assets`).
10
- assetBase: null,
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
- function normalizeAmount(amount) {
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('PayulinkCheckout runs in the browser only. In React Native use a WebView pointing at the hosted /pay page.');
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
- this.options = Object.assign({}, DEFAULTS, options);
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 modal.
33
- * @param {object} payment
34
- * @param {number} payment.amount Amount in rupees (required).
35
- * @param {string} [payment.item] Description of what's being paid for.
36
- * @param {string} [payment.note] Sub-line under the price summary.
37
- * @param {object} [payment.prefill] { name, contact, email } — skips the contact form when name + 10-digit contact are present.
38
- * @param {function} [payment.handler] Called on success: ({ merchant_order_no, utr, amount }).
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(payment = {}) {
44
- const o = Object.assign({}, this.options, payment);
55
+ open(p = {}) {
56
+ const o = Object.assign({}, this.options, p);
45
57
  const apiBase = String(o.apiBase || '').replace(/\/$/, '');
46
- if (!apiBase) throw new Error('PayulinkCheckout: `apiBase` is required');
47
- const assetBase = String(o.assetBase || `${apiBase}/assets`).replace(/\/$/, '');
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 headers = () => Object.assign({ 'Content-Type': 'application/json' }, key ? { 'x-payulink-key': key } : {});
52
- const post = async (body) => {
53
- const res = await fetch(`${apiBase}/api/checkout`, { method: 'POST', headers: headers(), body: JSON.stringify(body) });
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 watchOrder = (orderNo, onUpdate) => {
61
- let es = null;
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 common = {
74
- apiBase, assetBase,
75
- name: o.name,
76
- logo: o.logo || `${assetBase}/payulink-logo.png`,
77
- brandColor: o.brandColor,
78
- note: o.note,
79
- prefill: o.prefill || {},
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
- // SECURE flow — open a server-created order by id; the amount is fixed on the backend.
87
- if (orderId) {
88
- const handle = { _inner: null, close() { if (this._inner) this._inner.close(); } };
89
- fetch(`${apiBase}/api/order/${orderId}`)
90
- .then((r) => r.json())
91
- .then((meta) => {
92
- if (!meta || typeof meta.amount !== 'number') throw new Error('Unknown or expired order');
93
- handle._inner = openModal(Object.assign({}, common, {
94
- amount: meta.amount,
95
- description: meta.item || o.item || o.name,
96
- createOrder: (customer) => post({ order_id: orderId, customer }),
97
- }));
98
- })
99
- .catch(onError);
100
- return handle;
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
- // Convenience flow — amount supplied by the client (enable server-side via PAYULINK_ALLOW_CLIENT_AMOUNT).
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 PayulinkCheckout(options).open(options);
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.PayulinkCheckout = PayulinkCheckout;
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">&times;</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
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
141
+ }