Просмотр исходного кода

Merge pull request #11681 from overleaf/jel-confirm-subscription-change

[web] Plan change request in subscription dash

GitOrigin-RevId: 9afe22b2da035f887c6fa9abe8129711d492108c
Jessica Lawshe 3 лет назад
Родитель
Сommit
d539aaf226

+ 6 - 0
services/web/frontend/extracted-translations.json

@@ -224,6 +224,7 @@
   "error": "",
   "error_performing_request": "",
   "example_project": "",
+  "existing_plan_active_until_term_end": "",
   "expand": "",
   "export_csv": "",
   "export_project_to_github": "",
@@ -286,6 +287,7 @@
   "galileo_suggestion_feedback_button": "",
   "galileo_suggestions_loading_error": "",
   "galileo_toggle_description": "",
+  "generic_if_problem_continues_contact_us": "",
   "generic_linked_file_compile_error": "",
   "generic_something_went_wrong": "",
   "get_collaborative_benefits": "",
@@ -566,6 +568,7 @@
   "proceed_to_paypal": "",
   "proceeding_to_paypal_takes_you_to_the_paypal_site_to_pay": "",
   "processing": "",
+  "processing_uppercase": "",
   "professional": "",
   "project": "",
   "project_approaching_file_limit": "",
@@ -633,6 +636,7 @@
   "resend_confirmation_email": "",
   "resending_confirmation_email": "",
   "reverse_x_sort_order": "",
+  "revert_pending_plan_change": "",
   "review": "",
   "revoke": "",
   "revoke_invite": "",
@@ -733,6 +737,8 @@
   "subscription_admins_cannot_be_deleted": "",
   "subscription_canceled_and_terminate_on_x": "",
   "subscription_will_remain_active_until_end_of_billing_period_x": "",
+  "sure_you_want_to_cancel_plan_change": "",
+  "sure_you_want_to_change_plan": "",
   "sure_you_want_to_delete": "",
   "switch_to_editor": "",
   "switch_to_pdf": "",

+ 4 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/change-plan.tsx

@@ -2,7 +2,9 @@ import { useTranslation } from 'react-i18next'
 import LoadingSpinner from '../../../../../../../shared/components/loading-spinner'
 import { useSubscriptionDashboardContext } from '../../../../../context/subscription-dashboard-context'
 import { ChangeToGroupPlan } from './change-to-group-plan'
+import { ConfirmChangePlanModal } from './confirm-change-plan-modal'
 import { IndividualPlansTable } from './individual-plans-table'
+import { KeepCurrentPlanModal } from './keep-current-plan-modal'
 
 export function ChangePlan() {
   const { t } = useTranslation()
@@ -28,6 +30,8 @@ export function ChangePlan() {
         <h2>{t('change_plan')}</h2>
         <IndividualPlansTable plans={plans} />
         <ChangeToGroupPlan />
+        <ConfirmChangePlanModal />
+        <KeepCurrentPlanModal />
       </>
     )
   }

+ 101 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/confirm-change-plan-modal.tsx

