@blocklet/payment-react-headless 1.29.8 → 1.29.10
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/useCustomerForm.js +2 -0
- package/es/checkout/hooks/useSubmit.js +37 -3
- 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/useCustomerForm.js +6 -2
- package/lib/checkout/hooks/useSubmit.js +39 -3
- 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/useCustomerForm.ts +7 -2
- package/src/checkout/hooks/useSubmit.ts +58 -3
- package/src/shared/api.ts +1 -0
- package/src/shared/polling.ts +30 -4
- package/tests/shared/polling.spec.ts +65 -0
|
@@ -49,6 +49,7 @@ export function useCustomerForm(sessionData, currencyId, methodId) {
|
|
|
49
49
|
}));
|
|
50
50
|
}
|
|
51
51
|
} catch {
|
|
52
|
+
setPrefetched(true);
|
|
52
53
|
}
|
|
53
54
|
};
|
|
54
55
|
fetchCustomer();
|
|
@@ -105,6 +106,7 @@ export function useCustomerForm(sessionData, currencyId, methodId) {
|
|
|
105
106
|
}));
|
|
106
107
|
}
|
|
107
108
|
} catch {
|
|
109
|
+
setPrefetched(true);
|
|
108
110
|
}
|
|
109
111
|
});
|
|
110
112
|
return {
|
|
@@ -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);
|
|
@@ -105,6 +116,29 @@ export function useSubmit(sessionData, sessionId, currencyId, isStripe, isCredit
|
|
|
105
116
|
setContext({ type: "error", message: getErrorMessage(err) || "Payment verification failed" });
|
|
106
117
|
}
|
|
107
118
|
});
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (completedDetectedRef.current || !sessionId) return;
|
|
121
|
+
if (status !== "idle" && status !== "waiting_stripe") return;
|
|
122
|
+
let redirectStatus = null;
|
|
123
|
+
let hasPaymentIntent = false;
|
|
124
|
+
try {
|
|
125
|
+
const params = new URLSearchParams(window.location.search);
|
|
126
|
+
redirectStatus = params.get("redirect_status");
|
|
127
|
+
hasPaymentIntent = !!params.get("payment_intent");
|
|
128
|
+
} catch {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (!redirectStatus && !hasPaymentIntent) return;
|
|
132
|
+
completedDetectedRef.current = true;
|
|
133
|
+
if (redirectStatus === "failed" || redirectStatus === "canceled") {
|
|
134
|
+
setStatus("failed");
|
|
135
|
+
setContext({ type: "error", message: "Payment was not completed. Please try again." });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
setStatus("submitting");
|
|
139
|
+
setContext(null);
|
|
140
|
+
handleCompletion(true);
|
|
141
|
+
}, [sessionId]);
|
|
108
142
|
useEffect(() => {
|
|
109
143
|
if (status !== "completed" || !sessionId) return void 0;
|
|
110
144
|
const hasVendors = session?.line_items?.some(
|
|
@@ -479,7 +513,7 @@ export function useSubmit(sessionData, sessionId, currencyId, isStripe, isCredit
|
|
|
479
513
|
if (status !== "waiting_stripe") return;
|
|
480
514
|
setStatus("submitting");
|
|
481
515
|
setContext(null);
|
|
482
|
-
await handleCompletion();
|
|
516
|
+
await handleCompletion(true);
|
|
483
517
|
});
|
|
484
518
|
const stripeCancel = useMemoizedFn(async () => {
|
|
485
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;
|
|
@@ -62,7 +62,9 @@ function useCustomerForm(sessionData, currencyId, methodId) {
|
|
|
62
62
|
}
|
|
63
63
|
}));
|
|
64
64
|
}
|
|
65
|
-
} catch {
|
|
65
|
+
} catch {
|
|
66
|
+
setPrefetched(true);
|
|
67
|
+
}
|
|
66
68
|
};
|
|
67
69
|
fetchCustomer();
|
|
68
70
|
}, [session?.id]);
|
|
@@ -124,7 +126,9 @@ function useCustomerForm(sessionData, currencyId, methodId) {
|
|
|
124
126
|
}
|
|
125
127
|
}));
|
|
126
128
|
}
|
|
127
|
-
} catch {
|
|
129
|
+
} catch {
|
|
130
|
+
setPrefetched(true);
|
|
131
|
+
}
|
|
128
132
|
});
|
|
129
133
|
return {
|
|
130
134
|
fields,
|
|
@@ -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);
|
|
@@ -104,6 +114,32 @@ function useSubmit(sessionData, sessionId, currencyId, isStripe, isCredit, isDon
|
|
|
104
114
|
});
|
|
105
115
|
}
|
|
106
116
|
});
|
|
117
|
+
(0, _react.useEffect)(() => {
|
|
118
|
+
if (completedDetectedRef.current || !sessionId) return;
|
|
119
|
+
if (status !== "idle" && status !== "waiting_stripe") return;
|
|
120
|
+
let redirectStatus = null;
|
|
121
|
+
let hasPaymentIntent = false;
|
|
122
|
+
try {
|
|
123
|
+
const params = new URLSearchParams(window.location.search);
|
|
124
|
+
redirectStatus = params.get("redirect_status");
|
|
125
|
+
hasPaymentIntent = !!params.get("payment_intent");
|
|
126
|
+
} catch {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (!redirectStatus && !hasPaymentIntent) return;
|
|
130
|
+
completedDetectedRef.current = true;
|
|
131
|
+
if (redirectStatus === "failed" || redirectStatus === "canceled") {
|
|
132
|
+
setStatus("failed");
|
|
133
|
+
setContext({
|
|
134
|
+
type: "error",
|
|
135
|
+
message: "Payment was not completed. Please try again."
|
|
136
|
+
});
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
setStatus("submitting");
|
|
140
|
+
setContext(null);
|
|
141
|
+
handleCompletion(true);
|
|
142
|
+
}, [sessionId]);
|
|
107
143
|
(0, _react.useEffect)(() => {
|
|
108
144
|
if (status !== "completed" || !sessionId) return void 0;
|
|
109
145
|
const hasVendors = session?.line_items?.some(item => !!item?.price?.product?.vendor_config?.length);
|
|
@@ -496,7 +532,7 @@ function useSubmit(sessionData, sessionId, currencyId, isStripe, isCredit, isDon
|
|
|
496
532
|
if (status !== "waiting_stripe") return;
|
|
497
533
|
setStatus("submitting");
|
|
498
534
|
setContext(null);
|
|
499
|
-
await handleCompletion();
|
|
535
|
+
await handleCompletion(true);
|
|
500
536
|
});
|
|
501
537
|
const stripeCancel = (0, _ahooks.useMemoizedFn)(async () => {
|
|
502
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.10",
|
|
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.10",
|
|
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": "18c831f5dbdfb271a96640befb06f589adbeeab0"
|
|
64
64
|
}
|
|
@@ -82,7 +82,11 @@ export function useCustomerForm(
|
|
|
82
82
|
}));
|
|
83
83
|
}
|
|
84
84
|
} catch {
|
|
85
|
-
//
|
|
85
|
+
// /customers/me is best-effort. Still mark prefetch as attempted so the
|
|
86
|
+
// customer-info card renders an editable form (letting the user fill the
|
|
87
|
+
// required name/email manually) instead of staying hidden forever — e.g.
|
|
88
|
+
// when the endpoint 403s under embedded (no resolvable payment session).
|
|
89
|
+
setPrefetched(true);
|
|
86
90
|
}
|
|
87
91
|
};
|
|
88
92
|
|
|
@@ -149,7 +153,8 @@ export function useCustomerForm(
|
|
|
149
153
|
}));
|
|
150
154
|
}
|
|
151
155
|
} catch {
|
|
152
|
-
//
|
|
156
|
+
// Best-effort (see prefetch above) — mark attempted so the form still renders.
|
|
157
|
+
setPrefetched(true);
|
|
153
158
|
}
|
|
154
159
|
});
|
|
155
160
|
|
|
@@ -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
|
|
@@ -197,6 +210,48 @@ export function useSubmit(
|
|
|
197
210
|
}
|
|
198
211
|
});
|
|
199
212
|
|
|
213
|
+
// Stripe redirect fallback: when confirmPayment redirects back to return_url
|
|
214
|
+
// (3DS or redirect-based methods), the page fully reloads — onConfirm →
|
|
215
|
+
// stripeConfirm never runs, and the completion event only arrives via the
|
|
216
|
+
// WebSocket relay, which is unreachable on embedded/no-relay runtimes. Stripe
|
|
217
|
+
// appends redirect_status / payment_intent to the return URL; detect it and
|
|
218
|
+
// start polling until the backend reconciles, instead of getting stuck on
|
|
219
|
+
// loading until the user manually refreshes.
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
if (completedDetectedRef.current || !sessionId) return;
|
|
222
|
+
if (status !== 'idle' && status !== 'waiting_stripe') return;
|
|
223
|
+
let redirectStatus: string | null = null;
|
|
224
|
+
let hasPaymentIntent = false;
|
|
225
|
+
try {
|
|
226
|
+
const params = new URLSearchParams(window.location.search);
|
|
227
|
+
redirectStatus = params.get('redirect_status');
|
|
228
|
+
hasPaymentIntent = !!params.get('payment_intent');
|
|
229
|
+
} catch {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
// Not a Stripe redirect return — nothing to reconcile.
|
|
233
|
+
if (!redirectStatus && !hasPaymentIntent) return;
|
|
234
|
+
|
|
235
|
+
completedDetectedRef.current = true;
|
|
236
|
+
|
|
237
|
+
// A failed/canceled redirect ALSO carries `payment_intent`, so the bare presence
|
|
238
|
+
// of that param is not a success signal. Surface the failure immediately instead
|
|
239
|
+
// of polling a session that will never reach paid until the 5-minute timeout.
|
|
240
|
+
if (redirectStatus === 'failed' || redirectStatus === 'canceled') {
|
|
241
|
+
setStatus('failed');
|
|
242
|
+
setContext({ type: 'error', message: 'Payment was not completed. Please try again.' });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// succeeded / pending / (param present without an explicit status) → the backend
|
|
247
|
+
// is the source of truth; poll until it reconciles. waitForCheckoutComplete also
|
|
248
|
+
// early-throws on a surfaced last_payment_error, so a hard decline still ends fast.
|
|
249
|
+
setStatus('submitting');
|
|
250
|
+
setContext(null);
|
|
251
|
+
handleCompletion(true);
|
|
252
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
253
|
+
}, [sessionId]);
|
|
254
|
+
|
|
200
255
|
// Vendor order polling after completion
|
|
201
256
|
useEffect(() => {
|
|
202
257
|
if (status !== 'completed' || !sessionId) return undefined;
|
|
@@ -678,7 +733,7 @@ export function useSubmit(
|
|
|
678
733
|
if (status !== 'waiting_stripe') return;
|
|
679
734
|
setStatus('submitting');
|
|
680
735
|
setContext(null);
|
|
681
|
-
await handleCompletion();
|
|
736
|
+
await handleCompletion(true);
|
|
682
737
|
});
|
|
683
738
|
|
|
684
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
|
+
});
|