client/components/mma/paymentUpdate/PaymentDetailUpdate.tsx (545 lines of code) (raw):

import { css } from '@emotion/react'; import { from, headlineBold28, palette, space, textSans17, until, } from '@guardian/source/foundations'; import { Button, Radio, RadioGroup, SvgArrowRightStraight, } from '@guardian/source/react-components'; import { ErrorSummary } from '@guardian/source-development-kitchen/react-components'; import * as Sentry from '@sentry/browser'; import type { PaymentMethod as StripeCheckoutSessionPaymentMethod } from '@stripe/stripe-js'; import type * as React from 'react'; import { useCallback, useContext, useEffect, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { DirectDebitGatewayOwner } from '@/shared/directDebit'; import { getScopeFromRequestPathOrEmptyString, X_GU_ID_FORWARDED_SCOPE, } from '../../../../shared/identity'; import type { MembersDataApiResponse, ProductDetail, Subscription, WithSubscription, } from '../../../../shared/productResponse'; import { isObserverProduct } from '../../../../shared/productResponse'; import { getMainPlan, isPaidSubscriptionPlan, isProduct, } from '../../../../shared/productResponse'; import { type ProductType, type WithProductType, } from '../../../../shared/productTypes'; import { trackEvent } from '../../../utilities/analytics'; import { createProductDetailFetch } from '../../../utilities/productUtils'; import { getStripeKeyByProduct } from '../../../utilities/stripe'; import { processResponse } from '../../../utilities/utils'; import { GenericErrorScreen } from '../../shared/GenericErrorScreen'; import { SupportTheGuardianButton } from '../../shared/SupportTheGuardianButton'; import { DirectDebitLogo } from '../shared/assets/DirectDebitLogo'; import { cardTypeToSVG } from '../shared/CardDisplay'; import { OverlayLoader } from '../shared/OverlayLoader'; import { augmentPaymentFailureAlertText } from '../shared/PaymentFailureAlertIfApplicable'; import { CardInputForm } from './card/CardInputForm'; import { NewCardPaymentMethodDetail, type StripePaymentMethod as StripeCardPaymentMethod, } from './card/NewCardPaymentMethodDetail'; import { StripeCheckoutSessionButton, StripeCheckoutSessionPaymentMethodType, } from './card/StripeCheckoutSessionButton'; import { ContactUs } from './ContactUs'; import { CurrentPaymentDetails } from './CurrentPaymentDetail'; import { DirectDebitInputForm } from './dd/DirectDebitInputForm'; import type { NewPaymentMethodDetail } from './NewPaymentMethodDetail'; import type { PaymentUpdateContextInterface } from './PaymentDetailUpdateContainer'; import { PaymentUpdateContext } from './PaymentDetailUpdateContainer'; export enum PaymentMethod { Card = 'Credit card / debit card', PayPal = 'PayPal', DirectDebit = 'Direct debit', ResetRequired = 'ResetRequired', Free = 'FREE', Unknown = 'Unknown', } const subHeadingCss = css` border-top: 1px solid ${palette.neutral['86']}; ${headlineBold28}; margin-top: 50px; ${until.tablet} { font-size: 1.25rem; line-height: 1.6; } ; `; interface PaymentMethodProps { value: PaymentMethod; updatePaymentMethod: (newPaymentMethod: PaymentMethod) => void; directDebitIsAllowed: boolean; } interface PaymentMethodRadioButtonProps extends PaymentMethodProps { paymentMethod: PaymentMethod; } export function getLogos(paymentMethod: PaymentMethod) { if (paymentMethod === PaymentMethod.Card) { return ( <> {cardTypeToSVG('visa')} {cardTypeToSVG('mastercard')} {cardTypeToSVG('americanexpress')} </> ); } else if (paymentMethod === PaymentMethod.DirectDebit) { return ( <DirectDebitLogo fill={palette.brand[400]} additionalCss={css` width: 47px; height: 16px; `} /> ); } } const PaymentMethodRadioButton = (props: PaymentMethodRadioButtonProps) => { const isChecked = props.value === props.paymentMethod; const defaultRadioStyles = css` display: flex; align-items: center; padding: ${space[4]}px; margin-bottom: ${space[4]}px; ${textSans17}; line-height: normal; font-weight: bold; color: ${palette.neutral[46]}; border-radius: 4px; box-shadow: inset 0px 0px 0px 2px ${palette.neutral[46]}; cursor: pointer; &:hover { box-shadow: inset 0px 0px 0px 4px ${palette.brand[500]}; } `; const checkedRadioStyles = css` box-shadow: inset 0px 0px 0px 4px ${palette.brand[500]}; background-color: #e3f6ff; color: ${palette.brand[400]}; `; return ( <label data-cy={props.paymentMethod} css={css` ${defaultRadioStyles} ${isChecked && checkedRadioStyles} `} > <Radio checked={isChecked} onChange={(changeEvent: React.ChangeEvent<HTMLInputElement>) => props.updatePaymentMethod( changeEvent.target.value as PaymentMethod, ) } value={props.paymentMethod} /> {props.paymentMethod} <div css={css` display: none; margin-left: auto; ${from.mobileMedium} { display: flex; } `} > {getLogos(props.paymentMethod)} </div> </label> ); }; export const SelectPaymentMethod = ( props: PaymentMethodProps & { currentPaymentMethod: string | undefined }, ) => ( <form> <RadioGroup label="Select payment method" hideLabel> <PaymentMethodRadioButton paymentMethod={PaymentMethod.Card} {...props} /> {props.directDebitIsAllowed ? ( <PaymentMethodRadioButton paymentMethod={PaymentMethod.DirectDebit} {...props} /> ) : ( <></> )} </RadioGroup> </form> ); const subscriptionToPaymentMethod = (productDetail: ProductDetail) => { if (!productDetail.subscription.safeToUpdatePaymentMethod) { return PaymentMethod.Unknown; } else if ( productDetail.subscription.paymentMethod === 'Card' && productDetail.subscription.card ) { return PaymentMethod.Card; } else if ( productDetail.subscription.paymentMethod === 'PayPal' && productDetail.subscription.payPalEmail ) { return PaymentMethod.PayPal; } else if ( productDetail.subscription.paymentMethod === 'DirectDebit' && productDetail.subscription.mandate ) { return PaymentMethod.DirectDebit; } else if (productDetail.subscription.paymentMethod === 'ResetRequired') { return PaymentMethod.ResetRequired; } else if (!productDetail.isPaidTier) { return PaymentMethod.Free; } return PaymentMethod.Unknown; }; export interface PaymentUpdaterStepState { newPaymentMethodDetail?: NewPaymentMethodDetail; newSubscriptionData?: WithSubscription[]; } export const PaymentDetailUpdate = (props: WithProductType<ProductType>) => { const location = useLocation(); const state = location.state as { paymentMethodInfo?: StripeCheckoutSessionPaymentMethod; paymentMethodType?: StripeCheckoutSessionPaymentMethodType; }; const { productDetail, isFromApp } = useContext( PaymentUpdateContext, ) as PaymentUpdateContextInterface; const currentPaymentMethod = subscriptionToPaymentMethod(productDetail); const mainPlan = getMainPlan(productDetail.subscription); const directDebitIsAllowed = currentPaymentMethod === PaymentMethod.DirectDebit || (isPaidSubscriptionPlan(mainPlan) && mainPlan.currencyISO === 'GBP' && (!productDetail.subscription.deliveryAddress || !productDetail.subscription.deliveryAddress?.country || productDetail.subscription.deliveryAddress.country === 'United Kingdom')); const [paymentUpdateState, setPaymentUpdateState] = useState<PaymentUpdaterStepState>({ newPaymentMethodDetail: undefined, newSubscriptionData: undefined, }); const [executingPaymentUpdate, setExecutingPaymentUpdate] = useState<boolean>(false); const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<PaymentMethod>( currentPaymentMethod === PaymentMethod.DirectDebit ? PaymentMethod.Unknown : PaymentMethod.Card, ); const navigate = useNavigate(); const executePaymentUpdate = useCallback( async (newPaymentMethodDetail: NewPaymentMethodDetail) => { setExecutingPaymentUpdate(true); try { const paymentUpdateFetch = await fetch( `/api/payment/${newPaymentMethodDetail.apiUrlPart}/${productDetail.subscription.subscriptionId}`, { credentials: 'include', method: 'POST', body: JSON.stringify( newPaymentMethodDetail.detailToPayloadObject(), ), headers: { 'Content-Type': 'application/json', [X_GU_ID_FORWARDED_SCOPE]: getScopeFromRequestPathOrEmptyString( window.location.href, ), }, }, ); const response = await processResponse<NewPaymentMethodDetail>( paymentUpdateFetch, ); if (newPaymentMethodDetail.matchesResponse(response)) { const paymentMethodChangeType: string = productDetail.subscription.paymentMethod === PaymentMethod.ResetRequired ? 'reset' : 'update'; trackEvent({ eventCategory: 'payment', eventAction: `${newPaymentMethodDetail.name}_${paymentMethodChangeType}_success`, product: { productType: props.productType, productDetail: productDetail, }, eventLabel: props.productType.urlPart, }); // refetch subscription from members data api const mdapiResponse = (await createProductDetailFetch( props.productType.allProductsProductTypeFilterString, productDetail.subscription.subscriptionId, )) as MembersDataApiResponse; const newSubscriptionData = mdapiResponse.products.filter(isProduct); navigate('updated', { state: { paymentFailureRecoveryMessage: newPaymentMethodDetail.paymentFailureRecoveryMessage, subHasExpectedPaymentType: newPaymentMethodDetail.subHasExpectedPaymentType( newSubscriptionData[0].subscription, ), newSubscriptionData, isFromApp: isFromApp, productDetail, }, }); } } catch (error) { console.error('Payment update error:', error); navigate('failed', { state: { newPaymentMethodDetailFriendlyName: newPaymentMethodDetail.friendlyName, productDetail, }, }); } }, [isFromApp, navigate, productDetail, props.productType], ); const newPaymentMethodDetailUpdater = ( newPaymentMethodDetail: NewPaymentMethodDetail, ) => setPaymentUpdateState({ ...paymentUpdateState, newPaymentMethodDetail, }); const updatePaymentMethod = (newPaymentMethod: PaymentMethod) => setSelectedPaymentMethod(newPaymentMethod); const getInputForm = (subscription: Subscription, isTestUser: boolean) => { const stripePublicKey: string = getStripeKeyByProduct(productDetail); switch (selectedPaymentMethod) { case PaymentMethod.ResetRequired: return stripePublicKey ? ( <CardInputForm stripeApiKey={stripePublicKey} newPaymentMethodDetailUpdater={ newPaymentMethodDetailUpdater } userEmail={ subscription.card?.email || window.guardian.identityDetails.email } executePaymentUpdate={executePaymentUpdate} /> ) : ( <GenericErrorScreen loggingMessage="No Stripe key provided to enable adding a payment method" /> ); case PaymentMethod.Card: return stripePublicKey ? ( <> {isObserverProduct(productDetail) ? ( <StripeCheckoutSessionButton stripeApiKey={stripePublicKey} productTypeUrlPart={props.productType.urlPart} paymentMethodType={ StripeCheckoutSessionPaymentMethodType.Card } subscriptionId={subscription.subscriptionId} /> ) : ( <CardInputForm stripeApiKey={stripePublicKey} newPaymentMethodDetailUpdater={ newPaymentMethodDetailUpdater } userEmail={ subscription.card?.email || window.guardian.identityDetails.email } executePaymentUpdate={executePaymentUpdate} /> )} </> ) : ( <GenericErrorScreen loggingMessage="No existing card information to update from" /> ); case PaymentMethod.Free: return ( <div> <p> If you are interested in supporting our journalism in other ways, please consider either a contribution or a subscription. </p> <SupportTheGuardianButton supportReferer="payment_flow" theme="brand" size="small" /> </div> ); case PaymentMethod.PayPal: return ( <p> Updating your PayPal payment details is not possible here. Please login to PayPal to change your payment details. </p> ); case PaymentMethod.DirectDebit: return ( <DirectDebitInputForm newPaymentMethodDetailUpdater={ newPaymentMethodDetailUpdater } testUser={isTestUser} executePaymentUpdate={executePaymentUpdate} gatewayOwner={ isObserverProduct(productDetail) ? DirectDebitGatewayOwner.TortoiseMedia : undefined } /> ); case PaymentMethod.Unknown: return null; default: Sentry.captureException( 'user cannot update their payment online', ); return ( <span> It is not currently possible to update your payment method online. </span> ); } }; useEffect(() => { if (state?.paymentMethodInfo) { if ( state.paymentMethodType !== StripeCheckoutSessionPaymentMethodType.Card ) { Sentry.captureException( 'Payment Method Type processing not implemented', { extra: { paymentMethodType: state.paymentMethodType, }, }, ); return; } const detail = new NewCardPaymentMethodDetail( state?.paymentMethodInfo as StripeCardPaymentMethod, getStripeKeyByProduct(productDetail), ); executePaymentUpdate(detail); } }, [ state?.paymentMethodInfo, state?.paymentMethodType, executePaymentUpdate, props.productType, productDetail, ]); return ( <> {executingPaymentUpdate && ( <OverlayLoader message={`Updating payment details...`} /> )} <div css={{ minWidth: '260px' }}> {productDetail.alertText && ( <ErrorSummary cssOverrides={css` margin-top: ${space[9]}px; `} message={augmentPaymentFailureAlertText( productDetail.alertText, )} /> )} <h3 css={css` ${subHeadingCss} margin-top: ${space[9]}px; `} > Your current payment method </h3> <CurrentPaymentDetails {...productDetail} /> {productDetail.subscription.payPalEmail && ( <p css={css` ${textSans17}; `} > To update your payment details, please login to your PayPal account. Alternatively, you can switch to a card based payment method below. </p> )} </div> <h3 css={css` ${subHeadingCss} ${productDetail.subscription.payPalEmail && 'margin-top: 36px'} `} > {selectedPaymentMethod === PaymentMethod.Unknown ? 'Choose your payment method' : 'Update your payment method'} </h3> <SelectPaymentMethod updatePaymentMethod={updatePaymentMethod} value={selectedPaymentMethod} currentPaymentMethod={currentPaymentMethod} directDebitIsAllowed={directDebitIsAllowed} /> {getInputForm(productDetail.subscription, productDetail.isTestUser)} { /* Dummy button when user has not selected a payment method */ selectedPaymentMethod === PaymentMethod.Unknown ? ( <div css={css` margin-top: ${space[9]}px; margin-bottom: ${space[9]}px; `} > <Button disabled priority="secondary" icon={<SvgArrowRightStraight />} iconSide="right" cssOverrides={css` background-color: ${palette.neutral[86]}; color: ${palette.neutral[46]}; :hover { background-color: ${palette.neutral[86]}; color: ${palette.neutral[46]}; } cursor: not-allowed; `} > Update payment method </Button> </div> ) : null } <ContactUs /> </> ); };