@m2c/checkout 0.1.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/LICENSE +21 -0
- package/README.md +298 -0
- package/dist/auction.d.ts +21 -0
- package/dist/auction.js +136 -0
- package/dist/client.d.ts +33 -0
- package/dist/client.js +960 -0
- package/dist/errors.d.ts +42 -0
- package/dist/errors.js +79 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/poll.d.ts +25 -0
- package/dist/poll.js +51 -0
- package/dist/status.d.ts +17 -0
- package/dist/status.js +175 -0
- package/dist/storage.d.ts +37 -0
- package/dist/storage.js +62 -0
- package/dist/types.d.ts +178 -0
- package/dist/types.js +1 -0
- package/dist/validate.d.ts +34 -0
- package/dist/validate.js +138 -0
- package/package.json +55 -0
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed error surface for the checkout SDK. The taxonomy mirrors the bid
|
|
3
|
+
* server's response codes for the auction and status-read endpoints so a
|
|
4
|
+
* merchant can branch on `code` without sniffing HTTP statuses or message text.
|
|
5
|
+
*/
|
|
6
|
+
export type CheckoutErrorCode = 'InvalidRequest' | 'OriginNotAllowed' | 'AccountSuspended' | 'NoVendorsAvailable' | 'RateLimited' | 'ServiceUnavailable' | 'CheckoutExpired' | 'Network' | 'Unknown';
|
|
7
|
+
export interface M2CCheckoutErrorOptions {
|
|
8
|
+
status?: number;
|
|
9
|
+
retryAfterSeconds?: number;
|
|
10
|
+
cause?: unknown;
|
|
11
|
+
}
|
|
12
|
+
/** Every error the checkout SDK rejects with is an instance of this class. */
|
|
13
|
+
export declare class M2CCheckoutError extends Error {
|
|
14
|
+
readonly code: CheckoutErrorCode;
|
|
15
|
+
/** The HTTP status when the error came from a response; absent for client-side / network errors. */
|
|
16
|
+
readonly status?: number;
|
|
17
|
+
/** Present when the server sent a Retry-After header (seconds). */
|
|
18
|
+
readonly retryAfterSeconds?: number;
|
|
19
|
+
constructor(code: CheckoutErrorCode, message: string, options?: M2CCheckoutErrorOptions);
|
|
20
|
+
/**
|
|
21
|
+
* Whether re-attempting could plausibly succeed. Transient conditions
|
|
22
|
+
* (rate limit, 5xx / unavailable, network) are retryable; auth and contract
|
|
23
|
+
* failures (bad origin, suspended, invalid request) never are. The poll loop
|
|
24
|
+
* uses this to decide whether to swallow a status-read failure and keep
|
|
25
|
+
* polling or surface it to the integrator.
|
|
26
|
+
*/
|
|
27
|
+
get retryable(): boolean;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Map a non-2xx auction response to a typed error. The bid server returns the
|
|
31
|
+
* same HTTP status for more than one 403 condition (origin vs suspended), so we
|
|
32
|
+
* disambiguate on the body's error string - the only signal available - and
|
|
33
|
+
* fall back to OriginNotAllowed, the far more common case, when it is absent.
|
|
34
|
+
*/
|
|
35
|
+
export declare function auctionErrorForResponse(status: number, bodyError: string | undefined, retryAfterSeconds: number | undefined): M2CCheckoutError;
|
|
36
|
+
/**
|
|
37
|
+
* Parse a Retry-After header into seconds. Accepts RFC 7231 delta-seconds or an
|
|
38
|
+
* HTTP-date; rejects junk (e.g. "-1", "1.5") so it never yields a negative or
|
|
39
|
+
* fractional backoff. Returns undefined when there is nothing usable, leaving
|
|
40
|
+
* the caller on its normal backoff schedule.
|
|
41
|
+
*/
|
|
42
|
+
export declare function parseRetryAfter(header: string | null): number | undefined;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Every error the checkout SDK rejects with is an instance of this class. */
|
|
2
|
+
export class M2CCheckoutError extends Error {
|
|
3
|
+
constructor(code, message, options = {}) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = 'M2CCheckoutError';
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.status = options.status;
|
|
8
|
+
this.retryAfterSeconds = options.retryAfterSeconds;
|
|
9
|
+
// Preserve the originating error without widening the public type surface.
|
|
10
|
+
if (options.cause !== undefined) {
|
|
11
|
+
this.cause = options.cause;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Whether re-attempting could plausibly succeed. Transient conditions
|
|
16
|
+
* (rate limit, 5xx / unavailable, network) are retryable; auth and contract
|
|
17
|
+
* failures (bad origin, suspended, invalid request) never are. The poll loop
|
|
18
|
+
* uses this to decide whether to swallow a status-read failure and keep
|
|
19
|
+
* polling or surface it to the integrator.
|
|
20
|
+
*/
|
|
21
|
+
get retryable() {
|
|
22
|
+
return (this.code === 'RateLimited' ||
|
|
23
|
+
this.code === 'ServiceUnavailable' ||
|
|
24
|
+
this.code === 'Network');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Map a non-2xx auction response to a typed error. The bid server returns the
|
|
29
|
+
* same HTTP status for more than one 403 condition (origin vs suspended), so we
|
|
30
|
+
* disambiguate on the body's error string - the only signal available - and
|
|
31
|
+
* fall back to OriginNotAllowed, the far more common case, when it is absent.
|
|
32
|
+
*/
|
|
33
|
+
export function auctionErrorForResponse(status, bodyError, retryAfterSeconds) {
|
|
34
|
+
const message = bodyError && bodyError.trim() !== '' ? bodyError : `auction failed: ${status}`;
|
|
35
|
+
switch (status) {
|
|
36
|
+
case 400:
|
|
37
|
+
return new M2CCheckoutError('InvalidRequest', message, { status });
|
|
38
|
+
case 403:
|
|
39
|
+
if (bodyError && /suspend/i.test(bodyError)) {
|
|
40
|
+
return new M2CCheckoutError('AccountSuspended', message, { status });
|
|
41
|
+
}
|
|
42
|
+
return new M2CCheckoutError('OriginNotAllowed', message, { status });
|
|
43
|
+
case 404:
|
|
44
|
+
return new M2CCheckoutError('NoVendorsAvailable', message, { status });
|
|
45
|
+
case 429:
|
|
46
|
+
return new M2CCheckoutError('RateLimited', message, { status, retryAfterSeconds });
|
|
47
|
+
default:
|
|
48
|
+
if (status >= 500 && status <= 599) {
|
|
49
|
+
return new M2CCheckoutError('ServiceUnavailable', message, { status, retryAfterSeconds });
|
|
50
|
+
}
|
|
51
|
+
return new M2CCheckoutError('Unknown', message, { status });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Parse a Retry-After header into seconds. Accepts RFC 7231 delta-seconds or an
|
|
56
|
+
* HTTP-date; rejects junk (e.g. "-1", "1.5") so it never yields a negative or
|
|
57
|
+
* fractional backoff. Returns undefined when there is nothing usable, leaving
|
|
58
|
+
* the caller on its normal backoff schedule.
|
|
59
|
+
*/
|
|
60
|
+
export function parseRetryAfter(header) {
|
|
61
|
+
if (!header)
|
|
62
|
+
return undefined;
|
|
63
|
+
const trimmed = header.trim();
|
|
64
|
+
if (trimmed === '')
|
|
65
|
+
return undefined;
|
|
66
|
+
if (/^\d+$/.test(trimmed)) {
|
|
67
|
+
const seconds = Number(trimmed);
|
|
68
|
+
return Number.isFinite(seconds) ? seconds : undefined;
|
|
69
|
+
}
|
|
70
|
+
// A bare signed/fractional number is neither valid delta-seconds nor an
|
|
71
|
+
// HTTP-date; Date.parse would misread some of these as historical dates.
|
|
72
|
+
if (/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(trimmed)) {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
const dateMs = Date.parse(trimmed);
|
|
76
|
+
if (Number.isNaN(dateMs))
|
|
77
|
+
return undefined;
|
|
78
|
+
return Math.max(0, Math.ceil((dateMs - Date.now()) / 1000));
|
|
79
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createClient } from './client.js';
|
|
2
|
+
export { M2CCheckoutError } from './errors.js';
|
|
3
|
+
export type { CheckoutErrorCode } from './errors.js';
|
|
4
|
+
export { DEFAULT_POLL_CONFIG } from './poll.js';
|
|
5
|
+
export type { CheckoutClient, CheckoutResult, CheckoutState, ClientConfig, ClientStatus, LaunchMode, Navigate, OpenCheckoutWindow, PollConfig, ResumeParams, StartFromSessionParams, StartParams, StateChangeContext, StateChangeListener, StatusSource, SubscribeAdapter, CheckoutStorage, } from './types.js';
|
package/dist/index.js
ADDED
package/dist/poll.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ClientStatus, PollConfig } from './types.js';
|
|
2
|
+
/** Default schedule: immediate, then 1s, 2s, 4s, 8s, capped at 8s, total ~90s window. */
|
|
3
|
+
export declare const DEFAULT_POLL_CONFIG: PollConfig;
|
|
4
|
+
/** Resolves the current advisory status for a request, or throws on a read failure. */
|
|
5
|
+
export type CheckStatusFn = (requestId: string) => Promise<ClientStatus>;
|
|
6
|
+
/** Injectable clock + sleeper so the schedule is deterministically testable. */
|
|
7
|
+
export interface PollDeps {
|
|
8
|
+
sleep: (ms: number) => Promise<void>;
|
|
9
|
+
now: () => number;
|
|
10
|
+
}
|
|
11
|
+
export declare const realPollDeps: PollDeps;
|
|
12
|
+
/**
|
|
13
|
+
* Poll a status source on a bounded exponential backoff until it returns a
|
|
14
|
+
* terminal status or the window elapses.
|
|
15
|
+
*
|
|
16
|
+
* Transient read failures (rate limit, 5xx, network, not-yet-visible row) are
|
|
17
|
+
* swallowed and retried: the read is advisory and the webhook is the truth, so
|
|
18
|
+
* a flaky read must not fail the checkout. A developer-actionable failure
|
|
19
|
+
* (bad origin, invalid request) is NOT swallowed - it is rethrown so the
|
|
20
|
+
* integrator sees their misconfiguration instead of a silent `pending_timeout`.
|
|
21
|
+
*
|
|
22
|
+
* On window timeout returns `processing`; the caller maps that to the terminal
|
|
23
|
+
* `pending_timeout` result. Never rejects on a terminal status.
|
|
24
|
+
*/
|
|
25
|
+
export declare function pollStatus(checkStatus: CheckStatusFn, requestId: string, config: PollConfig, deps?: PollDeps): Promise<ClientStatus>;
|
package/dist/poll.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { M2CCheckoutError } from './errors.js';
|
|
2
|
+
/** Default schedule: immediate, then 1s, 2s, 4s, 8s, capped at 8s, total ~90s window. */
|
|
3
|
+
export const DEFAULT_POLL_CONFIG = {
|
|
4
|
+
firstDelayMs: 1000,
|
|
5
|
+
maxDelayMs: 8000,
|
|
6
|
+
factor: 2,
|
|
7
|
+
windowMs: 90000,
|
|
8
|
+
};
|
|
9
|
+
export const realPollDeps = {
|
|
10
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
11
|
+
now: () => Date.now(),
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Poll a status source on a bounded exponential backoff until it returns a
|
|
15
|
+
* terminal status or the window elapses.
|
|
16
|
+
*
|
|
17
|
+
* Transient read failures (rate limit, 5xx, network, not-yet-visible row) are
|
|
18
|
+
* swallowed and retried: the read is advisory and the webhook is the truth, so
|
|
19
|
+
* a flaky read must not fail the checkout. A developer-actionable failure
|
|
20
|
+
* (bad origin, invalid request) is NOT swallowed - it is rethrown so the
|
|
21
|
+
* integrator sees their misconfiguration instead of a silent `pending_timeout`.
|
|
22
|
+
*
|
|
23
|
+
* On window timeout returns `processing`; the caller maps that to the terminal
|
|
24
|
+
* `pending_timeout` result. Never rejects on a terminal status.
|
|
25
|
+
*/
|
|
26
|
+
export async function pollStatus(checkStatus, requestId, config, deps = realPollDeps) {
|
|
27
|
+
const deadline = deps.now() + config.windowMs;
|
|
28
|
+
let delayMs = 0; // first poll is immediate
|
|
29
|
+
for (;;) {
|
|
30
|
+
if (delayMs > 0) {
|
|
31
|
+
await deps.sleep(delayMs);
|
|
32
|
+
}
|
|
33
|
+
let status = 'processing';
|
|
34
|
+
try {
|
|
35
|
+
status = await checkStatus(requestId);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
if (err instanceof M2CCheckoutError && !err.retryable) {
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
status = 'processing';
|
|
42
|
+
}
|
|
43
|
+
if (status === 'completed' || status === 'failed' || status === 'canceled') {
|
|
44
|
+
return status;
|
|
45
|
+
}
|
|
46
|
+
if (deps.now() >= deadline) {
|
|
47
|
+
return 'processing';
|
|
48
|
+
}
|
|
49
|
+
delayMs = delayMs === 0 ? config.firstDelayMs : Math.min(delayMs * config.factor, config.maxDelayMs);
|
|
50
|
+
}
|
|
51
|
+
}
|
package/dist/status.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CheckStatusFn } from './poll.js';
|
|
2
|
+
import type { StatusSourceRef } from './storage.js';
|
|
3
|
+
import type { NormalizedConfig, StatusSource } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Rebuild a live status reader from the persisted ref and the (re-instantiated)
|
|
6
|
+
* config. `m2c` and `url` rebuild from config alone; `callback` needs the
|
|
7
|
+
* function re-supplied on the return page (functions do not survive the
|
|
8
|
+
* redirect). `subscribe` is reserved and rejected for now (see README).
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolveStatusSource(ref: StatusSourceRef, config: NormalizedConfig, resupplied: StatusSource | undefined): CheckStatusFn;
|
|
11
|
+
/**
|
|
12
|
+
* Build a one-shot status reader from the live, configured status source. Backs
|
|
13
|
+
* `client.checkStatus(requestId)`, which reconciles an outcome after the fact
|
|
14
|
+
* (e.g. a window_closed / pending_timeout that later completed) - separate from
|
|
15
|
+
* the resume-record-driven poll.
|
|
16
|
+
*/
|
|
17
|
+
export declare function checkStatusForSource(source: StatusSource, config: NormalizedConfig): CheckStatusFn;
|
package/dist/status.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { M2CCheckoutError, parseRetryAfter } from './errors.js';
|
|
2
|
+
const STATUS_PATH = '/api/v1/conversions';
|
|
3
|
+
/**
|
|
4
|
+
* Rebuild a live status reader from the persisted ref and the (re-instantiated)
|
|
5
|
+
* config. `m2c` and `url` rebuild from config alone; `callback` needs the
|
|
6
|
+
* function re-supplied on the return page (functions do not survive the
|
|
7
|
+
* redirect). `subscribe` is reserved and rejected for now (see README).
|
|
8
|
+
*/
|
|
9
|
+
export function resolveStatusSource(ref, config, resupplied) {
|
|
10
|
+
switch (ref.kind) {
|
|
11
|
+
case 'm2c':
|
|
12
|
+
return m2cCheckStatus(config);
|
|
13
|
+
case 'url':
|
|
14
|
+
return urlCheckStatus(ref.template, config);
|
|
15
|
+
case 'callback': {
|
|
16
|
+
const source = resupplied ?? config.statusSource;
|
|
17
|
+
if (!source || source.kind !== 'callback' || typeof source.checkStatus !== 'function') {
|
|
18
|
+
throw new M2CCheckoutError('InvalidRequest', 'a callback status source must be re-supplied on the return page via resume({ statusSource }) or createClient({ statusSource }); a function cannot survive the redirect');
|
|
19
|
+
}
|
|
20
|
+
return callbackCheckStatus(source.checkStatus);
|
|
21
|
+
}
|
|
22
|
+
default:
|
|
23
|
+
throw new M2CCheckoutError('InvalidRequest', 'the subscribe status source is not supported yet; use m2c, url, or callback');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Build a one-shot status reader from the live, configured status source. Backs
|
|
28
|
+
* `client.checkStatus(requestId)`, which reconciles an outcome after the fact
|
|
29
|
+
* (e.g. a window_closed / pending_timeout that later completed) - separate from
|
|
30
|
+
* the resume-record-driven poll.
|
|
31
|
+
*/
|
|
32
|
+
export function checkStatusForSource(source, config) {
|
|
33
|
+
switch (source.kind) {
|
|
34
|
+
case 'm2c':
|
|
35
|
+
return m2cCheckStatus(config);
|
|
36
|
+
case 'url':
|
|
37
|
+
return urlCheckStatus(source.template, config);
|
|
38
|
+
case 'callback':
|
|
39
|
+
return callbackCheckStatus(source.checkStatus);
|
|
40
|
+
default:
|
|
41
|
+
throw new M2CCheckoutError('InvalidRequest', 'the subscribe status source is not supported yet; use m2c, url, or callback');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function m2cCheckStatus(config) {
|
|
45
|
+
if (!config.publishableKey) {
|
|
46
|
+
throw new M2CCheckoutError('InvalidRequest', 'the m2c status source requires a publishableKey (re-supply it on the return page)');
|
|
47
|
+
}
|
|
48
|
+
if (!config.fetch) {
|
|
49
|
+
throw new M2CCheckoutError('InvalidRequest', 'no fetch implementation available');
|
|
50
|
+
}
|
|
51
|
+
const fetchImpl = config.fetch;
|
|
52
|
+
const key = config.publishableKey;
|
|
53
|
+
return async (requestId) => {
|
|
54
|
+
const url = `${config.baseUrl}${STATUS_PATH}/${encodeURIComponent(requestId)}`;
|
|
55
|
+
let res;
|
|
56
|
+
try {
|
|
57
|
+
res = await fetchImpl(url, {
|
|
58
|
+
method: 'GET',
|
|
59
|
+
headers: { 'X-API-Key': key },
|
|
60
|
+
credentials: 'omit',
|
|
61
|
+
redirect: 'error',
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
throw new M2CCheckoutError('Network', `status read failed: ${err.message}`, {
|
|
66
|
+
cause: err,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (res.status === 200) {
|
|
70
|
+
return parseClientStatus(await res.text().catch(() => ''));
|
|
71
|
+
}
|
|
72
|
+
throw statusReadError(res.status, parseRetryAfter(res.headers.get('retry-after')));
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function urlCheckStatus(template, config) {
|
|
76
|
+
if (typeof template !== 'string' || template === '') {
|
|
77
|
+
throw new M2CCheckoutError('InvalidRequest', 'url status source requires a non-empty template');
|
|
78
|
+
}
|
|
79
|
+
if (!config.fetch) {
|
|
80
|
+
throw new M2CCheckoutError('InvalidRequest', 'no fetch implementation available');
|
|
81
|
+
}
|
|
82
|
+
const fetchImpl = config.fetch;
|
|
83
|
+
return async (requestId) => {
|
|
84
|
+
const url = template.replace(/\{request_id\}/g, encodeURIComponent(requestId));
|
|
85
|
+
let res;
|
|
86
|
+
try {
|
|
87
|
+
// Default (same-origin) credentials: a merchant status endpoint on the
|
|
88
|
+
// page's own origin commonly authenticates the customer by cookie.
|
|
89
|
+
res = await fetchImpl(url, { method: 'GET' });
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
throw new M2CCheckoutError('Network', `merchant status read failed: ${err.message}`, {
|
|
93
|
+
cause: err,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (res.ok) {
|
|
97
|
+
return parseClientStatus(await res.text().catch(() => ''));
|
|
98
|
+
}
|
|
99
|
+
// The merchant endpoint's error shape is theirs; treat any non-2xx as
|
|
100
|
+
// transient so the poll keeps trying within the window rather than failing
|
|
101
|
+
// the checkout on a blip.
|
|
102
|
+
throw new M2CCheckoutError('ServiceUnavailable', `merchant status endpoint returned ${res.status}`, { status: res.status });
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function callbackCheckStatus(fn) {
|
|
106
|
+
return async (requestId) => {
|
|
107
|
+
try {
|
|
108
|
+
return coerceClientStatus(await fn(requestId));
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
// A merchant callback throwing is treated as transient: the read is
|
|
112
|
+
// advisory, so a flaky backend should not fail the checkout. Persisting
|
|
113
|
+
// failures resolve to pending_timeout when the window elapses.
|
|
114
|
+
throw new M2CCheckoutError('ServiceUnavailable', `status callback threw: ${err.message}`, {
|
|
115
|
+
cause: err,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Map the status read JSON to a ClientStatus. Merchant status endpoints often
|
|
122
|
+
* reflect M2C webhook statuses directly, so accept both the client enum and the
|
|
123
|
+
* internal/webhook terminal names. An unrecognized or missing value is treated
|
|
124
|
+
* as `processing` (fail safe) so the poll continues rather than mistaking an
|
|
125
|
+
* unexpected payload for a terminal outcome.
|
|
126
|
+
*/
|
|
127
|
+
function parseClientStatus(text) {
|
|
128
|
+
let status;
|
|
129
|
+
try {
|
|
130
|
+
const obj = JSON.parse(text);
|
|
131
|
+
status = typeof obj === 'object' && obj !== null ? obj.status : undefined;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
status = undefined;
|
|
135
|
+
}
|
|
136
|
+
return coerceClientStatus(status);
|
|
137
|
+
}
|
|
138
|
+
function coerceClientStatus(status) {
|
|
139
|
+
switch (status) {
|
|
140
|
+
case 'completed':
|
|
141
|
+
case 'refunded':
|
|
142
|
+
case 'chargedback':
|
|
143
|
+
return 'completed';
|
|
144
|
+
case 'failed':
|
|
145
|
+
return 'failed';
|
|
146
|
+
case 'canceled':
|
|
147
|
+
case 'abandoned':
|
|
148
|
+
return 'canceled';
|
|
149
|
+
case 'processing':
|
|
150
|
+
case 'pending':
|
|
151
|
+
return 'processing';
|
|
152
|
+
default:
|
|
153
|
+
return 'processing';
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function statusReadError(status, retryAfterSeconds) {
|
|
157
|
+
switch (status) {
|
|
158
|
+
case 400:
|
|
159
|
+
return new M2CCheckoutError('InvalidRequest', 'status read rejected the request', { status });
|
|
160
|
+
case 403:
|
|
161
|
+
return new M2CCheckoutError('OriginNotAllowed', 'origin not allowed for this key', { status });
|
|
162
|
+
case 429:
|
|
163
|
+
return new M2CCheckoutError('RateLimited', 'status read rate limited', { status, retryAfterSeconds });
|
|
164
|
+
case 404:
|
|
165
|
+
// The row is account-scoped and may briefly not be visible to this key
|
|
166
|
+
// just after the auction; treat as transient so the poll retries rather
|
|
167
|
+
// than failing. A persistent 404 resolves to pending_timeout.
|
|
168
|
+
return new M2CCheckoutError('ServiceUnavailable', 'status not yet available', { status });
|
|
169
|
+
default:
|
|
170
|
+
if (status >= 500 && status <= 599) {
|
|
171
|
+
return new M2CCheckoutError('ServiceUnavailable', `status read returned ${status}`, { status });
|
|
172
|
+
}
|
|
173
|
+
return new M2CCheckoutError('Unknown', `status read returned ${status}`, { status });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { CheckoutStorage, LaunchMode, StatusSource } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Serializable descriptor of the status source, persisted across the full-page
|
|
4
|
+
* redirect. A `callback` function cannot be serialized, so only its kind is
|
|
5
|
+
* stored; the merchant must re-supply the function on the return page. `m2c`
|
|
6
|
+
* and `url` carry everything needed to rebuild the source from config alone.
|
|
7
|
+
*/
|
|
8
|
+
export type StatusSourceRef = {
|
|
9
|
+
kind: 'm2c';
|
|
10
|
+
} | {
|
|
11
|
+
kind: 'url';
|
|
12
|
+
template: string;
|
|
13
|
+
} | {
|
|
14
|
+
kind: 'callback';
|
|
15
|
+
} | {
|
|
16
|
+
kind: 'subscribe';
|
|
17
|
+
};
|
|
18
|
+
/** What `start*` writes before navigating away. */
|
|
19
|
+
export interface ResumeRecord {
|
|
20
|
+
v: 1;
|
|
21
|
+
requestId: string;
|
|
22
|
+
mode: 'client' | 'session';
|
|
23
|
+
launchMode?: LaunchMode;
|
|
24
|
+
statusSourceRef: StatusSourceRef;
|
|
25
|
+
/** Epoch ms when the checkout was started. */
|
|
26
|
+
startedAt: number;
|
|
27
|
+
/** Validity in seconds, if known. */
|
|
28
|
+
ttl?: number;
|
|
29
|
+
}
|
|
30
|
+
export declare function statusSourceRefFor(source: StatusSource): StatusSourceRef;
|
|
31
|
+
export declare function writeResumeRecord(storage: CheckoutStorage, key: string, record: ResumeRecord): void;
|
|
32
|
+
/**
|
|
33
|
+
* Read and immediately clear the resume record. Clearing first (even on a
|
|
34
|
+
* malformed value) makes resume single-shot: a refresh of the return page does
|
|
35
|
+
* not re-poll a stale checkout. Returns null when nothing valid was stored.
|
|
36
|
+
*/
|
|
37
|
+
export declare function readAndClearResumeRecord(storage: CheckoutStorage, key: string): ResumeRecord | null;
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export function statusSourceRefFor(source) {
|
|
2
|
+
switch (source.kind) {
|
|
3
|
+
case 'url':
|
|
4
|
+
return { kind: 'url', template: source.template };
|
|
5
|
+
case 'callback':
|
|
6
|
+
return { kind: 'callback' };
|
|
7
|
+
case 'subscribe':
|
|
8
|
+
return { kind: 'subscribe' };
|
|
9
|
+
default:
|
|
10
|
+
return { kind: 'm2c' };
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function writeResumeRecord(storage, key, record) {
|
|
14
|
+
storage.setItem(key, JSON.stringify(record));
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Read and immediately clear the resume record. Clearing first (even on a
|
|
18
|
+
* malformed value) makes resume single-shot: a refresh of the return page does
|
|
19
|
+
* not re-poll a stale checkout. Returns null when nothing valid was stored.
|
|
20
|
+
*/
|
|
21
|
+
export function readAndClearResumeRecord(storage, key) {
|
|
22
|
+
const raw = storage.getItem(key);
|
|
23
|
+
if (!raw)
|
|
24
|
+
return null;
|
|
25
|
+
storage.removeItem(key);
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(raw);
|
|
28
|
+
if (isResumeRecord(parsed)) {
|
|
29
|
+
return parsed;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Corrupt value (manual tampering, partial write): treat as no record.
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
function isResumeRecord(value) {
|
|
38
|
+
if (typeof value !== 'object' || value === null)
|
|
39
|
+
return false;
|
|
40
|
+
const r = value;
|
|
41
|
+
if (r.v !== 1)
|
|
42
|
+
return false;
|
|
43
|
+
if (typeof r.requestId !== 'string' || r.requestId === '')
|
|
44
|
+
return false;
|
|
45
|
+
if (r.mode !== 'client' && r.mode !== 'session')
|
|
46
|
+
return false;
|
|
47
|
+
if (typeof r.startedAt !== 'number')
|
|
48
|
+
return false;
|
|
49
|
+
if (typeof r.statusSourceRef !== 'object' || r.statusSourceRef === null)
|
|
50
|
+
return false;
|
|
51
|
+
const ref = r.statusSourceRef;
|
|
52
|
+
switch (ref.kind) {
|
|
53
|
+
case 'm2c':
|
|
54
|
+
case 'callback':
|
|
55
|
+
case 'subscribe':
|
|
56
|
+
return true;
|
|
57
|
+
case 'url':
|
|
58
|
+
return typeof ref.template === 'string' && ref.template !== '';
|
|
59
|
+
default:
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|