@blocklet/payment-react 1.24.4 → 1.25.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 (98) hide show
  1. package/es/components/auto-topup/modal.d.ts +2 -0
  2. package/es/components/auto-topup/modal.js +48 -6
  3. package/es/components/auto-topup/product-card.d.ts +16 -1
  4. package/es/components/auto-topup/product-card.js +97 -15
  5. package/es/components/dynamic-pricing-unavailable.d.ts +9 -0
  6. package/es/components/dynamic-pricing-unavailable.js +58 -0
  7. package/es/components/loading-amount.d.ts +17 -0
  8. package/es/components/loading-amount.js +46 -0
  9. package/es/components/price-change-confirm.d.ts +18 -0
  10. package/es/components/price-change-confirm.js +107 -0
  11. package/es/components/quote-details-panel.d.ts +21 -0
  12. package/es/components/quote-details-panel.js +170 -0
  13. package/es/components/quote-lock-banner.d.ts +7 -0
  14. package/es/components/quote-lock-banner.js +79 -0
  15. package/es/components/slippage-config.d.ts +20 -0
  16. package/es/components/slippage-config.js +261 -0
  17. package/es/history/invoice/list.js +125 -15
  18. package/es/hooks/dynamic-pricing.d.ts +102 -0
  19. package/es/hooks/dynamic-pricing.js +393 -0
  20. package/es/index.d.ts +6 -1
  21. package/es/index.js +9 -1
  22. package/es/libs/util.d.ts +42 -5
  23. package/es/libs/util.js +345 -57
  24. package/es/locales/en.js +114 -3
  25. package/es/locales/zh.js +114 -3
  26. package/es/payment/form/index.d.ts +4 -1
  27. package/es/payment/form/index.js +454 -22
  28. package/es/payment/index.d.ts +1 -1
  29. package/es/payment/index.js +279 -16
  30. package/es/payment/product-item.d.ts +26 -1
  31. package/es/payment/product-item.js +330 -51
  32. package/es/payment/summary-section/promotion-section.d.ts +32 -0
  33. package/es/payment/summary-section/promotion-section.js +143 -0
  34. package/es/payment/summary-section/total-section.d.ts +39 -0
  35. package/es/payment/summary-section/total-section.js +83 -0
  36. package/es/payment/summary.d.ts +17 -2
  37. package/es/payment/summary.js +300 -253
  38. package/es/types/index.d.ts +11 -0
  39. package/lib/components/auto-topup/modal.d.ts +2 -0
  40. package/lib/components/auto-topup/modal.js +54 -6
  41. package/lib/components/auto-topup/product-card.d.ts +16 -1
  42. package/lib/components/auto-topup/product-card.js +75 -7
  43. package/lib/components/dynamic-pricing-unavailable.d.ts +9 -0
  44. package/lib/components/dynamic-pricing-unavailable.js +81 -0
  45. package/lib/components/loading-amount.d.ts +17 -0
  46. package/lib/components/loading-amount.js +53 -0
  47. package/lib/components/price-change-confirm.d.ts +18 -0
  48. package/lib/components/price-change-confirm.js +157 -0
  49. package/lib/components/quote-details-panel.d.ts +21 -0
  50. package/lib/components/quote-details-panel.js +226 -0
  51. package/lib/components/quote-lock-banner.d.ts +7 -0
  52. package/lib/components/quote-lock-banner.js +93 -0
  53. package/lib/components/slippage-config.d.ts +20 -0
  54. package/lib/components/slippage-config.js +316 -0
  55. package/lib/history/invoice/list.js +167 -27
  56. package/lib/hooks/dynamic-pricing.d.ts +102 -0
  57. package/lib/hooks/dynamic-pricing.js +390 -0
  58. package/lib/index.d.ts +6 -1
  59. package/lib/index.js +32 -0
  60. package/lib/libs/util.d.ts +42 -5
  61. package/lib/libs/util.js +367 -49
  62. package/lib/locales/en.js +114 -3
  63. package/lib/locales/zh.js +114 -3
  64. package/lib/payment/form/index.d.ts +4 -1
  65. package/lib/payment/form/index.js +476 -20
  66. package/lib/payment/index.d.ts +1 -1
  67. package/lib/payment/index.js +308 -14
  68. package/lib/payment/product-item.d.ts +26 -1
  69. package/lib/payment/product-item.js +270 -35
  70. package/lib/payment/summary-section/promotion-section.d.ts +32 -0
  71. package/lib/payment/summary-section/promotion-section.js +133 -0
  72. package/lib/payment/summary-section/total-section.d.ts +39 -0
  73. package/lib/payment/summary-section/total-section.js +117 -0
  74. package/lib/payment/summary.d.ts +17 -2
  75. package/lib/payment/summary.js +205 -127
  76. package/lib/types/index.d.ts +11 -0
  77. package/package.json +3 -3
  78. package/src/components/auto-topup/modal.tsx +59 -6
  79. package/src/components/auto-topup/product-card.tsx +118 -11
  80. package/src/components/dynamic-pricing-unavailable.tsx +69 -0
  81. package/src/components/loading-amount.tsx +66 -0
  82. package/src/components/price-change-confirm.tsx +136 -0
  83. package/src/components/quote-details-panel.tsx +218 -0
  84. package/src/components/quote-lock-banner.tsx +99 -0
  85. package/src/components/slippage-config.tsx +336 -0
  86. package/src/history/invoice/list.tsx +143 -9
  87. package/src/hooks/dynamic-pricing.ts +617 -0
  88. package/src/index.ts +9 -0
  89. package/src/libs/util.ts +473 -58
  90. package/src/locales/en.tsx +117 -0
  91. package/src/locales/zh.tsx +111 -0
  92. package/src/payment/form/index.tsx +561 -19
  93. package/src/payment/index.tsx +349 -10
  94. package/src/payment/product-item.tsx +451 -37
  95. package/src/payment/summary-section/promotion-section.tsx +172 -0
  96. package/src/payment/summary-section/total-section.tsx +141 -0
  97. package/src/payment/summary.tsx +334 -192
  98. package/src/types/index.ts +15 -0
