/* 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 */}
);
}