@blocklet/payment-react 1.24.3 → 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 (101) 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/credit/transactions-list.js +11 -1
  18. package/es/history/invoice/list.js +125 -15
  19. package/es/hooks/dynamic-pricing.d.ts +102 -0
  20. package/es/hooks/dynamic-pricing.js +393 -0
  21. package/es/index.d.ts +6 -1
  22. package/es/index.js +9 -1
  23. package/es/libs/util.d.ts +42 -5
  24. package/es/libs/util.js +345 -57
  25. package/es/locales/en.js +114 -3
  26. package/es/locales/zh.js +114 -3
  27. package/es/payment/form/index.d.ts +4 -1
  28. package/es/payment/form/index.js +454 -22
  29. package/es/payment/index.d.ts +1 -1
  30. package/es/payment/index.js +279 -16
  31. package/es/payment/product-item.d.ts +26 -1
  32. package/es/payment/product-item.js +330 -51
  33. package/es/payment/summary-section/promotion-section.d.ts +32 -0
  34. package/es/payment/summary-section/promotion-section.js +143 -0
  35. package/es/payment/summary-section/total-section.d.ts +39 -0
  36. package/es/payment/summary-section/total-section.js +83 -0
  37. package/es/payment/summary.d.ts +17 -2
  38. package/es/payment/summary.js +300 -253
  39. package/es/types/index.d.ts +11 -0
  40. package/lib/components/auto-topup/modal.d.ts +2 -0
  41. package/lib/components/auto-topup/modal.js +54 -6
  42. package/lib/components/auto-topup/product-card.d.ts +16 -1
  43. package/lib/components/auto-topup/product-card.js +75 -7
  44. package/lib/components/dynamic-pricing-unavailable.d.ts +9 -0
  45. package/lib/components/dynamic-pricing-unavailable.js +81 -0
  46. package/lib/components/loading-amount.d.ts +17 -0
  47. package/lib/components/loading-amount.js +53 -0
  48. package/lib/components/price-change-confirm.d.ts +18 -0
  49. package/lib/components/price-change-confirm.js +157 -0
  50. package/lib/components/quote-details-panel.d.ts +21 -0
  51. package/lib/components/quote-details-panel.js +226 -0
  52. package/lib/components/quote-lock-banner.d.ts +7 -0
  53. package/lib/components/quote-lock-banner.js +93 -0
  54. package/lib/components/slippage-config.d.ts +20 -0
  55. package/lib/components/slippage-config.js +316 -0
  56. package/lib/history/credit/transactions-list.js +11 -1
  57. package/lib/history/invoice/list.js +167 -27
  58. package/lib/hooks/dynamic-pricing.d.ts +102 -0
  59. package/lib/hooks/dynamic-pricing.js +390 -0
  60. package/lib/index.d.ts +6 -1
  61. package/lib/index.js +32 -0
  62. package/lib/libs/util.d.ts +42 -5
  63. package/lib/libs/util.js +367 -49
  64. package/lib/locales/en.js +114 -3
  65. package/lib/locales/zh.js +114 -3
  66. package/lib/payment/form/index.d.ts +4 -1
  67. package/lib/payment/form/index.js +476 -20
  68. package/lib/payment/index.d.ts +1 -1
  69. package/lib/payment/index.js +308 -14
  70. package/lib/payment/product-item.d.ts +26 -1
  71. package/lib/payment/product-item.js +270 -35
  72. package/lib/payment/summary-section/promotion-section.d.ts +32 -0
  73. package/lib/payment/summary-section/promotion-section.js +133 -0
  74. package/lib/payment/summary-section/total-section.d.ts +39 -0
  75. package/lib/payment/summary-section/total-section.js +117 -0
  76. package/lib/payment/summary.d.ts +17 -2
  77. package/lib/payment/summary.js +205 -127
  78. package/lib/types/index.d.ts +11 -0
  79. package/package.json +3 -3
  80. package/src/components/auto-topup/modal.tsx +59 -6
  81. package/src/components/auto-topup/product-card.tsx +118 -11
  82. package/src/components/dynamic-pricing-unavailable.tsx +69 -0
  83. package/src/components/loading-amount.tsx +66 -0
  84. package/src/components/price-change-confirm.tsx +136 -0
  85. package/src/components/quote-details-panel.tsx +218 -0
  86. package/src/components/quote-lock-banner.tsx +99 -0
  87. package/src/components/slippage-config.tsx +336 -0
  88. package/src/history/credit/transactions-list.tsx +14 -1
  89. package/src/history/invoice/list.tsx +143 -9
  90. package/src/hooks/dynamic-pricing.ts +617 -0
  91. package/src/index.ts +9 -0
  92. package/src/libs/util.ts +473 -58
  93. package/src/locales/en.tsx +117 -0
  94. package/src/locales/zh.tsx +111 -0
  95. package/src/payment/form/index.tsx +561 -19
  96. package/src/payment/index.tsx +349 -10
  97. package/src/payment/product-item.tsx +451 -37
  98. package/src/payment/summary-section/promotion-section.tsx +172 -0
  99. package/src/payment/summary-section/total-section.tsx +141 -0
  100. package/src/payment/summary.tsx +334 -192
  101. package/src/types/index.ts +15 -0
