Quellcode durchsuchen

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

Add support for pausing subscription

GitOrigin-RevId: f939ea4e7f3c2b1fa16dcb8aff1b2460d091d4e2
roo hutton vor 1 Jahr
Ursprung
Commit
ad096f82bf
41 geänderte Dateien mit 1318 neuen und 22 gelöschten Zeilen
  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) {
 async function _getIndividualFeatures(userId) {
   const subscription =
   const subscription =
     await SubscriptionLocator.promises.getUsersSubscription(userId)
     await SubscriptionLocator.promises.getUsersSubscription(userId)
-  if (subscription == null) {
+  if (subscription == null || subscription?.recurlyStatus?.state === 'paused') {
     return {}
     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
  * Get the payment method for the given user
  *
  *
@@ -459,6 +469,8 @@ module.exports = {
   getAddOn: callbackify(getAddOn),
   getAddOn: callbackify(getAddOn),
   getPlan: callbackify(getPlan),
   getPlan: callbackify(getPlan),
   subscriptionIsCanceledOrExpired,
   subscriptionIsCanceledOrExpired,
+  pauseSubscriptionByUuid: callbackify(pauseSubscriptionByUuid),
+  resumeSubscriptionByUuid: callbackify(resumeSubscriptionByUuid),
 
 
   promises: {
   promises: {
     getSubscription,
     getSubscription,
@@ -471,6 +483,8 @@ module.exports = {
     removeSubscriptionChangeByUuid,
     removeSubscriptionChangeByUuid,
     reactivateSubscriptionByUuid,
     reactivateSubscriptionByUuid,
     cancelSubscriptionByUuid,
     cancelSubscriptionByUuid,
+    pauseSubscriptionByUuid,
+    resumeSubscriptionByUuid,
     getPaymentMethod,
     getPaymentMethod,
     getAddOn,
     getAddOn,
     getPlan,
     getPlan,

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

@@ -30,6 +30,13 @@ async function sendRecurlyAnalyticsEvent(event, eventData) {
     case 'reactivated_account_notification':
     case 'reactivated_account_notification':
       await _sendSubscriptionReactivatedEvent(userId, eventData)
       await _sendSubscriptionReactivatedEvent(userId, eventData)
       break
       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':
     case 'paid_charge_invoice_notification':
       if (
       if (
         eventData.invoice.state === 'paid' &&
         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) {
 async function _sendSubscriptionStartedEvent(userId, eventData) {
   const { planCode, quantity, state, isTrial, hasAiAddOn, subscriptionId } =
   const { planCode, quantity, state, isTrial, hasAiAddOn, subscriptionId } =
     _getSubscriptionData(eventData)
     _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, 'ai-add-on')
 
 
+  await SplitTestHandler.promises.getAssignment(req, res, 'pause-subscription')
+
   // Populates splitTestVariants with a value for the split test name and allows
   // Populates splitTestVariants with a value for the split test name and allows
   // Pug to read it
   // Pug to read it
   await SplitTestHandler.promises.getAssignment(
   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) {
 function cancelSubscription(req, res, next) {
   const user = SessionManager.getSessionUser(req.session)
   const user = SessionManager.getSessionUser(req.session)
   logger.debug({ userId: user._id }, 'canceling subscription')
   logger.debug({ userId: user._id }, 'canceling subscription')
@@ -458,6 +510,8 @@ function recurlyCallback(req, res, next) {
       'new_subscription_notification',
       'new_subscription_notification',
       'updated_subscription_notification',
       'updated_subscription_notification',
       'expired_subscription_notification',
       'expired_subscription_notification',
+      'subscription_paused_notification',
+      'subscription_resumed_notification',
     ].includes(event)
     ].includes(event)
   ) {
   ) {
     const recurlySubscription = eventData.subscription
     const recurlySubscription = eventData.subscription
@@ -667,6 +721,8 @@ module.exports = {
   userSubscriptionPage: expressify(userSubscriptionPage),
   userSubscriptionPage: expressify(userSubscriptionPage),
   successfulSubscription: expressify(successfulSubscription),
   successfulSubscription: expressify(successfulSubscription),
   cancelSubscription,
   cancelSubscription,
+  pauseSubscription,
+  resumeSubscription,
   canceledSubscription: expressify(canceledSubscription),
   canceledSubscription: expressify(canceledSubscription),
   cancelV1Subscription,
   cancelV1Subscription,
   previewSubscription: expressify(previewSubscription),
   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 = {
 module.exports = {
   validateNoSubscriptionInRecurly: callbackify(validateNoSubscriptionInRecurly),
   validateNoSubscriptionInRecurly: callbackify(validateNoSubscriptionInRecurly),
   createSubscription: callbackify(createSubscription),
   createSubscription: callbackify(createSubscription),
@@ -395,6 +437,8 @@ module.exports = {
   previewAddonPurchase: callbackify(previewAddonPurchase),
   previewAddonPurchase: callbackify(previewAddonPurchase),
   purchaseAddon: callbackify(purchaseAddon),
   purchaseAddon: callbackify(purchaseAddon),
   removeAddon: callbackify(removeAddon),
   removeAddon: callbackify(removeAddon),
+  pauseSubscription: callbackify(pauseSubscription),
+  resumeSubscription: callbackify(resumeSubscription),
   promises: {
   promises: {
     validateNoSubscriptionInRecurly,
     validateNoSubscriptionInRecurly,
     createSubscription,
     createSubscription,
@@ -409,5 +453,7 @@ module.exports = {
     previewAddonPurchase,
     previewAddonPurchase,
     purchaseAddon,
     purchaseAddon,
     removeAddon,
     removeAddon,
+    pauseSubscription,
+    resumeSubscription,
   },
   },
 }
 }

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

@@ -205,6 +205,23 @@ export default {
       RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
       RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
       SubscriptionController.cancelSubscription
       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(
     webRouter.post(
       '/user/subscription/reactivate',
       '/user/subscription/reactivate',
       AuthenticationController.requireLogin(),
       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,
       trial_ends_at: recurlySubscription.trial_ends_at,
       activeCoupons: recurlyCoupons,
       activeCoupons: recurlyCoupons,
       account: recurlySubscription.account,
       account: recurlySubscription.account,
+      pausedAt: recurlySubscription.paused_at,
+      remainingPauseCycles: recurlySubscription.remaining_pause_cycles,
     }
     }
     if (recurlySubscription.pending_subscription) {
     if (recurlySubscription.pending_subscription) {
       const pendingPlan = PlansLocator.findLocalPlanInSettings(
       const pendingPlan = PlansLocator.findLocalPlanInSettings(

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

@@ -42,6 +42,7 @@
   "accepted_invite": "",
   "accepted_invite": "",
   "accepting_invite_as": "",
   "accepting_invite_as": "",
   "access_denied": "",
   "access_denied": "",
+  "access_edit_your_projects": "",
   "access_levels_changed": "",
   "access_levels_changed": "",
   "account_has_been_link_to_institution_account": "",
   "account_has_been_link_to_institution_account": "",
   "account_has_past_due_invoice_change_plan_warning": "",
   "account_has_past_due_invoice_change_plan_warning": "",
@@ -188,6 +189,7 @@
   "cancel_my_account": "",
   "cancel_my_account": "",
   "cancel_my_subscription": "",
   "cancel_my_subscription": "",
   "cancel_personal_subscription_first": "",
   "cancel_personal_subscription_first": "",
+  "cancel_subscription": "",
   "cancel_your_subscription": "",
   "cancel_your_subscription": "",
   "cannot_invite_non_user": "",
   "cannot_invite_non_user": "",
   "cannot_invite_self": "",
   "cannot_invite_self": "",
@@ -234,6 +236,7 @@
   "clear_search": "",
   "clear_search": "",
   "click_here_to_view_sl_in_lng": "",
   "click_here_to_view_sl_in_lng": "",
   "click_to_give_feedback": "",
   "click_to_give_feedback": "",
+  "click_to_unpause": "",
   "clicking_delete_will_remove_sso_config_and_clear_saml_data": "",
   "clicking_delete_will_remove_sso_config_and_clear_saml_data": "",
   "clone_with_git": "",
   "clone_with_git": "",
   "close": "",
   "close": "",
@@ -296,6 +299,7 @@
   "continue": "",
   "continue": "",
   "continue_github_merge": "",
   "continue_github_merge": "",
   "continue_to": "",
   "continue_to": "",
+  "continue_using_free_features": "",
   "copied": "",
   "copied": "",
   "copy": "",
   "copy": "",
   "copy_code": "",
   "copy_code": "",
@@ -394,6 +398,7 @@
   "doing_this_allow_log_in_through_institution_2": "",
   "doing_this_allow_log_in_through_institution_2": "",
   "doing_this_will_verify_affiliation_and_allow_log_in_2": "",
   "doing_this_will_verify_affiliation_and_allow_log_in_2": "",
   "done": "",
   "done": "",
+  "dont_forget_you_currently_have": "",
   "download": "",
   "download": "",
   "download_all": "",
   "download_all": "",
   "download_metadata": "",
   "download_metadata": "",
@@ -493,6 +498,7 @@
   "failed_to_send_managed_user_invite_to_email": "",
   "failed_to_send_managed_user_invite_to_email": "",
   "failed_to_send_sso_link_invite_to_email": "",
   "failed_to_send_sso_link_invite_to_email": "",
   "fast": "",
   "fast": "",
+  "features_like_track_changes": "",
   "file_action_created": "",
   "file_action_created": "",
   "file_action_deleted": "",
   "file_action_deleted": "",
   "file_action_edited": "",
   "file_action_edited": "",
@@ -759,6 +765,7 @@
   "institution_templates": "",
   "institution_templates": "",
   "institutional_leavers_survey_notification": "",
   "institutional_leavers_survey_notification": "",
   "integrations": "",
   "integrations": "",
+  "integrations_like_github": "",
   "interested_in_cheaper_personal_plan": "",
   "interested_in_cheaper_personal_plan": "",
   "interface": "",
   "interface": "",
   "interface_settings": "",
   "interface_settings": "",
@@ -835,6 +842,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": "",
   "library": "",
   "library": "",
   "license_for_educational_purposes_confirmation": "",
   "license_for_educational_purposes_confirmation": "",
   "limited_offer": "",
   "limited_offer": "",
@@ -944,9 +952,12 @@
   "missing_fields_for_entry": "",
   "missing_fields_for_entry": "",
   "money_back_guarantee": "",
   "money_back_guarantee": "",
   "month": "",
   "month": "",
+  "month_plural": "",
   "more": "",
   "more": "",
   "more_actions": "",
   "more_actions": "",
+  "more_collabs_per_project": "",
   "more_comments": "",
   "more_comments": "",
+  "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": "",
@@ -1085,6 +1096,8 @@
   "paste_options": "",
   "paste_options": "",
   "paste_with_formatting": "",
   "paste_with_formatting": "",
   "paste_without_formatting": "",
   "paste_without_formatting": "",
+  "pause_subscription": "",
+  "pause_subscription_for": "",
   "pay_now": "",
   "pay_now": "",
   "payment_provider_unreachable_error": "",
   "payment_provider_unreachable_error": "",
   "payment_summary": "",
   "payment_summary": "",
@@ -1107,6 +1120,7 @@
   "percent_is_the_percentage_of_the_line_width": "",
   "percent_is_the_percentage_of_the_line_width": "",
   "permanently_disables_the_preview": "",
   "permanently_disables_the_preview": "",
   "personal_library": "",
   "personal_library": "",
+  "pick_up_where_you_left_off": "",
   "plan": "",
   "plan": "",
   "plan_tooltip": "",
   "plan_tooltip": "",
   "please_ask_the_project_owner_to_upgrade_more_editors": "",
   "please_ask_the_project_owner_to_upgrade_more_editors": "",
@@ -1778,9 +1792,11 @@
   "unlink_warning_reference": "",
   "unlink_warning_reference": "",
   "unlinking": "",
   "unlinking": "",
   "unmerge_cells": "",
   "unmerge_cells": "",
+  "unpause_subscription": "",
   "unpublish": "",
   "unpublish": "",
   "unpublishing": "",
   "unpublishing": "",
   "unsubscribe": "",
   "unsubscribe": "",
+  "until_then_you_can_still": "",
   "untrash": "",
   "untrash": "",
   "update": "",
   "update": "",
   "update_account_info": "",
   "update_account_info": "",
@@ -1874,6 +1890,7 @@
   "website_status": "",
   "website_status": "",
   "wed_love_you_to_stay": "",
   "wed_love_you_to_stay": "",
   "welcome_to_sl": "",
   "welcome_to_sl": "",
+  "well_be_here_when_youre_ready": "",
   "were_making_some_changes_to_project_sharing_this_means_you_will_be_visible": "",
   "were_making_some_changes_to_project_sharing_this_means_you_will_be_visible": "",
   "were_performing_maintenance": "",
   "were_performing_maintenance": "",
   "weve_recently_reduced_the_compile_timeout_limit_which_may_have_affected_this_project": "",
   "weve_recently_reduced_the_compile_timeout_limit_which_may_have_affected_this_project": "",
@@ -1886,6 +1903,7 @@
   "what_should_we_call_you": "",
   "what_should_we_call_you": "",
   "when_you_tick_the_include_caption_box": "",
   "when_you_tick_the_include_caption_box": "",
   "why_latex": "",
   "why_latex": "",
+  "why_not_pause_instead": "",
   "wide": "",
   "wide": "",
   "will_lose_edit_access_on_date": "",
   "will_lose_edit_access_on_date": "",
   "with_premium_subscription_you_also_get": "",
   "with_premium_subscription_you_also_get": "",
@@ -1930,6 +1948,7 @@
   "you_can_request_a_maximum_of_limit_fixes_per_day": "",
   "you_can_request_a_maximum_of_limit_fixes_per_day": "",
   "you_can_select_or_invite": "",
   "you_can_select_or_invite": "",
   "you_can_select_or_invite_plural": "",
   "you_can_select_or_invite_plural": "",
+  "you_can_still_use_your_premium_features": "",
   "you_cant_add_or_change_password_due_to_sso": "",
   "you_cant_add_or_change_password_due_to_sso": "",
   "you_cant_join_this_group_subscription": "",
   "you_cant_join_this_group_subscription": "",
   "you_dont_have_any_repositories": "",
   "you_dont_have_any_repositories": "",
@@ -1943,6 +1962,7 @@
   "you_have_x_users_and_your_plan_supports_up_to_y": "",
   "you_have_x_users_and_your_plan_supports_up_to_y": "",
   "you_have_x_users_on_your_subscription": "",
   "you_have_x_users_on_your_subscription": "",
   "you_need_to_configure_your_sso_settings": "",
   "you_need_to_configure_your_sso_settings": "",
+  "you_unpaused_your_subscription": "",
   "you_will_be_able_to_reassign_subscription": "",
   "you_will_be_able_to_reassign_subscription": "",
   "youll_get_best_results_in_visual_but_can_be_used_in_source": "",
   "youll_get_best_results_in_visual_but_can_be_used_in_source": "",
   "youll_need_to_ask_the_github_repository_owner": "",
   "youll_need_to_ask_the_github_repository_owner": "",
@@ -1952,6 +1972,7 @@
   "your_affiliation_is_confirmed": "",
   "your_affiliation_is_confirmed": "",
   "your_browser_does_not_support_this_feature": "",
   "your_browser_does_not_support_this_feature": "",
   "your_compile_timed_out": "",
   "your_compile_timed_out": "",
+  "your_current_plan_gives_you": "",
   "your_current_plan_supports_up_to_x_users": "",
   "your_current_plan_supports_up_to_x_users": "",
   "your_current_project_will_revert_to_the_version_from_time": "",
   "your_current_project_will_revert_to_the_version_from_time": "",
   "your_git_access_info": "",
   "your_git_access_info": "",
@@ -1970,6 +1991,7 @@
   "your_plan_is_changing_at_term_end": "",
   "your_plan_is_changing_at_term_end": "",
   "your_plan_is_limited_to_n_editors": "",
   "your_plan_is_limited_to_n_editors": "",
   "your_plan_is_limited_to_n_editors_plural": "",
   "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_compile_timeout_limit_on_free_plan": "",
   "your_project_exceeded_editor_limit": "",
   "your_project_exceeded_editor_limit": "",
   "your_project_near_compile_timeout_limit": "",
   "your_project_near_compile_timeout_limit": "",
@@ -1977,6 +1999,8 @@
   "your_role": "",
   "your_role": "",
   "your_subscription": "",
   "your_subscription": "",
   "your_subscription_has_expired": "",
   "your_subscription_has_expired": "",
+  "your_subscription_will_pause_on": "",
+  "your_subscription_will_pause_on_short": "",
   "youre_a_member_of_overleaf_labs": "",
   "youre_a_member_of_overleaf_labs": "",
   "youre_about_to_disable_single_sign_on": "",
   "youre_about_to_disable_single_sign_on": "",
   "youre_about_to_enable_single_sign_on": "",
   "youre_about_to_enable_single_sign_on": "",
@@ -1989,6 +2013,7 @@
   "youve_added_more_users": "",
   "youve_added_more_users": "",
   "youve_added_x_more_users_to_your_subscription_invite_people": "",
   "youve_added_x_more_users_to_your_subscription_invite_people": "",
   "youve_lost_edit_access": "",
   "youve_lost_edit_access": "",
+  "youve_paused_your_subscription": "",
   "youve_unlinked_all_users": "",
   "youve_unlinked_all_users": "",
   "youve_upgraded_your_plan": "",
   "youve_upgraded_your_plan": "",
   "zoom_in": "",
   "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 IndividualPlan from './individual-plan'
 import GroupPlan from './group-plan'
 import GroupPlan from './group-plan'
 import CommonsPlan from './commons-plan'
 import CommonsPlan from './commons-plan'
+import PausedPlan from './paused-plan'
 import getMeta from '../../../../utils/meta'
 import getMeta from '../../../../utils/meta'
 
 
 function CurrentPlanWidget() {
 function CurrentPlanWidget() {
@@ -16,8 +17,12 @@ function CurrentPlanWidget() {
   const isIndividualPlan = type === 'individual'
   const isIndividualPlan = type === 'individual'
   const isGroupPlan = type === 'group'
   const isGroupPlan = type === 'group'
   const isCommonsPlan = type === 'commons'
   const isCommonsPlan = type === 'commons'
+  const isPaused =
+    isIndividualPlan &&
+    usersBestSubscription.subscription?.recurlyStatus?.state === 'paused'
 
 
   const featuresPageURL = '/learn/how-to/Overleaf_premium_features'
   const featuresPageURL = '/learn/how-to/Overleaf_premium_features'
+  const subscriptionPageUrl = '/user/subscription'
 
 
   let currentPlan
   let currentPlan
 
 
@@ -35,6 +40,10 @@ function CurrentPlanWidget() {
     )
     )
   }
   }
 
 
+  if (isPaused) {
+    currentPlan = <PausedPlan subscriptionPageUrl={subscriptionPageUrl} />
+  }
+
   if (isGroupPlan) {
   if (isGroupPlan) {
     currentPlan = (
     currentPlan = (
       <GroupPlan
       <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

Datei-Diff unterdrückt, da er zu groß ist
+ 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 { RecurlySubscription } from '../../../../../../types/subscription/dashboard/subscription'
 import { ActiveSubscription } from './states/active/active'
 import { ActiveSubscription } from './states/active/active'
 import { ActiveAiAddonSubscription } from './states/active/active-ai-addon'
 import { ActiveAiAddonSubscription } from './states/active/active-ai-addon'
+import { PausedSubscription } from './states/active/paused'
 import { CanceledSubscription } from './states/canceled'
 import { CanceledSubscription } from './states/canceled'
 import { ExpiredSubscription } from './states/expired'
 import { ExpiredSubscription } from './states/expired'
 import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
 import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
@@ -57,6 +58,8 @@ function PersonalSubscriptionStates({
     return <CanceledSubscription subscription={subscription} />
     return <CanceledSubscription subscription={subscription} />
   } else if (state === 'expired') {
   } else if (state === 'expired') {
     return <ExpiredSubscription subscription={subscription} />
     return <ExpiredSubscription subscription={subscription} />
+  } else if (state === 'paused') {
+    return <PausedSubscription subscription={subscription} />
   } else {
   } else {
     return <>{t('problem_with_subscription_contact_us')}</>
     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 { KeepCurrentPlanModal } from './change-plan/modals/keep-current-plan-modal'
 import { ChangeToGroupModal } from './change-plan/modals/change-to-group-modal'
 import { ChangeToGroupModal } from './change-plan/modals/change-to-group-modal'
 import OLButton from '@/features/ui/components/ol/ol-button'
 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({
 export function ActiveSubscription({
   subscription,
   subscription,
@@ -22,26 +30,65 @@ export function ActiveSubscription({
   subscription: RecurlySubscription
   subscription: RecurlySubscription
 }) {
 }) {
   const { t } = useTranslation()
   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 />
   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 (
   return (
     <>
     <>
+      <div className="notification-list">
+        <FlashMessage />
+
+        {isErrorPause && (
+          <Notification
+            type="error"
+            content={t('generic_something_went_wrong')}
+          />
+        )}
+      </div>
       <p>
       <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 && (
         {subscription.pendingPlan && (
           <>
           <>
             {' '}
             {' '}
@@ -60,6 +107,7 @@ export function ActiveSubscription({
           )}
           )}
         {!recurlyLoadError &&
         {!recurlyLoadError &&
           !subscription.groupPlan &&
           !subscription.groupPlan &&
+          !hasPendingPause &&
           subscription.recurly.account.has_past_due_invoice._ !== 'true' && (
           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>
       <p>
         <Trans
         <Trans
           i18nKey="next_payment_of_x_collectected_on_y"
           i18nKey="next_payment_of_x_collectected_on_y"
           values={{
           values={{
             paymentAmmount: subscription.recurly.displayPrice,
             paymentAmmount: subscription.recurly.displayPrice,
-            collectionDate: subscription.recurly.nextPaymentDueAt,
+            collectionDate: getFormattedRenewalDate(),
           }}
           }}
           shouldUnescape
           shouldUnescape
           tOptions={{ interpolation: { escapeValue: true } }}
           tOptions={{ interpolation: { escapeValue: true } }}
@@ -104,6 +187,8 @@ export function ActiveSubscription({
           ]}
           ]}
         />
         />
       </p>
       </p>
+
+      <hr />
       <PriceExceptions subscription={subscription} />
       <PriceExceptions subscription={subscription} />
       <p className="d-inline-flex flex-wrap gap-1">
       <p className="d-inline-flex flex-wrap gap-1">
         <a
         <a
@@ -145,6 +230,7 @@ export function ActiveSubscription({
       <ConfirmChangePlanModal />
       <ConfirmChangePlanModal />
       <KeepCurrentPlanModal />
       <KeepCurrentPlanModal />
       <ChangeToGroupModal />
       <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 * as eventTracking from '../../../../../../infrastructure/event-tracking'
 import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context'
 import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context'
 import OLButton from '@/features/ui/components/ol/ol-button'
 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() {
 export function CancelSubscriptionButton() {
   const { t } = useTranslation()
   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() {
   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
   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 { formatCurrency } from '@/shared/utils/currency'
 import { ManagedInstitution } from '../../../../../types/subscription/dashboard/managed-institution'
 import { ManagedInstitution } from '../../../../../types/subscription/dashboard/managed-institution'
 import { Publisher } from '../../../../../types/subscription/dashboard/publisher'
 import { Publisher } from '../../../../../types/subscription/dashboard/publisher'
+import { formatTime } from '@/features/utils/format-date'
 
 
 type SubscriptionDashboardContextValue = {
 type SubscriptionDashboardContextValue = {
   groupPlanToChangeToCode: string
   groupPlanToChangeToCode: string
@@ -73,6 +74,7 @@ type SubscriptionDashboardContextValue = {
   leavingGroupId?: string
   leavingGroupId?: string
   setLeavingGroupId: React.Dispatch<React.SetStateAction<string | undefined>>
   setLeavingGroupId: React.Dispatch<React.SetStateAction<string | undefined>>
   userCanExtendTrial: boolean
   userCanExtendTrial: boolean
+  getFormattedRenewalDate: () => string
 }
 }
 
 
 export const SubscriptionDashboardContext = createContext<
 export const SubscriptionDashboardContext = createContext<
@@ -139,6 +141,20 @@ export function SubscriptionDashboardProvider({
       memberGroupSubscriptions?.length > 0
       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(() => {
   useEffect(() => {
     if (!isRecurlyLoaded()) {
     if (!isRecurlyLoaded()) {
       setRecurlyLoadError(true)
       setRecurlyLoadError(true)
@@ -283,6 +299,7 @@ export function SubscriptionDashboardProvider({
       leavingGroupId,
       leavingGroupId,
       setLeavingGroupId,
       setLeavingGroupId,
       userCanExtendTrial,
       userCanExtendTrial,
+      getFormattedRenewalDate,
     }),
     }),
     [
     [
       groupPlanToChangeToCode,
       groupPlanToChangeToCode,
@@ -319,6 +336,7 @@ export function SubscriptionDashboardProvider({
       leavingGroupId,
       leavingGroupId,
       setLeavingGroupId,
       setLeavingGroupId,
       userCanExtendTrial,
       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
     // eslint-disable-next-line no-restricted-syntax
     window.location.reload()
     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])
   }, [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",
   "accepted_invite": "Accepted invite",
   "accepting_invite_as": "You are accepting this invite as",
   "accepting_invite_as": "You are accepting this invite as",
   "access_denied": "Access Denied",
   "access_denied": "Access Denied",
+  "access_edit_your_projects": "Access and edit your projects",
   "access_levels_changed": "Access levels changed",
   "access_levels_changed": "Access levels changed",
   "account": "Account",
   "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.",
   "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_account": "Cancel my subscription",
   "cancel_my_subscription": "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_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",
   "cancel_your_subscription": "Cancel your subscription",
   "cannot_invite_non_user": "Can’t send invite. Recipient must already have an __appName__ account",
   "cannot_invite_non_user": "Can’t send invite. Recipient must already have an __appName__ account",
   "cannot_invite_self": "Can’t send invite to yourself",
   "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_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_link_to_proceed": "Click <b>__clickText__</b> below to proceed.",
   "click_to_give_feedback": "Click to give feedback.",
   "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.",
   "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",
   "clone_with_git": "Clone with Git",
   "close": "Close",
   "close": "Close",
@@ -390,6 +393,7 @@
   "continue": "Continue",
   "continue": "Continue",
   "continue_github_merge": "I have manually merged. Continue",
   "continue_github_merge": "I have manually merged. Continue",
   "continue_to": "Continue to __appName__",
   "continue_to": "Continue to __appName__",
+  "continue_using_free_features": "Continue using our free features",
   "continue_with_free_plan": "Continue with free plan",
   "continue_with_free_plan": "Continue with free plan",
   "continue_with_service": "Continue with __service__",
   "continue_with_service": "Continue with __service__",
   "copied": "Copied",
   "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_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.",
   "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",
   "done": "Done",
+  "dont_forget_you_currently_have": "Don’t forget, you currently have:",
   "dont_have_account": "Don’t have an account?",
   "dont_have_account": "Don’t have an account?",
   "download": "Download",
   "download": "Download",
   "download_all": "Download all",
   "download_all": "Download all",
@@ -663,6 +668,7 @@
   "featured_latex_templates": "Featured LaTeX Templates",
   "featured_latex_templates": "Featured LaTeX Templates",
   "features": "Features",
   "features": "Features",
   "features_and_benefits": "Features & Benefits",
   "features_and_benefits": "Features & Benefits",
+  "features_like_track_changes": "Features like real-time track changes",
   "february": "February",
   "february": "February",
   "file_action_created": "Created",
   "file_action_created": "Created",
   "file_action_deleted": "Deleted",
   "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_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.",
   "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",
+  "integrations_like_github": "Integrations like GitHub Sync",
   "interested_in_cheaper_personal_plan": "Would you be interested in the cheaper <0>__price__</0> Personal plan?",
   "interested_in_cheaper_personal_plan": "Would you be interested in the cheaper <0>__price__</0> Personal plan?",
   "interface": "Interface",
   "interface": "Interface",
   "interface_settings": "Interface settings",
   "interface_settings": "Interface settings",
@@ -1115,6 +1122,7 @@
   "let_us_know": "Let us know",
   "let_us_know": "Let us know",
   "let_us_know_how_we_can_help": "Let us know how we can help",
   "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",
   "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",
   "libraries": "Libraries",
   "library": "Library",
   "library": "Library",
   "license": "License",
   "license": "License",
@@ -1260,10 +1268,13 @@
   "missing_fields_for_entry": "Missing fields for",
   "missing_fields_for_entry": "Missing fields for",
   "money_back_guarantee": "30-day money back guarantee, no questions asked",
   "money_back_guarantee": "30-day money back guarantee, no questions asked",
   "month": "month",
   "month": "month",
+  "month_plural": "months",
   "monthly": "Monthly",
   "monthly": "Monthly",
   "more": "More",
   "more": "More",
   "more_actions": "More actions",
   "more_actions": "More actions",
+  "more_collabs_per_project": "More collaborators per project",
   "more_comments": "More comments",
   "more_comments": "More comments",
+  "more_compile_time": "More compile time",
   "more_info": "More Info",
   "more_info": "More Info",
   "more_options": "More options",
   "more_options": "More options",
   "more_options_for_border_settings_coming_soon": "More options for border settings coming soon.",
   "more_options_for_border_settings_coming_soon": "More options for border settings coming soon.",
@@ -1470,6 +1481,8 @@
   "paste_options": "Paste options",
   "paste_options": "Paste options",
   "paste_with_formatting": "Paste with formatting",
   "paste_with_formatting": "Paste with formatting",
   "paste_without_formatting": "Paste without formatting",
   "paste_without_formatting": "Paste without formatting",
+  "pause_subscription": "Pause subscription",
+  "pause_subscription_for": "Pause subscription for",
   "pay_now": "Pay now",
   "pay_now": "Pay now",
   "payment_method_accepted": "__paymentMethod__ accepted",
   "payment_method_accepted": "__paymentMethod__ accepted",
   "payment_provider_unreachable_error": "Sorry, there was an error talking to our payment provider. Please try again in a few moments.\nIf you are using any ad or script blocking extensions in your browser, you may need to temporarily disable them.",
   "payment_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": "Personal",
   "personal_library": "Personal library",
   "personal_library": "Personal library",
   "personalized_onboarding": "Personalized onboarding",
   "personalized_onboarding": "Personalized onboarding",
+  "pick_up_where_you_left_off": "Pick up where you left off",
   "pl": "Polish",
   "pl": "Polish",
   "plan": "Plan",
   "plan": "Plan",
   "plan_tooltip": "You’re on the __plan__ plan. Click to find out how to make the most of your Overleaf premium features.",
   "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.",
   "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",
   "unlinking": "Unlinking",
   "unmerge_cells": "Unmerge cells",
   "unmerge_cells": "Unmerge cells",
+  "unpause_subscription": "Unpause subscription",
   "unpublish": "Unpublish",
   "unpublish": "Unpublish",
   "unpublishing": "Unpublishing",
   "unpublishing": "Unpublishing",
   "unsubscribe": "Unsubscribe",
   "unsubscribe": "Unsubscribe",
   "unsubscribed": "Unsubscribed",
   "unsubscribed": "Unsubscribed",
   "unsubscribing": "Unsubscribing",
   "unsubscribing": "Unsubscribing",
+  "until_then_you_can_still": "Until then you can still:",
   "untrash": "Restore",
   "untrash": "Restore",
   "update": "Update",
   "update": "Update",
   "update_account_info": "Update Account Info",
   "update_account_info": "Update Account Info",
@@ -2414,6 +2430,7 @@
   "website_status": "Website status",
   "website_status": "Website status",
   "wed_love_you_to_stay": "We’d love you to stay",
   "wed_love_you_to_stay": "We’d love you to stay",
   "welcome_to_sl": "Welcome to __appName__",
   "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_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.",
   "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.",
   "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_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.",
   "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_latex": "Why LaTeX?",
+  "why_not_pause_instead": "Pause instead, to pick up where you left off",
   "wide": "Wide",
   "wide": "Wide",
   "will_lose_edit_access_on_date": "Will lose edit access on __date__",
   "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>.",
   "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_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": "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_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_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_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>.",
   "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_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_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_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_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",
   "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.",
   "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_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_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_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_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_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.",
   "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_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": "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_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_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_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.",
   "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_sessions": "Your Sessions",
   "your_subscription": "Your subscription",
   "your_subscription": "Your subscription",
   "your_subscription_has_expired": "Your subscription has expired.",
   "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_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_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.",
   "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_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_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_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_unlinked_all_users": "You’ve unlinked all users",
   "youve_upgraded_your_plan": "You’ve upgraded your plan!",
   "youve_upgraded_your_plan": "You’ve upgraded your plan!",
   "zh-CN": "Chinese",
   "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
     /click to find out how you could benefit from overleaf premium features/i
   const paidPlanTooltipMessage =
   const paidPlanTooltipMessage =
     /click to find out how to make the most of your overleaf premium features/i
     /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
   let sendMBSpy: sinon.SinonSpy
 
 
@@ -25,6 +27,30 @@ describe('<CurrentPlanWidget />', function () {
     sendMBSpy.restore()
     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 () {
   describe('free plan', function () {
     beforeEach(function () {
     beforeEach(function () {
       window.metaAttributesCache.set('ol-usersBestSubscription', {
       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,
       assign: assignStub,
       replace: sinon.stub(),
       replace: sinon.stub(),
       reload: 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,
         assign: assignStub,
         replace: sinon.stub(),
         replace: sinon.stub(),
         reload: sinon.stub(),
         reload: sinon.stub(),
+        setHash: sinon.stub(),
       })
       })
       fetchMock.reset()
       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,
       assign: assignStub,
       replace: sinon.stub(),
       replace: sinon.stub(),
       reload: 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,
       assign: assignStub,
       replace: sinon.stub(),
       replace: sinon.stub(),
       reload: sinon.stub(),
       reload: sinon.stub(),
+      setHash: sinon.stub(),
     })
     })
     render(
     render(
       <CompileAndDownloadProjectPDFButtonTooltip project={projectsData[0]} />
       <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,
       assign: assignStub,
       replace: sinon.stub(),
       replace: sinon.stub(),
       reload: sinon.stub(),
       reload: sinon.stub(),
+      setHash: sinon.stub(),
     })
     })
     render(<DownloadProjectButtonTooltip project={projectsData[0]} />)
     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,
       assign: assignStub,
       replace: sinon.stub(),
       replace: sinon.stub(),
       reload: 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,
         assign: assignStub,
         replace: sinon.stub(),
         replace: sinon.stub(),
         reload: sinon.stub(),
         reload: sinon.stub(),
+        setHash: sinon.stub(),
       })
       })
       Object.assign(getMeta('ol-ExposedSettings'), { isOverleaf: true })
       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(),
         assign: sinon.stub(),
         replace: sinon.stub(),
         replace: sinon.stub(),
         reload: reloadStub,
         reload: reloadStub,
+        setHash: sinon.stub(),
       })
       })
 
 
       render(
       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(),
         assign: sinon.stub(),
         replace: sinon.stub(),
         replace: sinon.stub(),
         reload: reloadStub,
         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,
         assign: assignStub,
         replace: sinon.stub(),
         replace: sinon.stub(),
         reload: reloadStub,
         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(),
       assign: sinon.stub(),
       replace: sinon.stub(),
       replace: sinon.stub(),
       reload: reloadStub,
       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('previewSubscriptionChange', function () {
     describe('compute immediate charge', function () {
     describe('compute immediate charge', function () {
       it('only has charge invoice', async 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(),
         updateSubscription: sinon.stub().resolves(),
         reactivateSubscription: sinon.stub().resolves(),
         reactivateSubscription: sinon.stub().resolves(),
         cancelSubscription: sinon.stub().resolves(),
         cancelSubscription: sinon.stub().resolves(),
+        pauseSubscription: sinon.stub().resolves(),
+        resumeSubscription: sinon.stub().resolves(),
         syncSubscription: sinon.stub().resolves(),
         syncSubscription: sinon.stub().resolves(),
         attemptPaypalInvoiceCollection: sinon.stub().resolves(),
         attemptPaypalInvoiceCollection: sinon.stub().resolves(),
         startFreeTrial: sinon.stub().resolves(),
         startFreeTrial: sinon.stub().resolves(),
@@ -162,6 +164,10 @@ describe('SubscriptionController', function () {
             res.status(422)
             res.status(422)
             res.json({ message })
             res.json({ message })
           }),
           }),
+          badRequest: sinon.stub().callsFake((req, res, message) => {
+            res.status(400)
+            res.json({ message })
+          }),
         }),
         }),
         './Errors': SubscriptionErrors,
         './Errors': SubscriptionErrors,
         '../Analytics/AnalyticsManager': (this.AnalyticsManager = {
         '../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 () {
   describe('cancelSubscription', function () {
     beforeEach(function (done) {
     beforeEach(function (done) {
       this.res = {
       this.res = {

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

@@ -105,6 +105,8 @@ describe('SubscriptionHandler', function () {
         getSubscription: sinon
         getSubscription: sinon
           .stub()
           .stub()
           .resolves(this.activeRecurlyClientSubscription),
           .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('reactivateSubscription', function () {
     describe('with a user without a subscription', function () {
     describe('with a user without a subscription', function () {
       beforeEach(async function () {
       beforeEach(async function () {

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

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

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

@@ -5,3 +5,5 @@ export type SubscriptionDashModalIds =
   | 'leave-group'
   | 'leave-group'
   | 'change-plan'
   | 'change-plan'
   | 'cancel-ai-add-on'
   | '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 { Plan, AddOn } from '../plan'
 import { User } from '../../user'
 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
 // when puchasing a new add-on in recurly, we only need to provide the code
 export type PurchasingAddOnCode = {
 export type PurchasingAddOnCode = {
@@ -45,6 +45,8 @@ type Recurly = {
   currentPlanDisplayPrice?: string
   currentPlanDisplayPrice?: string
   pendingAdditionalLicenses?: number
   pendingAdditionalLicenses?: number
   pendingTotalLicenses?: number
   pendingTotalLicenses?: number
+  pausedAt?: Nullable<string>
+  remainingPauseCycles?: Nullable<number>
 }
 }
 
 
 export type GroupPolicy = {
 export type GroupPolicy = {

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.