@@ -7,6 +7,7 @@ import { FlagEmoji } from 'react-international-phone';
7
7
  import Toast from '@arcblock/ux/lib/Toast';
8
8
  import type {
9
9
  TCheckoutSession,
10
+ TCheckoutSessionExpanded,
10
11
  TCustomer,
11
12
  TLineItemExpanded,
12
13
  TPaymentIntent,
@@ -20,13 +21,14 @@ import { Controller, useFormContext, useWatch } from 'react-hook-form';
20
21
  import { joinURL } from 'ufo';
21
22
  import { dispatch } from 'use-bus';
22
23
  import isEmail from 'validator/es/lib/isEmail';
23
- import { fromUnitToToken } from '@ocap/util';
24
+ import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util';
24
25
  import DID from '@arcblock/ux/lib/DID';
25
26
  import { PayFailedEvent } from '@arcblock/ux/lib/withTracker/action/pay';
26
27
 
27
28
  import isEmpty from 'lodash/isEmpty';
28
29
  import { HelpOutline, OpenInNew } from '@mui/icons-material';
29
30
  import { ReactGA } from '@arcblock/ux/lib/withTracker';
31
+ import trim from 'lodash/trim';
30
32
  import FormInput from '../../components/input';
31
33
  import FormLabel from '../../components/label';
32
34
  import { usePaymentContext } from '../../contexts/payment';
@@ -41,6 +43,12 @@ import {
41
43
  getStatementDescriptor,
42
44
  getTokenBalanceLink,
43
45
  isCrossOrigin,
46
+ getCheckoutAmount,
47
+ formatNumber,
48
+ formatUsdAmount,
49
+ getUsdAmountFromBaseAmount,
50
+ getUsdAmountFromTokenUnits,
51
+ formatAmount,
44
52
  } from '../../libs/util';
45
53
  import type { CheckoutCallbacks, CheckoutContext } from '../../types';
46
54
  import AddressForm from './address';
@@ -53,8 +61,14 @@ import LoadingButton from '../../components/loading-button';
53
61
  import OverdueInvoicePayment from '../../components/over-due-invoice-payment';
54
62
  import { saveCurrencyPreference } from '../../libs/currency';
55
63
  import ConfirmDialog from '../../components/confirm';
64
+ import PriceChangeConfirm from '../../components/price-change-confirm';
56
65
  import { getFieldValidation, validatePostalCode } from '../../libs/validator';
57
66
 
67
+ // Generate unique idempotency key for submit (Final Freeze Architecture)
68
+ const generateIdempotencyKey = (sessionId: string, currencyId: string): string => {
69
+ return `${sessionId}-${currencyId}-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`;
70
+ };
71
+
58
72
  export const waitForCheckoutComplete = async (sessionId: string) => {
59
73
  let result: CheckoutContext;
60
74
 
@@ -91,6 +105,10 @@ export const hasDidWallet = (user: any) => {
91
105
 
92
106
  type PageData = CheckoutContext &
93
107
  CheckoutCallbacks & {
108
+ onQuoteUpdated?: (
109
+ data: Pick<CheckoutContext, 'checkoutSession' | 'quotes' | 'rateUnavailable' | 'rateError'>
110
+ ) => void;
111
+ onPaymentIntentUpdate?: (intent: TPaymentIntent | null) => void;
94
112
  onlyShowBtn?: boolean;
95
113
  isDonation?: boolean;
96
114
  };
@@ -178,6 +196,15 @@ const setUserFormValues = (
178
196
  return updatedFields;
179
197
  };
180
198
 
199
+ // ✅ No longer need to collect quotes from frontend - backend auto-finds them
200
+ // const collectQuotes = (lineItems: TLineItemExpanded[]) =>
201
+ // lineItems
202
+ // ?.filter((item) => (item.price as any)?.pricing_type === 'dynamic' && (item as any)?.quote_id)
203
+ // .map((item) => ({
204
+ // price_id: item.price_id,
205
+ // quote_id: (item as any).quote_id,
206
+ // })) || [];
207
+
181
208
  // FIXME: https://stripe.com/docs/elements/address-element
182
209
  // TODO: https://country-regions.github.io/react-country-region-selector/
183
210
  // https://www.npmjs.com/package/postal-codes-js
@@ -191,10 +218,13 @@ export default function PaymentForm({
191
218
  customer,
192
219
  onPaid,
193
220
  onError,
221
+ onQuoteUpdated = undefined,
222
+ onPaymentIntentUpdate = undefined,
194
223
  // mode,
195
224
  action,
196
225
  onlyShowBtn = false,
197
226
  isDonation = false,
227
+ rateUnavailable = false,
198
228
  }: PageData) {
199
229
  // const theme = useTheme();
200
230
  const { t, locale } = useLocaleContext();
@@ -240,6 +270,14 @@ export default function PaymentForm({
240
270
  open: boolean;
241
271
  } | null;
242
272
  showEditForm: boolean;
273
+ // Final Freeze: Price change confirmation state
274
+ priceChangeConfirm: {
275
+ open: boolean;
276
+ previewRate?: string;
277
+ submitRate?: string;
278
+ changePercent: number;
279
+ formData?: any;
280
+ } | null;
243
281
  }>({
244
282
  submitting: false,
245
283
  paying: false,
@@ -252,6 +290,7 @@ export default function PaymentForm({
252
290
  fastCheckoutInfo: null,
253
291
  creditInsufficientInfo: null,
254
292
  showEditForm: false,
293
+ priceChangeConfirm: null,
255
294
  });
256
295
 
257
296
  const currencies = flattenPaymentMethods(paymentMethods);
@@ -388,8 +427,192 @@ export default function PaymentForm({
388
427
  const method = paymentMethods.find((x) => x.id === paymentMethod) as TPaymentMethodExpanded;
389
428
  const paymentCurrency = currencies.find((x) => x.id === paymentCurrencyId);
390
429
  const showStake = method.type === 'arcblock' && !checkoutSession.subscription_data?.no_stake;
430
+ const hasDynamicPricing = useMemo(
431
+ () =>
432
+ (checkoutSession.line_items || []).some((item: any) => {
433
+ const price = item.upsell_price || item.price;
434
+ return price && (price as any)?.pricing_type === 'dynamic';
435
+ }),
436
+ [checkoutSession.line_items]
437
+ );
438
+ const rateUnavailableForDynamic = hasDynamicPricing && rateUnavailable;
439
+ const canPay = payable && !rateUnavailableForDynamic;
391
440
 
392
441
  const isDonationMode = checkoutSession?.submit_type === 'donate' && isDonation;
442
+ const [priceUpdateInfo, setPriceUpdateInfo] = useSetState<{
443
+ open: boolean;
444
+ total: string;
445
+ usd: string | null;
446
+ hasQuotes: boolean;
447
+ baseCurrency: string;
448
+ oldTotal: string;
449
+ reason: 'rateChanged' | 'recalculated';
450
+ }>({
451
+ open: false,
452
+ total: '',
453
+ usd: null,
454
+ hasQuotes: false,
455
+ baseCurrency: 'USD',
456
+ oldTotal: '',
457
+ reason: 'recalculated',
458
+ });
459
+ // lockExpiredInfo state removed - now auto-refreshes instead
460
+
461
+ const normalizeExchangeRate = useMemoizedFn((rate?: string | null): string | null => {
462
+ if (!rate) {
463
+ return null;
464
+ }
465
+ const value = Number(rate);
466
+ if (!Number.isFinite(value)) {
467
+ return null;
468
+ }
469
+ return value.toFixed(8);
470
+ });
471
+
472
+ const getExchangeRateFromSession = useMemoizedFn((sessionData?: TCheckoutSession | null): string | null => {
473
+ if (!sessionData?.line_items?.length) {
474
+ return null;
475
+ }
476
+ for (const item of sessionData.line_items as TLineItemExpanded[]) {
477
+ const rate = (item as any)?.exchange_rate;
478
+ if (rate) {
479
+ return rate;
480
+ }
481
+ }
482
+ return null;
483
+ });
484
+
485
+ const quoteAutoRetryRef = useRef(false);
486
+ const lastRetryKeyRef = useRef<string>('');
487
+ const buildRetryKey = useMemoizedFn((sessionData?: TCheckoutSession | null) => {
488
+ if (!sessionData?.line_items?.length) {
489
+ return '';
490
+ }
491
+ return (sessionData.line_items as TLineItemExpanded[])
492
+ .map((item) => {
493
+ const priceId = item.price_id || item.price?.id || '';
494
+ const quoteId = (item as any)?.quote_id || '';
495
+ const quotedAmount = (item as any)?.quoted_amount || '';
496
+ const exchangeRate = (item as any)?.exchange_rate || '';
497
+ return `${priceId}:${quoteId}:${quotedAmount}:${exchangeRate}`;
498
+ })
499
+ .join('|');
500
+ });
501
+
502
+ const buildPriceUpdateSummary = useMemoizedFn((sessionData: TCheckoutSession) => {
503
+ if (!paymentCurrency) {
504
+ return { total: '', usd: null, hasQuotes: false, baseCurrency: 'USD', totalUnit: null as BN | null };
505
+ }
506
+ const lineItems = (sessionData.line_items || []) as TLineItemExpanded[];
507
+ let baseCurrency = 'USD';
508
+ for (const item of lineItems) {
509
+ const price = item.upsell_price || item.price;
510
+ const base = (price as any)?.base_currency;
511
+ if (base) {
512
+ baseCurrency = base;
513
+ break;
514
+ }
515
+ }
516
+ const hasQuotes = lineItems.some((item) => (item as any)?.quoted_amount && (item as any)?.exchange_rate);
517
+ if (!hasQuotes) {
518
+ return { total: '', usd: null, hasQuotes: false, baseCurrency, totalUnit: null as BN | null };
519
+ }
520
+ let trialInDays = Number(sessionData?.subscription_data?.trial_period_days || 0);
521
+ const trialCurrencyIds = (sessionData?.subscription_data?.trial_currency || '')
522
+ .split(',')
523
+ .map(trim)
524
+ .filter(Boolean);
525
+ if (trialCurrencyIds.length > 0 && paymentCurrencyId && trialCurrencyIds.includes(paymentCurrencyId) === false) {
526
+ trialInDays = 0;
527
+ }
528
+ const { total } = getCheckoutAmount(lineItems, paymentCurrency, trialInDays > 0);
529
+ const discountAmount = new BN(sessionData.total_details?.amount_discount || '0');
530
+ const totalUnit = new BN(total).sub(discountAmount);
531
+ const normalizedTotalUnit = totalUnit.isNeg() ? new BN(0) : totalUnit;
532
+ const totalDisplay = `${formatNumber(
533
+ fromUnitToToken(normalizedTotalUnit.toString(), paymentCurrency.decimal),
534
+ 6
535
+ )} ${paymentCurrency.symbol}`;
536
+
537
+ const itemUsdReferences = lineItems.map((item) => {
538
+ const price = item.upsell_price || item.price;
539
+ const baseAmount = (price as any)?.base_amount;
540
+ const hasBaseAmount = baseAmount !== undefined && baseAmount !== null;
541
+ if (hasBaseAmount) {
542
+ return getUsdAmountFromBaseAmount(baseAmount, item.quantity || 0);
543
+ }
544
+ const exchangeRate = (item as any)?.exchange_rate;
545
+ const quotedAmount = (item as any)?.quoted_amount;
546
+ if (!exchangeRate || !quotedAmount) {
547
+ return null;
548
+ }
549
+ return getUsdAmountFromTokenUnits(new BN(quotedAmount), paymentCurrency.decimal, exchangeRate);
550
+ });
551
+ const usdValues = itemUsdReferences.filter((value): value is string => Boolean(value));
552
+ if (!usdValues.length) {
553
+ return { total: totalDisplay, usd: null, hasQuotes, baseCurrency, totalUnit: normalizedTotalUnit };
554
+ }
555
+ const sumUnit = usdValues.reduce((acc, value) => acc.add(new BN(fromTokenToUnit(value, 8))), new BN(0));
556
+ const totalUsdReference = fromUnitToToken(sumUnit.toString(), 8);
557
+ return {
558
+ total: totalDisplay,
559
+ usd: formatUsdAmount(totalUsdReference, locale),
560
+ hasQuotes,
561
+ baseCurrency,
562
+ totalUnit: normalizedTotalUnit,
563
+ };
564
+ });
565
+
566
+ const compareTotals = useMemoizedFn((prevSession: TCheckoutSession, nextSession: TCheckoutSession) => {
567
+ const prev = buildPriceUpdateSummary(prevSession);
568
+ const next = buildPriceUpdateSummary(nextSession);
569
+ if (!prev.totalUnit || !next.totalUnit) {
570
+ return { changed: false, prev, next };
571
+ }
572
+ const diff = next.totalUnit.sub(prev.totalUnit).abs();
573
+ const epsilon = new BN(1);
574
+ return { changed: diff.gt(epsilon), prev, next };
575
+ });
576
+
577
+ const applyQuoteUpdate = useMemoizedFn(
578
+ (
579
+ payload: { checkoutSession: TCheckoutSession; quotes?: any; rateUnavailable?: boolean; rateError?: string },
580
+ options: { forceConfirm?: boolean; reason?: 'rateChanged' | 'recalculated' } = {}
581
+ ) => {
582
+ if (!payload?.checkoutSession) {
583
+ return;
584
+ }
585
+ onQuoteUpdated?.({
586
+ checkoutSession: payload.checkoutSession as TCheckoutSessionExpanded,
587
+ quotes: payload.quotes,
588
+ rateUnavailable: payload.rateUnavailable,
589
+ rateError: payload.rateError,
590
+ });
591
+ const { changed, prev, next } = compareTotals(checkoutSession, payload.checkoutSession);
592
+ const previousRate = normalizeExchangeRate(getExchangeRateFromSession(checkoutSession));
593
+ const nextRate = normalizeExchangeRate(getExchangeRateFromSession(payload.checkoutSession));
594
+ const rateChanged = !!(previousRate && nextRate && previousRate !== nextRate);
595
+ const shouldShowModal = (options.forceConfirm || changed) && next.hasQuotes;
596
+ if (shouldShowModal) {
597
+ setPriceUpdateInfo({
598
+ open: true,
599
+ total: next.total,
600
+ usd: next.usd,
601
+ hasQuotes: next.hasQuotes,
602
+ baseCurrency: next.baseCurrency,
603
+ oldTotal: prev.total,
604
+ reason: options.reason || (rateChanged ? 'rateChanged' : 'recalculated'),
605
+ });
606
+ return;
607
+ }
608
+ setPriceUpdateInfo({ open: false });
609
+ const retryKey = buildRetryKey(payload.checkoutSession);
610
+ if (retryKey && retryKey !== lastRetryKeyRef.current) {
611
+ lastRetryKeyRef.current = retryKey;
612
+ quoteAutoRetryRef.current = true;
613
+ }
614
+ }
615
+ );
393
616
 
394
617
  const validateUserInfo = (values: any) => {
395
618
  if (!values) {
@@ -496,6 +719,17 @@ export default function PaymentForm({
496
719
 
497
720
  const showForm = session?.user ? state.showEditForm : false;
498
721
 
722
+ useEffect(() => {
723
+ if (!quoteAutoRetryRef.current) {
724
+ return;
725
+ }
726
+ if (state.submitting || state.paying) {
727
+ return;
728
+ }
729
+ quoteAutoRetryRef.current = false;
730
+ onAction();
731
+ }, [state.submitting, state.paying]); // eslint-disable-line react-hooks/exhaustive-deps
732
+
499
733
  const handleConnected = async () => {
500
734
  if (processingRef.current) {
501
735
  return;
@@ -560,7 +794,11 @@ export default function PaymentForm({
560
794
  });
561
795
 
562
796
  try {
563
- const result = await api.post(`/api/checkout-sessions/${checkoutSession.id}/fast-checkout-confirm`);
797
+ // No longer need to send quotes - backend auto-finds them
798
+ const result = await api.post(`/api/checkout-sessions/${checkoutSession.id}/fast-checkout-confirm`, {});
799
+ if (result.data.paymentIntent) {
800
+ onPaymentIntentUpdate?.(result.data.paymentIntent);
801
+ }
564
802
  if (result.data.fastPaid) {
565
803
  setState({
566
804
  fastCheckoutInfo: null,
@@ -575,8 +813,56 @@ export default function PaymentForm({
575
813
  });
576
814
  openConnect();
577
815
  }
578
- } catch (err) {
816
+ } catch (err: unknown) {
579
817
  console.error(err);
818
+ const errorCode = (err as any)?.response?.data?.code;
819
+ // Auto-refresh for quote-related errors (including lock expired)
820
+ if (
821
+ [
822
+ 'QUOTE_LOCK_EXPIRED',
823
+ 'QUOTE_AMOUNT_MISMATCH',
824
+ 'QUOTE_EXPIRED_OR_USED',
825
+ 'QUOTE_NOT_FOUND',
826
+ 'QUOTE_REQUIRED',
827
+ ].includes(errorCode)
828
+ ) {
829
+ try {
830
+ const { data: refreshed } = await api.get(`/api/checkout-sessions/retrieve/${checkoutSession.id}`, {
831
+ params: { forceRefresh: '1' },
832
+ });
833
+ if (refreshed?.checkoutSession) {
834
+ applyQuoteUpdate(refreshed, { reason: 'rateChanged' });
835
+ Toast.info(t('payment.checkout.quote.updated.pleaseRetry') || 'Price updated, please resubmit');
836
+ }
837
+ } catch (refreshError) {
838
+ console.error(refreshError);
839
+ Toast.error(formatError(refreshError));
840
+ } finally {
841
+ setState({ fastCheckoutInfo: null });
842
+ }
843
+ return;
844
+ }
845
+ if (errorCode === 'QUOTE_UPDATED') {
846
+ const payload = (err as any)?.response?.data;
847
+ if (payload?.checkoutSession) {
848
+ applyQuoteUpdate(payload);
849
+ }
850
+ setState({ fastCheckoutInfo: null });
851
+ return;
852
+ }
853
+ if (errorCode === 'RATE_UNAVAILABLE') {
854
+ const payload = (err as any)?.response?.data;
855
+ if (payload?.checkoutSession) {
856
+ onQuoteUpdated?.({
857
+ checkoutSession: payload.checkoutSession as TCheckoutSessionExpanded,
858
+ quotes: payload.quotes,
859
+ rateUnavailable: payload.rateUnavailable,
860
+ rateError: payload.rateError,
861
+ });
862
+ }
863
+ setState({ fastCheckoutInfo: null });
864
+ return;
865
+ }
580
866
  Toast.error(formatError(err));
581
867
  setState({
582
868
  fastCheckoutInfo: null,
@@ -592,6 +878,29 @@ export default function PaymentForm({
592
878
  setState({ creditInsufficientInfo: null });
593
879
  };
594
880
 
881
+ const handlePriceUpdateConfirm = () => {
882
+ setPriceUpdateInfo({ open: false });
883
+ quoteAutoRetryRef.current = true;
884
+ };
885
+
886
+ const handlePriceUpdateCancel = () => {
887
+ setPriceUpdateInfo({ open: false });
888
+ };
889
+
890
+ // Final Freeze: Handle price change confirmation (PRICE_CHANGED error)
891
+ const handlePriceChangeConfirm = () => {
892
+ const formData = state.priceChangeConfirm?.formData;
893
+ setState({ priceChangeConfirm: null });
894
+ if (formData) {
895
+ // Retry submit with price_confirmed flag
896
+ onFormSubmit(formData);
897
+ }
898
+ };
899
+
900
+ const handlePriceChangeCancel = () => {
901
+ setState({ priceChangeConfirm: null });
902
+ };
903
+
595
904
  const openConnect = () => {
596
905
  try {
597
906
  if (!['arcblock', 'ethereum', 'base'].includes(method.type)) {
@@ -632,6 +941,10 @@ export default function PaymentForm({
632
941
  };
633
942
 
634
943
  const onFormSubmit = async (data: any) => {
944
+ if (state.submitting) {
945
+ return;
946
+ }
947
+
635
948
  const userInfo = session.user;
636
949
 
637
950
  if (!userInfo.sourceAppPid) {
@@ -644,13 +957,36 @@ export default function PaymentForm({
644
957
  }
645
958
  }
646
959
 
960
+ // ✅ No longer need to collect or validate quotes - backend auto-finds them
961
+ // const quotes = collectQuotes(checkoutSession.line_items as TLineItemExpanded[]);
962
+ // if (
963
+ // (checkoutSession.line_items || []).some((item: any) => item.price?.pricing_type === 'dynamic') &&
964
+ // quotes.length === 0
965
+ // ) {
966
+ // Toast.error(t('payment.checkout.quote.expired'));
967
+ // return;
968
+ // }
969
+
647
970
  setState({ submitting: true });
648
971
  try {
649
972
  let result;
973
+ // Final Freeze: Add idempotency_key and preview_rate for dynamic pricing
974
+ const previewRate =
975
+ checkoutSession.line_items?.find((item: TLineItemExpanded) => (item as any)?.exchange_rate)?.exchange_rate ||
976
+ undefined;
977
+
978
+ const payload = {
979
+ ...data,
980
+ // Final Freeze: Include these for new quote creation at submit
981
+ idempotency_key: generateIdempotencyKey(checkoutSession.id, paymentCurrency?.id || ''),
982
+ preview_rate: (previewRate as unknown as string) || undefined,
983
+ price_confirmed: state.priceChangeConfirm?.formData ? true : undefined,
984
+ };
985
+
650
986
  if (isDonationMode) {
651
- result = await api.put(`/api/checkout-sessions/${checkoutSession.id}/donate-submit`, data);
987
+ result = await api.put(`/api/checkout-sessions/${checkoutSession.id}/donate-submit`, payload);
652
988
  } else {
653
- result = await api.put(`/api/checkout-sessions/${checkoutSession.id}/submit`, data);
989
+ result = await api.put(`/api/checkout-sessions/${checkoutSession.id}/submit`, payload);
654
990
  }
655
991
  setState({
656
992
  paymentIntent: result.data.paymentIntent,
@@ -659,8 +995,34 @@ export default function PaymentForm({
659
995
  submitting: false,
660
996
  customerLimited: false,
661
997
  });
998
+ if (result.data.paymentIntent) {
999
+ onPaymentIntentUpdate?.(result.data.paymentIntent);
1000
+ }
662
1001
 
663
1002
  if (['arcblock', 'ethereum', 'base'].includes(method.type)) {
1003
+ // 如果不需要支付(如免费试用),直接确认
1004
+ if (result.data.noPaymentRequired) {
1005
+ try {
1006
+ const confirmResult = await api.post(
1007
+ `/api/checkout-sessions/${checkoutSession.id}/fast-checkout-confirm`,
1008
+ {}
1009
+ );
1010
+ if (confirmResult.data.paymentIntent) {
1011
+ onPaymentIntentUpdate?.(confirmResult.data.paymentIntent);
1012
+ }
1013
+ if (confirmResult.data.fastPaid || confirmResult.data.checkoutSession?.status === 'complete') {
1014
+ setState({ paying: true });
1015
+ await handleConnected();
1016
+ } else {
1017
+ openConnect();
1018
+ }
1019
+ } catch (confirmErr) {
1020
+ console.error('noPaymentRequired confirm failed', confirmErr);
1021
+ openConnect();
1022
+ }
1023
+ return;
1024
+ }
1025
+
664
1026
  // 优先判断 credit 支付
665
1027
  if (paymentCurrency?.type === 'credit') {
666
1028
  if (result.data.creditSufficient === true) {
@@ -715,15 +1077,109 @@ export default function PaymentForm({
715
1077
  console.error(err);
716
1078
  let shouldToast = true;
717
1079
 
718
- if (err.response?.data?.code) {
719
- dispatch(`error.${err.response?.data?.code}`);
1080
+ const errorCode = err.response?.data?.code;
1081
+ if (errorCode) {
1082
+ if (
1083
+ ![
1084
+ 'QUOTE_UPDATED',
1085
+ 'RATE_UNAVAILABLE',
1086
+ 'QUOTE_LOCK_EXPIRED',
1087
+ 'QUOTE_AMOUNT_MISMATCH',
1088
+ 'QUOTE_EXPIRED_OR_USED',
1089
+ 'QUOTE_NOT_FOUND',
1090
+ 'QUOTE_REQUIRED',
1091
+ 'QUOTE_MAX_PAYABLE_EXCEEDED',
1092
+ ].includes(errorCode)
1093
+ ) {
1094
+ dispatch(`error.${errorCode}`);
1095
+ }
1096
+
1097
+ // Auto-refresh for all quote-related errors (no modal dialog)
1098
+ if (
1099
+ [
1100
+ 'QUOTE_LOCK_EXPIRED',
1101
+ 'QUOTE_AMOUNT_MISMATCH',
1102
+ 'QUOTE_EXPIRED_OR_USED',
1103
+ 'QUOTE_NOT_FOUND',
1104
+ 'QUOTE_REQUIRED',
1105
+ 'QUOTE_MAX_PAYABLE_EXCEEDED',
1106
+ 'quote_validation_failed',
1107
+ ].includes(errorCode)
1108
+ ) {
1109
+ shouldToast = false;
1110
+ try {
1111
+ const { data: refreshed } = await api.get(`/api/checkout-sessions/retrieve/${checkoutSession.id}`, {
1112
+ params: { forceRefresh: '1' },
1113
+ });
1114
+ if (refreshed?.checkoutSession) {
1115
+ applyQuoteUpdate(refreshed, { reason: 'rateChanged' });
1116
+ Toast.info(t('payment.checkout.quote.updated.pleaseRetry') || 'Price updated, please resubmit');
1117
+ }
1118
+ } catch (refreshError) {
1119
+ console.error(refreshError);
1120
+ Toast.error(formatError(refreshError));
1121
+ }
1122
+ }
1123
+
1124
+ if (errorCode === 'QUOTE_UPDATED') {
1125
+ shouldToast = false;
1126
+ const payload = err.response?.data;
1127
+ if (payload?.checkoutSession) {
1128
+ applyQuoteUpdate(payload);
1129
+ }
1130
+ }
1131
+
1132
+ if (errorCode === 'RATE_UNAVAILABLE') {
1133
+ shouldToast = false;
1134
+ const payload = err.response?.data;
1135
+ if (payload?.checkoutSession) {
1136
+ onQuoteUpdated?.({
1137
+ checkoutSession: payload.checkoutSession,
1138
+ quotes: payload.quotes,
1139
+ rateUnavailable: payload.rateUnavailable,
1140
+ rateError: payload.rateError,
1141
+ });
1142
+ }
1143
+ }
1144
+
1145
+ // Final Freeze: Handle new error codes
1146
+ if (errorCode === 'PRICE_UNAVAILABLE') {
1147
+ shouldToast = false;
1148
+ Toast.error(
1149
+ t('payment.checkout.priceChange.unavailable', {
1150
+ fallback: 'Unable to fetch exchange rate. Please try again later.',
1151
+ })
1152
+ );
1153
+ }
720
1154
 
721
- if (err.response?.data?.code === 'UNIFIED_APP_REQUIRED') {
1155
+ if (errorCode === 'PRICE_UNSTABLE') {
1156
+ shouldToast = false;
1157
+ Toast.error(
1158
+ t('payment.checkout.priceChange.unstable', {
1159
+ fallback: 'Price is volatile. Please try again later.',
1160
+ })
1161
+ );
1162
+ }
1163
+
1164
+ if (errorCode === 'PRICE_CHANGED') {
1165
+ shouldToast = false;
1166
+ const errorData = err.response?.data;
1167
+ // Show price change confirmation dialog
1168
+ setState({
1169
+ priceChangeConfirm: {
1170
+ open: true,
1171
+ changePercent: errorData?.change_percent || 0,
1172
+ formData: data, // Save form data for retry
1173
+ },
1174
+ });
1175
+ }
1176
+
1177
+ if (errorCode === 'UNIFIED_APP_REQUIRED') {
722
1178
  shouldToast = false;
723
1179
  Toast.error(t('payment.checkout.vendor.accountRequired'));
724
1180
  }
725
1181
 
726
- if (err.response.data.code === 'CUSTOMER_LIMITED') {
1182
+ if (errorCode === 'CUSTOMER_LIMITED') {
727
1183
  shouldToast = false;
728
1184
  setState({ customerLimited: true });
729
1185
  }
@@ -744,7 +1200,7 @@ export default function PaymentForm({
744
1200
  };
745
1201
 
746
1202
  const onAction = () => {
747
- if (state.submitting || state.paying) {
1203
+ if (state.submitting || state.paying || !canPay) {
748
1204
  return;
749
1205
  }
750
1206
  if (errorRef.current && !isEmpty(errors) && isMobile) {
@@ -769,6 +1225,8 @@ export default function PaymentForm({
769
1225
  }
770
1226
  };
771
1227
 
1228
+ // Lock expired handlers removed - now auto-refreshes instead
1229
+
772
1230
  const onStripeConfirm = async () => {
773
1231
  setState({ stripePaying: false, paying: true });
774
1232
  await handleConnected();
@@ -793,7 +1251,7 @@ export default function PaymentForm({
793
1251
  !state.paying &&
794
1252
  !state.stripePaying &&
795
1253
  quantityInventoryStatus &&
796
- payable
1254
+ canPay
797
1255
  ) {
798
1256
  onAction();
799
1257
  }
@@ -803,7 +1261,7 @@ export default function PaymentForm({
803
1261
  return () => {
804
1262
  window.removeEventListener('keydown', handleKeyDown);
805
1263
  };
806
- }, [state.submitting, state.paying, state.stripePaying, quantityInventoryStatus, payable]); // eslint-disable-line react-hooks/exhaustive-deps
1264
+ }, [state.submitting, state.paying, state.stripePaying, quantityInventoryStatus, canPay]); // eslint-disable-line react-hooks/exhaustive-deps
807
1265
 
808
1266
  const balanceLink = getTokenBalanceLink(method, state.fastCheckoutInfo?.payer || '');
809
1267
 
@@ -820,7 +1278,7 @@ export default function PaymentForm({
820
1278
  state.fastCheckoutInfo.sourceType === 'credit' ? (
821
1279
  <Typography>
822
1280
  {t('payment.checkout.fastPay.credit.meteringSubscriptionMessage', {
823
- available: `${fromUnitToToken(state.fastCheckoutInfo?.balance || '0', paymentCurrency?.decimal || 18).toString()} ${paymentCurrency?.symbol}`,
1281
+ available: `${formatAmount(state.fastCheckoutInfo?.balance || '0', paymentCurrency?.decimal || 18)} ${paymentCurrency?.symbol}`,
824
1282
  })}
825
1283
  </Typography>
826
1284
  ) : (
@@ -873,7 +1331,7 @@ export default function PaymentForm({
873
1331
  {t('payment.checkout.fastPay.amount')}
874
1332
  </Typography>
875
1333
  <Typography>
876
- {fromUnitToToken(state.fastCheckoutInfo.amount, paymentCurrency?.decimal || 18).toString()}{' '}
1334
+ {formatAmount(state.fastCheckoutInfo.amount, paymentCurrency?.decimal || 18)}{' '}
877
1335
  {paymentCurrency?.symbol}
878
1336
  </Typography>
879
1337
  </Stack>
@@ -896,6 +1354,47 @@ export default function PaymentForm({
896
1354
  />
897
1355
  );
898
1356
 
1357
+ const PriceUpdatedDialog = priceUpdateInfo.open && (
1358
+ <ConfirmDialog
1359
+ onConfirm={handlePriceUpdateConfirm}
1360
+ onCancel={handlePriceUpdateCancel}
1361
+ title={t('payment.checkout.quote.priceUpdatedTitle')}
1362
+ message={
1363
+ <Stack spacing={1}>
1364
+ <Typography>
1365
+ {t(
1366
+ priceUpdateInfo.reason === 'rateChanged'
1367
+ ? 'payment.checkout.quote.priceUpdatedDescriptionRate'
1368
+ : 'payment.checkout.quote.priceUpdatedDescriptionRecalc'
1369
+ )}
1370
+ </Typography>
1371
+ {priceUpdateInfo.hasQuotes && (
1372
+ <Stack spacing={0.25}>
1373
+ <Typography sx={{ color: 'text.secondary', fontSize: '0.7875rem' }}>
1374
+ {t('payment.checkout.quote.priceUpdatedNewTotalLabel')}
1375
+ </Typography>
1376
+ <Typography sx={{ fontWeight: 600 }}>{priceUpdateInfo.total}</Typography>
1377
+ {priceUpdateInfo.usd && (
1378
+ <Typography sx={{ color: 'text.secondary' }}>
1379
+ ≈ {priceUpdateInfo.usd} {priceUpdateInfo.baseCurrency}
1380
+ </Typography>
1381
+ )}
1382
+ {priceUpdateInfo.oldTotal && (
1383
+ <Typography sx={{ color: 'text.secondary', fontSize: '0.75rem' }}>
1384
+ {t('payment.checkout.quote.priceUpdatedOldTotal', { total: priceUpdateInfo.oldTotal })}
1385
+ </Typography>
1386
+ )}
1387
+ </Stack>
1388
+ )}
1389
+ </Stack>
1390
+ }
1391
+ confirm={t('payment.checkout.quote.priceUpdatedConfirm')}
1392
+ cancel={t('common.cancel')}
1393
+ color="primary"
1394
+ />
1395
+ );
1396
+ // LockExpiredDialog removed - now auto-refreshes instead
1397
+
899
1398
  const getRedirectUrl = () => {
900
1399
  if (searchParams.redirect) {
901
1400
  return decodeURIComponent(searchParams.redirect);
@@ -919,13 +1418,13 @@ export default function PaymentForm({
919
1418
  size="large"
920
1419
  className="cko-submit-button"
921
1420
  onClick={() => {
922
- if (state.submitting || state.paying) {
1421
+ if (state.submitting || state.paying || !canPay) {
923
1422
  return;
924
1423
  }
925
1424
  onAction();
926
1425
  }}
927
1426
  fullWidth
928
- disabled={state.stripePaying || !quantityInventoryStatus || !payable}>
1427
+ disabled={state.stripePaying || !quantityInventoryStatus || !canPay}>
929
1428
  {(state.submitting || state.paying) && (
930
1429
  <CircularProgress size={16} sx={{ mr: 0.5, color: 'primary.contrastText' }} />
931
1430
  )}
@@ -962,6 +1461,17 @@ export default function PaymentForm({
962
1461
  )}
963
1462
  {FastCheckoutConfirmDialog}
964
1463
  {CreditInsufficientDialog}
1464
+ {PriceUpdatedDialog}
1465
+ {/* Final Freeze: Price change confirmation dialog */}
1466
+ {state.priceChangeConfirm?.open && (
1467
+ <PriceChangeConfirm
1468
+ open
1469
+ changePercent={state.priceChangeConfirm.changePercent}
1470
+ onConfirm={handlePriceChangeConfirm}
1471
+ onCancel={handlePriceChangeCancel}
1472
+ loading={state.submitting}
1473
+ />
1474
+ )}
965
1475
  </>
966
1476
  );
967
1477
  }
@@ -998,10 +1508,31 @@ export default function PaymentForm({
998
1508
  <CurrencySelector
999
1509
  value={field.value}
1000
1510
  currencies={currencies}
1001
- onChange={(id: string, methodId: string) => {
1511
+ onChange={async (id: string, methodId: string) => {
1512
+ const oldCurrencyId = field.value;
1002
1513
  field.onChange(id);
1003
1514
  setValue('payment_method', methodId);
1004
1515
  saveCurrencyPreference(id, session?.user?.did);
1516
+
1517
+ // Call API to switch currency and clear quote-related fields if currency changed
1518
+ // This is essential because quotes are currency-specific (e.g., TBA quote with 18 decimals
1519
+ // cannot be used for USD with 2 decimals)
1520
+ if (oldCurrencyId && oldCurrencyId !== id) {
1521
+ try {
1522
+ const { data } = await api.put(
1523
+ `/api/checkout-sessions/${checkoutSession.id}/switch-currency`,
1524
+ {
1525
+ currency_id: id,
1526
+ payment_method_id: methodId,
1527
+ }
1528
+ );
1529
+ if (data.currency_changed && onQuoteUpdated) {
1530
+ onQuoteUpdated({ checkoutSession: data, quotes: data.quotes });
1531
+ }
1532
+ } catch (err) {
1533
+ console.error('Failed to switch currency:', err);
1534
+ }
1535
+ }
1005
1536
  }}
1006
1537
  />
1007
1538
  )}
@@ -1154,11 +1685,10 @@ export default function PaymentForm({
1154
1685
  }}
1155
1686
  fullWidth
1156
1687
  loading={state.submitting || state.paying}
1157
- disabled={state.stripePaying || !quantityInventoryStatus || !payable}>
1688
+ disabled={state.stripePaying || !quantityInventoryStatus || !canPay}>
1158
1689
  {state.submitting || state.paying ? t('payment.checkout.processing') : buttonText}
1159
1690
  </LoadingButton>
1160
1691
  </Box>
1161
-
1162
1692
  {['subscription', 'setup'].includes(checkoutSession.mode) && (
1163
1693
  <Typography sx={{ mt: 2.5, color: 'text.lighter', fontSize: '0.7875rem', lineHeight: '0.9625rem' }}>
1164
1694
  {showStake
@@ -1205,6 +1735,18 @@ export default function PaymentForm({
1205
1735
  )}
1206
1736
  {FastCheckoutConfirmDialog}
1207
1737
  {CreditInsufficientDialog}
1738
+ {PriceUpdatedDialog}
1739
+ {/* Final Freeze: Price change confirmation dialog */}
1740
+ {state.priceChangeConfirm?.open && (
1741
+ <PriceChangeConfirm
1742
+ open
1743
+ changePercent={state.priceChangeConfirm.changePercent}
1744
+ onConfirm={handlePriceChangeConfirm}
1745
+ onCancel={handlePriceChangeCancel}
1746
+ loading={state.submitting}
1747
+ />
1748
+ )}
1749
+ {/* LockExpiredDialog removed - now auto-refreshes instead */}
1208
1750
  </>
1209
1751
  );
1210
1752
  }