[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
This commit is contained in:
Kristina
2025-07-14 10:27:38 +02:00
committed by Copybot
parent 2ac46151f8
commit ea2ba8cdbe
9 changed files with 213 additions and 33 deletions
@@ -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', {
@@ -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": "",
@@ -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<FetchError | null>(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() {
</OLModalHeader>
<OLModalBody>
{error && (
<OLNotification
type="error"
aria-live="polite"
content={
<>
{t('generic_something_went_wrong')}. {t('try_again')}.{' '}
{t('generic_if_problem_continues_contact_us')}.
</>
}
/>
)}
{error !== null && <PaymentErrorNotification error={error} />}
<p>
<Trans
i18nKey="sure_you_want_to_change_plan"
@@ -12,7 +12,6 @@ import useAsync from '@/shared/hooks/use-async'
import { useLocation } from '@/shared/hooks/use-location'
import { debugConsole } from '@/utils/debugging'
import { FetchError, postJSON } from '@/infrastructure/fetch-json'
import Notification from '@/shared/components/notification'
import OLCard from '@/features/ui/components/ol/ol-card'
import OLRow from '@/features/ui/components/ol/ol-row'
import OLCol from '@/features/ui/components/ol/ol-col'
@@ -21,6 +20,7 @@ import { subscriptionUpdateUrl } from '@/features/subscription/data/subscription
import * as eventTracking from '@/infrastructure/event-tracking'
import sparkleText from '@/shared/svgs/ai-sparkle-text.svg'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import PaymentErrorNotification from '@/features/subscription/components/shared/payment-error-notification'
import handleStripePaymentAction from '../../util/handle-stripe-payment-action'
function PreviewSubscriptionChange() {
@@ -96,15 +96,8 @@ function PreviewSubscriptionChange() {
) : null}
{payNowTask.isError && (
<Notification
type="error"
aria-live="polite"
content={
<>
{t('generic_something_went_wrong')}. {t('try_again')}.{' '}
{t('generic_if_problem_continues_contact_us')}.
</>
}
<PaymentErrorNotification
error={payNowTask.error as FetchError}
/>
)}
@@ -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 = (
<Trans
i18nKey="payment_error_intermittent_error"
components={[
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
<a href="/contact" target="_blank" />,
]}
/>
)
break
case 'do_not_try_again':
case 'confirm_card_data':
message = (
<Trans
i18nKey="payment_error_update_payment_method"
components={[
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
<a href={billingPortalUrl} />,
]}
/>
)
break
default:
// clientSecret indicates they needed to pass a 3DS challenge
if (error.data?.clientSecret) {
message = (
<Trans
i18nKey="payment_error_3ds_failed"
components={[
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
<a href="/contact" target="_blank" />,
]}
/>
)
} else {
message = (
<Trans
i18nKey="payment_error_generic"
components={[
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
<a href="/contact" target="_blank" />,
]}
/>
)
}
}
return <OLNotification type="error" aria-live="polite" content={message} />
}
@@ -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'
export const reactivateSubscriptionUrl = '/user/subscription/reactivate'
export const billingPortalUrl = '/user/subscription/payment/account-management'
+4
View File
@@ -1594,6 +1594,10 @@
"pause_subscription": "Pause subscription",
"pause_subscription_for": "Pause subscription for",
"pay_now": "Pay now",
"payment_error_3ds_failed": "We couldnt complete your payment because authentication wasnt successful. Please try again or choose a different payment method. If the problem continues please <0>contact us</0>.",
"payment_error_generic": "Sorry, something went wrong. Please try again. If the problem continues please <0>contact us</0>.",
"payment_error_intermittent_error": "We were unable to process your payment. Please try again later or <0>contact us</0> for assistance.",
"payment_error_update_payment_method": "Your payment was declined. Please <0>update your billing information</0> 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",
@@ -238,11 +238,11 @@ describe('<ChangePlanModal />', 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'))
@@ -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('<PaymentErrorNotification />', function () {
it('does not render if error is missing', function () {
render(<PaymentErrorNotification error={null} />)
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(<PaymentErrorNotification error={error} />)
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(<PaymentErrorNotification error={error} />)
expect(
screen.queryAllByText(
(content, element) =>
element?.textContent ===
'We couldnt complete your payment because authentication wasnt 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(<PaymentErrorNotification error={error} />)
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(<PaymentErrorNotification error={error} />)
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(<PaymentErrorNotification error={error} />)
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)
})
})