Przeglądaj źródła

Merge pull request #11888 from overleaf/jel-subscription-dash-cancel-plan

[web] Migrate cancel subscription to React

GitOrigin-RevId: 8482fd61894c8011b4c980263ae1d41f396233c6
Jessica Lawshe 3 lat temu
rodzic
commit
ebc04e4a9d
23 zmienionych plików z 749 dodań i 47 usunięć
  1. 1 1
      services/web/app/views/subscriptions/dashboard/_personal_subscription_recurly.pug
  2. 5 0
      services/web/frontend/extracted-translations.json
  3. 12 0
      services/web/frontend/js/features/subscription/components/dashboard/action-button-text.tsx
  4. 3 3
      services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx
  5. 183 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/cancel-subscription.tsx
  6. 43 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/extend-trial-button.tsx
  7. 0 15
      services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-subscription.tsx
  8. 0 5
      services/web/frontend/js/features/subscription/components/dashboard/states/active/trial-ending.tsx
  9. 3 0
      services/web/frontend/js/features/subscription/data/subscription-url.ts
  10. 13 0
      services/web/frontend/js/features/subscription/util/can-extend-trial.ts
  11. 11 0
      services/web/frontend/js/features/subscription/util/free-trial-expires-under-seven-days.ts
  12. 9 0
      services/web/frontend/js/features/subscription/util/is-in-free-trial.ts
  13. 10 0
      services/web/frontend/js/features/subscription/util/is-monthly-collaborator-plan.ts
  14. 13 0
      services/web/frontend/js/features/subscription/util/show-downgrade-option.ts
  15. 1 2
      services/web/locales/en.json
  16. 218 21
      services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx
  17. 87 0
      services/web/test/frontend/features/subscription/fixtures/subscriptions.tsx
  18. 24 0
      services/web/test/frontend/features/subscription/util/can-extend-trial.test.ts
  19. 25 0
      services/web/test/frontend/features/subscription/util/free-trial-expires-under-seven-days.test.ts
  20. 24 0
      services/web/test/frontend/features/subscription/util/is-in-free-trial.test.ts
  21. 17 0
      services/web/test/frontend/features/subscription/util/is-monthly-collaborator-plan.test.ts
  22. 0 0
      services/web/test/frontend/features/subscription/util/recurly-pricing.test.ts
  23. 47 0
      services/web/test/frontend/features/subscription/util/show-downgrade-option.test.ts

+ 1 - 1
services/web/app/views/subscriptions/dashboard/_personal_subscription_recurly.pug

@@ -102,7 +102,7 @@ div(ng-controller="RecurlySubscriptionController")
 
 
 		div(ng-show="showDowngrade")
 		div(ng-show="showDowngrade")
 			div(ng-controller="ChangePlanFormController")
 			div(ng-controller="ChangePlanFormController")
-				p !{translate("interested_in_cheaper_personal_plan",{price:'{{personalDisplayPrice}}'})}
+				p !{translate("interested_in_cheaper_personal_plan", {price:'{{personalDisplayPrice}}'}, ['strong'] )}
 				p
 				p
 					button(type="submit", ng-click="downgradeToPaidPersonal()", ng-disabled='inflight').btn.btn-primary #{translate("yes_move_me_to_personal_plan")}
 					button(type="submit", ng-click="downgradeToPaidPersonal()", ng-disabled='inflight').btn.btn-primary #{translate("yes_move_me_to_personal_plan")}
 				p
 				p

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

@@ -73,6 +73,7 @@
   "can_now_relink_dropbox": "",
   "can_now_relink_dropbox": "",
   "cancel": "",
   "cancel": "",
   "cancel_anytime": "",
   "cancel_anytime": "",
+  "cancel_my_account": "",
   "cancel_your_subscription": "",
   "cancel_your_subscription": "",
   "cannot_invite_non_user": "",
   "cannot_invite_non_user": "",
   "cannot_invite_self": "",
   "cannot_invite_self": "",
@@ -335,6 +336,7 @@
   "group_plan_with_name_tooltip": "",
   "group_plan_with_name_tooltip": "",
   "group_subscription": "",
   "group_subscription": "",
   "have_an_extra_backup": "",
   "have_an_extra_backup": "",
+  "have_more_days_to_try": "",
   "headers": "",
   "headers": "",
   "help": "",
   "help": "",
   "hide_document_preamble": "",
   "hide_document_preamble": "",
@@ -363,8 +365,10 @@
   "hotkey_toggle_track_changes": "",
   "hotkey_toggle_track_changes": "",
   "hotkey_undo": "",
   "hotkey_undo": "",
   "hotkeys": "",
   "hotkeys": "",
+  "i_want_to_stay": "",
   "if_error_persists_try_relinking_provider": "",
   "if_error_persists_try_relinking_provider": "",
   "ignore_validation_errors": "",
   "ignore_validation_errors": "",
+  "ill_take_it": "",
   "import_from_github": "",
   "import_from_github": "",
   "import_to_sharelatex": "",
   "import_to_sharelatex": "",
   "imported_from_another_project_at_date": "",
   "imported_from_another_project_at_date": "",
@@ -512,6 +516,7 @@
   "no_projects": "",
   "no_projects": "",
   "no_search_results": "",
   "no_search_results": "",
   "no_symbols_found": "",
   "no_symbols_found": "",
