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

Merge pull request #21905 from overleaf/rh-pause-sub

Add support for pausing subscription

GitOrigin-RevId: f939ea4e7f3c2b1fa16dcb8aff1b2460d091d4e2
roo hutton 1 год назад
Родитель
Сommit
ad096f82bf
41 измененных файлов с 1318 добавлено и 22 удалено
  1. 1 1
      services/web/app/src/Features/Subscription/FeaturesUpdater.js
  2. 14 0
      services/web/app/src/Features/Subscription/RecurlyClient.js
  3. 46 0
      services/web/app/src/Features/Subscription/RecurlyEventHandler.js
  4. 56 0
      services/web/app/src/Features/Subscription/SubscriptionController.js
  5. 46 0
      services/web/app/src/Features/Subscription/SubscriptionHandler.js
  6. 17 0
      services/web/app/src/Features/Subscription/SubscriptionRouter.mjs
  7. 2 0
      services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js
  8. 25 0
      services/web/frontend/extracted-translations.json
  9. 9 0
      services/web/frontend/js/features/project-list/components/current-plan-widget/current-plan-widget.tsx
  10. 59 0
      services/web/frontend/js/features/project-list/components/current-plan-widget/paused-plan.tsx
  11. 20 0
      services/web/frontend/js/features/subscription/components/dashboard/pause-duck.svg
  12. 137 0
      services/web/frontend/js/features/subscription/components/dashboard/pause-modal.tsx
  13. 3 0
      services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx
  14. 101 15
      services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx
  15. 34 4
      services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-subscription-button.tsx
  16. 103 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/confirm-unpause-modal.tsx
  17. 61 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/flash-message.tsx
  18. 95 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/paused.tsx
  19. 18 0
      services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx
  20. 7 0
      services/web/frontend/js/shared/components/location.js
  21. 20 1
      services/web/frontend/js/shared/hooks/use-location.ts
  22. 25 0
      services/web/locales/en.json
  23. 26 0
      services/web/test/frontend/features/project-list/components/current-plan-widget.test.tsx
  24. 1 0
      services/web/test/frontend/features/project-list/components/new-project-button/modal-content-new-project-form.test.tsx
  25. 1 0
      services/web/test/frontend/features/project-list/components/notifications.test.tsx
  26. 1 0
      services/web/test/frontend/features/project-list/components/project-list-root.test.tsx
  27. 1 0
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.test.tsx
  28. 1 0
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/download-project-button.test.tsx
  29. 1 0
      services/web/test/frontend/features/settings/components/emails/reconfirmation-info.test.tsx
  30. 1 0
      services/web/test/frontend/features/settings/components/leave/modal-form.test.tsx
  31. 1 0
      services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx
  32. 143 0
      services/web/test/frontend/features/subscription/components/dashboard/pause-modal.test.tsx
  33. 2 0
      services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx
  34. 2 0
      services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx
  35. 2 0
      services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx
  36. 18 0
      services/web/test/unit/src/Subscription/RecurlyClientTests.js
  37. 60 0
      services/web/test/unit/src/Subscription/SubscriptionControllerTests.js
  38. 148 0
      services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js
  39. 5 0
      services/web/types/project/dashboard/subscription.ts
  40. 2 0
      services/web/types/subscription/dashboard/modal-ids.ts
  41. 3 1
      services/web/types/subscription/dashboard/subscription.ts

+ 1 - 1
services/web/app/src/Features/Subscription/FeaturesUpdater.js

