/* eslint-disable @typescript-eslint/indent */ import { useEffect, useMemo, useRef, useState } from 'react'; import { Button, Typography, Stack, Alert, SxProps, useTheme } from '@mui/material'; import { useLocaleContext } from '@arcblock/ux/lib/Locale/context'; import useArcblockBrowser from '@arcblock/react-hooks/lib/useBrowser'; import Toast from '@arcblock/ux/lib/Toast'; import { joinURL } from 'ufo'; import type { Customer, Invoice, PaymentCurrency, PaymentMethod, Subscription, TInvoiceExpanded, } from '@blocklet/payment-types'; import { useRequest } from 'ahooks'; import pWaitFor from 'p-wait-for'; import Dialog from '@arcblock/ux/lib/Dialog/dialog'; import { CheckCircle as CheckCircleIcon } from '@mui/icons-material'; import debounce from 'lodash/debounce'; import { usePaymentContext } from '../contexts/payment'; import { formatAmount, formatError, getPrefix, isCrossOrigin, primaryContrastColor } from '../libs/util'; import { useSubscription } from '../hooks/subscription'; import api from '../libs/api'; import LoadingButton from './loading-button'; import StripePaymentAction from './stripe-payment-action'; type DialogProps = { open?: boolean; onClose?: () => void; title?: string; }; type DetailLinkOptions = { enabled?: boolean; onClick?: (e: React.MouseEvent) => void; title?: string; }; type Props = { subscriptionId?: string; customerId?: string; mode?: 'default' | 'custom'; onPaid?: (id: string, currencyId: string, type: 'subscription' | 'customer') => void; dialogProps?: DialogProps; detailLinkOptions?: DetailLinkOptions; successToast?: boolean; alertMessage?: string; // only for customer children?: ( handlePay: (item: SummaryItem) => void, data: { subscription?: Subscription; summary: { [key: string]: SummaryItem }; invoices: Invoice[]; subscriptionCount?: number; detailUrl: string; } ) => React.ReactNode; authToken?: string; }; type SummaryItem = { amount: string; currency: PaymentCurrency; method: PaymentMethod; }; type OverdueInvoicesResult = { subscription?: Subscription; summary: { [key: string]: SummaryItem }; invoices: Invoice[]; subscriptionCount?: number; customer?: Customer; }; const fetchOverdueInvoices = async (params: { subscriptionId?: string; customerId?: string; authToken?: string; }): Promise => { if (!params.subscriptionId && !params.customerId) { throw new Error('Either subscriptionId or customerId must be provided'); } let url; if (params.subscriptionId) { url = `/api/subscriptions/${params.subscriptionId}/overdue/invoices`; } else { url = `/api/customers/${params.customerId}/overdue/invoices`; } const res = await api.get(params.authToken ? joinURL(url, `?authToken=${params.authToken}`) : url); return res.data; }; function OverdueInvoicePayment({ subscriptionId = undefined, customerId = undefined, mode = 'default', dialogProps = { open: true, }, children = undefined, onPaid = () => {}, detailLinkOptions = { enabled: true }, successToast = true, alertMessage = '', authToken = undefined, }: Props) { const { t, locale } = useLocaleContext(); const browser = useArcblockBrowser(); const inArcSphere = browser?.arcSphere; const theme = useTheme(); const { connect, session } = usePaymentContext(); const [selectCurrencyId, setSelectCurrencyId] = useState(''); const [payLoading, setPayLoading] = useState(false); const [dialogOpen, setDialogOpen] = useState(dialogProps.open || false); const [processedCurrencies, setProcessedCurrencies] = useState<{ [key: string]: number }>({}); const [paymentStatus, setPaymentStatus] = useState<{ [key: string]: 'success' | 'error' | 'idle' | 'processing' }>( {} ); const [stripePaymentInProgress, setStripePaymentInProgress] = useState<{ [key: string]: boolean }>({}); const stripePaymentInProgressRef = useRef(stripePaymentInProgress); const sourceType = subscriptionId ? 'subscription' : 'customer'; const effectiveCustomerId = customerId || session?.user?.did; const sourceId = subscriptionId || effectiveCustomerId; const customerIdRef = useRef(effectiveCustomerId); useEffect(() => { stripePaymentInProgressRef.current = stripePaymentInProgress; }, [stripePaymentInProgress]); const { data = { summary: {}, invoices: [], } as OverdueInvoicesResult, error, loading, runAsync: refresh, } = useRequest(() => fetchOverdueInvoices({ subscriptionId, customerId: effectiveCustomerId, authToken }), { ready: !!subscriptionId || !!effectiveCustomerId, onSuccess: (res) => { if (res.customer?.id && res.customer?.id !== customerIdRef.current) { customerIdRef.current = res.customer?.id; } }, }); const detailUrl = useMemo(() => { if (subscriptionId) { return joinURL(getPrefix(), `/customer/subscription/${subscriptionId}`); } if (effectiveCustomerId) { return joinURL(getPrefix(), '/customer/invoice/past-due'); } return ''; }, [subscriptionId, effectiveCustomerId]); const summaryList = useMemo(() => { if (!data?.summary) { return []; } return Object.values(data.summary); }, [data?.summary]); const checkAndHandleInvoicePaid = async (currencyId: string) => { // If Stripe payment is in progress, check if it's complete before refreshing if (stripePaymentInProgressRef.current[currencyId]) { try { const checkData = await fetchOverdueInvoices({ subscriptionId, customerId: effectiveCustomerId, authToken, }); const hasRemainingInvoices = checkData.invoices?.some((inv: Invoice) => inv.currency_id === currencyId); // Only refresh UI when all invoices are paid if (hasRemainingInvoices) { return; } // Clear Stripe payment state setStripePaymentInProgress((prev) => { const newState = { ...prev }; delete newState[currencyId]; return newState; }); setPaymentStatus((prev) => ({ ...prev, [currencyId]: 'success' })); } catch (err) { console.error('Error checking Stripe payment completion:', err); return; } } // Now refresh and update UI if (successToast) { Toast.close(); Toast.success(t('payment.customer.invoice.paySuccess')); } setPayLoading(false); const res = await refresh(); if (res.invoices?.length === 0) { setDialogOpen(false); onPaid(sourceId as string, currencyId, sourceType as 'subscription' | 'customer'); } }; const debouncedHandleInvoicePaid = debounce((currencyId: string) => checkAndHandleInvoicePaid(currencyId), 1000, { leading: false, trailing: true, maxWait: 5000, }); const isCrossOriginRequest = isCrossOrigin(); const subscription = useSubscription('events'); const waitForInvoicePaidByCurrency = async (currencyId: string) => { let isPaid = false; await pWaitFor( async () => { const checkData = await fetchOverdueInvoices({ subscriptionId, customerId: effectiveCustomerId, authToken, }); const hasRemainingInvoices = checkData.invoices?.some((inv: Invoice) => inv.currency_id === currencyId); isPaid = !hasRemainingInvoices; return isPaid; }, { interval: 2000, timeout: 3 * 60 * 1000 } ); return isPaid; }; const handleConnected = async (currencyId: string, isStripe = false) => { if (isCrossOriginRequest || inArcSphere) { try { const paid = await waitForInvoicePaidByCurrency(currencyId); if (paid) { setPaymentStatus((prev) => ({ ...prev, [currencyId]: 'success' })); const finalRes = await refresh(); if (successToast) { Toast.close(); Toast.success(t('payment.customer.invoice.paySuccess')); } if (finalRes.invoices?.length === 0) { setDialogOpen(false); onPaid(sourceId as string, currencyId, sourceType as 'subscription' | 'customer'); } } } catch (err) { console.error('Check payment status failed:', err); setPaymentStatus((prev) => ({ ...prev, [currencyId]: 'error' })); } finally { setPayLoading(false); } } else if (isStripe) { setStripePaymentInProgress((prev) => ({ ...prev, [currencyId]: true })); setPaymentStatus((prev) => ({ ...prev, [currencyId]: 'processing' })); } }; useEffect(() => { if (!subscription || isCrossOriginRequest || inArcSphere) { return undefined; } const handleInvoicePaid = ({ response }: { response: TInvoiceExpanded }) => { const relevantId = subscriptionId || response.customer_id; const uniqueKey = `${relevantId}-${response.currency_id}`; if ( (subscriptionId && response.subscription_id === subscriptionId) || (effectiveCustomerId && effectiveCustomerId === response.customer_id) || (customerIdRef.current && customerIdRef.current === response.customer_id) ) { if (!processedCurrencies[uniqueKey]) { setProcessedCurrencies((prev) => ({ ...prev, [uniqueKey]: 1 })); debouncedHandleInvoicePaid(response.currency_id); } } }; subscription.on('invoice.paid', handleInvoicePaid); return () => { subscription.off('invoice.paid', handleInvoicePaid); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [subscription, subscriptionId, effectiveCustomerId]); const handlePay = (item: SummaryItem) => { const { currency, method } = item; if (method.type === 'stripe') { Toast.error(t('payment.subscription.overdue.notSupport')); return; } if (payLoading) { return; } setSelectCurrencyId(currency.id); setPayLoading(true); setPaymentStatus((prev) => ({ ...prev, [currency.id]: 'idle', })); if (['arcblock', 'ethereum', 'base'].includes(method.type)) { const extraParams: any = { currencyId: currency.id }; if (subscriptionId) { extraParams.subscriptionId = subscriptionId; } else if (effectiveCustomerId) { extraParams.customerId = effectiveCustomerId; } connect.open({ locale: locale as 'en' | 'zh', containerEl: undefined as unknown as Element, saveConnect: false, action: 'collect-batch', prefix: joinURL(getPrefix(), '/api/did'), useSocket: !isCrossOriginRequest, extraParams, messages: { scan: t('common.connect.defaultScan'), title: t('payment.customer.invoice.payBatch'), confirm: t('common.connect.confirm'), } as any, onSuccess: () => { connect.close(); handleConnected(currency.id); setPayLoading(false); setPaymentStatus((prev) => ({ ...prev, [currency.id]: 'success', })); }, onClose: () => { connect.close(); setPayLoading(false); }, onError: (err: any) => { Toast.error(formatError(err)); setPaymentStatus((prev) => ({ ...prev, [currency.id]: 'error', })); setPayLoading(false); }, }); } }; const handleClose = () => { setDialogOpen(false); dialogProps.onClose?.(); }; const handleViewDetailClick = (e: React.MouseEvent) => { if (detailLinkOptions.onClick) { e.preventDefault(); detailLinkOptions.onClick(e); } else if (!detailLinkOptions.enabled) { e.preventDefault(); handleClose(); } }; if (loading) { return null; } const getDetailLinkText = () => { if (detailLinkOptions.title) { return detailLinkOptions.title; } if (subscriptionId) { return t('payment.subscription.overdue.view'); } return t('payment.customer.pastDue.view'); }; const renderPayButton = ( item: SummaryItem, primaryButton = true, options: { variant?: 'contained' | 'text'; sx?: SxProps; } = { variant: 'contained', } ) => { const { currency } = item; const inProcess = payLoading && selectCurrencyId === currency.id; const status = paymentStatus[currency.id] || 'idle'; const containedColorSx = (options?.variant || 'contained') === 'contained' ? { color: (th: any) => primaryContrastColor(th) } : {}; if (status === 'success') { return ( ); } if (item.method.type === 'stripe') { let buttonText = t('payment.subscription.overdue.payNow'); if (status === 'error') { buttonText = t('payment.subscription.overdue.retry'); } else if (status === 'processing') { buttonText = t('payment.subscription.overdue.processing'); } return ( { handleConnected(currency.id, true); }} onError={() => { setPaymentStatus((prev) => ({ ...prev, [currency.id]: 'error' })); setStripePaymentInProgress((prev) => ({ ...prev, [currency.id]: false })); }}> {(onPay: () => void, paying: boolean) => ( {buttonText} )} ); } return ( handlePay(item)} sx={{ ...containedColorSx, ...((options?.sx || {}) as any) }}> {status === 'error' ? t('payment.subscription.overdue.retry') : t('payment.subscription.overdue.payNow')} ); }; const getMethodText = (method: PaymentMethod) => { if (method.name && method.type !== 'arcblock') { return ` (${method.name})`; } return ''; }; const getOverdueTitle = () => { if (subscriptionId && data.subscription) { if (summaryList.length === 1) { return t('payment.subscription.overdue.title', { name: data.subscription?.description, count: data.invoices?.length, total: formatAmount(summaryList[0]?.amount, summaryList[0]?.currency?.decimal), symbol: summaryList[0]?.currency?.symbol, method: getMethodText(summaryList[0]?.method), }); } return t('payment.subscription.overdue.simpleTitle', { name: data.subscription?.description, count: data.invoices?.length, }); } if (effectiveCustomerId) { let title = ''; if (summaryList.length === 1) { title = t('payment.customer.overdue.title', { subscriptionCount: data.subscriptionCount || 0, count: data.invoices?.length, total: formatAmount(summaryList[0]?.amount, summaryList[0]?.currency?.decimal), symbol: summaryList[0]?.currency?.symbol, method: getMethodText(summaryList[0]?.method), }); } else { title = t('payment.customer.overdue.simpleTitle', { subscriptionCount: data.subscriptionCount || 0, count: data.invoices?.length, }); } if (alertMessage) { return `${title}${alertMessage}`; } return `${title}${t('payment.customer.overdue.defaultAlert')}`; } return ''; }; const getEmptyStateMessage = () => { if (subscriptionId && data.subscription) { return t('payment.subscription.overdue.empty', { name: data.subscription?.description, }); } return t('payment.customer.overdue.empty'); }; if (mode === 'custom' && children && typeof children === 'function') { return ( {children(handlePay, { subscription: data?.subscription, summary: data?.summary as { [key: string]: SummaryItem }, invoices: data?.invoices as Invoice[], subscriptionCount: data?.subscriptionCount, detailUrl, })} ); } return ( {error ? ( {error.message} ) : ( {summaryList.length === 0 && ( <> {getEmptyStateMessage()} )} {summaryList.length === 1 && ( <> {getOverdueTitle()} {detailLinkOptions.enabled && ( <>
{t('payment.subscription.overdue.description')} {getDetailLinkText()} )}
{/* @ts-ignore */} {renderPayButton(summaryList[0])} )} {summaryList.length > 1 && ( <> {getOverdueTitle()} {detailLinkOptions.enabled && ( <>
{t('payment.subscription.overdue.description')} {getDetailLinkText()} )}
{t('payment.subscription.overdue.list')} {summaryList.map((item: any) => ( theme.palette.grey[100], }, mt: 0, }}> {t('payment.subscription.overdue.total', { total: formatAmount(item?.amount, item?.currency?.decimal), currency: item?.currency?.symbol, method: getMethodText(item?.method), })} {renderPayButton(item, false, { variant: 'text', sx: { color: 'text.link', }, })} ))} )}
)}
); } export default OverdueInvoicePayment;