@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
package/src/libs/util.ts CHANGED
@@ -17,7 +17,7 @@ import type {
17
17
  TSubscriptionExpanded,
18
18
  TSubscriptionItemExpanded,
19
19
  } from '@blocklet/payment-types';
20
- import { BN, fromUnitToToken } from '@ocap/util';
20
+ import { BN, fromUnitToToken, fromTokenToUnit } from '@ocap/util';
21
21
  import omit from 'lodash/omit';
22
22
  import trimEnd from 'lodash/trimEnd';
23
23
  import numbro from 'numbro';
@@ -265,6 +265,136 @@ export function formatNumber(
265
265
  return right ? [left, trimEnd(right, '0')].filter(Boolean).join('.') : left;
266
266
  }
267
267
 
268
+ export function formatDynamicPrice(
269
+ n: number | string,
270
+ isDynamic: boolean,
271
+ precision: number = 6,
272
+ trim: boolean = true,
273
+ thousandSeparated: boolean = true
274
+ ) {
275
+ if (!isDynamic) {
276
+ return formatNumber(n, precision, trim, thousandSeparated);
277
+ }
278
+ const num = Number(n);
279
+ if (!Number.isFinite(num)) {
280
+ return formatNumber(n, precision, trim, thousandSeparated);
281
+ }
282
+ const abs = Math.abs(num);
283
+ const targetPrecision = abs > 0 && abs < 0.01 ? precision : 2;
284
+ return formatNumber(n, targetPrecision, trim, thousandSeparated);
285
+ }
286
+
287
+ const USD_DECIMALS = 8;
288
+
289
+ export function getUsdAmountFromBaseAmount(
290
+ amount: string | number | undefined,
291
+ quantity: number,
292
+ scale = BASE_AMOUNT_SCALE
293
+ ): string | null {
294
+ if (amount === undefined || amount === null || !quantity || quantity <= 0) {
295
+ return null;
296
+ }
297
+ const scaled = toScaledBaseAmount(amount, quantity, scale);
298
+ return formatScaledAmount(scaled, scale);
299
+ }
300
+
301
+ export function getUsdAmountFromTokenUnits(
302
+ tokenAmount: BN | string,
303
+ tokenDecimals: number,
304
+ exchangeRate?: string | null
305
+ ): string | null {
306
+ if (!exchangeRate && exchangeRate !== '0') {
307
+ return null;
308
+ }
309
+ if (tokenDecimals === undefined || tokenDecimals === null) {
310
+ return null;
311
+ }
312
+
313
+ try {
314
+ // exchangeRate is in token format (e.g., "0.25535177995959574"), may have more than 8 decimal places
315
+ // tokenAmount is in unit format (smallest unit)
316
+ // We need to calculate: (tokenAmount / 10^tokenDecimals) * exchangeRate
317
+ // To avoid precision loss, we use: (tokenAmount * exchangeRate) / 10^tokenDecimals
318
+ // But exchangeRate is a decimal string, so we need to handle it carefully
319
+
320
+ const amountBN = tokenAmount instanceof BN ? tokenAmount : new BN(tokenAmount || '0');
321
+ const tokenScale = new BN(10).pow(new BN(tokenDecimals));
322
+ if (tokenScale.isZero()) {
323
+ return null;
324
+ }
325
+
326
+ // Convert exchangeRate (token format) to BN with proper precision
327
+ // exchangeRate may have more than 8 decimal places, so we need to preserve all precision
328
+ // We'll use a higher precision scale for the rate calculation
329
+ const rateBN = new BN(exchangeRate.replace('.', ''));
330
+ const rateDecimalPlaces = exchangeRate.includes('.') ? exchangeRate.split('.')[1]?.length || 0 : 0;
331
+ const rateScale = new BN(10).pow(new BN(rateDecimalPlaces));
332
+
333
+ // Calculate: (amountBN * rateBN * USD_PRECISION_SCALE) / (tokenScale * rateScale)
334
+ // The USD_PRECISION_SCALE keeps enough trailing digits so the final conversion via fromUnitToToken
335
+ // produces a value with 8 decimal places.
336
+ const usdPrecisionScale = new BN(10).pow(new BN(USD_DECIMALS));
337
+ const usdUnit = amountBN.mul(rateBN).mul(usdPrecisionScale).div(tokenScale.mul(rateScale));
338
+
339
+ // Convert from unit format to token format with 8 decimals
340
+ return fromUnitToToken(usdUnit.toString(), USD_DECIMALS);
341
+ } catch {
342
+ return null;
343
+ }
344
+ }
345
+
346
+ export function formatUsdAmount(amount: string | null, locale: string = 'en'): string | null {
347
+ if (!amount && amount !== '0') {
348
+ return null;
349
+ }
350
+ const num = Number(amount);
351
+ if (!Number.isFinite(num)) {
352
+ return null;
353
+ }
354
+ return new Intl.NumberFormat(locale, {
355
+ minimumFractionDigits: 2,
356
+ maximumFractionDigits: 2,
357
+ }).format(num);
358
+ }
359
+
360
+ export function formatExchangeRate(amount: string | null): string | null {
361
+ if (!amount && amount !== '0') {
362
+ return null;
363
+ }
364
+ const value = String(amount);
365
+ const num = Number(value);
366
+ if (!Number.isFinite(num)) {
367
+ return null;
368
+ }
369
+ return value;
370
+ }
371
+
372
+ /**
373
+ * Format exchange rate with currency symbol for display
374
+ * @param rate - The exchange rate value
375
+ * @param currency - The currency code (default: 'USD')
376
+ * @param decimals - Number of decimal places (default: 2, use 4 for live exchange rates)
377
+ * @returns Formatted string like "$0.12" for USD, or "0.12 EUR" for other currencies
378
+ */
379
+ export function formatExchangeRateDisplay(
380
+ rate: string | number | null | undefined,
381
+ currency: string = 'USD',
382
+ decimals: number = 2
383
+ ): string | null {
384
+ if (rate === null || rate === undefined) {
385
+ return null;
386
+ }
387
+ const num = Number(rate);
388
+ if (!Number.isFinite(num)) {
389
+ return null;
390
+ }
391
+ const formattedValue = num.toFixed(decimals);
392
+ if (currency === 'USD') {
393
+ return `$${formattedValue}`;
394
+ }
395
+ return `${formattedValue} ${currency}`;
396
+ }
397
+
268
398
  export const formatPrice = (
269
399
  price: TPrice,
270
400
  currency: TPaymentCurrency,
@@ -280,6 +410,33 @@ export const formatPrice = (
280
410
  return `Custom (${currency.symbol})`;
281
411
  }
282
412
 
413
+ // For dynamic pricing, display USD reference price
414
+ if (
415
+ (price as any).pricing_type === 'dynamic' &&
416
+ (price as any).base_amount &&
417
+ (price as any).base_currency === 'USD'
418
+ ) {
419
+ const baseAmount = (price as any).base_amount;
420
+ const usdAmount = Number(baseAmount) * quantity;
421
+ const formattedUsd = formatUsdAmount(usdAmount.toString(), locale);
422
+
423
+ if (price?.type === 'recurring' && price.recurring) {
424
+ const recurring = formatRecurring(price.recurring, false, 'slash', locale);
425
+
426
+ if (unit_label) {
427
+ return `$${formattedUsd} / ${unit_label} ${recurring}`;
428
+ }
429
+ if (price.recurring.usage_type === 'metered') {
430
+ return `$${formattedUsd} / unit ${recurring}`;
431
+ }
432
+
433
+ return `$${formattedUsd} ${recurring}`;
434
+ }
435
+
436
+ return `$${formattedUsd}`;
437
+ }
438
+
439
+ // For fixed pricing, display token amount
283
440
  const unit = getPriceUintAmountByCurrency(price, currency);
284
441
  const amount = bn
285
442
  ? fromUnitToToken(new BN(unit).mul(new BN(quantity)), currency.decimal).toString()
@@ -305,11 +462,38 @@ export const formatPriceAmount = (
305
462
  currency: TPaymentCurrency,
306
463
  unit_label?: string,
307
464
  quantity: number = 1,
308
- bn: boolean = true
465
+ bn: boolean = true,
466
+ locale: string = 'en'
309
467
  ) => {
310
468
  if (!currency) {
311
469
  return '';
312
470
  }
471
+
472
+ // For dynamic pricing, display USD reference price
473
+ if (
474
+ (price as any).pricing_type === 'dynamic' &&
475
+ (price as any).base_amount &&
476
+ (price as any).base_currency === 'USD'
477
+ ) {
478
+ const baseAmount = (price as any).base_amount;
479
+ const usdAmount = Number(baseAmount) * quantity;
480
+ const formattedUsd = formatUsdAmount(usdAmount.toString(), locale);
481
+
482
+ if (price?.type === 'recurring' && price.recurring) {
483
+ if (unit_label) {
484
+ return `$${formattedUsd} / ${unit_label}`;
485
+ }
486
+ if (price.recurring.usage_type === 'metered') {
487
+ return `$${formattedUsd} / unit`;
488
+ }
489
+
490
+ return `$${formattedUsd}`;
491
+ }
492
+
493
+ return `$${formattedUsd}`;
494
+ }
495
+
496
+ // For fixed pricing, display token amount
313
497
  const unit = getPriceUintAmountByCurrency(price, currency);
314
498
  const amount = bn
315
499
  ? fromUnitToToken(new BN(unit).mul(new BN(quantity)), currency.decimal).toString()
@@ -413,26 +597,167 @@ export function getPriceCurrencyOptions(price: TPrice): PriceCurrency[] {
413
597
  ];
414
598
  }
415
599
 
600
+ const BASE_AMOUNT_SCALE = 8;
601
+
602
+ function formatScaledAmount(value: BN, scale = BASE_AMOUNT_SCALE) {
603
+ const isNegative = value.isNeg();
604
+ const absValue = value.abs();
605
+ const str = absValue.toString().padStart(scale + 1, '0');
606
+ const integerPart = str.slice(0, str.length - scale) || '0';
607
+ const fraction = str.slice(-scale).replace(/0+$/, '');
608
+ const formatted = fraction ? `${integerPart}.${fraction}` : integerPart;
609
+ return isNegative ? `-${formatted}` : formatted;
610
+ }
611
+
612
+ function toScaledBaseAmount(amount: string | number | undefined, quantity: number, scale = BASE_AMOUNT_SCALE) {
613
+ if (!amount || !quantity) {
614
+ return new BN(0);
615
+ }
616
+ const normalized = String(amount);
617
+ const [integer = '0', fraction = ''] = normalized.split('.');
618
+ const frac = `${fraction}`.padEnd(scale, '0').slice(0, scale);
619
+ const digits = `${integer.replace('-', '')}${frac}` || '0';
620
+ const scaled = new BN(digits).mul(new BN(quantity));
621
+ return normalized.startsWith('-') ? scaled.neg() : scaled;
622
+ }
623
+
624
+ export function getLineItemAmounts(
625
+ item: TLineItemExpanded,
626
+ currency: TPaymentCurrency,
627
+ { useUpsell = true, exchangeRate = null }: { useUpsell?: boolean; exchangeRate?: string | null } = {}
628
+ ): { unitAmount: BN; totalAmount: BN; isDynamicQuote: boolean } {
629
+ if (!currency) {
630
+ return { unitAmount: new BN(0), totalAmount: new BN(0), isDynamicQuote: false };
631
+ }
632
+
633
+ const price = (useUpsell ? item.upsell_price || item.price : item.price) as any;
634
+ const quantity = new BN(item.quantity || 0);
635
+ const quoteAmount = (item as any)?.quoted_amount;
636
+ const quoteCurrencyId = (item as any)?.quote_currency_id;
637
+ const isDynamicPrice = price?.pricing_type === 'dynamic';
638
+
639
+ // For dynamic pricing, only use quoted_amount if the quote was created for the current currency
640
+ // This prevents using a quote with wrong decimal precision when user switches currencies
641
+ // e.g., ABT quote (decimal=18) should not be used with USD (decimal=2)
642
+ const isQuoteValidForCurrency = isDynamicPrice && quoteAmount && quoteCurrencyId === currency.id;
643
+
644
+ if (isQuoteValidForCurrency) {
645
+ const totalAmount = new BN(quoteAmount || '0');
646
+ const unitAmount = quantity.gt(new BN(0)) ? totalAmount.add(quantity).sub(new BN(1)).div(quantity) : new BN(0);
647
+
648
+ return { unitAmount, totalAmount, isDynamicQuote: true };
649
+ }
650
+
651
+ // For dynamic pricing without valid quote, calculate from base_amount and exchange rate
652
+ if (isDynamicPrice && exchangeRate && price?.base_amount) {
653
+ const rate = Number(exchangeRate);
654
+ if (rate > 0 && Number.isFinite(rate)) {
655
+ // base_amount is stored in dollar format (e.g., "1.00" = $1.00)
656
+ const baseAmountUsd = Number(price.base_amount);
657
+ if (baseAmountUsd > 0 && Number.isFinite(baseAmountUsd)) {
658
+ // Calculate token amount: USD / rate
659
+ const tokenAmount = baseAmountUsd / rate;
660
+ const unitAmount = fromTokenToUnit(tokenAmount.toFixed(currency.decimal || 8), currency.decimal || 8);
661
+ return {
662
+ unitAmount,
663
+ totalAmount: unitAmount.mul(quantity),
664
+ isDynamicQuote: false,
665
+ };
666
+ }
667
+ }
668
+ }
669
+
670
+ // For all other cases (non-dynamic, or quote not valid for current currency), use currency_options
671
+ const unitAmount = new BN(getPriceUintAmountByCurrency(price, currency));
672
+ return {
673
+ unitAmount,
674
+ totalAmount: unitAmount.mul(quantity),
675
+ isDynamicQuote: false,
676
+ };
677
+ }
678
+
679
+ export type QuoteLockInfo = {
680
+ baseAmount: string;
681
+ baseCurrency: string;
682
+ tokenAmount: string;
683
+ tokenSymbol: string;
684
+ expiresAt: number | null;
685
+ };
686
+
687
+ export function getQuoteLockInfo(items: TLineItemExpanded[], currency?: TPaymentCurrency | null): QuoteLockInfo | null {
688
+ if (!items?.length || !currency) {
689
+ return null;
690
+ }
691
+
692
+ const dynamicItems = items.filter((item) => {
693
+ const price = (item.upsell_price || item.price) as any;
694
+ return price?.pricing_type === 'dynamic' && (item as any)?.quoted_amount;
695
+ });
696
+ if (!dynamicItems.length) {
697
+ return null;
698
+ }
699
+
700
+ let totalBaseAmount = new BN(0);
701
+ let totalTokenAmount = new BN(0);
702
+ let expiresAt: number | null = null;
703
+
704
+ dynamicItems.forEach((item) => {
705
+ const price = (item.upsell_price || item.price) as any;
706
+ const quoteAmount = new BN((item as any)?.quoted_amount || '0');
707
+ totalTokenAmount = totalTokenAmount.add(quoteAmount);
708
+
709
+ const baseAmount = price?.base_amount;
710
+ if (baseAmount) {
711
+ totalBaseAmount = totalBaseAmount.add(toScaledBaseAmount(baseAmount, item.quantity));
712
+ }
713
+
714
+ if ((item as any)?.expires_at) {
715
+ expiresAt = expiresAt === null ? (item as any)?.expires_at : Math.min(expiresAt, (item as any)?.expires_at);
716
+ }
717
+ });
718
+
719
+ return {
720
+ baseAmount: formatScaledAmount(totalBaseAmount, BASE_AMOUNT_SCALE),
721
+ baseCurrency: ((dynamicItems[0]?.upsell_price || dynamicItems[0]?.price) as any)?.base_currency || 'USD',
722
+ tokenAmount: fromUnitToToken(totalTokenAmount, currency.decimal).toString(),
723
+ tokenSymbol: currency.symbol,
724
+ expiresAt,
725
+ };
726
+ }
727
+
416
728
  export function formatLineItemPricing(
417
729
  item: TLineItemExpanded,
418
730
  currency: TPaymentCurrency,
419
- { trialEnd, trialInDays }: { trialEnd: number; trialInDays: number },
731
+ {
732
+ trialEnd,
733
+ trialInDays,
734
+ exchangeRate = null,
735
+ }: { trialEnd: number; trialInDays: number; exchangeRate?: string | null },
420
736
  locale: string = 'en'
421
737
  ): { primary: string; secondary?: string; quantity: string } {
422
738
  if (!currency) {
423
739
  return { primary: '', secondary: '', quantity: '' };
424
740
  }
425
741
  const price = item.upsell_price || item.price;
742
+ if (!price) {
743
+ return { primary: '', secondary: '', quantity: '' };
744
+ }
426
745
 
427
746
  let quantity = t('common.qty', locale, { count: item.quantity });
428
747
  if (price.recurring?.usage_type === 'metered' || +item.quantity === 1) {
429
748
  quantity = '';
430
749
  }
431
- const unitValue = new BN(getPriceUintAmountByCurrency(price, currency));
750
+ const { unitAmount, totalAmount } = getLineItemAmounts(item, currency, { exchangeRate });
751
+ const isDynamic = (price as any)?.pricing_type === 'dynamic';
752
+
753
+ const formatLineItemAmount = (bn: BN) => {
754
+ const value = fromUnitToToken(bn, currency.decimal);
755
+ return formatDynamicPrice(value, isDynamic, 6);
756
+ };
432
757
 
433
- const total = `${fromUnitToToken(unitValue.mul(new BN(item.quantity)), currency.decimal)} ${currency.symbol}`;
758
+ const total = `${formatLineItemAmount(totalAmount)} ${currency.symbol}`;
434
759
 
435
- const unit = `${fromUnitToToken(unitValue, currency.decimal)} ${currency.symbol}`;
760
+ const unit = `${formatLineItemAmount(unitAmount)} ${currency.symbol}`;
436
761
 
437
762
  const trialResult = getFreeTrialTime({ trialInDays, trialEnd }, locale);
438
763
 
@@ -563,9 +888,16 @@ export function getCheckoutAmount(
563
888
  items: TLineItemExpanded[],
564
889
  currency: TPaymentCurrency,
565
890
  trialing = false,
566
- upsell = true
891
+ upsell = true,
892
+ { exchangeRate = null }: { exchangeRate?: string | null } = {}
567
893
  ) {
568
- if (items.find((x) => (x.upsell_price || x.price).custom_unit_amount) && items.length > 1) {
894
+ if (
895
+ items.find((x) => {
896
+ const price = upsell ? x.upsell_price || x.price : x.price;
897
+ return price?.custom_unit_amount;
898
+ }) &&
899
+ items.length > 1
900
+ ) {
569
901
  throw new Error('Multiple items with custom unit amount are not supported');
570
902
  }
571
903
 
@@ -576,20 +908,22 @@ export function getCheckoutAmount(
576
908
  return price != null;
577
909
  })
578
910
  .reduce((acc, x) => {
579
- if (x.custom_amount) {
911
+ // For custom_amount, we need to check if it was created for the current currency
912
+ // custom_amount can be set from: 1) quoted_amount when a quote is applied, or 2) user input for custom pricing
913
+ // If quote_currency_id exists and doesn't match current currency, we should NOT use custom_amount
914
+ // because the quote was created for a different currency (e.g., ABT quote shouldn't be used for USD)
915
+ const quoteCurrencyId = (x as any)?.quote_currency_id;
916
+ const isQuoteForDifferentCurrency = quoteCurrencyId && quoteCurrencyId !== currency.id;
917
+
918
+ if (x.custom_amount && !isQuoteForDifferentCurrency) {
580
919
  return acc.add(new BN(x.custom_amount));
581
920
  }
582
921
 
583
922
  const price = upsell ? x.upsell_price || x.price : x.price;
584
- const unitPrice = getPriceUintAmountByCurrency(price, currency);
585
- if (price.custom_unit_amount) {
586
- if (unitPrice) {
587
- return acc.add(new BN(unitPrice).mul(new BN(x.quantity)));
588
- }
589
- }
923
+ const { totalAmount } = getLineItemAmounts(x, currency, { useUpsell: upsell, exchangeRate });
590
924
 
591
925
  if (price?.type === 'recurring') {
592
- renew = renew.add(new BN(unitPrice).mul(new BN(x.quantity)));
926
+ renew = renew.add(totalAmount);
593
927
 
594
928
  if (trialing) {
595
929
  return acc;
@@ -599,11 +933,18 @@ export function getCheckoutAmount(
599
933
  }
600
934
  }
601
935
 
602
- return acc.add(new BN(unitPrice).mul(new BN(x.quantity)));
936
+ return acc.add(totalAmount);
603
937
  }, new BN(0))
604
938
  .toString();
605
939
 
606
- return { subtotal: total, total, renew: renew.toString(), discount: '0', shipping: '0', tax: '0' };
940
+ return {
941
+ subtotal: total,
942
+ total,
943
+ renew: formatDynamicPrice(renew.toString(), !!exchangeRate),
944
+ discount: '0',
945
+ shipping: '0',
946
+ tax: '0',
947
+ };
607
948
  }
608
949
 
609
950
  export function getRecurringPeriod(recurring: PriceRecurring) {
@@ -631,7 +972,7 @@ export function formatUpsellSaving(items: TLineItemExpanded[], currency: TPaymen
631
972
  if (items[0]?.upsell_price_id) {
632
973
  return '0';
633
974
  }
634
- if (!items[0]?.price.upsell?.upsells_to) {
975
+ if (!items[0]?.price?.upsell?.upsells_to) {
635
976
  return '0';
636
977
  }
637
978
 
@@ -673,6 +1014,18 @@ export function formatMeteredThen(
673
1014
  return t('payment.checkout.then', locale, { subscription, recurring });
674
1015
  }
675
1016
 
1017
+ export function formatThenValue(
1018
+ subscription: string,
1019
+ recurring: string,
1020
+ hasMetered: boolean,
1021
+ locale: string = 'en'
1022
+ ): string {
1023
+ if (hasMetered) {
1024
+ return t('payment.checkout.metered', locale, { recurring });
1025
+ }
1026
+ return [subscription, recurring].filter(Boolean).join(' ');
1027
+ }
1028
+
676
1029
  export function formatPriceDisplay(
677
1030
  { amount, then, actualAmount, showThen }: { amount: string; then?: string; actualAmount: string; showThen?: boolean },
678
1031
  recurring: string,
@@ -742,18 +1095,20 @@ export function formatCheckoutHeadlines(
742
1095
  items: TLineItemExpanded[],
743
1096
  currency: TPaymentCurrency,
744
1097
  { trialInDays, trialEnd }: { trialInDays: number; trialEnd: number },
745
- locale: string = 'en'
1098
+ locale: string = 'en',
1099
+ { exchangeRate = null }: { exchangeRate?: string | null } = {}
746
1100
  ): {
747
1101
  action: string;
748
1102
  amount: string;
749
1103
  then?: string;
1104
+ thenValue?: string;
750
1105
  secondary?: string;
751
1106
  showThen?: boolean;
752
1107
  actualAmount: string;
753
1108
  priceDisplay: string;
754
1109
  } {
755
1110
  const brand = getStatementDescriptor(items);
756
- const { total } = getCheckoutAmount(items, currency, trialInDays > 0);
1111
+ const { total } = getCheckoutAmount(items, currency, trialInDays > 0, true, { exchangeRate });
757
1112
  const actualAmount = fromUnitToToken(total, currency.decimal);
758
1113
  const amount = `${fromUnitToToken(total, currency.decimal)} ${currency.symbol}`;
759
1114
  const trialResult = getFreeTrialTime({ trialInDays, trialEnd }, locale);
@@ -769,10 +1124,10 @@ export function formatCheckoutHeadlines(
769
1124
  };
770
1125
  }
771
1126
 
772
- const { name } = items[0]?.price.product || { name: '' };
1127
+ const { name } = items[0]?.price?.product || { name: '' };
773
1128
 
774
1129
  // all one time
775
- if (items.every((x) => x.price.type === 'one_time')) {
1130
+ if (items.every((x) => x.price?.type === 'one_time')) {
776
1131
  const action = t('payment.checkout.pay', locale, { payee: brand });
777
1132
  if (items.length > 1) {
778
1133
  return { action, amount, actualAmount, priceDisplay: amount };
@@ -781,31 +1136,34 @@ export function formatCheckoutHeadlines(
781
1136
  return { action, amount, then: '', actualAmount, priceDisplay: amount };
782
1137
  }
783
1138
 
784
- const item = items.find((x) => x.price.type === 'recurring');
1139
+ const item = items.find((x) => x.price?.type === 'recurring');
785
1140
  const recurring = formatRecurring(
786
1141
  (item?.upsell_price || item?.price)?.recurring as PriceRecurring,
787
1142
  false,
788
1143
  'per',
789
1144
  locale
790
1145
  );
791
- const hasMetered = items.some((x) => x.price.type === 'recurring' && x.price.recurring?.usage_type === 'metered');
1146
+
1147
+ const hasMetered = items.some((x) => x.price?.type === 'recurring' && x.price?.recurring?.usage_type === 'metered');
792
1148
  const differentRecurring = hasMultipleRecurringIntervals(items);
793
1149
  // all recurring
794
- if (items.every((x) => x.price.type === 'recurring')) {
1150
+ if (items.every((x) => x.price?.type === 'recurring')) {
795
1151
  // check if there has different recurring price
796
1152
  const subscription = [
797
1153
  hasMetered ? t('payment.checkout.least', locale) : '',
798
- fromUnitToToken(
799
- items.reduce((acc, x) => {
800
- if (x.price.recurring?.usage_type === 'metered') {
801
- return acc;
802
- }
803
-
804
- return acc.add(
805
- new BN(getPriceUintAmountByCurrency(x.upsell_price || x.price, currency)).mul(new BN(x.quantity))
806
- );
807
- }, new BN(0)),
808
- currency.decimal
1154
+ formatDynamicPrice(
1155
+ fromUnitToToken(
1156
+ items.reduce((acc, x) => {
1157
+ const price = (x.upsell_price || x.price) as any;
1158
+ if (price?.recurring?.usage_type === 'metered') {
1159
+ return acc;
1160
+ }
1161
+
1162
+ return acc.add(getLineItemAmounts(x, currency, { exchangeRate }).totalAmount);
1163
+ }, new BN(0)),
1164
+ currency.decimal
1165
+ ),
1166
+ !!exchangeRate
809
1167
  ),
810
1168
  currency.symbol,
811
1169
  ]
@@ -813,91 +1171,148 @@ export function formatCheckoutHeadlines(
813
1171
  .join(' ');
814
1172
  if (items.length > 1) {
815
1173
  if (trialResult.count > 0) {
1174
+ const thenDisplay = formatMeteredThen(
1175
+ subscription,
1176
+ recurring,
1177
+ hasMetered && Number(subscription) === 0,
1178
+ locale
1179
+ );
1180
+ const thenValue = formatThenValue(subscription, recurring, hasMetered && Number(subscription) === 0, locale);
816
1181
  const result = {
817
1182
  action: t('payment.checkout.try2', locale, { name, count: items.length - 1 }),
818
1183
  amount: t('payment.checkout.free', locale, { count: trialResult.count, interval: trialResult.interval }),
819
- then: formatMeteredThen(subscription, recurring, hasMetered && Number(subscription) === 0, locale),
1184
+ then: thenDisplay,
1185
+ thenValue,
820
1186
  showThen: true,
821
1187
  actualAmount: '0',
822
1188
  };
823
1189
  return {
824
1190
  ...result,
825
- priceDisplay: formatPriceDisplay(result, recurring, hasMetered, locale),
1191
+ priceDisplay: formatPriceDisplay(
1192
+ { amount: result.amount, then: thenDisplay, actualAmount: result.actualAmount, showThen: result.showThen },
1193
+ recurring,
1194
+ hasMetered,
1195
+ locale
1196
+ ),
826
1197
  };
827
1198
  }
1199
+ const thenDisplay = hasMetered ? t('payment.checkout.meteredThen', locale, { recurring }) : recurring;
1200
+ const thenValue = hasMetered ? t('payment.checkout.metered', locale, { recurring }) : recurring;
828
1201
  const result = {
829
1202
  action: t('payment.checkout.sub2', locale, { name, count: items.length - 1 }),
830
1203
  amount,
831
- then: hasMetered ? t('payment.checkout.meteredThen', locale, { recurring }) : recurring,
1204
+ then: thenDisplay,
1205
+ thenValue,
832
1206
  showThen: hasMetered,
833
1207
  actualAmount,
834
1208
  };
835
1209
  return {
836
1210
  ...result,
837
- priceDisplay: formatPriceDisplay(result, recurring, hasMetered, locale),
1211
+ priceDisplay: formatPriceDisplay(
1212
+ { amount: result.amount, then: thenDisplay, actualAmount: result.actualAmount, showThen: result.showThen },
1213
+ recurring,
1214
+ hasMetered,
1215
+ locale
1216
+ ),
838
1217
  };
839
1218
  }
840
1219
 
841
1220
  if (trialResult.count > 0) {
1221
+ const thenDisplay = formatMeteredThen(subscription, recurring, hasMetered && Number(subscription) === 0, locale);
1222
+ const thenValue = formatThenValue(subscription, recurring, hasMetered && Number(subscription) === 0, locale);
842
1223
  const result = {
843
1224
  action: t('payment.checkout.try1', locale, { name }),
844
1225
  amount: t('payment.checkout.free', locale, { count: trialResult.count, interval: trialResult.interval }),
845
- then: formatMeteredThen(subscription, recurring, hasMetered && Number(subscription) === 0, locale),
1226
+ then: thenDisplay,
1227
+ thenValue,
846
1228
  showThen: true,
847
1229
  actualAmount: '0',
848
1230
  };
849
1231
  return {
850
1232
  ...result,
851
- priceDisplay: formatPriceDisplay(result, recurring, hasMetered, locale),
1233
+ priceDisplay: formatPriceDisplay(
1234
+ { amount: result.amount, then: thenDisplay, actualAmount: result.actualAmount, showThen: result.showThen },
1235
+ recurring,
1236
+ hasMetered,
1237
+ locale
1238
+ ),
852
1239
  };
853
1240
  }
1241
+ const thenDisplay = hasMetered ? t('payment.checkout.meteredThen', locale, { recurring }) : recurring;
1242
+ const thenValue = hasMetered ? t('payment.checkout.metered', locale, { recurring }) : recurring;
854
1243
  const result = {
855
1244
  action: t('payment.checkout.sub1', locale, { name }),
856
1245
  amount,
857
- then: hasMetered ? t('payment.checkout.meteredThen', locale, { recurring }) : recurring,
1246
+ then: thenDisplay,
1247
+ thenValue,
858
1248
  showThen: hasMetered && !differentRecurring,
859
1249
  actualAmount,
860
1250
  };
861
1251
  return {
862
1252
  ...result,
863
- priceDisplay: formatPriceDisplay(result, recurring, hasMetered, locale),
1253
+ priceDisplay: formatPriceDisplay(
1254
+ { amount: result.amount, then: thenDisplay, actualAmount: result.actualAmount, showThen: result.showThen },
1255
+ recurring,
1256
+ hasMetered,
1257
+ locale
1258
+ ),
864
1259
  };
865
1260
  }
866
1261
 
867
1262
  // mixed
868
1263
  const subscription = fromUnitToToken(
869
1264
  items
870
- .filter((x) => x.price.type === 'recurring')
1265
+ .filter((x) => x.price?.type === 'recurring')
871
1266
  .reduce((acc, x) => {
872
- if (x.price.recurring?.usage_type === 'metered') {
1267
+ if (x.price?.recurring?.usage_type === 'metered') {
873
1268
  return acc;
874
1269
  }
875
- return acc.add(new BN(getPriceUintAmountByCurrency(x.price, currency)).mul(new BN(x.quantity)));
1270
+ return acc.add(getLineItemAmounts(x, currency, { useUpsell: false, exchangeRate }).totalAmount);
876
1271
  }, new BN(0)),
877
1272
  currency.decimal
878
1273
  );
879
1274
 
1275
+ const thenDisplay = formatMeteredThen(
1276
+ `${subscription} ${currency.symbol}`,
1277
+ recurring,
1278
+ hasMetered && Number(subscription) === 0,
1279
+ locale
1280
+ );
1281
+ const thenValue = formatThenValue(
1282
+ `${subscription} ${currency.symbol}`,
1283
+ recurring,
1284
+ hasMetered && Number(subscription) === 0,
1285
+ locale
1286
+ );
880
1287
  const result = {
881
1288
  action: t('payment.checkout.pay', locale, { payee: brand }),
882
1289
  amount,
883
- then: formatMeteredThen(
884
- `${subscription} ${currency.symbol}`,
885
- recurring,
886
- hasMetered && Number(subscription) === 0,
887
- locale
888
- ),
1290
+ then: thenDisplay,
1291
+ thenValue,
889
1292
  showThen: !differentRecurring,
890
1293
  actualAmount,
891
1294
  };
892
1295
 
893
1296
  return {
894
1297
  ...result,
895
- priceDisplay: formatPriceDisplay(result, recurring, hasMetered, locale),
1298
+ priceDisplay: formatPriceDisplay(
1299
+ { amount: result.amount, then: thenDisplay, actualAmount: result.actualAmount, showThen: result.showThen },
1300
+ recurring,
1301
+ hasMetered,
1302
+ locale
1303
+ ),
896
1304
  };
897
1305
  }
898
1306
 
899
- export function formatAmount(amount: string, decimals: number) {
900
- return fromUnitToToken(amount, decimals);
1307
+ export function formatAmount(amount: string, decimals: number, precision: number = 2) {
1308
+ const tokenAmount = fromUnitToToken(amount, decimals);
1309
+ const numericValue = Number(tokenAmount);
1310
+ if (!Number.isFinite(numericValue)) {
1311
+ return formatBNStr(amount, decimals, precision, true, false);
1312
+ }
1313
+ const abs = Math.abs(numericValue);
1314
+ const targetPrecision = abs > 0 && abs < 0.01 ? decimals : 2;
1315
+ return formatNumber(tokenAmount, targetPrecision, true, false);
901
1316
  }
902
1317
 
903
1318
  export function findCurrency(methods: TPaymentMethodExpanded[], currencyId: string): TPaymentCurrencyExpanded | null {
@@ -1386,7 +1801,7 @@ export function showStaking(method: TPaymentMethod, currency: TPaymentCurrency,
1386
1801
  if (noStake) {
1387
1802
  return false;
1388
1803
  }
1389
- if (method.type === 'arcblock') {
1804
+ if (method && method.type === 'arcblock') {
1390
1805
  return currency.type !== 'credit';
1391
1806
  }
1392
1807
  return false;