@@ -117,7 +117,7 @@ async function computeFeatures(userId) {
 async function _getIndividualFeatures(userId) {
   const subscription =
     await SubscriptionLocator.promises.getUsersSubscription(userId)
-  if (subscription == null) {
+  if (subscription == null || subscription?.recurlyStatus?.state === 'paused') {
     return {}
   }
 

+ 14 - 0
services/web/app/src/Features/Subscription/RecurlyClient.js

@@ -176,6 +176,16 @@ async function cancelSubscriptionByUuid(subscriptionUuid) {
   }
 }
 
+async function pauseSubscriptionByUuid(subscriptionUuid, pauseCycles) {
+  return await client.pauseSubscription('uuid-' + subscriptionUuid, {
+    remainingPauseCycles: pauseCycles,
+  })
+}
+
+async function resumeSubscriptionByUuid(subscriptionUuid) {
+  return await client.resumeSubscription('uuid-' + subscriptionUuid)
+}
+
 /**
  * Get the payment method for the given user
  *
@@ -459,6 +469,8 @@ module.exports = {
   getAddOn: callbackify(getAddOn),
   getPlan: callbackify(getPlan),
   subscriptionIsCanceledOrExpired,
+  pauseSubscriptionByUuid: callbackify(pauseSubscriptionByUuid),
+  resumeSubscriptionByUuid: callbackify(resumeSubscriptionByUuid),
 
   promises: {
     getSubscription,
@@ -471,6 +483,8 @@ module.exports = {
     removeSubscriptionChangeByUuid,
     reactivateSubscriptionByUuid,
     cancelSubscriptionByUuid,
+    pauseSubscriptionByUuid,
+    resumeSubscriptionByUuid,
     getPaymentMethod,
     getAddOn,
     getPlan,

+ 46 - 0
services/web/app/src/Features/Subscription/RecurlyEventHandler.js

@@ -30,6 +30,13 @@ async function sendRecurlyAnalyticsEvent(event, eventData) {
     case 'reactivated_account_notification':
       await _sendSubscriptionReactivatedEvent(userId, eventData)
       break
+    case 'subscription_paused_notification':
+      await _sendSubscriptionPausedEvent(userId, eventData)
+      break
+    case 'subscription_resumed_notification':
+      // 'resumed' here means resumed from pause
+      await _sendSubscriptionResumedEvent(userId, eventData)
+      break
     case 'paid_charge_invoice_notification':
       if (
         eventData.invoice.state === 'paid' &&
@@ -49,6 +56,45 @@ async function sendRecurlyAnalyticsEvent(event, eventData) {
   }
 }
 
+async function _sendSubscriptionResumedEvent(userId, eventData) {
+  const { planCode, state, subscriptionId } = _getSubscriptionData(eventData)
+
+  AnalyticsManager.recordEventForUserInBackground(
+    userId,
+    'subscription-resumed',
+    {
+      plan_code: planCode,
+      subscriptionId,
+    }
+  )
+  AnalyticsManager.setUserPropertyForUserInBackground(
+    userId,
+    'subscription-state',
+    state
+  )
+}
+
+async function _sendSubscriptionPausedEvent(userId, eventData) {
+  const { planCode, state, subscriptionId } = _getSubscriptionData(eventData)
+
+  const pauseLength = eventData.subscription.remaining_pause_cycles
+
+  AnalyticsManager.recordEventForUserInBackground(
+    userId,
+    'subscription-paused',
+    {
+      pause_length: pauseLength,
+      plan_code: planCode,
+      subscriptionId,
+    }
+  )
+  AnalyticsManager.setUserPropertyForUserInBackground(
+    userId,
+    'subscription-state',
+    state
+  )
+}
+
 async function _sendSubscriptionStartedEvent(userId, eventData) {
   const { planCode, quantity, state, isTrial, hasAiAddOn, subscriptionId } =
     _getSubscriptionData(eventData)

+ 56 - 0
services/web/app/src/Features/Subscription/SubscriptionController.js

@@ -51,6 +51,8 @@ async function userSubscriptionPage(req, res) {
 
   await SplitTestHandler.promises.getAssignment(req, res, 'ai-add-on')
 
+  await SplitTestHandler.promises.getAssignment(req, res, 'pause-subscription')
+
   // Populates splitTestVariants with a value for the split test name and allows
   // Pug to read it
   await SplitTestHandler.promises.getAssignment(
@@ -173,6 +175,56 @@ async function successfulSubscription(req, res) {
   }
 }
 
+async function pauseSubscription(req, res, next) {
+  const user = SessionManager.getSessionUser(req.session)
+  const pauseCycles = req.params.pauseCycles
+  if (!('pauseCycles' in req.params)) {
+    return HttpErrorHandler.badRequest(
+      req,
+      res,
+      `Pausing subscription requires a 'pauseCycles' argument with number of billing cycles to pause for`
+    )
+  }
+  if (pauseCycles < 0) {
+    return HttpErrorHandler.badRequest(
+      req,
+      res,
+      `'pauseCycles' should be a number of billing cycles to pause for, or 0 to cancel a pending pause`
+    )
+  }
+  logger.debug(
+    { userId: user._id },
+    `pausing subscription for ${pauseCycles} billing cycles`
+  )
+  try {
+    await SubscriptionHandler.promises.pauseSubscription(user, pauseCycles)
+    return res.sendStatus(200)
+  } catch (err) {
+    if (err instanceof Error) {
+      OError.tag(err, 'something went wrong pausing subscription', {
+        user_id: user._id,
+      })
+    }
+    return next(err)
+  }
+}
+
+async function resumeSubscription(req, res, next) {
+  const user = SessionManager.getSessionUser(req.session)
+  logger.debug({ userId: user._id }, `resuming subscription`)
+  try {
+    await SubscriptionHandler.promises.resumeSubscription(user)
+    return res.sendStatus(200)
+  } catch (err) {
+    if (err instanceof Error) {
+      OError.tag(err, 'something went wrong resuming subscription', {
+        user_id: user._id,
+      })
+    }
+    return next(err)
+  }
+}
+
 function cancelSubscription(req, res, next) {
   const user = SessionManager.getSessionUser(req.session)
   logger.debug({ userId: user._id }, 'canceling subscription')
@@ -458,6 +510,8 @@ function recurlyCallback(req, res, next) {
       'new_subscription_notification',
       'updated_subscription_notification',
       'expired_subscription_notification',
+      'subscription_paused_notification',
+      'subscription_resumed_notification',
     ].includes(event)
   ) {
     const recurlySubscription = eventData.subscription
@@ -667,6 +721,8 @@ module.exports = {
   userSubscriptionPage: expressify(userSubscriptionPage),
   successfulSubscription: expressify(successfulSubscription),
   cancelSubscription,
+  pauseSubscription,
+  resumeSubscription,
   canceledSubscription: expressify(canceledSubscription),
   cancelV1Subscription,
   previewSubscription: expressify(previewSubscription),

+ 46 - 0
services/web/app/src/Features/Subscription/SubscriptionHandler.js

@@ -381,6 +381,48 @@ async function getSubscriptionForUser(userId) {
   }
 }
 
+async function pauseSubscription(user, pauseCycles) {
+  // only allow pausing on monthly plans not in a trial
+  const { subscription } =
+    await LimitationsManager.promises.userHasSubscription(user)
+  if (!subscription || !subscription.recurlyStatus) {
+    throw new Error('No active subscription to pause')
+  }
+
+  if (
+    !subscription.planCode ||
+    subscription.planCode.includes('ann') ||
+    subscription.groupPlan
+  ) {
+    throw new Error('Can only pause monthly individual plans')
+  }
+  if (
+    subscription.recurlyStatus.trialEndsAt &&
+    subscription.recurlyStatus.trialEndsAt > new Date()
+  ) {
+    throw new Error('Cannot pause a subscription in a trial')
+  }
+  if (subscription.addOns?.length) {
+    throw new Error('Cannot pause a subscription with addons')
+  }
+
+  await RecurlyClient.promises.pauseSubscriptionByUuid(
+    subscription.recurlySubscription_id,
+    pauseCycles
+  )
+}
+
+async function resumeSubscription(user) {
+  const { subscription } =
+    await LimitationsManager.promises.userHasSubscription(user)
+  if (!subscription || !subscription.recurlyStatus) {
+    throw new Error('No active subscription to resume')
+  }
+  await RecurlyClient.promises.resumeSubscriptionByUuid(
+    subscription.recurlySubscription_id
+  )
+}
+
 module.exports = {
   validateNoSubscriptionInRecurly: callbackify(validateNoSubscriptionInRecurly),
   createSubscription: callbackify(createSubscription),
@@ -395,6 +437,8 @@ module.exports = {
   previewAddonPurchase: callbackify(previewAddonPurchase),
   purchaseAddon: callbackify(purchaseAddon),
   removeAddon: callbackify(removeAddon),
+  pauseSubscription: callbackify(pauseSubscription),
+  resumeSubscription: callbackify(resumeSubscription),
   promises: {
     validateNoSubscriptionInRecurly,
     createSubscription,
@@ -409,5 +453,7 @@ module.exports = {
     previewAddonPurchase,
     purchaseAddon,
     removeAddon,
+    pauseSubscription,
+    resumeSubscription,
   },
 }

+ 17 - 0
services/web/app/src/Features/Subscription/SubscriptionRouter.mjs

@@ -205,6 +205,23 @@ export default {
       RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
       SubscriptionController.cancelSubscription
     )
+    webRouter.post(
+      '/user/subscription/pause/:pauseCycles',
+      AuthenticationController.requireLogin(),
+      validate({
+        params: Joi.object({
+          pauseCycles: Joi.number().integer().max(12),
+        }),
+      }),
+      RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
+      SubscriptionController.pauseSubscription
+    )
+    webRouter.post(
+      '/user/subscription/resume',
+      AuthenticationController.requireLogin(),
+      RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
+      SubscriptionController.resumeSubscription
+    )
     webRouter.post(
       '/user/subscription/reactivate',
       AuthenticationController.requireLogin(),

+ 2 - 0
services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js

@@ -279,6 +279,8 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
       trial_ends_at: recurlySubscription.trial_ends_at,
       activeCoupons: recurlyCoupons,
       account: recurlySubscription.account,
+      pausedAt: recurlySubscription.paused_at,
+      remainingPauseCycles: recurlySubscription.remaining_pause_cycles,
     }
     if (recurlySubscription.pending_subscription) {
       const pendingPlan = PlansLocator.findLocalPlanInSettings(

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

@@ -42,6 +42,7 @@
   "accepted_invite": "",
   "accepting_invite_as": "",
   "access_denied": "",
+  "access_edit_your_projects": "",
   "access_levels_changed": "",
   "account_has_been_link_to_institution_account": "",
   "account_has_past_due_invoice_change_plan_warning": "",
@@ -188,6 +189,7 @@
   "cancel_my_account": "",
   "cancel_my_subscription": "",
   "cancel_personal_subscription_first": "",
+  "cancel_subscription": "",
   "cancel_your_subscription": "",
   "cannot_invite_non_user": "",
   "cannot_invite_self": "",
@@ -234,6 +236,7 @@
   "clear_search": "",
   "click_here_to_view_sl_in_lng": "",
   "click_to_give_feedback": "",
+  "click_to_unpause": "",
   "clicking_delete_will_remove_sso_config_and_clear_saml_data": "",
   "clone_with_git": "",
   "close": "",
@@ -296,6 +299,7 @@
   "continue": "",
   "continue_github_merge": "",
   "continue_to": "",
+  "continue_using_free_features": "",
   "copied": "",
   "copy": "",
   "copy_code": "",
@@ -394,6 +398,7 @@
   "doing_this_allow_log_in_through_institution_2": "",
   "doing_this_will_verify_affiliation_and_allow_log_in_2": "",
   "done": "",
+  "dont_forget_you_currently_have": "",
   "download": "",
   "download_all": "",
   "download_metadata": "",
@@ -493,6 +498,7 @@
   "failed_to_send_managed_user_invite_to_email": "",
   "failed_to_send_sso_link_invite_to_email": "",
   "fast": "",
+  "features_like_track_changes": "",
   "file_action_created": "",
   "file_action_deleted": "",
   "file_action_edited": "",
@@ -759,6 +765,7 @@
   "institution_templates": "",
   "institutional_leavers_survey_notification": "",
   "integrations": "",
+  "integrations_like_github": "",
   "interested_in_cheaper_personal_plan": "",
   "interface": "",
   "interface_settings": "",
@@ -835,6 +842,7 @@
   "let_us_know": "",
   "let_us_know_how_we_can_help": "",
   "let_us_know_what_you_think": "",
+  "lets_get_those_premium_features": "",
   "library": "",
   "license_for_educational_purposes_confirmation": "",
   "limited_offer": "",
@@ -944,9 +952,12 @@
   "missing_fields_for_entry": "",
   "money_back_guarantee": "",
   "month": "",
+  "month_plural": "",
   "more": "",
   "more_actions": "",
+  "more_collabs_per_project": "",
   "more_comments": "",
+  "more_compile_time": "",
   "more_info": "",
   "more_options": "",
   "more_options_for_border_settings_coming_soon": "",
@@ -1085,6 +1096,8 @@
   "paste_options": "",
   "paste_with_formatting": "",
   "paste_without_formatting": "",
+  "pause_subscription": "",
+  "pause_subscription_for": "",
   "pay_now": "",
   "payment_provider_unreachable_error": "",
   "payment_summary": "",
@@ -1107,6 +1120,7 @@
   "percent_is_the_percentage_of_the_line_width": "",
   "permanently_disables_the_preview": "",
   "personal_library": "",
+  "pick_up_where_you_left_off": "",
   "plan": "",
   "plan_tooltip": "",
   "please_ask_the_project_owner_to_upgrade_more_editors": "",
@@ -1778,9 +1792,11 @@
   "unlink_warning_reference": "",
   "unlinking": "",
   "unmerge_cells": "",
+  "unpause_subscription": "",
   "unpublish": "",
   "unpublishing": "",
   "unsubscribe": "",
+  "until_then_you_can_still": "",
   "untrash": "",
   "update": "",
   "update_account_info": "",
@@ -1874,6 +1890,7 @@
   "website_status": "",
   "wed_love_you_to_stay": "",
   "welcome_to_sl": "",
+  "well_be_here_when_youre_ready": "",
   "were_making_some_changes_to_project_sharing_this_means_you_will_be_visible": "",
   "were_performing_maintenance": "",
   "weve_recently_reduced_the_compile_timeout_limit_which_may_have_affected_this_project": "",
@@ -1886,6 +1903,7 @@
   "what_should_we_call_you": "",
   "when_you_tick_the_include_caption_box": "",
   "why_latex": "",
+  "why_not_pause_instead": "",
   "wide": "",
   "will_lose_edit_access_on_date": "",
   "with_premium_subscription_you_also_get": "",
@@ -1930,6 +1948,7 @@
   "you_can_request_a_maximum_of_limit_fixes_per_day": "",
   "you_can_select_or_invite": "",
   "you_can_select_or_invite_plural": "",
+  "you_can_still_use_your_premium_features": "",
   "you_cant_add_or_change_password_due_to_sso": "",
   "you_cant_join_this_group_subscription": "",
   "you_dont_have_any_repositories": "",
@@ -1943,6 +1962,7 @@
   "you_have_x_users_and_your_plan_supports_up_to_y": "",
   "you_have_x_users_on_your_subscription": "",
   "you_need_to_configure_your_sso_settings": "",
+  "you_unpaused_your_subscription": "",
   "you_will_be_able_to_reassign_subscription": "",
   "youll_get_best_results_in_visual_but_can_be_used_in_source": "",
   "youll_need_to_ask_the_github_repository_owner": "",
@@ -1952,6 +1972,7 @@
   "your_affiliation_is_confirmed": "",
   "your_browser_does_not_support_this_feature": "",
   "your_compile_timed_out": "",
+  "your_current_plan_gives_you": "",
   "your_current_plan_supports_up_to_x_users": "",
   "your_current_project_will_revert_to_the_version_from_time": "",
   "your_git_access_info": "",
@@ -1970,6 +1991,7 @@
   "your_plan_is_changing_at_term_end": "",
   "your_plan_is_limited_to_n_editors": "",
   "your_plan_is_limited_to_n_editors_plural": "",
+  "your_premium_plan_is_paused": "",
   "your_project_exceeded_compile_timeout_limit_on_free_plan": "",
   "your_project_exceeded_editor_limit": "",
   "your_project_near_compile_timeout_limit": "",
@@ -1977,6 +1999,8 @@
   "your_role": "",
   "your_subscription": "",
   "your_subscription_has_expired": "",
+  "your_subscription_will_pause_on": "",
+  "your_subscription_will_pause_on_short": "",
   "youre_a_member_of_overleaf_labs": "",
   "youre_about_to_disable_single_sign_on": "",
   "youre_about_to_enable_single_sign_on": "",
@@ -1989,6 +2013,7 @@
   "youve_added_more_users": "",
   "youve_added_x_more_users_to_your_subscription_invite_people": "",
   "youve_lost_edit_access": "",
+  "youve_paused_your_subscription": "",
   "youve_unlinked_all_users": "",
   "youve_upgraded_your_plan": "",
   "zoom_in": "",

+ 9 - 0
services/web/frontend/js/features/project-list/components/current-plan-widget/current-plan-widget.tsx

@@ -2,6 +2,7 @@ import FreePlan from './free-plan'
 import IndividualPlan from './individual-plan'
 import GroupPlan from './group-plan'
 import CommonsPlan from './commons-plan'
+import PausedPlan from './paused-plan'
 import getMeta from '../../../../utils/meta'
 
 function CurrentPlanWidget() {
@@ -16,8 +17,12 @@ function CurrentPlanWidget() {
   const isIndividualPlan = type === 'individual'
   const isGroupPlan = type === 'group'
   const isCommonsPlan = type === 'commons'
+  const isPaused =
+    isIndividualPlan &&
+    usersBestSubscription.subscription?.recurlyStatus?.state === 'paused'
 
   const featuresPageURL = '/learn/how-to/Overleaf_premium_features'
+  const subscriptionPageUrl = '/user/subscription'
 
   let currentPlan
 
@@ -35,6 +40,10 @@ function CurrentPlanWidget() {
     )
   }
 
+  if (isPaused) {
+    currentPlan = <PausedPlan subscriptionPageUrl={subscriptionPageUrl} />
+  }
+
   if (isGroupPlan) {
     currentPlan = (
       <GroupPlan

+ 59 - 0
services/web/frontend/js/features/project-list/components/current-plan-widget/paused-plan.tsx

@@ -0,0 +1,59 @@
+import { Trans, useTranslation } from 'react-i18next'
+import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import MaterialIcon from '@/shared/components/material-icon'
+import { bsVersion } from '@/features/utils/bootstrap-5'
+import classnames from 'classnames'
+
+type PausedPlanProps = {
+  subscriptionPageUrl: string
+}
+
+function PausedPlan({ subscriptionPageUrl }: PausedPlanProps) {
+  const { t } = useTranslation()
+  const currentPlanLabel = (
+    <Trans
+      i18nKey="your_premium_plan_is_paused"
+      components={[
+        // eslint-disable-next-line react/jsx-key
+        <strong />,
+      ]}
+    />
+  )
+
+  return (
+    <>
+      <span
+        className={classnames(
+          'current-plan-label',
+          bsVersion({ bs5: 'd-md-none', bs3: 'visible-xs' })
+        )}
+      >
+        {currentPlanLabel}
+      </span>
+      <OLTooltip
+        description={t('click_to_unpause')}
+        id="individual-plan"
+        overlayProps={{ placement: 'bottom' }}
+      >
+        <a
+          href={subscriptionPageUrl}
+          className={classnames(
+            'current-plan-label',
+            bsVersion({ bs5: 'd-none d-md-inline-block', bs3: 'hidden-xs' })
+          )}
+        >
+          {currentPlanLabel}&nbsp;
+          <BootstrapVersionSwitcher
+            bs3={<span className="info-badge" />}
+            bs5={
+              <MaterialIcon type="info" className="current-plan-label-icon" />
+            }
+          />
+        </a>
+      </OLTooltip>
+    </>
+  )
+}
+
+export default PausedPlan

Разница между файлами не показана из-за своего большого размера
+ 20 - 0
services/web/frontend/js/features/subscription/components/dashboard/pause-duck.svg


+ 137 - 0
services/web/frontend/js/features/subscription/components/dashboard/pause-modal.tsx

@@ -0,0 +1,137 @@
+import { useTranslation } from 'react-i18next'
+import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
+import { useCallback, useMemo, useState } from 'react'
+import { postJSON } from '@/infrastructure/fetch-json'
+import { useLocation } from '@/shared/hooks/use-location'
+import OLModal, {
+  OLModalBody,
+  OLModalHeader,
+} from '@/features/ui/components/ol/ol-modal'
+import { Select } from '@/shared/components/select'
+import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
+import Button from '@/features/ui/components/bootstrap-5/button'
+import { Stack } from 'react-bootstrap-5'
+import { debugConsole } from '@/utils/debugging'
+import * as eventTracking from '../../../../infrastructure/event-tracking'
+import PauseDuck from './pause-duck.svg'
+import GenericErrorAlert from './generic-error-alert'
+import { RecurlySubscription } from '../../../../../../types/subscription/dashboard/subscription'
+
+const pauseMonthDurationOptions = [1, 2, 3]
+
+export const PAUSE_SUB_MODAL_ID = 'pause-subscription'
+
+export default function PauseSubscriptionModal() {
+  const { t } = useTranslation()
+  const {
+    handleCloseModal,
+    modalIdShown,
+    setShowCancellation,
+    personalSubscription,
+  } = useSubscriptionDashboardContext()
+  const [inflight, setInflight] = useState(false)
+  const [pauseError, setPauseError] = useState(false)
+  const [selectedDuration, setSelectedDuration] = useState(1)
+  const location = useLocation()
+
+  function handleCancelSubscriptionClick() {
+    const subscription = personalSubscription as RecurlySubscription
+    eventTracking.sendMB('subscription-page-cancel-button-click', {
+      plan_code: subscription?.planCode,
+      is_trial:
+        subscription?.recurly.trialEndsAtFormatted &&
+        subscription?.recurly.trial_ends_at &&
+        new Date(subscription.recurly.trial_ends_at).getTime() > Date.now(),
+    })
+    setShowCancellation(true)
+  }
+
+  const pauseSelectItems = useMemo(
+    () =>
+      pauseMonthDurationOptions.map(month => ({
+        key: month,
+        value: `${month} ${t('month', { count: month })}`,
+      })),
+    [t]
+  )
+
+  const handleConfirmPauseSubscriptionClick = useCallback(async () => {
+    if (!selectedDuration) {
+      return
+    }
+    setPauseError(false)
+    setInflight(true)
+    try {
+      await postJSON(`/user/subscription/pause/${selectedDuration}`)
+      const newUrl = new URL(location.toString())
+      newUrl.searchParams.set('flash', 'paused')
+      window.history.replaceState(null, '', newUrl)
+      location.reload()
+    } catch (err) {
+      debugConsole.error('error pausing subscription', err)
+      setInflight(false)
+      setPauseError(true)
+    }
+  }, [location, selectedDuration])
+
+  if (modalIdShown !== PAUSE_SUB_MODAL_ID) {
+    return null
+  }
+
+  return (
+    <OLModal
+      id={PAUSE_SUB_MODAL_ID}
+      show
+      animation
+      onHide={handleCloseModal}
+      backdrop="static"
+    >
+      <OLModalBody>
+        <OLModalHeader closeButton style={{ border: 0 }} />
+        <img
+          src={PauseDuck}
+          alt="Need to duck out for a while?"
+          style={{ display: 'block', margin: '-32px auto 0 auto' }}
+        />
+        {pauseError && <GenericErrorAlert />}
+
+        <h4>{t('why_not_pause_instead')}</h4>
+        <p>{t('your_current_plan_gives_you')}</p>
+        <span>{t('dont_forget_you_currently_have')}</span>
+        <ul>
+          {personalSubscription?.plan?.features?.collaborators !== 1 && (
+            <li>{t('more_collabs_per_project')}</li>
+          )}
+          <li>{t('more_compile_time')}</li>
+          <li>{t('features_like_track_changes')}</li>
+          <li>{t('integrations_like_github')}</li>
+        </ul>
+        <OLFormGroup>
+          <Select
+            label={t('pause_subscription_for')}
+            items={pauseSelectItems}
+            itemToString={x => String(x?.value)}
+            itemToKey={x => String(x.key)}
+            defaultText={`1 ${t('month')}`}
+            onSelectedItemChanged={item => setSelectedDuration(item?.key || 0)}
+          />
+        </OLFormGroup>
+        <Stack gap={2}>
+          <Button
+            onClick={handleConfirmPauseSubscriptionClick}
+            disabled={inflight}
+          >
+            {t('pause_subscription')}
+          </Button>
+          <Button
+            onClick={handleCancelSubscriptionClick}
+            disabled={inflight}
+            variant="danger-ghost"
+          >
+            {t('cancel_subscription')}
+          </Button>
+        </Stack>
+      </OLModalBody>
+    </OLModal>
+  )
+}

+ 3 - 0
services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx

@@ -2,6 +2,7 @@ import { Trans, useTranslation } from 'react-i18next'
 import { RecurlySubscription } from '../../../../../../types/subscription/dashboard/subscription'
 import { ActiveSubscription } from './states/active/active'
 import { ActiveAiAddonSubscription } from './states/active/active-ai-addon'
+import { PausedSubscription } from './states/active/paused'
 import { CanceledSubscription } from './states/canceled'
 import { ExpiredSubscription } from './states/expired'
 import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
@@ -57,6 +58,8 @@ function PersonalSubscriptionStates({
     return <CanceledSubscription subscription={subscription} />
   } else if (state === 'expired') {
     return <ExpiredSubscription subscription={subscription} />
+  } else if (state === 'paused') {
+    return <PausedSubscription subscription={subscription} />
   } else {
     return <>{t('problem_with_subscription_contact_us')}</>
   }

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

@@ -15,6 +15,14 @@ import { ConfirmChangePlanModal } from './change-plan/modals/confirm-change-plan
 import { KeepCurrentPlanModal } from './change-plan/modals/keep-current-plan-modal'
 import { ChangeToGroupModal } from './change-plan/modals/change-to-group-modal'
 import OLButton from '@/features/ui/components/ol/ol-button'
+import useAsync from '@/shared/hooks/use-async'
+import { postJSON } from '@/infrastructure/fetch-json'
+import PauseSubscriptionModal from '../../pause-modal'
+import Notification from '@/shared/components/notification'
+import { debugConsole } from '@/utils/debugging'
+import { FlashMessage } from './flash-message'
+import { useLocation } from '@/shared/hooks/use-location'
+import LoadingSpinner from '@/shared/components/loading-spinner'
 
 export function ActiveSubscription({
   subscription,
@@ -22,26 +30,65 @@ export function ActiveSubscription({
   subscription: RecurlySubscription
 }) {
   const { t } = useTranslation()
-  const { recurlyLoadError, setModalIdShown, showCancellation } =
-    useSubscriptionDashboardContext()
+  const {
+    recurlyLoadError,
+    setModalIdShown,
+    showCancellation,
+    getFormattedRenewalDate,
+  } = useSubscriptionDashboardContext()
+  const {
+    isError: isErrorPause,
+    runAsync: runAsyncCancelPause,
+    isLoading: isLoadingCancelPause,
+  } = useAsync()
+  const location = useLocation()
 
   if (showCancellation) return <CancelSubscription />
 
+  const hasPendingPause =
+    subscription.recurly.state === 'active' &&
+    subscription.recurly.remainingPauseCycles &&
+    subscription.recurly.remainingPauseCycles > 0
+
+  const handleCancelPendingPauseClick = async () => {
+    try {
+      await runAsyncCancelPause(postJSON('/user/subscription/pause/0'))
+      const newUrl = new URL(location.toString())
+      newUrl.searchParams.set('flash', 'unpaused')
+      window.history.replaceState(null, '', newUrl)
+      location.reload()
+    } catch (e) {
+      debugConsole.error(e)
+    }
+  }
+
   return (
     <>
+      <div className="notification-list">
+        <FlashMessage />
+
+        {isErrorPause && (
+          <Notification
+            type="error"
+            content={t('generic_something_went_wrong')}
+          />
+        )}
+      </div>
       <p>
-        <Trans
-          i18nKey="currently_subscribed_to_plan"
-          values={{
-            planName: subscription.plan.name,
-          }}
-          shouldUnescape
-          tOptions={{ interpolation: { escapeValue: true } }}
-          components={[
-            // eslint-disable-next-line react/jsx-key
-            <strong />,
-          ]}
-        />
+        {!hasPendingPause && (
+          <Trans
+            i18nKey="currently_subscribed_to_plan"
+            values={{
+              planName: subscription.plan.name,
+            }}
+            shouldUnescape
+            tOptions={{ interpolation: { escapeValue: true } }}
+            components={[
+              // eslint-disable-next-line react/jsx-key
+              <strong />,
+            ]}
+          />
+        )}
         {subscription.pendingPlan && (
           <>
             {' '}
@@ -60,6 +107,7 @@ export function ActiveSubscription({
           )}
         {!recurlyLoadError &&
           !subscription.groupPlan &&
+          !hasPendingPause &&
           subscription.recurly.account.has_past_due_invoice._ !== 'true' && (
             <>
               {' '}
@@ -87,12 +135,47 @@ export function ActiveSubscription({
           />
         )}
 
+      {hasPendingPause && (
+        <>
+          <p>
+            <Trans
+              i18nKey="your_subscription_will_pause_on"
+              values={{
+                planName: subscription.plan.name,
+                pauseDate: subscription.recurly.nextPaymentDueAt,
+                reactivationDate: getFormattedRenewalDate(),
+              }}
+              shouldUnescape
+              tOptions={{ interpolation: { escapeValue: true } }}
+              components={[
+                // eslint-disable-next-line react/jsx-key
+                <strong />,
+              ]}
+            />
+          </p>
+          <p>{t('you_can_still_use_your_premium_features')}</p>
+          <p>
+            <OLButton
+              variant="primary"
+              onClick={handleCancelPendingPauseClick}
+              disabled={isLoadingCancelPause}
+            >
+              {isLoadingCancelPause ? (
+                <LoadingSpinner />
+              ) : (
+                t('unpause_subscription')
+              )}
+            </OLButton>
+          </p>
+        </>
+      )}
+
       <p>
         <Trans
           i18nKey="next_payment_of_x_collectected_on_y"
           values={{
             paymentAmmount: subscription.recurly.displayPrice,
-            collectionDate: subscription.recurly.nextPaymentDueAt,
+            collectionDate: getFormattedRenewalDate(),
           }}
           shouldUnescape
           tOptions={{ interpolation: { escapeValue: true } }}
@@ -104,6 +187,8 @@ export function ActiveSubscription({
           ]}
         />
       </p>
+
+      <hr />
       <PriceExceptions subscription={subscription} />
       <p className="d-inline-flex flex-wrap gap-1">
         <a
@@ -145,6 +230,7 @@ export function ActiveSubscription({
       <ConfirmChangePlanModal />
       <KeepCurrentPlanModal />
       <ChangeToGroupModal />
+      <PauseSubscriptionModal />
     </>
   )
 }

+ 34 - 4
services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-subscription-button.tsx

@@ -2,15 +2,45 @@ import { useTranslation } from 'react-i18next'
 import * as eventTracking from '../../../../../../infrastructure/event-tracking'
 import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context'
 import OLButton from '@/features/ui/components/ol/ol-button'
+import { RecurlySubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
 
 export function CancelSubscriptionButton() {
   const { t } = useTranslation()
-  const { recurlyLoadError, setShowCancellation } =
-    useSubscriptionDashboardContext()
+  const {
+    recurlyLoadError,
+    personalSubscription,
+    setModalIdShown,
+    setShowCancellation,
+  } = useSubscriptionDashboardContext()
+
+  const subscription = personalSubscription as RecurlySubscription
+  const isInTrial =
+    subscription?.recurly.trialEndsAtFormatted &&
+    subscription?.recurly.trial_ends_at &&
+    new Date(subscription.recurly.trial_ends_at).getTime() > Date.now()
+  const hasPendingOrActivePause =
+    subscription.recurly.state === 'paused' ||
+    (subscription.recurly.state === 'active' &&
+      subscription.recurly.remainingPauseCycles &&
+      subscription.recurly.remainingPauseCycles > 0)
+  const planIsEligibleForPause =
+    !subscription.groupPlan &&
+    !isInTrial &&
+    !subscription.planCode.includes('ann') &&
+    !subscription.addOns?.length
+  const enablePause =
+    useFeatureFlag('pause-subscription') &&
+    !hasPendingOrActivePause &&
+    planIsEligibleForPause
 
   function handleCancelSubscriptionClick() {
-    eventTracking.sendMB('subscription-page-cancel-button-click', {})
-    setShowCancellation(true)
+    eventTracking.sendMB('subscription-page-cancel-button-click', {
+      plan_code: subscription?.planCode,
+      is_trial: isInTrial,
+    })
+    if (enablePause) setModalIdShown('pause-subscription')
+    else setShowCancellation(true)
   }
 
   if (recurlyLoadError) return null

+ 103 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/confirm-unpause-modal.tsx

@@ -0,0 +1,103 @@
+import { useState } from 'react'
+import { SubscriptionDashModalIds } from '../../../../../../../../types/subscription/dashboard/modal-ids'
+import { Trans, useTranslation } from 'react-i18next'
+import { useSubscriptionDashboardContext } from '@/features/subscription/context/subscription-dashboard-context'
+import OLModal, {
+  OLModalBody,
+  OLModalFooter,
+  OLModalHeader,
+  OLModalTitle,
+} from '@/features/ui/components/ol/ol-modal'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import { postJSON } from '@/infrastructure/fetch-json'
+import { useLocation } from '@/shared/hooks/use-location'
+import OLNotification from '@/features/ui/components/ol/ol-notification'
+import { RecurlySubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
+
+export function ConfirmUnpauseSubscriptionModal() {
+  const modalId: SubscriptionDashModalIds = 'unpause-subscription'
+  const [error, setError] = useState(false)
+  const [inflight, setInflight] = useState(false)
+  const { t } = useTranslation()
+  const { handleCloseModal, modalIdShown, personalSubscription } =
+    useSubscriptionDashboardContext()
+  const location = useLocation()
+  const subscription = personalSubscription as RecurlySubscription
+
+  async function handleConfirmUnpause() {
+    setError(false)
+    setInflight(true)
+    try {
+      await postJSON('/user/subscription/resume')
+      const newUrl = new URL(location.toString())
+      newUrl.searchParams.set('flash', 'unpaused')
+      window.history.replaceState(null, '', newUrl)
+      location.reload()
+    } catch (e) {
+      setError(true)
+      setInflight(false)
+    }
+  }
+
+  if (modalIdShown !== modalId) return null
+
+  return (
+    <OLModal
+      id={modalId}
+      show
+      animation
+      onHide={handleCloseModal}
+      backdrop="static"
+    >
+      <OLModalHeader>
+        <OLModalTitle>{t('pick_up_where_you_left_off')}</OLModalTitle>
+      </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')}.
+              </>
+            }
+          />
+        )}
+        <p>
+          <Trans
+            i18nKey="lets_get_those_premium_features"
+            values={{
+              paymentAmount: subscription.recurly.displayPrice,
+            }}
+            shouldUnescape
+            tOptions={{ interpolation: { escapeValue: true } }}
+            components={[
+              // eslint-disable-next-line react/jsx-key
+              <strong />,
+            ]}
+          />
+        </p>
+      </OLModalBody>
+      <OLModalFooter>
+        <OLButton
+          variant="secondary"
+          disabled={inflight}
+          onClick={handleCloseModal}
+        >
+          {t('cancel')}
+        </OLButton>
+        <OLButton
+          variant="primary"
+          disabled={inflight}
+          isLoading={inflight}
+          onClick={handleConfirmUnpause}
+        >
+          {t('unpause_subscription')}
+        </OLButton>
+      </OLModalFooter>
+    </OLModal>
+  )
+}

+ 61 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/flash-message.tsx

@@ -0,0 +1,61 @@
+import { useSubscriptionDashboardContext } from '@/features/subscription/context/subscription-dashboard-context'
+import Notification from '@/shared/components/notification'
+import { Trans, useTranslation } from 'react-i18next'
+import { RecurlySubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
+import { useEffect, useState } from 'react'
+import { useLocation } from '@/shared/hooks/use-location'
+
+export type FlashMessageName = 'paused' | 'unpaused' | 'error'
+
+export function FlashMessage() {
+  const { t } = useTranslation()
+  const { personalSubscription } = useSubscriptionDashboardContext()
+  const location = useLocation()
+  const [message] = useState(
+    // eslint-disable-next-line no-restricted-syntax
+    new URL(window.location.toString()).searchParams.get(
+      'flash'
+    ) as FlashMessageName
+  )
+  const subscription = personalSubscription as RecurlySubscription
+  useEffect(() => {
+    // clear any flash message IDs so they only show once
+    if (location.toString()) {
+      const newUrl = new URL(location.toString())
+      newUrl.searchParams.delete('flash')
+      window.history.replaceState(null, '', newUrl)
+    }
+  }, [location])
+
+  switch (message) {
+    case 'paused':
+      return (
+        <Notification
+          type="success"
+          content={
+            <Trans
+              i18nKey="your_subscription_will_pause_on_short"
+              values={{
+                pauseDate: subscription.recurly.nextPaymentDueAt,
+              }}
+              shouldUnescape
+              tOptions={{ interpolation: { escapeValue: true } }}
+              components={[
+                // eslint-disable-next-line react/jsx-key
+                <strong />,
+              ]}
+            />
+          }
+        />
+      )
+    case 'unpaused':
+      return (
+        <Notification
+          type="success"
+          content={t('you_unpaused_your_subscription')}
+        />
+      )
+    default:
+      return <></>
+  }
+}

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

@@ -0,0 +1,95 @@
+import { useTranslation, Trans } from 'react-i18next'
+import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context'
+import { RecurlySubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
+import { CancelSubscriptionButton } from './cancel-subscription-button'
+import { CancelSubscription } from './cancel-plan/cancel-subscription'
+import { ChangePlanModal } from './change-plan/modals/change-plan-modal'
+import { ConfirmChangePlanModal } from './change-plan/modals/confirm-change-plan-modal'
+import { KeepCurrentPlanModal } from './change-plan/modals/keep-current-plan-modal'
+import { ChangeToGroupModal } from './change-plan/modals/change-to-group-modal'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import PauseSubscriptionModal from '../../pause-modal'
+import { ConfirmUnpauseSubscriptionModal } from './confirm-unpause-modal'
+
+export function PausedSubscription({
+  subscription,
+}: {
+  subscription: RecurlySubscription
+}) {
+  const { t } = useTranslation()
+  const {
+    recurlyLoadError,
+    setModalIdShown,
+    showCancellation,
+    getFormattedRenewalDate,
+  } = useSubscriptionDashboardContext()
+
+  if (showCancellation) return <CancelSubscription />
+
+  const handleUnpauseClick = async () => {
+    setModalIdShown('unpause-subscription')
+  }
+
+  return (
+    <>
+      <p>
+        <Trans
+          i18nKey="youve_paused_your_subscription"
+          values={{
+            planName: subscription.plan.name,
+            reactivationDate: getFormattedRenewalDate(),
+          }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
+          components={[
+            // eslint-disable-next-line react/jsx-key
+            <strong />,
+          ]}
+        />
+      </p>
+      <p>{t('until_then_you_can_still')}</p>
+      <ul>
+        <li>{t('access_edit_your_projects')}</li>
+        <li>{t('continue_using_free_features')}</li>
+      </ul>
+      <p>{t('well_be_here_when_youre_ready')}</p>
+      <p>
+        <OLButton variant="primary" onClick={handleUnpauseClick}>
+          {t('unpause_subscription')}
+        </OLButton>
+        {!recurlyLoadError && (
+          <>
+            {' '}
+            <CancelSubscriptionButton />
+          </>
+        )}
+      </p>
+
+      <p className="d-inline-flex flex-wrap gap-1">
+        <a
+          href={subscription.recurly.billingDetailsLink}
+          target="_blank"
+          rel="noreferrer noopener"
+          className="btn btn-secondary-info btn-secondary"
+        >
+          {t('update_your_billing_details')}
+        </a>{' '}
+        <a
+          href={subscription.recurly.accountManagementLink}
+          target="_blank"
+          rel="noreferrer noopener"
+          className="btn btn-secondary-info btn-secondary"
+        >
+          {t('view_your_invoices')}
+        </a>
+      </p>
+
+      <ChangePlanModal />
+      <ConfirmChangePlanModal />
+      <KeepCurrentPlanModal />
+      <ChangeToGroupModal />
+      <PauseSubscriptionModal />
+      <ConfirmUnpauseSubscriptionModal />
+    </>
+  )
+}

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

@@ -30,6 +30,7 @@ import { debugConsole } from '@/utils/debugging'
 import { formatCurrency } from '@/shared/utils/currency'
 import { ManagedInstitution } from '../../../../../types/subscription/dashboard/managed-institution'
 import { Publisher } from '../../../../../types/subscription/dashboard/publisher'
+import { formatTime } from '@/features/utils/format-date'
 
 type SubscriptionDashboardContextValue = {
   groupPlanToChangeToCode: string
@@ -73,6 +74,7 @@ type SubscriptionDashboardContextValue = {
   leavingGroupId?: string
   setLeavingGroupId: React.Dispatch<React.SetStateAction<string | undefined>>
   userCanExtendTrial: boolean
+  getFormattedRenewalDate: () => string
 }
 
 export const SubscriptionDashboardContext = createContext<
@@ -139,6 +141,20 @@ export function SubscriptionDashboardProvider({
       memberGroupSubscriptions?.length > 0
   )
 
+  const getFormattedRenewalDate = useCallback(() => {
+    if (
+      !personalSubscription.recurly.pausedAt ||
+      !personalSubscription.recurly.remainingPauseCycles
+    ) {
+      return personalSubscription.recurly.nextPaymentDueAt
+    }
+    const pausedDate = new Date(personalSubscription.recurly.pausedAt)
+    pausedDate.setMonth(
+      pausedDate.getMonth() + personalSubscription.recurly.remainingPauseCycles
+    )
+    return formatTime(pausedDate, 'MMMM Do, YYYY')
+  }, [personalSubscription])
+
   useEffect(() => {
     if (!isRecurlyLoaded()) {
       setRecurlyLoadError(true)
@@ -283,6 +299,7 @@ export function SubscriptionDashboardProvider({
       leavingGroupId,
       setLeavingGroupId,
       userCanExtendTrial,
+      getFormattedRenewalDate,
     }),
     [
       groupPlanToChangeToCode,
@@ -319,6 +336,7 @@ export function SubscriptionDashboardProvider({
       leavingGroupId,
       setLeavingGroupId,
       userCanExtendTrial,
+      getFormattedRenewalDate,
     ]
   )
 

+ 7 - 0
services/web/frontend/js/shared/components/location.js

@@ -13,4 +13,11 @@ export const location = {
     // eslint-disable-next-line no-restricted-syntax
     window.location.reload()
   },
+  setHash(hash) {
+    window.location.hash = hash
+  },
+  toString() {
+    // eslint-disable-next-line no-restricted-syntax
+    return window.location.toString()
+  },
 }

+ 20 - 1
services/web/frontend/js/shared/hooks/use-location.ts

@@ -29,5 +29,24 @@ export const useLocation = () => {
     }
   }, [isMounted])
 
-  return useMemo(() => ({ assign, replace, reload }), [assign, replace, reload])
+  const setHash = useCallback(
+    (hash: string) => {
+      if (isMounted.current) {
+        location.setHash(hash)
+      }
+    },
+    [isMounted]
+  )
+
+  const toString = useCallback(() => {
+    if (isMounted.current) {
+      return location.toString()
+    }
+    return ''
+  }, [isMounted])
+
+  return useMemo(
+    () => ({ assign, replace, reload, setHash, toString }),
+    [assign, replace, reload, setHash, toString]
+  )
 }

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

@@ -46,6 +46,7 @@
   "accepted_invite": "Accepted invite",
   "accepting_invite_as": "You are accepting this invite as",
   "access_denied": "Access Denied",
+  "access_edit_your_projects": "Access and edit your projects",
   "access_levels_changed": "Access levels changed",
   "account": "Account",
   "account_has_been_link_to_institution_account": "Your __appName__ account on <b>__email__</b> has been linked to your <b>__institutionName__</b> institutional account.",
@@ -247,6 +248,7 @@
   "cancel_my_account": "Cancel my subscription",
   "cancel_my_subscription": "Cancel my subscription",
   "cancel_personal_subscription_first": "You already have an individual subscription, would you like us to cancel this first before joining the group licence?",
+  "cancel_subscription": "Cancel subscription",
   "cancel_your_subscription": "Cancel your subscription",
   "cannot_invite_non_user": "Can’t send invite. Recipient must already have an __appName__ account",
   "cannot_invite_self": "Can’t send invite to yourself",
@@ -306,6 +308,7 @@
   "click_here_to_view_sl_in_lng": "Click here to use __appName__ in <0>__lngName__</0>",
   "click_link_to_proceed": "Click <b>__clickText__</b> below to proceed.",
   "click_to_give_feedback": "Click to give feedback.",
+  "click_to_unpause": "Click to unpause and reactivate your Overleaf premium features.",
   "clicking_delete_will_remove_sso_config_and_clear_saml_data": "Clicking <0>Delete</0> will remove your SSO configuration and unlink all users. You can only do this when SSO is disabled in your Group settings.",
   "clone_with_git": "Clone with Git",
   "close": "Close",
@@ -390,6 +393,7 @@
   "continue": "Continue",
   "continue_github_merge": "I have manually merged. Continue",
   "continue_to": "Continue to __appName__",
+  "continue_using_free_features": "Continue using our free features",
   "continue_with_free_plan": "Continue with free plan",
   "continue_with_service": "Continue with __service__",
   "copied": "Copied",
@@ -517,6 +521,7 @@
   "doing_this_allow_log_in_through_institution_2": "Doing this will allow you to log in to <0>__appName__</0> through your institution and will reconfirm your institutional email address.",
   "doing_this_will_verify_affiliation_and_allow_log_in_2": "Doing this will verify your affiliation with <0>__institutionName__</0> and will allow you to log in to <0>__appName__</0> through your institution.",
   "done": "Done",
+  "dont_forget_you_currently_have": "Don’t forget, you currently have:",
   "dont_have_account": "Don’t have an account?",
   "download": "Download",
   "download_all": "Download all",
@@ -663,6 +668,7 @@
   "featured_latex_templates": "Featured LaTeX Templates",
   "features": "Features",
   "features_and_benefits": "Features & Benefits",
+  "features_like_track_changes": "Features like real-time track changes",
   "february": "February",
   "file_action_created": "Created",
   "file_action_deleted": "Deleted",
@@ -997,6 +1003,7 @@
   "institutional_login_not_supported": "Your institution doesn’t support <b>institutional login</b> yet, but you can still register with your institutional email.",
   "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_like_github": "Integrations like GitHub Sync",
   "interested_in_cheaper_personal_plan": "Would you be interested in the cheaper <0>__price__</0> Personal plan?",
   "interface": "Interface",
   "interface_settings": "Interface settings",
@@ -1115,6 +1122,7 @@
   "let_us_know": "Let us know",
   "let_us_know_how_we_can_help": "Let us know how we can help",
   "let_us_know_what_you_think": "Let us know what you think",
+  "lets_get_those_premium_features": "Let’s get those premium features up and running for you straightaway. You’ll be billed <0>__paymentAmount__</0> using the payment details we have for you.",
   "libraries": "Libraries",
   "library": "Library",
   "license": "License",
@@ -1260,10 +1268,13 @@
   "missing_fields_for_entry": "Missing fields for",
   "money_back_guarantee": "30-day money back guarantee, no questions asked",
   "month": "month",
+  "month_plural": "months",
   "monthly": "Monthly",
   "more": "More",
   "more_actions": "More actions",
+  "more_collabs_per_project": "More collaborators per project",
   "more_comments": "More comments",
+  "more_compile_time": "More compile time",
   "more_info": "More Info",
   "more_options": "More options",
   "more_options_for_border_settings_coming_soon": "More options for border settings coming soon.",
@@ -1470,6 +1481,8 @@
   "paste_options": "Paste options",
   "paste_with_formatting": "Paste with formatting",
   "paste_without_formatting": "Paste without formatting",
+  "pause_subscription": "Pause subscription",
+  "pause_subscription_for": "Pause subscription for",
   "pay_now": "Pay now",
   "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.",
@@ -1500,6 +1513,7 @@
   "personal": "Personal",
   "personal_library": "Personal library",
   "personalized_onboarding": "Personalized onboarding",
+  "pick_up_where_you_left_off": "Pick up where you left off",
   "pl": "Polish",
   "plan": "Plan",
   "plan_tooltip": "You’re on the __plan__ plan. Click to find out how to make the most of your Overleaf premium features.",
@@ -2303,11 +2317,13 @@
   "unlink_warning_reference": "Warning: When you unlink your account from this provider you will not be able to import references into your projects.",
   "unlinking": "Unlinking",
   "unmerge_cells": "Unmerge cells",
+  "unpause_subscription": "Unpause subscription",
   "unpublish": "Unpublish",
   "unpublishing": "Unpublishing",
   "unsubscribe": "Unsubscribe",
   "unsubscribed": "Unsubscribed",
   "unsubscribing": "Unsubscribing",
+  "until_then_you_can_still": "Until then you can still:",
   "untrash": "Restore",
   "update": "Update",
   "update_account_info": "Update Account Info",
@@ -2414,6 +2430,7 @@
   "website_status": "Website status",
   "wed_love_you_to_stay": "We’d love you to stay",
   "welcome_to_sl": "Welcome to __appName__",
+  "well_be_here_when_youre_ready": "We’ll be here when you’re ready to dive back in! 🦆",
   "were_making_some_changes_to_project_sharing_this_means_you_will_be_visible": "We’re making some <0>changes to project sharing</0>. This means, as someone with edit access, your name and email address will be visible to the project owner and other editors.",
   "were_performing_maintenance": "We’re performing maintenance on Overleaf and you need to wait a moment. Sorry for any inconvenience. The editor will refresh automatically in __seconds__ seconds.",
   "weve_recently_reduced_the_compile_timeout_limit_which_may_have_affected_this_project": "We’ve recently <0>reduced the compile timeout limit</0> on our free plan, which may have affected this project.",
@@ -2428,6 +2445,7 @@
   "when_you_join_labs": "When you join Labs, you can choose which experiments you want to be part of. Once you’ve done that, you can use Overleaf as normal, but you’ll see any labs features marked with this badge:",
   "when_you_tick_the_include_caption_box": "When you tick the box “Include caption” the image will be inserted into your document with a placeholder caption. To edit it, you simply select the placeholder text and type to replace it with your own.",
   "why_latex": "Why LaTeX?",
+  "why_not_pause_instead": "Pause instead, to pick up where you left off",
   "wide": "Wide",
   "will_lose_edit_access_on_date": "Will lose edit access on __date__",
   "will_need_to_log_out_from_and_in_with": "You will need to <b>log out</b> from your <b>__email1__</b> account and then log in with <b>__email2__</b>.",
@@ -2480,6 +2498,7 @@
   "you_can_request_a_maximum_of_limit_fixes_per_day": "You can request a maximum of __limit__ fixes per day. Please try again tomorrow.",
   "you_can_select_or_invite": "You can select or invite __count__ editor on your current plan, or upgrade to get more.",
   "you_can_select_or_invite_plural": "You can select or invite __count__ editors on your current plan, or upgrade to get more.",
+  "you_can_still_use_your_premium_features": "You can still use your premium features until the pause becomes active.",
   "you_cant_add_or_change_password_due_to_sso": "You can’t add or change your password because your group or organization uses <0>single sign-on (SSO)</0>.",
   "you_cant_join_this_group_subscription": "You can’t join this group subscription",
   "you_cant_reset_password_due_to_sso": "You can’t reset your password because your group or organization uses SSO. <0>Log in with SSO</0>.",
@@ -2494,6 +2513,7 @@
   "you_have_x_users_and_your_plan_supports_up_to_y": "You have __addedUsersSize__ users and your plan supports up to __groupSize__.",
   "you_have_x_users_on_your_subscription": "You have __groupSize__ users on your subscription.",
   "you_need_to_configure_your_sso_settings": "You need to configure and test your SSO settings before enabling SSO",
+  "you_unpaused_your_subscription": "You unpaused your subscription.",
   "you_will_be_able_to_contact_us_any_time_to_share_your_feedback": "<0>You will be able to contact us</0> any time to share your feedback",
   "you_will_be_able_to_reassign_subscription": "You will be able to reassign their subscription membership to another person in your organization",
   "youll_get_best_results_in_visual_but_can_be_used_in_source": "You’ll get the best results from using this tool in the <0>Visual Editor</0>, although you can still use it to insert tables in the <1>Code Editor</1>. Once you’ve selected the number of rows and columns you need, the table will appear in your document and you can double click in a cell to add contents to it.",
@@ -2505,6 +2525,7 @@
   "your_affiliation_is_confirmed": "Your <0>__institutionName__</0> affiliation is confirmed.",
   "your_browser_does_not_support_this_feature": "Sorry, your browser doesn’t support this feature. Please update your browser to its latest version.",
   "your_compile_timed_out": "Your compile timed out",
+  "your_current_plan_gives_you": "By pausing your subscription, you’ll be able to access your premium features faster when you need them again.",
   "your_current_plan_supports_up_to_x_users": "Your current plan supports up to __users__ users.",
   "your_current_project_will_revert_to_the_version_from_time": "Your current project will revert to the version from __timestamp__",
   "your_git_access_info": "Your Git authentication tokens should be entered whenever you’re prompted for a password.",
@@ -2524,6 +2545,7 @@
   "your_plan_is_changing_at_term_end": "Your plan is changing to <0>__pendingPlanName__</0> at the end of the current billing period.",
   "your_plan_is_limited_to_n_editors": "Your plan allows __count__ collaborator with edit access and unlimited viewers.",
   "your_plan_is_limited_to_n_editors_plural": "Your plan allows __count__ collaborators with edit access and unlimited viewers.",
+  "your_premium_plan_is_paused": "Your Premium plan is <0>paused</0>.",
   "your_project_exceeded_compile_timeout_limit_on_free_plan": "Your project exceeded the compile timeout limit on our free plan.",
   "your_project_exceeded_editor_limit": "Your project exceeded the editor limit and access levels were changed. Select a new access level for your collaborators, or upgrade to add more editors.",
   "your_project_near_compile_timeout_limit": "Your project is near the compile timeout limit for our free plan.",
@@ -2533,6 +2555,8 @@
   "your_sessions": "Your Sessions",
   "your_subscription": "Your subscription",
   "your_subscription_has_expired": "Your subscription has expired.",
+  "your_subscription_will_pause_on": "Your <0>__planName__</0> subscription will pause on <0>__pauseDate__</0>. It’ll automatically unpause on <0>__reactivationDate__</0>. Or you can unpause it yourself at any time.",
+  "your_subscription_will_pause_on_short": "Your subscription will pause on <0>__pauseDate__</0>.",
   "youre_a_member_of_overleaf_labs": "You’re a member of Overleaf Labs. Don’t forget to check in regularly to see what experiments you can sign up to.",
   "youre_about_to_disable_single_sign_on": "You’re about to disable single sign-on for all group members.",
   "youre_about_to_enable_single_sign_on": "You’re about to enable single sign-on (SSO). Before you do this, you should ensure you’re confident the SSO configuration is correct and all your group members have managed user accounts.",
@@ -2546,6 +2570,7 @@
   "youve_added_more_users": "You’ve added more users!",
   "youve_added_x_more_users_to_your_subscription_invite_people": "You’ve added __users__ more users to your subscription. <0>Invite people</0>.",
   "youve_lost_edit_access": "You’ve lost edit access",
+  "youve_paused_your_subscription": "Your <0>__planName__</0> subscription is paused until <0>__reactivationDate__</0>, then it’ll automatically unpause. You can unpause early at any time.",
   "youve_unlinked_all_users": "You’ve unlinked all users",
   "youve_upgraded_your_plan": "You’ve upgraded your plan!",
   "zh-CN": "Chinese",

+ 26 - 0
services/web/test/frontend/features/project-list/components/current-plan-widget.test.tsx

@@ -15,6 +15,8 @@ describe('<CurrentPlanWidget />', function () {
     /click to find out how you could benefit from overleaf premium features/i
   const paidPlanTooltipMessage =
     /click to find out how to make the most of your overleaf premium features/i
+  const pausedTooltipMessage =
+    /click to unpause and reactivate your overleaf premium features/i
 
   let sendMBSpy: sinon.SinonSpy
 
@@ -25,6 +27,30 @@ describe('<CurrentPlanWidget />', function () {
     sendMBSpy.restore()
   })
 
+  describe('paused', function () {
+    beforeEach(function () {
+      window.metaAttributesCache.set('ol-usersBestSubscription', {
+        type: 'individual',
+        subscription: {
+          recurlyStatus: {
+            state: 'paused',
+          },
+        },
+      })
+
+      render(<CurrentPlanWidget />)
+    })
+
+    it('shows text and tooltip on mouseover', function () {
+      const link = screen.getByRole('link', {
+        name: /plan is paused/i,
+      })
+      fireEvent.mouseOver(link)
+
+      screen.getByRole('tooltip', { name: pausedTooltipMessage })
+    })
+  })
+
   describe('free plan', function () {
     beforeEach(function () {
       window.metaAttributesCache.set('ol-usersBestSubscription', {

+ 1 - 0
services/web/test/frontend/features/project-list/components/new-project-button/modal-content-new-project-form.test.tsx

@@ -14,6 +14,7 @@ describe('<ModalContentNewProjectForm />', function () {
       assign: assignStub,
       replace: sinon.stub(),
       reload: sinon.stub(),
+      setHash: sinon.stub(),
     })
   })
 

+ 1 - 0
services/web/test/frontend/features/project-list/components/notifications.test.tsx

@@ -708,6 +708,7 @@ describe('<UserNotifications />', function () {
         assign: assignStub,
         replace: sinon.stub(),
         reload: sinon.stub(),
+        setHash: sinon.stub(),
       })
       fetchMock.reset()
     })

+ 1 - 0
services/web/test/frontend/features/project-list/components/project-list-root.test.tsx

@@ -67,6 +67,7 @@ describe('<ProjectListRoot />', function () {
       assign: assignStub,
       replace: sinon.stub(),
       reload: sinon.stub(),
+      setHash: sinon.stub(),
     })
   })
 

+ 1 - 0
services/web/test/frontend/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.test.tsx

@@ -19,6 +19,7 @@ describe('<CompileAndDownloadProjectPDFButton />', function () {
       assign: assignStub,
       replace: sinon.stub(),
       reload: sinon.stub(),
+      setHash: sinon.stub(),
     })
     render(
       <CompileAndDownloadProjectPDFButtonTooltip project={projectsData[0]} />

+ 1 - 0
services/web/test/frontend/features/project-list/components/table/cells/action-buttons/download-project-button.test.tsx

@@ -14,6 +14,7 @@ describe('<DownloadProjectButton />', function () {
       assign: assignStub,
       replace: sinon.stub(),
       reload: sinon.stub(),
+      setHash: sinon.stub(),
     })
     render(<DownloadProjectButtonTooltip project={projectsData[0]} />)
   })

+ 1 - 0
services/web/test/frontend/features/settings/components/emails/reconfirmation-info.test.tsx

@@ -37,6 +37,7 @@ describe('<ReconfirmationInfo/>', function () {
       assign: assignStub,
       replace: sinon.stub(),
       reload: sinon.stub(),
+      setHash: sinon.stub(),
     })
   })
 

+ 1 - 0
services/web/test/frontend/features/settings/components/leave/modal-form.test.tsx

@@ -62,6 +62,7 @@ describe('<LeaveModalForm />', function () {
         assign: assignStub,
         replace: sinon.stub(),
         reload: sinon.stub(),
+        setHash: sinon.stub(),
       })
       Object.assign(getMeta('ol-ExposedSettings'), { isOverleaf: true })
     })

+ 1 - 0
services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx

@@ -81,6 +81,7 @@ describe('<GroupSubscriptionMemberships />', function () {
         assign: sinon.stub(),
         replace: sinon.stub(),
         reload: reloadStub,
+        setHash: sinon.stub(),
       })
 
       render(

+ 143 - 0
services/web/test/frontend/features/subscription/components/dashboard/pause-modal.test.tsx

@@ -0,0 +1,143 @@
+import { fireEvent, screen, waitFor } from '@testing-library/react'
+import fetchMock from 'fetch-mock'
+import sinon from 'sinon'
+import { expect } from 'chai'
+import {
+  annualActiveSubscription,
+  groupActiveSubscription,
+  monthlyActiveCollaborator,
+  trialSubscription,
+} from '../../fixtures/subscriptions'
+import { renderActiveSubscription } from '../../helpers/render-active-subscription'
+import * as useLocationModule from '../../../../../../frontend/js/shared/hooks/use-location'
+import { MetaTag } from '@/utils/meta'
+
+const pauseSubscriptionSplitTestMeta: MetaTag[] = [
+  { name: 'ol-splitTestVariants', value: { 'pause-subscription': 'enabled' } },
+  { name: 'ol-bootstrapVersion', value: 5 },
+]
+
+function renderSubscriptionWithPauseSupport(
+  subscription = monthlyActiveCollaborator
+) {
+  return renderActiveSubscription(subscription, pauseSubscriptionSplitTestMeta)
+}
+
+function clickCancelButton() {
+  const button = screen.getByRole('button', {
+    name: /Cancel your subscription/i,
+  })
+  fireEvent.click(button)
+}
+
+function clickDurationSelect() {
+  const pauseDurationSelect = screen.getByLabelText('Pause subscription for', {
+    selector: 'input',
+  })
+  fireEvent.click(pauseDurationSelect)
+}
+
+function clickSubmitButton() {
+  const buttonConfirm = screen.getByRole('button', {
+    name: 'Pause subscription',
+  })
+  fireEvent.click(buttonConfirm)
+}
+
+describe('<PauseSubscriptionModal />', function () {
+  beforeEach(function () {
+    reloadStub = sinon.stub()
+    this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+      assign: sinon.stub(),
+      replace: sinon.stub(),
+      reload: reloadStub,
+      setHash: sinon.stub(),
+      toString: sinon
+        .stub()
+        .returns('https://www.dev-overleaf.com/user/subscription'),
+    })
+    this.replaceStateStub = sinon.stub(window.history, 'replaceState')
+  })
+
+  afterEach(function () {
+    fetchMock.reset()
+    this.locationStub.restore()
+    this.replaceStateStub.restore()
+  })
+
+  it('does not render with an annual subscription', async function () {
+    renderSubscriptionWithPauseSupport(annualActiveSubscription)
+    clickCancelButton()
+    // goes straight to cancel
+    await screen.findByText('We’d love you to stay')
+  })
+
+  it('does not render with a group plan', async function () {
+    renderSubscriptionWithPauseSupport(groupActiveSubscription)
+    clickCancelButton()
+    // goes straight to cancel
+    await screen.findByText('We’d love you to stay')
+  })
+
+  it('does not render when in a trial', async function () {
+    renderSubscriptionWithPauseSupport(trialSubscription)
+    clickCancelButton()
+    await screen.findByText('We’d love you to stay')
+  })
+
+  it('renders when trying to cancel subscription', async function () {
+    renderSubscriptionWithPauseSupport()
+    clickCancelButton()
+    await screen.findByText('Pause instead, to pick up where you left off')
+  })
+  let reloadStub: sinon.SinonStub
+
+  it('renders options for pause duration', async function () {
+    renderSubscriptionWithPauseSupport()
+    clickCancelButton()
+    clickDurationSelect()
+    await screen.findByRole('option', { name: '1 month' })
+    await screen.findByRole('option', { name: '2 months' })
+    await screen.findByRole('option', { name: '3 months' })
+  })
+
+  it('changes to selected duration', async function () {
+    renderSubscriptionWithPauseSupport()
+    clickCancelButton()
+    clickDurationSelect()
+    const twoMonthsOption = await screen.findByRole('option', {
+      name: '2 months',
+      selected: false,
+    })
+    fireEvent.click(twoMonthsOption)
+    clickDurationSelect()
+    await screen.findByRole('option', { name: '2 months', selected: true })
+  })
+
+  it('shows error if pausing failed', async function () {
+    const endPointResponse = {
+      status: 500,
+    }
+    fetchMock.post(`/user/subscription/pause/1`, endPointResponse)
+    renderSubscriptionWithPauseSupport()
+    clickCancelButton()
+    clickSubmitButton()
+
+    await screen.findByText('Sorry, something went wrong. ', {
+      exact: false,
+    })
+  })
+
+  it('reloads if pause successful', async function () {
+    const endPointResponse = {
+      status: 200,
+    }
+    fetchMock.post(`/user/subscription/pause/1`, endPointResponse)
+    renderSubscriptionWithPauseSupport()
+    clickCancelButton()
+    clickSubmitButton()
+    await waitFor(() => {
+      expect(reloadStub).to.have.been.called
+    })
+  })
+})

+ 2 - 0
services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx

@@ -56,6 +56,8 @@ describe('<PersonalSubscription />', function () {
         assign: sinon.stub(),
         replace: sinon.stub(),
         reload: reloadStub,
+        setHash: sinon.stub(),
+        toString: sinon.stub(),
       })
     })
 

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

@@ -199,6 +199,8 @@ describe('<ActiveSubscription />', function () {
         assign: assignStub,
         replace: sinon.stub(),
         reload: reloadStub,
+        setHash: sinon.stub(),
+        toString: sinon.stub(),
       })
     })
 

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

@@ -31,6 +31,8 @@ describe('<ChangePlanModal />', function () {
       assign: sinon.stub(),
       replace: sinon.stub(),
       reload: reloadStub,
+      setHash: sinon.stub(),
+      toString: sinon.stub(),
     })
   })
 

+ 18 - 0
services/web/test/unit/src/Subscription/RecurlyClientTests.js

@@ -383,6 +383,24 @@ describe('RecurlyClient', function () {
     })
   })
 
+  describe('pauseSubscriptionByUuid', function () {
+    it('should attempt to pause the subscription', async function () {
+      this.client.pauseSubscription = sinon
+        .stub()
+        .resolves(this.recurlySubscription)
+      const subscription =
+        await this.RecurlyClient.promises.pauseSubscriptionByUuid(
+          this.subscription.uuid,
+          3
+        )
+      expect(subscription).to.deep.equal(this.recurlySubscription)
+      expect(this.client.pauseSubscription).to.be.calledWith(
+        'uuid-' + this.subscription.uuid,
+        { remainingPauseCycles: 3 }
+      )
+    })
+  })
+
   describe('previewSubscriptionChange', function () {
     describe('compute immediate charge', function () {
       it('only has charge invoice', async function () {

+ 60 - 0
services/web/test/unit/src/Subscription/SubscriptionControllerTests.js

@@ -53,6 +53,8 @@ describe('SubscriptionController', function () {
         updateSubscription: sinon.stub().resolves(),
         reactivateSubscription: sinon.stub().resolves(),
         cancelSubscription: sinon.stub().resolves(),
+        pauseSubscription: sinon.stub().resolves(),
+        resumeSubscription: sinon.stub().resolves(),
         syncSubscription: sinon.stub().resolves(),
         attemptPaypalInvoiceCollection: sinon.stub().resolves(),
         startFreeTrial: sinon.stub().resolves(),
@@ -162,6 +164,10 @@ describe('SubscriptionController', function () {
             res.status(422)
             res.json({ message })
           }),
+          badRequest: sinon.stub().callsFake((req, res, message) => {
+            res.status(400)
+            res.json({ message })
+          }),
         }),
         './Errors': SubscriptionErrors,
         '../Analytics/AnalyticsManager': (this.AnalyticsManager = {
@@ -416,6 +422,60 @@ describe('SubscriptionController', function () {
     })
   })
 
+  describe('pauseSubscription', function () {
+    it('should throw an error if no pause length is provided', async function () {
+      this.res = new MockResponse()
+      this.req = new MockRequest()
+      this.next = sinon.stub()
+      await this.SubscriptionController.pauseSubscription(
+        this.req,
+        this.res,
+        this.next
+      )
+      expect(this.res.statusCode).to.equal(400)
+    })
+
+    it('should throw an error if an invalid pause length is provided', async function () {
+      this.res = new MockResponse()
+      this.req = new MockRequest()
+      this.req.params = { pauseCycles: -10 }
+      this.next = sinon.stub()
+      await this.SubscriptionController.pauseSubscription(
+        this.req,
+        this.res,
+        this.next
+      )
+      expect(this.res.statusCode).to.equal(400)
+    })
+
+    it('should return a 200 when requesting a pause', async function () {
+      this.res = new MockResponse()
+      this.req = new MockRequest()
+      this.req.params = { pauseCycles: 3 }
+      this.next = sinon.stub()
+      await this.SubscriptionController.pauseSubscription(
+        this.req,
+        this.res,
+        this.next
+      )
+      expect(this.res.statusCode).to.equal(200)
+    })
+  })
+
+  describe('resumeSubscription', function () {
+    it('should return a 200 when resuming a subscription', async function () {
+      this.res = new MockResponse()
+      this.req = new MockRequest()
+      this.next = sinon.stub()
+      await this.SubscriptionController.resumeSubscription(
+        this.req,
+        this.res,
+        this.next
+      )
+      expect(this.res.statusCode).to.equal(200)
+    })
+  })
+
   describe('cancelSubscription', function () {
     beforeEach(function (done) {
       this.res = {

+ 148 - 0
services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js

@@ -105,6 +105,8 @@ describe('SubscriptionHandler', function () {
         getSubscription: sinon
           .stub()
           .resolves(this.activeRecurlyClientSubscription),
+        pauseSubscriptionByUuid: sinon.stub().resolves(),
+        resumeSubscriptionByUuid: sinon.stub().resolves(),
       },
     }
 
@@ -442,6 +444,152 @@ describe('SubscriptionHandler', function () {
     })
   })
 
+  describe('resumeSubscription', function () {
+    describe('for a user without a subscription', function () {
+      beforeEach(async function () {
+        this.LimitationsManager.promises.userHasSubscription.resolves({
+          hasSubscription: false,
+          subscription: this.subscription,
+        })
+      })
+      it('should not make a resume call to recurly', async function () {
+        expect(
+          this.SubscriptionHandler.promises.resumeSubscription(this.user)
+        ).to.be.rejectedWith('No active subscription to resume')
+        this.RecurlyClient.promises.resumeSubscriptionByUuid.called.should.equal(
+          false
+        )
+      })
+    })
+
+    describe('for a user with a subscription', function () {
+      beforeEach(async function () {
+        this.LimitationsManager.promises.userHasSubscription.resolves({
+          hasSubscription: true,
+          subscription: {
+            recurlySubscription_id: this.activeRecurlySubscription.uuid,
+            recurlyStatus: { state: 'non-trial' },
+            planCode: 'collaborator',
+          },
+        })
+      })
+      it('should make a resume call to recurly', async function () {
+        await this.SubscriptionHandler.promises.resumeSubscription(this.user)
+
+        this.RecurlyClient.promises.resumeSubscriptionByUuid.called.should.equal(
+          true
+        )
+      })
+    })
+  })
+
+  describe('pauseSubscription', function () {
+    describe('for a user without a subscription', function () {
+      beforeEach(async function () {
+        this.LimitationsManager.promises.userHasSubscription.resolves({
+          hasSubscription: false,
+          subscription: this.subscription,
+        })
+      })
+      it('should not make a pause call to recurly', async function () {
+        expect(
+          this.SubscriptionHandler.promises.pauseSubscription(this.user, 3)
+        ).to.be.rejectedWith('No active subscription to pause')
+        this.RecurlyClient.promises.pauseSubscriptionByUuid.called.should.equal(
+          false
+        )
+      })
+    })
+
+    describe('for a user with an annual subscription', function () {
+      beforeEach(async function () {
+        this.LimitationsManager.promises.userHasSubscription.resolves({
+          hasSubscription: false,
+          subscription: {
+            recurlySubscription_id: this.activeRecurlySubscription.uuid,
+            recurlyStatus: { state: 'non-trial' },
+            planCode: 'collaborator-annual',
+          },
+        })
+      })
+      it('should not make a pause call to recurly', async function () {
+        expect(
+          this.SubscriptionHandler.promises.pauseSubscription(this.user, 3)
+        ).to.be.rejectedWith('Can only pause monthly individual plans')
+        this.RecurlyClient.promises.pauseSubscriptionByUuid.called.should.equal(
+          false
+        )
+      })
+    })
+
+    describe('for a user with a subscription', function () {
+      beforeEach(async function () {
+        this.LimitationsManager.promises.userHasSubscription.resolves({
+          hasSubscription: true,
+          subscription: {
+            recurlySubscription_id: this.activeRecurlySubscription.uuid,
+            recurlyStatus: { state: 'non-trial' },
+            planCode: 'collaborator',
+            addOns: [],
+          },
+        })
+      })
+      it('should make a pause call to recurly', async function () {
+        await this.SubscriptionHandler.promises.pauseSubscription(this.user, 3)
+
+        this.RecurlyClient.promises.pauseSubscriptionByUuid.called.should.equal(
+          true
+        )
+      })
+    })
+
+    describe('for a user in a trial', function () {
+      beforeEach(async function () {
+        this.LimitationsManager.promises.userHasSubscription.resolves({
+          hasSubscription: true,
+          subscription: {
+            recurlySubscription_id: this.activeRecurlySubscription.uuid,
+            recurlyStatus: {
+              state: 'trial',
+              trialEndsAt: Date.now() + 1000000,
+            },
+            planCode: 'collaborator',
+          },
+        })
+      })
+      it('should not make a pause call to recurly', async function () {
+        expect(
+          this.SubscriptionHandler.promises.pauseSubscription(this.user, 3)
+        ).to.be.rejectedWith('Cannot pause a subscription in a trial')
+        this.RecurlyClient.promises.pauseSubscriptionByUuid.called.should.equal(
+          false
+        )
+      })
+    })
+
+    describe('for a user with addons', function () {
+      beforeEach(async function () {
+        this.LimitationsManager.promises.userHasSubscription.resolves({
+          hasSubscription: true,
+          subscription: {
+            recurlySubscription_id: this.activeRecurlySubscription.uuid,
+            recurlyStatus: { state: 'non-trial' },
+            planCode: 'collaborator',
+            addOns: ['mock-addon'],
+          },
+        })
+      })
+      it('should not make a pause call to recurly', async function () {
+        expect(
+          this.SubscriptionHandler.promises.pauseSubscription(this.user, 3)
+        ).to.be.rejectedWith('Cannot pause a subscription with addons')
+        this.RecurlyClient.promises.pauseSubscriptionByUuid.called.should.equal(
+          false
+        )
+      })
+    })
+  })
+
   describe('reactivateSubscription', function () {
     describe('with a user without a subscription', function () {
       beforeEach(async function () {

+ 5 - 0
services/web/types/project/dashboard/subscription.ts

@@ -8,6 +8,10 @@ export type FreePlanSubscription = {
 
 type FreeSubscription = FreePlanSubscription
 
+type RecurlyStatus = {
+  state: 'active' | 'canceled' | 'expired' | 'paused'
+}
+
 type PaidSubscriptionBase = {
   plan: {
     name: string
@@ -15,6 +19,7 @@ type PaidSubscriptionBase = {
   subscription: {
     teamName?: string
     name: string
+    recurlyStatus?: RecurlyStatus
   }
 } & SubscriptionBase
 

+ 2 - 0
services/web/types/subscription/dashboard/modal-ids.ts

@@ -5,3 +5,5 @@ export type SubscriptionDashModalIds =
   | 'leave-group'
   | 'change-plan'
   | 'cancel-ai-add-on'
+  | 'pause-subscription'
+  | 'unpause-subscription'

+ 3 - 1
services/web/types/subscription/dashboard/subscription.ts

@@ -3,7 +3,7 @@ import { Nullable } from '../../utils'
 import { Plan, AddOn } from '../plan'
 import { User } from '../../user'
 
-type SubscriptionState = 'active' | 'canceled' | 'expired'
+type SubscriptionState = 'active' | 'canceled' | 'expired' | 'paused'
 
 // when puchasing a new add-on in recurly, we only need to provide the code
 export type PurchasingAddOnCode = {
@@ -45,6 +45,8 @@ type Recurly = {
   currentPlanDisplayPrice?: string
   pendingAdditionalLicenses?: number
   pendingTotalLicenses?: number
+  pausedAt?: Nullable<string>
+  remainingPauseCycles?: Nullable<number>
 }
 
 export type GroupPolicy = {

Некоторые файлы не были показаны из-за большого количества измененных файлов