diff --git a/services/web/app/src/Features/Subscription/RecurlyEntities.js b/services/web/app/src/Features/Subscription/RecurlyEntities.js
index dbbcdc3356..c21eb62634 100644
--- a/services/web/app/src/Features/Subscription/RecurlyEntities.js
+++ b/services/web/app/src/Features/Subscription/RecurlyEntities.js
@@ -216,6 +216,30 @@ class RecurlySubscription {
addOnUpdates,
})
}
+
+ /**
+ * Upgrade group plan with the plan code provided
+ *
+ * @param {string} newPlanCode
+ * @return {RecurlySubscriptionChangeRequest}
+ */
+ getRequestForFlexibleLicensingGroupPlanUpgrade(newPlanCode) {
+ // Ensure all the existing add-ons are added to the new plan
+ const existingAddOns = this.addOns.map(
+ addOn =>
+ new RecurlySubscriptionAddOnUpdate({
+ code: addOn.code,
+ quantity: addOn.quantity,
+ })
+ )
+
+ return new RecurlySubscriptionChangeRequest({
+ subscription: this,
+ timeframe: 'now',
+ addOnUpdates: existingAddOns,
+ planCode: newPlanCode,
+ })
+ }
}
/**
diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs
index 33792bb184..ff7fad8d3e 100644
--- a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs
+++ b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs
@@ -12,9 +12,10 @@ import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
import ErrorController from '../Errors/ErrorController.js'
import SalesContactFormController from '../../../../modules/cms/app/src/controllers/SalesContactFormController.mjs'
import UserGetter from '../User/UserGetter.js'
+import { Subscription } from '../../models/Subscription.js'
/**
- * @import { Subscription } from "../../../../types/subscription/dashboard/subscription"
+ * @import { Subscription } from "../../../../types/subscription/dashboard/subscription.js"
*/
/**
@@ -220,6 +221,36 @@ async function flexibleLicensingSplitTest(req, res, next) {
next()
}
+async function subscriptionUpgradePage(req, res) {
+ try {
+ const userId = SessionManager.getLoggedInUserId(req.session)
+ const changePreview =
+ await SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview(userId)
+ const olSubscription = await Subscription.findOne({
+ admin_id: userId,
+ }).exec()
+ res.render('subscriptions/upgrade-group-subscription-react', {
+ changePreview,
+ totalLicenses: olSubscription.membersLimit,
+ groupName: olSubscription.teamName,
+ })
+ } catch (error) {
+ logger.err({ error }, 'error loading upgrade subscription page')
+ return res.render('/user/subscription')
+ }
+}
+
+async function upgradeSubscription(req, res) {
+ try {
+ const userId = SessionManager.getLoggedInUserId(req.session)
+ await SubscriptionGroupHandler.promises.upgradeGroupPlan(userId)
+ return res.sendStatus(200)
+ } catch (error) {
+ logger.err({ error }, 'error trying to upgrade subscription')
+ return res.sendStatus(500)
+ }
+}
+
export default {
removeUserFromGroup: expressify(removeUserFromGroup),
removeSelfFromGroup: expressify(removeSelfFromGroup),
@@ -232,4 +263,6 @@ export default {
createAddSeatsSubscriptionChange: expressify(
createAddSeatsSubscriptionChange
),
+ subscriptionUpgradePage: expressify(subscriptionUpgradePage),
+ upgradeSubscription: expressify(upgradeSubscription),
}
diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js
index 42c79279ca..087e581c33 100644
--- a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js
+++ b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js
@@ -7,6 +7,11 @@ const SessionManager = require('../Authentication/SessionManager')
const RecurlyClient = require('./RecurlyClient')
const PlansLocator = require('./PlansLocator')
+const PLAN_UPGRADE_MAP = {
+ group_collaborator: 'group_professional',
+ group_collaborator_educational: 'group_professional_educational',
+}
+
async function removeUserFromGroup(subscriptionId, userIdToRemove) {
await SubscriptionUpdater.promises.removeUserFromGroup(
subscriptionId,
@@ -140,12 +145,58 @@ async function createAddSeatsSubscriptionChange(req) {
return { adding: req.body.adding }
}
+async function _getUpgradeTargetPlanCodeMaybeThrow(subscription) {
+ if (!Object.keys(PLAN_UPGRADE_MAP).includes(subscription.planCode)) {
+ throw new Error('Not eligible for group plan upgrade')
+ }
+
+ return PLAN_UPGRADE_MAP[subscription.planCode]
+}
+
+async function _getGroupPlanUpgradeChangeRequest(ownerId) {
+ const olSubscription =
+ await SubscriptionLocator.promises.getUsersSubscription(ownerId)
+
+ const newPlanCode = await _getUpgradeTargetPlanCodeMaybeThrow(olSubscription)
+
+ const recurlySubscription = await RecurlyClient.promises.getSubscription(
+ olSubscription.recurlySubscription_id
+ )
+ return recurlySubscription.getRequestForFlexibleLicensingGroupPlanUpgrade(
+ newPlanCode
+ )
+}
+
+async function getGroupPlanUpgradePreview(ownerId) {
+ const changeRequest = await _getGroupPlanUpgradeChangeRequest(ownerId)
+ const subscriptionChange =
+ await RecurlyClient.promises.previewSubscriptionChange(changeRequest)
+ const paymentMethod = await RecurlyClient.promises.getPaymentMethod(ownerId)
+ return SubscriptionController.makeChangePreview(
+ {
+ type: 'group-plan-upgrade',
+ prevPlan: {
+ name: subscriptionChange.subscription.planName,
+ },
+ },
+ subscriptionChange,
+ paymentMethod
+ )
+}
+
+async function upgradeGroupPlan(ownerId) {
+ const changeRequest = await _getGroupPlanUpgradeChangeRequest(ownerId)
+ await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest)
+}
+
module.exports = {
removeUserFromGroup: callbackify(removeUserFromGroup),
replaceUserReferencesInGroups: callbackify(replaceUserReferencesInGroups),
ensureFlexibleLicensingEnabled: callbackify(ensureFlexibleLicensingEnabled),
getTotalConfirmedUsersInGroup: callbackify(getTotalConfirmedUsersInGroup),
isUserPartOfGroup: callbackify(isUserPartOfGroup),
+ getGroupPlanUpgradePreview: callbackify(getGroupPlanUpgradePreview),
+ upgradeGroupPlan: callbackify(upgradeGroupPlan),
promises: {
removeUserFromGroup,
replaceUserReferencesInGroups,
@@ -155,5 +206,7 @@ module.exports = {
getUsersGroupSubscriptionDetails,
previewAddSeatsSubscriptionChange,
createAddSeatsSubscriptionChange,
+ getGroupPlanUpgradePreview,
+ upgradeGroupPlan,
},
}
diff --git a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs
index e186aa710f..f5ebb9de92 100644
--- a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs
+++ b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs
@@ -108,6 +108,21 @@ export default {
SubscriptionGroupController.submitForm
)
+ webRouter.get(
+ '/user/subscription/group/upgrade-subscription',
+ AuthenticationController.requireLogin(),
+ RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
+ SubscriptionGroupController.flexibleLicensingSplitTest,
+ SubscriptionGroupController.subscriptionUpgradePage
+ )
+
+ webRouter.post(
+ '/user/subscription/group/upgrade-subscription',
+ AuthenticationController.requireLogin(),
+ RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
+ SubscriptionGroupController.upgradeSubscription
+ )
+
// Team invites
webRouter.get(
'/subscription/invites/:token/',
diff --git a/services/web/app/views/subscriptions/upgrade-group-subscription-react.pug b/services/web/app/views/subscriptions/upgrade-group-subscription-react.pug
new file mode 100644
index 0000000000..4ac2999ee5
--- /dev/null
+++ b/services/web/app/views/subscriptions/upgrade-group-subscription-react.pug
@@ -0,0 +1,15 @@
+extends ../layout-marketing
+
+block vars
+ - bootstrap5PageStatus = 'enabled' // Enforce BS5 version
+
+block entrypointVar
+ - entrypoint = 'pages/user/subscription/group-management/upgrade-group-subscription'
+
+block append meta
+ meta(name="ol-subscriptionChangePreview" data-type="json" content=changePreview)
+ meta(name="ol-totalLicenses", data-type="number", content=totalLicenses)
+ meta(name="ol-groupName", data-type="string", content=groupName)
+
+block content
+ main.content.content-alt#upgrade-group-subscription-root
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index 6d8da57d3b..9c48a99cdc 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -75,6 +75,7 @@
"add_more_managers": "",
"add_more_members": "",
"add_more_users": "",
+ "add_more_users_to_my_plan": "",
"add_new_email": "",
"add_ons_are": "",
"add_or_remove_project_from_tag": "",
@@ -108,6 +109,7 @@
"ai_feedback_there_was_no_code_fix_suggested": "",
"alignment": "",
"all_borders": "",
+ "all_features_in_group_standard_plus": "",
"all_premium_features": "",
"all_premium_features_including": "",
"all_projects": "",
@@ -159,6 +161,7 @@
"beta_program_benefits": "",
"beta_program_not_participating": "",
"better_bibliographies": "",
+ "billed_yearly": "",
"binary_history_error": "",
"blank_project": "",
"blocked_filename": "",
@@ -619,6 +622,7 @@
"group_libraries": "",
"group_managed_by_group_administrator": "",
"group_plan_tooltip": "",
+ "group_plan_upgrade_description": "",
"group_plan_with_name_tooltip": "",
"group_sso_configuration_idp_metadata": "",
"group_sso_configure_service_provider_in_idp": "",
@@ -896,6 +900,7 @@
"manage_sessions": "",
"manage_subscription": "",
"managed": "",
+ "managed_user_accounts": "",
"managed_user_invite_has_been_sent_to_email": "",
"managed_users": "",
"managed_users_explanation": "",
@@ -1076,6 +1081,7 @@
"pending_additional_licenses": "",
"pending_addon_cancellation": "",
"pending_invite": "",
+ "per_user": "",
"percent_discount_for_groups": "",
"percent_is_the_percentage_of_the_line_width": "",
"permanently_disables_the_preview": "",
@@ -1723,6 +1729,7 @@
"university": "",
"university_school": "",
"unknown": "",
+ "unlimited_collaborators_per_project": "",
"unlimited_collabs": "",
"unlimited_projects": "",
"unlink": "",
@@ -1760,10 +1767,12 @@
"upgrade_for_12x_more_compile_time": "",
"upgrade_my_plan": "",
"upgrade_now": "",
+ "upgrade_summary": "",
"upgrade_to_add_more_editors": "",
"upgrade_to_add_more_editors_and_access_collaboration_features": "",
"upgrade_to_get_feature": "",
"upgrade_to_track_changes": "",
+ "upgrade_your_subscription": "",
"upload": "",
"upload_from_computer": "",
"upload_project": "",
@@ -1784,6 +1793,7 @@
"user_first_name_attribute": "",
"user_last_name_attribute": "",
"user_sessions": "",
+ "users": "",
"using_latex": "",
"using_premium_features": "",
"using_the_overleaf_editor": "",
@@ -1830,6 +1840,7 @@
"we_logged_you_in": "",
"we_sent_new_code": "",
"we_will_charge_you_now_for_the_cost_of_your_additional_users_based_on_remaining_months": "",
+ "we_will_charge_you_now_for_your_new_plan_based_on_the_remaining_months_of_your_current_subscription": "",
"webinars": "",
"website_status": "",
"wed_love_you_to_stay": "",
@@ -1901,6 +1912,7 @@
"you_have_been_invited_to_transfer_management_of_your_account_to": "",
"you_have_been_removed_from_this_project_and_will_be_redirected_to_project_dashboard": "",
"you_have_x_users_and_your_plan_supports_up_to_y": "",
+ "you_have_x_users_on_your_subscription": "",
"you_need_to_configure_your_sso_settings": "",
"you_will_be_able_to_reassign_subscription": "",
"youll_get_best_results_in_visual_but_can_be_used_in_source": "",
@@ -1949,6 +1961,7 @@
"youve_added_x_more_users_to_your_subscription_invite_people": "",
"youve_lost_edit_access": "",
"youve_unlinked_all_users": "",
+ "youve_upgraded_your_plan": "",
"zoom_in": "",
"zoom_out": "",
"zoom_to": "",
diff --git a/services/web/frontend/js/features/group-management/components/request-status.tsx b/services/web/frontend/js/features/group-management/components/request-status.tsx
index ca581949eb..e7bf3d9cff 100644
--- a/services/web/frontend/js/features/group-management/components/request-status.tsx
+++ b/services/web/frontend/js/features/group-management/components/request-status.tsx
@@ -9,7 +9,7 @@ import classnames from 'classnames'
type RequestStatusProps = {
icon: string
title: string
- content: React.ReactNode
+ content?: React.ReactNode
variant?: 'primary' | 'danger'
}
@@ -44,7 +44,9 @@ function RequestStatus({ icon, title, content, variant }: RequestStatusProps) {
{title}
- {content}
+ {content && (
+ {content}
+ )}