+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx b/services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx
index 6c17f9ae8c..47d8762955 100644
--- a/services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx
+++ b/services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx
@@ -30,6 +30,7 @@ import { debugConsole } from '@/utils/debugging'
import { formatCurrency } from '@/shared/utils/currency'
import { ManagedInstitution } from '../../../../../types/subscription/dashboard/managed-institution'
import { Publisher } from '../../../../../types/subscription/dashboard/publisher'
+import { formatTime } from '@/features/utils/format-date'
type SubscriptionDashboardContextValue = {
groupPlanToChangeToCode: string
@@ -73,6 +74,7 @@ type SubscriptionDashboardContextValue = {
leavingGroupId?: string
setLeavingGroupId: React.Dispatch>
userCanExtendTrial: boolean
+ getFormattedRenewalDate: () => string
}
export const SubscriptionDashboardContext = createContext<
@@ -139,6 +141,20 @@ export function SubscriptionDashboardProvider({
memberGroupSubscriptions?.length > 0
)
+ const getFormattedRenewalDate = useCallback(() => {
+ if (
+ !personalSubscription.recurly.pausedAt ||
+ !personalSubscription.recurly.remainingPauseCycles
+ ) {
+ return personalSubscription.recurly.nextPaymentDueAt
+ }
+ const pausedDate = new Date(personalSubscription.recurly.pausedAt)
+ pausedDate.setMonth(
+ pausedDate.getMonth() + personalSubscription.recurly.remainingPauseCycles
+ )
+ return formatTime(pausedDate, 'MMMM Do, YYYY')
+ }, [personalSubscription])
+
useEffect(() => {
if (!isRecurlyLoaded()) {
setRecurlyLoadError(true)
@@ -283,6 +299,7 @@ export function SubscriptionDashboardProvider({
leavingGroupId,
setLeavingGroupId,
userCanExtendTrial,
+ getFormattedRenewalDate,
}),
[
groupPlanToChangeToCode,
@@ -319,6 +336,7 @@ export function SubscriptionDashboardProvider({
leavingGroupId,
setLeavingGroupId,
userCanExtendTrial,
+ getFormattedRenewalDate,
]
)
diff --git a/services/web/frontend/js/shared/components/location.js b/services/web/frontend/js/shared/components/location.js
index 67ba24662d..0c4d5d3436 100644
--- a/services/web/frontend/js/shared/components/location.js
+++ b/services/web/frontend/js/shared/components/location.js
@@ -13,4 +13,11 @@ export const location = {
// eslint-disable-next-line no-restricted-syntax
window.location.reload()
},
+ setHash(hash) {
+ window.location.hash = hash
+ },
+ toString() {
+ // eslint-disable-next-line no-restricted-syntax
+ return window.location.toString()
+ },
}
diff --git a/services/web/frontend/js/shared/hooks/use-location.ts b/services/web/frontend/js/shared/hooks/use-location.ts
index 3185fa8d06..9f4b3b470f 100644
--- a/services/web/frontend/js/shared/hooks/use-location.ts
+++ b/services/web/frontend/js/shared/hooks/use-location.ts
@@ -29,5 +29,24 @@ export const useLocation = () => {
}
}, [isMounted])
- return useMemo(() => ({ assign, replace, reload }), [assign, replace, reload])
+ const setHash = useCallback(
+ (hash: string) => {
+ if (isMounted.current) {
+ location.setHash(hash)
+ }
+ },
+ [isMounted]
+ )
+
+ const toString = useCallback(() => {
+ if (isMounted.current) {
+ return location.toString()
+ }
+ return ''
+ }, [isMounted])
+
+ return useMemo(
+ () => ({ assign, replace, reload, setHash, toString }),
+ [assign, replace, reload, setHash, toString]
+ )
}
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index 597424e8b1..67f96e3a51 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -46,6 +46,7 @@
"accepted_invite": "Accepted invite",
"accepting_invite_as": "You are accepting this invite as",
"access_denied": "Access Denied",
+ "access_edit_your_projects": "Access and edit your projects",
"access_levels_changed": "Access levels changed",
"account": "Account",
"account_has_been_link_to_institution_account": "Your __appName__ account on __email__ has been linked to your __institutionName__ institutional account.",
@@ -247,6 +248,7 @@
"cancel_my_account": "Cancel my subscription",
"cancel_my_subscription": "Cancel my subscription",
"cancel_personal_subscription_first": "You already have an individual subscription, would you like us to cancel this first before joining the group licence?",
+ "cancel_subscription": "Cancel subscription",
"cancel_your_subscription": "Cancel your subscription",
"cannot_invite_non_user": "Can’t send invite. Recipient must already have an __appName__ account",
"cannot_invite_self": "Can’t send invite to yourself",
@@ -306,6 +308,7 @@
"click_here_to_view_sl_in_lng": "Click here to use __appName__ in <0>__lngName__0>",
"click_link_to_proceed": "Click __clickText__ below to proceed.",
"click_to_give_feedback": "Click to give feedback.",
+ "click_to_unpause": "Click to unpause and reactivate your Overleaf premium features.",
"clicking_delete_will_remove_sso_config_and_clear_saml_data": "Clicking <0>Delete0> will remove your SSO configuration and unlink all users. You can only do this when SSO is disabled in your Group settings.",
"clone_with_git": "Clone with Git",
"close": "Close",
@@ -390,6 +393,7 @@
"continue": "Continue",
"continue_github_merge": "I have manually merged. Continue",
"continue_to": "Continue to __appName__",
+ "continue_using_free_features": "Continue using our free features",
"continue_with_free_plan": "Continue with free plan",
"continue_with_service": "Continue with __service__",
"copied": "Copied",
@@ -517,6 +521,7 @@
"doing_this_allow_log_in_through_institution_2": "Doing this will allow you to log in to <0>__appName__0> through your institution and will reconfirm your institutional email address.",
"doing_this_will_verify_affiliation_and_allow_log_in_2": "Doing this will verify your affiliation with <0>__institutionName__0> and will allow you to log in to <0>__appName__0> through your institution.",
"done": "Done",
+ "dont_forget_you_currently_have": "Don’t forget, you currently have:",
"dont_have_account": "Don’t have an account?",
"download": "Download",
"download_all": "Download all",
@@ -663,6 +668,7 @@
"featured_latex_templates": "Featured LaTeX Templates",
"features": "Features",
"features_and_benefits": "Features & Benefits",
+ "features_like_track_changes": "Features like real-time track changes",
"february": "February",
"file_action_created": "Created",
"file_action_deleted": "Deleted",
@@ -997,6 +1003,7 @@
"institutional_login_not_supported": "Your institution doesn’t support institutional login 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 list of institutions to find yours, or you can use one of the other options below.",
"integrations": "Integrations",
+ "integrations_like_github": "Integrations like GitHub Sync",
"interested_in_cheaper_personal_plan": "Would you be interested in the cheaper <0>__price__0> Personal plan?",
"interface": "Interface",
"interface_settings": "Interface settings",
@@ -1115,6 +1122,7 @@
"let_us_know": "Let us know",
"let_us_know_how_we_can_help": "Let us know how we can help",
"let_us_know_what_you_think": "Let us know what you think",
+ "lets_get_those_premium_features": "Let’s get those premium features up and running for you straightaway. You’ll be billed <0>__paymentAmount__0> using the payment details we have for you.",
"libraries": "Libraries",
"library": "Library",
"license": "License",
@@ -1260,10 +1268,13 @@
"missing_fields_for_entry": "Missing fields for",
"money_back_guarantee": "30-day money back guarantee, no questions asked",
"month": "month",
+ "month_plural": "months",
"monthly": "Monthly",
"more": "More",
"more_actions": "More actions",
+ "more_collabs_per_project": "More collaborators per project",
"more_comments": "More comments",
+ "more_compile_time": "More compile time",
"more_info": "More Info",
"more_options": "More options",
"more_options_for_border_settings_coming_soon": "More options for border settings coming soon.",
@@ -1470,6 +1481,8 @@
"paste_options": "Paste options",
"paste_with_formatting": "Paste with formatting",
"paste_without_formatting": "Paste without formatting",
+ "pause_subscription": "Pause subscription",
+ "pause_subscription_for": "Pause subscription for",
"pay_now": "Pay now",
"payment_method_accepted": "__paymentMethod__ accepted",
"payment_provider_unreachable_error": "Sorry, there was an error talking to our payment provider. Please try again in a few moments.\nIf you are using any ad or script blocking extensions in your browser, you may need to temporarily disable them.",
@@ -1500,6 +1513,7 @@
"personal": "Personal",
"personal_library": "Personal library",
"personalized_onboarding": "Personalized onboarding",
+ "pick_up_where_you_left_off": "Pick up where you left off",
"pl": "Polish",
"plan": "Plan",
"plan_tooltip": "You’re on the __plan__ plan. Click to find out how to make the most of your Overleaf premium features.",
@@ -2303,11 +2317,13 @@
"unlink_warning_reference": "Warning: When you unlink your account from this provider you will not be able to import references into your projects.",
"unlinking": "Unlinking",
"unmerge_cells": "Unmerge cells",
+ "unpause_subscription": "Unpause subscription",
"unpublish": "Unpublish",
"unpublishing": "Unpublishing",
"unsubscribe": "Unsubscribe",
"unsubscribed": "Unsubscribed",
"unsubscribing": "Unsubscribing",
+ "until_then_you_can_still": "Until then you can still:",
"untrash": "Restore",
"update": "Update",
"update_account_info": "Update Account Info",
@@ -2414,6 +2430,7 @@
"website_status": "Website status",
"wed_love_you_to_stay": "We’d love you to stay",
"welcome_to_sl": "Welcome to __appName__",
+ "well_be_here_when_youre_ready": "We’ll be here when you’re ready to dive back in! 🦆",
"were_making_some_changes_to_project_sharing_this_means_you_will_be_visible": "We’re making some <0>changes to project sharing0>. This means, as someone with edit access, your name and email address will be visible to the project owner and other editors.",
"were_performing_maintenance": "We’re performing maintenance on Overleaf and you need to wait a moment. Sorry for any inconvenience. The editor will refresh automatically in __seconds__ seconds.",
"weve_recently_reduced_the_compile_timeout_limit_which_may_have_affected_this_project": "We’ve recently <0>reduced the compile timeout limit0> on our free plan, which may have affected this project.",
@@ -2428,6 +2445,7 @@
"when_you_join_labs": "When you join Labs, you can choose which experiments you want to be part of. Once you’ve done that, you can use Overleaf as normal, but you’ll see any labs features marked with this badge:",
"when_you_tick_the_include_caption_box": "When you tick the box “Include caption” the image will be inserted into your document with a placeholder caption. To edit it, you simply select the placeholder text and type to replace it with your own.",
"why_latex": "Why LaTeX?",
+ "why_not_pause_instead": "Pause instead, to pick up where you left off",
"wide": "Wide",
"will_lose_edit_access_on_date": "Will lose edit access on __date__",
"will_need_to_log_out_from_and_in_with": "You will need to log out from your __email1__ account and then log in with __email2__.",
@@ -2480,6 +2498,7 @@
"you_can_request_a_maximum_of_limit_fixes_per_day": "You can request a maximum of __limit__ fixes per day. Please try again tomorrow.",
"you_can_select_or_invite": "You can select or invite __count__ editor on your current plan, or upgrade to get more.",
"you_can_select_or_invite_plural": "You can select or invite __count__ editors on your current plan, or upgrade to get more.",
+ "you_can_still_use_your_premium_features": "You can still use your premium features until the pause becomes active.",
"you_cant_add_or_change_password_due_to_sso": "You can’t add or change your password because your group or organization uses <0>single sign-on (SSO)0>.",
"you_cant_join_this_group_subscription": "You can’t join this group subscription",
"you_cant_reset_password_due_to_sso": "You can’t reset your password because your group or organization uses SSO. <0>Log in with SSO0>.",
@@ -2494,6 +2513,7 @@
"you_have_x_users_and_your_plan_supports_up_to_y": "You have __addedUsersSize__ users and your plan supports up to __groupSize__.",
"you_have_x_users_on_your_subscription": "You have __groupSize__ users on your subscription.",
"you_need_to_configure_your_sso_settings": "You need to configure and test your SSO settings before enabling SSO",
+ "you_unpaused_your_subscription": "You unpaused your subscription.",
"you_will_be_able_to_contact_us_any_time_to_share_your_feedback": "<0>You will be able to contact us0> any time to share your feedback",
"you_will_be_able_to_reassign_subscription": "You will be able to reassign their subscription membership to another person in your organization",
"youll_get_best_results_in_visual_but_can_be_used_in_source": "You’ll get the best results from using this tool in the <0>Visual Editor0>, although you can still use it to insert tables in the <1>Code Editor1>. Once you’ve selected the number of rows and columns you need, the table will appear in your document and you can double click in a cell to add contents to it.",
@@ -2505,6 +2525,7 @@
"your_affiliation_is_confirmed": "Your <0>__institutionName__0> affiliation is confirmed.",
"your_browser_does_not_support_this_feature": "Sorry, your browser doesn’t support this feature. Please update your browser to its latest version.",
"your_compile_timed_out": "Your compile timed out",
+ "your_current_plan_gives_you": "By pausing your subscription, you’ll be able to access your premium features faster when you need them again.",
"your_current_plan_supports_up_to_x_users": "Your current plan supports up to __users__ users.",
"your_current_project_will_revert_to_the_version_from_time": "Your current project will revert to the version from __timestamp__",
"your_git_access_info": "Your Git authentication tokens should be entered whenever you’re prompted for a password.",
@@ -2524,6 +2545,7 @@
"your_plan_is_changing_at_term_end": "Your plan is changing to <0>__pendingPlanName__0> at the end of the current billing period.",
"your_plan_is_limited_to_n_editors": "Your plan allows __count__ collaborator with edit access and unlimited viewers.",
"your_plan_is_limited_to_n_editors_plural": "Your plan allows __count__ collaborators with edit access and unlimited viewers.",
+ "your_premium_plan_is_paused": "Your Premium plan is <0>paused0>.",
"your_project_exceeded_compile_timeout_limit_on_free_plan": "Your project exceeded the compile timeout limit on our free plan.",
"your_project_exceeded_editor_limit": "Your project exceeded the editor limit and access levels were changed. Select a new access level for your collaborators, or upgrade to add more editors.",
"your_project_near_compile_timeout_limit": "Your project is near the compile timeout limit for our free plan.",
@@ -2533,6 +2555,8 @@
"your_sessions": "Your Sessions",
"your_subscription": "Your subscription",
"your_subscription_has_expired": "Your subscription has expired.",
+ "your_subscription_will_pause_on": "Your <0>__planName__0> subscription will pause on <0>__pauseDate__0>. It’ll automatically unpause on <0>__reactivationDate__0>. Or you can unpause it yourself at any time.",
+ "your_subscription_will_pause_on_short": "Your subscription will pause on <0>__pauseDate__0>.",
"youre_a_member_of_overleaf_labs": "You’re a member of Overleaf Labs. Don’t forget to check in regularly to see what experiments you can sign up to.",
"youre_about_to_disable_single_sign_on": "You’re about to disable single sign-on for all group members.",
"youre_about_to_enable_single_sign_on": "You’re about to enable single sign-on (SSO). Before you do this, you should ensure you’re confident the SSO configuration is correct and all your group members have managed user accounts.",
@@ -2546,6 +2570,7 @@
"youve_added_more_users": "You’ve added more users!",
"youve_added_x_more_users_to_your_subscription_invite_people": "You’ve added __users__ more users to your subscription. <0>Invite people0>.",
"youve_lost_edit_access": "You’ve lost edit access",
+ "youve_paused_your_subscription": "Your <0>__planName__0> subscription is paused until <0>__reactivationDate__0>, then it’ll automatically unpause. You can unpause early at any time.",
"youve_unlinked_all_users": "You’ve unlinked all users",
"youve_upgraded_your_plan": "You’ve upgraded your plan!",
"zh-CN": "Chinese",
diff --git a/services/web/test/frontend/features/project-list/components/current-plan-widget.test.tsx b/services/web/test/frontend/features/project-list/components/current-plan-widget.test.tsx
index faca302f05..c4f67ce472 100644
--- a/services/web/test/frontend/features/project-list/components/current-plan-widget.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/current-plan-widget.test.tsx
@@ -15,6 +15,8 @@ describe('', function () {
/click to find out how you could benefit from overleaf premium features/i
const paidPlanTooltipMessage =
/click to find out how to make the most of your overleaf premium features/i
+ const pausedTooltipMessage =
+ /click to unpause and reactivate your overleaf premium features/i
let sendMBSpy: sinon.SinonSpy
@@ -25,6 +27,30 @@ describe('', function () {
sendMBSpy.restore()
})
+ describe('paused', function () {
+ beforeEach(function () {
+ window.metaAttributesCache.set('ol-usersBestSubscription', {
+ type: 'individual',
+ subscription: {
+ recurlyStatus: {
+ state: 'paused',
+ },
+ },
+ })
+
+ render()
+ })
+
+ it('shows text and tooltip on mouseover', function () {
+ const link = screen.getByRole('link', {
+ name: /plan is paused/i,
+ })
+ fireEvent.mouseOver(link)
+
+ screen.getByRole('tooltip', { name: pausedTooltipMessage })
+ })
+ })
+
describe('free plan', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-usersBestSubscription', {
diff --git a/services/web/test/frontend/features/project-list/components/new-project-button/modal-content-new-project-form.test.tsx b/services/web/test/frontend/features/project-list/components/new-project-button/modal-content-new-project-form.test.tsx
index 61504a597a..f33ad2f345 100644
--- a/services/web/test/frontend/features/project-list/components/new-project-button/modal-content-new-project-form.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/new-project-button/modal-content-new-project-form.test.tsx
@@ -14,6 +14,7 @@ describe('', function () {
assign: assignStub,
replace: sinon.stub(),
reload: sinon.stub(),
+ setHash: sinon.stub(),
})
})
diff --git a/services/web/test/frontend/features/project-list/components/notifications.test.tsx b/services/web/test/frontend/features/project-list/components/notifications.test.tsx
index 679e00815e..07e605ab59 100644
--- a/services/web/test/frontend/features/project-list/components/notifications.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/notifications.test.tsx
@@ -708,6 +708,7 @@ describe('', function () {
assign: assignStub,
replace: sinon.stub(),
reload: sinon.stub(),
+ setHash: sinon.stub(),
})
fetchMock.reset()
})
diff --git a/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx b/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx
index a2567319bd..03b20d25ee 100644
--- a/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx
@@ -67,6 +67,7 @@ describe('', function () {
assign: assignStub,
replace: sinon.stub(),
reload: sinon.stub(),
+ setHash: sinon.stub(),
})
})
diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.test.tsx
index 2479d9d3ab..0f1ac903ed 100644
--- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.test.tsx
+++ b/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('', function () {
assign: assignStub,
replace: sinon.stub(),
reload: sinon.stub(),
+ setHash: sinon.stub(),
})
render(
diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/download-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/download-project-button.test.tsx
index 3991560897..28b3e8c4a2 100644
--- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/download-project-button.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/download-project-button.test.tsx
@@ -14,6 +14,7 @@ describe('', function () {
assign: assignStub,
replace: sinon.stub(),
reload: sinon.stub(),
+ setHash: sinon.stub(),
})
render()
})
diff --git a/services/web/test/frontend/features/settings/components/emails/reconfirmation-info.test.tsx b/services/web/test/frontend/features/settings/components/emails/reconfirmation-info.test.tsx
index 92fede4f53..decfbd023f 100644
--- a/services/web/test/frontend/features/settings/components/emails/reconfirmation-info.test.tsx
+++ b/services/web/test/frontend/features/settings/components/emails/reconfirmation-info.test.tsx
@@ -37,6 +37,7 @@ describe('', function () {
assign: assignStub,
replace: sinon.stub(),
reload: sinon.stub(),
+ setHash: sinon.stub(),
})
})
diff --git a/services/web/test/frontend/features/settings/components/leave/modal-form.test.tsx b/services/web/test/frontend/features/settings/components/leave/modal-form.test.tsx
index 542aa1c5ea..52c07de20f 100644
--- a/services/web/test/frontend/features/settings/components/leave/modal-form.test.tsx
+++ b/services/web/test/frontend/features/settings/components/leave/modal-form.test.tsx
@@ -62,6 +62,7 @@ describe('', function () {
assign: assignStub,
replace: sinon.stub(),
reload: sinon.stub(),
+ setHash: sinon.stub(),
})
Object.assign(getMeta('ol-ExposedSettings'), { isOverleaf: true })
})
diff --git a/services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx
index ce7917bacc..45a83b36c6 100644
--- a/services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx
+++ b/services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx
@@ -81,6 +81,7 @@ describe('', function () {
assign: sinon.stub(),
replace: sinon.stub(),
reload: reloadStub,
+ setHash: sinon.stub(),
})
render(
diff --git a/services/web/test/frontend/features/subscription/components/dashboard/pause-modal.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/pause-modal.test.tsx
new file mode 100644
index 0000000000..462db9f81b
--- /dev/null
+++ b/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('', 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
+ })
+ })
+})
diff --git a/services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx
index 0cbf334f5a..a977f678fc 100644
--- a/services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx
+++ b/services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx
@@ -56,6 +56,8 @@ describe('', function () {
assign: sinon.stub(),
replace: sinon.stub(),
reload: reloadStub,
+ setHash: sinon.stub(),
+ toString: sinon.stub(),
})
})
diff --git a/services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx
index be8f3ba87c..c132911088 100644
--- a/services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx
+++ b/services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx
@@ -199,6 +199,8 @@ describe('', function () {
assign: assignStub,
replace: sinon.stub(),
reload: reloadStub,
+ setHash: sinon.stub(),
+ toString: sinon.stub(),
})
})
diff --git a/services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx
index 979a87743b..5e30d1793f 100644
--- a/services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx
+++ b/services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx
@@ -31,6 +31,8 @@ describe('', function () {
assign: sinon.stub(),
replace: sinon.stub(),
reload: reloadStub,
+ setHash: sinon.stub(),
+ toString: sinon.stub(),
})
})
diff --git a/services/web/test/unit/src/Subscription/RecurlyClientTests.js b/services/web/test/unit/src/Subscription/RecurlyClientTests.js
index ae80ee1828..35fd515d8c 100644
--- a/services/web/test/unit/src/Subscription/RecurlyClientTests.js
+++ b/services/web/test/unit/src/Subscription/RecurlyClientTests.js
@@ -383,6 +383,24 @@ describe('RecurlyClient', function () {
})
})
+ describe('pauseSubscriptionByUuid', function () {
+ it('should attempt to pause the subscription', async function () {
+ this.client.pauseSubscription = sinon
+ .stub()
+ .resolves(this.recurlySubscription)
+ const subscription =
+ await this.RecurlyClient.promises.pauseSubscriptionByUuid(
+ this.subscription.uuid,
+ 3
+ )
+ expect(subscription).to.deep.equal(this.recurlySubscription)
+ expect(this.client.pauseSubscription).to.be.calledWith(
+ 'uuid-' + this.subscription.uuid,
+ { remainingPauseCycles: 3 }
+ )
+ })
+ })
+
describe('previewSubscriptionChange', function () {
describe('compute immediate charge', function () {
it('only has charge invoice', async function () {
diff --git a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js
index 17e6c18a19..1000e4b48f 100644
--- a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js
+++ b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js
@@ -53,6 +53,8 @@ describe('SubscriptionController', function () {
updateSubscription: sinon.stub().resolves(),
reactivateSubscription: sinon.stub().resolves(),
cancelSubscription: sinon.stub().resolves(),
+ pauseSubscription: sinon.stub().resolves(),
+ resumeSubscription: sinon.stub().resolves(),
syncSubscription: sinon.stub().resolves(),
attemptPaypalInvoiceCollection: sinon.stub().resolves(),
startFreeTrial: sinon.stub().resolves(),
@@ -162,6 +164,10 @@ describe('SubscriptionController', function () {
res.status(422)
res.json({ message })
}),
+ badRequest: sinon.stub().callsFake((req, res, message) => {
+ res.status(400)
+ res.json({ message })
+ }),
}),
'./Errors': SubscriptionErrors,
'../Analytics/AnalyticsManager': (this.AnalyticsManager = {
@@ -416,6 +422,60 @@ describe('SubscriptionController', function () {
})
})
+ describe('pauseSubscription', function () {
+ it('should throw an error if no pause length is provided', async function () {
+ this.res = new MockResponse()
+ this.req = new MockRequest()
+ this.next = sinon.stub()
+ await this.SubscriptionController.pauseSubscription(
+ this.req,
+ this.res,
+ this.next
+ )
+ expect(this.res.statusCode).to.equal(400)
+ })
+
+ it('should throw an error if an invalid pause length is provided', async function () {
+ this.res = new MockResponse()
+ this.req = new MockRequest()
+ this.req.params = { pauseCycles: -10 }
+ this.next = sinon.stub()
+ await this.SubscriptionController.pauseSubscription(
+ this.req,
+ this.res,
+ this.next
+ )
+ expect(this.res.statusCode).to.equal(400)
+ })
+
+ it('should return a 200 when requesting a pause', async function () {
+ this.res = new MockResponse()
+ this.req = new MockRequest()
+ this.req.params = { pauseCycles: 3 }
+ this.next = sinon.stub()
+ await this.SubscriptionController.pauseSubscription(
+ this.req,
+ this.res,
+ this.next
+ )
+ expect(this.res.statusCode).to.equal(200)
+ })
+ })
+
+ describe('resumeSubscription', function () {
+ it('should return a 200 when resuming a subscription', async function () {
+ this.res = new MockResponse()
+ this.req = new MockRequest()
+ this.next = sinon.stub()
+ await this.SubscriptionController.resumeSubscription(
+ this.req,
+ this.res,
+ this.next
+ )
+ expect(this.res.statusCode).to.equal(200)
+ })
+ })
+
describe('cancelSubscription', function () {
beforeEach(function (done) {
this.res = {
diff --git a/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js b/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js
index 1a86b0215f..64e9381b49 100644
--- a/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js
+++ b/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js
@@ -105,6 +105,8 @@ describe('SubscriptionHandler', function () {
getSubscription: sinon
.stub()
.resolves(this.activeRecurlyClientSubscription),
+ pauseSubscriptionByUuid: sinon.stub().resolves(),
+ resumeSubscriptionByUuid: sinon.stub().resolves(),
},
}
@@ -442,6 +444,152 @@ describe('SubscriptionHandler', function () {
})
})
+ describe('resumeSubscription', function () {
+ describe('for a user without a subscription', function () {
+ beforeEach(async function () {
+ this.LimitationsManager.promises.userHasSubscription.resolves({
+ hasSubscription: false,
+ subscription: this.subscription,
+ })
+ })
+ it('should not make a resume call to recurly', async function () {
+ expect(
+ this.SubscriptionHandler.promises.resumeSubscription(this.user)
+ ).to.be.rejectedWith('No active subscription to resume')
+ this.RecurlyClient.promises.resumeSubscriptionByUuid.called.should.equal(
+ false
+ )
+ })
+ })
+
+ describe('for a user with a subscription', function () {
+ beforeEach(async function () {
+ this.LimitationsManager.promises.userHasSubscription.resolves({
+ hasSubscription: true,
+ subscription: {
+ recurlySubscription_id: this.activeRecurlySubscription.uuid,
+ recurlyStatus: { state: 'non-trial' },
+ planCode: 'collaborator',
+ },
+ })
+ })
+ it('should make a resume call to recurly', async function () {
+ await this.SubscriptionHandler.promises.resumeSubscription(this.user)
+
+ this.RecurlyClient.promises.resumeSubscriptionByUuid.called.should.equal(
+ true
+ )
+ })
+ })
+ })
+
+ describe('pauseSubscription', function () {
+ describe('for a user without a subscription', function () {
+ beforeEach(async function () {
+ this.LimitationsManager.promises.userHasSubscription.resolves({
+ hasSubscription: false,
+ subscription: this.subscription,
+ })
+ })
+ it('should not make a pause call to recurly', async function () {
+ expect(
+ this.SubscriptionHandler.promises.pauseSubscription(this.user, 3)
+ ).to.be.rejectedWith('No active subscription to pause')
+ this.RecurlyClient.promises.pauseSubscriptionByUuid.called.should.equal(
+ false
+ )
+ })
+ })
+
+ describe('for a user with an annual subscription', function () {
+ beforeEach(async function () {
+ this.LimitationsManager.promises.userHasSubscription.resolves({
+ hasSubscription: false,
+ subscription: {
+ recurlySubscription_id: this.activeRecurlySubscription.uuid,
+ recurlyStatus: { state: 'non-trial' },
+ planCode: 'collaborator-annual',
+ },
+ })
+ })
+ it('should not make a pause call to recurly', async function () {
+ expect(
+ this.SubscriptionHandler.promises.pauseSubscription(this.user, 3)
+ ).to.be.rejectedWith('Can only pause monthly individual plans')
+ this.RecurlyClient.promises.pauseSubscriptionByUuid.called.should.equal(
+ false
+ )
+ })
+ })
+
+ describe('for a user with a subscription', function () {
+ beforeEach(async function () {
+ this.LimitationsManager.promises.userHasSubscription.resolves({
+ hasSubscription: true,
+ subscription: {
+ recurlySubscription_id: this.activeRecurlySubscription.uuid,
+ recurlyStatus: { state: 'non-trial' },
+ planCode: 'collaborator',
+ addOns: [],
+ },
+ })
+ })
+ it('should make a pause call to recurly', async function () {
+ await this.SubscriptionHandler.promises.pauseSubscription(this.user, 3)
+
+ this.RecurlyClient.promises.pauseSubscriptionByUuid.called.should.equal(
+ true
+ )
+ })
+ })
+
+ describe('for a user in a trial', function () {
+ beforeEach(async function () {
+ this.LimitationsManager.promises.userHasSubscription.resolves({
+ hasSubscription: true,
+ subscription: {
+ recurlySubscription_id: this.activeRecurlySubscription.uuid,
+ recurlyStatus: {
+ state: 'trial',
+ trialEndsAt: Date.now() + 1000000,
+ },
+ planCode: 'collaborator',
+ },
+ })
+ })
+ it('should not make a pause call to recurly', async function () {
+ expect(
+ this.SubscriptionHandler.promises.pauseSubscription(this.user, 3)
+ ).to.be.rejectedWith('Cannot pause a subscription in a trial')
+ this.RecurlyClient.promises.pauseSubscriptionByUuid.called.should.equal(
+ false
+ )
+ })
+ })
+
+ describe('for a user with addons', function () {
+ beforeEach(async function () {
+ this.LimitationsManager.promises.userHasSubscription.resolves({
+ hasSubscription: true,
+ subscription: {
+ recurlySubscription_id: this.activeRecurlySubscription.uuid,
+ recurlyStatus: { state: 'non-trial' },
+ planCode: 'collaborator',
+ addOns: ['mock-addon'],
+ },
+ })
+ })
+ it('should not make a pause call to recurly', async function () {
+ expect(
+ this.SubscriptionHandler.promises.pauseSubscription(this.user, 3)
+ ).to.be.rejectedWith('Cannot pause a subscription with addons')
+ this.RecurlyClient.promises.pauseSubscriptionByUuid.called.should.equal(
+ false
+ )
+ })
+ })
+ })
+
describe('reactivateSubscription', function () {
describe('with a user without a subscription', function () {
beforeEach(async function () {
diff --git a/services/web/types/project/dashboard/subscription.ts b/services/web/types/project/dashboard/subscription.ts
index 4f430cbfc2..3501ad8c38 100644
--- a/services/web/types/project/dashboard/subscription.ts
+++ b/services/web/types/project/dashboard/subscription.ts
@@ -8,6 +8,10 @@ export type FreePlanSubscription = {
type FreeSubscription = FreePlanSubscription
+type RecurlyStatus = {
+ state: 'active' | 'canceled' | 'expired' | 'paused'
+}
+
type PaidSubscriptionBase = {
plan: {
name: string
@@ -15,6 +19,7 @@ type PaidSubscriptionBase = {
subscription: {
teamName?: string
name: string
+ recurlyStatus?: RecurlyStatus
}
} & SubscriptionBase
diff --git a/services/web/types/subscription/dashboard/modal-ids.ts b/services/web/types/subscription/dashboard/modal-ids.ts
index 680dfc1135..bbc13614c2 100644
--- a/services/web/types/subscription/dashboard/modal-ids.ts
+++ b/services/web/types/subscription/dashboard/modal-ids.ts
@@ -5,3 +5,5 @@ export type SubscriptionDashModalIds =
| 'leave-group'
| 'change-plan'
| 'cancel-ai-add-on'
+ | 'pause-subscription'
+ | 'unpause-subscription'
diff --git a/services/web/types/subscription/dashboard/subscription.ts b/services/web/types/subscription/dashboard/subscription.ts
index 5474ea1edd..c4ba1991de 100644
--- a/services/web/types/subscription/dashboard/subscription.ts
+++ b/services/web/types/subscription/dashboard/subscription.ts
@@ -3,7 +3,7 @@ import { Nullable } from '../../utils'
import { Plan, AddOn } from '../plan'
import { User } from '../../user'
-type SubscriptionState = 'active' | 'canceled' | 'expired'
+type SubscriptionState = 'active' | 'canceled' | 'expired' | 'paused'
// when puchasing a new add-on in recurly, we only need to provide the code
export type PurchasingAddOnCode = {
@@ -45,6 +45,8 @@ type Recurly = {
currentPlanDisplayPrice?: string
pendingAdditionalLicenses?: number
pendingTotalLicenses?: number
+ pausedAt?: Nullable
+ remainingPauseCycles?: Nullable
}
export type GroupPolicy = {