From ea2ba8cdbeb93c6383cc09b60bbb51b9ac697b81 Mon Sep 17 00:00:00 2001 From: Kristina <7614497+khjrtbrg@users.noreply.github.com> Date: Mon, 14 Jul 2025 10:27:38 +0200 Subject: [PATCH] [web] add error messages for payment failing on upgrade (#27054) * [web] add error messages for payment failing to upgrade modal * [web] show payment error on preview change page * [web] add separate message for 3ds failure GitOrigin-RevId: b2680ff9b4f01e42f31c1c11457f216a5eadf49d --- .../Subscription/SubscriptionController.js | 19 ++++ .../web/frontend/extracted-translations.json | 4 + .../modals/confirm-change-plan-modal.tsx | 24 ++-- .../preview-subscription-change/root.tsx | 13 +-- .../shared/payment-error-notification.tsx | 66 +++++++++++ .../subscription/data/subscription-url.ts | 3 +- services/web/locales/en.json | 4 + .../active/change-plan/change-plan.test.tsx | 10 +- .../payment-error-notification.test.tsx | 103 ++++++++++++++++++ 9 files changed, 213 insertions(+), 33 deletions(-) create mode 100644 services/web/frontend/js/features/subscription/components/shared/payment-error-notification.tsx create mode 100644 services/web/test/frontend/features/subscription/components/shared/payment-error-notification.test.tsx diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index fe79f3caed..def0abf7ac 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -20,6 +20,7 @@ const { DuplicateAddOnError, AddOnNotPresentError, PaymentActionRequiredError, + PaymentFailedError, } = require('./Errors') const SplitTestHandler = require('../SplitTests/SplitTestHandler') const AuthorizationManager = require('../Authorization/AuthorizationManager') @@ -449,11 +450,29 @@ async function purchaseAddon(req, res, next) { { addon: addOnCode } ) } else if (err instanceof PaymentActionRequiredError) { + logger.debug( + { userId: user._id }, + 'Customer needs to perform payment action to complete transaction' + ) return res.status(402).json({ message: 'Payment action required', clientSecret: err.info.clientSecret, publicKey: err.info.publicKey, }) + } else if (err instanceof PaymentFailedError) { + logger.debug( + { + userId: user._id, + reason: err.info.reason, + adviceCode: err.info.adviceCode, + }, + 'Payment failed for transaction' + ) + return res.status(402).json({ + message: 'Payment failed', + reason: err.info.reason, + adviceCode: err.info.adviceCode, + }) } else { if (err instanceof Error) { OError.tag(err, 'something went wrong purchasing add-ons', { diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 4031c6db26..f4bb6c7c8e 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -1207,6 +1207,10 @@ "pause_subscription": "", "pause_subscription_for": "", "pay_now": "", + "payment_error_3ds_failed": "", + "payment_error_generic": "", + "payment_error_intermittent_error": "", + "payment_error_update_payment_method": "", "payment_provider_unreachable_error": "", "payment_summary": "", "pdf_compile_in_progress_error": "", diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/confirm-change-plan-modal.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/confirm-change-plan-modal.tsx index a964009dcc..9693ad3e23 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/confirm-change-plan-modal.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/confirm-change-plan-modal.tsx @@ -16,12 +16,12 @@ import OLModal, { OLModalTitle, } from '@/features/ui/components/ol/ol-modal' import OLButton from '@/features/ui/components/ol/ol-button' -import OLNotification from '@/features/ui/components/ol/ol-notification' +import PaymentErrorNotification from '@/features/subscription/components/shared/payment-error-notification' import handleStripePaymentAction from '@/features/subscription/util/handle-stripe-payment-action' export function ConfirmChangePlanModal() { const modalId: SubscriptionDashModalIds = 'change-to-plan' - const [error, setError] = useState(false) + const [error, setError] = useState(null) const [inflight, setInflight] = useState(false) const { t } = useTranslation() const { handleCloseModal, modalIdShown, plans, planCodeToChangeTo } = @@ -30,7 +30,7 @@ export function ConfirmChangePlanModal() { const location = useLocation() async function handleConfirmChange() { - setError(false) + setError(null) setInflight(true) try { @@ -41,11 +41,12 @@ export function ConfirmChangePlanModal() { }) location.reload() } catch (e) { - const { handled } = await handleStripePaymentAction(e as FetchError) + const fetchError = e as FetchError + const { handled } = await handleStripePaymentAction(fetchError) if (handled) { location.reload() } else { - setError(true) + setError(fetchError) setInflight(false) } } @@ -73,18 +74,7 @@ export function ConfirmChangePlanModal() { - {error && ( - - {t('generic_something_went_wrong')}. {t('try_again')}.{' '} - {t('generic_if_problem_continues_contact_us')}. - - } - /> - )} + {error !== null && }

- {t('generic_something_went_wrong')}. {t('try_again')}.{' '} - {t('generic_if_problem_continues_contact_us')}. - - } + )} diff --git a/services/web/frontend/js/features/subscription/components/shared/payment-error-notification.tsx b/services/web/frontend/js/features/subscription/components/shared/payment-error-notification.tsx new file mode 100644 index 0000000000..a727be6e51 --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/shared/payment-error-notification.tsx @@ -0,0 +1,66 @@ +import { FetchError } from '@/infrastructure/fetch-json' +import { Trans } from 'react-i18next' +import OLNotification from '@/features/ui/components/ol/ol-notification' +import { billingPortalUrl } from '../../data/subscription-url' + +type Props = { + error: FetchError | null +} + +export default function PaymentErrorNotification({ error }: Props) { + if (!error) { + return + } + + let message + switch (error.data?.adviceCode) { + case 'try_again_later': + message = ( + , + ]} + /> + ) + break + case 'do_not_try_again': + case 'confirm_card_data': + message = ( + , + ]} + /> + ) + break + default: + // clientSecret indicates they needed to pass a 3DS challenge + if (error.data?.clientSecret) { + message = ( + , + ]} + /> + ) + } else { + message = ( + , + ]} + /> + ) + } + } + + return +} diff --git a/services/web/frontend/js/features/subscription/data/subscription-url.ts b/services/web/frontend/js/features/subscription/data/subscription-url.ts index 9dc294b4f6..b26c635a05 100644 --- a/services/web/frontend/js/features/subscription/data/subscription-url.ts +++ b/services/web/frontend/js/features/subscription/data/subscription-url.ts @@ -4,4 +4,5 @@ export const cancelPendingSubscriptionChangeUrl = export const cancelSubscriptionUrl = '/user/subscription/cancel' export const redirectAfterCancelSubscriptionUrl = '/user/subscription/canceled' export const extendTrialUrl = '/user/subscription/extend' -export const reactivateSubscriptionUrl = '/user/subscription/reactivate' \ No newline at end of file +export const reactivateSubscriptionUrl = '/user/subscription/reactivate' +export const billingPortalUrl = '/user/subscription/payment/account-management' diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 7fbe27fc3c..c5ba1f84b7 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -1594,6 +1594,10 @@ "pause_subscription": "Pause subscription", "pause_subscription_for": "Pause subscription for", "pay_now": "Pay now", + "payment_error_3ds_failed": "We couldn’t complete your payment because authentication wasn’t successful. Please try again or choose a different payment method. If the problem continues please <0>contact us.", + "payment_error_generic": "Sorry, something went wrong. Please try again. If the problem continues please <0>contact us.", + "payment_error_intermittent_error": "We were unable to process your payment. Please try again later or <0>contact us for assistance.", + "payment_error_update_payment_method": "Your payment was declined. Please <0>update your billing information and try again.", "payment_method_accepted": "__paymentMethod__ accepted", "payment_provider_unreachable_error": "Sorry, there was an error talking to our payment provider. Please try again in a few moments.\nIf you are using any ad or script blocking extensions in your browser, you may need to temporarily disable them.", "payment_summary": "Payment summary", diff --git a/services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx index 7ffe7760d8..bbcb955d2d 100644 --- a/services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx +++ b/services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx @@ -238,11 +238,11 @@ describe('', function () { screen.getByRole('button', { name: 'Processing…' }) - await screen.findByText('Sorry, something went wrong. ', { exact: false }) - await screen.findByText('Please try again. ', { exact: false }) - await screen.findByText('If the problem continues please contact us.', { - exact: false, - }) + await screen.findAllByText( + (content, element) => + element?.textContent === + 'Sorry, something went wrong. Please try again. If the problem continues please contact us.' + ) expect( within(screen.getByRole('dialog')) diff --git a/services/web/test/frontend/features/subscription/components/shared/payment-error-notification.test.tsx b/services/web/test/frontend/features/subscription/components/shared/payment-error-notification.test.tsx new file mode 100644 index 0000000000..e88551b20b --- /dev/null +++ b/services/web/test/frontend/features/subscription/components/shared/payment-error-notification.test.tsx @@ -0,0 +1,103 @@ +import { expect } from 'chai' +import { render, screen } from '@testing-library/react' +import PaymentErrorNotification from '../../../../../../frontend/js/features/subscription/components/shared/payment-error-notification' +import { FetchError } from '@/infrastructure/fetch-json' +import { billingPortalUrl } from '@/features/subscription/data/subscription-url' + +describe('', function () { + it('does not render if error is missing', function () { + render() + + expect(screen.queryByRole('link')).to.be.null + }) + + it('renders a generic error if adviceCode is missing', function () { + const error = { data: { adviceCode: null } } as FetchError + + render() + + expect( + screen.queryAllByText( + (content, element) => + element?.textContent === + 'Sorry, something went wrong. Please try again. If the problem continues please contact us.' + ).length + ).to.be.greaterThan(0) + + const link = screen.queryByRole('link') + expect(link).to.exist + expect(link?.getAttribute('href')).to.equal('/contact') + }) + + it('renders an error if adviceCode is missing but clientSecret is present', function () { + const error = { data: { clientSecret: 'cs_12345' } } as FetchError + + render() + + expect( + screen.queryAllByText( + (content, element) => + element?.textContent === + 'We couldn’t complete your payment because authentication wasn’t successful. Please try again or choose a different payment method. If the problem continues please contact us.' + ).length + ).to.be.greaterThan(0) + + const link = screen.queryByRole('link') + expect(link).to.exist + expect(link?.getAttribute('href')).to.equal('/contact') + }) + + it('renders a error to try again if adviceCode is try_again_later', function () { + const error = { data: { adviceCode: 'try_again_later' } } as FetchError + + render() + + expect( + screen.queryAllByText( + (content, element) => + element?.textContent === + 'We were unable to process your payment. Please try again later or contact us for assistance.' + ).length + ).to.be.greaterThan(0) + + const link = screen.queryByRole('link') + expect(link).to.exist + expect(link?.getAttribute('href')).to.equal('/contact') + }) + + it('renders an error to update payment method if adviceCode do_not_try_again', function () { + const error = { data: { adviceCode: 'do_not_try_again' } } as FetchError + + render() + + expect( + screen.queryAllByText( + (content, element) => + element?.textContent === + 'Your payment was declined. Please update your billing information and try again.' + ).length + ).to.be.greaterThan(0) + + const link = screen.queryByRole('link') + expect(link).to.exist + expect(link?.getAttribute('href')).to.equal(billingPortalUrl) + }) + + it('renders an error to update payment method if adviceCode confirm_card_data', function () { + const error = { data: { adviceCode: 'confirm_card_data' } } as FetchError + + render() + + expect( + screen.queryAllByText( + (content, element) => + element?.textContent === + 'Your payment was declined. Please update your billing information and try again.' + ).length + ).to.be.greaterThan(0) + + const link = screen.queryByRole('link') + expect(link).to.exist + expect(link?.getAttribute('href')).to.equal(billingPortalUrl) + }) +})