@blocklet/payment-react-headless 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. package/.eslintrc.js +18 -0
  2. package/build.config.ts +30 -0
  3. package/es/checkout/context/CheckoutProvider.d.ts +6 -0
  4. package/es/checkout/context/CheckoutProvider.js +209 -0
  5. package/es/checkout/context/CustomerFormContext.d.ts +4 -0
  6. package/es/checkout/context/CustomerFormContext.js +9 -0
  7. package/es/checkout/context/ExchangeRateContext.d.ts +11 -0
  8. package/es/checkout/context/ExchangeRateContext.js +9 -0
  9. package/es/checkout/context/PaymentMethodContext.d.ts +26 -0
  10. package/es/checkout/context/PaymentMethodContext.js +9 -0
  11. package/es/checkout/context/SessionContext.d.ts +45 -0
  12. package/es/checkout/context/SessionContext.js +9 -0
  13. package/es/checkout/context/SubmitContext.d.ts +4 -0
  14. package/es/checkout/context/SubmitContext.js +9 -0
  15. package/es/checkout/context/index.d.ts +6 -0
  16. package/es/checkout/context/index.js +6 -0
  17. package/es/checkout/core/billingInterval.d.ts +15 -0
  18. package/es/checkout/core/billingInterval.js +36 -0
  19. package/es/checkout/core/crossSell.d.ts +4 -0
  20. package/es/checkout/core/crossSell.js +30 -0
  21. package/es/checkout/core/customerForm.d.ts +5 -0
  22. package/es/checkout/core/customerForm.js +105 -0
  23. package/es/checkout/core/exchangeRate.d.ts +11 -0
  24. package/es/checkout/core/exchangeRate.js +25 -0
  25. package/es/checkout/core/index.d.ts +10 -0
  26. package/es/checkout/core/index.js +55 -0
  27. package/es/checkout/core/lineItems.d.ts +7 -0
  28. package/es/checkout/core/lineItems.js +59 -0
  29. package/es/checkout/core/paymentMethod.d.ts +23 -0
  30. package/es/checkout/core/paymentMethod.js +85 -0
  31. package/es/checkout/core/pricing.d.ts +32 -0
  32. package/es/checkout/core/pricing.js +221 -0
  33. package/es/checkout/core/promotion.d.ts +10 -0
  34. package/es/checkout/core/promotion.js +39 -0
  35. package/es/checkout/core/session.d.ts +26 -0
  36. package/es/checkout/core/session.js +50 -0
  37. package/es/checkout/core/submit.d.ts +40 -0
  38. package/es/checkout/core/submit.js +66 -0
  39. package/es/checkout/hooks/index.d.ts +34 -0
  40. package/es/checkout/hooks/index.js +19 -0
  41. package/es/checkout/hooks/useBillingInterval.d.ts +14 -0
  42. package/es/checkout/hooks/useBillingInterval.js +50 -0
  43. package/es/checkout/hooks/useCheckout.d.ts +2 -0
  44. package/es/checkout/hooks/useCheckout.js +212 -0
  45. package/es/checkout/hooks/useCheckoutSession.d.ts +58 -0
  46. package/es/checkout/hooks/useCheckoutSession.js +107 -0
  47. package/es/checkout/hooks/useCheckoutStatus.d.ts +10 -0
  48. package/es/checkout/hooks/useCheckoutStatus.js +16 -0
  49. package/es/checkout/hooks/useCrossSell.d.ts +8 -0
  50. package/es/checkout/hooks/useCrossSell.js +57 -0
  51. package/es/checkout/hooks/useCustomerForm.d.ts +14 -0
  52. package/es/checkout/hooks/useCustomerForm.js +116 -0
  53. package/es/checkout/hooks/useCustomerFormFeature.d.ts +2 -0
  54. package/es/checkout/hooks/useCustomerFormFeature.js +4 -0
  55. package/es/checkout/hooks/useExchangeRate.d.ts +11 -0
  56. package/es/checkout/hooks/useExchangeRate.js +15 -0
  57. package/es/checkout/hooks/useLineItems.d.ts +22 -0
  58. package/es/checkout/hooks/useLineItems.js +139 -0
  59. package/es/checkout/hooks/usePaymentMethod.d.ts +26 -0
  60. package/es/checkout/hooks/usePaymentMethod.js +101 -0
  61. package/es/checkout/hooks/usePaymentMethodFeature.d.ts +2 -0
  62. package/es/checkout/hooks/usePaymentMethodFeature.js +4 -0
  63. package/es/checkout/hooks/usePricing.d.ts +57 -0
  64. package/es/checkout/hooks/usePricing.js +174 -0
  65. package/es/checkout/hooks/usePricingFeature.d.ts +28 -0
  66. package/es/checkout/hooks/usePricingFeature.js +36 -0
  67. package/es/checkout/hooks/useProduct.d.ts +32 -0
  68. package/es/checkout/hooks/useProduct.js +5 -0
  69. package/es/checkout/hooks/usePromotion.d.ts +12 -0
  70. package/es/checkout/hooks/usePromotion.js +48 -0
  71. package/es/checkout/hooks/useSlippage.d.ts +8 -0
  72. package/es/checkout/hooks/useSlippage.js +29 -0
  73. package/es/checkout/hooks/useSubmit.d.ts +38 -0
  74. package/es/checkout/hooks/useSubmit.js +493 -0
  75. package/es/checkout/hooks/useSubmitFeature.d.ts +2 -0
  76. package/es/checkout/hooks/useSubmitFeature.js +4 -0
  77. package/es/checkout/hooks/useUpsell.d.ts +5 -0
  78. package/es/checkout/hooks/useUpsell.js +25 -0
  79. package/es/checkout/index.d.ts +37 -0
  80. package/es/checkout/index.js +28 -0
  81. package/es/checkout/types.d.ts +262 -0
  82. package/es/checkout/types.js +0 -0
  83. package/es/index.d.ts +1 -0
  84. package/es/index.js +28 -0
  85. package/es/shared/api.d.ts +41 -0
  86. package/es/shared/api.js +81 -0
  87. package/es/shared/format.d.ts +38 -0
  88. package/es/shared/format.js +229 -0
  89. package/es/shared/polling.d.ts +15 -0
  90. package/es/shared/polling.js +20 -0
  91. package/es/shared/types.d.ts +10 -0
  92. package/es/shared/types.js +0 -0
  93. package/es/shared/validation.d.ts +38 -0
  94. package/es/shared/validation.js +190 -0
  95. package/es/types/checkout-augmented.d.ts +42 -0
  96. package/es/types/checkout-augmented.js +17 -0
  97. package/es/types/external.d.ts +18 -0
  98. package/examples/01-basic-checkout.tsx +159 -0
  99. package/examples/01-credit-recharge.tsx +19 -0
  100. package/examples/02-subscription.tsx +40 -0
  101. package/examples/03-upsell.tsx +60 -0
  102. package/examples/04-cross-sell.tsx +54 -0
  103. package/examples/05-full-checkout.tsx +126 -0
  104. package/jest.config.js +15 -0
  105. package/lib/checkout/context/CheckoutProvider.d.ts +6 -0
  106. package/lib/checkout/context/CheckoutProvider.js +181 -0
  107. package/lib/checkout/context/CustomerFormContext.d.ts +4 -0
  108. package/lib/checkout/context/CustomerFormContext.js +16 -0
  109. package/lib/checkout/context/ExchangeRateContext.d.ts +11 -0
  110. package/lib/checkout/context/ExchangeRateContext.js +16 -0
  111. package/lib/checkout/context/PaymentMethodContext.d.ts +26 -0
  112. package/lib/checkout/context/PaymentMethodContext.js +16 -0
  113. package/lib/checkout/context/SessionContext.d.ts +45 -0
  114. package/lib/checkout/context/SessionContext.js +16 -0
  115. package/lib/checkout/context/SubmitContext.d.ts +4 -0
  116. package/lib/checkout/context/SubmitContext.js +16 -0
  117. package/lib/checkout/context/index.d.ts +6 -0
  118. package/lib/checkout/context/index.js +77 -0
  119. package/lib/checkout/core/billingInterval.d.ts +15 -0
  120. package/lib/checkout/core/billingInterval.js +42 -0
  121. package/lib/checkout/core/crossSell.d.ts +4 -0
  122. package/lib/checkout/core/crossSell.js +43 -0
  123. package/lib/checkout/core/customerForm.d.ts +5 -0
  124. package/lib/checkout/core/customerForm.js +106 -0
  125. package/lib/checkout/core/exchangeRate.d.ts +11 -0
  126. package/lib/checkout/core/exchangeRate.js +45 -0
  127. package/lib/checkout/core/index.d.ts +10 -0
  128. package/lib/checkout/core/index.js +297 -0
  129. package/lib/checkout/core/lineItems.d.ts +7 -0
  130. package/lib/checkout/core/lineItems.js +76 -0
  131. package/lib/checkout/core/paymentMethod.d.ts +23 -0
  132. package/lib/checkout/core/paymentMethod.js +114 -0
  133. package/lib/checkout/core/pricing.d.ts +32 -0
  134. package/lib/checkout/core/pricing.js +216 -0
  135. package/lib/checkout/core/promotion.d.ts +10 -0
  136. package/lib/checkout/core/promotion.js +62 -0
  137. package/lib/checkout/core/session.d.ts +26 -0
  138. package/lib/checkout/core/session.js +58 -0
  139. package/lib/checkout/core/submit.d.ts +40 -0
  140. package/lib/checkout/core/submit.js +84 -0
  141. package/lib/checkout/hooks/index.d.ts +34 -0
  142. package/lib/checkout/hooks/index.js +138 -0
  143. package/lib/checkout/hooks/useBillingInterval.d.ts +14 -0
  144. package/lib/checkout/hooks/useBillingInterval.js +63 -0
  145. package/lib/checkout/hooks/useCheckout.d.ts +2 -0
  146. package/lib/checkout/hooks/useCheckout.js +190 -0
  147. package/lib/checkout/hooks/useCheckoutSession.d.ts +58 -0
  148. package/lib/checkout/hooks/useCheckoutSession.js +119 -0
  149. package/lib/checkout/hooks/useCheckoutStatus.d.ts +10 -0
  150. package/lib/checkout/hooks/useCheckoutStatus.js +28 -0
  151. package/lib/checkout/hooks/useCrossSell.d.ts +8 -0
  152. package/lib/checkout/hooks/useCrossSell.js +75 -0
  153. package/lib/checkout/hooks/useCustomerForm.d.ts +14 -0
  154. package/lib/checkout/hooks/useCustomerForm.js +135 -0
  155. package/lib/checkout/hooks/useCustomerFormFeature.d.ts +2 -0
  156. package/lib/checkout/hooks/useCustomerFormFeature.js +10 -0
  157. package/lib/checkout/hooks/useExchangeRate.d.ts +11 -0
  158. package/lib/checkout/hooks/useExchangeRate.js +29 -0
  159. package/lib/checkout/hooks/useLineItems.d.ts +22 -0
  160. package/lib/checkout/hooks/useLineItems.js +142 -0
  161. package/lib/checkout/hooks/usePaymentMethod.d.ts +26 -0
  162. package/lib/checkout/hooks/usePaymentMethod.js +101 -0
  163. package/lib/checkout/hooks/usePaymentMethodFeature.d.ts +2 -0
  164. package/lib/checkout/hooks/usePaymentMethodFeature.js +10 -0
  165. package/lib/checkout/hooks/usePricing.d.ts +57 -0
  166. package/lib/checkout/hooks/usePricing.js +168 -0
  167. package/lib/checkout/hooks/usePricingFeature.d.ts +28 -0
  168. package/lib/checkout/hooks/usePricingFeature.js +48 -0
  169. package/lib/checkout/hooks/useProduct.d.ts +32 -0
  170. package/lib/checkout/hooks/useProduct.js +21 -0
  171. package/lib/checkout/hooks/usePromotion.d.ts +12 -0
  172. package/lib/checkout/hooks/usePromotion.js +57 -0
  173. package/lib/checkout/hooks/useSlippage.d.ts +8 -0
  174. package/lib/checkout/hooks/useSlippage.js +39 -0
  175. package/lib/checkout/hooks/useSubmit.d.ts +38 -0
  176. package/lib/checkout/hooks/useSubmit.js +504 -0
  177. package/lib/checkout/hooks/useSubmitFeature.d.ts +2 -0
  178. package/lib/checkout/hooks/useSubmitFeature.js +10 -0
  179. package/lib/checkout/hooks/useUpsell.d.ts +5 -0
  180. package/lib/checkout/hooks/useUpsell.js +40 -0
  181. package/lib/checkout/index.d.ts +37 -0
  182. package/lib/checkout/index.js +182 -0
  183. package/lib/checkout/types.d.ts +262 -0
  184. package/lib/checkout/types.js +1 -0
  185. package/lib/index.d.ts +1 -0
  186. package/lib/index.js +162 -0
  187. package/lib/shared/api.d.ts +41 -0
  188. package/lib/shared/api.js +88 -0
  189. package/lib/shared/format.d.ts +38 -0
  190. package/lib/shared/format.js +262 -0
  191. package/lib/shared/polling.d.ts +15 -0
  192. package/lib/shared/polling.js +32 -0
  193. package/lib/shared/types.d.ts +10 -0
  194. package/lib/shared/types.js +1 -0
  195. package/lib/shared/validation.d.ts +38 -0
  196. package/lib/shared/validation.js +212 -0
  197. package/lib/types/checkout-augmented.d.ts +42 -0
  198. package/lib/types/checkout-augmented.js +24 -0
  199. package/lib/types/external.d.ts +18 -0
  200. package/package.json +64 -0
  201. package/src/checkout/context/CheckoutProvider.tsx +269 -0
  202. package/src/checkout/context/CustomerFormContext.ts +14 -0
  203. package/src/checkout/context/ExchangeRateContext.ts +21 -0
  204. package/src/checkout/context/PaymentMethodContext.ts +36 -0
  205. package/src/checkout/context/SessionContext.ts +49 -0
  206. package/src/checkout/context/SubmitContext.ts +14 -0
  207. package/src/checkout/context/index.ts +6 -0
  208. package/src/checkout/core/billingInterval.ts +62 -0
  209. package/src/checkout/core/crossSell.ts +52 -0
  210. package/src/checkout/core/customerForm.ts +122 -0
  211. package/src/checkout/core/exchangeRate.ts +38 -0
  212. package/src/checkout/core/index.ts +60 -0
  213. package/src/checkout/core/lineItems.ts +106 -0
  214. package/src/checkout/core/paymentMethod.ts +113 -0
  215. package/src/checkout/core/pricing.ts +347 -0
  216. package/src/checkout/core/promotion.ts +59 -0
  217. package/src/checkout/core/session.ts +62 -0
  218. package/src/checkout/core/submit.ts +109 -0
  219. package/src/checkout/hooks/index.ts +41 -0
  220. package/src/checkout/hooks/useBillingInterval.ts +71 -0
  221. package/src/checkout/hooks/useCheckout.ts +267 -0
  222. package/src/checkout/hooks/useCheckoutSession.ts +217 -0
  223. package/src/checkout/hooks/useCheckoutStatus.ts +31 -0
  224. package/src/checkout/hooks/useCrossSell.ts +80 -0
  225. package/src/checkout/hooks/useCustomerForm.ts +156 -0
  226. package/src/checkout/hooks/useCustomerFormFeature.ts +7 -0
  227. package/src/checkout/hooks/useExchangeRate.ts +28 -0
  228. package/src/checkout/hooks/useLineItems.ts +191 -0
  229. package/src/checkout/hooks/usePaymentMethod.ts +165 -0
  230. package/src/checkout/hooks/usePaymentMethodFeature.ts +8 -0
  231. package/src/checkout/hooks/usePricing.ts +274 -0
  232. package/src/checkout/hooks/usePricingFeature.ts +73 -0
  233. package/src/checkout/hooks/useProduct.ts +32 -0
  234. package/src/checkout/hooks/usePromotion.ts +67 -0
  235. package/src/checkout/hooks/useSlippage.ts +39 -0
  236. package/src/checkout/hooks/useSubmit.ts +684 -0
  237. package/src/checkout/hooks/useSubmitFeature.ts +7 -0
  238. package/src/checkout/hooks/useUpsell.ts +35 -0
  239. package/src/checkout/index.ts +65 -0
  240. package/src/checkout/types.ts +292 -0
  241. package/src/index.ts +64 -0
  242. package/src/shared/api.ts +118 -0
  243. package/src/shared/format.ts +318 -0
  244. package/src/shared/polling.ts +49 -0
  245. package/src/shared/types.ts +13 -0
  246. package/src/shared/validation.ts +254 -0
  247. package/src/types/checkout-augmented.ts +77 -0
  248. package/src/types/external.d.ts +18 -0
  249. package/tools/jest.js +1 -0
  250. package/tsconfig.json +18 -0