@@ -11,11 +11,12 @@ import { Controller, useFormContext, useWatch } from "react-hook-form";
11
11
  import { joinURL } from "ufo";
12
12
  import { dispatch } from "use-bus";
13
13
  import isEmail from "validator/es/lib/isEmail";
14
- import { fromUnitToToken } from "@ocap/util";
14
+ import { BN, fromTokenToUnit, fromUnitToToken } from "@ocap/util";
15
15
  import DID from "@arcblock/ux/lib/DID";
16
16
  import isEmpty from "lodash/isEmpty";
17
17
  import { HelpOutline, OpenInNew } from "@mui/icons-material";
18
18
  import { ReactGA } from "@arcblock/ux/lib/withTracker";
19
+ import trim from "lodash/trim";
19
20
  import FormInput from "../../components/input.js";
20
21
  import FormLabel from "../../components/label.js";
21
22
  import { usePaymentContext } from "../../contexts/payment.js";
@@ -29,7 +30,13 @@ import {
29
30
  getQueryParams,
30
31
  getStatementDescriptor,
31
32
  getTokenBalanceLink,
32
- isCrossOrigin
33
+ isCrossOrigin,
34
+ getCheckoutAmount,
35
+ formatNumber,
36
+ formatUsdAmount,
37
+ getUsdAmountFromBaseAmount,
38
+ getUsdAmountFromTokenUnits,
39
+ formatAmount
33
40
  } from "../../libs/util.js";
34
41
  import AddressForm from "./address.js";
35
42
  import CurrencySelector from "./currency.js";
@@ -41,7 +48,11 @@ import LoadingButton from "../../components/loading-button.js";
41
48
  import OverdueInvoicePayment from "../../components/over-due-invoice-payment.js";
42
49
  import { saveCurrencyPreference } from "../../libs/currency.js";
43
50
  import ConfirmDialog from "../../components/confirm.js";
51
+ import PriceChangeConfirm from "../../components/price-change-confirm.js";
44
52
  import { getFieldValidation, validatePostalCode } from "../../libs/validator.js";
