/* eslint-disable @typescript-eslint/indent */ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context'; import Toast from '@arcblock/ux/lib/Toast'; import type { DonationSettings, TCheckoutSession, TLineItemExpanded, TPaymentCurrency, TPaymentIntent, TPaymentMethodExpanded, } from '@blocklet/payment-types'; import { HelpOutline } from '@mui/icons-material'; import { Box, Divider, Fade, Grow, Stack, Tooltip, Typography, Collapse, IconButton } from '@mui/material'; import type { IconButtonProps } from '@mui/material'; import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util'; import { useRequest, useSetState } from 'ahooks'; import noop from 'lodash/noop'; import useBus from 'use-bus'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import { styled } from '@mui/material/styles'; import { useEffect, useMemo, useRef } from 'react'; import Status from '../components/status'; import api from '../libs/api'; import { formatAmount, formatCheckoutHeadlines, getPriceUintAmountByCurrency, formatDynamicPrice, findCurrency, formatError, } from '../libs/util'; import ProductDonation from './product-donation'; import ProductItem from './product-item'; import Livemode from '../components/livemode'; import { usePaymentContext } from '../contexts/payment'; import { useMobile } from '../hooks/mobile'; import { useDynamicPricing, type LiveRateInfo, type LiveQuoteSnapshot } from '../hooks/dynamic-pricing'; import LoadingButton from '../components/loading-button'; import DynamicPricingUnavailable from '../components/dynamic-pricing-unavailable'; import PromotionSection from './summary-section/promotion-section'; import TotalSection from './summary-section/total-section'; import type { SlippageConfigValue } from '../components/slippage-config'; interface ExpandMoreProps extends IconButtonProps { expand: boolean; } const ExpandMore = styled((props: ExpandMoreProps) => { const { expand, ...other } = props; return ; })(({ theme, expand }) => ({ transform: !expand ? 'rotate(0deg)' : 'rotate(180deg)', marginLeft: 'auto', transition: theme.transitions.create('transform', { duration: theme.transitions.duration.shortest, }), })); type Props = { items: TLineItemExpanded[]; currency: TPaymentCurrency; trialInDays: number; trialEnd?: number; billingThreshold: number; showStaking?: boolean; onUpsell?: Function; onDownsell?: Function; onQuantityChange?: Function; onChangeAmount?: Function; onApplyCrossSell?: Function; onCancelCrossSell?: Function; checkoutSessionId?: string; crossSellBehavior?: string; donationSettings?: DonationSettings; action?: string; completed?: boolean; checkoutSession?: TCheckoutSession; paymentIntent?: TPaymentIntent | null; onPromotionUpdate?: () => void; paymentMethods?: TPaymentMethodExpanded[]; showFeatures?: boolean; rateUnavailable?: boolean; rateError?: string; isRateLoading?: boolean; // Loading state for skeleton display during currency switch onQuoteExpired?: (forceRefresh?: boolean) => void; onRefreshRate?: () => Promise; onSlippageChange?: (slippageConfig: SlippageConfigValue) => void; slippageConfig?: SlippageConfigValue; liveRate?: LiveRateInfo; liveQuoteSnapshot?: LiveQuoteSnapshot; isStripePayment?: boolean; isSubscription?: boolean; }; async function fetchCrossSell(id: string, skipError = true) { try { const { data } = await api.get(`/api/checkout-sessions/${id}/cross-sell?skipError=${skipError}`); if (!data.error) { return data; } return null; } catch (err) { return null; } } function getStakingSetup(items: TLineItemExpanded[], currency: TPaymentCurrency, billingThreshold = 0) { const staking = { licensed: new BN(0), metered: new BN(0), }; const recurringItems = items .map((x) => x.upsell_price || x.price) .filter((x) => x.type === 'recurring' && x.recurring); if (recurringItems.length > 0) { if (+billingThreshold) { return fromTokenToUnit(billingThreshold, currency.decimal).toString(); } items.forEach((x) => { const price = x.upsell_price || x.price; const unit = getPriceUintAmountByCurrency(price, currency); const amount = new BN(unit).mul(new BN(x.quantity)); if (price.type === 'recurring' && price.recurring) { if (price.recurring.usage_type === 'licensed') { staking.licensed = staking.licensed.add(amount); } if (price.recurring.usage_type === 'metered') { staking.metered = staking.metered.add(amount); } } }); return staking.licensed.add(staking.metered).toString(); } return '0'; } export default function PaymentSummary({ items, currency, trialInDays, billingThreshold, onUpsell = noop, onDownsell = noop, onQuantityChange = noop, onApplyCrossSell = noop, onCancelCrossSell = noop, onChangeAmount = noop, checkoutSessionId = '', crossSellBehavior = '', showStaking = false, donationSettings = undefined, action = '', trialEnd = 0, completed = false, checkoutSession = undefined, paymentIntent = undefined, paymentMethods = [], onPromotionUpdate = noop, showFeatures = false, rateUnavailable = false, isRateLoading = false, // eslint-disable-next-line @typescript-eslint/no-unused-vars rateError: _rateError = undefined, // Technical errors are logged but not displayed to users onQuoteExpired = undefined, onRefreshRate = undefined, onSlippageChange = undefined, slippageConfig: slippageConfigProp = undefined, liveRate = undefined, liveQuoteSnapshot = undefined, isStripePayment = false, isSubscription: isSubscriptionProp = undefined, ...rest }: Props) { const { t, locale } = useLocaleContext(); const { isMobile } = useMobile(); const { paymentState, ...settings } = usePaymentContext(); const [state, setState] = useSetState({ loading: false, shake: false, expanded: items?.length < 3 }); const { data, runAsync } = useRequest((skipError) => checkoutSessionId ? fetchCrossSell(checkoutSessionId, skipError) : Promise.resolve(null) ); // Discounts and promotion codes const sessionDiscounts = (checkoutSession as any)?.discounts || []; const allowPromotionCodes = !!checkoutSession?.allow_promotion_codes; const hasDiscounts = sessionDiscounts?.length > 0; // Resolve discount currency const discountCurrency = paymentMethods && checkoutSession ? (findCurrency( paymentMethods as TPaymentMethodExpanded[], hasDiscounts ? checkoutSession?.currency_id || currency.id : (currency.id as string) ) as TPaymentCurrency) || settings.settings?.baseCurrency : currency; const slippageConfig = slippageConfigProp ?? (checkoutSession as any)?.metadata?.slippage; // Use the dynamic pricing hook for all rate-related calculations const { hasDynamicPricing, isPriceLocked, quoteMeta, rateInfo, quoteLockedAt, currentSlippagePercent, rateDisplay, calculatedTokenAmount, calculatedDiscountAmount, calculateUsdDisplay, buildQuoteDetailRows, } = useDynamicPricing({ items, currency: discountCurrency, liveRate, liveQuoteSnapshot, checkoutSession, paymentIntent, locale, isStripePayment, isSubscription: isSubscriptionProp, slippageConfig, trialInDays, trialEnd, discounts: sessionDiscounts, }); // Headlines and amount calculations const headlines = formatCheckoutHeadlines(items, discountCurrency, { trialEnd, trialInDays }, locale, { exchangeRate: rateInfo.exchangeRate, }); const staking = showStaking ? getStakingSetup(items, discountCurrency, billingThreshold) : '0'; // For Stripe payments, always use standard formatting regardless of dynamic pricing // This prevents calculation errors when switching from crypto to fiat payments const effectiveHasDynamicPricing = hasDynamicPricing && !isStripePayment; // Check if this is a trial scenario: actualAmount is '0' AND it's a subscription // Only subscriptions (recurring items) have trials - one_time products should still be charged const hasRecurringItems = items.some((x) => (x.upsell_price || x.price)?.type === 'recurring'); const isTrialScenario = headlines.actualAmount === '0' && hasRecurringItems; const headlineAmountDisplay = useMemo(() => { // For Stripe payments, use standard formatting regardless of dynamic pricing if (!effectiveHasDynamicPricing) { return headlines.amount; } // For trial scenarios, use the formatted trial amount (e.g., "Free 7-day trial") if (isTrialScenario || !headlines.amount.includes(discountCurrency.symbol)) { return headlines.amount; } if (calculatedTokenAmount) { const displayAmount = fromUnitToToken(calculatedTokenAmount, discountCurrency?.decimal); const formatted = formatDynamicPrice(displayAmount, true, 6); return `${formatted} ${discountCurrency.symbol}`; } const formatted = formatDynamicPrice(headlines.actualAmount, true, 6); return `${formatted} ${discountCurrency.symbol}`; }, [ headlines.amount, headlines.actualAmount, discountCurrency.symbol, discountCurrency?.decimal, effectiveHasDynamicPricing, calculatedTokenAmount, isTrialScenario, ]); // Amount calculations - wrap discountAmount in useMemo to avoid dependency warning const discountAmount = useMemo( () => new BN(checkoutSession?.total_details?.amount_discount || '0'), [checkoutSession?.total_details?.amount_discount] ); const subtotalAmountUnit = new BN(fromTokenToUnit(headlines.actualAmount, discountCurrency?.decimal)) .add(new BN(staking)) .toString(); const subtotalAmount = fromUnitToToken(subtotalAmountUnit, discountCurrency?.decimal); const totalAmountUnit = new BN(subtotalAmountUnit).sub(discountAmount).toString(); const totalAmountValue = fromUnitToToken(totalAmountUnit, discountCurrency?.decimal); // Format displays - for dynamic pricing, include staking in the calculated total // For trial scenarios, don't add calculatedTokenAmount since payment amount is 0 const subtotalDisplay = useMemo(() => { if (effectiveHasDynamicPricing && calculatedTokenAmount && !isTrialScenario) { const dynamicSubtotalUnit = new BN(calculatedTokenAmount).add(new BN(staking)).toString(); const displayAmount = fromUnitToToken(dynamicSubtotalUnit, discountCurrency?.decimal); return formatDynamicPrice(displayAmount, true, 6); } return formatDynamicPrice(subtotalAmount, effectiveHasDynamicPricing, 6); }, [ effectiveHasDynamicPricing, calculatedTokenAmount, staking, discountCurrency?.decimal, subtotalAmount, isTrialScenario, ]); const totalAmountDisplay = useMemo(() => { // For trial scenarios, don't add calculatedTokenAmount since payment amount is 0 if (effectiveHasDynamicPricing && calculatedTokenAmount && !isTrialScenario) { // Use calculatedDiscountAmount (frontend) instead of discountAmount (backend) // Backend discountAmount is stale when exchange rate changes const effectiveDiscount = calculatedDiscountAmount ? new BN(calculatedDiscountAmount) : discountAmount; const dynamicTotalUnit = new BN(calculatedTokenAmount).add(new BN(staking)).sub(effectiveDiscount).toString(); const displayAmount = fromUnitToToken(dynamicTotalUnit, discountCurrency?.decimal); const numericValue = Number(displayAmount); if (Number.isFinite(numericValue) && numericValue >= 0) { return formatDynamicPrice(displayAmount, true, 6); } } // For Stripe payments: use calculatedDiscountAmount when backend discountAmount is 0 or stale // This ensures correct total when discount is applied but backend hasn't recalculated if (isStripePayment && calculatedDiscountAmount && !isTrialScenario) { const effectiveDiscount = new BN(calculatedDiscountAmount); const adjustedTotalUnit = new BN(subtotalAmountUnit).sub(effectiveDiscount).toString(); const displayAmount = fromUnitToToken(adjustedTotalUnit, discountCurrency?.decimal); const numericValue = Number(displayAmount); if (Number.isFinite(numericValue) && numericValue >= 0) { return formatDynamicPrice(displayAmount, false, 6); } } return formatDynamicPrice(totalAmountValue, effectiveHasDynamicPricing, 6); }, [ effectiveHasDynamicPricing, calculatedTokenAmount, staking, discountAmount, calculatedDiscountAmount, discountCurrency?.decimal, totalAmountValue, isTrialScenario, isStripePayment, subtotalAmountUnit, ]); const totalAmountText = totalAmountDisplay === '—' ? '—' : `${totalAmountDisplay} ${discountCurrency.symbol}`; // Fix: Use dynamically calculated total for USD display, not static totalAmountValue // For Stripe payments, no USD equivalent display is needed since it's already USD // For trial scenarios, don't add calculatedTokenAmount since payment amount is 0 const totalUsdDisplay = useMemo(() => { if (effectiveHasDynamicPricing && calculatedTokenAmount && !isTrialScenario) { // Use calculatedDiscountAmount (frontend) instead of discountAmount (backend) const effectiveDiscount = calculatedDiscountAmount ? new BN(calculatedDiscountAmount) : discountAmount; const dynamicTotalUnit = new BN(calculatedTokenAmount).add(new BN(staking)).sub(effectiveDiscount).toString(); const dynamicTotalToken = fromUnitToToken(dynamicTotalUnit, discountCurrency?.decimal); return calculateUsdDisplay(dynamicTotalToken); } return calculateUsdDisplay(totalAmountValue); }, [ effectiveHasDynamicPricing, calculatedTokenAmount, staking, discountAmount, calculatedDiscountAmount, discountCurrency?.decimal, totalAmountValue, calculateUsdDisplay, isTrialScenario, ]); const quoteDetailRows = buildQuoteDetailRows(t); const isSubscription = isSubscriptionProp ?? (checkoutSession?.mode === 'subscription' || checkoutSession?.mode === 'setup'); // Promotion handlers const handlePromotionUpdate = () => { onPromotionUpdate?.(); }; const handleRemovePromotion = async (sessionId: string) => { if (paymentState.paying || paymentState.stripePaying) { return; } try { await api.delete(`/api/checkout-sessions/${sessionId}/remove-promotion`); onPromotionUpdate?.(); } catch (err: any) { console.error('Failed to remove promotion code:', err); } }; // Quote expiry handling (backward compatibility - Final Freeze deprecates this) const expiredHandledRef = useRef(false); useEffect(() => { if (completed || expiredHandledRef.current) { return; } // In Final Freeze, there's no quote during preview, so no expiry check needed if (!liveQuoteSnapshot?.expires_at && !quoteMeta?.expiresAt) { return; } const currentTime = Math.floor(Date.now() / 1000); const effectiveExpiresAt = liveQuoteSnapshot?.expires_at ?? quoteMeta?.expiresAt; const quoteRemaining = effectiveExpiresAt ? Math.max(0, effectiveExpiresAt - currentTime) : 0; const lockRemaining = quoteLockedAt ? Math.max(0, quoteLockedAt + 180 - currentTime) : 0; const hasExpiry = !!effectiveExpiresAt; const lockActive = lockRemaining > 0; if (hasExpiry && !lockActive && quoteRemaining <= 0) { expiredHandledRef.current = true; onQuoteExpired?.(); } }, [liveQuoteSnapshot?.expires_at, quoteMeta?.expiresAt, quoteLockedAt, completed, onQuoteExpired]); // Slippage handler const handleSlippageChange = async (newSlippageConfig: SlippageConfigValue) => { if (!onSlippageChange) { return; } if (!checkoutSessionId) { onSlippageChange(newSlippageConfig); return; } try { await api.put(`/api/checkout-sessions/${checkoutSessionId}/slippage`, { slippage_config: newSlippageConfig, }); onSlippageChange(newSlippageConfig); if (onQuoteExpired) { await onQuoteExpired(true); } } catch (err: any) { console.error('Failed to update slippage', err); Toast.error(err.response?.data?.error || formatError(err)); } }; // Cross-sell event handling useBus( 'error.REQUIRE_CROSS_SELL', () => { setState({ shake: true }); setTimeout(() => { setState({ shake: false }); }, 1000); }, [] ); // Product action handlers const handleUpsell = async (from: string, to: string) => { await onUpsell!(from, to); runAsync(false); }; const handleQuantityChange = async (itemId: string, quantity: number) => { await onQuantityChange!(itemId, quantity); runAsync(false); }; const handleDownsell = async (from: string) => { await onDownsell!(from); runAsync(false); }; const handleApplyCrossSell = async () => { if (data) { try { setState({ loading: true }); await onApplyCrossSell!(data.id); } catch (err) { console.error(err); } finally { setState({ loading: false }); } } }; const handleCancelCrossSell = async () => { try { setState({ loading: true }); await onCancelCrossSell!(); } catch (err) { console.error(err); } finally { setState({ loading: false }); } }; const hasSubTotal = +staking > 0 || allowPromotionCodes; // Product list component const ProductCardList = ( {items.map((x: TLineItemExpanded) => x.price?.custom_unit_amount && onChangeAmount && donationSettings ? ( ) : ( {x.cross_sell && ( {t('payment.checkout.cross_sell.remove')} )} ) )} {data && items.some((x) => x.price_id === data.id) === false && ( {crossSellBehavior === 'required' && ( )} {t('payment.checkout.cross_sell.add')} )} ); if (!discountCurrency || !items?.length) { return null; } return ( {/* Header */} {action || t('payment.checkout.orderSummary')} {!settings.livemode && } {/* Dynamic pricing unavailable warning - hide for Stripe payments since no exchange rate needed */} {effectiveHasDynamicPricing && rateUnavailable && ( )} {/* Product list (collapsible on mobile) */} {isMobile && !donationSettings ? ( <> setState({ expanded: !state.expanded })} sx={{ justifyContent: 'space-between', flexDirection: 'row', alignItems: 'center', mb: 1.5, }}> {t('payment.checkout.productListTotal', { total: items.length })} {ProductCardList} ) : ( ProductCardList )} {/* Staking section */} {+staking > 0 && ( {t('payment.checkout.paymentRequired')} {headlineAmountDisplay} {t('payment.checkout.staking.title')} {formatAmount(staking, discountCurrency.decimal)} {discountCurrency.symbol} )} {/* Subtotal row */} {(allowPromotionCodes || hasDiscounts) && ( 0 && { borderTop: '1px solid', borderColor: 'divider', pt: 1, mt: 1, }), }}> {t('common.subtotal')} {subtotalDisplay} {discountCurrency.symbol} )} {/* Promotion section */} {/* For Stripe payments, use total_details.amount_discount (backend calculated) */} {/* For dynamic pricing, use calculatedDiscountAmount (frontend calculated based on rate) */} {hasSubTotal && } {/* Total section */} ); }