+  "no_thanks_cancel_now": "",
   "normal": "",
   "normal": "",
   "normally_x_price_per_month": "",
   "normally_x_price_per_month": "",
   "normally_x_price_per_year": "",
   "normally_x_price_per_year": "",

+ 12 - 0
services/web/frontend/js/features/subscription/components/dashboard/action-button-text.tsx

@@ -0,0 +1,12 @@
+import { useTranslation } from 'react-i18next'
+
+export default function ActionButtonText({
+  inflight,
+  buttonText,
+}: {
+  inflight: boolean
+  buttonText: string
+}) {
+  const { t } = useTranslation()
+  return <>{!inflight ? buttonText : t('processing_uppercase') + '…'}</>
+}

+ 3 - 3
services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx

@@ -4,12 +4,13 @@ import { PriceExceptions } from '../../../shared/price-exceptions'
 import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context'
 import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context'
 import { RecurlySubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
 import { RecurlySubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
 import { CancelSubscriptionButton } from './cancel-subscription-button'
 import { CancelSubscriptionButton } from './cancel-subscription-button'
-import { CancelSubscription } from './cancel-subscription'
+import { CancelSubscription } from './cancel-plan/cancel-subscription'
 import { PendingPlanChange } from './pending-plan-change'
 import { PendingPlanChange } from './pending-plan-change'
 import { TrialEnding } from './trial-ending'
 import { TrialEnding } from './trial-ending'
 import { ChangePlan } from './change-plan/change-plan'
 import { ChangePlan } from './change-plan/change-plan'
 import { PendingAdditionalLicenses } from './pending-additional-licenses'
 import { PendingAdditionalLicenses } from './pending-additional-licenses'
 import { ContactSupportToChangeGroupPlan } from './contact-support-to-change-group-plan'
 import { ContactSupportToChangeGroupPlan } from './contact-support-to-change-group-plan'
+import isInFreeTrial from '../../../../util/is-in-free-trial'
 
 
 export function ActiveSubscription({
 export function ActiveSubscription({
   subscription,
   subscription,
@@ -72,10 +73,9 @@ export function ActiveSubscription({
       {(!subscription.pendingPlan ||
       {(!subscription.pendingPlan ||
         subscription.pendingPlan.name === subscription.plan.name) &&
         subscription.pendingPlan.name === subscription.plan.name) &&
         subscription.plan.groupPlan && <ContactSupportToChangeGroupPlan />}
         subscription.plan.groupPlan && <ContactSupportToChangeGroupPlan />}
-      {subscription.recurly.trial_ends_at &&
+      {isInFreeTrial(subscription.recurly.trial_ends_at) &&
         subscription.recurly.trialEndsAtFormatted && (
         subscription.recurly.trialEndsAtFormatted && (
           <TrialEnding
           <TrialEnding
-            trialEndsAt={subscription.recurly.trial_ends_at}
             trialEndsAtFormatted={subscription.recurly.trialEndsAtFormatted}
             trialEndsAtFormatted={subscription.recurly.trialEndsAtFormatted}
           />
           />
         )}
         )}

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

@@ -0,0 +1,183 @@
+import { Trans, useTranslation } from 'react-i18next'
+import { postJSON } from '../../../../../../../infrastructure/fetch-json'
+import useAsync from '../../../../../../../shared/hooks/use-async'
+import { useSubscriptionDashboardContext } from '../../../../../context/subscription-dashboard-context'
+import {
+  cancelSubscriptionUrl,
+  redirectAfterCancelSubscriptionUrl,
+} from '../../../../../data/subscription-url'
+import canExtendTrial from '../../../../../util/can-extend-trial'
+import showDowngradeOption from '../../../../../util/show-downgrade-option'
+import ActionButtonText from '../../../action-button-text'
+import GenericErrorAlert from '../../../generic-error-alert'
+import ExtendTrialButton from './extend-trial-button'
+
+function ConfirmCancelSubscriptionButton({
+  buttonClass,
+  buttonText,
+  handleCancelSubscription,
+  isLoadingCancel,
+  isSuccessCancel,
+  isButtonDisabled,
+}: {
+  buttonClass: string
+  buttonText: string
+  handleCancelSubscription: () => void
+  isLoadingCancel: boolean
+  isSuccessCancel: boolean
+  isButtonDisabled: boolean
+}) {
+  return (
+    <button
+      className={`btn ${buttonClass}`}
+      onClick={handleCancelSubscription}
+      disabled={isButtonDisabled}
+    >
+      <ActionButtonText
+        inflight={isSuccessCancel || isLoadingCancel}
+        buttonText={buttonText}
+      />
+    </button>
+  )
+}
+
+function NotCancelOption({
+  isButtonDisabled,
+  isLoadingSecondaryAction,
+  isSuccessSecondaryAction,
+  showExtendFreeTrial,
+  runAsyncSecondaryAction,
+}: {
+  isButtonDisabled: boolean
+  isLoadingSecondaryAction: boolean
+  isSuccessSecondaryAction: boolean
+  showExtendFreeTrial: boolean
+  runAsyncSecondaryAction: (promise: Promise<unknown>) => Promise<unknown>
+}) {
+  const { t } = useTranslation()
+
+  const { setShowCancellation } = useSubscriptionDashboardContext()
+
+  if (showExtendFreeTrial) {
+    return (
+      <>
+        <p>
+          <Trans
+            i18nKey="have_more_days_to_try"
+            values={{
+              days: 14,
+            }}
+            components={[
+              // eslint-disable-next-line react/jsx-key
+              <strong />,
+            ]}
+          />
+        </p>
+        <p>
+          <ExtendTrialButton
+            isButtonDisabled={isButtonDisabled}
+            isLoadingSecondaryAction={isLoadingSecondaryAction}
+            isSuccessSecondaryAction={isSuccessSecondaryAction}
+            runAsyncSecondaryAction={runAsyncSecondaryAction}
+          />
+        </p>
+      </>
+    )
+  }
+
+  function handleKeepPlan() {
+    setShowCancellation(false)
+  }
+
+  return (
+    <p>
+      <button
+        className="btn btn-secondary-info btn-secondary"
+        onClick={handleKeepPlan}
+      >
+        {t('i_want_to_stay')}
+      </button>
+    </p>
+  )
+}
+
+export function CancelSubscription() {
+  const { t } = useTranslation()
+  const { personalSubscription } = useSubscriptionDashboardContext()
+
+  const {
+    isLoading: isLoadingCancel,
+    isError: isErrorCancel,
+    isSuccess: isSuccessCancel,
+    runAsync: runAsyncCancel,
+  } = useAsync()
+  const {
+    isLoading: isLoadingSecondaryAction,
+    isError: isErrorSecondaryAction,
+    isSuccess: isSuccessSecondaryAction,
+    runAsync: runAsyncSecondaryAction,
+  } = useAsync()
+
+  const isButtonDisabled =
+    isLoadingCancel ||
+    isLoadingSecondaryAction ||
+    isSuccessSecondaryAction ||
+    isSuccessCancel
+
+  if (!personalSubscription || !('recurly' in personalSubscription)) return null
+
+  async function handleCancelSubscription() {
+    try {
+      await runAsyncCancel(postJSON(cancelSubscriptionUrl))
+      window.location.assign(redirectAfterCancelSubscriptionUrl)
+    } catch (e) {
+      console.error(e)
+    }
+  }
+
+  const showExtendFreeTrial = canExtendTrial(
+    personalSubscription.plan.planCode,
+    personalSubscription.plan.groupPlan,
+    personalSubscription.recurly.trial_ends_at
+  )
+
+  const showDowngrade = showDowngradeOption(
+    personalSubscription.plan.planCode,
+    personalSubscription.plan.groupPlan,
+    personalSubscription.recurly.trial_ends_at
+  )
+
+  let confirmCancelButtonText = t('cancel_my_account')
+  let confirmCancelButtonClass = 'btn-primary'
+  if (showExtendFreeTrial || showDowngrade) {
+    confirmCancelButtonText = t('no_thanks_cancel_now')
+    confirmCancelButtonClass = 'btn-inline-link'
+  }
+
+  return (
+    <div className="text-center">
+      <p>
+        <strong>{t('wed_love_you_to_stay')}</strong>
+      </p>
+
+      {(isErrorCancel || isErrorSecondaryAction) && <GenericErrorAlert />}
+
+      <NotCancelOption
+        showExtendFreeTrial={showExtendFreeTrial}
+        isButtonDisabled={isButtonDisabled}
+        isLoadingSecondaryAction={isLoadingSecondaryAction}
+        isSuccessSecondaryAction={isSuccessSecondaryAction}
+        runAsyncSecondaryAction={runAsyncSecondaryAction}
+      />
+
+      <ConfirmCancelSubscriptionButton
+        buttonClass={confirmCancelButtonClass}
+        buttonText={confirmCancelButtonText}
+        isButtonDisabled={isButtonDisabled}
+        handleCancelSubscription={handleCancelSubscription}
+        isSuccessCancel={isSuccessCancel}
+        isLoadingCancel={isLoadingCancel}
+      />
+    </div>
+  )
+}

+ 43 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/extend-trial-button.tsx

@@ -0,0 +1,43 @@
+import { useTranslation } from 'react-i18next'
+import { putJSON } from '../../../../../../../infrastructure/fetch-json'
+import { extendTrialUrl } from '../../../../../data/subscription-url'
+import ActionButtonText from '../../../action-button-text'
+
+export default function ExtendTrialButton({
+  isButtonDisabled,
+  isLoadingSecondaryAction,
+  isSuccessSecondaryAction,
+  runAsyncSecondaryAction,
+}: {
+  isButtonDisabled: boolean
+  isLoadingSecondaryAction: boolean
+  isSuccessSecondaryAction: boolean
+  runAsyncSecondaryAction: (promise: Promise<unknown>) => Promise<unknown>
+}) {
+  const { t } = useTranslation()
+  const buttonText = t('ill_take_it')
+
+  async function handleExtendTrial() {
+    try {
+      await runAsyncSecondaryAction(putJSON(extendTrialUrl))
+      window.location.reload()
+    } catch (e) {
+      console.error(e)
+    }
+  }
+
+  return (
+    <>
+      <button
+        className="btn btn-primary"
+        onClick={handleExtendTrial}
+        disabled={isButtonDisabled}
+      >
+        <ActionButtonText
+          inflight={isLoadingSecondaryAction || isSuccessSecondaryAction}
+          buttonText={buttonText}
+        />
+      </button>
+    </>
+  )
+}

+ 0 - 15
services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-subscription.tsx

@@ -1,15 +0,0 @@
-import { useTranslation } from 'react-i18next'
-
-export function CancelSubscription() {
-  const { t } = useTranslation()
-  return (
-    <div className="text-center">
-      <p>
-        <strong>{t('wed_love_you_to_stay')}</strong>
-      </p>
-      {/* todo: showExtendFreeTrial */}
-      {/* todo: showDowngrade */}
-      {/* todo: showBasicCancel */}
-    </div>
-  )
-}

+ 0 - 5
services/web/frontend/js/features/subscription/components/dashboard/states/active/trial-ending.tsx

@@ -1,15 +1,10 @@
 import { Trans } from 'react-i18next'
 import { Trans } from 'react-i18next'
 
 
 export function TrialEnding({
 export function TrialEnding({
-  trialEndsAt,
   trialEndsAtFormatted,
   trialEndsAtFormatted,
 }: {
 }: {
-  trialEndsAt: string
   trialEndsAtFormatted: string
   trialEndsAtFormatted: string
 }) {
 }) {
-  const endDate = new Date(trialEndsAt)
-  if (endDate.getTime() < Date.now()) return null
-
   return (
   return (
     <p>
     <p>
       <Trans
       <Trans

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

@@ -1,3 +1,6 @@
 export const subscriptionUpdateUrl = '/user/subscription/update'
 export const subscriptionUpdateUrl = '/user/subscription/update'
 export const cancelPendingSubscriptionChangeUrl =
 export const cancelPendingSubscriptionChangeUrl =
   '/user/subscription/cancel-pending'
   '/user/subscription/cancel-pending'
+export const cancelSubscriptionUrl = '/user/subscription/cancel'
+export const redirectAfterCancelSubscriptionUrl = '/user/subscription/canceled'
+export const extendTrialUrl = '/user/subscription/extend'

+ 13 - 0
services/web/frontend/js/features/subscription/util/can-extend-trial.ts

@@ -0,0 +1,13 @@
+import freeTrialExpiresUnderSevenDays from './free-trial-expires-under-seven-days'
+import isMonthlyCollaboratorPlan from './is-monthly-collaborator-plan'
+
+export default function canExtendTrial(
+  planCode: string,
+  isGroupPlan?: boolean,
+  trialEndsAt?: string | null
+) {
+  return (
+    isMonthlyCollaboratorPlan(planCode, isGroupPlan) &&
+    freeTrialExpiresUnderSevenDays(trialEndsAt)
+  )
+}

+ 11 - 0
services/web/frontend/js/features/subscription/util/free-trial-expires-under-seven-days.ts

@@ -0,0 +1,11 @@
+export default function freeTrialExpiresUnderSevenDays(
+  trialEndsAt?: string | null
+) {
+  if (!trialEndsAt) return false
+
+  const sevenDaysTime = new Date()
+  sevenDaysTime.setDate(sevenDaysTime.getDate() + 7)
+  const freeTrialEndDate = new Date(trialEndsAt)
+
+  return new Date() < freeTrialEndDate && freeTrialEndDate < sevenDaysTime
+}

+ 9 - 0
services/web/frontend/js/features/subscription/util/is-in-free-trial.ts

@@ -0,0 +1,9 @@
+export default function isInFreeTrial(trialEndsAt?: string | null) {
+  if (!trialEndsAt) return false
+
+  const endDate = new Date(trialEndsAt)
+
+  if (endDate.getTime() < Date.now()) return false
+
+  return true
+}

+ 10 - 0
services/web/frontend/js/features/subscription/util/is-monthly-collaborator-plan.ts

@@ -0,0 +1,10 @@
+export default function isMonthlyCollaboratorPlan(
+  planCode: string,
+  isGroupPlan?: boolean
+) {
+  return (
+    planCode.indexOf('collaborator') !== -1 &&
+    planCode.indexOf('ann') === -1 &&
+    !isGroupPlan
+  )
+}

+ 13 - 0
services/web/frontend/js/features/subscription/util/show-downgrade-option.ts

@@ -0,0 +1,13 @@
+import isInFreeTrial from './is-in-free-trial'
+import isMonthlyCollaboratorPlan from './is-monthly-collaborator-plan'
+
+export default function showDowngradeOption(
+  planCode: string,
+  isGroupPlan?: boolean,
+  trialEndsAt?: string | null
+) {
+  return (
+    isMonthlyCollaboratorPlan(planCode, isGroupPlan) &&
+    !isInFreeTrial(trialEndsAt)
+  )
+}

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

@@ -737,8 +737,7 @@
   "institutional_login_unknown": "Sorry, we don’t know which institution issued that email address. You can browse our <a href=\"__link__\">list of institutions</a> to find yours, or you can use one of the other options below.",
   "institutional_login_unknown": "Sorry, we don’t know which institution issued that email address. You can browse our <a href=\"__link__\">list of institutions</a> to find yours, or you can use one of the other options below.",
   "integrations": "Integrations",
   "integrations": "Integrations",
   "interested_in": "Interested in",
   "interested_in": "Interested in",
-  "interested_in_cheaper_personal_plan": "Would you be interested in the cheaper <strong>__price__</strong> Personal plan?",
-  "invalid": "Invalid",
+  "interested_in_cheaper_personal_plan": "Would you be interested in the cheaper <0>__price__</0> Personal plan?",
   "invalid_element_name": "Could not copy your project because of filenames containing invalid characters\r\n(such as asterisks, slashes or control characters). Please rename the files and\r\ntry again.",
   "invalid_element_name": "Could not copy your project because of filenames containing invalid characters\r\n(such as asterisks, slashes or control characters). Please rename the files and\r\ntry again.",
   "invalid_email": "An email address is invalid",
   "invalid_email": "An email address is invalid",
   "invalid_file_name": "Invalid File Name",
   "invalid_file_name": "Invalid File Name",

+ 218 - 21
services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx

@@ -1,18 +1,25 @@
 import { expect } from 'chai'
 import { expect } from 'chai'
-import { fireEvent, screen } from '@testing-library/react'
+import { fireEvent, screen, waitFor } from '@testing-library/react'
 import * as eventTracking from '../../../../../../../../frontend/js/infrastructure/event-tracking'
 import * as eventTracking from '../../../../../../../../frontend/js/infrastructure/event-tracking'
 import { RecurlySubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
 import { RecurlySubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
 import {
 import {
   annualActiveSubscription,
   annualActiveSubscription,
   groupActiveSubscription,
   groupActiveSubscription,
   groupActiveSubscriptionWithPendingLicenseChange,
   groupActiveSubscriptionWithPendingLicenseChange,
+  monthlyActiveCollaborator,
   pendingSubscriptionChange,
   pendingSubscriptionChange,
+  trialCollaboratorSubscription,
   trialSubscription,
   trialSubscription,
 } from '../../../../fixtures/subscriptions'
 } from '../../../../fixtures/subscriptions'
 import sinon from 'sinon'
 import sinon from 'sinon'
 import { cleanUpContext } from '../../../../helpers/render-with-subscription-dash-context'
 import { cleanUpContext } from '../../../../helpers/render-with-subscription-dash-context'
 import { renderActiveSubscription } from '../../../../helpers/render-active-subscription'
 import { renderActiveSubscription } from '../../../../helpers/render-active-subscription'
 import { cloneDeep } from 'lodash'
 import { cloneDeep } from 'lodash'
+import fetchMock from 'fetch-mock'
+import {
+  cancelSubscriptionUrl,
+  extendTrialUrl,
+} from '../../../../../../../../frontend/js/features/subscription/data/subscription-url'
 
 
 describe('<ActiveSubscription />', function () {
 describe('<ActiveSubscription />', function () {
   let sendMBSpy: sinon.SinonSpy
   let sendMBSpy: sinon.SinonSpy
@@ -186,30 +193,220 @@ describe('<ActiveSubscription />', function () {
     )
     )
   })
   })
 
 
-  it('shows cancel UI and sends event', function () {
-    renderActiveSubscription(annualActiveSubscription)
-    // before button clicked
-    screen.getByText(
-      'Your subscription will remain active until the end of your billing period',
-      { exact: false }
-    )
-    const dates = screen.getAllByText(
-      annualActiveSubscription.recurly.nextPaymentDueAt,
-      {
-        exact: false,
+  describe('cancel plan', function () {
+    const locationStub = sinon.stub()
+    const reloadStub = sinon.stub()
+    const originalLocation = window.location
+
+    beforeEach(function () {
+      Object.defineProperty(window, 'location', {
+        value: { assign: locationStub, reload: reloadStub },
+      })
+    })
+
+    afterEach(function () {
+      Object.defineProperty(window, 'location', {
+        value: originalLocation,
+      })
+      fetchMock.reset()
+    })
+
+    function showConfirmCancelUI() {
+      const button = screen.getByRole('button', {
+        name: 'Cancel Your Subscription',
+      })
+      fireEvent.click(button)
+    }
+
+    it('shows cancel UI and sends event', function () {
+      renderActiveSubscription(annualActiveSubscription)
+      // before button clicked
+      screen.getByText(
+        'Your subscription will remain active until the end of your billing period',
+        { exact: false }
+      )
+      const dates = screen.getAllByText(
+        annualActiveSubscription.recurly.nextPaymentDueAt,
+        {
+          exact: false,
+        }
+      )
+      expect(dates.length).to.equal(2)
+
+      showConfirmCancelUI()
+
+      expect(sendMBSpy).to.be.calledOnceWith(
+        'subscription-page-cancel-button-click'
+      )
+
+      screen.getByText('We’d love you to stay')
+      screen.getByRole('button', { name: 'Cancel my subscription' })
+    })
+
+    it('cancels subscription and redirects page', async function () {
+      const endPointResponse = {
+        status: 200,
       }
       }
-    )
-    expect(dates.length).to.equal(2)
+      fetchMock.post(cancelSubscriptionUrl, endPointResponse)
+      renderActiveSubscription(annualActiveSubscription)
+      showConfirmCancelUI()
+      const button = screen.getByRole('button', {
+        name: 'Cancel my subscription',
+      })
+      fireEvent.click(button)
+      await waitFor(() => {
+        expect(locationStub).to.have.been.called
+      })
+      sinon.assert.calledWithMatch(locationStub, '/user/subscription/canceled')
+    })
 
 
-    const button = screen.getByRole('button', {
-      name: 'Cancel Your Subscription',
+    it('shows an error message if canceling subscription failed', async function () {
+      const endPointResponse = {
+        status: 500,
+      }
+      fetchMock.post(cancelSubscriptionUrl, endPointResponse)
+      renderActiveSubscription(annualActiveSubscription)
+      showConfirmCancelUI()
+      const button = screen.getByRole('button', {
+        name: 'Cancel my subscription',
+      })
+      fireEvent.click(button)
+      await screen.findByText('Sorry, something went wrong. ', {
+        exact: false,
+      })
+      screen.getByText('Please try again. ', { exact: false })
+      screen.getByText('If the problem continues please contact us.', {
+        exact: false,
+      })
     })
     })
-    fireEvent.click(button)
-    expect(sendMBSpy).to.be.calledOnceWith(
-      'subscription-page-cancel-button-click'
-    )
 
 
-    screen.getByText('We’d love you to stay')
+    it('disables cancels subscription button after clicking and updates text', async function () {
+      renderActiveSubscription(annualActiveSubscription)
+      showConfirmCancelUI()
+      screen.getByRole('button', {
+        name: 'I want to stay',
+      })
+      const button = screen.getByRole('button', {
+        name: 'Cancel my subscription',
+      })
+      fireEvent.click(button)
+
+      const cancelButtton = screen.getByRole('button', {
+        name: 'Processing…',
+      }) as HTMLButtonElement
+      expect(cancelButtton.disabled).to.be.true
+
+      expect(screen.queryByText('Cancel my subscription')).to.be.null
+    })
+
+    describe('extend trial', function () {
+      const cancelButtonText = 'No thanks, I still want to cancel'
+      const extendTrialButtonText = 'I’ll take it!'
+      it('shows alternate cancel subscription button text for cancel button and option to extend trial', function () {
+        renderActiveSubscription(trialCollaboratorSubscription)
+        showConfirmCancelUI()
+        screen.getByText('Have another', { exact: false })
+        screen.getByText('14 days', { exact: false })
+        screen.getByText('on your Trial!', { exact: false })
+        screen.getByRole('button', {
+          name: cancelButtonText,
+        })
+        screen.getByRole('button', {
+          name: extendTrialButtonText,
+        })
+      })
+
+      it('disables both buttons and updates text for when trial button clicked', function () {
+        renderActiveSubscription(trialCollaboratorSubscription)
+        showConfirmCancelUI()
+        const extendTrialButton = screen.getByRole('button', {
+          name: extendTrialButtonText,
+        })
+        fireEvent.click(extendTrialButton)
+
+        const buttons = screen.getAllByRole('button')
+        expect(buttons.length).to.equal(2)
+        expect(buttons[0].getAttribute('disabled')).to.equal('')
+        expect(buttons[1].getAttribute('disabled')).to.equal('')
+        screen.getByRole('button', {
+          name: cancelButtonText,
+        })
+        screen.getByRole('button', {
+          name: 'Processing…',
+        })
+      })
+
+      it('disables both buttons and updates text for when cancel button clicked', function () {
+        renderActiveSubscription(trialCollaboratorSubscription)
+        showConfirmCancelUI()
+        const cancelButtton = screen.getByRole('button', {
+          name: cancelButtonText,
+        })
+        fireEvent.click(cancelButtton)
+
+        const buttons = screen.getAllByRole('button')
+        expect(buttons.length).to.equal(2)
+        expect(buttons[0].getAttribute('disabled')).to.equal('')
+        expect(buttons[1].getAttribute('disabled')).to.equal('')
+        screen.getByRole('button', {
+          name: 'Processing…',
+        })
+        screen.getByRole('button', {
+          name: extendTrialButtonText,
+        })
+      })
+
+      it('does not show option to extend trial when not a collaborator trial', function () {
+        const trialPlan = cloneDeep(trialCollaboratorSubscription)
+        trialPlan.plan.planCode = 'anotherplan'
+        renderActiveSubscription(trialPlan)
+        showConfirmCancelUI()
+        expect(
+          screen.queryByRole('button', {
+            name: extendTrialButtonText,
+          })
+        ).to.be.null
+      })
+
+      it('does not show option to extend trial when a collaborator trial but does not expire in 7 days', function () {
+        const trialPlan = cloneDeep(trialCollaboratorSubscription)
+        trialPlan.recurly.trial_ends_at = null
+        renderActiveSubscription(trialPlan)
+        showConfirmCancelUI()
+        expect(
+          screen.queryByRole('button', {
+            name: extendTrialButtonText,
+          })
+        ).to.be.null
+      })
+
+      it('reloads page after the succesful request to extend trial', async function () {
+        const endPointResponse = {
+          status: 200,
+        }
+        fetchMock.put(extendTrialUrl, endPointResponse)
+        renderActiveSubscription(trialCollaboratorSubscription)
+        showConfirmCancelUI()
+        const extendTrialButton = screen.getByRole('button', {
+          name: extendTrialButtonText,
+        })
+        fireEvent.click(extendTrialButton)
+        // page is reloaded on success
+        await waitFor(() => {
+          expect(reloadStub).to.have.been.called
+        })
+      })
+    })
+
+    describe('downgrade plan', function () {
+      it('shows alternate cancel subscription button text', function () {
+        renderActiveSubscription(monthlyActiveCollaborator)
+        showConfirmCancelUI()
+        screen.getByRole('button', {
+          name: 'No thanks, I still want to cancel',
+        })
+      })
+    })
   })
   })
 
 
   describe('group plans', function () {
   describe('group plans', function () {

+ 87 - 0
services/web/test/frontend/features/subscription/fixtures/subscriptions.tsx

@@ -432,3 +432,90 @@ export const customSubscription: CustomSubscription = {
   },
   },
   customAccount: true,
   customAccount: true,
 }
 }
+
+export const trialCollaboratorSubscription: RecurlySubscription = {
+  manager_ids: ['abc123'],
+  member_ids: [],
+  invited_emails: [],
+  groupPlan: false,
+  membersLimit: 0,
+  _id: 'def456',
+  admin_id: 'abc123',
+  teamInvites: [],
+  planCode: 'collaborator_free_trial_7_days',
+  recurlySubscription_id: 'ghi789',
+  plan: {
+    planCode: 'collaborator_free_trial_7_days',
+    name: 'Standard (Collaborator)',
+    price_in_cents: 2300,
+    featureDescription: [],
+    hideFromUsers: true,
+  },
+  recurly: {
+    tax: 0,
+    taxRate: 0,
+    billingDetailsLink: '/user/subscription/recurly/billing-details',
+    accountManagementLink: '/user/subscription/recurly/account-management',
+    additionalLicenses: 0,
+    totalLicenses: 0,
+    nextPaymentDueAt: sevenDaysFromTodayFormatted,
+    currency: 'USD',
+    state: 'active',
+    trialEndsAtFormatted: sevenDaysFromTodayFormatted,
+    trial_ends_at: new Date(sevenDaysFromToday).toString(),
+    activeCoupons: [],
+    account: {
+      has_canceled_subscription: {
+        _: 'false',
+        $: {
+          type: 'boolean',
+        },
+      },
+      has_past_due_invoice: {
+        _: 'false',
+        $: {
+          type: 'boolean',
+        },
+      },
+    },
+    displayPrice: '$21.00',
+  },
+}
+
+export const monthlyActiveCollaborator: RecurlySubscription = {
+  manager_ids: ['abc123'],
+  member_ids: [],
+  invited_emails: [],
+  groupPlan: false,
+  membersLimit: 0,
+  _id: 'def456',
+  admin_id: 'abc123',
+  teamInvites: [],
+  planCode: 'collaborator',
+  recurlySubscription_id: 'ghi789',
+  plan: {
+    planCode: 'collaborator',
+    name: 'Standard (Collaborator)',
+    price_in_cents: 212300900,
+    featureDescription: [],
+  },
+  recurly: {
+    tax: 0,
+    taxRate: 0,
+    billingDetailsLink: '/user/subscription/recurly/billing-details',
+    accountManagementLink: '/user/subscription/recurly/account-management',
+    additionalLicenses: 0,
+    totalLicenses: 0,
+    nextPaymentDueAt,
+    currency: 'USD',
+    state: 'active',
+    trialEndsAtFormatted: null,
+    trial_ends_at: null,
+    activeCoupons: [],
+    account: {
+      has_canceled_subscription: { _: 'false', $: { type: 'boolean' } },
+      has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
+    },
+    displayPrice: '$21.00',
+  },
+}

+ 24 - 0
services/web/test/frontend/features/subscription/util/can-extend-trial.test.ts

@@ -0,0 +1,24 @@
+import { expect } from 'chai'
+import canExtendTrial from '../../../../../frontend/js/features/subscription/util/can-extend-trial'
+
+describe('canExtendTrial', function () {
+  const d = new Date()
+  d.setDate(d.getDate() + 6)
+
+  it('returns false when no trial end date', function () {
+    expect(canExtendTrial('collab')).to.be.false
+  })
+  it('returns false when a plan code without "collaborator" ', function () {
+    expect(canExtendTrial('test', false, d.toString())).to.be.false
+  })
+  it('returns false when on a plan with trial date in future but has "collaborator" and "ann" in plan code', function () {
+    expect(canExtendTrial('collaborator-annual', false, d.toString())).to.be
+      .false
+  })
+  it('returns false when on a plan with trial date in future and plan code has "collaborator" and no "ann" but is a group plan', function () {
+    expect(canExtendTrial('collaborator', true, d.toString())).to.be.false
+  })
+  it('returns true when on a plan with "collaborator" and without "ann" and trial date in future', function () {
+    expect(canExtendTrial('collaborator', false, d.toString())).to.be.true
+  })
+})

+ 25 - 0
services/web/test/frontend/features/subscription/util/free-trial-expires-under-seven-days.test.ts

@@ -0,0 +1,25 @@
+import { expect } from 'chai'
+import freeTrialExpiresUnderSevenDays from '../../../../../frontend/js/features/subscription/util/free-trial-expires-under-seven-days'
+
+describe('freeTrialExpiresUnderSevenDays', function () {
+  it('returns false when no date sent', function () {
+    expect(freeTrialExpiresUnderSevenDays()).to.be.false
+  })
+  it('returns false when date is null', function () {
+    expect(freeTrialExpiresUnderSevenDays(null)).to.be.false
+  })
+  it('returns false when date is in the past', function () {
+    expect(freeTrialExpiresUnderSevenDays('2000-02-16T17:59:07.000Z')).to.be
+      .false
+  })
+  it('returns true when date is in 6 days', function () {
+    const d = new Date()
+    d.setDate(d.getDate() + 6)
+    expect(freeTrialExpiresUnderSevenDays(d.toString())).to.be.true
+  })
+  it('returns false when date is in 8 days', function () {
+    const d = new Date()
+    d.setDate(d.getDate() + 8)
+    expect(freeTrialExpiresUnderSevenDays(d.toString())).to.be.false
+  })
+})

+ 24 - 0
services/web/test/frontend/features/subscription/util/is-in-free-trial.test.ts

@@ -0,0 +1,24 @@
+import { expect } from 'chai'
+import isInFreeTrial from '../../../../../frontend/js/features/subscription/util/is-in-free-trial'
+const dateformat = require('dateformat')
+
+describe('isInFreeTrial', function () {
+  it('returns false when no date sent', function () {
+    expect(isInFreeTrial()).to.be.false
+  })
+  it('returns false when date is null', function () {
+    expect(isInFreeTrial(null)).to.be.false
+  })
+  it('returns false when date is in the past', function () {
+    expect(isInFreeTrial('2000-02-16T17:59:07.000Z')).to.be.false
+  })
+  it('returns true when date is in the future', function () {
+    const today = new Date()
+    const sevenDaysFromToday = new Date().setDate(today.getDate() + 7)
+    const sevenDaysFromTodayFormatted = dateformat(
+      sevenDaysFromToday,
+      'dS mmmm yyyy'
+    )
+    expect(isInFreeTrial(sevenDaysFromTodayFormatted)).to.be.true
+  })
+})

+ 17 - 0
services/web/test/frontend/features/subscription/util/is-monthly-collaborator-plan.test.ts

@@ -0,0 +1,17 @@
+import { expect } from 'chai'
+import isMonthlyCollaboratorPlan from '../../../../../frontend/js/features/subscription/util/is-monthly-collaborator-plan'
+
+describe('isMonthlyCollaboratorPlan', function () {
+  it('returns false when a plan code without "collaborator" ', function () {
+    expect(isMonthlyCollaboratorPlan('test', false)).to.be.false
+  })
+  it('returns false when on a plan with "collaborator" and "ann"', function () {
+    expect(isMonthlyCollaboratorPlan('collaborator-annual', false)).to.be.false
+  })
+  it('returns false when on a plan with "collaborator" and without "ann" but is a group plan', function () {
+    expect(isMonthlyCollaboratorPlan('collaborator', true)).to.be.false
+  })
+  it('returns true when on a plan with non-group "collaborator" monthly plan', function () {
+    expect(isMonthlyCollaboratorPlan('collaborator', false)).to.be.true
+  })
+})

+ 0 - 0
services/web/test/frontend/features/subscription/util/recurly-pricing.test.tsx → services/web/test/frontend/features/subscription/util/recurly-pricing.test.ts


+ 47 - 0
services/web/test/frontend/features/subscription/util/show-downgrade-option.test.ts

@@ -0,0 +1,47 @@
+import { expect } from 'chai'
+import showDowngradeOption from '../../../../../frontend/js/features/subscription/util/show-downgrade-option'
+const dateformat = require('dateformat')
+
+describe('showDowngradeOption', function () {
+  const today = new Date()
+  const sevenDaysFromToday = new Date().setDate(today.getDate() + 7)
+  const sevenDaysFromTodayFormatted = dateformat(
+    sevenDaysFromToday,
+    'dS mmmm yyyy'
+  )
+
+  it('returns false when no trial end date', function () {
+    expect(showDowngradeOption('collab')).to.be.false
+  })
+  it('returns false when a plan code without "collaborator" ', function () {
+    expect(showDowngradeOption('test', false, sevenDaysFromTodayFormatted)).to
+      .be.false
+  })
+  it('returns false when on a plan with trial date in future but has "collaborator" and "ann" in plan code', function () {
+    expect(
+      showDowngradeOption(
+        'collaborator-annual',
+        false,
+        sevenDaysFromTodayFormatted
+      )
+    ).to.be.false
+  })
+  it('returns false when on a plan with trial date in future and plan code has "collaborator" and no "ann" but is a group plan', function () {
+    expect(
+      showDowngradeOption('collaborator', true, sevenDaysFromTodayFormatted)
+    ).to.be.false
+  })
+  it('returns false when on a plan with "collaborator" and without "ann" and trial date in future', function () {
+    expect(
+      showDowngradeOption('collaborator', false, sevenDaysFromTodayFormatted)
+    ).to.be.false
+  })
+  it('returns true when on a plan with "collaborator" and without "ann" and no trial date', function () {
+    expect(showDowngradeOption('collaborator', false)).to.be.true
+  })
+  it('returns true when on a plan with "collaborator" and without "ann" and trial date is in the past', function () {
+    expect(
+      showDowngradeOption('collaborator', false, '2000-02-16T17:59:07.000Z')
+    ).to.be.true
+  })
+})