@@ -0,0 +1,101 @@
+import { useState } from 'react'
+import { Modal } from 'react-bootstrap'
+import { useTranslation, Trans } from 'react-i18next'
+import { postJSON } from '../../../../../../../infrastructure/fetch-json'
+import AccessibleModal from '../../../../../../../shared/components/accessible-modal'
+import getMeta from '../../../../../../../utils/meta'
+import { useSubscriptionDashboardContext } from '../../../../../context/subscription-dashboard-context'
+import { subscriptionUrl } from '../../../../../data/subscription-url'
+
+export function ConfirmChangePlanModal() {
+  const modalId = 'change-to-plan'
+  const [error, setError] = useState(false)
+  const [inflight, setInflight] = useState(false)
+  const { t } = useTranslation()
+  const { handleCloseModal, modalIdShown, plans, planCodeToChangeTo } =
+    useSubscriptionDashboardContext()
+  const planCodesChangingAtTermEnd = getMeta('ol-planCodesChangingAtTermEnd')
+
+  async function handleConfirmChange() {
+    setError(false)
+    setInflight(true)
+
+    try {
+      await postJSON(`${subscriptionUrl}?origin=confirmChangePlan`, {
+        body: {
+          plan_code: planCodeToChangeTo,
+        },
+      })
+    } catch (e) {
+      setError(true)
+      setInflight(false)
+    }
+    window.location.reload()
+  }
+
+  if (modalIdShown !== modalId || !planCodeToChangeTo) return null
+
+  const plan = plans.find(p => p.planCode === planCodeToChangeTo)
+  if (!plan) return null
+
+  const planWillChangeAtTermEnd =
+    planCodesChangingAtTermEnd?.indexOf(planCodeToChangeTo) > -1
+
+  return (
+    <AccessibleModal
+      id={modalId}
+      show
+      animation
+      onHide={handleCloseModal}
+      backdrop="static"
+    >
+      <Modal.Header>
+        <Modal.Title>{t('change_plan')}</Modal.Title>
+      </Modal.Header>
+
+      <Modal.Body>
+        {error && (
+          <div className="alert alert-warning">
+            {t('generic_something_went_wrong')}. {t('try_again')}.{' '}
+            {t('generic_if_problem_continues_contact_us')}.
+          </div>
+        )}
+        <p>
+          <Trans
+            i18nKey="sure_you_want_to_change_plan"
+            values={{
+              planName: plan.name,
+            }}
+            components={[
+              // eslint-disable-next-line react/jsx-key
+              <strong />,
+            ]}
+          />
+        </p>
+        {planWillChangeAtTermEnd && (
+          <>
+            <p>{t('existing_plan_active_until_term_end')}</p>
+            <p>{t('want_change_to_apply_before_plan_end')}</p>
+          </>
+        )}
+      </Modal.Body>
+
+      <Modal.Footer>
+        <button
+          disabled={inflight}
+          className="btn btn-secondary"
+          onClick={handleCloseModal}
+        >
+          {t('cancel')}
+        </button>
+        <button
+          disabled={inflight}
+          className="btn btn-primary"
+          onClick={handleConfirmChange}
+        >
+          {!inflight ? t('change_plan') : t('processing_uppercase') + '…'}
+        </button>
+      </Modal.Footer>
+    </AccessibleModal>
+  )
+}

+ 20 - 24
services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/individual-plans-table.tsx

@@ -3,37 +3,33 @@ import { Plan } from '../../../../../../../../../types/subscription/plan'
 import Icon from '../../../../../../../shared/components/icon'
 import { useSubscriptionDashboardContext } from '../../../../../context/subscription-dashboard-context'
 
-function ChangeToPlanButton({ plan }: { plan: Plan }) {
+function ChangeToPlanButton({ planCode }: { planCode: string }) {
   const { t } = useTranslation()
-  // for when the user selected to change a plan, but the plan change is still pending
+  const { handleOpenModal } = useSubscriptionDashboardContext()
+
+  const handleClick = () => {
+    handleOpenModal('change-to-plan', planCode)
+  }
+
   return (
-    <form>
-      {/* todo: ng-model="plan_code" */}
-      <input type="hidden" name="plan_code" value={plan.planCode} />
-      {/* todo: handle submit changePlan */}
-      <input
-        type="submit"
-        value={t('change_to_this_plan')}
-        className="btn btn-primary"
-      />
-    </form>
+    <button className="btn btn-primary" onClick={handleClick}>
+      {t('change_to_this_plan')}
+    </button>
   )
 }
 
 function KeepCurrentPlanButton({ plan }: { plan: Plan }) {
   const { t } = useTranslation()
-  // for when the user selected to change a plan, but the plan change is still pending
+  const { handleOpenModal } = useSubscriptionDashboardContext()
+
+  const handleClick = () => {
+    handleOpenModal('keep-current-plan')
+  }
+
   return (
-    <form>
-      {/* todo: ng-model="plan_code" */}
-      <input type="hidden" name="plan_code" value={plan.planCode} />
-      {/* todo: handle submit cancelPendingPlanChange */}
-      <input
-        type="submit"
-        value={t('keep_current_plan')}
-        className="btn btn-primary"
-      />
-    </form>
+    <button className="btn btn-primary" onClick={handleClick}>
+      {t('keep_current_plan')}
+    </button>
   )
 }
 
@@ -61,7 +57,7 @@ function ChangePlanButton({ plan }: { plan: Plan }) {
       </b>
     )
   } else {
-    return <ChangeToPlanButton plan={plan} />
+    return <ChangeToPlanButton planCode={plan.planCode} />
   }
 }
 