@@ -0,0 +1,684 @@
1
+ import { useState, useRef, useEffect } from 'react';
2
+ import { useMemoizedFn } from 'ahooks';
3
+ import { WsClient } from '@arcblock/ws';
4
+
5
+ import type { TLineItemExpanded } from '@blocklet/payment-types';
6
+
7
+ import api, { API } from '../../shared/api';
8
+ import { waitForCheckoutComplete, generateIdempotencyKey } from '../../shared/polling';
9
+ import type { CheckoutSessionRuntime } from '../../types/checkout-augmented';
10
+ import { getErrorMessage, getAxiosErrorDetails } from '../../types/checkout-augmented';
11
+ import type { SessionData } from './useCheckoutSession';
12
+ import type { SubmitStatus, SubmitContext, CheckoutResult, CheckoutFormData } from '../types';
13
+ import {
14
+ RELAY_SOCKET_PREFIX,
15
+ getAppId,
16
+ getRelayChannel,
17
+ getRelayProtocol,
18
+ getSocketHost,
19
+ getSessionFingerprint,
20
+ buildSubmitPayload,
21
+ isQuoteError,
22
+ abortStripePayment,
23
+ submitCheckout,
24
+ confirmFastCheckout,
25
+ } from '../core/submit';
26
+
27
+ export interface VendorStatus {
28
+ success: boolean;
29
+ status: 'delivered' | 'pending' | 'failed';
30
+ progress: number;
31
+ message: string;
32
+ appUrl?: string;
33
+ title?: string;
34
+ name?: string;
35
+ vendorType: string;
36
+ }
37
+
38
+ export interface VendorOrderStatus {
39
+ payment_status: string;
40
+ session_status: string;
41
+ vendors: VendorStatus[];
42
+ error: string | null;
43
+ isAllCompleted: boolean;
44
+ hasFailed: boolean;
45
+ }
46
+
47
+ export interface UseSubmitReturn {
48
+ status: SubmitStatus;
49
+ context: SubmitContext;
50
+ execute: () => Promise<void>;
51
+ confirm: () => Promise<void>;
52
+ cancel: () => void;
53
+ result: CheckoutResult | null;
54
+ retry: () => Promise<void>;
55
+ reset: () => void;
56
+ stripeConfirm: () => Promise<void>;
57
+ stripeCancel: () => Promise<void>;
58
+ vendorStatus: VendorOrderStatus | null;
59
+ /** Whether the checkout config is locked (user clicked "Connect and Pay", pending login/submit) */
60
+ locked: boolean;
61
+ /** Lock the checkout config — prevents quantity/currency/method changes */
62
+ lock: () => void;
63
+ }
64
+
65
+ export function useSubmit(
66
+ sessionData: SessionData | null,
67
+ sessionId: string,
68
+ currencyId: string | null,
69
+ isStripe: boolean,
70
+ isCredit: boolean,
71
+ isDonation: boolean,
72
+ formValues: CheckoutFormData,
73
+ validateForm: () => Promise<boolean>,
74
+ refreshSession: (force?: boolean) => Promise<void>,
75
+ updateSessionData?: (data: SessionData) => void
76
+ ): UseSubmitReturn {
77
+ const session = sessionData?.checkoutSession;
78
+
79
+ const [status, setStatus] = useState<SubmitStatus>('idle');
80
+ const [context, setContext] = useState<SubmitContext>(null);
81
+ const [result, setResult] = useState<CheckoutResult | null>(null);
82
+ const [locked, setLocked] = useState(false);
83
+
84
+ const lock = useMemoizedFn(() => setLocked(true));
85
+ const unlock = useMemoizedFn(() => setLocked(false));
86
+
87
+ const mountedRef = useRef(true);
88
+ const pollingAbortRef = useRef(false);
89
+ const socketRef = useRef<WsClient | null>(null);
90
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
91
+ const subscriptionRef = useRef<{
92
+ channel: string;
93
+ on: (event: string, fn: (...args: any[]) => void) => void;
94
+ off?: (event: string, fn: (...args: any[]) => void) => void;
95
+ } | null>(null);
96
+ const lastPayloadRef = useRef<Record<string, unknown> | null>(null);
97
+ const [vendorStatus, setVendorStatus] = useState<VendorOrderStatus | null>(null);
98
+ const vendorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
99
+ const quoteRetryCountRef = useRef(0);
100
+ const MAX_QUOTE_RETRIES = 2;
101
+
102
+ // Stable idempotency key: reuse same Quote on retry (intent: "retry with same Quote")
103
+ // Only regenerate when payment context truly changes (currency, line items)
104
+ const idempotencyKeyRef = useRef<string>('');
105
+ const sessionFingerprintRef = useRef<string>('');
106
+
107
+ // Auto-detect already-completed session on load/refresh (e.g. page refresh after payment)
108
+ const completedDetectedRef = useRef(false);
109
+ useEffect(() => {
110
+ if (completedDetectedRef.current) return;
111
+ if (!session || !sessionData) return;
112
+
113
+ const isSessionComplete =
114
+ (session as any).status === 'complete' &&
115
+ ['paid', 'no_payment_required'].includes((session as any).payment_status);
116
+
117
+ if (isSessionComplete && status === 'idle') {
118
+ completedDetectedRef.current = true;
119
+ setResult(sessionData as CheckoutResult);
120
+ setStatus('completed');
121
+ }
122
+ }, [session, sessionData]); // eslint-disable-line react-hooks/exhaustive-deps
123
+
124
+ // WebSocket: connect and listen for checkout.session.completed
125
+ useEffect(() => {
126
+ mountedRef.current = true;
127
+
128
+ if (!sessionId || !getAppId()) return undefined;
129
+
130
+ const socket = new WsClient(`${getRelayProtocol()}//${getSocketHost()}${RELAY_SOCKET_PREFIX}`, {
131
+ longpollerTimeout: 5000,
132
+ heartbeatIntervalMs: 30 * 1000,
133
+ });
134
+ socket.connect();
135
+ socketRef.current = socket;
136
+
137
+ const sub = socket.subscribe(getRelayChannel('events'));
138
+ sub.channel = 'events';
139
+ subscriptionRef.current = sub;
140
+
141
+ return () => {
142
+ mountedRef.current = false;
143
+ pollingAbortRef.current = true;
144
+
145
+ if (subscriptionRef.current) {
146
+ socket.unsubscribe(getRelayChannel(subscriptionRef.current.channel));
147
+ subscriptionRef.current = null;
148
+ }
149
+ socket.disconnect();
150
+ socketRef.current = null;
151
+ };
152
+ }, [sessionId]);
153
+
154
+ // Listen for checkout completion via WebSocket
155
+ const handleWsComplete = useMemoizedFn(async ({ response }: { response: { id?: string } }) => {
156
+ if (response.id === sessionId && status !== 'completed') {
157
+ pollingAbortRef.current = true;
158
+ await handleCompletion();
159
+ }
160
+ });
161
+
162
+ useEffect(() => {
163
+ const sub = subscriptionRef.current;
164
+ if (sub) {
165
+ sub.on('checkout.session.completed', handleWsComplete);
166
+ }
167
+ return () => {
168
+ if (sub) {
169
+ try {
170
+ sub.off?.('checkout.session.completed', handleWsComplete);
171
+ } catch {
172
+ // Ignore
173
+ }
174
+ }
175
+ };
176
+ }, [subscriptionRef.current, status]); // eslint-disable-line react-hooks/exhaustive-deps
177
+
178
+ // Common completion handler
179
+ const handleCompletion = useMemoizedFn(async () => {
180
+ try {
181
+ const completionResult = await waitForCheckoutComplete(sessionId);
182
+ if (!mountedRef.current) return;
183
+ setResult(completionResult);
184
+ // Update session context so downstream consumers (e.g. SuccessView) have fresh data
185
+ updateSessionData?.(completionResult as SessionData);
186
+ setStatus('completed');
187
+ setContext(null);
188
+ } catch (err: unknown) {
189
+ if (!mountedRef.current) return;
190
+ setStatus('failed');
191
+ setContext({ type: 'error', message: getErrorMessage(err) || 'Payment verification failed' });
192
+ }
193
+ });
194
+
195
+ // Vendor order polling after completion
196
+ useEffect(() => {
197
+ if (status !== 'completed' || !sessionId) return undefined;
198
+
199
+ const hasVendors = (session?.line_items as TLineItemExpanded[] | undefined)?.some(
200
+ (item) => !!item?.price?.product?.vendor_config?.length
201
+ );
202
+ if (!hasVendors) return undefined;
203
+
204
+ const startTime = Date.now();
205
+
206
+ const fetchVendorStatus = async () => {
207
+ try {
208
+ const { data } = await api.get(API.VENDOR_ORDER_STATUS(sessionId));
209
+ if (!mountedRef.current) return;
210
+
211
+ const needCheckError = Date.now() - startTime > 6000;
212
+ const allCompleted = data?.vendors?.every((v: VendorStatus) => v.progress >= 100);
213
+ const hasFailed = data?.vendors?.some(
214
+ (v: VendorStatus & { error?: string; error_message?: string }) =>
215
+ v.status === 'failed' || (needCheckError && !!v.error && !!v.error_message)
216
+ );
217
+
218
+ setVendorStatus({
219
+ ...data,
220
+ isAllCompleted: !hasFailed && allCompleted,
221
+ hasFailed,
222
+ });
223
+
224
+ if (hasFailed || allCompleted) return;
225
+
226
+ vendorTimerRef.current = setTimeout(fetchVendorStatus, 5000);
227
+ } catch {
228
+ // Ignore polling errors
229
+ }
230
+ };
231
+
232
+ fetchVendorStatus();
233
+
234
+ return () => {
235
+ if (vendorTimerRef.current) clearTimeout(vendorTimerRef.current);
236
+ };
237
+ }, [status, sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
238
+
239
+ // Auto-refresh session for quote errors, with auto-retry for stale-lock errors (like V1)
240
+ const handleQuoteError = useMemoizedFn(async (errorCode: string, errorData?: Record<string, unknown>) => {
241
+ try {
242
+ await refreshSession(true);
243
+ if (!mountedRef.current) return;
244
+ } catch {
245
+ // Ignore refresh error
246
+ }
247
+
248
+ // QUOTE_UPDATED means the price actually changed — user should review
249
+ if (errorCode === 'QUOTE_UPDATED' && errorData?.checkoutSession) {
250
+ quoteRetryCountRef.current = 0;
251
+ setStatus('failed');
252
+ setContext({ type: 'error', message: 'Price updated, please resubmit', code: errorCode });
253
+ return;
254
+ }
255
+
256
+ // Stale-lock errors: auto-retry silently (up to MAX_QUOTE_RETRIES)
257
+ const canAutoRetry = ['QUOTE_LOCK_EXPIRED', 'QUOTE_EXPIRED_OR_USED', 'QUOTE_NOT_FOUND', 'QUOTE_REQUIRED'].includes(
258
+ errorCode
259
+ );
260
+ if (canAutoRetry && quoteRetryCountRef.current < MAX_QUOTE_RETRIES) {
261
+ quoteRetryCountRef.current += 1;
262
+ setStatus('idle');
263
+ setContext(null);
264
+ await execute(true);
265
+ return;
266
+ }
267
+
268
+ // Exhausted retries or unknown quote error
269
+ quoteRetryCountRef.current = 0;
270
+ setStatus('failed');
271
+ setContext({
272
+ type: 'error',
273
+ message: 'Price updated, please resubmit',
274
+ code: errorCode,
275
+ });
276
+ });
277
+
278
+ // Route based on submit response
279
+ const routeSubmitResponse = useMemoizedFn(async (data: Record<string, unknown>) => {
280
+ // Stripe payment — check BEFORE noPaymentRequired to match V1 behavior
281
+ // V1 routes by payment method type first, regardless of noPaymentRequired flag
282
+ if (isStripe) {
283
+ const stripeCtx = data.stripeContext as
284
+ | { status?: string; client_secret?: string; intent_type?: string }
285
+ | undefined;
286
+ if (stripeCtx?.status === 'succeeded') {
287
+ await handleCompletion();
288
+ return;
289
+ }
290
+
291
+ const piCtx = data.paymentIntent as { client_secret?: string } | undefined;
292
+ const clientSecret = stripeCtx?.client_secret || piCtx?.client_secret;
293
+ if (clientSecret) {
294
+ const intentType = (stripeCtx?.intent_type || 'payment_intent') as 'payment_intent' | 'setup_intent';
295
+ setStatus('waiting_stripe');
296
+ setContext({ type: 'stripe', clientSecret, intentType });
297
+ return;
298
+ }
299
+
300
+ // Stripe with no client_secret (e.g., free trial with no setup_intent needed)
301
+ if (data.noPaymentRequired) {
302
+ try {
303
+ const confirmResult = await confirmFastCheckout(sessionId);
304
+ const confirmSession = confirmResult.checkoutSession as Record<string, unknown> | undefined;
305
+ if (confirmResult.fastPaid || confirmSession?.status === 'complete') {
306
+ await handleCompletion();
307
+ return;
308
+ }
309
+ } catch {
310
+ // fall through
311
+ }
312
+ }
313
+
314
+ await handleCompletion();
315
+ return;
316
+ }
317
+
318
+ // No payment required (free trial, etc.) — crypto/DID path
319
+ if (data.noPaymentRequired) {
320
+ try {
321
+ const confirmResult = await confirmFastCheckout(sessionId);
322
+ const confirmSession = confirmResult.checkoutSession as Record<string, unknown> | undefined;
323
+ if (confirmResult.fastPaid || confirmSession?.status === 'complete') {
324
+ await handleCompletion();
325
+ } else {
326
+ setStatus('waiting_did');
327
+ setContext({
328
+ type: 'did_connect',
329
+ action: session?.mode || 'payment',
330
+ checkpointId: sessionId,
331
+ extraParams: {
332
+ checkoutSessionId: sessionId,
333
+ sessionUserDid: (session as CheckoutSessionRuntime | undefined)?.user?.did,
334
+ },
335
+ });
336
+ }
337
+ } catch {
338
+ if (!mountedRef.current) return;
339
+ setStatus('waiting_did');
340
+ setContext({
341
+ type: 'did_connect',
342
+ action: session?.mode || 'payment',
343
+ checkpointId: sessionId,
344
+ extraParams: {
345
+ checkoutSessionId: sessionId,
346
+ sessionUserDid: (session as CheckoutSessionRuntime | undefined)?.user?.did,
347
+ },
348
+ });
349
+ }
350
+ return;
351
+ }
352
+
353
+ // Fast pay routing — match V1: credit first, then balance/delegation
354
+ const fastPayInfo = data.fastPayInfo as
355
+ | { type?: string; amount?: string; payer?: string; token?: { balance?: string } }
356
+ | undefined;
357
+ const balance = data.balance as { sufficient?: boolean } | undefined;
358
+ const delegation = data.delegation as { sufficient?: boolean } | undefined;
359
+
360
+ // Credit payment — only when currency type is 'credit' (matches V1: paymentCurrency?.type === 'credit')
361
+ if (isCredit) {
362
+ if (data.creditSufficient === true && fastPayInfo) {
363
+ setStatus('confirming_fast_pay');
364
+ setContext({
365
+ type: 'fast_pay',
366
+ payType: 'credit',
367
+ amount: fastPayInfo.amount || '0',
368
+ payer: fastPayInfo.payer || '',
369
+ });
370
+ return;
371
+ }
372
+ // Credit currency but insufficient — show dialog
373
+ setStatus('credit_insufficient');
374
+ setContext({
375
+ type: 'credit_insufficient',
376
+ available: '0',
377
+ required: '0',
378
+ });
379
+ return;
380
+ }
381
+
382
+ // Balance/delegation fast pay (non-credit, non-donation)
383
+ if ((balance?.sufficient || delegation?.sufficient) && !isDonation && fastPayInfo) {
384
+ setStatus('confirming_fast_pay');
385
+ setContext({
386
+ type: 'fast_pay',
387
+ payType: (fastPayInfo.type as 'balance' | 'delegation') || 'balance',
388
+ amount: fastPayInfo.amount || '',
389
+ payer: fastPayInfo.payer || '',
390
+ });
391
+ return;
392
+ }
393
+
394
+ // Need DID connect (crypto payment)
395
+ setStatus('waiting_did');
396
+ setContext({
397
+ type: 'did_connect',
398
+ action: session?.mode || 'payment',
399
+ checkpointId: sessionId,
400
+ extraParams: {
401
+ checkoutSessionId: sessionId,
402
+ sessionUserDid: (session as CheckoutSessionRuntime | undefined)?.user?.did,
403
+ },
404
+ });
405
+ });
406
+
407
+ // Handle submit error
408
+ const handleSubmitError = useMemoizedFn(async (err: unknown) => {
409
+ const { code: errorCode, data: errorData, message: errMessage } = getAxiosErrorDetails(err);
410
+
411
+ // Quote-related errors
412
+ if (errorCode && isQuoteError(errorCode)) {
413
+ await handleQuoteError(errorCode, errorData);
414
+ return;
415
+ }
416
+
417
+ // RATE_BELOW_SLIPPAGE_LIMIT: fallback — stable idempotency key should prevent this,
418
+ // but handle gracefully if it occurs (e.g., after session refresh with new context)
419
+ if (errorCode === 'RATE_BELOW_SLIPPAGE_LIMIT') {
420
+ try {
421
+ await refreshSession(true);
422
+ } catch {
423
+ // Ignore
424
+ }
425
+ if (!mountedRef.current) return;
426
+ // Invalidate stale key so next submit creates a fresh quote
427
+ idempotencyKeyRef.current = '';
428
+ sessionFingerprintRef.current = '';
429
+ setStatus('failed');
430
+ setContext({
431
+ type: 'error',
432
+ message: (errorData?.error as string) || 'Exchange rate below acceptable limit, please retry',
433
+ code: errorCode,
434
+ });
435
+ return;
436
+ }
437
+
438
+ // RATE_UNAVAILABLE
439
+ if (errorCode === 'RATE_UNAVAILABLE') {
440
+ try {
441
+ await refreshSession(true);
442
+ } catch {
443
+ // Ignore
444
+ }
445
+ if (!mountedRef.current) return;
446
+ setStatus('failed');
447
+ setContext({
448
+ type: 'error',
449
+ message: (errorData?.rateError as string) || 'Exchange rate unavailable',
450
+ code: errorCode,
451
+ });
452
+ return;
453
+ }
454
+
455
+ // PRICE_CHANGED — match V1: show confirmation dialog with change_percent (snake_case from backend)
456
+ if (errorCode === 'PRICE_CHANGED') {
457
+ setStatus('confirming_price');
458
+ setContext({
459
+ type: 'price_change',
460
+ changePercent: (errorData?.change_percent as number) || 0,
461
+ });
462
+ return;
463
+ }
464
+
465
+ // PRICE_UNAVAILABLE / PRICE_UNSTABLE
466
+ if (errorCode === 'PRICE_UNAVAILABLE' || errorCode === 'PRICE_UNSTABLE') {
467
+ try {
468
+ await refreshSession(true);
469
+ } catch {
470
+ // Ignore
471
+ }
472
+ if (!mountedRef.current) return;
473
+ setStatus('failed');
474
+ setContext({
475
+ type: 'error',
476
+ message: (errorData?.error as string) || 'Price unavailable, please retry',
477
+ code: errorCode,
478
+ });
479
+ return;
480
+ }
481
+
482
+ // UNIFIED_APP_REQUIRED / CUSTOMER_LIMITED
483
+ if (errorCode === 'UNIFIED_APP_REQUIRED' || errorCode === 'CUSTOMER_LIMITED') {
484
+ setStatus('failed');
485
+ setContext({
486
+ type: 'error',
487
+ message: (errorData?.error as string) || errMessage || 'Cannot complete payment',
488
+ code: errorCode,
489
+ });
490
+ return;
491
+ }
492
+
493
+ // Generic error
494
+ const message = (errorData?.error as string) || errMessage || 'Payment failed';
495
+ setStatus('failed');
496
+ setContext({ type: 'error', message, code: errorCode });
497
+ });
498
+
499
+ // Main execute
500
+ const execute = useMemoizedFn(async (force = false) => {
501
+ if (!force && status !== 'idle') return;
502
+
503
+ // Reset quote retry counter on fresh user-initiated submit
504
+ if (!force) quoteRetryCountRef.current = 0;
505
+
506
+ if (!(await validateForm())) return;
507
+
508
+ // Vendor account validation
509
+ const user = (session as CheckoutSessionRuntime | undefined)?.user;
510
+ if (!user?.sourceAppPid) {
511
+ const hasVendorConfig = (session?.line_items as TLineItemExpanded[] | undefined)?.some(
512
+ (item) => !!item?.price?.product?.vendor_config?.length
513
+ );
514
+ if (hasVendorConfig) {
515
+ setStatus('failed');
516
+ setContext({ type: 'error', message: 'Vendor account required', code: 'VENDOR_ACCOUNT_REQUIRED' });
517
+ return;
518
+ }
519
+ }
520
+
521
+ setStatus('submitting');
522
+ setContext(null);
523
+ setResult(null);
524
+ pollingAbortRef.current = false;
525
+
526
+ // Stable idempotency key: only regenerate when payment context changes
527
+ // Same context retry → reuse Quote (intent: "Failed Payments don't invalidate Quote")
528
+ const fingerprint = getSessionFingerprint(session, currencyId);
529
+ if (fingerprint !== sessionFingerprintRef.current || !idempotencyKeyRef.current) {
530
+ sessionFingerprintRef.current = fingerprint;
531
+ idempotencyKeyRef.current = generateIdempotencyKey(sessionId, currencyId || '');
532
+ }
533
+
534
+ const payload = buildSubmitPayload(sessionId, currencyId, formValues, session, false, idempotencyKeyRef.current);
535
+ lastPayloadRef.current = payload;
536
+
537
+ try {
538
+ const data = await submitCheckout(sessionId, isDonation, payload);
539
+ if (!mountedRef.current) return;
540
+ await routeSubmitResponse(data);
541
+ } catch (err: unknown) {
542
+ if (!mountedRef.current) return;
543
+ await handleSubmitError(err);
544
+ }
545
+ });
546
+
547
+ // Confirm: handles both fast_pay and price_change
548
+ const confirm = useMemoizedFn(async () => {
549
+ if (status === 'confirming_fast_pay') {
550
+ setStatus('submitting');
551
+ try {
552
+ const data = await confirmFastCheckout(sessionId);
553
+
554
+ if (!mountedRef.current) return;
555
+
556
+ if (data.fastPaid) {
557
+ await handleCompletion();
558
+ } else {
559
+ setStatus('waiting_did');
560
+ setContext({
561
+ type: 'did_connect',
562
+ action: session?.mode || 'payment',
563
+ checkpointId: sessionId,
564
+ extraParams: {
565
+ checkoutSessionId: sessionId,
566
+ sessionUserDid: (session as CheckoutSessionRuntime | undefined)?.user?.did,
567
+ },
568
+ });
569
+ }
570
+ } catch (err: unknown) {
571
+ if (!mountedRef.current) return;
572
+
573
+ const { code: errorCode, data: errorData, message: errMsg } = getAxiosErrorDetails(err);
574
+
575
+ if (errorCode && isQuoteError(errorCode)) {
576
+ await handleQuoteError(errorCode, errorData);
577
+ return;
578
+ }
579
+
580
+ if (errorCode === 'RATE_UNAVAILABLE') {
581
+ try {
582
+ await refreshSession(true);
583
+ } catch {
584
+ /* Ignore */
585
+ }
586
+ if (!mountedRef.current) return;
587
+ setStatus('failed');
588
+ setContext({
589
+ type: 'error',
590
+ message: (errorData?.rateError as string) || 'Exchange rate unavailable',
591
+ code: errorCode,
592
+ });
593
+ return;
594
+ }
595
+
596
+ setStatus('failed');
597
+ setContext({
598
+ type: 'error',
599
+ message: (errorData?.error as string) || errMsg || 'Fast pay confirmation failed',
600
+ code: errorCode,
601
+ });
602
+ }
603
+ return;
604
+ }
605
+
606
+ if (status === 'confirming_price') {
607
+ setStatus('submitting');
608
+ setContext(null);
609
+ pollingAbortRef.current = false;
610
+
611
+ // Price confirmed resubmit: reuse same idempotency key (same Quote context)
612
+ const payload = buildSubmitPayload(sessionId, currencyId, formValues, session, true, idempotencyKeyRef.current);
613
+ lastPayloadRef.current = payload;
614
+
615
+ try {
616
+ const data = await submitCheckout(sessionId, isDonation, payload);
617
+ if (!mountedRef.current) return;
618
+ await routeSubmitResponse(data);
619
+ } catch (err: unknown) {
620
+ if (!mountedRef.current) return;
621
+ await handleSubmitError(err);
622
+ }
623
+ }
624
+ });
625
+
626
+ // Cancel
627
+ const cancel = useMemoizedFn(() => {
628
+ if (status === 'confirming_price' || status === 'confirming_fast_pay' || status === 'credit_insufficient') {
629
+ setStatus('idle');
630
+ setContext(null);
631
+ unlock();
632
+ }
633
+ });
634
+
635
+ // Retry
636
+ const retry = useMemoizedFn(async () => {
637
+ if (status !== 'failed') return;
638
+ setStatus('idle');
639
+ setContext(null);
640
+ setResult(null);
641
+ await execute(true);
642
+ });
643
+
644
+ // Reset
645
+ const reset = useMemoizedFn(() => {
646
+ pollingAbortRef.current = true;
647
+ setStatus('idle');
648
+ setContext(null);
649
+ setResult(null);
650
+ unlock();
651
+ });
652
+
653
+ // Stripe confirm
654
+ const stripeConfirm = useMemoizedFn(async () => {
655
+ if (status !== 'waiting_stripe') return;
656
+ setStatus('submitting');
657
+ setContext(null);
658
+ await handleCompletion();
659
+ });
660
+
661
+ // Stripe cancel
662
+ const stripeCancel = useMemoizedFn(async () => {
663
+ if (status !== 'waiting_stripe') return;
664
+ await abortStripePayment(sessionId);
665
+ setStatus('idle');
666
+ setContext(null);
667
+ });
668
+
669
+ return {
670
+ status,
671
+ context,
672
+ execute,
673
+ confirm,
674
+ cancel,
675
+ result,
676
+ retry,
677
+ reset,
678
+ stripeConfirm,
679
+ stripeCancel,
680
+ vendorStatus,
681
+ locked,
682
+ lock,
683
+ };
684
+ }
@@ -0,0 +1,7 @@
1
+ import { useSubmitContext } from '../context/SubmitContext';
2
+ import type { UseSubmitReturn } from './useSubmit';
3
+
4
+ // Context-aware version: reads shared submit state from CheckoutProvider
5
+ export function useSubmitFeature(): UseSubmitReturn {
6
+ return useSubmitContext();
7
+ }