client/components/mma/billing/Billing.tsx (421 lines of code) (raw):

import { css } from '@emotion/react'; import { headlineBold20, headlineBold28, palette, space, textSans17, until, } from '@guardian/source/foundations'; import { capitalize } from 'lodash'; import { Fragment } from 'react'; import { parseDate } from '../../../../shared/dates'; import { featureSwitches } from '../../../../shared/featureSwitches'; import type { AppSubscription, MPAPIResponse, } from '../../../../shared/mpapiResponse'; import { AppStore, determineAppStore, isPuzzle, isValidAppSubscription, } from '../../../../shared/mpapiResponse'; import type { InvoiceDataApiItem, MembersDataApiResponse, PaidSubscriptionPlan, ProductDetail, } from '../../../../shared/productResponse'; import { getMainPlan, getSpecificProductType, isGift, isProduct, sortByJoinDate, } from '../../../../shared/productResponse'; import type { GroupedProductTypeKeys } from '../../../../shared/productTypes'; import { GROUPED_PRODUCT_TYPES } from '../../../../shared/productTypes'; import { fetchWithDefaultParameters } from '../../../utilities/fetch'; import { LoadingState, useAsyncLoader, } from '../../../utilities/hooks/useAsyncLoader'; import { allRecurringProductsDetailFetcher } from '../../../utilities/productUtils'; import { GenericErrorScreen } from '../../shared/GenericErrorScreen'; import { NAV_LINKS } from '../../shared/nav/NavConfig'; import { EmptyAccountOverview } from '../accountoverview/EmptyAccountOverview'; import { SixForSixExplainerIfApplicable } from '../accountoverview/SixForSixExplainer'; import { PageContainer } from '../Page'; import { ErrorIcon } from '../shared/assets/ErrorIcon'; import { GiftIcon } from '../shared/assets/GiftIcon'; import { JsonResponseHandler } from '../shared/asyncComponents/DefaultApiResponseHandler'; import { DefaultLoadingView } from '../shared/asyncComponents/DefaultLoadingView'; import { BasicProductInfoTable } from '../shared/BasicProductInfoTable'; import { LinkButton } from '../shared/Buttons'; import { getNextPaymentDetails } from '../shared/NextPaymentDetails'; import { PaymentDetailsTable } from '../shared/PaymentDetailsTable'; import { PaymentFailureAlertIfApplicable } from '../shared/PaymentFailureAlertIfApplicable'; import { InvoicesTable } from './InvoicesTable'; interface ProductDetailWithInvoice extends ProductDetail { invoices: InvoiceDataApiItem[]; } type ProductGroupingToProductDetails = Record< GroupedProductTypeKeys, ProductDetailWithInvoice[] >; type BillingResponse = [ MembersDataApiResponse, { invoices: InvoiceDataApiItem[] }, MPAPIResponse, ]; const subHeadingTitleCss = ` ${headlineBold28}; ${until.tablet} { font-size: 1.25rem; line-height: 1.6; }; `; const subHeadingBorderTopCss = css` border-top: 1px solid ${palette.neutral['86']}; margin: ${space[12]}px 0 ${space[5]}px; `; function decorateProductDetailWithInvoices( productDetail: ProductDetail, ): ProductDetailWithInvoice { return { ...productDetail, invoices: [] }; } function joinInvoicesWithProductsInCategories( mdapiResponse: MembersDataApiResponse, invoicesResponse: { invoices: InvoiceDataApiItem[] }, ) { const allProductDetails = mdapiResponse.products .filter(isProduct) .sort(sortByJoinDate) .map(decorateProductDetailWithInvoices); const invoiceData = invoicesResponse.invoices.sort( (a: InvoiceDataApiItem, b: InvoiceDataApiItem) => b.date.localeCompare(a.date), ); invoiceData.forEach((invoice) => { const matchingProduct = allProductDetails.find( (product) => product.subscription.subscriptionId === invoice.subscriptionName, ); if (matchingProduct) { matchingProduct.invoices.push(invoice); } }); const productGroupingToProductDetails = organiseProductsIntoCategory(allProductDetails); return { allProductDetails, productGroupingToProductDetails }; } function organiseProductsIntoCategory(allProductDetails: ProductDetail[]) { return allProductDetails.reduce((accumulator, productDetail) => { const specificProductType = getSpecificProductType(productDetail.tier); return { ...accumulator, [specificProductType.groupedProductType]: [ ...(accumulator[specificProductType.groupedProductType] || []), productDetail, ], }; }, {} as ProductGroupingToProductDetails); } function renderProductBillingInfo([productGrouping, productDetails]: [ string, ProductDetailWithInvoice[], ]) { return ( productDetails.length > 0 && ( <Fragment key={productGrouping}> {productDetails.map((productDetail) => { const mainPlan = getMainPlan(productDetail.subscription); if (!mainPlan) { throw new Error( 'mainPlan does not exist for product in billing page', ); } const specificProductType = getSpecificProductType( productDetail.tier, ); const groupedProductType = GROUPED_PRODUCT_TYPES[ specificProductType.groupedProductType ]; const hasCancellationPending = productDetail.subscription.cancelledAt; const cancelledCopy = specificProductType.cancelledCopy || groupedProductType.cancelledCopy; const nextPaymentDetails = getNextPaymentDetails( mainPlan, productDetail.subscription, null, !!productDetail.alertText, ); const paidPlan = getMainPlan( productDetail.subscription, ) as PaidSubscriptionPlan; const maybePatronSuffix = productDetail.subscription.readerType === 'Patron' ? ' - Patron' : ''; const productInvoiceData = productDetail.invoices.map( (invoice) => ({ ...invoice, pdfPath: `/api/${invoice.pdfPath}`, currency: paidPlan.currency, currencyISO: paidPlan.currencyISO, productUrlPart: specificProductType.urlPart, }), ); const resultsPerPage = paidPlan.billingPeriod?.includes( 'year', ) ? productInvoiceData.length : 6; return ( <Fragment key={productDetail.subscription.subscriptionId} > <div css={css` ${subHeadingBorderTopCss} display: flex; align-items: start; justify-content: space-between; `} > <h2 css={css` ${subHeadingTitleCss} margin: 0; `} > {specificProductType.productTitle(mainPlan)} {maybePatronSuffix} </h2> {isGift(productDetail.subscription) && ( <i css={css` margin: 4px 0 0 ${space[3]}px; `} > <GiftIcon alignArrowToThisSide={'left'} /> </i> )} </div> {hasCancellationPending && ( <p css={css` ${textSans17}; `} > <ErrorIcon fill={palette.brandAlt[200]} /> <span css={css` margin-left: ${space[2]}px; `} > {cancelledCopy}{' '} <strong> {parseDate( productDetail.subscription.end, ).dateStr()} </strong> </span> . </p> )} <BasicProductInfoTable groupedProductType={groupedProductType} productDetail={productDetail} /> <SixForSixExplainerIfApplicable additionalCss={css` ${textSans17}; `} mainPlan={mainPlan} hasCancellationPending={hasCancellationPending} /> <PaymentDetailsTable productDetail={productDetail} nextPaymentDetails={nextPaymentDetails} hasCancellationPending={hasCancellationPending} tableHeading="Payment" /> {productDetail.isPaidTier && productDetail.subscription .safeToUpdatePaymentMethod && ( <LinkButton colour={ productDetail.alertText ? palette.brand[400] : palette.brand[800] } textColour={ productDetail.alertText ? palette.neutral[100] : palette.brand[400] } fontWeight={'bold'} alert={!!productDetail.alertText} text="Update payment method" ariaLabelText={`${specificProductType.productTitle( mainPlan, )} : Update payment method`} to={`/payment/${specificProductType.urlPart}`} state={{ productDetail, flowReferrer: { title: NAV_LINKS.billing.title, link: NAV_LINKS.billing.link, }, }} /> )} {productInvoiceData.length > 0 && ( <div css={css` margin-top: ${space[12]}px; margin-bottom: ${space[3]}px; `} > <InvoicesTable resultsPerPage={resultsPerPage} invoiceData={productInvoiceData} /> </div> )} </Fragment> ); })} </Fragment> ) ); } function getAppStoreMessage(subscription: AppSubscription) { const iosMessage = 'Apple (for iOS)'; const androidMessage = 'Google (for Android)'; const appStore = determineAppStore(subscription); switch (appStore) { case AppStore.IOS: return iosMessage; case AppStore.ANDROID: return androidMessage; default: return `${iosMessage}, or ${androidMessage}`; } } function renderInAppPurchase(subscription: AppSubscription) { const tableHeadingCss = css` width: 100%; ${headlineBold20}; margin: 0; padding: ${space[3]}px ${space[5]}px; background-color: ${palette.neutral[97]}; ${until.tablet} { font-size: 1.0625rem; line-height: 1.6; padding: ${space[3]}px; } `; const puzzleOrNews = isPuzzle(subscription) ? 'puzzle' : 'news'; return ( <div css={subHeadingBorderTopCss} key={subscription.subscriptionId}> <h2 css={css` ${subHeadingTitleCss} margin: 0; `} > {capitalize(puzzleOrNews)} app </h2> <div css={css` ${textSans17}; border: 1px solid ${palette.neutral[86]}; display: flex; flex-wrap: wrap; margin: ${space[5]}px 0; `} > <h2 css={tableHeadingCss}>Payment</h2> <div css={css` padding: ${space[3]}px; `} > To change your payment setup, please contact{' '} {getAppStoreMessage(subscription)}. </div> </div> </div> ); } function BillingDetailsComponent(props: { productGroupingToProductDetails: ProductGroupingToProductDetails; }) { return ( <> {Object.entries(props.productGroupingToProductDetails).map( renderProductBillingInfo, )} </> ); } const BillingPage = () => { const { data: billingResponse, loadingState, }: { data: BillingResponse | null; loadingState: LoadingState; } = useAsyncLoader(billingFetcher, JsonResponseHandler); if (loadingState == LoadingState.HasError) { return <GenericErrorScreen />; } if (loadingState == LoadingState.IsLoading) { return ( <DefaultLoadingView loadingMessage="Loading your billing details..." /> ); } if (billingResponse === null) { return <GenericErrorScreen />; } const [mdapiResponse, invoicesResponse, mpapiResponse] = billingResponse; const appSubscriptions = mpapiResponse.subscriptions.filter( isValidAppSubscription, ); const { allProductDetails, productGroupingToProductDetails } = joinInvoicesWithProductsInCategories(mdapiResponse, invoicesResponse); if ( (allProductDetails.length === 0 && appSubscriptions.length === 0) || (allProductDetails.length === 0 && !featureSwitches.appSubscriptions) ) { return <EmptyAccountOverview />; } return ( <> <PaymentFailureAlertIfApplicable productDetails={allProductDetails} /> {featureSwitches.appSubscriptions && appSubscriptions.map(renderInAppPurchase)} <BillingDetailsComponent productGroupingToProductDetails={ productGroupingToProductDetails } /> </> ); }; const billingFetcher = () => Promise.all([ allRecurringProductsDetailFetcher(), fetchWithDefaultParameters('/api/invoices'), fetchWithDefaultParameters('/mpapi/user/mobile-subscriptions'), ]); export const Billing = () => { return ( <PageContainer selectedNavItem={NAV_LINKS.billing} pageTitle="Billing"> <BillingPage /> </PageContainer> ); };