+ 85 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/keep-current-plan-modal.tsx

@@ -0,0 +1,85 @@
+import { useState } from 'react'
+import { Modal } from 'react-bootstrap'
+import { useTranslation, Trans } from 'react-i18next'
+import { postJSON } from '../../../../../../../infrastructure/fetch-json'
+import AccessibleModal from '../../../../../../../shared/components/accessible-modal'
+import { useSubscriptionDashboardContext } from '../../../../../context/subscription-dashboard-context'
+import { cancelPendingSubscriptionChangeUrl } from '../../../../../data/subscription-url'
+
+export function KeepCurrentPlanModal() {
+  const modalId = 'keep-current-plan'
+  const [error, setError] = useState(false)
+  const [inflight, setInflight] = useState(false)
+  const { t } = useTranslation()
+  const { modalIdShown, handleCloseModal, personalSubscription } =
+    useSubscriptionDashboardContext()
+
+  async function confirmCancelPendingPlanChange() {
+    setError(false)
+    setInflight(true)
+
+    try {
+      await postJSON(cancelPendingSubscriptionChangeUrl)
+    } catch (e) {
+      setError(true)
+      setInflight(false)
+    }
+    window.location.reload()
+  }
+
+  if (modalIdShown !== modalId || !personalSubscription) return null
+
+  return (
+    <AccessibleModal
+      id={modalId}
+      show
+      animation
+      onHide={handleCloseModal}
+      backdrop="static"
+    >
+      <Modal.Header>
+        <Modal.Title>{t('change_plan')}</Modal.Title>
+      </Modal.Header>
+
+      <Modal.Body>
+        {error && (
+          <div className="alert alert-warning">
+            {t('generic_something_went_wrong')}. {t('try_again')}.{' '}
+            {t('generic_if_problem_continues_contact_us')}.
+          </div>
+        )}
+        <p>
+          <Trans
+            i18nKey="sure_you_want_to_cancel_plan_change"
+            values={{
+              planName: personalSubscription.plan.name,
+            }}
+            components={[
+              // eslint-disable-next-line react/jsx-key
+              <strong />,
+            ]}
+          />
+        </p>
+      </Modal.Body>
+
+      <Modal.Footer>
+        <button
+          disabled={inflight}
+          className="btn btn-secondary"
+          onClick={handleCloseModal}
+        >
+          {t('cancel')}
+        </button>
+        <button
+          disabled={inflight}
+          className="btn btn-primary"
+          onClick={confirmCancelPendingPlanChange}
+        >
+          {!inflight
+            ? t('revert_pending_plan_change')
+            : t('processing_uppercase') + '…'}
+        </button>
+      </Modal.Footer>
+    </AccessibleModal>
+  )
+}

+ 36 - 0
services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx

@@ -19,15 +19,23 @@ import { loadDisplayPriceWithTaxPromise } from '../util/recurly-pricing'
 import { isRecurlyLoaded } from '../util/is-recurly-loaded'
 
 type SubscriptionDashboardContextValue = {
+  handleCloseModal: () => void
+  handleOpenModal: (modalIdToOpen: string, planCode?: string) => void
   hasDisplayedSubscription: boolean
   institutionMemberships?: Institution[]
   managedGroupSubscriptions: ManagedGroupSubscription[]
   managedInstitutions: ManagedInstitution[]
   updateManagedInstitution: (institution: ManagedInstitution) => void
+  modalIdShown?: string
   personalSubscription?: Subscription
   plans: Plan[]
+  planCodeToChangeTo?: string
   queryingIndividualPlansData: boolean
   recurlyLoadError: boolean
+  setModalIdShown: React.Dispatch<React.SetStateAction<string | undefined>>
+  setPlanCodeToChangeTo: React.Dispatch<
+    React.SetStateAction<string | undefined>
+  >
   setRecurlyLoadError: React.Dispatch<React.SetStateAction<boolean>>
   showCancellation: boolean
   setShowCancellation: React.Dispatch<React.SetStateAction<boolean>>
@@ -44,12 +52,16 @@ export function SubscriptionDashboardProvider({
 }: {
   children: ReactNode
 }) {
+  const [modalIdShown, setModalIdShown] = useState<string | undefined>()
   const [recurlyLoadError, setRecurlyLoadError] = useState(false)
   const [showCancellation, setShowCancellation] = useState(false)
   const [showChangePersonalPlan, setShowChangePersonalPlan] = useState(false)
   const [plans, setPlans] = useState([])
   const [queryingIndividualPlansData, setQueryingIndividualPlansData] =
     useState(true)
+  const [planCodeToChangeTo, setPlanCodeToChangeTo] = useState<
+    string | undefined
+  >()
 
   const plansWithoutDisplayPrice = getMeta('ol-plans')
   const institutionMemberships = getMeta('ol-currentInstitutionsWithLicence')
@@ -111,18 +123,36 @@ export function SubscriptionDashboardProvider({
     },
     []
   )
+  const handleCloseModal = useCallback(() => {
+    setModalIdShown('')
+    setPlanCodeToChangeTo(undefined)
+  }, [setModalIdShown, setPlanCodeToChangeTo])
+
+  const handleOpenModal = useCallback(
+    (id, planCode) => {
+      setModalIdShown(id)
+      setPlanCodeToChangeTo(planCode)
+    },
+    [setModalIdShown, setPlanCodeToChangeTo]
+  )
 
   const value = useMemo<SubscriptionDashboardContextValue>(
     () => ({
+      handleCloseModal,
+      handleOpenModal,
       hasDisplayedSubscription,
       institutionMemberships,
       managedGroupSubscriptions,
       managedInstitutions,
       updateManagedInstitution,
+      modalIdShown,
       personalSubscription,
       plans,
+      planCodeToChangeTo,
       queryingIndividualPlansData,
       recurlyLoadError,
+      setModalIdShown,
+      setPlanCodeToChangeTo,
       setRecurlyLoadError,
       showCancellation,
       setShowCancellation,
@@ -130,15 +160,21 @@ export function SubscriptionDashboardProvider({
       setShowChangePersonalPlan,
     }),
     [
+      handleCloseModal,
+      handleOpenModal,
       hasDisplayedSubscription,
       institutionMemberships,
       managedGroupSubscriptions,
       managedInstitutions,
       updateManagedInstitution,
+      modalIdShown,
       personalSubscription,
       plans,
+      planCodeToChangeTo,
       queryingIndividualPlansData,
       recurlyLoadError,
+      setModalIdShown,
+      setPlanCodeToChangeTo,
       setRecurlyLoadError,
       showCancellation,
       setShowCancellation,

+ 3 - 0
services/web/frontend/js/features/subscription/data/subscription-url.ts

@@ -0,0 +1,3 @@
+export const subscriptionUrl = '/user/subscription/update'
+export const cancelPendingSubscriptionChangeUrl =
+  '/user/subscription/cancel-pending'

+ 1 - 0
services/web/locales/en.json

@@ -1117,6 +1117,7 @@
   "proceed_to_paypal": "Proceed to PayPal",
   "proceeding_to_paypal_takes_you_to_the_paypal_site_to_pay": "Proceeding to PayPal will take you to the PayPal site to pay for your subscription.",
   "processing": "processing",
+  "processing_uppercase": "Processing",
   "processing_your_request": "Please wait while we process your request.",
   "professional": "Professional",
   "project": "project",

+ 2 - 19
services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx

@@ -1,7 +1,6 @@
 import { expect } from 'chai'
 import { fireEvent, screen } from '@testing-library/react'
 import * as eventTracking from '../../../../../../../../frontend/js/infrastructure/event-tracking'
-import { ActiveSubscription } from '../../../../../../../../frontend/js/features/subscription/components/dashboard/states/active/active'
 import { Subscription } from '../../../../../../../../types/subscription/dashboard/subscription'
 import {
   annualActiveSubscription,
@@ -11,11 +10,8 @@ import {
   trialSubscription,
 } from '../../../../fixtures/subscriptions'
 import sinon from 'sinon'
-import { plans } from '../../../../fixtures/plans'
-import {
-  cleanUpContext,
-  renderWithSubscriptionDashContext,
-} from '../../../../helpers/render-with-subscription-dash-context'
+import { cleanUpContext } from '../../../../helpers/render-with-subscription-dash-context'
+import { renderActiveSubscription } from '../../../../helpers/render-active-subscription'
 
 describe('<ActiveSubscription />', function () {
   let sendMBSpy: sinon.SinonSpy
@@ -61,19 +57,6 @@ describe('<ActiveSubscription />', function () {
     screen.getByRole('link', { name: 'View Your Invoices' })
   }
 
-  function renderActiveSubscription(subscription: Subscription) {
-    const renderOptions = {
-      metaTags: [
-        { name: 'ol-plans', value: plans },
-        { name: 'ol-subscription', value: subscription },
-      ],
-    }
-    renderWithSubscriptionDashContext(
-      <ActiveSubscription subscription={subscription} />,
-      renderOptions
-    )
-  }
-
   it('renders the dash annual active subscription', function () {
     renderActiveSubscription(annualActiveSubscription)
     expectedInActiveSubscription(annualActiveSubscription)

+ 240 - 32
services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx

@@ -1,5 +1,5 @@
 import { expect } from 'chai'
-import { fireEvent, screen } from '@testing-library/react'
+import { fireEvent, screen, waitFor, within } from '@testing-library/react'
 import { ChangePlan } from '../../../../../../../../../frontend/js/features/subscription/components/dashboard/states/active/change-plan/change-plan'
 import { plans } from '../../../../../fixtures/plans'
 import {
@@ -11,40 +11,45 @@ import {
   cleanUpContext,
   renderWithSubscriptionDashContext,
 } from '../../../../../helpers/render-with-subscription-dash-context'
+import sinon from 'sinon'
+import fetchMock from 'fetch-mock'
+import {
+  cancelPendingSubscriptionChangeUrl,
+  subscriptionUrl,
+} from '../../../../../../../../../frontend/js/features/subscription/data/subscription-url'
+import { renderActiveSubscription } from '../../../../../helpers/render-active-subscription'
 
 describe('<ChangePlan />', function () {
+  let reloadStub: () => void
+  const originalLocation = window.location
   const plansMetaTag = { name: 'ol-plans', value: plans }
-  const renderOptions = { metaTags: [plansMetaTag] }
+
+  beforeEach(function () {
+    reloadStub = sinon.stub()
+    Object.defineProperty(window, 'location', {
+      value: { reload: reloadStub },
+    })
+  })
 
   afterEach(function () {
     cleanUpContext()
+    fetchMock.reset()
+    Object.defineProperty(window, 'location', {
+      value: originalLocation,
+    })
   })
 
   it('does not render the UI when showChangePersonalPlan is false', function () {
     window.metaAttributesCache.delete('ol-plans')
-    const { container } = renderWithSubscriptionDashContext(
-      <ChangePlan />,
-      renderOptions
-    )
+    const { container } = renderWithSubscriptionDashContext(<ChangePlan />, {
+      metaTags: [plansMetaTag],
+    })
 
     expect(container.firstChild).to.be.null
   })
 
   it('renders the individual plans table and group plans UI', async function () {
-    renderWithSubscriptionDashContext(
-      <ActiveSubscription subscription={annualActiveSubscription} />,
-      {
-        metaTags: [
-          { name: 'ol-subscription', value: annualActiveSubscription },
-          plansMetaTag,
-          {
-            name: 'ol-recommendedCurrency',
-            value: 'USD',
-          },
-        ],
-      }
-    )
-
+    renderActiveSubscription(annualActiveSubscription)
     const button = screen.getByRole('button', { name: 'Change plan' })
     fireEvent.click(button)
 
@@ -68,15 +73,7 @@ describe('<ChangePlan />', function () {
   })
 
   it('renders "Your new plan" and "Keep current plan" when there is a pending plan change', async function () {
-    renderWithSubscriptionDashContext(
-      <ActiveSubscription subscription={pendingSubscriptionChange} />,
-      {
-        metaTags: [
-          { name: 'ol-subscription', value: pendingSubscriptionChange },
-          plansMetaTag,
-        ],
-      }
-    )
+    renderActiveSubscription(pendingSubscriptionChange)
 
     const button = screen.getByRole('button', { name: 'Change plan' })
     fireEvent.click(button)
@@ -98,13 +95,12 @@ describe('<ChangePlan />', function () {
     expect(container).not.to.be.null
   })
 
-  it('shows a loading message while still querying Recurly for prices', function () {
+  it('shows a loading message while still querying Recurly for prices', async function () {
     renderWithSubscriptionDashContext(
       <ActiveSubscription subscription={pendingSubscriptionChange} />,
       {
         metaTags: [
           { name: 'ol-subscription', value: pendingSubscriptionChange },
-          plansMetaTag,
         ],
       }
     )
@@ -112,6 +108,218 @@ describe('<ChangePlan />', function () {
     const button = screen.getByRole('button', { name: 'Change plan' })
     fireEvent.click(button)
 
-    screen.findByText('Loading', { exact: false })
+    await screen.findByText('Loading', { exact: false })
+  })
+
+  describe('Change plan modal', function () {
+    it('open confirmation modal when "Change to this plan" clicked', async function () {
+      renderActiveSubscription(annualActiveSubscription)
+
+      const button = screen.getByRole('button', { name: 'Change plan' })
+      fireEvent.click(button)
+
+      const buttons = await screen.findAllByRole('button', {
+        name: 'Change to this plan',
+      })
+      fireEvent.click(buttons[0])
+
+      await screen.findByText('Are you sure you want to change plan to', {
+        exact: false,
+      })
+      screen.getByRole('button', { name: 'Change plan' })
+
+      expect(
+        screen.queryByText(
+          'Your existing plan and its features will remain active until the end of the current billing period.'
+        )
+      ).to.be.null
+
+      expect(
+        screen.queryByText(
+          'If you wish this change to apply before the end of your current billing period, please contact us.'
+        )
+      ).to.be.null
+    })
+
+    it('shows message in confirmation dialog about plan remaining active until end of term when expected', async function () {
+      let planIndex = 0
+      const planThatWillChange = plans.find((p, i) => {
+        if (p.planCode !== annualActiveSubscription.planCode) {
+          planIndex = i
+        }
+        return p.planCode !== annualActiveSubscription.planCode
+      })
+
+      renderActiveSubscription(annualActiveSubscription, [
+        {
+          name: 'ol-planCodesChangingAtTermEnd',
+          value: [planThatWillChange!.planCode],
+        },
+      ])
+
+      const button = screen.getByRole('button', { name: 'Change plan' })
+      fireEvent.click(button)
+
+      const buttons = await screen.findAllByRole('button', {
+        name: 'Change to this plan',
+      })
+      fireEvent.click(buttons[planIndex])
+
+      const confirmModal = screen.getByRole('dialog')
+      await within(confirmModal).findByText(
+        'Your existing plan and its features will remain active until the end of the current billing period.'
+      )
+
+      screen.getByText(
+        'If you wish this change to apply before the end of your current billing period, please contact us.'
+      )
+    })
+
+    it('changes plan after confirmed in modal', async function () {
+      const endPointResponse = {
+        status: 200,
+      }
+      fetchMock.post(
+        `${subscriptionUrl}?origin=confirmChangePlan`,
+        endPointResponse
+      )
+
+      renderActiveSubscription(annualActiveSubscription, [
+        {
+          name: 'ol-planCodesChangingAtTermEnd',
+          value: [annualActiveSubscription.planCode],
+        },
+      ])
+
+      const button = screen.getByRole('button', { name: 'Change plan' })
+      fireEvent.click(button)
+
+      const buttons = await screen.findAllByRole('button', {
+        name: 'Change to this plan',
+      })
+      fireEvent.click(buttons[0])
+
+      await screen.findByText('Are you sure you want to change plan to', {
+        exact: false,
+      })
+      const buttonConfirm = screen.getByRole('button', { name: 'Change plan' })
+      fireEvent.click(buttonConfirm)
+
+      screen.getByText('processing', { exact: false })
+
+      // page is reloaded on success
+      await waitFor(() => {
+        expect(reloadStub).to.have.been.called
+      })
+    })
+
+    it('shows error if changing plan failed', async function () {
+      const endPointResponse = {
+        status: 500,
+      }
+      fetchMock.post(
+        `${subscriptionUrl}?origin=confirmChangePlan`,
+        endPointResponse
+      )
+
+      renderActiveSubscription(annualActiveSubscription)
+
+      const button = screen.getByRole('button', { name: 'Change plan' })
+      fireEvent.click(button)
+
+      const buttons = await screen.findAllByRole('button', {
+        name: 'Change to this plan',
+      })
+      fireEvent.click(buttons[0])
+
+      await screen.findByText('Are you sure you want to change plan to', {
+        exact: false,
+      })
+      const buttonConfirm = screen.getByRole('button', { name: 'Change plan' })
+      fireEvent.click(buttonConfirm)
+
+      screen.getByText('processing', { exact: false })
+
+      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,
+      })
+
+      expect(
+        within(screen.getByRole('dialog'))
+          .getByRole('button', { name: 'Change plan' })
+          .getAttribute('disabled')
+      ).to.not.exist
+    })
+  })
+
+  describe('Keep current plan modal', function () {
+    let confirmModal: HTMLElement
+
+    beforeEach(async function () {
+      renderActiveSubscription(pendingSubscriptionChange)
+
+      const button = screen.getByRole('button', { name: 'Change plan' })
+      fireEvent.click(button)
+
+      const keepPlanButton = await screen.findByRole('button', {
+        name: 'Keep my current plan',
+      })
+      fireEvent.click(keepPlanButton)
+
+      confirmModal = screen.getByRole('dialog')
+    })
+
+    it('opens confirmation modal when "Keep my current plan" is clicked', async function () {
+      within(confirmModal).getByText(
+        'Are you sure you want to revert your scheduled plan change? You will remain subscribed to the',
+        {
+          exact: false,
+        }
+      )
+      screen.getByRole('button', { name: 'Revert scheduled plan change' })
+    })
+
+    it('keeps current plan when "Revert scheduled plan change" is clicked in modal', async function () {
+      const endPointResponse = {
+        status: 200,
+      }
+      fetchMock.post(cancelPendingSubscriptionChangeUrl, endPointResponse)
+      const buttonConfirm = within(confirmModal).getByRole('button', {
+        name: 'Revert scheduled plan change',
+      })
+      fireEvent.click(buttonConfirm)
+
+      screen.getByText('processing', { exact: false })
+
+      // page is reloaded on success
+      await waitFor(() => {
+        expect(reloadStub).to.have.been.called
+      })
+    })
+
+    it('shows error if keeping plan failed', async function () {
+      const endPointResponse = {
+        status: 500,
+      }
+      fetchMock.post(cancelPendingSubscriptionChangeUrl, endPointResponse)
+      const buttonConfirm = within(confirmModal).getByRole('button', {
+        name: 'Revert scheduled plan change',
+      })
+      fireEvent.click(buttonConfirm)
+
+      screen.getByText('processing', { exact: false })
+      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,
+      })
+      expect(
+        within(screen.getByRole('dialog'))
+          .getByRole('button', { name: 'Revert scheduled plan change' })
+          .getAttribute('disabled')
+      ).to.not.exist
+    })
   })
 })

+ 25 - 0
services/web/test/frontend/features/subscription/helpers/render-active-subscription.tsx

@@ -0,0 +1,25 @@
+import { ActiveSubscription } from '../../../../../frontend/js/features/subscription/components/dashboard/states/active/active'
+import { Subscription } from '../../../../../types/subscription/dashboard/subscription'
+import { plans } from '../fixtures/plans'
+import { renderWithSubscriptionDashContext } from './render-with-subscription-dash-context'
+
+export function renderActiveSubscription(
+  subscription: Subscription,
+  tags: { name: string; value: string | object | Array<object> }[] = []
+) {
+  const renderOptions = {
+    metaTags: [
+      ...tags,
+      { name: 'ol-plans', value: plans },
+      { name: 'ol-subscription', value: subscription },
+      {
+        name: 'ol-recommendedCurrency',
+        value: 'USD',
+      },
+    ],
+  }
+  renderWithSubscriptionDashContext(
+    <ActiveSubscription subscription={subscription} />,
+    renderOptions
+  )
+}