/* eslint-disable @typescript-eslint/indent */ import 'react-international-phone/style.css'; import { useLocaleContext } from '@arcblock/ux/lib/Locale/context'; import { FlagEmoji } from 'react-international-phone'; // import { useTheme } from '@arcblock/ux/lib/Theme'; import Toast from '@arcblock/ux/lib/Toast'; import type { TCheckoutSession, TCheckoutSessionExpanded, TCustomer, TLineItemExpanded, TPaymentIntent, TPaymentMethodExpanded, } from '@blocklet/payment-types'; import { Box, Button, CircularProgress, Divider, Fade, Stack, Tooltip, Typography } from '@mui/material'; import { useMemoizedFn, useSetState } from 'ahooks'; import pWaitFor from 'p-wait-for'; import { useEffect, useMemo, useRef } from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { joinURL } from 'ufo'; import { dispatch } from 'use-bus'; import isEmail from 'validator/es/lib/isEmail'; import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util'; import DID from '@arcblock/ux/lib/DID'; import { PayFailedEvent } from '@arcblock/ux/lib/withTracker/action/pay'; import isEmpty from 'lodash/isEmpty'; import { HelpOutline, OpenInNew } from '@mui/icons-material'; import { ReactGA } from '@arcblock/ux/lib/withTracker'; import trim from 'lodash/trim'; import FormInput from '../../components/input'; import FormLabel from '../../components/label'; import { usePaymentContext } from '../../contexts/payment'; import { useSubscription } from '../../hooks/subscription'; import api from '../../libs/api'; import { flattenPaymentMethods, formatError, formatQuantityInventory, getPrefix, getQueryParams, getStatementDescriptor, getTokenBalanceLink, isCrossOrigin, getCheckoutAmount, formatNumber, formatUsdAmount, getUsdAmountFromBaseAmount, getUsdAmountFromTokenUnits, formatAmount, } from '../../libs/util'; import type { CheckoutCallbacks, CheckoutContext } from '../../types'; import AddressForm from './address'; import CurrencySelector from './currency'; import PhoneInput from './phone'; import StripeCheckout from './stripe'; import { useMobile } from '../../hooks/mobile'; import { formatPhone, validatePhoneNumber } from '../../libs/phone-validator'; import LoadingButton from '../../components/loading-button'; import OverdueInvoicePayment from '../../components/over-due-invoice-payment'; import { saveCurrencyPreference } from '../../libs/currency'; import ConfirmDialog from '../../components/confirm'; import ServiceSuspendedDialog from '../../components/service-suspended-dialog'; import PriceChangeConfirm from '../../components/price-change-confirm'; import { getFieldValidation, validatePostalCode } from '../../libs/validator'; // Generate unique idempotency key for submit (Final Freeze Architecture) const generateIdempotencyKey = (sessionId: string, currencyId: string): string => { return `${sessionId}-${currencyId}-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`; }; export const waitForCheckoutComplete = async (sessionId: string) => { let result: CheckoutContext; await pWaitFor( async () => { const { data } = await api.get(`/api/checkout-sessions/retrieve/${sessionId}`); if ( data.paymentIntent && data.paymentIntent.status === 'requires_action' && data.paymentIntent.last_payment_error ) { throw new Error(data.paymentIntent.last_payment_error.message); } result = data; return ( // eslint-disable-next-line @typescript-eslint/return-await data.checkoutSession?.status === 'complete' && ['paid', 'no_payment_required'].includes(data.checkoutSession?.payment_status) ); }, { interval: 2000, timeout: 3 * 60 * 1000 } ); // @ts-ignore return result; }; export const hasDidWallet = (user: any) => { const connected = user?.connectedAccounts || user?.extraConfigs?.connectedAccounts || []; return connected.some((x: any) => x.provider === 'wallet'); }; type PageData = CheckoutContext & CheckoutCallbacks & { onQuoteUpdated?: ( data: Pick ) => void; onPaymentIntentUpdate?: (intent: TPaymentIntent | null) => void; onlyShowBtn?: boolean; isDonation?: boolean; }; type UserInfo = { name?: string; fullName?: string; email?: string; phone?: string; address?: { country?: string; state?: string; province?: string; line1?: string; line2?: string; city?: string; postal_code?: string; postalCode?: string; }; metadata?: { phone?: { country?: string; phoneNumber?: string; }; }; }; type FastCheckoutInfo = { open: boolean; loading: boolean; sourceType: 'balance' | 'delegation' | 'credit'; amount: string; payer?: string; availableCredit?: string; balance?: string; }; const setUserFormValues = ( userInfo: UserInfo, currentValues: any, setValue: Function, options: { preferExisting?: boolean; showPhone?: boolean; shouldValidate?: boolean } = {} ) => { const { preferExisting = true, shouldValidate = false } = options; const basicFields = { customer_name: userInfo.name || userInfo.fullName, customer_email: userInfo.email, customer_phone: formatPhone(userInfo.phone), }; const addressFields: Record = { 'billing_address.state': userInfo.address?.state || userInfo.address?.province, 'billing_address.line1': userInfo.address?.line1, 'billing_address.line2': userInfo.address?.line2, 'billing_address.city': userInfo.address?.city, 'billing_address.postal_code': userInfo.address?.postal_code || userInfo.address?.postalCode, 'billing_address.country': userInfo.address?.country || 'us', }; if (options.showPhone) { addressFields['billing_address.country'] = userInfo.metadata?.phone?.country || userInfo.address?.country; } const allFields = { ...addressFields, ...basicFields }; const updatedFields: Record = { ...currentValues, billing_address: { ...currentValues.billing_address, }, }; Object.entries(allFields).forEach(([field, value]) => { if (!preferExisting || !currentValues[field.split('.')[0]]) { setValue(field, value, { shouldValidate }); if (field.startsWith('billing_address.')) { const addressKey = field.replace('billing_address.', ''); updatedFields.billing_address[addressKey] = value; } else { updatedFields[field] = value; } } }); return updatedFields; }; // ✅ No longer need to collect quotes from frontend - backend auto-finds them // const collectQuotes = (lineItems: TLineItemExpanded[]) => // lineItems // ?.filter((item) => (item.price as any)?.pricing_type === 'dynamic' && (item as any)?.quote_id) // .map((item) => ({ // price_id: item.price_id, // quote_id: (item as any).quote_id, // })) || []; // FIXME: https://stripe.com/docs/elements/address-element // TODO: https://country-regions.github.io/react-country-region-selector/ // https://www.npmjs.com/package/postal-codes-js // https://www.npmjs.com/package/val-zip // https://npm.runkit.com/zips export default function PaymentForm({ checkoutSession, paymentMethods, paymentIntent, paymentLink, customer, onPaid, onError, onQuoteUpdated = undefined, onPaymentIntentUpdate = undefined, // mode, action, onlyShowBtn = false, isDonation = false, rateUnavailable = false, }: PageData) { // const theme = useTheme(); const { t, locale } = useLocaleContext(); const { isMobile } = useMobile(); const { session, connect, payable, setPaymentState } = usePaymentContext(); const subscription = useSubscription('events'); const formErrorPosition = 'bottom'; const { control, getValues, setValue, handleSubmit, formState: { errors }, trigger, } = useFormContext(); const errorRef = useRef(null); const processingRef = useRef(false); // Stable idempotency key: reuse same Quote on retry (intent: "retry with same Quote") const idempotencyKeyRef = useRef(''); const sessionFingerprintRef = useRef(''); const quantityInventoryStatus = useMemo(() => { let status = true; for (const item of checkoutSession.line_items) { if (formatQuantityInventory(item.price, item.quantity)) { status = false; break; } } return status; }, [checkoutSession]); const [state, setState] = useSetState<{ submitting: boolean; paying: boolean; paid: boolean; paymentIntent?: TPaymentIntent; stripeContext?: { client_secret: string; intent_type: string; status: string; }; customer?: TCustomer; customerLimited?: boolean; serviceSuspended?: boolean; stripePaying: boolean; fastCheckoutInfo: FastCheckoutInfo | null; creditInsufficientInfo: { open: boolean; } | null; showEditForm: boolean; // Final Freeze: Price change confirmation state priceChangeConfirm: { open: boolean; previewRate?: string; submitRate?: string; changePercent: number; formData?: any; } | null; }>({ submitting: false, paying: false, paid: false, paymentIntent, stripeContext: undefined, customer, customerLimited: false, serviceSuspended: false, stripePaying: false, fastCheckoutInfo: null, creditInsufficientInfo: null, showEditForm: false, priceChangeConfirm: null, }); const currencies = flattenPaymentMethods(paymentMethods); const searchParams = getQueryParams(window.location.href); const onCheckoutComplete = useMemoizedFn(async ({ response }: { response: TCheckoutSession }) => { if (response.id === checkoutSession.id && state.paid === false) { await handleConnected(); } }); useEffect(() => { if (subscription) { subscription.on('checkout.session.completed', onCheckoutComplete); } }, [subscription]); // eslint-disable-line react-hooks/exhaustive-deps // Sync payment states to PaymentContext useEffect(() => { setPaymentState({ paying: state.submitting || state.paying, stripePaying: state.stripePaying, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [state.submitting, state.paying, state.stripePaying]); const mergeUserInfo = ( customerInfo: UserInfo | (TCustomer & { fullName?: string }), userInfo?: UserInfo ): UserInfo => { return { ...(userInfo || {}), name: customerInfo?.name || customerInfo?.fullName || userInfo?.name || userInfo?.fullName, fullName: customerInfo?.name || customerInfo?.fullName || userInfo?.name || userInfo?.fullName, email: customerInfo?.email || userInfo?.email, phone: customerInfo?.phone || userInfo?.phone, address: { ...(userInfo?.address || {}), ...(customerInfo?.address || {}), country: customerInfo?.address?.country || userInfo?.address?.country, state: customerInfo?.address?.state || userInfo?.address?.province, line1: customerInfo?.address?.line1 || userInfo?.address?.line1, line2: customerInfo?.address?.line2 || userInfo?.address?.line2, city: customerInfo?.address?.city || userInfo?.address?.city, postal_code: customerInfo?.address?.postal_code || userInfo?.address?.postalCode, }, metadata: { ...(userInfo?.metadata || {}), phone: { country: customerInfo?.address?.country || userInfo?.metadata?.phone?.country, phoneNumber: customerInfo?.phone || userInfo?.metadata?.phone?.phoneNumber, }, }, }; }; useEffect(() => { const initUserInfo = async () => { if (session?.user) { const values = getValues(); let userInfo = session.user; try { const { data: customerInfo } = await api.get('/api/customers/me?skipSummary=1&fallback=1'); userInfo = mergeUserInfo(customerInfo, userInfo); } catch (err) { // @ts-ignore userInfo = mergeUserInfo(customer || {}, userInfo); console.error(err); } const formValues = setUserFormValues(userInfo, values, setValue, { preferExisting: false, showPhone: checkoutSession.phone_number_collection?.enabled, }); const isValid = validateUserInfo(formValues); setState({ showEditForm: !isValid }); } else { setUserFormValues( { name: '', email: '', phone: '', address: { state: '', line1: '', line2: '', city: '', postal_code: '', }, }, {}, setValue, { preferExisting: false, showPhone: checkoutSession.phone_number_collection?.enabled } ); } }; if (state.submitting) { return; } initUserInfo(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [session?.user, checkoutSession.phone_number_collection?.enabled]); const paymentMethod = useWatch({ control, name: 'payment_method' }); const paymentCurrencyId = useWatch({ control, name: 'payment_currency' }); // const domSize = useSize(document.body); // const isColumnLayout = useCreation(() => { // if (domSize) { // if (domSize?.width <= theme.breakpoints.values.md) { // return true; // } // } // return false; // }, [domSize, theme]); const afterUserLoggedIn = useMemoizedFn(() => { handleSubmit(onFormSubmit, onFormError)(); }); const payee = getStatementDescriptor(checkoutSession.line_items); let buttonText = ''; if (paymentLink?.donation_settings) { if (action) { buttonText = action; } else { buttonText = t('payment.checkout.donate'); } } else { buttonText = t(`payment.checkout.${checkoutSession.mode}`); } buttonText = session?.user || isDonation ? buttonText : t('payment.checkout.connect', { action: buttonText }); const method = paymentMethods.find((x) => x.id === paymentMethod) as TPaymentMethodExpanded; const paymentCurrency = currencies.find((x) => x.id === paymentCurrencyId); const showStake = method.type === 'arcblock' && !checkoutSession.subscription_data?.no_stake; const hasDynamicPricing = useMemo( () => (checkoutSession.line_items || []).some((item: any) => { const price = item.upsell_price || item.price; return price && (price as any)?.pricing_type === 'dynamic'; }), [checkoutSession.line_items] ); const rateUnavailableForDynamic = hasDynamicPricing && rateUnavailable; const canPay = payable && !rateUnavailableForDynamic; const isDonationMode = checkoutSession?.submit_type === 'donate' && isDonation; const [priceUpdateInfo, setPriceUpdateInfo] = useSetState<{ open: boolean; total: string; usd: string | null; hasQuotes: boolean; baseCurrency: string; oldTotal: string; reason: 'rateChanged' | 'recalculated'; }>({ open: false, total: '', usd: null, hasQuotes: false, baseCurrency: 'USD', oldTotal: '', reason: 'recalculated', }); // lockExpiredInfo state removed - now auto-refreshes instead const normalizeExchangeRate = useMemoizedFn((rate?: string | null): string | null => { if (!rate) { return null; } const value = Number(rate); if (!Number.isFinite(value)) { return null; } return value.toFixed(8); }); const getExchangeRateFromSession = useMemoizedFn((sessionData?: TCheckoutSession | null): string | null => { if (!sessionData?.line_items?.length) { return null; } for (const item of sessionData.line_items as TLineItemExpanded[]) { const rate = (item as any)?.exchange_rate; if (rate) { return rate; } } return null; }); const quoteAutoRetryRef = useRef(false); const lastRetryKeyRef = useRef(''); const buildRetryKey = useMemoizedFn((sessionData?: TCheckoutSession | null) => { if (!sessionData?.line_items?.length) { return ''; } return (sessionData.line_items as TLineItemExpanded[]) .map((item) => { const priceId = item.price_id || item.price?.id || ''; const quoteId = (item as any)?.quote_id || ''; const quotedAmount = (item as any)?.quoted_amount || ''; const exchangeRate = (item as any)?.exchange_rate || ''; return `${priceId}:${quoteId}:${quotedAmount}:${exchangeRate}`; }) .join('|'); }); const buildPriceUpdateSummary = useMemoizedFn((sessionData: TCheckoutSession) => { if (!paymentCurrency) { return { total: '', usd: null, hasQuotes: false, baseCurrency: 'USD', totalUnit: null as BN | null }; } const lineItems = (sessionData.line_items || []) as TLineItemExpanded[]; let baseCurrency = 'USD'; for (const item of lineItems) { const price = item.upsell_price || item.price; const base = (price as any)?.base_currency; if (base) { baseCurrency = base; break; } } const hasQuotes = lineItems.some((item) => (item as any)?.quoted_amount && (item as any)?.exchange_rate); if (!hasQuotes) { return { total: '', usd: null, hasQuotes: false, baseCurrency, totalUnit: null as BN | null }; } let trialInDays = Number(sessionData?.subscription_data?.trial_period_days || 0); const trialCurrencyIds = (sessionData?.subscription_data?.trial_currency || '') .split(',') .map(trim) .filter(Boolean); if (trialCurrencyIds.length > 0 && paymentCurrencyId && trialCurrencyIds.includes(paymentCurrencyId) === false) { trialInDays = 0; } const { total } = getCheckoutAmount(lineItems, paymentCurrency, trialInDays > 0); const discountAmount = new BN(sessionData.total_details?.amount_discount || '0'); const totalUnit = new BN(total).sub(discountAmount); const normalizedTotalUnit = totalUnit.isNeg() ? new BN(0) : totalUnit; const totalDisplay = `${formatNumber( fromUnitToToken(normalizedTotalUnit.toString(), paymentCurrency.decimal), 6 )} ${paymentCurrency.symbol}`; const itemUsdReferences = lineItems.map((item) => { const price = item.upsell_price || item.price; const baseAmount = (price as any)?.base_amount; const hasBaseAmount = baseAmount !== undefined && baseAmount !== null; if (hasBaseAmount) { return getUsdAmountFromBaseAmount(baseAmount, item.quantity || 0); } const exchangeRate = (item as any)?.exchange_rate; const quotedAmount = (item as any)?.quoted_amount; if (!exchangeRate || !quotedAmount) { return null; } return getUsdAmountFromTokenUnits(new BN(quotedAmount), paymentCurrency.decimal, exchangeRate); }); const usdValues = itemUsdReferences.filter((value): value is string => Boolean(value)); if (!usdValues.length) { return { total: totalDisplay, usd: null, hasQuotes, baseCurrency, totalUnit: normalizedTotalUnit }; } const sumUnit = usdValues.reduce((acc, value) => acc.add(new BN(fromTokenToUnit(value, 8))), new BN(0)); const totalUsdReference = fromUnitToToken(sumUnit.toString(), 8); return { total: totalDisplay, usd: formatUsdAmount(totalUsdReference, locale), hasQuotes, baseCurrency, totalUnit: normalizedTotalUnit, }; }); const compareTotals = useMemoizedFn((prevSession: TCheckoutSession, nextSession: TCheckoutSession) => { const prev = buildPriceUpdateSummary(prevSession); const next = buildPriceUpdateSummary(nextSession); if (!prev.totalUnit || !next.totalUnit) { return { changed: false, prev, next }; } const diff = next.totalUnit.sub(prev.totalUnit).abs(); const epsilon = new BN(1); return { changed: diff.gt(epsilon), prev, next }; }); const applyQuoteUpdate = useMemoizedFn( ( payload: { checkoutSession: TCheckoutSession; quotes?: any; rateUnavailable?: boolean; rateError?: string }, options: { forceConfirm?: boolean; reason?: 'rateChanged' | 'recalculated' } = {} ) => { if (!payload?.checkoutSession) { return; } onQuoteUpdated?.({ checkoutSession: payload.checkoutSession as TCheckoutSessionExpanded, quotes: payload.quotes, rateUnavailable: payload.rateUnavailable, rateError: payload.rateError, }); const { changed, prev, next } = compareTotals(checkoutSession, payload.checkoutSession); const previousRate = normalizeExchangeRate(getExchangeRateFromSession(checkoutSession)); const nextRate = normalizeExchangeRate(getExchangeRateFromSession(payload.checkoutSession)); const rateChanged = !!(previousRate && nextRate && previousRate !== nextRate); const shouldShowModal = (options.forceConfirm || changed) && next.hasQuotes; if (shouldShowModal) { setPriceUpdateInfo({ open: true, total: next.total, usd: next.usd, hasQuotes: next.hasQuotes, baseCurrency: next.baseCurrency, oldTotal: prev.total, reason: options.reason || (rateChanged ? 'rateChanged' : 'recalculated'), }); return; } setPriceUpdateInfo({ open: false }); const retryKey = buildRetryKey(payload.checkoutSession); if (retryKey && retryKey !== lastRetryKeyRef.current) { lastRetryKeyRef.current = retryKey; quoteAutoRetryRef.current = true; } } ); const validateUserInfo = (values: any) => { if (!values) { return false; } const fieldValidation = checkoutSession.metadata?.page_info?.field_validation; const customerName = values.customer_name; if (!customerName || customerName.trim() === '') { return false; } const customerEmail = values.customer_email; if (!customerEmail || !isEmail(customerEmail)) { return false; } const nameValidation = getFieldValidation('customer_name', fieldValidation, locale); if (nameValidation.pattern) { const pattern = nameValidation.pattern.value; if (!pattern.test(customerName)) { return false; } } const emailValidation = getFieldValidation('customer_email', fieldValidation, locale); if (emailValidation.pattern) { const pattern = emailValidation.pattern.value; if (!pattern.test(customerEmail)) { return false; } } const billingAddress = values.billing_address || {}; const { postal_code: postalCode, country, state: stateValue, line1, city } = billingAddress; if (!postalCode || !validatePostalCode(postalCode, country)) { return false; } const postalCodeValidation = getFieldValidation('billing_address.postal_code', fieldValidation, locale); if (postalCodeValidation.pattern) { const pattern = postalCodeValidation.pattern.value; if (!pattern.test(postalCode)) { return false; } } if (!stateValue) { return false; } const stateValidation = getFieldValidation('billing_address.state', fieldValidation, locale); if (stateValidation.pattern) { const pattern = stateValidation.pattern.value; if (!pattern.test(stateValue)) { return false; } } if (checkoutSession.phone_number_collection?.enabled) { const customerPhone = values.customer_phone; if (!customerPhone || customerPhone.trim() === '') { return false; } const phoneValidation = getFieldValidation('customer_phone', fieldValidation, locale); if (phoneValidation.pattern) { const pattern = phoneValidation.pattern.value; if (!pattern.test(customerPhone)) { return false; } } } const addressMode = checkoutSession.billing_address_collection; if (addressMode === 'required') { if (!country || !stateValue || !line1 || !city || !postalCode) { return false; } const line1Validation = getFieldValidation('billing_address.line1', fieldValidation, locale); if (line1Validation.pattern) { const pattern = line1Validation.pattern.value; if (!pattern.test(line1)) { return false; } } const cityValidation = getFieldValidation('billing_address.city', fieldValidation, locale); if (cityValidation.pattern) { const pattern = cityValidation.pattern.value; if (!pattern.test(city)) { return false; } } } return true; }; const customerName = useWatch({ control, name: 'customer_name' }); const customerEmail = useWatch({ control, name: 'customer_email' }); const customerPhone = useWatch({ control, name: 'customer_phone' }); const billingAddress = useWatch({ control, name: 'billing_address' }); const showForm = session?.user ? state.showEditForm : false; useEffect(() => { if (!quoteAutoRetryRef.current) { return; } if (state.submitting || state.paying) { return; } quoteAutoRetryRef.current = false; onAction(); }, [state.submitting, state.paying]); // eslint-disable-line react-hooks/exhaustive-deps const handleConnected = async () => { if (processingRef.current) { return; } processingRef.current = true; setState({ paying: true }); try { const result = await waitForCheckoutComplete(checkoutSession.id); if (state.paid === false) { setState({ paid: true, paying: false }); onPaid(result); } } catch (err) { const errorMessage = formatError(err); const payFailedEvent: PayFailedEvent = { action: 'payFailed', // @ts-ignore 后续升级的话就会报错了,移除这个 lint 即可 mode: checkoutSession.mode, errorMessage, success: false, }; ReactGA.event(payFailedEvent.action, payFailedEvent); Toast.error(errorMessage); } finally { setState({ paying: false }); processingRef.current = false; } }; useEffect(() => { if (errorRef.current && !isEmpty(errors) && isMobile) { errorRef.current.scrollIntoView({ behavior: 'smooth' }); } }, [errors, isMobile]); const onUserLoggedIn = async () => { const { data: profile } = await api.get('/api/customers/me?fallback=1&skipSummary=1'); if (profile) { const values = getValues(); const userInfo = mergeUserInfo(profile, session?.user); const formValues = setUserFormValues(userInfo, values, setValue, { preferExisting: false, showPhone: checkoutSession.phone_number_collection?.enabled, shouldValidate: true, }); const isValid = validateUserInfo(formValues); setState({ showEditForm: !isValid }); await trigger(); } }; const handleFastCheckoutConfirm = async () => { if (!state.fastCheckoutInfo) return; setState({ fastCheckoutInfo: { ...state.fastCheckoutInfo, loading: true, }, }); try { // ✅ No longer need to send quotes - backend auto-finds them const result = await api.post(`/api/checkout-sessions/${checkoutSession.id}/fast-checkout-confirm`, {}); if (result.data.paymentIntent) { onPaymentIntentUpdate?.(result.data.paymentIntent); } if (result.data.fastPaid) { setState({ fastCheckoutInfo: null, paying: true, }); await handleConnected(); } else { Toast.error(t('payment.checkout.fastPay.failed')); setState({ fastCheckoutInfo: null, paying: true, }); openConnect(); } } catch (err: unknown) { console.error(err); const errorCode = (err as any)?.response?.data?.code; // Auto-refresh for quote-related errors (including lock expired) if ( [ 'QUOTE_LOCK_EXPIRED', 'QUOTE_AMOUNT_MISMATCH', 'QUOTE_EXPIRED_OR_USED', 'QUOTE_NOT_FOUND', 'QUOTE_REQUIRED', ].includes(errorCode) ) { try { const { data: refreshed } = await api.get(`/api/checkout-sessions/retrieve/${checkoutSession.id}`, { params: { forceRefresh: '1' }, }); if (refreshed?.checkoutSession) { applyQuoteUpdate(refreshed, { reason: 'rateChanged' }); Toast.info(t('payment.checkout.quote.updated.pleaseRetry') || 'Price updated, please resubmit'); } } catch (refreshError) { console.error(refreshError); Toast.error(formatError(refreshError)); } finally { setState({ fastCheckoutInfo: null }); } return; } if (errorCode === 'QUOTE_UPDATED') { const payload = (err as any)?.response?.data; if (payload?.checkoutSession) { applyQuoteUpdate(payload); } setState({ fastCheckoutInfo: null }); return; } if (errorCode === 'RATE_UNAVAILABLE') { const payload = (err as any)?.response?.data; if (payload?.checkoutSession) { onQuoteUpdated?.({ checkoutSession: payload.checkoutSession as TCheckoutSessionExpanded, quotes: payload.quotes, rateUnavailable: payload.rateUnavailable, rateError: payload.rateError, }); } setState({ fastCheckoutInfo: null }); return; } Toast.error(formatError(err)); setState({ fastCheckoutInfo: null, }); } }; const handleFastCheckoutCancel = () => { setState({ fastCheckoutInfo: null }); }; const handleCreditInsufficientClose = () => { setState({ creditInsufficientInfo: null }); }; const handlePriceUpdateConfirm = () => { setPriceUpdateInfo({ open: false }); quoteAutoRetryRef.current = true; }; const handlePriceUpdateCancel = () => { setPriceUpdateInfo({ open: false }); }; // Final Freeze: Handle price change confirmation (PRICE_CHANGED error) const handlePriceChangeConfirm = () => { const formData = state.priceChangeConfirm?.formData; setState({ priceChangeConfirm: null }); if (formData) { // Retry submit with price_confirmed flag onFormSubmit(formData); } }; const handlePriceChangeCancel = () => { setState({ priceChangeConfirm: null }); }; const openConnect = () => { try { if (!['arcblock', 'ethereum', 'base'].includes(method.type)) { return; } setState({ paying: true }); connect.open({ locale, containerEl: undefined as unknown as Element, action: checkoutSession.mode, prefix: joinURL(getPrefix(), '/api/did'), saveConnect: false, useSocket: isCrossOrigin() === false, extraParams: { checkoutSessionId: checkoutSession.id, sessionUserDid: session?.user?.did }, onSuccess: async () => { connect.close(); await handleConnected(); }, onClose: () => { connect.close(); setState({ submitting: false, paying: false }); }, onError: (err: any) => { console.error(err); setState({ submitting: false, paying: false }); onError(err); }, messages: { title: t('payment.checkout.connectModal.title', { action: buttonText }), scan: t('payment.checkout.connectModal.scan'), confirm: t('payment.checkout.connectModal.confirm'), cancel: t('payment.checkout.connectModal.cancel'), }, } as any); } catch (err) { Toast.error(formatError(err)); } }; const onFormSubmit = async (data: any) => { if (state.submitting) { return; } const userInfo = session.user; if (!userInfo.sourceAppPid) { const hasVendorConfig = checkoutSession.line_items?.some( (item: TLineItemExpanded) => !!item?.price?.product?.vendor_config?.length ); if (hasVendorConfig) { Toast.error(t('payment.checkout.vendor.accountRequired')); return; } } // ✅ No longer need to collect or validate quotes - backend auto-finds them // const quotes = collectQuotes(checkoutSession.line_items as TLineItemExpanded[]); // if ( // (checkoutSession.line_items || []).some((item: any) => item.price?.pricing_type === 'dynamic') && // quotes.length === 0 // ) { // Toast.error(t('payment.checkout.quote.expired')); // return; // } setState({ submitting: true }); try { let result; // Final Freeze: Add idempotency_key and preview_rate for dynamic pricing const previewRate = checkoutSession.line_items?.find((item: TLineItemExpanded) => (item as any)?.exchange_rate)?.exchange_rate || undefined; // Stable idempotency key: only regenerate when payment context changes // Same context retry → reuse Quote (intent: "Failed Payments don't invalidate Quote") const items = (checkoutSession.line_items || []) as TLineItemExpanded[]; const itemsSig = items .map((i: TLineItemExpanded) => `${(i as any).upsell_price_id || i.price_id}:${i.quantity}`) .join('|'); const fingerprint = `${checkoutSession.id}-${paymentCurrency?.id}-${itemsSig}`; if (fingerprint !== sessionFingerprintRef.current || !idempotencyKeyRef.current) { sessionFingerprintRef.current = fingerprint; idempotencyKeyRef.current = generateIdempotencyKey(checkoutSession.id, paymentCurrency?.id || ''); } const payload = { ...data, // Final Freeze: Include these for new quote creation at submit idempotency_key: idempotencyKeyRef.current, preview_rate: (previewRate as unknown as string) || undefined, price_confirmed: state.priceChangeConfirm?.formData ? true : undefined, }; if (isDonationMode) { result = await api.put(`/api/checkout-sessions/${checkoutSession.id}/donate-submit`, payload); } else { result = await api.put(`/api/checkout-sessions/${checkoutSession.id}/submit`, payload); } setState({ paymentIntent: result.data.paymentIntent, stripeContext: result.data.stripeContext, customer: result.data.customer, submitting: false, customerLimited: false, }); if (result.data.paymentIntent) { onPaymentIntentUpdate?.(result.data.paymentIntent); } if (['arcblock', 'ethereum', 'base'].includes(method.type)) { // 如果不需要支付(如免费试用),直接确认 if (result.data.noPaymentRequired) { try { const confirmResult = await api.post( `/api/checkout-sessions/${checkoutSession.id}/fast-checkout-confirm`, {} ); if (confirmResult.data.paymentIntent) { onPaymentIntentUpdate?.(confirmResult.data.paymentIntent); } if (confirmResult.data.fastPaid || confirmResult.data.checkoutSession?.status === 'complete') { setState({ paying: true }); await handleConnected(); } else { openConnect(); } } catch (confirmErr) { console.error('noPaymentRequired confirm failed', confirmErr); openConnect(); } return; } // 优先判断 credit 支付 if (paymentCurrency?.type === 'credit') { if (result.data.creditSufficient === true) { // 如果是 credit 支付且有足够额度,显示 credit 确认弹窗 setState({ fastCheckoutInfo: { open: true, loading: false, sourceType: 'credit', amount: result.data.fastPayInfo?.amount || '0', payer: result.data.fastPayInfo?.payer, availableCredit: result.data.fastPayInfo?.amount || '0', balance: result.data.fastPayInfo?.token?.balance || '0', }, }); } else { // 如果是 credit 支付但额度不足,显示额度不足弹窗 setState({ creditInsufficientInfo: { open: true, }, }); } } else if ( (result.data.balance?.sufficient || result.data.delegation?.sufficient) && !isDonationMode && result.data.fastPayInfo ) { setState({ fastCheckoutInfo: { open: true, loading: false, sourceType: result.data.fastPayInfo.type, amount: result.data.fastPayInfo.amount, payer: result.data.fastPayInfo.payer, }, }); } else { openConnect(); } } if (['stripe'].includes(method.type)) { if (result.data.stripeContext?.status === 'succeeded') { setState({ paying: true }); } else { setTimeout(() => { setState({ stripePaying: true }); }, 200); } } } catch (err: any) { console.error(err); let shouldToast = true; const errorCode = err.response?.data?.code; if (errorCode) { if ( ![ 'QUOTE_UPDATED', 'RATE_UNAVAILABLE', 'QUOTE_LOCK_EXPIRED', 'QUOTE_AMOUNT_MISMATCH', 'QUOTE_EXPIRED_OR_USED', 'QUOTE_NOT_FOUND', 'QUOTE_REQUIRED', 'QUOTE_MAX_PAYABLE_EXCEEDED', ].includes(errorCode) ) { dispatch(`error.${errorCode}`); } // Auto-refresh for all quote-related errors (no modal dialog) if ( [ 'QUOTE_LOCK_EXPIRED', 'QUOTE_AMOUNT_MISMATCH', 'QUOTE_EXPIRED_OR_USED', 'QUOTE_NOT_FOUND', 'QUOTE_REQUIRED', 'QUOTE_MAX_PAYABLE_EXCEEDED', 'quote_validation_failed', ].includes(errorCode) ) { shouldToast = false; try { const { data: refreshed } = await api.get(`/api/checkout-sessions/retrieve/${checkoutSession.id}`, { params: { forceRefresh: '1' }, }); if (refreshed?.checkoutSession) { applyQuoteUpdate(refreshed, { reason: 'rateChanged' }); Toast.info(t('payment.checkout.quote.updated.pleaseRetry') || 'Price updated, please resubmit'); } } catch (refreshError) { console.error(refreshError); Toast.error(formatError(refreshError)); } } if (errorCode === 'QUOTE_UPDATED') { shouldToast = false; const payload = err.response?.data; if (payload?.checkoutSession) { applyQuoteUpdate(payload); } } if (errorCode === 'RATE_UNAVAILABLE') { shouldToast = false; const payload = err.response?.data; if (payload?.checkoutSession) { onQuoteUpdated?.({ checkoutSession: payload.checkoutSession, quotes: payload.quotes, rateUnavailable: payload.rateUnavailable, rateError: payload.rateError, }); } } // Final Freeze: Handle new error codes if (errorCode === 'PRICE_UNAVAILABLE') { shouldToast = false; Toast.error( t('payment.checkout.priceChange.unavailable', { fallback: 'Unable to fetch exchange rate. Please try again later.', }) ); } if (errorCode === 'PRICE_UNSTABLE') { shouldToast = false; Toast.error( t('payment.checkout.priceChange.unstable', { fallback: 'Price is volatile. Please try again later.', }) ); } if (errorCode === 'PRICE_CHANGED') { shouldToast = false; const errorData = err.response?.data; // Show price change confirmation dialog setState({ priceChangeConfirm: { open: true, changePercent: errorData?.change_percent || 0, formData: data, // Save form data for retry }, }); } if (errorCode === 'UNIFIED_APP_REQUIRED') { shouldToast = false; Toast.error(t('payment.checkout.vendor.accountRequired')); } if (errorCode === 'CUSTOMER_LIMITED') { shouldToast = false; setState({ customerLimited: true }); } if (errorCode === 'STOP_ACCEPTING_ORDERS') { shouldToast = false; setState({ serviceSuspended: true }); } } if (shouldToast) { Toast.error(formatError(err)); } } finally { setState({ submitting: false }); } }; const onFormError = (err: unknown) => { if (err) { console.error(err); } setState({ submitting: false }); }; const onAction = () => { if (state.submitting || state.paying || !canPay) { return; } if (errorRef.current && !isEmpty(errors) && isMobile) { errorRef.current.scrollIntoView({ behavior: 'smooth' }); } if (session?.user) { handleSubmit(onFormSubmit, onFormError)(); } else { if (isDonationMode) { handleSubmit(onFormSubmit, onFormError)(); return; } session?.login(() => { setState({ submitting: true }); onUserLoggedIn() .then(afterUserLoggedIn) .catch((err) => { console.error(err); setState({ submitting: false }); }); }); } }; // Lock expired handlers removed - now auto-refreshes instead const onStripeConfirm = async () => { setState({ stripePaying: false, paying: true }); await handleConnected(); }; const onStripeCancel = async () => { try { await api.post(`/api/checkout-sessions/${checkoutSession.id}/abort-stripe`); } catch (err) { console.error(err); Toast.error(formatError(err)); } finally { setState({ stripePaying: false }); } }; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ( e.key === 'Enter' && !state.submitting && !state.paying && !state.stripePaying && quantityInventoryStatus && canPay ) { onAction(); } }; window.addEventListener('keydown', handleKeyDown); return () => { window.removeEventListener('keydown', handleKeyDown); }; }, [state.submitting, state.paying, state.stripePaying, quantityInventoryStatus, canPay]); // eslint-disable-line react-hooks/exhaustive-deps const balanceLink = getTokenBalanceLink(method, state.fastCheckoutInfo?.payer || ''); const FastCheckoutConfirmDialog = state.fastCheckoutInfo && ( {t('payment.checkout.fastPay.credit.meteringSubscriptionMessage', { available: `${formatAmount(state.fastCheckoutInfo?.balance || '0', paymentCurrency?.decimal || 18)} ${paymentCurrency?.symbol}`, })} ) : ( {t('payment.checkout.fastPay.autoPaymentReason')} {t('payment.checkout.fastPay.payer')} {balanceLink && ( { window.open(balanceLink, '_blank'); }} /> )} {t('payment.checkout.fastPay.amount')} {formatAmount(state.fastCheckoutInfo.amount, paymentCurrency?.decimal || 18)}{' '} {paymentCurrency?.symbol} ) } loading={state.fastCheckoutInfo.loading} color="primary" /> ); const CreditInsufficientDialog = state.creditInsufficientInfo && ( {t('payment.checkout.fastPay.credit.insufficientMessage')}} confirm={t('common.confirm')} /> ); const PriceUpdatedDialog = priceUpdateInfo.open && ( {t( priceUpdateInfo.reason === 'rateChanged' ? 'payment.checkout.quote.priceUpdatedDescriptionRate' : 'payment.checkout.quote.priceUpdatedDescriptionRecalc' )} {priceUpdateInfo.hasQuotes && ( {t('payment.checkout.quote.priceUpdatedNewTotalLabel')} {priceUpdateInfo.total} {priceUpdateInfo.usd && ( ≈ {priceUpdateInfo.usd} {priceUpdateInfo.baseCurrency} )} {priceUpdateInfo.oldTotal && ( {t('payment.checkout.quote.priceUpdatedOldTotal', { total: priceUpdateInfo.oldTotal })} )} )} } confirm={t('payment.checkout.quote.priceUpdatedConfirm')} cancel={t('common.cancel')} color="primary" /> ); // LockExpiredDialog removed - now auto-refreshes instead const getRedirectUrl = () => { if (searchParams.redirect) { return decodeURIComponent(searchParams.redirect); } if (checkoutSession.success_url) { return checkoutSession.success_url; } if (paymentLink?.after_completion?.redirect?.url) { return paymentLink.after_completion.redirect.url; } return undefined; }; if (onlyShowBtn) { return ( <> {state.customerLimited && ( { setState({ customerLimited: false }); onAction(); }} alertMessage={t('payment.customer.pastDue.alert.customMessage')} detailLinkOptions={{ enabled: true, onClick: () => { setState({ customerLimited: false }); window.open( joinURL( getPrefix(), `/customer/invoice/past-due?referer=${encodeURIComponent(window.location.href)}` ), '_self' ); }, }} dialogProps={{ open: state.customerLimited, onClose: () => setState({ customerLimited: false }), title: t('payment.customer.pastDue.alert.title'), }} /> )} {state.serviceSuspended && ( setState({ serviceSuspended: false })} /> )} {FastCheckoutConfirmDialog} {CreditInsufficientDialog} {PriceUpdatedDialog} {/* Final Freeze: Price change confirmation dialog */} {state.priceChangeConfirm?.open && ( )} ); } return ( <> {t('payment.checkout.paymentDetails')} ( { const oldCurrencyId = field.value; field.onChange(id); setValue('payment_method', methodId); saveCurrencyPreference(id, session?.user?.did); // Call API to switch currency and clear quote-related fields if currency changed // This is essential because quotes are currency-specific (e.g., TBA quote with 18 decimals // cannot be used for USD with 2 decimals) if (oldCurrencyId && oldCurrencyId !== id) { try { const { data } = await api.put( `/api/checkout-sessions/${checkoutSession.id}/switch-currency`, { currency_id: id, payment_method_id: methodId, } ); if (data.currency_changed && onQuoteUpdated) { onQuoteUpdated({ checkoutSession: data, quotes: data.quotes }); } } catch (err) { console.error('Failed to switch currency:', err); } } }} /> )} /> {state.stripePaying && state.stripeContext && ( )} {!showForm && session?.user && ( {t('payment.checkout.customerInfo')} {customerName} {customerEmail} {checkoutSession.phone_number_collection?.enabled && customerPhone && ( {customerPhone} )} {billingAddress && ( {billingAddress.country && ( )} {checkoutSession.billing_address_collection === 'required' ? [billingAddress.line1, billingAddress.city, billingAddress.state].filter(Boolean).join(', ') : billingAddress.state || ''} {billingAddress.postal_code && ` [ ${t('payment.checkout.billing.postal_code')}: ${billingAddress.postal_code} ]`} )} )} {showForm && ( {t('payment.checkout.customer.name')} {t('payment.checkout.customer.email')} (isEmail(x) ? true : t('payment.checkout.invalid')), ...getFieldValidation( 'customer_email', checkoutSession.metadata?.page_info?.field_validation, locale ), }} /> {checkoutSession.phone_number_collection?.enabled && ( <> {t('payment.checkout.customer.phone')} { const isValid = await validatePhoneNumber(x); return isValid ? true : t('payment.checkout.invalid'); }, ...getFieldValidation( 'customer_phone', checkoutSession.metadata?.page_info?.field_validation, locale ), }} /> )} )} { onAction(); }} fullWidth loading={state.submitting || state.paying} disabled={state.stripePaying || !quantityInventoryStatus || !canPay}> {state.submitting || state.paying ? t('payment.checkout.processing') : buttonText} {['subscription', 'setup'].includes(checkoutSession.mode) && ( {showStake ? t('payment.checkout.confirm.withStake', { payee }) : t('payment.checkout.confirm.withoutStake', { payee })} )} {checkoutSession.metadata?.page_info?.form_purpose_description && ( {locale === 'zh' ? checkoutSession.metadata.page_info.form_purpose_description.zh : checkoutSession.metadata.page_info.form_purpose_description.en} )} {state.customerLimited && ( { setState({ customerLimited: false }); onAction(); }} alertMessage={t('payment.customer.pastDue.alert.customMessage')} detailLinkOptions={{ enabled: true, onClick: () => { setState({ customerLimited: false }); window.open( joinURL(getPrefix(), `/customer/invoice/past-due?referer=${encodeURIComponent(window.location.href)}`), '_self' ); }, }} dialogProps={{ open: state.customerLimited, onClose: () => setState({ customerLimited: false }), title: t('payment.customer.pastDue.alert.title'), }} /> )} {state.serviceSuspended && setState({ serviceSuspended: false })} />} {FastCheckoutConfirmDialog} {CreditInsufficientDialog} {PriceUpdatedDialog} {/* Final Freeze: Price change confirmation dialog */} {state.priceChangeConfirm?.open && ( )} {/* LockExpiredDialog removed - now auto-refreshes instead */} ); }