@m2c/checkout 0.1.1 → 0.1.3
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 +8 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +83 -11
- package/dist/types.d.ts +4 -1
- package/dist/validate.d.ts +1 -0
- package/dist/validate.js +8 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -99,6 +99,14 @@ polls the status source to a terminal result.
|
|
|
99
99
|
`storageKey`, default `m2c.checkout`) before navigating. It is read and cleared
|
|
100
100
|
on the return page, so a refresh does not re-poll a stale checkout.
|
|
101
101
|
|
|
102
|
+
If the customer instead presses **Back** from the vendor checkout, the
|
|
103
|
+
originating page is restored (from the back-forward cache) and the `start` /
|
|
104
|
+
`startFromSession` promise resolves `{ status: 'window_closed', requestId }`.
|
|
105
|
+
This is an ambiguous browser outcome, not a payment status; keep using the
|
|
106
|
+
webhook/status source as the truth. The resume record remains in storage, so if
|
|
107
|
+
the customer goes Forward and completes the hosted checkout, the return page can
|
|
108
|
+
still call `resume()` and resolve the terminal payment result.
|
|
109
|
+
|
|
102
110
|
### Popup / new-tab launch
|
|
103
111
|
|
|
104
112
|
For apps that must keep the original page alive, such as web games, opt into a
|
package/dist/client.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ declare class CheckoutClientImpl implements CheckoutClient {
|
|
|
18
18
|
private persistAndLaunch;
|
|
19
19
|
private prepareAsyncLaunchWindow;
|
|
20
20
|
private waitForReturnResult;
|
|
21
|
+
private armRedirectWindowClosed;
|
|
21
22
|
private publishReturnResult;
|
|
22
23
|
private launchCheckout;
|
|
23
24
|
private openBlankCheckoutWindow;
|
package/dist/client.js
CHANGED
|
@@ -13,11 +13,6 @@ const WINDOW_CLOSED_OBSERVABILITY_GRACE_MS = 300;
|
|
|
13
13
|
const WINDOW_CLOSED_AFTER_FOCUS_GRACE_MS = 500;
|
|
14
14
|
const RETURN_RESULT_CHECK_INTERVAL_MS = 100;
|
|
15
15
|
const WINDOW_CLOSED_BEFORE_LAUNCH_MESSAGE = 'checkout window was closed before launch';
|
|
16
|
-
// Same-tab redirect tears down the page, so `start*` never resolves in that
|
|
17
|
-
// execution context; the result is obtained from `resume()` on the return page.
|
|
18
|
-
// Returning one shared never-settling promise keeps the documented
|
|
19
|
-
// Promise<CheckoutResult> return type without leaking.
|
|
20
|
-
const PENDING_UNTIL_REDIRECT = new Promise(() => { });
|
|
21
16
|
/** Create a headless checkout client. Safe to import in SSR; lifecycle methods need a DOM. */
|
|
22
17
|
export function createClient(config) {
|
|
23
18
|
return new CheckoutClientImpl(normalizeConfig(config));
|
|
@@ -216,12 +211,7 @@ class CheckoutClientImpl {
|
|
|
216
211
|
}
|
|
217
212
|
waitForReturnResult(requestId) {
|
|
218
213
|
if (this.config.launchMode === 'redirect') {
|
|
219
|
-
return
|
|
220
|
-
promise: PENDING_UNTIL_REDIRECT,
|
|
221
|
-
cancel: () => { },
|
|
222
|
-
resolveWindowClosed: () => { },
|
|
223
|
-
watchWindowClosed: () => { },
|
|
224
|
-
};
|
|
214
|
+
return this.armRedirectWindowClosed(requestId);
|
|
225
215
|
}
|
|
226
216
|
return listenForReturnResult(this.config.storageKey, requestId, this.config.storage, this.config.returnTimeoutMs, (result) => {
|
|
227
217
|
this.requestId = result.requestId;
|
|
@@ -237,6 +227,54 @@ class CheckoutClientImpl {
|
|
|
237
227
|
this.setState(result.status);
|
|
238
228
|
});
|
|
239
229
|
}
|
|
230
|
+
// Redirect mode tears the page down after navigate, so the happy-path result
|
|
231
|
+
// comes from resume() on success_url/cancel_url. But if the customer presses
|
|
232
|
+
// Back from the vendor checkout, the originating page is restored (back-forward
|
|
233
|
+
// cache) frozen in `awaiting_return` with nothing listening. Arm a page-restore
|
|
234
|
+
// listener so that case resolves instead of hanging forever.
|
|
235
|
+
armRedirectWindowClosed(requestId) {
|
|
236
|
+
let settled = false;
|
|
237
|
+
let triggered = false;
|
|
238
|
+
let removeListeners = () => { };
|
|
239
|
+
let resolveResult;
|
|
240
|
+
const promise = new Promise((resolve) => {
|
|
241
|
+
resolveResult = resolve;
|
|
242
|
+
});
|
|
243
|
+
const settle = (result) => {
|
|
244
|
+
if (settled)
|
|
245
|
+
return;
|
|
246
|
+
settled = true;
|
|
247
|
+
removeListeners();
|
|
248
|
+
resolveResult(result);
|
|
249
|
+
};
|
|
250
|
+
const settleWindowClosed = () => {
|
|
251
|
+
this.requestId = requestId;
|
|
252
|
+
this.setState('window_closed');
|
|
253
|
+
settle({ status: 'window_closed', requestId });
|
|
254
|
+
};
|
|
255
|
+
// `state === 'awaiting_return'` is the real gate: in same-tab redirect mode
|
|
256
|
+
// the originating page is usually shown again via Back navigation. That is
|
|
257
|
+
// an ambiguous browser outcome, not a payment status, so keep the resume
|
|
258
|
+
// record for a later success_url/cancel_url return.
|
|
259
|
+
const onRestore = () => {
|
|
260
|
+
if (triggered || settled || this.state !== 'awaiting_return')
|
|
261
|
+
return;
|
|
262
|
+
triggered = true;
|
|
263
|
+
settleWindowClosed();
|
|
264
|
+
};
|
|
265
|
+
removeListeners = listenForPageRestore(onRestore);
|
|
266
|
+
return {
|
|
267
|
+
promise,
|
|
268
|
+
cancel: () => {
|
|
269
|
+
if (settled)
|
|
270
|
+
return;
|
|
271
|
+
settled = true;
|
|
272
|
+
removeListeners();
|
|
273
|
+
},
|
|
274
|
+
resolveWindowClosed: () => { },
|
|
275
|
+
watchWindowClosed: () => { },
|
|
276
|
+
};
|
|
277
|
+
}
|
|
240
278
|
publishReturnResult(result) {
|
|
241
279
|
publishReturnResult(this.config.storageKey, result, this.config.storage);
|
|
242
280
|
}
|
|
@@ -670,6 +708,40 @@ function listenForWindowClosed(win, onClosed, onUnobservable) {
|
|
|
670
708
|
check();
|
|
671
709
|
return stop;
|
|
672
710
|
}
|
|
711
|
+
// Fire `onRestore` when the originating page is shown again - a `pageshow`
|
|
712
|
+
// (including back-forward cache restores) or the document becoming visible. The
|
|
713
|
+
// caller gates on lifecycle state, so an unconditional callback is safe here.
|
|
714
|
+
function listenForPageRestore(onRestore) {
|
|
715
|
+
try {
|
|
716
|
+
if (typeof globalThis.addEventListener !== 'function') {
|
|
717
|
+
return () => { };
|
|
718
|
+
}
|
|
719
|
+
const handler = () => onRestore();
|
|
720
|
+
globalThis.addEventListener('pageshow', handler);
|
|
721
|
+
const doc = globalThis.document;
|
|
722
|
+
let visibilityListener;
|
|
723
|
+
if (doc && typeof doc.addEventListener === 'function') {
|
|
724
|
+
visibilityListener = () => {
|
|
725
|
+
if (doc.visibilityState === 'visible') {
|
|
726
|
+
onRestore();
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
doc.addEventListener('visibilitychange', visibilityListener);
|
|
730
|
+
}
|
|
731
|
+
return () => {
|
|
732
|
+
try {
|
|
733
|
+
globalThis.removeEventListener('pageshow', handler);
|
|
734
|
+
if (visibilityListener && doc && typeof doc.removeEventListener === 'function') {
|
|
735
|
+
doc.removeEventListener('visibilitychange', visibilityListener);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
catch { }
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
return () => { };
|
|
743
|
+
}
|
|
744
|
+
}
|
|
673
745
|
function listenForOpenerFocus(onFocus) {
|
|
674
746
|
try {
|
|
675
747
|
if (typeof globalThis.addEventListener !== 'function') {
|
package/dist/types.d.ts
CHANGED
|
@@ -7,7 +7,10 @@ import type { M2CCheckoutError } from './errors.js';
|
|
|
7
7
|
export type ClientStatus = 'processing' | 'completed' | 'failed' | 'canceled';
|
|
8
8
|
/** Lifecycle states the SDK passes through. Drives the merchant's progress UI. */
|
|
9
9
|
export type CheckoutState = 'idle' | 'creating' | 'ready' | 'launching' | 'awaiting_return' | 'returned' | 'polling' | 'completed' | 'failed' | 'canceled' | 'pending_timeout' | 'window_closed' | 'error';
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Terminal outcome of a checkout. `pending_timeout` and `window_closed` resolve;
|
|
12
|
+
* they do not reject.
|
|
13
|
+
*/
|
|
11
14
|
export type CheckoutResult = {
|
|
12
15
|
status: 'completed';
|
|
13
16
|
requestId: string;
|
package/dist/validate.d.ts
CHANGED
package/dist/validate.js
CHANGED
|
@@ -3,6 +3,11 @@ import { M2CCheckoutError } from './errors.js';
|
|
|
3
3
|
// contract violation fails client-side with a clear message instead of a
|
|
4
4
|
// round-trip and a generic 400.
|
|
5
5
|
export const MAX_TRANSACTION_VALUE = 5000000000;
|
|
6
|
+
// Smallest positive value the server keeps: it rounds major units to micro-units
|
|
7
|
+
// (1e-6) internally, so a positive value below one micro-unit becomes 0 at the
|
|
8
|
+
// storage boundary and is rejected. Mirror that floor instead of taking the
|
|
9
|
+
// round-trip for a generic 400.
|
|
10
|
+
export const MIN_TRANSACTION_VALUE = 0.000001;
|
|
6
11
|
const MAX_RETURN_URL_BYTES = 2048;
|
|
7
12
|
const MAX_CHECKOUT_URL_BYTES = 4096;
|
|
8
13
|
const MAX_DESCRIPTION_BYTES = 256;
|
|
@@ -48,6 +53,9 @@ export function assertTransactionValue(value) {
|
|
|
48
53
|
if (value <= 0) {
|
|
49
54
|
throw invalid('transactionValue must be greater than 0');
|
|
50
55
|
}
|
|
56
|
+
if (value < MIN_TRANSACTION_VALUE) {
|
|
57
|
+
throw invalid(`transactionValue must be at least ${MIN_TRANSACTION_VALUE}`);
|
|
58
|
+
}
|
|
51
59
|
if (value > MAX_TRANSACTION_VALUE) {
|
|
52
60
|
throw invalid(`transactionValue must be at most ${MAX_TRANSACTION_VALUE} major units`);
|
|
53
61
|
}
|
package/package.json
CHANGED