[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:
@@ -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": "",
|
||||
|
||||
+7
-17
@@ -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"
|
||||
|
||||
+3
-10
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
+66
@@ -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'
|
||||
|
||||
@@ -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</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",
|
||||
|
||||
+5
-5
@@ -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'))
|
||||
|
||||
+103
@@ -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 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(<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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user