@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.
@@ -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);
@@ -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;
@@ -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;
@@ -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);
@@ -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;
@@ -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.9",
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.9",
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": "249bd9bf3c6cc54f77c260edb4b4d3c0be60f126"
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 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
@@ -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`,
@@ -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
+ });