/* eslint-disable @typescript-eslint/indent */ /* eslint-disable react/require-default-props */ /* eslint-disable react/no-unused-prop-types */ /* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable no-nested-ternary */ /* eslint-disable react/no-unstable-nested-components */ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context'; import Toast from '@arcblock/ux/lib/Toast'; import type { Paginated, TInvoiceExpanded, TSubscription } from '@blocklet/payment-types'; import { OpenInNewOutlined } from '@mui/icons-material'; import { Box, Button, CircularProgress, Stack, Typography, Tooltip, Avatar } from '@mui/material'; import { styled } from '@mui/system'; import { useInfiniteScroll, useRequest, useSetState } from 'ahooks'; import React, { useEffect, useRef, useState } from 'react'; // eslint-disable-next-line import/no-extraneous-dependencies import { useNavigate } from 'react-router-dom'; import debounce from 'lodash/debounce'; import { BN } from '@ocap/util'; import Status from '../../components/status'; import { usePaymentContext } from '../../contexts/payment'; import { useSubscription } from '../../hooks/subscription'; import api from '../../libs/api'; import StripePaymentAction from '../../components/stripe-payment-action'; import { formatCreditAmount, formatError, formatToDate, formatToDatetime, formatTime, formatExchangeRate, getInvoiceDescriptionAndReason, getInvoiceStatusColor, getTxLink, isCrossOrigin, getUsdAmountFromTokenUnits, formatUsdAmount, formatAmount, } from '../../libs/util'; import Table from '../../components/table'; import { createLink, handleNavigation, LinkInfo } from '../../libs/navigation'; type Result = Paginated & { subscription: TSubscription }; const groupByDate = (items: TInvoiceExpanded[]) => { const grouped: { [key: string]: TInvoiceExpanded[] } = {}; items.forEach((item) => { const date = new Date(item.created_at).toLocaleDateString(); if (!grouped[date]) { grouped[date] = []; } grouped[date]?.push(item); }); return grouped; }; const fetchData = (params: Record = {}): Promise => { const search = new URLSearchParams(); const mergedParams: Record = { include_quote: true, ...params }; Object.keys(mergedParams).forEach((key) => { if (mergedParams[key]) { search.set(key, String(mergedParams[key])); } }); return api.get(`/api/invoices?${search.toString()}`).then((res: any) => res.data); }; const getInvoiceQuoteInfo = (invoice: TInvoiceExpanded) => { const lines = (invoice as any).lines || []; for (const line of lines) { const quote = (line.metadata as any)?.quote; if (quote?.exchange_rate) { return quote; } } return null; }; type Props = { customer_id?: string; subscription_id?: string; currency_id?: string; include_staking?: boolean; include_return_staking?: boolean; include_recovered_from?: boolean; status?: string; pageSize?: number; target?: string; action?: string; type?: 'list' | 'table'; onTableDataChange?: Function; relatedSubscription?: boolean; }; const getInvoiceLink = (invoice: TInvoiceExpanded, action?: string) => { if (invoice.id.startsWith('in_')) { const path = `/customer/invoice/${invoice.id}${invoice.status === 'uncollectible' && action ? `?action=${action}` : ''}`; return { connect: invoice.status === 'uncollectible', link: createLink(path), }; } return { connect: false, link: createLink(getTxLink(invoice.paymentMethod, invoice.metadata?.payment_details).link, true), }; }; const linkStyle = { cursor: 'pointer', }; const InvoiceTable = React.memo((props: Props & { onPay: (invoiceId: string) => void }) => { const { pageSize, target, action, onPay, status, customer_id, currency_id, subscription_id, include_staking, include_return_staking, include_recovered_from, onTableDataChange, relatedSubscription, } = props; const listKey = 'invoice-table'; const { t, locale } = useLocaleContext(); const navigate = useNavigate(); const { getCurrency } = usePaymentContext(); const [search, setSearch] = useState<{ pageSize: number; page: number }>({ pageSize: pageSize || 10, page: 1, }); const { loading, data = { list: [], count: 0 }, refresh, } = useRequest( () => fetchData({ ...search, status, customer_id, currency_id, subscription_id, include_staking, include_return_staking, include_recovered_from, ignore_zero: true, }), { refreshDeps: [search, status, customer_id, currency_id, subscription_id, include_staking, include_recovered_from], } ); const prevData = useRef(data); useEffect(() => { if (onTableDataChange) { onTableDataChange(data, prevData.current); prevData.current = data; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [data]); const subscription = useSubscription('events'); const debouncedHandleInvoicePaid = debounce( async () => { Toast.close(); Toast.success(t('payment.customer.invoice.paySuccess')); await refresh(); }, 1000, { leading: false, trailing: true, maxWait: 5000, } ); useEffect(() => { if (subscription && customer_id) { subscription.on('invoice.paid', ({ response }: { response: TInvoiceExpanded }) => { if (response.customer_id === customer_id) { debouncedHandleInvoicePaid(); } }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [subscription]); const handleLinkClick = (e: React.MouseEvent, invoice: TInvoiceExpanded) => { const { link } = getInvoiceLink(invoice, action); handleNavigation(e, link, navigate, { target: link.external ? '_blank' : target }); }; const handleRelatedSubscriptionClick = (e: React.MouseEvent, invoice: TInvoiceExpanded) => { if (invoice.subscription_id) { handleNavigation(e, createLink(`/customer/subscription/${invoice.subscription_id}`), navigate); } }; const columns = [ { label: t('common.amount'), name: 'total', width: 80, align: 'right', options: { customBodyRenderLite: (_: string, index: number) => { const invoice = data?.list[index] as TInvoiceExpanded; const isVoid = invoice.status === 'void'; const quoteInfo = getInvoiceQuoteInfo(invoice); const providers = quoteInfo?.providers || []; const providerNames = providers.map((provider: any) => provider.provider_name).filter(Boolean); const providerDisplay = providerNames.length > 0 ? providerNames.join(', ') : quoteInfo?.rate_provider_name || quoteInfo?.rate_provider_id || '—'; const providerRates = quoteInfo?.providers ?.map((provider: any) => { const name = provider.provider_name || provider.provider_id || '—'; return provider.rate ? `${name}` : name; }) .filter(Boolean) || []; const rateTimestamp = quoteInfo?.rate_timestamp_ms ? formatTime(quoteInfo.rate_timestamp_ms) : '—'; const formattedRate = formatExchangeRate(quoteInfo?.exchange_rate || null); const rateLine = formattedRate ? (() => { const currencyMap = { USD: '$', CNY: '¥', }; const currencySymbol = currencyMap[quoteInfo?.base_currency as keyof typeof currencyMap]; return `1 ${invoice.paymentCurrency.symbol} ≈ ${ currencySymbol ? `${currencySymbol}${formattedRate}` : `${formattedRate} ${quoteInfo?.base_currency || 'USD'}` }`; })() : null; let usdAmount: string | null = null; if (quoteInfo?.base_amount) { usdAmount = formatUsdAmount(quoteInfo.base_amount, locale); } else if (quoteInfo?.exchange_rate && invoice.total) { const calculatedUsd = getUsdAmountFromTokenUnits( new BN(invoice.total), invoice.paymentCurrency.decimal, quoteInfo.exchange_rate ); if (calculatedUsd) { usdAmount = formatUsdAmount(calculatedUsd, locale); } } const tooltipContent = quoteInfo ? ( {t('payment.customer.invoice.quote.providers')}: {(providerRates.length > 0 ? providerRates.join(', ') : providerDisplay) || '—'} {rateLine && ( {t('payment.customer.invoice.quote.exchangeRate')}: {rateLine} )} {t('payment.customer.invoice.quote.rateTimestamp')}: {rateTimestamp} ) : null; return ( handleLinkClick(e, invoice)} sx={linkStyle}> {formatAmount(invoice.total, invoice.paymentCurrency.decimal)}  {invoice.paymentCurrency.symbol} {(usdAmount || rateLine) && ( {usdAmount && ( ≈ ${usdAmount} )} )} ); }, }, }, { label: t('common.paymentMethod'), name: 'paymentMethod', options: { customBodyRenderLite: (_: string, index: number) => { const invoice = data?.list[index] as TInvoiceExpanded; return ( handleLinkClick(e, invoice)}> {invoice.paymentMethod.name} ); }, }, }, { label: t('common.type'), name: 'billing_reason', options: { customBodyRenderLite: (_: string, index: number) => { const invoice = data.list[index] as TInvoiceExpanded; return ( handleLinkClick(e, invoice)} sx={linkStyle}> ); }, }, }, { label: t('payment.customer.invoice.invoiceNumber'), name: 'number', options: { customBodyRenderLite: (_: string, index: number) => { const invoice = data?.list[index] as TInvoiceExpanded; return ( handleLinkClick(e, invoice)} sx={linkStyle}> {invoice?.number} ); }, }, }, ...(relatedSubscription ? [ { label: t('common.purchaseItems'), name: 'purchase_items', options: { customBodyRenderLite: (_: string, index: number) => { const invoice = data?.list[index] as TInvoiceExpanded; const lines = (invoice as any).lines || []; const items = lines .map((line: any) => { const name = line.price?.product?.name || line.description; if (!name) { return null; } const quantity = Number(line.quantity || 0); const label = Number.isFinite(quantity) && quantity > 1 ? `${name} x${quantity}` : name; const lineKey = line.id || line.price?.id || line.price?.product?.id || line.description || name; return { key: String(lineKey), label }; }) .filter(Boolean) as Array<{ key: string; label: string }>; if (items.length === 0 && invoice.subscription?.description) { items.push({ key: `subscription-${invoice.subscription_id || invoice.id}`, label: invoice.subscription.description, }); } const isSubscription = Boolean(invoice.subscription_id); const clickableProps = isSubscription ? { onClick: (e: React.MouseEvent) => handleRelatedSubscriptionClick(e, invoice) } : {}; if (items.length === 0) { return ( {t('common.none')} ); } return ( {items.map((item) => ( {item.label} ))} ); }, }, }, { label: t('common.credits'), name: 'credits', options: { customBodyRenderLite: (_: string, index: number) => { const invoice = data?.list[index] as TInvoiceExpanded; const lines = (invoice as any).lines || []; const creditItems: Array<{ key: string; label: string }> = []; lines.forEach((line: any) => { const lineKey = String( line.id || line.price?.id || line.price?.product?.id || line.description || invoice.id ); const pushCreditItem = (suffix: string, label: string) => { creditItems.push({ key: `${lineKey}-${suffix}`, label }); }; const creditConfig = line.price?.metadata?.credit_config; const creditAmount = Number(creditConfig?.credit_amount || 0); if (creditAmount > 0) { const quantity = Number(line.quantity || 0); const totalAmount = creditAmount * (Number.isFinite(quantity) && quantity > 0 ? quantity : 1); const currencySymbol = getCurrency(creditConfig?.currency_id)?.symbol || creditConfig?.currency_id || 'Credits'; pushCreditItem('amount', `+${formatCreditAmount(String(totalAmount), currencySymbol)}`); return; } const scheduleConfig = creditConfig?.schedule; const scheduledAmount = Number(scheduleConfig?.amount_per_grant || 0); if (scheduleConfig?.enabled && scheduleConfig?.delivery_mode === 'schedule' && scheduledAmount > 0) { const quantity = Number(line.quantity || 0); const totalAmount = scheduledAmount * (Number.isFinite(quantity) && quantity > 0 ? quantity : 1); const currencySymbol = getCurrency(creditConfig?.currency_id)?.symbol || creditConfig?.currency_id || 'Credits'; pushCreditItem('schedule', `+${formatCreditAmount(String(totalAmount), currencySymbol)}`); return; } const creditInfo = (line.price as any)?.credit; const creditInfoAmount = Number(creditInfo?.amount || 0); if (creditInfoAmount > 0) { const currencySymbol = creditInfo.currency?.symbol || 'Credits'; pushCreditItem('credit', `+${formatCreditAmount(String(creditInfoAmount), currencySymbol)}`); } }); if (creditItems.length === 0) { return '-'; } return ( {creditItems.map((creditItem) => ( {creditItem.label} ))} ); }, }, }, ] : []), { label: t('common.updatedAt'), name: 'name', options: { customBodyRenderLite: (val: string, index: number) => { const invoice = data?.list[index] as TInvoiceExpanded; const periodTooltip = invoice.subscription_id && invoice.period_start && invoice.period_end ? `${t('common.billingPeriod')}: ${formatToDate(invoice.period_start * 1000, locale, 'YYYY-MM-DD HH:mm')} ~ ${formatToDate(invoice.period_end * 1000, locale, 'YYYY-MM-DD HH:mm')}` : ''; return ( handleLinkClick(e, invoice)} sx={linkStyle}> {formatToDate( invoice.created_at, locale, relatedSubscription ? 'YYYY-MM-DD HH:mm' : 'YYYY-MM-DD HH:mm:ss' )} ); }, }, }, { label: t('common.status'), name: 'status', options: { customBodyRenderLite: (val: string, index: number) => { const invoice = data?.list[index] as TInvoiceExpanded; const hidePay = invoice.billing_reason === 'overdraft-protection'; const { connect } = getInvoiceLink(invoice, action); const isVoid = invoice.status === 'void'; if (action && !hidePay) { if (connect) { if (invoice.paymentMethod?.type === 'stripe') { return ( { refresh(); }}> {(handlePay, paying) => ( )} ); } return ( ); } return ( ); } return ( handleLinkClick(e, invoice)} sx={linkStyle}> {isVoid ? ( ) : ( )} ); }, }, }, ]; const onTableChange = ({ page, rowsPerPage }: any) => { if (search.pageSize !== rowsPerPage) { setSearch((x) => ({ ...x, pageSize: rowsPerPage, page: 1 })); } else if (search.page !== page + 1) { setSearch((x) => ({ ...x, page: page + 1 })); } }; return ( ); }); const InvoiceTableRoot = styled(Box)` @media (max-width: ${({ theme }) => theme.breakpoints.values.md}px) { .MuiTable-root > .MuiTableBody-root > .MuiTableRow-root > td.MuiTableCell-root { > div { width: fit-content; flex: inherit; font-size: 14px; } } .invoice-summary { padding-right: 20px; } } `; const InvoiceList = React.memo((props: Props & { onPay: (invoiceId: string) => void }) => { const { customer_id, subscription_id, include_recovered_from, currency_id, include_staking, status, pageSize, target, action, onPay, onTableDataChange, } = props; const size = pageSize || 10; const subscription = useSubscription('events'); const { t, locale } = useLocaleContext(); const navigate = useNavigate(); const { data, loadMore, loadingMore, loading, reloadAsync } = useInfiniteScroll( (d) => { const page = d ? Math.ceil(d.list.length / size) + 1 : 1; return fetchData({ page, pageSize: size, status, customer_id, currency_id, subscription_id, include_staking, include_recovered_from, ignore_zero: true, }); }, { reloadDeps: [customer_id, subscription_id, status, include_staking, include_recovered_from], } ); const prevData = useRef(data); useEffect(() => { if (onTableDataChange) { onTableDataChange(data, prevData.current); prevData.current = data; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [data]); const debouncedHandleInvoicePaid = debounce( async () => { Toast.close(); Toast.success(t('payment.customer.invoice.paySuccess')); await reloadAsync(); }, 1000, { leading: false, trailing: true, maxWait: 5000, } ); // Listen to invoice.paid event and refresh data useEffect(() => { if (subscription && customer_id) { subscription.on('invoice.paid', ({ response }: { response: TInvoiceExpanded }) => { if (response.customer_id === customer_id) { debouncedHandleInvoicePaid(); } }); } }, [subscription]); // eslint-disable-line react-hooks/exhaustive-deps if (loading || !data) { return ; } if (data && data.list.length === 0) { if (data.subscription && ['active', 'trialing'].includes(data.subscription.status)) { return ( {t('payment.customer.invoice.next', { date: formatToDatetime(data.subscription.current_period_end * 1000) })} ); } return ( {t('payment.customer.invoice.empty')} ); } const hasMore = data && data.list.length < data.count; const grouped = groupByDate(data.list as any); const handleLinkClick = (e: React.MouseEvent, link: LinkInfo) => { handleNavigation(e, link, navigate, { target: link.external ? '_blank' : target }); }; return ( {Object.entries(grouped).map(([date, invoices]) => ( {date} {invoices.map((invoice) => { const { link, connect } = getInvoiceLink(invoice, action); const isVoid = invoice.status === 'void'; const quoteInfo = getInvoiceQuoteInfo(invoice); const formattedRate = formatExchangeRate(quoteInfo?.exchange_rate || null); const rateLine = formattedRate ? `1 ${invoice.paymentCurrency.symbol} ≈ ${formattedRate} ${quoteInfo?.base_currency || 'USD'}` : null; return ( handleLinkClick(e, link)}> {invoice.number} {link.external && ( )} {formatAmount(invoice.total, invoice.paymentCurrency.decimal)}  {invoice.paymentCurrency.symbol} {rateLine && ( {rateLine} )} {formatToDate(invoice.created_at, locale, 'HH:mm:ss')} {!action && ( {invoice.description || invoice.id} )} {action ? ( connect ? ( invoice.paymentMethod?.type === 'stripe' ? ( { await reloadAsync(); }}> {(handlePay, paying) => ( )} ) : ( ) ) : ( ) ) : isVoid ? ( ) : ( )} ); })} ))} {hasMore && ( )} {!hasMore && data.count > size && ( {t('common.noMore', { resource: t('payment.customer.invoices') })} )} ); }); export default function CustomerInvoiceList(rawProps: Props) { const props = Object.assign( { customer_id: '', subscription_id: '', currency_id: '', include_staking: false, include_recovered_from: false, status: 'open,paid,uncollectible', pageSize: 10, target: '_self', action: '', type: 'list', onTableDataChange: () => {}, relatedSubscription: false, }, rawProps ); // eslint-disable-next-line react/prop-types const { action, type } = props; const { t, locale } = useLocaleContext(); const { connect } = usePaymentContext(); const [state, setState] = useSetState({ paying: '' }); const onPay = (invoiceId: string) => { if (state.paying) { return; } setState({ paying: invoiceId }); connect.open({ action: 'collect', saveConnect: false, locale: locale as 'en' | 'zh', useSocket: isCrossOrigin() === false, messages: { scan: '', title: t(`payment.customer.invoice.${action || 'pay'}`), success: t(`payment.customer.invoice.${action || 'pay'}Success`), error: t(`payment.customer.invoice.${action || 'pay'}Error`), confirm: '', } as any, extraParams: { invoiceId, action }, onSuccess: () => { connect.close(); setState({ paying: '' }); }, onClose: () => { connect.close(); setState({ paying: '' }); }, onError: (err: any) => { setState({ paying: '' }); Toast.error(formatError(err)); }, }); }; if (type === 'table') { return ; } return ; } const Root = styled(Stack)` @media (max-width: ${({ theme }) => theme.breakpoints.values.md}px) { svg.MuiSvgIcon-root { display: none !important; } } a.MuiButton-root { text-decoration: none !important; } `;