Procházet zdrojové kódy

[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
Kristina před 1 rokem
rodič
revize
ea2ba8cdbe

+ 19 - 0
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', {

+ 4 - 0
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": "",

+ 7 - 17
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<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
services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx

@@ -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
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 = (
+        <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} />
+}

+ 2 - 1
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'
+export const reactivateSubscriptionUrl = '/user/subscription/reactivate'
+export const billingPortalUrl = '/user/subscription/payment/account-management'

+ 4 - 0
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</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
services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx

@@ -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
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('<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)
+  })
+})