@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.
@@ -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 handleCompletion = useMemoizedFn(async () => {
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
- const completionResult = await waitForCheckoutComplete(sessionId);
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;
@@ -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`,
@@ -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 {};
@@ -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 startedAt = Date.now();
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 = Date.now() - startedAt;
22
+ const elapsed = now() - startedAt;
19
23
  if (elapsed >= POLL_TIMEOUT_MS) {
20
24
  throw new Error("waitForCheckoutComplete: timed out");
21
25
  }
22
- await sleep(nextPollInterval(elapsed));
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 handleCompletion = (0, _ahooks.useMemoizedFn)(async () => {
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
- const completionResult = await (0, _polling.waitForCheckoutComplete)(sessionId);
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;
@@ -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`,
@@ -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 {};
@@ -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 startedAt = Date.now();
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 = Date.now() - startedAt;
33
+ const elapsed = now() - startedAt;
30
34
  if (elapsed >= POLL_TIMEOUT_MS) {
31
35
  throw new Error("waitForCheckoutComplete: timed out");
32
36
  }
33
- await sleep(nextPollInterval(elapsed));
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.8",
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.8",
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": "547c0614f1f83abad80ea08452d2f5a9f4f5aa07"
63
+ "gitHead": "18c831f5dbdfb271a96640befb06f589adbeeab0"
64
64
  }
@@ -82,7 +82,11 @@ export function useCustomerForm(
82
82
  }));
83
83
  }
84
84
  } catch {
85
- // Ignore - customer info is optional
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
- // Ignore - customer info is optional
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 handleCompletion = useMemoizedFn(async () => {
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
- const completionResult = await waitForCheckoutComplete(sessionId);
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`,
@@ -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(sessionId: string): Promise<CheckoutResult> {
30
- const startedAt = Date.now();
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 = Date.now() - startedAt;
64
+ const elapsed = now() - startedAt;
52
65
  if (elapsed >= POLL_TIMEOUT_MS) {
53
66
  throw new Error('waitForCheckoutComplete: timed out');
54
67
  }
55
- await sleep(nextPollInterval(elapsed));
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
+ });