@blocklet/payment-react-headless 1.29.9 → 1.29.11
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/es/checkout/hooks/useSubmit.js +15 -4
- package/es/shared/api.d.ts +1 -0
- package/es/shared/api.js +1 -0
- package/es/shared/polling.d.ts +7 -1
- package/es/shared/polling.js +15 -4
- package/lib/checkout/hooks/useSubmit.js +14 -4
- package/lib/shared/api.d.ts +1 -0
- package/lib/shared/api.js +1 -0
- package/lib/shared/polling.d.ts +7 -1
- package/lib/shared/polling.js +14 -4
- package/package.json +3 -3
- package/src/checkout/hooks/useSubmit.ts +17 -4
- package/src/shared/api.ts +1 -0
- package/src/shared/polling.ts +30 -4
- package/tests/shared/polling.spec.ts +65 -0
|
@@ -91,9 +91,20 @@ export function useSubmit(sessionData, sessionId, currencyId, isStripe, isCredit
|
|
|
91
91
|
}
|
|
92
92
|
};
|
|
93
93
|
}, [subscriptionRef.current, status]);
|
|
94
|
-
const
|
|
94
|
+
const reconcileStripe = useMemoizedFn(async () => {
|
|
95
|
+
await api.post(API.RECONCILE_STRIPE_SESSION(sessionId));
|
|
96
|
+
});
|
|
97
|
+
const handleCompletion = useMemoizedFn(async (withStripeReconcile = false) => {
|
|
95
98
|
try {
|
|
96
|
-
|
|
99
|
+
if (withStripeReconcile) {
|
|
100
|
+
try {
|
|
101
|
+
await reconcileStripe();
|
|
102
|
+
} catch {
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const completionResult = await waitForCheckoutComplete(sessionId, {
|
|
106
|
+
reconcile: withStripeReconcile ? reconcileStripe : void 0
|
|
107
|
+
});
|
|
97
108
|
if (!mountedRef.current) return;
|
|
98
109
|
setResult(completionResult);
|
|
99
110
|
updateSessionData?.(completionResult);
|
|
@@ -126,7 +137,7 @@ export function useSubmit(sessionData, sessionId, currencyId, isStripe, isCredit
|
|
|
126
137
|
}
|
|
127
138
|
setStatus("submitting");
|
|
128
139
|
setContext(null);
|
|
129
|
-
handleCompletion();
|
|
140
|
+
handleCompletion(true);
|
|
130
141
|
}, [sessionId]);
|
|
131
142
|
useEffect(() => {
|
|
132
143
|
if (status !== "completed" || !sessionId) return void 0;
|
|
@@ -502,7 +513,7 @@ export function useSubmit(sessionData, sessionId, currencyId, isStripe, isCredit
|
|
|
502
513
|
if (status !== "waiting_stripe") return;
|
|
503
514
|
setStatus("submitting");
|
|
504
515
|
setContext(null);
|
|
505
|
-
await handleCompletion();
|
|
516
|
+
await handleCompletion(true);
|
|
506
517
|
});
|
|
507
518
|
const stripeCancel = useMemoizedFn(async () => {
|
|
508
519
|
if (status !== "waiting_stripe") return;
|
package/es/shared/api.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ export declare const API: {
|
|
|
22
22
|
readonly START_PAYMENT_LINK: (id: string) => string;
|
|
23
23
|
readonly RETRIEVE_SESSION: (id: string) => string;
|
|
24
24
|
readonly STATUS_SESSION: (id: string) => string;
|
|
25
|
+
readonly RECONCILE_STRIPE_SESSION: (id: string) => string;
|
|
25
26
|
readonly SWITCH_CURRENCY: (id: string) => string;
|
|
26
27
|
readonly SUBMIT: (id: string) => string;
|
|
27
28
|
readonly DONATE_SUBMIT: (id: string) => string;
|
package/es/shared/api.js
CHANGED
|
@@ -62,6 +62,7 @@ export const API = {
|
|
|
62
62
|
START_PAYMENT_LINK: (id) => `/api/checkout-sessions/start/${id}`,
|
|
63
63
|
RETRIEVE_SESSION: (id) => `/api/checkout-sessions/retrieve/${id}`,
|
|
64
64
|
STATUS_SESSION: (id) => `/api/checkout-sessions/status/${id}`,
|
|
65
|
+
RECONCILE_STRIPE_SESSION: (id) => `/api/checkout-sessions/${id}/stripe-reconcile`,
|
|
65
66
|
SWITCH_CURRENCY: (id) => `/api/checkout-sessions/${id}/switch-currency`,
|
|
66
67
|
SUBMIT: (id) => `/api/checkout-sessions/${id}/submit`,
|
|
67
68
|
DONATE_SUBMIT: (id) => `/api/checkout-sessions/${id}/donate-submit`,
|
package/es/shared/polling.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { CheckoutResult } from '../checkout/types';
|
|
2
|
+
type PollOptions = {
|
|
3
|
+
reconcile?: () => Promise<void>;
|
|
4
|
+
now?: () => number;
|
|
5
|
+
sleep?: (ms: number) => Promise<void>;
|
|
6
|
+
};
|
|
2
7
|
/**
|
|
3
8
|
* Poll for checkout session completion.
|
|
4
9
|
*
|
|
@@ -8,9 +13,10 @@ import type { CheckoutResult } from '../checkout/types';
|
|
|
8
13
|
*
|
|
9
14
|
* This reduces D1 load during payment processing and speeds up detection.
|
|
10
15
|
*/
|
|
11
|
-
export declare function waitForCheckoutComplete(sessionId: string): Promise<CheckoutResult>;
|
|
16
|
+
export declare function waitForCheckoutComplete(sessionId: string, options?: PollOptions): Promise<CheckoutResult>;
|
|
12
17
|
/**
|
|
13
18
|
* Generate unique idempotency key for submit (Final Freeze Architecture).
|
|
14
19
|
* Format: ${sessionId}-${currencyId}-${timestamp}-${random}
|
|
15
20
|
*/
|
|
16
21
|
export declare function generateIdempotencyKey(sessionId: string, currencyId: string): string;
|
|
22
|
+
export {};
|
package/es/shared/polling.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import api, { API } from "./api.js";
|
|
2
2
|
const POLL_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
3
|
+
const STRIPE_RECONCILE_CHECKPOINTS_MS = [2e3, 5e3, 1e4];
|
|
3
4
|
function nextPollInterval(elapsedMs) {
|
|
4
5
|
if (elapsedMs < 5e3) return 500;
|
|
5
6
|
if (elapsedMs < 3e4) return 1500;
|
|
6
7
|
return 3e3;
|
|
7
8
|
}
|
|
8
9
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
9
|
-
export async function waitForCheckoutComplete(sessionId) {
|
|
10
|
-
const
|
|
10
|
+
export async function waitForCheckoutComplete(sessionId, options = {}) {
|
|
11
|
+
const now = options.now ?? Date.now;
|
|
12
|
+
const wait = options.sleep ?? sleep;
|
|
13
|
+
const startedAt = now();
|
|
14
|
+
let reconcileIndex = 0;
|
|
11
15
|
while (true) {
|
|
12
16
|
const { data: data2 } = await api.get(API.STATUS_SESSION(sessionId));
|
|
13
17
|
if (data2.paymentIntent && data2.paymentIntent.status === "requires_action" && data2.paymentIntent.last_payment_error) {
|
|
@@ -15,11 +19,18 @@ export async function waitForCheckoutComplete(sessionId) {
|
|
|
15
19
|
}
|
|
16
20
|
const done = data2.checkoutSession?.status === "complete" && ["paid", "no_payment_required"].includes(data2.checkoutSession?.payment_status);
|
|
17
21
|
if (done) break;
|
|
18
|
-
const elapsed =
|
|
22
|
+
const elapsed = now() - startedAt;
|
|
19
23
|
if (elapsed >= POLL_TIMEOUT_MS) {
|
|
20
24
|
throw new Error("waitForCheckoutComplete: timed out");
|
|
21
25
|
}
|
|
22
|
-
|
|
26
|
+
if (options.reconcile && reconcileIndex < STRIPE_RECONCILE_CHECKPOINTS_MS.length && elapsed >= STRIPE_RECONCILE_CHECKPOINTS_MS[reconcileIndex]) {
|
|
27
|
+
reconcileIndex += 1;
|
|
28
|
+
try {
|
|
29
|
+
await options.reconcile();
|
|
30
|
+
} catch {
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
await wait(nextPollInterval(elapsed));
|
|
23
34
|
}
|
|
24
35
|
const { data } = await api.get(API.RETRIEVE_SESSION(sessionId));
|
|
25
36
|
return data;
|
|
@@ -87,9 +87,19 @@ function useSubmit(sessionData, sessionId, currencyId, isStripe, isCredit, isDon
|
|
|
87
87
|
}
|
|
88
88
|
};
|
|
89
89
|
}, [subscriptionRef.current, status]);
|
|
90
|
-
const
|
|
90
|
+
const reconcileStripe = (0, _ahooks.useMemoizedFn)(async () => {
|
|
91
|
+
await _api.default.post(_api.API.RECONCILE_STRIPE_SESSION(sessionId));
|
|
92
|
+
});
|
|
93
|
+
const handleCompletion = (0, _ahooks.useMemoizedFn)(async (withStripeReconcile = false) => {
|
|
91
94
|
try {
|
|
92
|
-
|
|
95
|
+
if (withStripeReconcile) {
|
|
96
|
+
try {
|
|
97
|
+
await reconcileStripe();
|
|
98
|
+
} catch {}
|
|
99
|
+
}
|
|
100
|
+
const completionResult = await (0, _polling.waitForCheckoutComplete)(sessionId, {
|
|
101
|
+
reconcile: withStripeReconcile ? reconcileStripe : void 0
|
|
102
|
+
});
|
|
93
103
|
if (!mountedRef.current) return;
|
|
94
104
|
setResult(completionResult);
|
|
95
105
|
updateSessionData?.(completionResult);
|
|
@@ -128,7 +138,7 @@ function useSubmit(sessionData, sessionId, currencyId, isStripe, isCredit, isDon
|
|
|
128
138
|
}
|
|
129
139
|
setStatus("submitting");
|
|
130
140
|
setContext(null);
|
|
131
|
-
handleCompletion();
|
|
141
|
+
handleCompletion(true);
|
|
132
142
|
}, [sessionId]);
|
|
133
143
|
(0, _react.useEffect)(() => {
|
|
134
144
|
if (status !== "completed" || !sessionId) return void 0;
|
|
@@ -522,7 +532,7 @@ function useSubmit(sessionData, sessionId, currencyId, isStripe, isCredit, isDon
|
|
|
522
532
|
if (status !== "waiting_stripe") return;
|
|
523
533
|
setStatus("submitting");
|
|
524
534
|
setContext(null);
|
|
525
|
-
await handleCompletion();
|
|
535
|
+
await handleCompletion(true);
|
|
526
536
|
});
|
|
527
537
|
const stripeCancel = (0, _ahooks.useMemoizedFn)(async () => {
|
|
528
538
|
if (status !== "waiting_stripe") return;
|
package/lib/shared/api.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ export declare const API: {
|
|
|
22
22
|
readonly START_PAYMENT_LINK: (id: string) => string;
|
|
23
23
|
readonly RETRIEVE_SESSION: (id: string) => string;
|
|
24
24
|
readonly STATUS_SESSION: (id: string) => string;
|
|
25
|
+
readonly RECONCILE_STRIPE_SESSION: (id: string) => string;
|
|
25
26
|
readonly SWITCH_CURRENCY: (id: string) => string;
|
|
26
27
|
readonly SUBMIT: (id: string) => string;
|
|
27
28
|
readonly DONATE_SUBMIT: (id: string) => string;
|
package/lib/shared/api.js
CHANGED
|
@@ -69,6 +69,7 @@ const API = exports.API = {
|
|
|
69
69
|
START_PAYMENT_LINK: id => `/api/checkout-sessions/start/${id}`,
|
|
70
70
|
RETRIEVE_SESSION: id => `/api/checkout-sessions/retrieve/${id}`,
|
|
71
71
|
STATUS_SESSION: id => `/api/checkout-sessions/status/${id}`,
|
|
72
|
+
RECONCILE_STRIPE_SESSION: id => `/api/checkout-sessions/${id}/stripe-reconcile`,
|
|
72
73
|
SWITCH_CURRENCY: id => `/api/checkout-sessions/${id}/switch-currency`,
|
|
73
74
|
SUBMIT: id => `/api/checkout-sessions/${id}/submit`,
|
|
74
75
|
DONATE_SUBMIT: id => `/api/checkout-sessions/${id}/donate-submit`,
|
package/lib/shared/polling.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { CheckoutResult } from '../checkout/types';
|
|
2
|
+
type PollOptions = {
|
|
3
|
+
reconcile?: () => Promise<void>;
|
|
4
|
+
now?: () => number;
|
|
5
|
+
sleep?: (ms: number) => Promise<void>;
|
|
6
|
+
};
|
|
2
7
|
/**
|
|
3
8
|
* Poll for checkout session completion.
|
|
4
9
|
*
|
|
@@ -8,9 +13,10 @@ import type { CheckoutResult } from '../checkout/types';
|
|
|
8
13
|
*
|
|
9
14
|
* This reduces D1 load during payment processing and speeds up detection.
|
|
10
15
|
*/
|
|
11
|
-
export declare function waitForCheckoutComplete(sessionId: string): Promise<CheckoutResult>;
|
|
16
|
+
export declare function waitForCheckoutComplete(sessionId: string, options?: PollOptions): Promise<CheckoutResult>;
|
|
12
17
|
/**
|
|
13
18
|
* Generate unique idempotency key for submit (Final Freeze Architecture).
|
|
14
19
|
* Format: ${sessionId}-${currencyId}-${timestamp}-${random}
|
|
15
20
|
*/
|
|
16
21
|
export declare function generateIdempotencyKey(sessionId: string, currencyId: string): string;
|
|
22
|
+
export {};
|
package/lib/shared/polling.js
CHANGED
|
@@ -9,14 +9,18 @@ var _api = _interopRequireWildcard(require("./api"));
|
|
|
9
9
|
function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function (e) { return e ? t : r; })(e); }
|
|
10
10
|
function _interopRequireWildcard(e, r) { if (!r && e && e.__esModule) return e; if (null === e || "object" != typeof e && "function" != typeof e) return { default: e }; var t = _getRequireWildcardCache(r); if (t && t.has(e)) return t.get(e); var n = { __proto__: null }, a = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var u in e) if ("default" !== u && {}.hasOwnProperty.call(e, u)) { var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; } return n.default = e, t && t.set(e, n), n; }
|
|
11
11
|
const POLL_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
12
|
+
const STRIPE_RECONCILE_CHECKPOINTS_MS = [2e3, 5e3, 1e4];
|
|
12
13
|
function nextPollInterval(elapsedMs) {
|
|
13
14
|
if (elapsedMs < 5e3) return 500;
|
|
14
15
|
if (elapsedMs < 3e4) return 1500;
|
|
15
16
|
return 3e3;
|
|
16
17
|
}
|
|
17
18
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
18
|
-
async function waitForCheckoutComplete(sessionId) {
|
|
19
|
-
const
|
|
19
|
+
async function waitForCheckoutComplete(sessionId, options = {}) {
|
|
20
|
+
const now = options.now ?? Date.now;
|
|
21
|
+
const wait = options.sleep ?? sleep;
|
|
22
|
+
const startedAt = now();
|
|
23
|
+
let reconcileIndex = 0;
|
|
20
24
|
while (true) {
|
|
21
25
|
const {
|
|
22
26
|
data: data2
|
|
@@ -26,11 +30,17 @@ async function waitForCheckoutComplete(sessionId) {
|
|
|
26
30
|
}
|
|
27
31
|
const done = data2.checkoutSession?.status === "complete" && ["paid", "no_payment_required"].includes(data2.checkoutSession?.payment_status);
|
|
28
32
|
if (done) break;
|
|
29
|
-
const elapsed =
|
|
33
|
+
const elapsed = now() - startedAt;
|
|
30
34
|
if (elapsed >= POLL_TIMEOUT_MS) {
|
|
31
35
|
throw new Error("waitForCheckoutComplete: timed out");
|
|
32
36
|
}
|
|
33
|
-
|
|
37
|
+
if (options.reconcile && reconcileIndex < STRIPE_RECONCILE_CHECKPOINTS_MS.length && elapsed >= STRIPE_RECONCILE_CHECKPOINTS_MS[reconcileIndex]) {
|
|
38
|
+
reconcileIndex += 1;
|
|
39
|
+
try {
|
|
40
|
+
await options.reconcile();
|
|
41
|
+
} catch {}
|
|
42
|
+
}
|
|
43
|
+
await wait(nextPollInterval(elapsed));
|
|
34
44
|
}
|
|
35
45
|
const {
|
|
36
46
|
data
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blocklet/payment-react-headless",
|
|
3
|
-
"version": "1.29.
|
|
3
|
+
"version": "1.29.11",
|
|
4
4
|
"description": "Headless React hooks for payment-kit checkout",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@arcblock/ws": "^1.30.24",
|
|
37
37
|
"@blocklet/js-sdk": "workspace:*",
|
|
38
|
-
"@blocklet/payment-types": "1.29.
|
|
38
|
+
"@blocklet/payment-types": "1.29.11",
|
|
39
39
|
"@ocap/util": "^1.30.24",
|
|
40
40
|
"ahooks": "^3.8.5",
|
|
41
41
|
"google-libphonenumber": "^3.2.42",
|
|
@@ -60,5 +60,5 @@
|
|
|
60
60
|
"publishConfig": {
|
|
61
61
|
"access": "public"
|
|
62
62
|
},
|
|
63
|
-
"gitHead": "
|
|
63
|
+
"gitHead": "775c2d6b9624e70d12a3872f46f7b2deb52591ba"
|
|
64
64
|
}
|
|
@@ -181,9 +181,22 @@ export function useSubmit(
|
|
|
181
181
|
}, [subscriptionRef.current, status]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
182
182
|
|
|
183
183
|
// Common completion handler
|
|
184
|
-
const
|
|
184
|
+
const reconcileStripe = useMemoizedFn(async () => {
|
|
185
|
+
await api.post(API.RECONCILE_STRIPE_SESSION(sessionId));
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const handleCompletion = useMemoizedFn(async (withStripeReconcile = false) => {
|
|
185
189
|
try {
|
|
186
|
-
|
|
190
|
+
if (withStripeReconcile) {
|
|
191
|
+
try {
|
|
192
|
+
await reconcileStripe();
|
|
193
|
+
} catch {
|
|
194
|
+
// Continue polling; webhook delivery may still reconcile the payment.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const completionResult = await waitForCheckoutComplete(sessionId, {
|
|
198
|
+
reconcile: withStripeReconcile ? reconcileStripe : undefined,
|
|
199
|
+
});
|
|
187
200
|
if (!mountedRef.current) return;
|
|
188
201
|
setResult(completionResult);
|
|
189
202
|
// Update session context so downstream consumers (e.g. SuccessView) have fresh data
|
|
@@ -235,7 +248,7 @@ export function useSubmit(
|
|
|
235
248
|
// early-throws on a surfaced last_payment_error, so a hard decline still ends fast.
|
|
236
249
|
setStatus('submitting');
|
|
237
250
|
setContext(null);
|
|
238
|
-
handleCompletion();
|
|
251
|
+
handleCompletion(true);
|
|
239
252
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
240
253
|
}, [sessionId]);
|
|
241
254
|
|
|
@@ -720,7 +733,7 @@ export function useSubmit(
|
|
|
720
733
|
if (status !== 'waiting_stripe') return;
|
|
721
734
|
setStatus('submitting');
|
|
722
735
|
setContext(null);
|
|
723
|
-
await handleCompletion();
|
|
736
|
+
await handleCompletion(true);
|
|
724
737
|
});
|
|
725
738
|
|
|
726
739
|
// Stripe cancel
|
package/src/shared/api.ts
CHANGED
|
@@ -99,6 +99,7 @@ export const API = {
|
|
|
99
99
|
START_PAYMENT_LINK: (id: string) => `/api/checkout-sessions/start/${id}`,
|
|
100
100
|
RETRIEVE_SESSION: (id: string) => `/api/checkout-sessions/retrieve/${id}`,
|
|
101
101
|
STATUS_SESSION: (id: string) => `/api/checkout-sessions/status/${id}`,
|
|
102
|
+
RECONCILE_STRIPE_SESSION: (id: string) => `/api/checkout-sessions/${id}/stripe-reconcile`,
|
|
102
103
|
SWITCH_CURRENCY: (id: string) => `/api/checkout-sessions/${id}/switch-currency`,
|
|
103
104
|
SUBMIT: (id: string) => `/api/checkout-sessions/${id}/submit`,
|
|
104
105
|
DONATE_SUBMIT: (id: string) => `/api/checkout-sessions/${id}/donate-submit`,
|
package/src/shared/polling.ts
CHANGED
|
@@ -2,6 +2,13 @@ import api, { API } from './api';
|
|
|
2
2
|
import type { CheckoutResult } from '../checkout/types';
|
|
3
3
|
|
|
4
4
|
const POLL_TIMEOUT_MS = 5 * 60 * 1000;
|
|
5
|
+
const STRIPE_RECONCILE_CHECKPOINTS_MS = [2_000, 5_000, 10_000];
|
|
6
|
+
|
|
7
|
+
type PollOptions = {
|
|
8
|
+
reconcile?: () => Promise<void>;
|
|
9
|
+
now?: () => number;
|
|
10
|
+
sleep?: (ms: number) => Promise<void>;
|
|
11
|
+
};
|
|
5
12
|
|
|
6
13
|
/**
|
|
7
14
|
* Progressive polling interval — typical DID Connect + on-chain confirm + DB
|
|
@@ -26,8 +33,14 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
26
33
|
*
|
|
27
34
|
* This reduces D1 load during payment processing and speeds up detection.
|
|
28
35
|
*/
|
|
29
|
-
export async function waitForCheckoutComplete(
|
|
30
|
-
|
|
36
|
+
export async function waitForCheckoutComplete(
|
|
37
|
+
sessionId: string,
|
|
38
|
+
options: PollOptions = {}
|
|
39
|
+
): Promise<CheckoutResult> {
|
|
40
|
+
const now = options.now ?? Date.now;
|
|
41
|
+
const wait = options.sleep ?? sleep;
|
|
42
|
+
const startedAt = now();
|
|
43
|
+
let reconcileIndex = 0;
|
|
31
44
|
|
|
32
45
|
// Phase 1: Lightweight status polling with progressive backoff.
|
|
33
46
|
// First check fires immediately — happy-path DID flows where the backend
|
|
@@ -48,11 +61,24 @@ export async function waitForCheckoutComplete(sessionId: string): Promise<Checko
|
|
|
48
61
|
['paid', 'no_payment_required'].includes(data.checkoutSession?.payment_status);
|
|
49
62
|
if (done) break;
|
|
50
63
|
|
|
51
|
-
const elapsed =
|
|
64
|
+
const elapsed = now() - startedAt;
|
|
52
65
|
if (elapsed >= POLL_TIMEOUT_MS) {
|
|
53
66
|
throw new Error('waitForCheckoutComplete: timed out');
|
|
54
67
|
}
|
|
55
|
-
|
|
68
|
+
if (
|
|
69
|
+
options.reconcile &&
|
|
70
|
+
reconcileIndex < STRIPE_RECONCILE_CHECKPOINTS_MS.length &&
|
|
71
|
+
elapsed >= STRIPE_RECONCILE_CHECKPOINTS_MS[reconcileIndex]!
|
|
72
|
+
) {
|
|
73
|
+
reconcileIndex += 1;
|
|
74
|
+
try {
|
|
75
|
+
await options.reconcile();
|
|
76
|
+
} catch {
|
|
77
|
+
// Webhook remains the primary path. A failed fallback must not stop
|
|
78
|
+
// local status polling or turn a successful payment into a UI error.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
await wait(nextPollInterval(elapsed));
|
|
56
82
|
}
|
|
57
83
|
|
|
58
84
|
// Phase 2: Full retrieve for success page data
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it, jest } from '@jest/globals';
|
|
2
|
+
|
|
3
|
+
const get = jest.fn<(url: string) => Promise<any>>();
|
|
4
|
+
|
|
5
|
+
jest.mock('../../src/shared/api', () => ({
|
|
6
|
+
__esModule: true,
|
|
7
|
+
default: { get },
|
|
8
|
+
API: {
|
|
9
|
+
STATUS_SESSION: (id: string) => `/status/${id}`,
|
|
10
|
+
RETRIEVE_SESSION: (id: string) => `/retrieve/${id}`,
|
|
11
|
+
},
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
import { waitForCheckoutComplete } from '../../src/shared/polling';
|
|
15
|
+
|
|
16
|
+
describe('waitForCheckoutComplete Stripe reconciliation', () => {
|
|
17
|
+
it('reconciles at bounded checkpoints while local status remains pending', async () => {
|
|
18
|
+
let elapsed = 0;
|
|
19
|
+
const reconcile = jest.fn<() => Promise<void>>().mockResolvedValue();
|
|
20
|
+
get.mockImplementation(async (url: string) => {
|
|
21
|
+
if (url.startsWith('/retrieve/')) {
|
|
22
|
+
return { data: { checkoutSession: { status: 'complete', payment_status: 'paid' } } };
|
|
23
|
+
}
|
|
24
|
+
if (reconcile.mock.calls.length >= 2) {
|
|
25
|
+
return {
|
|
26
|
+
data: {
|
|
27
|
+
checkoutSession: { status: 'complete', payment_status: 'paid' },
|
|
28
|
+
paymentIntent: { status: 'succeeded' },
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
data: {
|
|
34
|
+
checkoutSession: { status: 'open', payment_status: 'unpaid' },
|
|
35
|
+
paymentIntent: { status: 'processing' },
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const result = await waitForCheckoutComplete('cs_1', {
|
|
41
|
+
reconcile,
|
|
42
|
+
now: () => elapsed,
|
|
43
|
+
sleep: async (ms: number) => {
|
|
44
|
+
elapsed += ms;
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(reconcile).toHaveBeenCalledTimes(2);
|
|
49
|
+
expect(elapsed).toBeGreaterThanOrEqual(5000);
|
|
50
|
+
expect(result.checkoutSession.status).toBe('complete');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('does not reconcile non-Stripe polling when no callback is supplied', async () => {
|
|
54
|
+
get
|
|
55
|
+
.mockResolvedValueOnce({
|
|
56
|
+
data: { checkoutSession: { status: 'complete', payment_status: 'paid' }, paymentIntent: null },
|
|
57
|
+
})
|
|
58
|
+
.mockResolvedValueOnce({
|
|
59
|
+
data: { checkoutSession: { status: 'complete', payment_status: 'paid' } },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await expect(waitForCheckoutComplete('cs_2')).resolves.toBeTruthy();
|
|
63
|
+
expect(get).toHaveBeenCalledTimes(2);
|
|
64
|
+
});
|
|
65
|
+
});
|