53
+ const generateIdempotencyKey = (sessionId, currencyId) => {
54
+ return `${sessionId}-${currencyId}-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`;
55
+ };
45
56
  export const waitForCheckoutComplete = async (sessionId) => {
46
57
  let result;
47
58
  await pWaitFor(
@@ -110,10 +121,13 @@ export default function PaymentForm({
110
121
  customer,
111
122
  onPaid,
112
123
  onError,
124
+ onQuoteUpdated = void 0,
125
+ onPaymentIntentUpdate = void 0,
113
126
  // mode,
114
127
  action,
115
128
  onlyShowBtn = false,
116
- isDonation = false
129
+ isDonation = false,
130
+ rateUnavailable = false
117
131
  }) {
118
132
  const { t, locale } = useLocaleContext();
119
133
  const { isMobile } = useMobile();
@@ -151,7 +165,8 @@ export default function PaymentForm({
151
165
  stripePaying: false,
152
166
  fastCheckoutInfo: null,
153
167
  creditInsufficientInfo: null,
154
- showEditForm: false
168
+ showEditForm: false,
169
+ priceChangeConfirm: null
155
170
  });
156
171
  const currencies = flattenPaymentMethods(paymentMethods);
157
172
  const searchParams = getQueryParams(window.location.href);
@@ -260,7 +275,166 @@ export default function PaymentForm({
260
275
  const method = paymentMethods.find((x) => x.id === paymentMethod);
261
276
  const paymentCurrency = currencies.find((x) => x.id === paymentCurrencyId);
262
277
  const showStake = method.type === "arcblock" && !checkoutSession.subscription_data?.no_stake;
278
+ const hasDynamicPricing = useMemo(
279
+ () => (checkoutSession.line_items || []).some((item) => {
280
+ const price = item.upsell_price || item.price;
281
+ return price && price?.pricing_type === "dynamic";
282
+ }),
283
+ [checkoutSession.line_items]
284
+ );
285
+ const rateUnavailableForDynamic = hasDynamicPricing && rateUnavailable;
286
+ const canPay = payable && !rateUnavailableForDynamic;
263
287
  const isDonationMode = checkoutSession?.submit_type === "donate" && isDonation;
288
+ const [priceUpdateInfo, setPriceUpdateInfo] = useSetState({
289
+ open: false,
290
+ total: "",
291
+ usd: null,
292
+ hasQuotes: false,
293
+ baseCurrency: "USD",
294
+ oldTotal: "",
295
+ reason: "recalculated"
296
+ });
297
+ const normalizeExchangeRate = useMemoizedFn((rate) => {
298
+ if (!rate) {
299
+ return null;
300
+ }
301
+ const value = Number(rate);
302
+ if (!Number.isFinite(value)) {
303
+ return null;
304
+ }
305
+ return value.toFixed(8);
306
+ });
307
+ const getExchangeRateFromSession = useMemoizedFn((sessionData) => {
308
+ if (!sessionData?.line_items?.length) {
309
+ return null;
310
+ }
311
+ for (const item of sessionData.line_items) {
312
+ const rate = item?.exchange_rate;
313
+ if (rate) {
314
+ return rate;
315
+ }
316
+ }
317
+ return null;
318
+ });
319
+ const quoteAutoRetryRef = useRef(false);
320
+ const lastRetryKeyRef = useRef("");
321
+ const buildRetryKey = useMemoizedFn((sessionData) => {
322
+ if (!sessionData?.line_items?.length) {
323
+ return "";
324
+ }
325
+ return sessionData.line_items.map((item) => {
326
+ const priceId = item.price_id || item.price?.id || "";
327
+ const quoteId = item?.quote_id || "";
328
+ const quotedAmount = item?.quoted_amount || "";
329
+ const exchangeRate = item?.exchange_rate || "";
330
+ return `${priceId}:${quoteId}:${quotedAmount}:${exchangeRate}`;
331
+ }).join("|");
332
+ });
333
+ const buildPriceUpdateSummary = useMemoizedFn((sessionData) => {
334
+ if (!paymentCurrency) {
335
+ return { total: "", usd: null, hasQuotes: false, baseCurrency: "USD", totalUnit: null };
336
+ }
337
+ const lineItems = sessionData.line_items || [];
338
+ let baseCurrency = "USD";
339
+ for (const item of lineItems) {
340
+ const price = item.upsell_price || item.price;
341
+ const base = price?.base_currency;
342
+ if (base) {
343
+ baseCurrency = base;
344
+ break;
345
+ }
346
+ }
347
+ const hasQuotes = lineItems.some((item) => item?.quoted_amount && item?.exchange_rate);
348
+ if (!hasQuotes) {
349
+ return { total: "", usd: null, hasQuotes: false, baseCurrency, totalUnit: null };
350
+ }
351
+ let trialInDays = Number(sessionData?.subscription_data?.trial_period_days || 0);
352
+ const trialCurrencyIds = (sessionData?.subscription_data?.trial_currency || "").split(",").map(trim).filter(Boolean);
353
+ if (trialCurrencyIds.length > 0 && paymentCurrencyId && trialCurrencyIds.includes(paymentCurrencyId) === false) {
354
+ trialInDays = 0;
355
+ }
356
+ const { total } = getCheckoutAmount(lineItems, paymentCurrency, trialInDays > 0);
357
+ const discountAmount = new BN(sessionData.total_details?.amount_discount || "0");
358
+ const totalUnit = new BN(total).sub(discountAmount);
359
+ const normalizedTotalUnit = totalUnit.isNeg() ? new BN(0) : totalUnit;
360
+ const totalDisplay = `${formatNumber(
361
+ fromUnitToToken(normalizedTotalUnit.toString(), paymentCurrency.decimal),
362
+ 6
363
+ )} ${paymentCurrency.symbol}`;
364
+ const itemUsdReferences = lineItems.map((item) => {
365
+ const price = item.upsell_price || item.price;
366
+ const baseAmount = price?.base_amount;
367
+ const hasBaseAmount = baseAmount !== void 0 && baseAmount !== null;
368
+ if (hasBaseAmount) {
369
+ return getUsdAmountFromBaseAmount(baseAmount, item.quantity || 0);
370
+ }
371
+ const exchangeRate = item?.exchange_rate;
372
+ const quotedAmount = item?.quoted_amount;
373
+ if (!exchangeRate || !quotedAmount) {
374
+ return null;
375
+ }
376
+ return getUsdAmountFromTokenUnits(new BN(quotedAmount), paymentCurrency.decimal, exchangeRate);
377
+ });
378
+ const usdValues = itemUsdReferences.filter((value) => Boolean(value));
379
+ if (!usdValues.length) {
380
+ return { total: totalDisplay, usd: null, hasQuotes, baseCurrency, totalUnit: normalizedTotalUnit };
381
+ }
382
+ const sumUnit = usdValues.reduce((acc, value) => acc.add(new BN(fromTokenToUnit(value, 8))), new BN(0));
383
+ const totalUsdReference = fromUnitToToken(sumUnit.toString(), 8);
384
+ return {
385
+ total: totalDisplay,
386
+ usd: formatUsdAmount(totalUsdReference, locale),
387
+ hasQuotes,
388
+ baseCurrency,
389
+ totalUnit: normalizedTotalUnit
390
+ };
391
+ });
392
+ const compareTotals = useMemoizedFn((prevSession, nextSession) => {
393
+ const prev = buildPriceUpdateSummary(prevSession);
394
+ const next = buildPriceUpdateSummary(nextSession);
395
+ if (!prev.totalUnit || !next.totalUnit) {
396
+ return { changed: false, prev, next };
397
+ }
398
+ const diff = next.totalUnit.sub(prev.totalUnit).abs();
399
+ const epsilon = new BN(1);
400
+ return { changed: diff.gt(epsilon), prev, next };
401
+ });
402
+ const applyQuoteUpdate = useMemoizedFn(
403
+ (payload, options = {}) => {
404
+ if (!payload?.checkoutSession) {
405
+ return;
406
+ }
407
+ onQuoteUpdated?.({
408
+ checkoutSession: payload.checkoutSession,
409
+ quotes: payload.quotes,
410
+ rateUnavailable: payload.rateUnavailable,
411
+ rateError: payload.rateError
412
+ });
413
+ const { changed, prev, next } = compareTotals(checkoutSession, payload.checkoutSession);
414
+ const previousRate = normalizeExchangeRate(getExchangeRateFromSession(checkoutSession));
415
+ const nextRate = normalizeExchangeRate(getExchangeRateFromSession(payload.checkoutSession));
416
+ const rateChanged = !!(previousRate && nextRate && previousRate !== nextRate);
417
+ const shouldShowModal = (options.forceConfirm || changed) && next.hasQuotes;
418
+ if (shouldShowModal) {
419
+ setPriceUpdateInfo({
420
+ open: true,
421
+ total: next.total,
422
+ usd: next.usd,
423
+ hasQuotes: next.hasQuotes,
424
+ baseCurrency: next.baseCurrency,
425
+ oldTotal: prev.total,
426
+ reason: options.reason || (rateChanged ? "rateChanged" : "recalculated")
427
+ });
428
+ return;
429
+ }
430
+ setPriceUpdateInfo({ open: false });
431
+ const retryKey = buildRetryKey(payload.checkoutSession);
432
+ if (retryKey && retryKey !== lastRetryKeyRef.current) {
433
+ lastRetryKeyRef.current = retryKey;
434
+ quoteAutoRetryRef.current = true;
435
+ }
436
+ }
437
+ );
264
438
  const validateUserInfo = (values) => {
265
439
  if (!values) {
266
440
  return false;
@@ -350,6 +524,16 @@ export default function PaymentForm({
350
524
  const customerPhone = useWatch({ control, name: "customer_phone" });
351
525
  const billingAddress = useWatch({ control, name: "billing_address" });
352
526
  const showForm = session?.user ? state.showEditForm : false;
527
+ useEffect(() => {
528
+ if (!quoteAutoRetryRef.current) {
529
+ return;
530
+ }
531
+ if (state.submitting || state.paying) {
532
+ return;
533
+ }
534
+ quoteAutoRetryRef.current = false;
535
+ onAction();
536
+ }, [state.submitting, state.paying]);
353
537
  const handleConnected = async () => {
354
538
  if (processingRef.current) {
355
539
  return;
@@ -407,7 +591,10 @@ export default function PaymentForm({
407
591
  }
408
592
  });
409
593
  try {
410
- const result = await api.post(`/api/checkout-sessions/${checkoutSession.id}/fast-checkout-confirm`);
594
+ const result = await api.post(`/api/checkout-sessions/${checkoutSession.id}/fast-checkout-confirm`, {});
595
+ if (result.data.paymentIntent) {
596
+ onPaymentIntentUpdate?.(result.data.paymentIntent);
597
+ }
411
598
  if (result.data.fastPaid) {
412
599
  setState({
413
600
  fastCheckoutInfo: null,
@@ -424,6 +611,51 @@ export default function PaymentForm({
424
611
  }
425
612
  } catch (err) {
426
613
  console.error(err);
614
+ const errorCode = err?.response?.data?.code;
615
+ if ([
616
+ "QUOTE_LOCK_EXPIRED",
617
+ "QUOTE_AMOUNT_MISMATCH",
618
+ "QUOTE_EXPIRED_OR_USED",
619
+ "QUOTE_NOT_FOUND",
620
+ "QUOTE_REQUIRED"
621
+ ].includes(errorCode)) {
622
+ try {
623
+ const { data: refreshed } = await api.get(`/api/checkout-sessions/retrieve/${checkoutSession.id}`, {
624
+ params: { forceRefresh: "1" }
625
+ });
626
+ if (refreshed?.checkoutSession) {
627
+ applyQuoteUpdate(refreshed, { reason: "rateChanged" });
628
+ Toast.info(t("payment.checkout.quote.updated.pleaseRetry") || "Price updated, please resubmit");
629
+ }
630
+ } catch (refreshError) {
631
+ console.error(refreshError);
632
+ Toast.error(formatError(refreshError));
633
+ } finally {
634
+ setState({ fastCheckoutInfo: null });
635
+ }
636
+ return;
637
+ }
638
+ if (errorCode === "QUOTE_UPDATED") {
639
+ const payload = err?.response?.data;
640
+ if (payload?.checkoutSession) {
641
+ applyQuoteUpdate(payload);
642
+ }
643
+ setState({ fastCheckoutInfo: null });
644
+ return;
645
+ }
646
+ if (errorCode === "RATE_UNAVAILABLE") {
647
+ const payload = err?.response?.data;
648
+ if (payload?.checkoutSession) {
649
+ onQuoteUpdated?.({
650
+ checkoutSession: payload.checkoutSession,
651
+ quotes: payload.quotes,
652
+ rateUnavailable: payload.rateUnavailable,
653
+ rateError: payload.rateError
654
+ });
655
+ }
656
+ setState({ fastCheckoutInfo: null });
657
+ return;
658
+ }
427
659
  Toast.error(formatError(err));
428
660
  setState({
429
661
  fastCheckoutInfo: null
@@ -436,6 +668,23 @@ export default function PaymentForm({
436
668
  const handleCreditInsufficientClose = () => {
437
669
  setState({ creditInsufficientInfo: null });
438
670
  };
671
+ const handlePriceUpdateConfirm = () => {
672
+ setPriceUpdateInfo({ open: false });
673
+ quoteAutoRetryRef.current = true;
674
+ };
675
+ const handlePriceUpdateCancel = () => {
676
+ setPriceUpdateInfo({ open: false });
677
+ };
678
+ const handlePriceChangeConfirm = () => {
679
+ const formData = state.priceChangeConfirm?.formData;
680
+ setState({ priceChangeConfirm: null });
681
+ if (formData) {
682
+ onFormSubmit(formData);
683
+ }
684
+ };
685
+ const handlePriceChangeCancel = () => {
686
+ setState({ priceChangeConfirm: null });
687
+ };
439
688
  const openConnect = () => {
440
689
  try {
441
690
  if (!["arcblock", "ethereum", "base"].includes(method.type)) {
@@ -475,6 +724,9 @@ export default function PaymentForm({
475
724
  }
476
725
  };
477
726
  const onFormSubmit = async (data) => {
727
+ if (state.submitting) {
728
+ return;
729
+ }
478
730
  const userInfo = session.user;
479
731
  if (!userInfo.sourceAppPid) {
480
732
  const hasVendorConfig = checkoutSession.line_items?.some(
@@ -488,10 +740,18 @@ export default function PaymentForm({
488
740
  setState({ submitting: true });
489
741
  try {
490
742
  let result;
743
+ const previewRate = checkoutSession.line_items?.find((item) => item?.exchange_rate)?.exchange_rate || void 0;
744
+ const payload = {
745
+ ...data,
746
+ // Final Freeze: Include these for new quote creation at submit
747
+ idempotency_key: generateIdempotencyKey(checkoutSession.id, paymentCurrency?.id || ""),
748
+ preview_rate: previewRate || void 0,
749
+ price_confirmed: state.priceChangeConfirm?.formData ? true : void 0
750
+ };
491
751
  if (isDonationMode) {
492
- result = await api.put(`/api/checkout-sessions/${checkoutSession.id}/donate-submit`, data);
752
+ result = await api.put(`/api/checkout-sessions/${checkoutSession.id}/donate-submit`, payload);
493
753
  } else {
494
- result = await api.put(`/api/checkout-sessions/${checkoutSession.id}/submit`, data);
754
+ result = await api.put(`/api/checkout-sessions/${checkoutSession.id}/submit`, payload);
495
755
  }
496
756
  setState({
497
757
  paymentIntent: result.data.paymentIntent,
@@ -500,7 +760,31 @@ export default function PaymentForm({
500
760
  submitting: false,
501
761
  customerLimited: false
502
762
  });
763
+ if (result.data.paymentIntent) {
764
+ onPaymentIntentUpdate?.(result.data.paymentIntent);
765
+ }
503
766
  if (["arcblock", "ethereum", "base"].includes(method.type)) {
767
+ if (result.data.noPaymentRequired) {
768
+ try {
769
+ const confirmResult = await api.post(
770
+ `/api/checkout-sessions/${checkoutSession.id}/fast-checkout-confirm`,
771
+ {}
772
+ );
773
+ if (confirmResult.data.paymentIntent) {
774
+ onPaymentIntentUpdate?.(confirmResult.data.paymentIntent);
775
+ }
776
+ if (confirmResult.data.fastPaid || confirmResult.data.checkoutSession?.status === "complete") {
777
+ setState({ paying: true });
778
+ await handleConnected();
779
+ } else {
780
+ openConnect();
781
+ }
782
+ } catch (confirmErr) {
783
+ console.error("noPaymentRequired confirm failed", confirmErr);
784
+ openConnect();
785
+ }
786
+ return;
787
+ }
504
788
  if (paymentCurrency?.type === "credit") {
505
789
  if (result.data.creditSufficient === true) {
506
790
  setState({
@@ -547,13 +831,95 @@ export default function PaymentForm({
547
831
  } catch (err) {
548
832
  console.error(err);
549
833
  let shouldToast = true;
550
- if (err.response?.data?.code) {
551
- dispatch(`error.${err.response?.data?.code}`);
552
- if (err.response?.data?.code === "UNIFIED_APP_REQUIRED") {
834
+ const errorCode = err.response?.data?.code;
835
+ if (errorCode) {
836
+ if (![
837
+ "QUOTE_UPDATED",
838
+ "RATE_UNAVAILABLE",
839
+ "QUOTE_LOCK_EXPIRED",
840
+ "QUOTE_AMOUNT_MISMATCH",
841
+ "QUOTE_EXPIRED_OR_USED",
842
+ "QUOTE_NOT_FOUND",
843
+ "QUOTE_REQUIRED",
844
+ "QUOTE_MAX_PAYABLE_EXCEEDED"
845
+ ].includes(errorCode)) {
846
+ dispatch(`error.${errorCode}`);
847
+ }
848
+ if ([
849
+ "QUOTE_LOCK_EXPIRED",
850
+ "QUOTE_AMOUNT_MISMATCH",
851
+ "QUOTE_EXPIRED_OR_USED",
852
+ "QUOTE_NOT_FOUND",
853
+ "QUOTE_REQUIRED",
854
+ "QUOTE_MAX_PAYABLE_EXCEEDED",
855
+ "quote_validation_failed"
856
+ ].includes(errorCode)) {
857
+ shouldToast = false;
858
+ try {
859
+ const { data: refreshed } = await api.get(`/api/checkout-sessions/retrieve/${checkoutSession.id}`, {
860
+ params: { forceRefresh: "1" }
861
+ });
862
+ if (refreshed?.checkoutSession) {
863
+ applyQuoteUpdate(refreshed, { reason: "rateChanged" });
864
+ Toast.info(t("payment.checkout.quote.updated.pleaseRetry") || "Price updated, please resubmit");
865
+ }
866
+ } catch (refreshError) {
867
+ console.error(refreshError);
868
+ Toast.error(formatError(refreshError));
869
+ }
870
+ }
871
+ if (errorCode === "QUOTE_UPDATED") {
872
+ shouldToast = false;
873
+ const payload = err.response?.data;
874
+ if (payload?.checkoutSession) {
875
+ applyQuoteUpdate(payload);
876
+ }
877
+ }
878
+ if (errorCode === "RATE_UNAVAILABLE") {
879
+ shouldToast = false;
880
+ const payload = err.response?.data;
881
+ if (payload?.checkoutSession) {
882
+ onQuoteUpdated?.({
883
+ checkoutSession: payload.checkoutSession,
884
+ quotes: payload.quotes,
885
+ rateUnavailable: payload.rateUnavailable,
886
+ rateError: payload.rateError
887
+ });
888
+ }
889
+ }
890
+ if (errorCode === "PRICE_UNAVAILABLE") {
891
+ shouldToast = false;
892
+ Toast.error(
893
+ t("payment.checkout.priceChange.unavailable", {
894
+ fallback: "Unable to fetch exchange rate. Please try again later."
895
+ })
896
+ );
897
+ }
898
+ if (errorCode === "PRICE_UNSTABLE") {
899
+ shouldToast = false;
900
+ Toast.error(
901
+ t("payment.checkout.priceChange.unstable", {
902
+ fallback: "Price is volatile. Please try again later."
903
+ })
904
+ );
905
+ }
906
+ if (errorCode === "PRICE_CHANGED") {
907
+ shouldToast = false;
908
+ const errorData = err.response?.data;
909
+ setState({
910
+ priceChangeConfirm: {
911
+ open: true,
912
+ changePercent: errorData?.change_percent || 0,
913
+ formData: data
914
+ // Save form data for retry
915
+ }
916
+ });
917
+ }
918
+ if (errorCode === "UNIFIED_APP_REQUIRED") {
553
919
  shouldToast = false;
554
920
  Toast.error(t("payment.checkout.vendor.accountRequired"));
555
921
  }
556
- if (err.response.data.code === "CUSTOMER_LIMITED") {
922
+ if (errorCode === "CUSTOMER_LIMITED") {
557
923
  shouldToast = false;
558
924
  setState({ customerLimited: true });
559
925
  }
@@ -572,7 +938,7 @@ export default function PaymentForm({
572
938
  setState({ submitting: false });
573
939
  };
574
940
  const onAction = () => {
575
- if (state.submitting || state.paying) {
941
+ if (state.submitting || state.paying || !canPay) {
576
942
  return;
577
943
  }
578
944
  if (errorRef.current && !isEmpty(errors) && isMobile) {
@@ -610,7 +976,7 @@ export default function PaymentForm({
610
976
  };
611
977
  useEffect(() => {
612
978
  const handleKeyDown = (e) => {
613
- if (e.key === "Enter" && !state.submitting && !state.paying && !state.stripePaying && quantityInventoryStatus && payable) {
979
+ if (e.key === "Enter" && !state.submitting && !state.paying && !state.stripePaying && quantityInventoryStatus && canPay) {
614
980
  onAction();
615
981
  }
616
982
  };
@@ -618,7 +984,7 @@ export default function PaymentForm({
618
984
  return () => {
619
985
  window.removeEventListener("keydown", handleKeyDown);
620
986
  };
621
- }, [state.submitting, state.paying, state.stripePaying, quantityInventoryStatus, payable]);
987
+ }, [state.submitting, state.paying, state.stripePaying, quantityInventoryStatus, canPay]);
622
988
  const balanceLink = getTokenBalanceLink(method, state.fastCheckoutInfo?.payer || "");
623
989
  const FastCheckoutConfirmDialog = state.fastCheckoutInfo && /* @__PURE__ */ jsx(
624
990
  ConfirmDialog,
@@ -627,7 +993,7 @@ export default function PaymentForm({
627
993
  onCancel: handleFastCheckoutCancel,
628
994
  title: state.fastCheckoutInfo.sourceType === "credit" ? t("payment.checkout.fastPay.credit.title") : t("payment.checkout.fastPay.title"),
629
995
  message: state.fastCheckoutInfo.sourceType === "credit" ? /* @__PURE__ */ jsx(Typography, { children: t("payment.checkout.fastPay.credit.meteringSubscriptionMessage", {
630
- available: `${fromUnitToToken(state.fastCheckoutInfo?.balance || "0", paymentCurrency?.decimal || 18).toString()} ${paymentCurrency?.symbol}`
996
+ available: `${formatAmount(state.fastCheckoutInfo?.balance || "0", paymentCurrency?.decimal || 18)} ${paymentCurrency?.symbol}`
631
997
  }) }) : /* @__PURE__ */ jsxs(Stack, { children: [
632
998
  /* @__PURE__ */ jsx(Typography, { children: t("payment.checkout.fastPay.autoPaymentReason") }),
633
999
  /* @__PURE__ */ jsx(Divider, { sx: { mt: 1.5, mb: 1.5 } }),
@@ -690,7 +1056,7 @@ export default function PaymentForm({
690
1056
  }
691
1057
  ),
692
1058
  /* @__PURE__ */ jsxs(Typography, { children: [
693
- fromUnitToToken(state.fastCheckoutInfo.amount, paymentCurrency?.decimal || 18).toString(),
1059
+ formatAmount(state.fastCheckoutInfo.amount, paymentCurrency?.decimal || 18),
694
1060
  " ",
695
1061
  paymentCurrency?.symbol
696
1062
  ] })
@@ -713,6 +1079,33 @@ export default function PaymentForm({
713
1079
  confirm: t("common.confirm")
714
1080
  }
715
1081
  );
1082
+ const PriceUpdatedDialog = priceUpdateInfo.open && /* @__PURE__ */ jsx(
1083
+ ConfirmDialog,
1084
+ {
1085
+ onConfirm: handlePriceUpdateConfirm,
1086
+ onCancel: handlePriceUpdateCancel,
1087
+ title: t("payment.checkout.quote.priceUpdatedTitle"),
1088
+ message: /* @__PURE__ */ jsxs(Stack, { spacing: 1, children: [
1089
+ /* @__PURE__ */ jsx(Typography, { children: t(
1090
+ priceUpdateInfo.reason === "rateChanged" ? "payment.checkout.quote.priceUpdatedDescriptionRate" : "payment.checkout.quote.priceUpdatedDescriptionRecalc"
1091
+ ) }),
1092
+ priceUpdateInfo.hasQuotes && /* @__PURE__ */ jsxs(Stack, { spacing: 0.25, children: [
1093
+ /* @__PURE__ */ jsx(Typography, { sx: { color: "text.secondary", fontSize: "0.7875rem" }, children: t("payment.checkout.quote.priceUpdatedNewTotalLabel") }),
1094
+ /* @__PURE__ */ jsx(Typography, { sx: { fontWeight: 600 }, children: priceUpdateInfo.total }),
1095
+ priceUpdateInfo.usd && /* @__PURE__ */ jsxs(Typography, { sx: { color: "text.secondary" }, children: [
1096
+ "\u2248 ",
1097
+ priceUpdateInfo.usd,
1098
+ " ",
1099
+ priceUpdateInfo.baseCurrency
1100
+ ] }),
1101
+ priceUpdateInfo.oldTotal && /* @__PURE__ */ jsx(Typography, { sx: { color: "text.secondary", fontSize: "0.75rem" }, children: t("payment.checkout.quote.priceUpdatedOldTotal", { total: priceUpdateInfo.oldTotal }) })
1102
+ ] })
1103
+ ] }),
1104
+ confirm: t("payment.checkout.quote.priceUpdatedConfirm"),
1105
+ cancel: t("common.cancel"),
1106
+ color: "primary"
1107
+ }
1108
+ );
716
1109
  const getRedirectUrl = () => {
717
1110
  if (searchParams.redirect) {
718
1111
  return decodeURIComponent(searchParams.redirect);
@@ -735,13 +1128,13 @@ export default function PaymentForm({
735
1128
  size: "large",
736
1129
  className: "cko-submit-button",
737
1130
  onClick: () => {
738
- if (state.submitting || state.paying) {
1131
+ if (state.submitting || state.paying || !canPay) {
739
1132
  return;
740
1133
  }
741
1134
  onAction();
742
1135
  },
743
1136
  fullWidth: true,
744
- disabled: state.stripePaying || !quantityInventoryStatus || !payable,
1137
+ disabled: state.stripePaying || !quantityInventoryStatus || !canPay,
745
1138
  children: [
746
1139
  (state.submitting || state.paying) && /* @__PURE__ */ jsx(CircularProgress, { size: 16, sx: { mr: 0.5, color: "primary.contrastText" } }),
747
1140
  state.submitting || state.paying ? t("payment.checkout.processing") : buttonText
@@ -778,7 +1171,18 @@ export default function PaymentForm({
778
1171
  }
779
1172
  ),
780
1173
  FastCheckoutConfirmDialog,
781
- CreditInsufficientDialog
1174
+ CreditInsufficientDialog,
1175
+ PriceUpdatedDialog,
1176
+ state.priceChangeConfirm?.open && /* @__PURE__ */ jsx(
1177
+ PriceChangeConfirm,
1178
+ {
1179
+ open: true,
1180
+ changePercent: state.priceChangeConfirm.changePercent,
1181
+ onConfirm: handlePriceChangeConfirm,
1182
+ onCancel: handlePriceChangeCancel,
1183
+ loading: state.submitting
1184
+ }
1185
+ )
782
1186
  ] });
783
1187
  }
784
1188
  return /* @__PURE__ */ jsxs(Fragment, { children: [
@@ -819,10 +1223,27 @@ export default function PaymentForm({
819
1223
  {
820
1224
  value: field.value,
821
1225
  currencies,
822
- onChange: (id, methodId) => {
1226
+ onChange: async (id, methodId) => {
1227
+ const oldCurrencyId = field.value;
823
1228
  field.onChange(id);
824
1229
  setValue("payment_method", methodId);
825
1230
  saveCurrencyPreference(id, session?.user?.did);
1231
+ if (oldCurrencyId && oldCurrencyId !== id) {
1232
+ try {
1233
+ const { data } = await api.put(
1234
+ `/api/checkout-sessions/${checkoutSession.id}/switch-currency`,
1235
+ {
1236
+ currency_id: id,
1237
+ payment_method_id: methodId
1238
+ }
1239
+ );
1240
+ if (data.currency_changed && onQuoteUpdated) {
1241
+ onQuoteUpdated({ checkoutSession: data, quotes: data.quotes });
1242
+ }
1243
+ } catch (err) {
1244
+ console.error("Failed to switch currency:", err);
1245
+ }
1246
+ }
826
1247
  }
827
1248
  }
828
1249
  )
@@ -978,7 +1399,7 @@ export default function PaymentForm({
978
1399
  },
979
1400
  fullWidth: true,
980
1401
  loading: state.submitting || state.paying,
981
- disabled: state.stripePaying || !quantityInventoryStatus || !payable,
1402
+ disabled: state.stripePaying || !quantityInventoryStatus || !canPay,
982
1403
  children: state.submitting || state.paying ? t("payment.checkout.processing") : buttonText
983
1404
  }
984
1405
  ) }),
@@ -1015,6 +1436,17 @@ export default function PaymentForm({
1015
1436
  }
1016
1437
  ),
1017
1438
  FastCheckoutConfirmDialog,
1018
- CreditInsufficientDialog
1439
+ CreditInsufficientDialog,
1440
+ PriceUpdatedDialog,
1441
+ state.priceChangeConfirm?.open && /* @__PURE__ */ jsx(
1442
+ PriceChangeConfirm,
1443
+ {
1444
+ open: true,
1445
+ changePercent: state.priceChangeConfirm.changePercent,
1446
+ onConfirm: handlePriceChangeConfirm,
1447
+ onCancel: handlePriceChangeCancel,
1448
+ loading: state.submitting
1449
+ }
1450
+ )
1019
1451
  ] });
1020
1452
  }
@@ -6,7 +6,7 @@ type Props = CheckoutContext & CheckoutCallbacks & {
6
6
  error?: any;
7
7
  showCheckoutSummary?: boolean;
8
8
  };
9
- export default function Payment({ checkoutSession, paymentMethods, paymentIntent, paymentLink, customer, completed, error, mode, onPaid, onError, onChange, goBack, action, showCheckoutSummary, }: Props): import("react").JSX.Element;
9
+ export default function Payment({ checkoutSession, paymentMethods, paymentIntent, paymentLink, customer, completed, error, mode, onPaid, onError, onChange, goBack, action, showCheckoutSummary, quotes, rateUnavailable, rateError, }: Props): import("react").JSX.Element;
10
10
  type RootProps = {
11
11
  mode: LiteralUnion<'standalone' | 'inline' | 'popup', string>;
12
12
  } & BoxProps;